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
|
@@ -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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
package/src/ui-navigation.js
CHANGED
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
|
|
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(
|
|
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}}
|