opencode-global-habit-profile 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/App.vue CHANGED
@@ -1,6 +1,6 @@
1
1
  <script>
2
2
  import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
- import { checkpointTitle, dailyTrend, groupCheckpoints, groupTodos, projectDirectoryLabel, reportGroups, trendBars } from './ui-model.js'
3
+ import { checkpointTitle, dailyTrend, groupCheckpoints, groupTodos, metricsRange, projectDirectoryLabel, reportGroups, sortMetricRows, tokenBars, trendBars } from './ui-model.js'
4
4
  import { viewFromHash, watchHashView } from './ui-navigation.js'
5
5
 
6
6
  export default {
@@ -12,8 +12,130 @@ export default {
12
12
  const activeView = ref(viewFromHash(window.location.hash))
13
13
  const busy = ref(false)
14
14
  const includeArchived = ref(false)
15
+ const metrics = ref(null)
16
+ const metricsError = ref('')
17
+ const metricsLoading = ref(false)
18
+ const rangeDays = ref(30)
19
+ const metricProject = ref('')
20
+ const metricAgent = ref('')
21
+ const metricProvider = ref('')
22
+ const metricModel = ref('')
23
+ const metricOptions = ref({ projects: [], agents: [], providers: [], models: [] })
15
24
  const token = document.querySelector('meta[name="habit-token"]')?.content
16
25
  const t = (es, en) => language.value === 'es' ? es : en
26
+ const numberFormat = computed(() => new Intl.NumberFormat(language.value, { maximumFractionDigits: 0 }))
27
+ const decimalFormat = computed(() => new Intl.NumberFormat(language.value, { maximumFractionDigits: 1 }))
28
+ const compactFormat = computed(() => new Intl.NumberFormat(language.value, { notation: 'compact', maximumFractionDigits: 1 }))
29
+ const percentFormat = computed(() => new Intl.NumberFormat(language.value, { style: 'percent', maximumFractionDigits: 1 }))
30
+ const formatNumber = (value) => numberFormat.value.format(Number(value) || 0)
31
+ const formatCompact = (value) => compactFormat.value.format(Number(value) || 0)
32
+ const formatPercent = (value) => percentFormat.value.format(Number(value) || 0)
33
+ const tokenBreakdown = (row) => {
34
+ const tokens = row?.tokens ?? {}
35
+ return [
36
+ `${t('entrada', 'input')} ${formatCompact(tokens.input)}`,
37
+ `${t('caché', 'cache')} ${formatCompact(tokens.cacheRead)}`,
38
+ `${t('salida', 'output')} ${formatCompact(tokens.output)}`,
39
+ `${t('raz.', 'reason.')} ${formatCompact(tokens.reasoning)}`,
40
+ ].join(' · ')
41
+ }
42
+ const seriesLabel = (key) => ({
43
+ input: t('Entrada sin caché', 'Uncached input'),
44
+ cache: t('Caché leída', 'Cache read'),
45
+ output: t('Salida', 'Output'),
46
+ }[key] ?? key)
47
+ const metricKindLabel = (kind) => ({
48
+ primary: t('Principal', 'Primary'),
49
+ subagent: t('Subagente', 'Subagent'),
50
+ title: t('Título', 'Title'),
51
+ compaction: t('Compactación', 'Compaction'),
52
+ generate: t('Generación', 'Generate'),
53
+ other: t('Otro', 'Other'),
54
+ }[kind] ?? kind)
55
+ const projectOptionLabel = (project) => project.directory || project.key
56
+ const metricsEmpty = computed(() => Boolean(metrics.value) && metrics.value.totals?.steps === 0)
57
+ const hasMetricFilters = computed(() => Boolean(metricProject.value || metricAgent.value || metricProvider.value || metricModel.value))
58
+ const tokenChart = computed(() => tokenBars(metrics.value?.series ?? []))
59
+ const firstMetricDay = computed(() => metrics.value?.series?.[0]?.day ?? '')
60
+ const lastMetricDay = computed(() => metrics.value?.series?.at(-1)?.day ?? '')
61
+ const cacheDenominator = computed(() => {
62
+ const tokens = metrics.value?.totals?.tokens
63
+ return tokens ? tokens.input + tokens.cacheRead + tokens.cacheWrite : 0
64
+ })
65
+ const sortTables = ['agent', 'model', 'project', 'kind']
66
+ const sortState = ref(Object.fromEntries(sortTables.map((table) => [table, { key: 'steps', direction: 'desc' }])))
67
+ const sortRows = (table, rows, nameOf = (row) => row?.key ?? '') => sortMetricRows(
68
+ rows.map((row) => ({ ...row, name: nameOf(row) })),
69
+ sortState.value[table].key,
70
+ sortState.value[table].direction,
71
+ )
72
+ const agentRows = computed(() => sortRows('agent', metrics.value?.byAgent ?? []))
73
+ const modelRows = computed(() => sortRows('model', metrics.value?.byModel ?? [], (row) => `${row.key ?? ''} ${row.provider ?? ''}`))
74
+ const projectRows = computed(() => sortRows('project', metrics.value?.byProject ?? [], (row) => row.directory || row.key || ''))
75
+ const kindRows = computed(() => sortRows('kind', metrics.value?.byKind ?? [], (row) => metricKindLabel(row.key)))
76
+ function toggleSort(table, key) {
77
+ const state = sortState.value[table]
78
+ state.direction = state.key === key ? (state.direction === 'asc' ? 'desc' : 'asc') : (key === 'name' ? 'asc' : 'desc')
79
+ state.key = key
80
+ }
81
+ const ariaSort = (table, key) => sortState.value[table].key !== key
82
+ ? 'none'
83
+ : sortState.value[table].direction === 'asc' ? 'ascending' : 'descending'
84
+ const sortIndicator = (table, key) => sortState.value[table].key !== key
85
+ ? ''
86
+ : sortState.value[table].direction === 'asc' ? '↑' : '↓'
87
+ const plugin = computed(() => metrics.value?.plugin ?? null)
88
+ const pluginHooks = computed(() => Object.entries(plugin.value?.hooks ?? {}).map(([name, hook]) => ({
89
+ name,
90
+ invocations: Number(hook?.invocations) || 0,
91
+ errors: Number(hook?.errors) || 0,
92
+ p50: hook?.latency?.p50 ?? null,
93
+ p95: hook?.latency?.p95 ?? null,
94
+ })))
95
+ const pluginErrors = computed(() => ["hooks", "store", "server", "collector"].map((key) => ({ key, count: Number(plugin.value?.errors?.[key]) || 0 })))
96
+ const pluginHookCalls = computed(() => pluginHooks.value.reduce((sum, hook) => sum + hook.invocations, 0))
97
+ const pluginHookErrors = computed(() => pluginHooks.value.reduce((sum, hook) => sum + hook.errors, 0))
98
+ const pluginErrorTotal = computed(() => pluginErrors.value.reduce((sum, error) => sum + error.count, 0))
99
+ const pluginCurrent = computed(() => plugin.value?.current ?? null)
100
+ const pluginEmpty = computed(() => {
101
+ const value = plugin.value
102
+ if (!value) return true
103
+ const hooks = Object.values(value.hooks ?? {}).some((hook) => (Number(hook?.invocations) || 0) > 0 || (Number(hook?.errors) || 0) > 0)
104
+ const storage = (Number(value.store?.reads) || 0) + (Number(value.store?.writes) || 0) + (Number(value.store?.errors) || 0)
105
+ const sse = (Number(value.sse?.clients) || 0) + (Number(value.sse?.peakClients) || 0) + (Number(value.sse?.refreshes) || 0)
106
+ const requests = ["2xx", "4xx", "5xx"].some((key) => (Number(value.requests?.[key]) || 0) > 0)
107
+ const context = (Number(value.context?.injections) || 0) + (Number(value.context?.injectedChars) || 0) + (Number(value.context?.notices?.checkpoint) || 0) + (Number(value.context?.notices?.todo) || 0)
108
+ const current = value.current && Object.values(value.current).some((count) => (Number(count) || 0) > 0)
109
+ const errors = (Number(value.errors?.hooks) || 0) + (Number(value.errors?.store) || 0) + (Number(value.errors?.server) || 0) + (Number(value.errors?.collector) || 0)
110
+ const flush = (Number(value.flush?.failures) || 0) + (Number(value.flush?.pending) || 0)
111
+ return !hooks && !storage && !sse && !requests && !context && !current && !errors && !flush
112
+ })
113
+ const formatMillis = (value) => value === null || value === undefined ? '—' : `${formatNumber(value)} ms`
114
+ const formatBytes = (value) => {
115
+ if (value === null || value === undefined) return '—'
116
+ const bytes = Number(value)
117
+ if (!Number.isFinite(bytes) || bytes < 0) return '—'
118
+ if (bytes < 1024) return `${formatNumber(bytes)} B`
119
+ if (bytes < 1024 * 1024) return `${decimalFormat.value.format(bytes / 1024)} KB`
120
+ return `${decimalFormat.value.format(bytes / (1024 * 1024))} MB`
121
+ }
122
+ const formatTime = (value) => {
123
+ if (!value) return '—'
124
+ const date = new Date(value)
125
+ return Number.isFinite(date.getTime()) ? date.toLocaleTimeString(language.value) : '—'
126
+ }
127
+ const pluginHookLabel = (name) => ({
128
+ "session.prompt": t('Prompt de sesión', 'Session prompt'),
129
+ "session.context": t('Contexto de sesión', 'Session context'),
130
+ "tool.execute.after": t('Después de herramienta', 'After tool'),
131
+ "shell.create.before": t('Antes de shell', 'Before shell'),
132
+ }[name] ?? name)
133
+ const pluginErrorLabel = (key) => ({
134
+ hooks: t('Hooks', 'Hooks'),
135
+ store: t('Almacenamiento', 'Storage'),
136
+ server: t('Servidor', 'Server'),
137
+ collector: t('Colector', 'Collector'),
138
+ }[key] ?? key)
17
139
  const scopes = computed(() => groupCheckpoints(data.value?.checkpoints ?? []))
18
140
  const projectDirectories = computed(() => new Map(scopes.value.map(({ project, directoryName }) => [project, directoryName])))
19
141
  const todoGroups = computed(() => groupTodos(data.value?.todos ?? [], includeArchived.value, projectDirectories.value))
@@ -41,6 +163,7 @@ export default {
41
163
 
42
164
  watch(language, (value) => { document.documentElement.lang = value })
43
165
  watch(theme, (value) => { document.documentElement.dataset.theme = value }, { immediate: true })
166
+ watch([rangeDays, metricProject, metricAgent, metricProvider, metricModel], loadMetrics)
44
167
 
45
168
  async function request(path, body) {
46
169
  const response = await fetch(path, {
@@ -57,6 +180,56 @@ export default {
57
180
  try { data.value = await request('/api/data'); error.value = '' } catch (e) { error.value = e.message }
58
181
  }
59
182
 
183
+ function mergeMetricOptions(result) {
184
+ const current = metricOptions.value
185
+ const projects = new Map(current.projects.map((project) => [project.key, project]))
186
+ for (const row of result?.byProject ?? []) if (row?.key) projects.set(row.key, { key: row.key, directory: row.directory || '' })
187
+ const agents = new Set(current.agents)
188
+ for (const row of result?.byAgent ?? []) if (row?.key) agents.add(row.key)
189
+ const providers = new Set(current.providers)
190
+ const models = new Set(current.models)
191
+ for (const row of result?.byModel ?? []) {
192
+ if (row?.key) models.add(row.key)
193
+ if (row?.provider) providers.add(row.provider)
194
+ }
195
+ metricOptions.value = {
196
+ projects: [...projects.values()].sort((a, b) => (a.directory || a.key).localeCompare(b.directory || b.key)),
197
+ agents: [...agents].sort((a, b) => a.localeCompare(b)),
198
+ providers: [...providers].sort((a, b) => a.localeCompare(b)),
199
+ models: [...models].sort((a, b) => a.localeCompare(b)),
200
+ }
201
+ }
202
+
203
+ let metricsRequest = 0
204
+ async function loadMetrics() {
205
+ const requestId = ++metricsRequest
206
+ metricsLoading.value = true
207
+ metricsError.value = ''
208
+ try {
209
+ const { from, to } = metricsRange(rangeDays.value)
210
+ const params = new URLSearchParams({ from, to })
211
+ if (metricProject.value) params.set('project', metricProject.value)
212
+ if (metricAgent.value) params.set('agent', metricAgent.value)
213
+ if (metricProvider.value) params.set('provider', metricProvider.value)
214
+ if (metricModel.value) params.set('model', metricModel.value)
215
+ const result = await request(`/api/metrics?${params}`)
216
+ if (requestId !== metricsRequest) return
217
+ metrics.value = result
218
+ mergeMetricOptions(result)
219
+ } catch (e) {
220
+ if (requestId === metricsRequest) metricsError.value = e.message
221
+ } finally {
222
+ if (requestId === metricsRequest) metricsLoading.value = false
223
+ }
224
+ }
225
+
226
+ function clearMetricFilters() {
227
+ metricProject.value = ''
228
+ metricAgent.value = ''
229
+ metricProvider.value = ''
230
+ metricModel.value = ''
231
+ }
232
+
60
233
  async function mutate(path, body, confirmText) {
61
234
  if (busy.value || (confirmText && !confirm(confirmText))) return
62
235
  busy.value = true
@@ -94,18 +267,19 @@ export default {
94
267
 
95
268
  let stopWatchingHash = () => {}
96
269
  let eventSource, refreshTimer
97
- const refreshOnVisible = () => { if (!document.hidden) load() }
270
+ const refreshOnVisible = () => { if (!document.hidden) { load(); loadMetrics() } }
98
271
  onMounted(() => {
99
272
  stopWatchingHash = watchHashView(window, (view) => {
100
273
  activeView.value = view
101
274
  window.scrollTo(0, 0)
102
275
  })
103
276
  load()
277
+ loadMetrics()
104
278
  if (typeof EventSource === 'function') {
105
279
  eventSource = new EventSource('/api/events')
106
280
  eventSource.onmessage = () => {
107
281
  clearTimeout(refreshTimer)
108
- refreshTimer = setTimeout(load, 150)
282
+ refreshTimer = setTimeout(() => { load(); loadMetrics() }, 150)
109
283
  }
110
284
  }
111
285
  document.addEventListener('visibilitychange', refreshOnVisible)
@@ -114,9 +288,10 @@ export default {
114
288
  stopWatchingHash()
115
289
  eventSource?.close()
116
290
  clearTimeout(refreshTimer)
291
+ metricsRequest++
117
292
  document.removeEventListener('visibilitychange', refreshOnVisible)
118
293
  })
119
- return { data, error, language, theme, activeView, busy, includeArchived, t, scopes, todoGroups, todoStatuses, todoStatusLabel, reports, trend, bars, hasHabitData, reportTitle, signalLabel, kindLabel, checkpointTitle, projectDirectoryLabel, load, edit, removeCheckpoint, removeScope, clearHabits }
294
+ return { data, error, language, theme, activeView, busy, includeArchived, t, scopes, todoGroups, todoStatuses, todoStatusLabel, reports, trend, bars, hasHabitData, reportTitle, signalLabel, kindLabel, checkpointTitle, projectDirectoryLabel, load, edit, removeCheckpoint, removeScope, clearHabits, metrics, metricsError, metricsLoading, rangeDays, metricProject, metricAgent, metricProvider, metricModel, metricOptions, metricsEmpty, hasMetricFilters, tokenChart, firstMetricDay, lastMetricDay, cacheDenominator, formatNumber, formatCompact, formatPercent, tokenBreakdown, agentRows, modelRows, projectRows, kindRows, toggleSort, ariaSort, sortIndicator, seriesLabel, metricKindLabel, projectOptionLabel, clearMetricFilters, loadMetrics, plugin, pluginHooks, pluginErrors, pluginHookCalls, pluginHookErrors, pluginErrorTotal, pluginCurrent, pluginEmpty, formatMillis, formatBytes, formatTime, pluginHookLabel, pluginErrorLabel }
120
295
  },
121
296
  }
122
297
  </script>
@@ -129,6 +304,7 @@ export default {
129
304
  <a href="#reports" :aria-current="activeView === 'reports' ? 'page' : undefined">{{ t('Informes', 'Reports') }}</a>
130
305
  <a href="#checkpoints" :aria-current="activeView === 'checkpoints' ? 'page' : undefined">{{ t('Puntos guardados', 'Saved checkpoints') }}</a>
131
306
  <a href="#todos" :aria-current="activeView === 'todos' ? 'page' : undefined">{{ t('TODOs', 'TODOs') }}</a>
307
+ <a href="#metrics" :aria-current="activeView === 'metrics' ? 'page' : undefined">{{ t('Métricas', 'Metrics') }}</a>
132
308
  </nav>
133
309
  <p class="local-note">{{ t('Tus datos permanecen en este equipo.', 'Your data stays on this machine.') }}</p>
134
310
  </aside>
@@ -138,7 +314,7 @@ export default {
138
314
  <div>
139
315
  <p class="eyebrow">{{ t('MEMORIA DE TRABAJO', 'WORKING MEMORY') }}</p>
140
316
  <h1>{{ t('Tu memoria de trabajo.', 'Your working memory.') }}</h1>
141
- <p class="intro">{{ t('Hábitos, puntos guardados y TODOs de este equipo.', 'Stored habits, checkpoints, and TODOs on this machine.') }}</p>
317
+ <p class="intro">{{ t('Hábitos, puntos guardados, TODOs y métricas de uso de este equipo.', 'Stored habits, checkpoints, TODOs, and usage metrics on this machine.') }}</p>
142
318
  </div>
143
319
  <div class="masthead-controls">
144
320
  <label class="language"><span>{{ t('Idioma', 'Language') }}</span>
@@ -156,7 +332,7 @@ export default {
156
332
  </header>
157
333
 
158
334
  <main>
159
- <p class="page-note">{{ t('Este índice contiene resúmenes, hábitos agregados y TODOs persistentes del complemento.', 'This index contains summaries, aggregate habits, and persistent TODOs saved by the plugin.') }}</p>
335
+ <p class="page-note">{{ t('Este índice contiene resúmenes, hábitos agregados, TODOs persistentes y métricas locales del complemento.', 'This index contains summaries, aggregate habits, persistent TODOs, and local metrics saved by the plugin.') }}</p>
160
336
  <p v-if="error" class="notice" role="alert">{{ error }} <button type="button" :disabled="busy" @click="load">{{ t('Reintentar', 'Retry') }}</button></p>
161
337
  <p v-if="busy" role="status">{{ t('Guardando cambios…', 'Saving changes…') }}</p>
162
338
  <p v-if="!data && !error" role="status">{{ t('Leyendo la memoria local…', 'Reading local memory…') }}</p>
@@ -289,6 +465,288 @@ export default {
289
465
  </div>
290
466
  </section>
291
467
  </template>
468
+
469
+ <section id="metrics" v-show="activeView === 'metrics'" aria-labelledby="metrics-heading" :aria-busy="metricsLoading">
470
+ <header class="report-heading">
471
+ <div>
472
+ <h2 id="metrics-heading">{{ t('Métricas de uso', 'Usage metrics') }}</h2>
473
+ <p class="section-note">{{ t('Tokens y caché por agente, modelo y proyecto, medidos a partir del uso reportado por el runtime.', 'Tokens and cache by agent, model, and project, measured from usage reported by the runtime.') }}</p>
474
+ </div>
475
+ </header>
476
+
477
+ <fieldset class="metrics-filters">
478
+ <legend>{{ t('Filtros', 'Filters') }}</legend>
479
+ <label class="metrics-filter">
480
+ <span>{{ t('Rango', 'Range') }}</span>
481
+ <select v-model.number="rangeDays">
482
+ <option :value="7">{{ t('7 días', '7 days') }}</option>
483
+ <option :value="30">{{ t('30 días', '30 days') }}</option>
484
+ <option :value="90">{{ t('90 días', '90 days') }}</option>
485
+ </select>
486
+ </label>
487
+ <label class="metrics-filter">
488
+ <span>{{ t('Proyecto', 'Project') }}</span>
489
+ <select v-model="metricProject">
490
+ <option value="">{{ t('Todos los proyectos', 'All projects') }}</option>
491
+ <option v-for="project in metricOptions.projects" :key="project.key" :value="project.key">{{ projectOptionLabel(project) }}</option>
492
+ </select>
493
+ </label>
494
+ <label class="metrics-filter">
495
+ <span>{{ t('Agente', 'Agent') }}</span>
496
+ <select v-model="metricAgent">
497
+ <option value="">{{ t('Todos los agentes', 'All agents') }}</option>
498
+ <option v-for="agent in metricOptions.agents" :key="agent" :value="agent">{{ agent }}</option>
499
+ </select>
500
+ </label>
501
+ <label class="metrics-filter">
502
+ <span>{{ t('Proveedor', 'Provider') }}</span>
503
+ <select v-model="metricProvider">
504
+ <option value="">{{ t('Todos los proveedores', 'All providers') }}</option>
505
+ <option v-for="provider in metricOptions.providers" :key="provider" :value="provider">{{ provider }}</option>
506
+ </select>
507
+ </label>
508
+ <label class="metrics-filter">
509
+ <span>{{ t('Modelo', 'Model') }}</span>
510
+ <select v-model="metricModel">
511
+ <option value="">{{ t('Todos los modelos', 'All models') }}</option>
512
+ <option v-for="model in metricOptions.models" :key="model" :value="model">{{ model }}</option>
513
+ </select>
514
+ </label>
515
+ <button type="button" class="metrics-reset" :disabled="!hasMetricFilters" @click="clearMetricFilters">{{ t('Limpiar filtros', 'Clear filters') }}</button>
516
+ </fieldset>
517
+
518
+ <p v-if="metricsError" class="notice" role="alert">{{ metricsError }} <button type="button" @click="loadMetrics">{{ t('Reintentar', 'Retry') }}</button></p>
519
+ <p v-if="metricsLoading" role="status">{{ t('Leyendo métricas…', 'Reading metrics…') }}</p>
520
+ <p v-else-if="metricsEmpty && !metricsError" class="empty-report metrics-empty">{{ t('La instrumentación empieza ahora. Aún no hay pasos registrados en este rango; el backfill aproximado puede tardar unos segundos.', 'Instrumentation starts now. No steps recorded in this range yet; approximate backfill may take a few seconds.') }}</p>
521
+
522
+ <template v-if="metrics && !metricsEmpty">
523
+ <div class="metrics-cards">
524
+ <article class="metric-card"><span>{{ t('Pasos', 'Steps') }}</span><strong>{{ formatNumber(metrics.totals.steps) }}</strong><small>{{ t('registrados en el rango', 'recorded in range') }}</small></article>
525
+ <article class="metric-card"><span>{{ t('Entrada sin caché', 'Uncached input') }}</span><strong>{{ formatCompact(metrics.totals.tokens.input) }}</strong><small>{{ t('tokens de entrada', 'input tokens') }}</small></article>
526
+ <article class="metric-card"><span>{{ t('Caché leída', 'Cache read') }}</span><strong>{{ formatCompact(metrics.totals.tokens.cacheRead) }}</strong><small>{{ t('tokens reutilizados', 'reused tokens') }}</small></article>
527
+ <article class="metric-card"><span>{{ t('Salida', 'Output') }}</span><strong>{{ formatCompact(metrics.totals.tokens.output) }}</strong><small>{{ t('tokens generados', 'generated tokens') }}</small></article>
528
+ <article class="metric-card"><span>{{ t('Razonamiento', 'Reasoning') }}</span><strong>{{ formatCompact(metrics.totals.tokens.reasoning) }}</strong><small>{{ t('tokens de razonamiento', 'reasoning tokens') }}</small></article>
529
+ </div>
530
+
531
+ <article class="savings-panel">
532
+ <div>
533
+ <h3>{{ t('Ahorro medido por caché', 'Measured cache savings') }}</h3>
534
+ <p class="section-note">{{ t(`cacheRead / (input + cacheRead + cacheWrite) = ${formatPercent(metrics.totals.cacheHitRate)}. Medición exacta de tokens reutilizados.`, `cacheRead / (input + cacheRead + cacheWrite) = ${formatPercent(metrics.totals.cacheHitRate)}. Exact measurement of reused tokens.`) }}</p>
535
+ </div>
536
+ <strong class="savings-rate">{{ formatPercent(metrics.totals.cacheHitRate) }}</strong>
537
+ <p class="savings-detail">{{ formatCompact(metrics.totals.tokens.cacheRead) }} / {{ formatCompact(cacheDenominator) }} {{ t('tokens de entrada reutilizados', 'input tokens reused') }}</p>
538
+ </article>
539
+
540
+ <div class="metrics-charts">
541
+ <article class="trend-panel">
542
+ <h3>{{ t('Tokens por día · UTC', 'Tokens per day · UTC') }}</h3>
543
+ <ul class="trend-legend" :aria-label="t('Leyenda del gráfico de tokens', 'Token chart legend')">
544
+ <li class="input-series">{{ seriesLabel('input') }}</li>
545
+ <li class="cache-series">{{ seriesLabel('cache') }}</li>
546
+ <li class="output-series">{{ seriesLabel('output') }}</li>
547
+ </ul>
548
+ <p v-if="!tokenChart.length" class="empty-report">{{ t('Sin tokens registrados en este rango.', 'No tokens recorded in this range.') }}</p>
549
+ <template v-else>
550
+ <p class="section-note">{{ t(`Días: ${firstMetricDay} – ${lastMetricDay}.`, `Days: ${firstMetricDay} – ${lastMetricDay}.`) }}</p>
551
+ <svg class="trend-chart metrics-chart" viewBox="0 0 720 180" role="img" aria-labelledby="metrics-tokens-title metrics-tokens-desc">
552
+ <title id="metrics-tokens-title">{{ t('Tokens diarios por serie', 'Daily tokens by series') }}</title>
553
+ <desc id="metrics-tokens-desc">{{ t(`Barras apiladas de entrada sin caché, caché leída y salida para ${metrics.series.length} días UTC.`, `Stacked bars of uncached input, cache read, and output for ${metrics.series.length} UTC days.`) }}</desc>
554
+ <line x1="28" y1="152" x2="704" y2="152" class="chart-axis" />
555
+ <rect v-for="bar in tokenChart" :key="`${bar.day}-${bar.key}`" :class="`${bar.key}-series`"
556
+ :x="bar.x" :y="bar.y" :width="bar.width" :height="bar.height">
557
+ <title>{{ `${bar.day} · ${seriesLabel(bar.key)}: ${formatNumber(bar.value)}` }}</title>
558
+ </rect>
559
+ <text :x="metrics.series.length === 1 ? 366 : 28" y="173" :text-anchor="metrics.series.length === 1 ? 'middle' : 'start'">{{ firstMetricDay }}</text>
560
+ <text v-if="metrics.series.length > 1" x="704" y="173" text-anchor="end">{{ lastMetricDay }}</text>
561
+ </svg>
562
+ </template>
563
+ </article>
564
+
565
+ </div>
566
+
567
+ <p class="section-note metrics-tables-note">{{ t('Máximo 50 filas por dimensión. Ordena por nombre, pasos o tokens desde las cabeceras.', 'Up to 50 rows per dimension. Sort by name, steps, or tokens from the column headers.') }}</p>
568
+ <div class="metrics-tables">
569
+ <article class="metrics-table-panel">
570
+ <table class="metrics-table">
571
+ <caption>{{ t('Por agente', 'By agent') }}</caption>
572
+ <thead><tr>
573
+ <th scope="col" :aria-sort="ariaSort('agent', 'name')">
574
+ <button type="button" class="sort-button" @click="toggleSort('agent', 'name')">{{ t('Agente', 'Agent') }}<span v-if="sortIndicator('agent', 'name')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('agent', 'name') }}</span></button>
575
+ </th>
576
+ <th scope="col" :aria-sort="ariaSort('agent', 'steps')">
577
+ <button type="button" class="sort-button" @click="toggleSort('agent', 'steps')">{{ t('Pasos', 'Steps') }}<span v-if="sortIndicator('agent', 'steps')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('agent', 'steps') }}</span></button>
578
+ </th>
579
+ <th scope="col" :aria-sort="ariaSort('agent', 'tokens')">
580
+ <button type="button" class="sort-button" @click="toggleSort('agent', 'tokens')">{{ t('Tokens', 'Tokens') }}<span v-if="sortIndicator('agent', 'tokens')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('agent', 'tokens') }}</span></button>
581
+ </th>
582
+ </tr></thead>
583
+ <tbody>
584
+ <tr v-for="row in agentRows" :key="row.key ?? 'none'">
585
+ <th scope="row">{{ row.key || t('Sin agente', 'No agent') }}</th>
586
+ <td class="numeric">{{ formatNumber(row.steps) }}</td>
587
+ <td>{{ tokenBreakdown(row) }}</td>
588
+ </tr>
589
+ </tbody>
590
+ </table>
591
+ <p v-if="!metrics.byAgent.length" class="empty-report">{{ t('Sin datos por agente en este rango.', 'No agent data in this range.') }}</p>
592
+ </article>
593
+
594
+ <article class="metrics-table-panel">
595
+ <table class="metrics-table">
596
+ <caption>{{ t('Por modelo y proveedor', 'By model and provider') }}</caption>
597
+ <thead><tr>
598
+ <th scope="col" :aria-sort="ariaSort('model', 'name')">
599
+ <button type="button" class="sort-button" @click="toggleSort('model', 'name')">{{ t('Modelo / proveedor', 'Model / provider') }}<span v-if="sortIndicator('model', 'name')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('model', 'name') }}</span></button>
600
+ </th>
601
+ <th scope="col" :aria-sort="ariaSort('model', 'steps')">
602
+ <button type="button" class="sort-button" @click="toggleSort('model', 'steps')">{{ t('Pasos', 'Steps') }}<span v-if="sortIndicator('model', 'steps')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('model', 'steps') }}</span></button>
603
+ </th>
604
+ <th scope="col" :aria-sort="ariaSort('model', 'tokens')">
605
+ <button type="button" class="sort-button" @click="toggleSort('model', 'tokens')">{{ t('Tokens', 'Tokens') }}<span v-if="sortIndicator('model', 'tokens')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('model', 'tokens') }}</span></button>
606
+ </th>
607
+ </tr></thead>
608
+ <tbody>
609
+ <tr v-for="row in modelRows" :key="`${row.provider ?? ''}:${row.key ?? ''}`">
610
+ <th scope="row"><span>{{ row.key || t('Sin modelo', 'No model') }}</span><small class="row-sub">{{ row.provider || t('Sin proveedor', 'No provider') }}</small></th>
611
+ <td class="numeric">{{ formatNumber(row.steps) }}</td>
612
+ <td>{{ tokenBreakdown(row) }}</td>
613
+ </tr>
614
+ </tbody>
615
+ </table>
616
+ <p v-if="!metrics.byModel.length" class="empty-report">{{ t('Sin datos por modelo en este rango.', 'No model data in this range.') }}</p>
617
+ </article>
618
+
619
+ <article class="metrics-table-panel">
620
+ <table class="metrics-table">
621
+ <caption>{{ t('Por proyecto', 'By project') }}</caption>
622
+ <thead><tr>
623
+ <th scope="col" :aria-sort="ariaSort('project', 'name')">
624
+ <button type="button" class="sort-button" @click="toggleSort('project', 'name')">{{ t('Proyecto / directorio', 'Project / directory') }}<span v-if="sortIndicator('project', 'name')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('project', 'name') }}</span></button>
625
+ </th>
626
+ <th scope="col" :aria-sort="ariaSort('project', 'steps')">
627
+ <button type="button" class="sort-button" @click="toggleSort('project', 'steps')">{{ t('Pasos', 'Steps') }}<span v-if="sortIndicator('project', 'steps')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('project', 'steps') }}</span></button>
628
+ </th>
629
+ <th scope="col" :aria-sort="ariaSort('project', 'tokens')">
630
+ <button type="button" class="sort-button" @click="toggleSort('project', 'tokens')">{{ t('Tokens', 'Tokens') }}<span v-if="sortIndicator('project', 'tokens')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('project', 'tokens') }}</span></button>
631
+ </th>
632
+ </tr></thead>
633
+ <tbody>
634
+ <tr v-for="row in projectRows" :key="row.key ?? 'none'">
635
+ <th scope="row"><code class="project-hash">{{ row.key || '—' }}</code><small class="row-sub">{{ row.directory || t('Directorio no disponible', 'Directory unavailable') }}</small></th>
636
+ <td class="numeric">{{ formatNumber(row.steps) }}</td>
637
+ <td>{{ tokenBreakdown(row) }}</td>
638
+ </tr>
639
+ </tbody>
640
+ </table>
641
+ <p v-if="!metrics.byProject.length" class="empty-report">{{ t('Sin datos por proyecto en este rango.', 'No project data in this range.') }}</p>
642
+ </article>
643
+
644
+ <article class="metrics-table-panel">
645
+ <table class="metrics-table">
646
+ <caption>{{ t('Por tipo de petición', 'By request type') }}</caption>
647
+ <thead><tr>
648
+ <th scope="col" :aria-sort="ariaSort('kind', 'name')">
649
+ <button type="button" class="sort-button" @click="toggleSort('kind', 'name')">{{ t('Tipo', 'Type') }}<span v-if="sortIndicator('kind', 'name')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('kind', 'name') }}</span></button>
650
+ </th>
651
+ <th scope="col" :aria-sort="ariaSort('kind', 'steps')">
652
+ <button type="button" class="sort-button" @click="toggleSort('kind', 'steps')">{{ t('Pasos', 'Steps') }}<span v-if="sortIndicator('kind', 'steps')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('kind', 'steps') }}</span></button>
653
+ </th>
654
+ <th scope="col" :aria-sort="ariaSort('kind', 'tokens')">
655
+ <button type="button" class="sort-button" @click="toggleSort('kind', 'tokens')">{{ t('Tokens', 'Tokens') }}<span v-if="sortIndicator('kind', 'tokens')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('kind', 'tokens') }}</span></button>
656
+ </th>
657
+ </tr></thead>
658
+ <tbody>
659
+ <tr v-for="row in kindRows" :key="row.key">
660
+ <th scope="row">{{ metricKindLabel(row.key) }}</th>
661
+ <td class="numeric">{{ formatNumber(row.steps) }}</td>
662
+ <td>{{ tokenBreakdown(row) }}</td>
663
+ </tr>
664
+ </tbody>
665
+ </table>
666
+ <p v-if="!metrics.byKind.length" class="empty-report">{{ t('Sin datos por tipo en este rango.', 'No type data in this range.') }}</p>
667
+ </article>
668
+ </div>
669
+ </template>
670
+
671
+ <section class="plugin-health" aria-labelledby="plugin-health-heading">
672
+ <header class="plugin-heading">
673
+ <div>
674
+ <h3 id="plugin-health-heading">{{ t('Salud del plugin', 'Plugin health') }}</h3>
675
+ <p class="section-note">{{ t('Contadores en memoria de esta sesión del plugin; no se guardan en store-v1.json y no incluyen prompts, rutas, argumentos ni IDs de sesión.', 'In-memory counters for this plugin session; they are not saved in store-v1.json and contain no prompts, paths, arguments, or session IDs.') }}</p>
676
+ </div>
677
+ </header>
678
+ <p v-if="pluginEmpty && !metricsLoading && !metricsError" class="empty-report">{{ t('Aún no hay actividad interna registrada. Los contadores aparecen cuando el plugin procesa hooks, storage o SSE.', 'No internal activity recorded yet. Counters appear when the plugin handles hooks, storage, or SSE.') }}</p>
679
+ <template v-else-if="!pluginEmpty">
680
+ <div class="metrics-cards">
681
+ <article class="metric-card"><span>{{ t('Hooks', 'Hooks') }}</span><strong>{{ formatNumber(pluginHookCalls) }}</strong><small>{{ t('llamadas', 'calls') }} · {{ formatNumber(pluginHookErrors) }} {{ t('errores', 'errors') }}</small></article>
682
+ <article class="metric-card"><span>{{ t('Almacenamiento', 'Storage') }}</span><strong>{{ formatNumber(plugin.store.reads) }} / {{ formatNumber(plugin.store.writes) }}</strong><small>{{ t('lecturas / escrituras', 'reads / writes') }}</small></article>
683
+ <article class="metric-card"><span>{{ t('Contexto inyectado', 'Injected context') }}</span><strong>{{ formatNumber(plugin.context.injectedChars) }}</strong><small>{{ t('caracteres', 'characters') }} · {{ formatNumber(plugin.context.injections) }} {{ t('inyecciones', 'injections') }}</small></article>
684
+ <article class="metric-card"><span>{{ t('SSE', 'SSE') }}</span><strong>{{ formatNumber(plugin.sse.clients) }}</strong><small>{{ t('clientes · pico', 'clients · peak') }} {{ formatNumber(plugin.sse.peakClients) }}</small></article>
685
+ <article class="metric-card"><span>{{ t('Peticiones 2xx', '2xx requests') }}</span><strong>{{ formatNumber(plugin.requests['2xx']) }}</strong><small>4xx {{ formatNumber(plugin.requests['4xx']) }} · 5xx {{ formatNumber(plugin.requests['5xx']) }}</small></article>
686
+ <article class="metric-card"><span>{{ t('Errores', 'Errors') }}</span><strong>{{ formatNumber(pluginErrorTotal) }}</strong><small>{{ t('contabilizados', 'counted') }}</small></article>
687
+ </div>
688
+
689
+ <div class="metrics-tables plugin-tables">
690
+ <article class="metrics-table-panel">
691
+ <table class="metrics-table">
692
+ <caption>{{ t('Hooks del plugin', 'Plugin hooks') }}</caption>
693
+ <thead><tr><th scope="col">{{ t('Hook', 'Hook') }}</th><th scope="col">{{ t('Llamadas', 'Calls') }}</th><th scope="col">{{ t('Errores', 'Errors') }}</th><th scope="col">p50</th><th scope="col">p95</th></tr></thead>
694
+ <tbody>
695
+ <tr v-for="hook in pluginHooks" :key="hook.name">
696
+ <th scope="row">{{ pluginHookLabel(hook.name) }}</th>
697
+ <td class="numeric">{{ formatNumber(hook.invocations) }}</td>
698
+ <td class="numeric">{{ formatNumber(hook.errors) }}</td>
699
+ <td class="numeric">{{ formatMillis(hook.p50) }}</td>
700
+ <td class="numeric">{{ formatMillis(hook.p95) }}</td>
701
+ </tr>
702
+ </tbody>
703
+ </table>
704
+ </article>
705
+
706
+ <article class="metrics-table-panel">
707
+ <table class="metrics-table">
708
+ <caption>{{ t('Almacenamiento y memoria', 'Storage and memory') }}</caption>
709
+ <thead><tr><th scope="col">{{ t('Métrica', 'Metric') }}</th><th scope="col">{{ t('Valor', 'Value') }}</th></tr></thead>
710
+ <tbody>
711
+ <tr><th scope="row">{{ t('Bytes leídos / escritos', 'Bytes read / written') }}</th><td class="numeric">{{ formatBytes(plugin.store.bytesRead) }} / {{ formatBytes(plugin.store.bytesWritten) }}</td></tr>
712
+ <tr><th scope="row">{{ t('Esperas / timeouts de lock', 'Lock waits / timeouts') }}</th><td class="numeric">{{ formatNumber(plugin.store.lockWaits) }} / {{ formatNumber(plugin.store.lockTimeouts) }}</td></tr>
713
+ <tr><th scope="row">{{ t('Latencia de escritura p50 / p95', 'Write latency p50 / p95') }}</th><td class="numeric">{{ formatMillis(plugin.store.writeLatency.p50) }} / {{ formatMillis(plugin.store.writeLatency.p95) }}</td></tr>
714
+ <tr><th scope="row">{{ t('Tamaño store / metrics', 'Store / metrics size') }}</th><td class="numeric">{{ formatBytes(plugin.bytes.store) }} / {{ formatBytes(plugin.bytes.metrics) }}</td></tr>
715
+ <tr><th scope="row">{{ t('Avisos checkpoint / TODO', 'Checkpoint / TODO notices') }}</th><td class="numeric">{{ formatNumber(plugin.context.notices.checkpoint) }} / {{ formatNumber(plugin.context.notices.todo) }}</td></tr>
716
+ </tbody>
717
+ </table>
718
+ </article>
719
+
720
+ <article class="metrics-table-panel">
721
+ <table class="metrics-table">
722
+ <caption>{{ t('SSE, peticiones y flush', 'SSE, requests, and flush') }}</caption>
723
+ <thead><tr><th scope="col">{{ t('Métrica', 'Metric') }}</th><th scope="col">{{ t('Valor', 'Value') }}</th></tr></thead>
724
+ <tbody>
725
+ <tr><th scope="row">{{ t('Clientes SSE / pico', 'SSE clients / peak') }}</th><td class="numeric">{{ formatNumber(plugin.sse.clients) }} / {{ formatNumber(plugin.sse.peakClients) }}</td></tr>
726
+ <tr><th scope="row">{{ t('Refrescos SSE', 'SSE refreshes') }}</th><td class="numeric">{{ formatNumber(plugin.sse.refreshes) }}</td></tr>
727
+ <tr><th scope="row">{{ t('Peticiones 2xx / 4xx / 5xx', 'Requests 2xx / 4xx / 5xx') }}</th><td class="numeric">{{ formatNumber(plugin.requests['2xx']) }} / {{ formatNumber(plugin.requests['4xx']) }} / {{ formatNumber(plugin.requests['5xx']) }}</td></tr>
728
+ <tr><th scope="row">{{ t('Último flush', 'Last flush') }}</th><td class="numeric">{{ formatTime(plugin.flush.lastAt) }}</td></tr>
729
+ <tr><th scope="row">{{ t('Pendientes / fallos de flush', 'Flush pending / failures') }}</th><td class="numeric">{{ formatNumber(plugin.flush.pending) }} / {{ formatNumber(plugin.flush.failures) }}</td></tr>
730
+ </tbody>
731
+ </table>
732
+ </article>
733
+
734
+ <article class="metrics-table-panel">
735
+ <table class="metrics-table">
736
+ <caption>{{ t('Errores y estado actual', 'Errors and current state') }}</caption>
737
+ <thead><tr><th scope="col">{{ t('Categoría', 'Category') }}</th><th scope="col">{{ t('Valor', 'Value') }}</th></tr></thead>
738
+ <tbody>
739
+ <tr v-for="error in pluginErrors" :key="error.key"><th scope="row">{{ pluginErrorLabel(error.key) }}</th><td class="numeric">{{ formatNumber(error.count) }}</td></tr>
740
+ <tr><th scope="row">{{ t('Checkpoints / TODOs / ámbitos', 'Checkpoints / TODOs / scopes') }}</th><td class="numeric">{{ pluginCurrent ? `${formatNumber(pluginCurrent.checkpoints)} / ${formatNumber(pluginCurrent.todos)} / ${formatNumber(pluginCurrent.scopes)}` : '—' }}</td></tr>
741
+ <tr><th scope="row">{{ t('TODOs activos', 'Active TODOs') }}</th><td class="numeric">{{ pluginCurrent ? formatNumber(pluginCurrent.activeTodos) : '—' }}</td></tr>
742
+ <tr><th scope="row">{{ t('Activo desde', 'Active since') }}</th><td class="numeric">{{ formatTime(plugin.startedAt) }}</td></tr>
743
+ </tbody>
744
+ </table>
745
+ </article>
746
+ </div>
747
+ </template>
748
+ </section>
749
+ </section>
292
750
  </main>
293
751
  </div>
294
752
  </div>