ohmyjev 0.3.1 → 0.3.2

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.
Files changed (2) hide show
  1. package/hooks/dashboard.ts +225 -0
  2. package/package.json +2 -1
@@ -0,0 +1,225 @@
1
+ /**
2
+ * /omj stats: one static HTML page from every session's decision log, in ohmyjev.xyz's theme. Pure: the hosts read the
3
+ * logs, write the page to ~/.ohmyjev/dashboard.html and open it. Nothing on the page leaves the machine except the
4
+ * four font files, fetched from ohmyjev.xyz (system fonts stand in when offline).
5
+ */
6
+ import type { LogEntry } from './policy.ts'
7
+
8
+ const FONTS = 'https://ohmyjev.xyz/fonts'
9
+ const DAYS = 30
10
+
11
+ export const esc = (s: unknown): string =>
12
+ String(s ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]!)
13
+
14
+ const day = (ts: number): string => new Date(ts).toISOString().slice(0, 10)
15
+ const when = (ts: number): string => new Date(ts).toISOString().replace('T', ' ').slice(0, 16)
16
+ const money = (usd: number): string => `$${usd.toFixed(usd >= 0.01 ? 2 : 4)}`
17
+ const pct = (n: number, of: number): number => (of ? Math.round((n / of) * 100) : 0)
18
+ const quantile = (sorted: number[], q: number): number => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 0
19
+ /** What a line was about: the tool, or the battery for lines with no tool. */
20
+ const what = (r: LogEntry): string =>
21
+ r.tool || ({ 'turn.start': 'router', Stop: 'done-check', 'session.measure': 'auto-compact', 'tool.result': 'screen', ask_jev: 'ask_jev' } as Record<string, string>)[r.event] || r.event
22
+
23
+ type Stat = { calls: number; denies: number; flags: number; cost: number }
24
+ const stat = (): Stat => ({ calls: 0, denies: 0, flags: 0, cost: 0 })
25
+ const isDeny = (r: LogEntry) => r.verdict === 'deny' || r.verdict === 'block'
26
+
27
+ export function dashboardHtml(rows: LogEntry[], opts: { now: number; sid?: string; source?: string }): string {
28
+ const calls = rows.filter(r => r.answers)
29
+ const ms = calls.map(r => r.ms ?? 0).sort((a, b) => a - b)
30
+ const denies = rows.filter(isDeny)
31
+ const total = { ...stat(), calls: calls.length, denies: denies.length, flags: rows.filter(r => r.verdict === 'flag').length, cost: calls.reduce((n, r) => n + (r.costUsd ?? 0), 0) }
32
+ const errors = rows.filter(r => r.error).length
33
+ const compactions = rows.filter(r => r.verdict === 'compact').length
34
+
35
+ const by = <K extends string>(key: (r: LogEntry) => K): Map<K, Stat> => {
36
+ const m = new Map<K, Stat>()
37
+ for (const r of rows) {
38
+ const s = m.get(key(r)) ?? stat()
39
+ if (r.answers) s.calls++
40
+ if (isDeny(r)) s.denies++
41
+ if (r.verdict === 'flag') s.flags++
42
+ s.cost += r.costUsd ?? 0
43
+ m.set(key(r), s)
44
+ }
45
+ return m
46
+ }
47
+ const tools = [...by(what)].sort((a, b) => b[1].denies - a[1].denies || b[1].calls - a[1].calls)
48
+ const days = by(r => day(r.ts))
49
+ const span = Array.from({ length: DAYS }, (_, i) => day(opts.now - (DAYS - 1 - i) * 86_400_000))
50
+ const peak = Math.max(1, ...span.map(d => days.get(d)?.calls ?? 0))
51
+ const sessions = new Map<string, Stat & { first: number; last: number }>()
52
+ for (const r of rows) {
53
+ const s = sessions.get(r.session) ?? { ...stat(), first: r.ts, last: r.ts }
54
+ s.first = Math.min(s.first, r.ts)
55
+ s.last = Math.max(s.last, r.ts)
56
+ if (r.answers) s.calls++
57
+ if (isDeny(r)) s.denies++
58
+ if (r.verdict === 'flag') s.flags++
59
+ s.cost += r.costUsd ?? 0
60
+ sessions.set(r.session, s)
61
+ }
62
+ const recentSessions = [...sessions].sort((a, b) => b[1].last - a[1].last).slice(0, 15)
63
+ const recentDenies = denies.slice(-25).reverse()
64
+
65
+ const tile = (n: string, label: string, kind = '') =>
66
+ `<div class="feat"><span class="kind ${kind}">${esc(label)}</span><strong>${esc(n)}</strong></div>`
67
+ const bars = span
68
+ .map(d => {
69
+ const s = days.get(d) ?? stat()
70
+ const h = Math.max(s.calls ? 3 : 1, Math.round((s.calls / peak) * 100))
71
+ const dh = s.calls ? Math.max(s.denies ? 3 : 0, Math.round((s.denies / peak) * 100)) : 0
72
+ return `<div class="bar" title="${esc(d)}: ${s.calls} calls, ${s.denies} denies, ${money(s.cost)}"><i style="height:${h}%"></i><b style="height:${dh}%"></b></div>`
73
+ })
74
+ .join('')
75
+ const toolRows = tools
76
+ .map(([t, s]) => `<tr><td><code>${esc(t)}</code></td><td>${s.calls}</td><td class="${s.denies ? 'o' : ''}">${s.denies}</td><td>${s.flags}</td><td>${money(s.cost)}</td></tr>`)
77
+ .join('')
78
+ const sessionRows = recentSessions
79
+ .map(([sid, s]) => `<tr${sid === opts.sid ? ' class="me"' : ''}><td><code>${esc(sid.slice(0, 8))}</code>${sid === opts.sid ? ' <span class="g">this</span>' : ''}</td><td>${when(s.last)}</td><td>${s.calls}</td><td class="${s.denies ? 'o' : ''}">${s.denies}</td><td>${money(s.cost)}</td></tr>`)
80
+ .join('')
81
+ const denyRows = recentDenies
82
+ .map(r => `<tr><td>${when(r.ts)}</td><td><code>${esc(what(r))}</code></td><td>${esc(r.reason ?? '')}</td><td><code>${esc(r.session.slice(0, 8))}</code></td></tr>`)
83
+ .join('')
84
+ const lede = rows.length
85
+ ? `${total.calls} Jev calls across ${sessions.size} sessions for ${money(total.cost)}. ${total.denies} ${total.denies === 1 ? 'deny' : 'denies'} (${pct(total.denies, total.calls + total.denies)}%), ${total.flags} injection ${total.flags === 1 ? 'flag' : 'flags'}, ${errors} ${errors === 1 ? 'error' : 'errors'}.`
86
+ : 'No decisions logged yet. Run a few commands with ohmyjev on, then come back.'
87
+
88
+ return `<!doctype html>
89
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
90
+ <title>ohmyjev · stats</title>
91
+ <style>
92
+ @font-face { font-family: "Changa"; src: url("${FONTS}/Changa-ExtraBold.woff2") format("woff2"); font-weight: 800; font-display: swap; }
93
+ @font-face { font-family: "Space Grotesk"; src: url("${FONTS}/SpaceGrotesk-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
94
+ @font-face { font-family: "Space Grotesk"; src: url("${FONTS}/SpaceGrotesk-SemiBold.woff2") format("woff2"); font-weight: 600; font-display: swap; }
95
+ @font-face { font-family: "Space Grotesk"; src: url("${FONTS}/SpaceGrotesk-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
96
+ @font-face { font-family: "JetBrains Mono"; src: url("${FONTS}/JetBrainsMono-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
97
+ :root {
98
+ --ground: #080b09; --surface: #111713; --panel: rgba(17, 23, 19, .9); --panel-hi: rgba(26, 36, 29, .94);
99
+ --paper: #f4f8f5; --muted: #a5b5a9; --faint: #829487; --line: #2c3930; --steel: #829487;
100
+ --brand: #00ee22; --brand-hi: #39ff53; --link: #79ff98; --lavender: #b69cff;
101
+ --ok: #56e887; --warn: #ffc857; --err: #ff737d; --info: #74c7ff;
102
+ --ui: clamp(.875rem, .82rem + .15vw, 1rem);
103
+ --logo: "Changa", "Space Grotesk", system-ui, sans-serif; --display: "Space Grotesk", system-ui, sans-serif;
104
+ --body: "Space Grotesk", system-ui, sans-serif; --label: "Space Grotesk", system-ui, sans-serif;
105
+ --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
106
+ color-scheme: dark;
107
+ }
108
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
109
+ html { background: var(--ground); scrollbar-color: var(--line) var(--ground); }
110
+ body {
111
+ min-height: 100vh; padding: clamp(26px, 4.5vh, 64px) clamp(22px, 6vw, 120px) 80px; color: var(--paper); font-family: var(--body);
112
+ -webkit-font-smoothing: antialiased; background: var(--ground);
113
+ background-image: linear-gradient(rgba(130, 148, 135, .07) 1px, transparent 1px), linear-gradient(90deg, rgba(130, 148, 135, .07) 1px, transparent 1px);
114
+ background-size: 64px 64px;
115
+ }
116
+ ::selection { background: rgba(0, 238, 34, .3); color: var(--paper); }
117
+ a { color: inherit; text-decoration: none; }
118
+ :focus-visible { outline: 2px solid var(--link); outline-offset: 3px; }
119
+ code { font-family: var(--mono); font-size: .875em; color: var(--paper); }
120
+ .lbl { font-family: var(--label); font-weight: 600; font-size: var(--ui); letter-spacing: .06em; text-transform: uppercase; }
121
+ .header { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: clamp(28px, 6vh, 64px); }
122
+ .brand { font: 800 30px/1 var(--logo); letter-spacing: -.01em; color: var(--paper); white-space: nowrap; }
123
+ .brand .acc { color: var(--brand); }
124
+ .header .lbl { color: var(--faint); }
125
+ .header .lbl span { color: var(--brand); }
126
+ .section-label { display: flex; align-items: center; gap: 10px; color: var(--paper); margin-bottom: 12px; }
127
+ .section-label::before { content: ""; width: 6px; height: 6px; background: var(--brand); }
128
+ .section-label span { margin-left: 8px; color: var(--faint); }
129
+ h2 { color: var(--paper); font: 600 clamp(1.75rem, 1.3rem + 1.2vw, 2.25rem)/1.1 var(--display); letter-spacing: -.02em; text-transform: lowercase; text-wrap: balance; }
130
+ h2 em { font-style: inherit; color: var(--brand); }
131
+ .lede { max-width: 62ch; color: var(--muted); font-size: clamp(1rem, .95rem + .2vw, 1.125rem); line-height: 1.5; margin: 14px 0 clamp(22px, 4vh, 40px); }
132
+ h3 { font: 600 var(--ui) var(--label); letter-spacing: .06em; text-transform: uppercase; color: var(--paper); }
133
+ section { margin-bottom: clamp(24px, 4vh, 44px); }
134
+ .grid { display: grid; gap: 1px; background: var(--line); border: 1px solid var(--line); }
135
+ .grid > * { position: relative; display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: clamp(14px, 2vh, 22px) clamp(14px, 1.4vw, 22px); background: var(--panel); transition: background .25s ease; }
136
+ .grid > *:hover { background: var(--panel-hi); }
137
+ .tiles { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
138
+ /* tiles draw their own hairlines, so a short last row ends in panel, not a block of line color */
139
+ .tiles { background: var(--panel); }
140
+ .tiles > * { box-shadow: 1px 0 0 var(--line), 0 1px 0 var(--line); }
141
+ .g2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); }
142
+ .kind { font: 600 .75rem var(--label); letter-spacing: .08em; text-transform: uppercase; color: var(--faint); display: inline-flex; align-items: center; gap: 6px; }
143
+ .kind::before { content: ""; width: 5px; height: 5px; background: var(--steel); }
144
+ .kind.gate::before { background: var(--err); }
145
+ .kind.check::before { background: var(--lavender); }
146
+ .kind.route::before { background: var(--info); }
147
+ .feat strong { font: 700 clamp(1.6rem, 2.4vw, 2.2rem)/1.1 var(--display); letter-spacing: -.03em; color: var(--paper); font-variant-numeric: tabular-nums; }
148
+ .card-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; }
149
+ .card-head .meta { color: var(--faint); font: 600 .75rem var(--label); letter-spacing: .06em; text-transform: uppercase; }
150
+ .chart { display: flex; align-items: flex-end; gap: 3px; height: 160px; padding-top: 8px; border-bottom: 1px solid var(--line); }
151
+ .bar { position: relative; flex: 1; height: 100%; min-width: 0; }
152
+ .bar i, .bar b { position: absolute; left: 0; right: 0; bottom: 0; display: block; }
153
+ .bar i { background: rgba(0, 238, 34, .45); transition: background .2s ease; }
154
+ .bar b { background: var(--err); }
155
+ .bar:hover i { background: var(--brand-hi); }
156
+ .axis { display: flex; justify-content: space-between; color: var(--faint); font: .75rem var(--mono); padding-top: 6px; }
157
+ .legend { display: flex; gap: 18px; color: var(--faint); font: 600 .75rem var(--label); letter-spacing: .06em; text-transform: uppercase; }
158
+ .legend i { display: inline-block; width: 10px; height: 10px; vertical-align: -1px; margin-right: 6px; background: rgba(0, 238, 34, .45); }
159
+ .legend i.o { background: var(--err); }
160
+ .tbl { overflow: auto; border: 1px solid var(--line); scrollbar-width: thin; }
161
+ table { width: 100%; border-collapse: collapse; }
162
+ th { padding: 7px 14px; text-align: left; font: 600 .75rem var(--label); letter-spacing: .08em; text-transform: uppercase; color: var(--faint); border-bottom: 1px solid var(--line); background: var(--panel); white-space: nowrap; }
163
+ td { padding: 6px 14px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: .9375rem; line-height: 1.35; vertical-align: top; background: rgba(17, 23, 19, .82); font-variant-numeric: tabular-nums; }
164
+ tr:last-child td { border-bottom: 0; }
165
+ tr:hover td { background: var(--panel-hi); }
166
+ td:first-child, td:nth-child(2) { white-space: nowrap; }
167
+ td.o { color: var(--err); }
168
+ .g { color: var(--ok); }
169
+ .me td { background: rgba(0, 238, 34, .06); }
170
+ .empty { padding: 18px 14px; color: var(--faint); }
171
+ .foot { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 24px; margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--line); color: var(--faint); font-size: .875rem; }
172
+ .foot a { color: var(--link); }
173
+ .foot a:hover { color: var(--brand-hi); }
174
+ .foot a span { color: var(--brand); }
175
+ </style></head>
176
+ <body>
177
+ <header class="header">
178
+ <a class="brand" href="https://ohmyjev.xyz" aria-label="ohmyjev"><span class="acc">&gt;_</span>ohmy<span class="acc">jev</span></a>
179
+ <div class="lbl">stats <span>·</span> ${esc(when(opts.now))} UTC${opts.source ? ` <span>·</span> ${esc(opts.source)}` : ''}</div>
180
+ </header>
181
+ <p class="section-label lbl">What Jev decided <span>every session on this machine</span></p>
182
+ <h2>what jev <em>decided</em></h2>
183
+ <p class="lede">${esc(lede)}</p>
184
+
185
+ <section><div class="grid tiles">
186
+ ${tile(String(total.calls), 'Jev calls')}
187
+ ${tile(String(total.denies), 'denies', 'gate')}
188
+ ${tile(String(total.flags), 'injection flags', 'check')}
189
+ ${tile(money(total.cost), 'cost')}
190
+ ${tile(`${quantile(ms, 0.5)} ms`, 'p50 latency')}
191
+ ${tile(`${quantile(ms, 0.95)} ms`, 'p95 latency')}
192
+ ${tile(String(sessions.size), 'sessions')}
193
+ ${tile(String(errors), 'errors')}
194
+ ${tile(String(compactions), 'auto-compactions', 'route')}
195
+ </div></section>
196
+
197
+ <section><div class="grid"><div>
198
+ <div class="card-head"><h3>last ${DAYS} days</h3><span class="legend"><span><i></i>calls</span><span><i class="o"></i>denies</span></span></div>
199
+ <div class="chart">${bars}</div>
200
+ <div class="axis"><span>${esc(span[0])}</span><span>${esc(span.at(-1))}</span></div>
201
+ </div></div></section>
202
+
203
+ <section><div class="grid g2">
204
+ <div>
205
+ <div class="card-head"><h3>by tool</h3><span class="meta">most denied first</span></div>
206
+ <div class="tbl">${toolRows ? `<table><thead><tr><th>tool</th><th>calls</th><th>denies</th><th>flags</th><th>cost</th></tr></thead><tbody>${toolRows}</tbody></table>` : '<p class="empty">nothing yet</p>'}</div>
207
+ </div>
208
+ <div>
209
+ <div class="card-head"><h3>sessions</h3><span class="meta">latest ${recentSessions.length} of ${sessions.size}</span></div>
210
+ <div class="tbl">${sessionRows ? `<table><thead><tr><th>session</th><th>last seen (UTC)</th><th>calls</th><th>denies</th><th>cost</th></tr></thead><tbody>${sessionRows}</tbody></table>` : '<p class="empty">nothing yet</p>'}</div>
211
+ </div>
212
+ </div></section>
213
+
214
+ <section><div class="grid"><div>
215
+ <div class="card-head"><h3>recent denies</h3><span class="meta">latest ${recentDenies.length} of ${denies.length}</span></div>
216
+ <div class="tbl">${denyRows ? `<table><thead><tr><th>when (UTC)</th><th>tool</th><th>reason</th><th>session</th></tr></thead><tbody>${denyRows}</tbody></table>` : '<p class="empty">no denies</p>'}</div>
217
+ </div></div></section>
218
+
219
+ <footer class="foot">
220
+ <span>Read from ~/.ohmyjev/log, readable only by you. Nothing on this page leaves the machine.</span>
221
+ <span>Regenerate with <code>/omj stats</code> · <a href="https://ohmyjev.xyz">ohmyjev<span>.</span>xyz</a></span>
222
+ </footer>
223
+ </body></html>
224
+ `
225
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ohmyjev",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Guardrails for coding agents, decided by TypeSafe's Jev: blocks destructive commands, catches prompt injection, pushes back on an unverified done and routes effort per turn. For Claude Code, pi and omp.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,6 +19,7 @@
19
19
  ],
20
20
  "files": [
21
21
  "adapters/*.ts",
22
+ "hooks/dashboard.ts",
22
23
  "hooks/jev.ts",
23
24
  "hooks/policy.ts",
24
25
  "extras/"