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.
@@ -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 (event) => {
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}`).catch(() => {})
261
- })
262
- await ctx.shell.hook("create.before", async (event) => {
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) await record(`cli:${command}`).catch(() => {})
265
- })
266
- await ctx.session.hook("prompt", async (event) => {
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 (event) => {
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
- event.system.push({
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) event.system.push(checkpointNotice)
331
+ if (checkpointNotice) { inject(checkpointNotice); recordNotice("checkpoint") }
297
332
  const todoNotice = await projectTodoContext(ctx, event, intent)
298
- if (todoNotice) event.system.push(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.1.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": ">=20"
46
+ "node": ">=24"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@vitejs/plugin-vue": "^5.2.4",