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/README.md +22 -3
- package/dist/assets/index-CMgH-UX9.js +21 -0
- package/dist/assets/index-CX37tAhW.css +1 -0
- package/dist/index.html +2 -2
- package/docs/metrics.md +200 -0
- package/index.js +50 -14
- package/package.json +2 -2
- package/src/App.vue +464 -6
- package/src/metrics.js +614 -0
- package/src/plugin-stats.js +215 -0
- package/src/store.js +28 -13
- package/src/ui-model.js +76 -0
- package/src/ui-navigation.js +1 -1
- package/src/ui-server.js +36 -9
- package/src/ui.css +56 -1
- package/dist/assets/index-CeYHNSfT.css +0 -1
- package/dist/assets/index-Nel_NS0n.js +0 -21
package/docs/metrics.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Métricas de uso
|
|
2
|
+
|
|
3
|
+
La WebUI de Habit Memory incluye una vista **Métricas** con datos de uso del runtime de OpenCode y del propio plugin. El detalle vive en `metrics.db`, una base SQLite independiente de `store-v1.json`.
|
|
4
|
+
|
|
5
|
+
## Fuentes de datos
|
|
6
|
+
|
|
7
|
+
OpenCode entrega eventos con el sobre:
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
{ id, created, type, metadata, location, data }
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Habit Memory se suscribe a dos tipos:
|
|
14
|
+
|
|
15
|
+
- **`session.step.ended`**: un paso de asistente. `data` contiene `sessionID`, `assistantMessageID`, `finish`, `rawFinish`, `cost`, `tokens` y metadatos del proveedor. `tokens` tiene la forma `{ input, output, reasoning, cache: { read, write } }`.
|
|
16
|
+
- **`session.usage.recorded`**: uso no asociado a un paso normal, con `source` `title`, `compaction` o `generate`, además de `cost` y `tokens`.
|
|
17
|
+
|
|
18
|
+
La ingesta en vivo se hace con `ctx.event.subscribe({ types, signal })`. Las dimensiones que no vienen en el evento (`parentID`, `projectID`, `agent`, `model`, `location.directory`) se resuelven con `ctx.session.get` y se guardan en una caché LRU acotada.
|
|
19
|
+
|
|
20
|
+
Para completar el histórico se ejecuta un **backfill** en segundo plano con `ctx.session.list` paginado por `cursor`. No recorre mensajes: inserta una fila aproximada por sesión con los totales de `Session.Info`.
|
|
21
|
+
|
|
22
|
+
## Almacenamiento
|
|
23
|
+
|
|
24
|
+
- Ruta: el mismo directorio que `store-v1.json`; por defecto `$XDG_DATA_HOME/habit-memory-plugin/metrics.db` o `~/.local/share/habit-memory-plugin/metrics.db`.
|
|
25
|
+
- `journal_mode=WAL`, `busy_timeout=5000`, `synchronous=NORMAL`.
|
|
26
|
+
- Permisos `0600` para el archivo y sus auxiliares WAL/SHM.
|
|
27
|
+
- `store-v1.json` no se modifica ni se usa para métricas.
|
|
28
|
+
|
|
29
|
+
### `steps`
|
|
30
|
+
|
|
31
|
+
Una fila por paso o por uso agregado. La deduplicación se hace por `message_hash`.
|
|
32
|
+
|
|
33
|
+
| Columna | Contenido |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `id` | Clave interna. |
|
|
36
|
+
| `day` | Día UTC (`YYYY-MM-DD`). |
|
|
37
|
+
| `created_at` | Marca temporal en milisegundos. |
|
|
38
|
+
| `message_hash` | SHA-256 de `message:<assistantMessageID>` o `event:<event.id>`. Único. |
|
|
39
|
+
| `session_hash` | SHA-256 del `sessionID`. |
|
|
40
|
+
| `parent_hash` | SHA-256 del `parentID` si existe. |
|
|
41
|
+
| `project_hash` | SHA-256 del `projectID` si existe. |
|
|
42
|
+
| `directory` | Solo el basename saneado del directorio. |
|
|
43
|
+
| `agent`, `provider`, `model`, `variant` | Etiquetas allowlisted, sin contenido libre. |
|
|
44
|
+
| `kind` | `primary`, `subagent`, `title`, `compaction`, `generate` u `other`. |
|
|
45
|
+
| `source` | `live` o `backfill`. |
|
|
46
|
+
| `input_tokens` | Tokens de entrada **sin caché**. |
|
|
47
|
+
| `output_tokens` | Tokens de salida. |
|
|
48
|
+
| `reasoning_tokens` | Tokens de razonamiento. |
|
|
49
|
+
| `cache_read_tokens` | Tokens leídos de caché. |
|
|
50
|
+
| `cache_write_tokens` | Tokens escritos a caché. |
|
|
51
|
+
| `cost` | Coste reportado por el runtime, en USD. |
|
|
52
|
+
| `priced` | `1` solo si `cost > 0`; `0` si falta precio o es cero. |
|
|
53
|
+
|
|
54
|
+
Índices: `day`, `session_hash` y `(session_hash, source)`.
|
|
55
|
+
|
|
56
|
+
### `monthly`
|
|
57
|
+
|
|
58
|
+
Tabla preparada para agregados mensuales con horizonte de 24 meses:
|
|
59
|
+
|
|
60
|
+
`month`, `project_hash`, `agent`, `provider`, `model`, `kind`, `steps`, tokens, `cost` y `priced_steps`, con clave primaria por dimensiones.
|
|
61
|
+
|
|
62
|
+
`createMetricsStore().rebuildMonthly()` recalcula la tabla desde `steps`. La API actual consulta `steps` directamente; la vista no depende de `monthly`.
|
|
63
|
+
|
|
64
|
+
### `meta`
|
|
65
|
+
|
|
66
|
+
Clave-valor para el estado de la ingesta:
|
|
67
|
+
|
|
68
|
+
- `source.liveFrom`: primera vez que se inició la suscripción en vivo.
|
|
69
|
+
- `source.backfillAt`: última finalización correcta del backfill.
|
|
70
|
+
|
|
71
|
+
## Privacidad
|
|
72
|
+
|
|
73
|
+
No se guardan prompts, argumentos, transcripciones, títulos de sesión, rutas absolutas ni IDs crudos.
|
|
74
|
+
|
|
75
|
+
- Sesión, padre y proyecto se guardan como hash SHA-256.
|
|
76
|
+
- El mensaje se deduplica por hash; nunca se guarda el ID original.
|
|
77
|
+
- El directorio se reduce a basename y se valida.
|
|
78
|
+
- Agente, proveedor, modelo y variante pasan por una lista de caracteres permitidos.
|
|
79
|
+
- El bloque de salud del plugin solo contiene contadores, latencias, tamaños y fechas; no contenido.
|
|
80
|
+
|
|
81
|
+
## Tokens y caché
|
|
82
|
+
|
|
83
|
+
OpenCode separa:
|
|
84
|
+
|
|
85
|
+
- `input`: tokens de entrada **no cacheados**.
|
|
86
|
+
- `cache.read`: tokens leídos de caché.
|
|
87
|
+
- `cache.write`: tokens escritos a caché.
|
|
88
|
+
- `output`: tokens generados.
|
|
89
|
+
- `reasoning`: tokens de razonamiento.
|
|
90
|
+
|
|
91
|
+
Por eso la API **no publica un total único de tokens**. Sumar `input` y `cache.read` sin conocer la semántica del proveedor puede duplicar conteos, y el total no distingue entrada, salida, razonamiento y caché. La vista y `/api/metrics` mantienen las cinco series separadas.
|
|
92
|
+
|
|
93
|
+
La tasa de acierto de caché es una medición exacta:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
cacheHitRate = cache.read / (input + cache.read + cache.write)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
No se convierte a USD ni se presenta como ahorro económico.
|
|
100
|
+
|
|
101
|
+
## Coste y cobertura
|
|
102
|
+
|
|
103
|
+
`cost` es el coste reportado por OpenCode. No se calcula a partir de precios de modelos ni se inventan importes.
|
|
104
|
+
|
|
105
|
+
- `priced = 1` solo cuando el runtime reporta `cost > 0`.
|
|
106
|
+
- `costCoverage = pricedSteps / steps`.
|
|
107
|
+
- `/api/metrics` sigue publicando `cost`, `pricedSteps` y `costCoverage` en `totals` y en cada fila agregada.
|
|
108
|
+
- La vista **Métricas** no muestra coste ni cobertura: se limita a pasos, tokens y caché. El coste queda disponible para consumidores de la API.
|
|
109
|
+
- Una cobertura baja significa que muchos pasos no tienen precio reportado, no que el coste sea cero.
|
|
110
|
+
|
|
111
|
+
## Retención
|
|
112
|
+
|
|
113
|
+
- **Detalle `steps`:** se conservan los últimos **90 días UTC**. La poda se ejecuta en cada ingesta.
|
|
114
|
+
- **Agregados `monthly`:** el diseño objetivo es **24 meses**. En 0.2.0 la tabla está preparada y `rebuildMonthly()` existe, pero no hay una poda automática de 24 meses ni se recalcula desde la API; la ventana efectiva depende del detalle disponible.
|
|
115
|
+
|
|
116
|
+
## Backfill
|
|
117
|
+
|
|
118
|
+
- Usa `ctx.session.list` con `limit`, `order: "desc"` y `cursor` opaco.
|
|
119
|
+
- Límite: 5000 sesiones o hasta alcanzar el corte de 90 días.
|
|
120
|
+
- Inserta una fila aproximada por sesión, con `source=backfill` y hash sintético `session:<id>`.
|
|
121
|
+
- El día se toma de `time.updated` de la sesión; es una aproximación, no un paso real.
|
|
122
|
+
- Omite sesiones que ya tienen filas `live` para no duplicar.
|
|
123
|
+
- `source.backfillAt` evita repetirlo. Si se borra esa clave con el plugin detenido, el siguiente arranque puede volver a intentarlo.
|
|
124
|
+
- No lee transcripciones ni mensajes: solo `Session.Info`.
|
|
125
|
+
|
|
126
|
+
## Ingesta en vivo
|
|
127
|
+
|
|
128
|
+
- Cola en memoria con flush debounced de **15 s** o **500 eventos**, lo que ocurra primero.
|
|
129
|
+
- Flush final al descargar el plugin y en el `exit` del proceso cuando es posible.
|
|
130
|
+
- Deduplicación por `message_hash`; reintentos y eventos repetidos no duplican filas.
|
|
131
|
+
- Si el proceso muere antes del flush, se puede perder el último lote.
|
|
132
|
+
|
|
133
|
+
## `/api/metrics`
|
|
134
|
+
|
|
135
|
+
```text
|
|
136
|
+
GET /api/metrics?from=YYYY-MM-DD&to=YYYY-MM-DD&project=&agent=&provider=&model=&kind=
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- `from`/`to` en UTC. Por defecto, últimos 30 días; máximo 90 días.
|
|
140
|
+
- `project` acepta el hash de proyecto o el basename del directorio.
|
|
141
|
+
- `agent`, `provider`, `model` y `kind` son filtros exactos.
|
|
142
|
+
- Top 50 por dimensión.
|
|
143
|
+
- Misma comprobación de host que `/api/data`; no requiere token.
|
|
144
|
+
- `400` para fechas o filtros inválidos; `500` con `{ "error": "..." }` si la base no se puede leer.
|
|
145
|
+
|
|
146
|
+
Respuesta:
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"range": { "from": "YYYY-MM-DD", "to": "YYYY-MM-DD", "days": 30 },
|
|
151
|
+
"totals": {
|
|
152
|
+
"steps": 0,
|
|
153
|
+
"tokens": { "input": 0, "output": 0, "reasoning": 0, "cacheRead": 0, "cacheWrite": 0 },
|
|
154
|
+
"cost": 0,
|
|
155
|
+
"pricedSteps": 0,
|
|
156
|
+
"costCoverage": 0,
|
|
157
|
+
"cacheHitRate": 0
|
|
158
|
+
},
|
|
159
|
+
"series": [],
|
|
160
|
+
"byAgent": [],
|
|
161
|
+
"byModel": [],
|
|
162
|
+
"byProject": [],
|
|
163
|
+
"byKind": [],
|
|
164
|
+
"plugin": {}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`series` rellena los días del rango con ceros. No existe `tokens.total` ni un total único de coste sin cobertura.
|
|
169
|
+
|
|
170
|
+
### Bloque `plugin`
|
|
171
|
+
|
|
172
|
+
`/api/metrics` añade contadores del propio plugin, en memoria:
|
|
173
|
+
|
|
174
|
+
`startedAt`, `uptimeMs`, `hooks`, `context`, `store`, `sse`, `requests`, `flush`, `errors`, `current` y `bytes`.
|
|
175
|
+
|
|
176
|
+
Incluye llamadas/errores/latencias de hooks, lecturas/escrituras del store, bytes, esperas de lock, clientes SSE, peticiones 2xx/4xx/5xx, pendientes/fallos de flush, errores por categoría y conteos actuales de checkpoints/TODOs/ámbitos. No incluye sessionIDs, prompts, rutas ni argumentos. Los contadores son de proceso: se reinician con el plugin.
|
|
177
|
+
|
|
178
|
+
## Requisitos
|
|
179
|
+
|
|
180
|
+
- **Node.js >= 24** (`engines`).
|
|
181
|
+
- `node:sqlite` (`DatabaseSync`), sin dependencias externas de base de datos.
|
|
182
|
+
- El plugin crea `metrics.db` al arrancar; no hace falta inicializarlo a mano.
|
|
183
|
+
|
|
184
|
+
## Troubleshooting
|
|
185
|
+
|
|
186
|
+
**No aparece `metrics.db`.** Comprueba Node 24, que el plugin esté cargado y que el directorio de datos sea escribible. La creación ocurre en `setup`.
|
|
187
|
+
|
|
188
|
+
**La vista está vacía.** La instrumentación empieza ahora: no se reconstruyen conversaciones antiguas. El backfill aproximado puede tardar unos segundos; espera al siguiente refresco o consulta el rango.
|
|
189
|
+
|
|
190
|
+
**Necesito coste o cobertura.** La vista no los muestra, pero `/api/metrics` sigue devolviendo `cost`, `pricedSteps` y `costCoverage` en `totals` y en cada fila. Una cobertura baja significa que OpenCode no reportó precio para esos pasos; no se estima USD.
|
|
191
|
+
|
|
192
|
+
**`400 Invalid metrics query`.** Revisa fechas `YYYY-MM-DD`, que `from <= to`, que el rango no supere 90 días y que los filtros existan.
|
|
193
|
+
|
|
194
|
+
**`500 Unable to read metrics`.** `metrics.db` puede estar corrupta o inaccesible. Con el plugin y la WebUI detenidos, renombra o elimina `metrics.db`, `metrics.db-wal` y `metrics.db-shm`; se recreará. `store-v1.json` no se toca.
|
|
195
|
+
|
|
196
|
+
**`Storage is locked`.** Otra operación mantiene el lock del store más de 5 s. Reintenta y revisa procesos concurrentes; no cambies la semántica de bloqueo.
|
|
197
|
+
|
|
198
|
+
**El backfill no se repite.** Es intencional por `source.backfillAt`. Si necesitas repetirlo, elimina esa clave de `meta` con el plugin detenido.
|
|
199
|
+
|
|
200
|
+
**SSE no refresca.** La WebUI observa `metrics.db` y `metrics.db-wal`. Si el navegador perdió la conexión, recupera la visibilidad o recarga; el endpoint `/api/events` no cambia.
|
package/index.js
CHANGED
|
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto"
|
|
|
2
2
|
import { basename, isAbsolute } from "node:path"
|
|
3
3
|
import { getData, incrementHabitCounts, saveCheckpoint, listCheckpoints, deleteCheckpoint, importLegacy, createTodo, listTodos, updateTodo, archiveTodo } from "./src/store.js"
|
|
4
4
|
import { startUiServer, stopUiServer } from "./src/ui-server.js"
|
|
5
|
+
import { backfillMetrics, createMetricsCollector, createMetricsStore } from "./src/metrics.js"
|
|
6
|
+
import { measureHook, recordContextChars, recordHookError, recordNotice } from "./src/plugin-stats.js"
|
|
5
7
|
|
|
6
8
|
const storageKey = "profile-v1"
|
|
7
9
|
const checkpointKeyPrefix = "project-checkpoint:v1:"
|
|
@@ -254,16 +256,44 @@ function todoView({ id, task, status, createdAt, updatedAt }) {
|
|
|
254
256
|
export default {
|
|
255
257
|
id: "global-habit-memory",
|
|
256
258
|
async setup(ctx) {
|
|
259
|
+
let stopMetrics
|
|
260
|
+
let metrics
|
|
261
|
+
try {
|
|
262
|
+
metrics = createMetricsStore()
|
|
263
|
+
const collector = createMetricsCollector({
|
|
264
|
+
store: metrics,
|
|
265
|
+
subscribe: (options) => ctx.event.subscribe(options),
|
|
266
|
+
getSession: (sessionID) => ctx.session.get({ sessionID }),
|
|
267
|
+
})
|
|
268
|
+
const backfill = backfillMetrics({
|
|
269
|
+
store: metrics,
|
|
270
|
+
listSessions: (params) => ctx.session.list(params),
|
|
271
|
+
})
|
|
272
|
+
void backfill.catch(() => {})
|
|
273
|
+
const onExit = () => { try { collector.flush() } catch {} }
|
|
274
|
+
process.once("exit", onExit)
|
|
275
|
+
let stopped = false
|
|
276
|
+
stopMetrics = async () => {
|
|
277
|
+
if (stopped) return
|
|
278
|
+
stopped = true
|
|
279
|
+
process.off("exit", onExit)
|
|
280
|
+
try { await collector.stop() } catch {}
|
|
281
|
+
try { await backfill } catch {}
|
|
282
|
+
try { metrics.close() } catch {}
|
|
283
|
+
}
|
|
284
|
+
} catch { try { metrics?.close() } catch {} }
|
|
257
285
|
try { await importLegacy(await ctx.storage.get(storageKey)) } catch {}
|
|
258
|
-
await ctx.tool.hook("execute.after", async (
|
|
286
|
+
await ctx.tool.hook("execute.after", (event) => measureHook("tool.execute.after", async () => {
|
|
259
287
|
if (event.status !== "completed" || !toolNames.has(event.tool)) return
|
|
260
|
-
await record(`tool:${event.tool}`)
|
|
261
|
-
})
|
|
262
|
-
await ctx.shell.hook("create.before", async (
|
|
288
|
+
try { await record(`tool:${event.tool}`) } catch { recordHookError("tool.execute.after") }
|
|
289
|
+
}))
|
|
290
|
+
await ctx.shell.hook("create.before", (event) => measureHook("shell.create.before", async () => {
|
|
263
291
|
const command = typeof event.command === "string" ? event.command.match(cliPattern)?.[1] : undefined
|
|
264
|
-
if (command)
|
|
265
|
-
|
|
266
|
-
|
|
292
|
+
if (command) {
|
|
293
|
+
try { await record(`cli:${command}`) } catch { recordHookError("shell.create.before") }
|
|
294
|
+
}
|
|
295
|
+
}))
|
|
296
|
+
await ctx.session.hook("prompt", (event) => measureHook("session.prompt", async () => {
|
|
267
297
|
if (typeof event.sessionID === "string") promptIntents.set(event.sessionID, checkpointIntent(event.prompt?.text))
|
|
268
298
|
const categories = requestCategories(event)
|
|
269
299
|
if (!categories.length) return
|
|
@@ -276,16 +306,21 @@ export default {
|
|
|
276
306
|
try {
|
|
277
307
|
await recordMany(categories)
|
|
278
308
|
} catch {
|
|
309
|
+
recordHookError("session.prompt")
|
|
279
310
|
if (messageID) seenPromptIDs.delete(messageID)
|
|
280
311
|
}
|
|
281
|
-
})
|
|
282
|
-
await ctx.session.hook("context", async (
|
|
312
|
+
}))
|
|
313
|
+
await ctx.session.hook("context", (event) => measureHook("session.context", async () => {
|
|
314
|
+
const inject = (item) => {
|
|
315
|
+
event.system.push(item)
|
|
316
|
+
if (typeof item?.text === "string") recordContextChars(item.text.length)
|
|
317
|
+
}
|
|
283
318
|
try {
|
|
284
319
|
await writes
|
|
285
320
|
const profile = await readProfile()
|
|
286
321
|
const intent = promptIntents.get(event?.sessionID) ?? "unclear"
|
|
287
322
|
if (typeof event?.sessionID === "string") promptIntents.delete(event.sessionID)
|
|
288
|
-
|
|
323
|
+
inject({
|
|
289
324
|
type: "text",
|
|
290
325
|
text: [
|
|
291
326
|
profileSummary(profile.counts),
|
|
@@ -293,11 +328,11 @@ export default {
|
|
|
293
328
|
].join(" "),
|
|
294
329
|
})
|
|
295
330
|
const checkpointNotice = await projectCheckpointContext(ctx, event, intent)
|
|
296
|
-
if (checkpointNotice)
|
|
331
|
+
if (checkpointNotice) { inject(checkpointNotice); recordNotice("checkpoint") }
|
|
297
332
|
const todoNotice = await projectTodoContext(ctx, event, intent)
|
|
298
|
-
if (todoNotice)
|
|
299
|
-
} catch {}
|
|
300
|
-
})
|
|
333
|
+
if (todoNotice) { inject(todoNotice); recordNotice("todo") }
|
|
334
|
+
} catch { recordHookError("session.context") }
|
|
335
|
+
}))
|
|
301
336
|
await ctx.command.transform((editor) => {
|
|
302
337
|
editor.add({
|
|
303
338
|
name: "habit-memory-ui",
|
|
@@ -461,5 +496,6 @@ export default {
|
|
|
461
496
|
},
|
|
462
497
|
})
|
|
463
498
|
})
|
|
499
|
+
return stopMetrics
|
|
464
500
|
},
|
|
465
501
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-global-habit-profile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Plugin de OpenCode para hábitos agregados, checkpoints y TODOs por proyecto.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"prepublishOnly": "npm test && npm run build"
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
|
46
|
-
"node": ">=
|
|
46
|
+
"node": ">=24"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@vitejs/plugin-vue": "^5.2.4",
|