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,215 @@
1
+ import { statSync } from "node:fs"
2
+
3
+ const hookNames = Object.freeze(["session.prompt", "session.context", "tool.execute.after", "shell.create.before"])
4
+ const latencyBuckets = Object.freeze([1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000])
5
+ const histogram = () => ({ counts: new Array(latencyBuckets.length).fill(0) })
6
+ let startedAt
7
+ let hooks
8
+ let context
9
+ let store
10
+ let sse
11
+ let requests
12
+ let flush
13
+ let errors
14
+
15
+ function reset() {
16
+ startedAt = Date.now()
17
+ hooks = Object.fromEntries(hookNames.map((name) => [name, { invocations: 0, errors: 0, latency: histogram() }]))
18
+ context = { injections: 0, injectedChars: 0, notices: { checkpoint: 0, todo: 0 } }
19
+ store = { reads: 0, writes: 0, bytesRead: 0, bytesWritten: 0, lockWaits: 0, lockTimeouts: 0, errors: 0, writeLatency: histogram() }
20
+ sse = { clients: 0, peakClients: 0, refreshes: 0 }
21
+ requests = { "2xx": 0, "4xx": 0, "5xx": 0 }
22
+ flush = { lastAt: null, pending: 0, failures: 0 }
23
+ errors = { hooks: 0, store: 0, server: 0, collector: 0 }
24
+ }
25
+ reset()
26
+
27
+ function observe(histogramValue, durationMs) {
28
+ const value = Number(durationMs)
29
+ if (!Number.isFinite(value) || value < 0) return
30
+ let index = latencyBuckets.findIndex((max) => value <= max)
31
+ if (index === -1) index = latencyBuckets.length - 1
32
+ histogramValue.counts[index]++
33
+ }
34
+
35
+ function percentile(histogramValue, quantile) {
36
+ const samples = histogramValue.counts.reduce((sum, count) => sum + count, 0)
37
+ if (!samples) return null
38
+ const target = Math.ceil(samples * quantile)
39
+ let seen = 0
40
+ for (let index = 0; index < histogramValue.counts.length; index++) {
41
+ seen += histogramValue.counts[index]
42
+ if (seen >= target) return latencyBuckets[index]
43
+ }
44
+ return latencyBuckets.at(-1)
45
+ }
46
+
47
+ function histogramSnapshot(histogramValue) {
48
+ return {
49
+ samples: histogramValue.counts.reduce((sum, count) => sum + count, 0),
50
+ p50: percentile(histogramValue, 0.5),
51
+ p95: percentile(histogramValue, 0.95),
52
+ buckets: histogramValue.counts.map((count, index) => ({ maxMs: index === histogramValue.counts.length - 1 ? null : latencyBuckets[index], count })),
53
+ }
54
+ }
55
+
56
+ function recordHook(name, durationMs, failed) {
57
+ const hook = hooks[name]
58
+ if (!hook) return
59
+ hook.invocations++
60
+ observe(hook.latency, durationMs)
61
+ if (failed) {
62
+ hook.errors++
63
+ errors.hooks++
64
+ }
65
+ }
66
+
67
+ export async function measureHook(name, fn) {
68
+ const started = Date.now()
69
+ try {
70
+ const result = await fn()
71
+ recordHook(name, Date.now() - started, false)
72
+ return result
73
+ } catch (error) {
74
+ recordHook(name, Date.now() - started, true)
75
+ throw error
76
+ }
77
+ }
78
+
79
+ export function recordHookError(name) {
80
+ const hook = hooks[name]
81
+ if (!hook) return
82
+ hook.errors++
83
+ errors.hooks++
84
+ }
85
+
86
+ export function recordContextChars(chars) {
87
+ const value = Number(chars)
88
+ if (!Number.isSafeInteger(value) || value < 0) return
89
+ if (!value) return
90
+ context.injections++
91
+ context.injectedChars += value
92
+ }
93
+
94
+ export function recordNotice(kind) {
95
+ if (kind === "checkpoint" || kind === "todo") context.notices[kind]++
96
+ }
97
+
98
+ export function recordStoreRead(bytes) {
99
+ store.reads++
100
+ const value = Number(bytes)
101
+ if (Number.isSafeInteger(value) && value >= 0) store.bytesRead += value
102
+ }
103
+
104
+ export function recordStoreWrite(bytes, durationMs) {
105
+ store.writes++
106
+ const value = Number(bytes)
107
+ if (Number.isSafeInteger(value) && value >= 0) store.bytesWritten += value
108
+ observe(store.writeLatency, durationMs)
109
+ }
110
+
111
+ export function recordStoreLockWait() {
112
+ store.lockWaits++
113
+ }
114
+
115
+ export function recordStoreLockTimeout() {
116
+ store.lockTimeouts++
117
+ errors.store++
118
+ }
119
+
120
+ export function recordStoreError() {
121
+ store.errors++
122
+ errors.store++
123
+ }
124
+
125
+ export function recordSseClient(delta) {
126
+ sse.clients = Math.max(0, sse.clients + (Number(delta) || 0))
127
+ sse.peakClients = Math.max(sse.peakClients, sse.clients)
128
+ }
129
+
130
+ export function recordSseRefresh() {
131
+ sse.refreshes++
132
+ }
133
+
134
+ export function recordRequestStatus(status) {
135
+ const code = Number(status)
136
+ if (code >= 200 && code < 300) requests["2xx"]++
137
+ else if (code >= 400 && code < 500) requests["4xx"]++
138
+ else if (code >= 500 && code < 600) {
139
+ requests["5xx"]++
140
+ errors.server++
141
+ }
142
+ }
143
+
144
+ export function recordServerError() {
145
+ errors.server++
146
+ }
147
+
148
+ export function recordCollectorPending(pending) {
149
+ const value = Number(pending)
150
+ flush.pending = Number.isSafeInteger(value) && value >= 0 ? value : 0
151
+ }
152
+
153
+ export function recordCollectorFlush(pending, failed) {
154
+ flush.lastAt = Date.now()
155
+ recordCollectorPending(pending)
156
+ if (failed) {
157
+ flush.failures++
158
+ errors.collector++
159
+ }
160
+ }
161
+
162
+ function currentCounts(storeData) {
163
+ if (!storeData || typeof storeData !== "object") return null
164
+ const checkpoints = Array.isArray(storeData.checkpoints) ? storeData.checkpoints : []
165
+ const todos = Array.isArray(storeData.todos) ? storeData.todos : []
166
+ return {
167
+ checkpoints: checkpoints.length,
168
+ todos: todos.length,
169
+ activeTodos: todos.filter((todo) => todo?.status === "pending").length,
170
+ scopes: new Set(checkpoints.map((checkpoint) => `${checkpoint?.project}\u0000${checkpoint?.context}`)).size,
171
+ }
172
+ }
173
+
174
+ function fileSize(path) {
175
+ try { return statSync(path).size } catch { return null }
176
+ }
177
+
178
+ export function storeStats() {
179
+ return {
180
+ reads: store.reads,
181
+ writes: store.writes,
182
+ bytesRead: store.bytesRead,
183
+ bytesWritten: store.bytesWritten,
184
+ lockWaits: store.lockWaits,
185
+ lockTimeouts: store.lockTimeouts,
186
+ errors: store.errors,
187
+ writeLatency: histogramSnapshot(store.writeLatency),
188
+ }
189
+ }
190
+
191
+ export function pluginSnapshot({ storeData, storePath, metricsPath } = {}) {
192
+ return {
193
+ startedAt: new Date(startedAt).toISOString(),
194
+ uptimeMs: Math.max(0, Date.now() - startedAt),
195
+ hooks: Object.fromEntries(Object.entries(hooks).map(([name, hook]) => [name, {
196
+ invocations: hook.invocations,
197
+ errors: hook.errors,
198
+ latency: histogramSnapshot(hook.latency),
199
+ }])),
200
+ context: { injections: context.injections, injectedChars: context.injectedChars, notices: { ...context.notices } },
201
+ store: storeStats(),
202
+ sse: { clients: sse.clients, peakClients: sse.peakClients, refreshes: sse.refreshes },
203
+ requests: { ...requests },
204
+ flush: { ...flush },
205
+ errors: { ...errors },
206
+ current: currentCounts(storeData),
207
+ bytes: { store: fileSize(storePath), metrics: fileSize(metricsPath) },
208
+ }
209
+ }
210
+
211
+ export function resetPluginStats() {
212
+ reset()
213
+ }
214
+
215
+ export { hookNames }
package/src/store.js CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"
2
2
  import { mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises"
3
3
  import { dirname, join } from "node:path"
4
4
  import { homedir } from "node:os"
5
+ import { recordStoreError, recordStoreLockTimeout, recordStoreLockWait, recordStoreRead, recordStoreWrite, storeStats } from "./plugin-stats.js"
5
6
 
6
7
  const maxBytes = 2_000_000
7
8
  const categories = new Set([
@@ -82,10 +83,12 @@ function valid(data) {
82
83
  async function read() {
83
84
  try {
84
85
  const raw = await readFile(storePath)
86
+ recordStoreRead(raw.length)
85
87
  if (raw.length > maxBytes) throw new Error("Storage exceeds size limit")
86
88
  return valid(JSON.parse(raw))
87
89
  } catch (error) {
88
- if (error.code === "ENOENT") return empty()
90
+ if (error.code === "ENOENT") { recordStoreRead(0); return empty() }
91
+ recordStoreError()
89
92
  throw error
90
93
  }
91
94
  }
@@ -117,7 +120,8 @@ async function locked(fn) {
117
120
  await recoverStaleLock()
118
121
  }
119
122
  if (!handle) {
120
- if (Date.now() >= deadline) throw new Error("Storage is locked")
123
+ if (Date.now() >= deadline) { recordStoreLockTimeout(); throw new Error("Storage is locked") }
124
+ recordStoreLockWait()
121
125
  await new Promise((resolve) => setTimeout(resolve, 25))
122
126
  }
123
127
  }
@@ -130,19 +134,26 @@ async function locked(fn) {
130
134
  }
131
135
  }
132
136
  async function write(data) {
133
- valid(data)
134
- const serialized = JSON.stringify(data)
135
- if (Buffer.byteLength(serialized) > maxBytes) throw new Error("Storage exceeds size limit")
136
- const temp = `${storePath}.${process.pid}.${randomUUID()}.tmp`
137
- const handle = await open(temp, "wx", 0o600)
137
+ const started = Date.now()
138
138
  try {
139
- await handle.writeFile(serialized)
140
- await handle.sync()
141
- await handle.close()
142
- await rename(temp, storePath)
139
+ valid(data)
140
+ const serialized = JSON.stringify(data)
141
+ if (Buffer.byteLength(serialized) > maxBytes) throw new Error("Storage exceeds size limit")
142
+ const temp = `${storePath}.${process.pid}.${randomUUID()}.tmp`
143
+ const handle = await open(temp, "wx", 0o600)
144
+ try {
145
+ await handle.writeFile(serialized)
146
+ await handle.sync()
147
+ await handle.close()
148
+ await rename(temp, storePath)
149
+ } catch (error) {
150
+ await handle.close().catch(() => {})
151
+ await unlink(temp).catch(() => {})
152
+ throw error
153
+ }
154
+ recordStoreWrite(Buffer.byteLength(serialized), Date.now() - started)
143
155
  } catch (error) {
144
- await handle.close().catch(() => {})
145
- await unlink(temp).catch(() => {})
156
+ recordStoreError()
146
157
  throw error
147
158
  }
148
159
  }
@@ -261,6 +272,10 @@ export async function archiveTodo(project, id) {
261
272
  return true
262
273
  })
263
274
  }
275
+ export function getStoreStats() {
276
+ return storeStats()
277
+ }
278
+
264
279
  export async function importLegacy(profile, checkpoint) {
265
280
  return locked(async () => {
266
281
  const data = await read()
package/src/ui-model.js CHANGED
@@ -87,6 +87,82 @@ export function dailyTrend(daily = {}, today = new Date().toISOString().slice(0,
87
87
  }).filter(({ tools, commands }) => tools + commands > 0)
88
88
  }
89
89
 
90
+ function metricDate(value) {
91
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10) === value
92
+ }
93
+
94
+ export function metricsRange(days, today = new Date().toISOString().slice(0, 10)) {
95
+ const allowed = [7, 30, 90]
96
+ const span = allowed.includes(Number(days)) ? Number(days) : 30
97
+ const to = metricDate(today) ? today : new Date().toISOString().slice(0, 10)
98
+ const from = new Date(`${to}T00:00:00.000Z`)
99
+ from.setUTCDate(from.getUTCDate() - (span - 1))
100
+ return { from: from.toISOString().slice(0, 10), to }
101
+ }
102
+
103
+ export function tokenBars(series = [], options = {}) {
104
+ if (!Array.isArray(series) || !series.length) return []
105
+ const rows = series.filter((row) => row && metricDate(row.day))
106
+ if (!rows.length) return []
107
+ const width = options.width ?? 720
108
+ const height = options.height ?? 180
109
+ const left = options.left ?? 28
110
+ const right = options.right ?? 16
111
+ const top = options.top ?? 12
112
+ const bottom = options.bottom ?? 28
113
+ const plotWidth = width - left - right
114
+ const plotHeight = height - top - bottom
115
+ const max = Math.max(1, ...rows.map((row) => {
116
+ const tokens = row.tokens ?? {}
117
+ return Number(tokens.input || 0) + Number(tokens.cacheRead || 0) + Number(tokens.output || 0)
118
+ }))
119
+ const slot = plotWidth / rows.length
120
+ const barWidth = Math.max(1, Math.min(28, slot * .62))
121
+ return rows.flatMap((row, index) => {
122
+ const x = rows.length === 1 ? left + (plotWidth - barWidth) / 2 : left + index * slot + (slot - barWidth) / 2
123
+ const values = [
124
+ ['input', Number(row.tokens?.input || 0)],
125
+ ['cache', Number(row.tokens?.cacheRead || 0)],
126
+ ['output', Number(row.tokens?.output || 0)],
127
+ ]
128
+ let cursor = top + plotHeight
129
+ return values.map(([key, value]) => {
130
+ const heightValue = Math.max(0, value) / max * plotHeight
131
+ const y = Math.max(top, cursor - heightValue)
132
+ const bar = { day: row.day, key, value, x, y, width: barWidth, height: cursor - y }
133
+ cursor -= heightValue
134
+ return bar
135
+ }).filter(({ value, height: barHeight }) => value > 0 && barHeight > 0)
136
+ })
137
+ }
138
+
139
+ export function metricTokenTotal(row) {
140
+ const tokens = row?.tokens ?? {}
141
+ return ['input', 'output', 'reasoning', 'cacheRead', 'cacheWrite']
142
+ .reduce((total, field) => total + (Number(tokens[field]) || 0), 0)
143
+ }
144
+
145
+ export function sortMetricRows(rows = [], key = 'steps', direction = 'desc') {
146
+ if (!Array.isArray(rows)) return []
147
+ const column = key === 'name' || key === 'tokens' ? key : 'steps'
148
+ const factor = direction === 'asc' ? 1 : -1
149
+ const nameOf = (row) => String(row?.name ?? row?.key ?? '')
150
+ const providerOf = (row) => String(row?.provider ?? '')
151
+ const valueOf = (row) => column === 'tokens'
152
+ ? metricTokenTotal(row)
153
+ : column === 'steps' ? Number(row?.steps) || 0 : 0
154
+ return rows
155
+ .map((row, index) => ({ row, index }))
156
+ .sort((a, b) => {
157
+ const byName = nameOf(a.row).localeCompare(nameOf(b.row))
158
+ const primary = column === 'name' ? byName * factor : (valueOf(a.row) - valueOf(b.row)) * factor
159
+ if (primary) return primary
160
+ if (column === 'name') return providerOf(a.row).localeCompare(providerOf(b.row)) || a.index - b.index
161
+ return byName || providerOf(a.row).localeCompare(providerOf(b.row)) || a.index - b.index
162
+ })
163
+ .map(({ row }) => row)
164
+ }
165
+
90
166
  export function trendBars(series) {
91
167
  if (!series.length) return []
92
168
  const width = 720, height = 180, left = 28, right = 16, top = 12, bottom = 28
@@ -1,4 +1,4 @@
1
- export const viewIds = Object.freeze(['reports', 'checkpoints', 'todos'])
1
+ export const viewIds = Object.freeze(['reports', 'checkpoints', 'todos', 'metrics'])
2
2
 
3
3
  export function viewFromHash(hash) {
4
4
  const view = hash.startsWith('#') ? hash.slice(1) : hash
package/src/ui-server.js CHANGED
@@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url"
6
6
  import { homedir } from "node:os"
7
7
  import { createServer, request as httpRequest } from "node:http"
8
8
  import { clearCheckpoints, clearHabits, deleteCheckpoint, getData, storePath, updateCheckpoint } from "./store.js"
9
+ import { metricsPath, queryMetrics } from "./metrics.js"
10
+ import { pluginSnapshot, recordRequestStatus, recordServerError, recordSseClient, recordSseRefresh } from "./plugin-stats.js"
9
11
 
10
12
  const maxBody = 8192
11
13
  const distPath = join(fileURLToPath(new URL("../dist/", import.meta.url)))
@@ -16,19 +18,20 @@ export function createUiServer({ port = 0 } = {}) {
16
18
  let watcher, refreshTimer
17
19
  const server = createServer(async (req, res) => {
18
20
  const host = req.headers.host
19
- if (![ `127.0.0.1:${server.address()?.port}`, `localhost:${server.address()?.port}` ].includes(host)) { res.writeHead(403).end("Forbidden"); return }
21
+ if (![ `127.0.0.1:${server.address()?.port}`, `localhost:${server.address()?.port}` ].includes(host)) { recordRequestStatus(403); res.writeHead(403).end("Forbidden"); return }
20
22
  const method = req.method || "GET", url = new URL(req.url || "/", `http://${host}`)
21
23
  if (url.pathname === controlPath) {
22
24
  const supplied = req.headers["x-habit-token"]
23
25
  const authorized = ["GET", "POST"].includes(method) && req.headers.origin === `http://${host}` && typeof supplied === "string" && Buffer.byteLength(supplied) === Buffer.byteLength(token) && timingSafeEqual(Buffer.from(supplied), Buffer.from(token))
24
- if (!authorized) { res.writeHead(403).end(); return }
26
+ if (!authorized) { recordRequestStatus(403); res.writeHead(403).end(); return }
27
+ recordRequestStatus(200)
25
28
  if (method === "GET") { res.writeHead(200, { "cache-control": "no-store" }).end("active"); return }
26
29
  res.writeHead(200, { "cache-control": "no-store" }).end("stopping")
27
30
  setImmediate(() => { server.closeStreams(); server.close() })
28
31
  return
29
32
  }
30
33
  const securityHeaders = { "x-content-type-options": "nosniff", "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'", "referrer-policy": "no-referrer" }
31
- const send = (status, payload, type = "application/json; charset=utf-8") => { res.writeHead(status, { "content-type": type, "cache-control": "no-store", ...securityHeaders }); res.end(payload) }
34
+ const send = (status, payload, type = "application/json; charset=utf-8") => { recordRequestStatus(status); res.writeHead(status, { "content-type": type, "cache-control": "no-store", ...securityHeaders }); res.end(payload) }
32
35
  if (method === "GET" && url.pathname === "/") {
33
36
  try { const html = (await readFile(join(distPath, "index.html"), "utf8")).replace("<head>", `<head><meta name="habit-token" content="${token}">`); send(200, html, "text/html; charset=utf-8") }
34
37
  catch { send(503, "WebUI assets are missing (dist/index.html was not found). Run `npm run build` from the plugin directory to generate `dist/`.", "text/plain; charset=utf-8") }
@@ -45,16 +48,36 @@ export function createUiServer({ port = 0 } = {}) {
45
48
  catch { send(500, JSON.stringify({ error: "Unable to read local memory" })) }
46
49
  return
47
50
  }
51
+ if (method === "GET" && url.pathname === "/api/metrics") {
52
+ try {
53
+ const filters = {}
54
+ for (const key of ["from", "to", "project", "agent", "provider", "model", "kind"]) {
55
+ const value = url.searchParams.get(key)
56
+ if (value !== null && value !== "") filters[key] = value
57
+ }
58
+ let storeData
59
+ try { storeData = await getData() } catch {}
60
+ const result = queryMetrics(filters)
61
+ result.plugin = pluginSnapshot({ storeData, storePath, metricsPath })
62
+ send(200, JSON.stringify(result))
63
+ } catch (error) {
64
+ const invalid = error?.code === "INVALID_METRICS_QUERY"
65
+ send(invalid ? 400 : 500, JSON.stringify({ error: invalid ? "Invalid metrics query" : "Unable to read metrics" }))
66
+ }
67
+ return
68
+ }
48
69
  if (method === "GET" && url.pathname === "/api/events") {
70
+ recordRequestStatus(200)
49
71
  res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-store", connection: "keep-alive", ...securityHeaders })
50
72
  res.write(": connected\n\n")
51
73
  clients.add(res)
74
+ recordSseClient(1)
52
75
  const heartbeat = setInterval(() => {
53
76
  if (res.writableEnded) return
54
77
  try { res.write(": ping\n\n") } catch { res.end() }
55
78
  }, 30_000)
56
79
  heartbeat.unref?.()
57
- res.on("close", () => { clearInterval(heartbeat); clients.delete(res) })
80
+ res.on("close", () => { clearInterval(heartbeat); if (clients.delete(res)) recordSseClient(-1) })
58
81
  return
59
82
  }
60
83
  if (method !== "POST" || !["/api/checkpoint/edit", "/api/checkpoint/delete", "/api/scope/delete", "/api/habits/clear"].includes(url.pathname)) { send(404, JSON.stringify({ error: "Not found" })); return }
@@ -78,25 +101,29 @@ export function createUiServer({ port = 0 } = {}) {
78
101
  send(changed ? 200 : 404, JSON.stringify({ changed }))
79
102
  } catch { send(400, JSON.stringify({ error: "Invalid request" })) }
80
103
  })
81
- server.on("clientError", (_error, socket) => socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"))
104
+ server.on("clientError", (_error, socket) => { recordRequestStatus(400); socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n") })
82
105
  try {
83
106
  const directory = dirname(storePath)
107
+ const metricsName = basename(metricsPath)
108
+ // -shm is excluded: read-only metrics queries update it and would self-trigger SSE.
109
+ const watched = new Set([basename(storePath), metricsName, `${metricsName}-wal`])
84
110
  mkdirSync(directory, { recursive: true, mode: 0o700 })
85
111
  watcher = watch(directory, (_event, filename) => {
86
- if (filename && filename !== basename(storePath)) return
112
+ if (filename && !watched.has(filename)) return
87
113
  if (refreshTimer) return
88
114
  refreshTimer = setTimeout(() => {
89
115
  refreshTimer = undefined
116
+ if (clients.size) recordSseRefresh()
90
117
  for (const client of clients) {
91
- if (client.writableEnded) { clients.delete(client); continue }
92
- try { client.write("data: refresh\n\n") } catch { clients.delete(client) }
118
+ if (client.writableEnded) { if (clients.delete(client)) recordSseClient(-1); continue }
119
+ try { client.write("data: refresh\n\n") } catch { if (clients.delete(client)) recordSseClient(-1); recordServerError() }
93
120
  }
94
121
  }, 100)
95
122
  })
96
123
  watcher.unref?.()
97
124
  watcher.on("error", () => { watcher?.close(); watcher = undefined })
98
125
  } catch {}
99
- server.closeStreams = () => { for (const client of clients) { try { client.end() } catch {} } clients.clear() }
126
+ server.closeStreams = () => { const count = clients.size; for (const client of clients) { try { client.end() } catch {} } clients.clear(); if (count) recordSseClient(-count) }
100
127
  server.on("close", () => { clearTimeout(refreshTimer); watcher?.close(); server.closeStreams() })
101
128
  server.habitToken = token
102
129
  return server.listen(port, "127.0.0.1")
package/src/ui.css CHANGED
@@ -8,6 +8,9 @@
8
8
  --brand: #59d6c8;
9
9
  --danger: #ed8589;
10
10
  --series-commands: #b69af7;
11
+ --series-input: #7cc7ff;
12
+ --series-cache: #59d6c8;
13
+ --series-output: #f0b86e;
11
14
  --line: color-mix(in srgb, var(--ink) 12%, transparent);
12
15
  --line-soft: color-mix(in srgb, var(--ink) 7%, transparent);
13
16
  --focus: var(--brand);
@@ -26,6 +29,9 @@
26
29
  --brand: #087d75;
27
30
  --danger: #a8323c;
28
31
  --series-commands: #6045a8;
32
+ --series-input: #0b5f8a;
33
+ --series-cache: #087d75;
34
+ --series-output: #8a4b00;
29
35
  --line: color-mix(in srgb, var(--ink) 17%, transparent);
30
36
  --line-soft: color-mix(in srgb, var(--ink) 10%, transparent);
31
37
  }
@@ -47,7 +53,7 @@ button, select { color: inherit; font: inherit; }
47
53
  }
48
54
  .brand { color: var(--ink); font-size: 1rem; font-weight: 750; letter-spacing: -.035em; text-decoration: none; white-space: nowrap; }
49
55
  .brand::before { content: "HM"; display: inline-grid; width: 1.75rem; height: 1.75rem; margin-right: .55rem; place-items: center; border: 1px solid color-mix(in srgb, var(--brand) 55%, transparent); border-radius: .35rem; color: var(--brand); font-size: .65rem; letter-spacing: .02em; vertical-align: -.2rem; }
50
- .sidebar-nav { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; flex: none; gap: .25rem; padding: .2rem; border: 1px solid var(--line-soft); border-radius: .45rem; background: var(--surface); }
56
+ .sidebar-nav { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); width: 100%; flex: none; gap: .25rem; padding: .2rem; border: 1px solid var(--line-soft); border-radius: .45rem; background: var(--surface); }
51
57
  .sidebar-nav a { flex: 1; min-width: 0; padding: .45rem .55rem; border-radius: .3rem; color: var(--muted); font-size: .82rem; font-weight: 650; overflow-wrap: anywhere; text-align: center; text-decoration: none; }
52
58
  .sidebar-nav a:hover, .sidebar-nav a:focus-visible { color: var(--ink); background: var(--surface-raised); }
53
59
  .sidebar-nav a[aria-current="page"] { color: color-mix(in srgb, var(--brand) 90%, var(--ink)); background: var(--surface-raised); }
@@ -103,6 +109,54 @@ h3 { display: flex; align-items: flex-start; justify-content: space-between; gap
103
109
  .trend-chart .tools-series { fill: var(--brand); }
104
110
  .trend-chart .commands-series { fill: var(--series-commands); }
105
111
 
112
+ .metrics-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(10.5rem, 1fr)); align-items: end; min-width: 0; gap: .65rem; margin: 1rem 0 1.2rem; padding: .75rem; border: 1px solid var(--line); border-radius: .4rem; background: var(--surface); }
113
+ .metrics-filters legend { padding: 0 .3rem; color: var(--brand); font-size: .68rem; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
114
+ .metrics-filter { display: grid; gap: .28rem; min-width: 0; color: var(--muted); font-size: .72rem; font-weight: 650; }
115
+ .metrics-filter select { width: 100%; min-width: 0; }
116
+ .metrics-reset { align-self: end; }
117
+ .metrics-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr)); gap: .6rem; margin-bottom: .8rem; }
118
+ .metric-card { display: grid; gap: .25rem; min-width: 0; padding: .75rem .8rem; border: 1px solid var(--line); border-radius: .4rem; background: var(--surface); color: var(--muted); font-size: .72rem; }
119
+ .metric-card span { font-weight: 650; overflow-wrap: anywhere; }
120
+ .metric-card strong { color: var(--ink); font-size: 1.35rem; font-variant-numeric: tabular-nums; line-height: 1.1; overflow-wrap: anywhere; }
121
+ .metric-card small { color: var(--muted); font-size: .68rem; overflow-wrap: anywhere; }
122
+ .savings-panel { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .4rem .8rem; padding: .8rem .9rem; border: 1px solid color-mix(in srgb, var(--brand) 38%, var(--line)); border-radius: .4rem; background: var(--surface); }
123
+ .savings-panel h3 { display: block; }
124
+ .savings-panel .section-note { margin: .2rem 0 0; }
125
+ .savings-rate { color: var(--brand); font-size: 1.8rem; font-variant-numeric: tabular-nums; line-height: 1; }
126
+ .savings-detail { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: .72rem; overflow-wrap: anywhere; }
127
+ .metrics-charts, .metrics-tables { display: grid; grid-template-columns: minmax(0, 1fr); gap: .65rem; margin-top: .8rem; }
128
+ .metrics-tables-note { margin-top: 1.2rem; }
129
+ .metrics-table-panel { min-width: 0; padding: .8rem .85rem; border: 1px solid var(--line); border-radius: .4rem; background: var(--surface); overflow-x: auto; }
130
+ .metrics-table { width: 100%; border-collapse: collapse; font-size: .76rem; }
131
+ .metrics-table caption { padding: 0 0 .55rem; color: var(--ink); font-size: .84rem; font-weight: 680; text-align: left; }
132
+ .metrics-table th, .metrics-table td { padding: .5rem .45rem; border-top: 1px solid var(--line-soft); text-align: left; vertical-align: top; }
133
+ .metrics-table thead th { color: var(--muted); font-size: .68rem; font-weight: 750; letter-spacing: .05em; text-transform: uppercase; }
134
+ .metrics-table thead th[aria-sort="ascending"], .metrics-table thead th[aria-sort="descending"] { color: var(--ink); }
135
+ .sort-button { display: flex; align-items: center; gap: .22rem; width: 100%; min-height: 1.75rem; padding: .1rem 0; border: 0; background: none; color: inherit; font: inherit; letter-spacing: inherit; text-transform: inherit; }
136
+ .sort-button:hover:not(:disabled) { border-color: transparent; color: var(--ink); }
137
+ .sort-indicator { color: var(--brand); font-size: .95em; line-height: 1; }
138
+ .metrics-table tbody th { max-width: 16rem; font-weight: 650; overflow-wrap: anywhere; }
139
+ .metrics-table td { color: var(--muted); }
140
+ .metrics-table .numeric { color: var(--ink); font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
141
+ .row-sub { display: block; margin-top: .15rem; color: var(--muted); font-size: .68rem; font-weight: 400; overflow-wrap: anywhere; }
142
+ .project-hash { display: block; max-width: 100%; color: var(--muted); font-size: .66rem; overflow-wrap: anywhere; }
143
+ .metrics-empty { margin-top: .8rem; }
144
+ .plugin-health { margin-top: 2rem; padding-top: 1.2rem; border-top: 1px solid var(--line); }
145
+ .plugin-heading { margin-bottom: .8rem; }
146
+ .plugin-heading h3 { display: block; font-size: 1rem; }
147
+ .plugin-heading .section-note { margin-bottom: 0; }
148
+ .plugin-tables { margin-top: .8rem; }
149
+ .input-series { color: var(--series-input); }
150
+ .cache-series { color: var(--series-cache); }
151
+ .output-series { color: var(--series-output); }
152
+ .trend-chart .input-series { fill: var(--series-input); }
153
+ .trend-chart .cache-series { fill: var(--series-cache); }
154
+ .trend-chart .output-series { fill: var(--series-output); }
155
+
156
+ @media (max-width: 30rem) {
157
+ .savings-panel { grid-template-columns: minmax(0, 1fr); }
158
+ }
159
+
106
160
  .scope { min-width: 0; border: 1px solid var(--line); border-radius: .4rem; background: var(--surface); }
107
161
  .scope-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: .65rem; margin-top: .8rem; }
108
162
  .project-summary { display: flex; align-items: center; gap: .75rem; min-width: 0; padding: .8rem; cursor: pointer; }
@@ -158,6 +212,7 @@ button:disabled { opacity: .55; cursor: not-allowed; }
158
212
  @media (min-width: 68rem) {
159
213
  .report-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
160
214
  .scope-list { grid-template-columns: minmax(0, 1fr); }
215
+ .metrics-tables { grid-template-columns: repeat(2, minmax(0, 1fr)); }
161
216
  }
162
217
 
163
218
  @media (prefers-reduced-motion: reduce) {
@@ -1 +0,0 @@
1
- :root{color-scheme:dark;--canvas: #0b1119;--surface: #101923;--surface-raised: #141f2b;--ink: #dce6ed;--muted: #91a2b1;--brand: #59d6c8;--danger: #ed8589;--series-commands: #b69af7;--line: color-mix(in srgb, var(--ink) 12%, transparent);--line-soft: color-mix(in srgb, var(--ink) 7%, transparent);--focus: var(--brand);background:var(--canvas);color:var(--ink);font:14px/1.5 system-ui,sans-serif}:root[data-theme=light]{color-scheme:light;--canvas: #f2f6f8;--surface: #ffffff;--surface-raised: #e8eff2;--ink: #172733;--muted: #536a78;--brand: #087d75;--danger: #a8323c;--series-commands: #6045a8;--line: color-mix(in srgb, var(--ink) 17%, transparent);--line-soft: color-mix(in srgb, var(--ink) 10%, transparent)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--canvas);color:var(--ink)}button,select{color:inherit;font:inherit}.app-shell{min-height:100vh;background:var(--canvas)}.sidebar{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem 1rem;padding:.875rem 1rem .75rem;border-bottom:1px solid var(--line);background:var(--canvas)}.brand{color:var(--ink);font-size:1rem;font-weight:750;letter-spacing:-.035em;text-decoration:none;white-space:nowrap}.brand:before{content:"HM";display:inline-grid;width:1.75rem;height:1.75rem;margin-right:.55rem;place-items:center;border:1px solid color-mix(in srgb,var(--brand) 55%,transparent);border-radius:.35rem;color:var(--brand);font-size:.65rem;letter-spacing:.02em;vertical-align:-.2rem}.sidebar-nav{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));width:100%;flex:none;gap:.25rem;padding:.2rem;border:1px solid var(--line-soft);border-radius:.45rem;background:var(--surface)}.sidebar-nav a{flex:1;min-width:0;padding:.45rem .55rem;border-radius:.3rem;color:var(--muted);font-size:.82rem;font-weight:650;overflow-wrap:anywhere;text-align:center;text-decoration:none}.sidebar-nav a:hover,.sidebar-nav a:focus-visible{color:var(--ink);background:var(--surface-raised)}.sidebar-nav a[aria-current=page]{color:color-mix(in srgb,var(--brand) 90%,var(--ink));background:var(--surface-raised)}.local-note{flex:1 1 9rem;max-width:13rem;margin:0;color:var(--muted);font-size:.75rem}.workspace{min-width:0}.masthead{display:flex;flex-direction:column;align-items:flex-start;gap:1rem;padding:1.4rem 1rem 1.1rem;border-bottom:1px solid var(--line-soft)}.eyebrow{margin:0 0 .35rem;color:var(--brand);font-size:.68rem;font-weight:750;letter-spacing:.13em}h1{margin:0;font-size:clamp(1.55rem,5vw,2.1rem);font-weight:650;letter-spacing:-.045em;line-height:1.18;text-wrap:balance}.intro{margin:.3rem 0 0;color:var(--muted);font-size:.9rem}.masthead-controls{display:flex;align-items:center;gap:.5rem}.language{display:flex;align-items:center;gap:.45rem;color:var(--muted);font-size:.75rem;font-weight:650}.language select,.theme-toggle{min-height:2.25rem;padding:.35rem .6rem;border:1px solid var(--line);border-radius:.35rem;background:var(--surface);color:var(--ink);font-size:.78rem;font-weight:650}.theme-toggle{cursor:pointer}main{width:min(100%,88rem);margin-inline:auto;padding:1rem 1rem 3rem}.page-note,.section-note{color:var(--muted)}.page-note{max-width:60rem;margin:0 0 1rem;font-size:.8rem}.stat-strip{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.6rem;margin-bottom:1.8rem}.stat-card{display:flex;align-items:center;justify-content:space-between;gap:.5rem;min-width:0;min-height:3.8rem;padding:.65rem .8rem;border:1px solid var(--line);border-radius:.4rem;background:var(--surface);color:var(--muted);font-size:.76rem;font-weight:650}.stat-card span{min-width:0;overflow-wrap:anywhere}.stat-card strong{color:var(--brand);font-size:1.35rem;font-variant-numeric:tabular-nums;line-height:1}.checkpoint-summary{grid-template-columns:minmax(0,1fr);max-width:20rem;margin:.8rem 0 1.2rem}main>section{margin-top:2rem;scroll-margin-top:1rem}h2{display:flex;align-items:baseline;gap:.65rem;margin:0;font-size:1.15rem;font-weight:680;letter-spacing:-.025em;text-wrap:balance}h3{display:flex;align-items:flex-start;justify-content:space-between;gap:.5rem;margin:0;font-size:.86rem;font-weight:680;line-height:1.4;text-wrap:balance}.section-note{margin:.25rem 0 .8rem;font-size:.8rem}.notice{display:flex;flex-wrap:wrap;align-items:center;gap:.65rem;color:var(--danger)}.report-heading{display:flex;flex-direction:column;align-items:flex-start;gap:.5rem}.report-heading .section-note{max-width:58rem;margin-bottom:0}.include-archived{display:flex;align-items:center;gap:.5rem;min-height:2.5rem;color:var(--muted);font-size:.8rem;font-weight:650;cursor:pointer}.include-archived input{width:1rem;height:1rem;margin:0;accent-color:var(--brand)}.include-archived input:focus-visible{outline:2px solid var(--focus);outline-offset:3px}.report-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:.6rem;margin-top:.8rem}.report-card,.trend-panel{min-width:0;padding:.8rem .85rem;border:1px solid var(--line);border-radius:.4rem;background:var(--surface)}.report-card-head{display:flex;align-items:flex-start;justify-content:space-between;gap:.7rem}.report-total{flex:none;color:var(--brand);font-size:1.1rem;font-variant-numeric:tabular-nums}.report-ranking{margin:.55rem 0 0;padding:0;list-style:none}.report-ranking li{display:flex;justify-content:space-between;gap:.6rem;padding:.55rem 0;border-top:1px solid var(--line-soft)}.report-ranking li>strong{flex:none;color:var(--brand);font-variant-numeric:tabular-nums}.report-label{min-width:0;overflow-wrap:anywhere}.report-label code{display:block;margin-top:.15rem;color:var(--muted);font-size:.7rem}.empty-report{margin:.7rem 0 0;color:var(--muted);font-size:.8rem}.trend-panel{margin-top:.65rem}.trend-legend{display:flex;flex-wrap:wrap;gap:.35rem 1rem;margin:.65rem 0 .2rem;padding:0;color:var(--muted);font-size:.72rem;list-style:none}.trend-legend li:before{display:inline-block;width:.48rem;height:.48rem;margin-right:.35rem;border-radius:.1rem;background:currentColor;content:""}.tools-series{color:var(--brand)}.commands-series{color:var(--series-commands)}.trend-chart{display:block;width:100%;max-width:100%;height:auto;overflow:hidden}.trend-chart .chart-axis{stroke:var(--line);stroke-width:1}.trend-chart text{fill:var(--muted);font:10px system-ui,sans-serif}.trend-chart rect{rx:1px}.trend-chart .tools-series{fill:var(--brand)}.trend-chart .commands-series{fill:var(--series-commands)}.scope{min-width:0;border:1px solid var(--line);border-radius:.4rem;background:var(--surface)}.scope-list{display:grid;grid-template-columns:minmax(0,1fr);gap:.65rem;margin-top:.8rem}.project-summary{display:flex;align-items:center;gap:.75rem;min-width:0;padding:.8rem;cursor:pointer}.project-summary:after{margin-left:auto;color:var(--muted);content:"⌄";font-size:1.2rem}.scope[open]>.project-summary:after{content:"⌃"}.project-summary:focus-visible{outline:2px solid var(--focus);outline-offset:-2px}.scope-provenance{display:grid;min-width:0;gap:.4rem}.scope-caption{display:flex;align-items:center;gap:.4rem;margin:0;color:var(--brand);font-size:.67rem;font-weight:750;letter-spacing:.09em;text-transform:uppercase}.scope-caption:before{content:"";width:.35rem;height:.35rem;border-radius:50%;background:var(--brand)}.scope-caption .scope-directory{min-width:0;color:var(--ink);font-size:.82rem;letter-spacing:normal;text-transform:none;overflow-wrap:anywhere}code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.project-summary code,.entry-context code{display:inline-block;max-width:100%;padding:.15rem .35rem;border:1px solid var(--line-soft);border-radius:.25rem;background:var(--canvas);font-size:.77rem;overflow-wrap:anywhere}.project-checkpoints{border-top:1px solid var(--line-soft)}.todo-status-list{display:grid;padding:.7rem .8rem;border-top:1px solid var(--line-soft)}.todo-status+.todo-status{padding-top:.65rem;border-top:1px solid var(--line-soft)}.todo-status-heading{color:var(--muted);font-size:.7rem;letter-spacing:.08em;text-transform:uppercase}.todo-status-heading span:last-child{color:var(--brand);font-variant-numeric:tabular-nums}.todo-items{margin:.35rem 0 0;padding:0;list-style:none}.todo-items li{padding:.5rem 0;border-top:1px solid var(--line-soft);overflow-wrap:anywhere;white-space:pre-wrap}.entry{display:flex;flex-direction:column;align-items:flex-start;gap:.65rem;padding:.8rem}.entry+.entry{border-top:1px solid var(--line-soft)}.entry-content{min-width:0}.entry-title{display:block;margin:0 0 .25rem;overflow-wrap:anywhere}.entry-summary{margin:0 0 .3rem;white-space:pre-wrap;overflow-wrap:anywhere}.entry-context{display:flex;flex-wrap:wrap;align-items:center;gap:.3rem;margin:0 0 .25rem;color:var(--muted);font-size:.72rem}time{color:var(--muted);font-size:.72rem}.entry-actions{display:flex;flex-wrap:wrap;gap:.4rem}button,select{min-height:2.35rem;padding:.4rem .65rem;border:1px solid var(--line);border-radius:.32rem;background:var(--surface-raised);color:var(--ink);cursor:pointer}button{font-size:.76rem;font-weight:650}button:hover:not(:disabled),select:hover{border-color:color-mix(in srgb,var(--brand) 65%,var(--line))}.action-edit{border-color:color-mix(in srgb,var(--brand) 38%,var(--line));color:var(--brand)}.action-delete{border-color:color-mix(in srgb,var(--danger) 46%,var(--line));color:var(--danger)}.action-delete:hover:not(:disabled){background:color-mix(in srgb,var(--danger) 10%,var(--surface))}button:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid var(--focus);outline-offset:2px}button:disabled{opacity:.55;cursor:not-allowed}@media(min-width:45rem){.app-shell{display:grid;grid-template-columns:13.5rem minmax(0,1fr)}.sidebar{position:sticky;top:0;display:flex;flex-direction:column;flex-wrap:nowrap;align-items:stretch;gap:1.5rem;height:100vh;min-height:32rem;padding:1.5rem 1rem 1rem;border-right:1px solid var(--line);border-bottom:0}.brand{font-size:1.05rem}.sidebar-nav{display:flex;flex:none;flex-direction:column;gap:.25rem;padding:.25rem}.sidebar-nav a{flex:none;padding:.62rem .65rem;text-align:left}.local-note{flex:none;max-width:none;margin:auto 0 0}.masthead{flex-direction:row;align-items:center;justify-content:space-between;gap:1rem;padding:1.6rem 1.75rem 1.2rem}main{padding:1.25rem 1.75rem 3rem}.report-heading{flex-direction:row;align-items:center;justify-content:space-between}.report-heading>button{flex:none}.entry{flex-direction:row;justify-content:space-between}.entry-actions{flex:none}}@media(min-width:68rem){.report-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.scope-list{grid-template-columns:minmax(0,1fr)}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{animation-duration:.01ms!important;transition-duration:.01ms!important}}