@polderlabs/bizar 5.1.0 → 5.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.
Files changed (50) hide show
  1. package/bizar-dash/dist/assets/{MobileChat-BVojK0n5.js → MobileChat-TCputYzr.js} +1 -1
  2. package/bizar-dash/dist/assets/{MobileChat-BVojK0n5.js.map → MobileChat-TCputYzr.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js → MobileSettings-BxcbL5XT.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js.map → MobileSettings-BxcbL5XT.js.map} +1 -1
  5. package/bizar-dash/dist/assets/{icons-Clz0NR6Y.js → icons-DRDXfbBP.js} +154 -134
  6. package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +1 -0
  7. package/bizar-dash/dist/assets/main-D5Ditnrd.js +19 -0
  8. package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +1 -0
  9. package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-xFpWMd32.css} +1 -1
  10. package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js → mobile--17fkfrl.js} +1 -1
  11. package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js.map → mobile--17fkfrl.js.map} +1 -1
  12. package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js → mobile-j3rOZK6v.js} +2 -2
  13. package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js.map → mobile-j3rOZK6v.js.map} +1 -1
  14. package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js → useSlashCommands-BG-DhEck.js} +2 -2
  15. package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js.map → useSlashCommands-BG-DhEck.js.map} +1 -1
  16. package/bizar-dash/dist/index.html +5 -5
  17. package/bizar-dash/dist/mobile.html +3 -3
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/src/server/api.mjs +4 -0
  20. package/bizar-dash/src/server/routes/plugins.mjs +11 -3
  21. package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
  22. package/bizar-dash/src/server/routes/voice.mjs +42 -6
  23. package/bizar-dash/src/server/server.mjs +13 -0
  24. package/bizar-dash/src/server/voice-store.mjs +27 -0
  25. package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
  26. package/bizar-dash/src/web/App.tsx +9 -0
  27. package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
  28. package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
  29. package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
  30. package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
  31. package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
  32. package/bizar-dash/src/web/components/Toggle.tsx +31 -0
  33. package/bizar-dash/src/web/components/Topbar.tsx +6 -0
  34. package/bizar-dash/src/web/styles/main.css +545 -0
  35. package/bizar-dash/src/web/views/Eval.tsx +172 -0
  36. package/bizar-dash/src/web/views/EvalReport.tsx +349 -0
  37. package/bizar-dash/src/web/views/Plugins.tsx +128 -0
  38. package/bizar-dash/src/web/views/Settings.tsx +2 -0
  39. package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
  40. package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
  41. package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
  42. package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
  43. package/cli/bin.mjs +14 -0
  44. package/cli/commands/dash.mjs +19 -1
  45. package/cli/commands/service.mjs +7 -0
  46. package/cli/commands/tailscale.mjs +251 -0
  47. package/package.json +1 -1
  48. package/bizar-dash/dist/assets/icons-Clz0NR6Y.js.map +0 -1
  49. package/bizar-dash/dist/assets/main-8feQWXiF.js +0 -19
  50. package/bizar-dash/dist/assets/main-8feQWXiF.js.map +0 -1
@@ -1 +1 @@
1
- {"version":"4.1.9","results":[[":tests/lib/utils.test.ts",{"duration":28.89169000000004,"failed":false}],[":tests/components/Button.test.tsx",{"duration":117.69011,"failed":false}],[":tests/components/Card.test.tsx",{"duration":30.92253400000004,"failed":false}],[":tests/hooks/useModal.test.tsx",{"duration":119.64900699999998,"failed":false}],[":tests/components/Toast.test.tsx",{"duration":159.04681100000005,"failed":false}],[":tests/components/Modal.test.tsx",{"duration":150.9410469999999,"failed":false}],[":tests/lib/i18n.test.ts",{"duration":3.2969249999999874,"failed":false}],[":tests/components/StatusBadge.test.tsx",{"duration":41.157249999999976,"failed":false}],[":tests/hooks/useToast.test.tsx",{"duration":104.85745599999996,"failed":false}],[":tests/components/Spinner.test.tsx",{"duration":70.16560200000004,"failed":false}],[":tests/backup-restore.test.tsx",{"duration":179.13565300000005,"failed":false}],[":tests/a11y.test.tsx",{"duration":715.4879470000001,"failed":false}],[":tests/lib/search-fuzzy.test.ts",{"duration":7.532295999999974,"failed":false}],[":tests/components/settings-search.test.tsx",{"duration":712.275263,"failed":false}],[":tests/memory-graph-view.test.tsx",{"duration":59.40099399999997,"failed":false}],[":tests/components/workspace-selector.test.tsx",{"duration":113.71114899999998,"failed":false}],[":tests/components/screenshot-ocr.test.tsx",{"duration":51.13657699999999,"failed":false}],[":tests/voice-recorder.test.tsx",{"duration":128.61647900000003,"failed":false}],[":tests/autosave.test.tsx",{"duration":251.98326499999996,"failed":false}],[":tests/settings-layout.test.tsx",{"duration":201.76037699999995,"failed":false}],[":tests/settings-nav.test.tsx",{"duration":228.42296299999998,"failed":false}],[":tests/components/doctor-panel.test.tsx",{"duration":67.53857800000003,"failed":false}],[":tests/chat-composer.test.tsx",{"duration":189.9785320000001,"failed":false}],[":tests/settings-mode-wiring.test.tsx",{"duration":205.661208,"failed":false}],[":tests/minimax-bar.test.ts",{"duration":0,"failed":true}],[":tests/minimax-bar.test.tsx",{"duration":30.342043999999987,"failed":false}]]}
1
+ {"version":"4.1.9","results":[[":tests/lib/utils.test.ts",{"duration":16.303165999999976,"failed":false}],[":tests/components/Button.test.tsx",{"duration":106.694435,"failed":false}],[":tests/components/Card.test.tsx",{"duration":19.81299100000001,"failed":false}],[":tests/hooks/useModal.test.tsx",{"duration":159.28117799999995,"failed":false}],[":tests/components/Toast.test.tsx",{"duration":196.709385,"failed":false}],[":tests/components/Modal.test.tsx",{"duration":196.62032399999998,"failed":false}],[":tests/lib/i18n.test.ts",{"duration":3.903647000000035,"failed":false}],[":tests/components/StatusBadge.test.tsx",{"duration":49.069886,"failed":false}],[":tests/hooks/useToast.test.tsx",{"duration":120.39812900000004,"failed":false}],[":tests/components/Spinner.test.tsx",{"duration":77.51955700000008,"failed":false}],[":tests/backup-restore.test.tsx",{"duration":205.62362600000006,"failed":false}],[":tests/a11y.test.tsx",{"duration":754.295168,"failed":false}],[":tests/lib/search-fuzzy.test.ts",{"duration":5.431500000000028,"failed":false}],[":tests/components/settings-search.test.tsx",{"duration":723.2007870000001,"failed":false}],[":tests/memory-graph-view.test.tsx",{"duration":47.14713799999993,"failed":false}],[":tests/components/workspace-selector.test.tsx",{"duration":152.31454200000007,"failed":false}],[":tests/components/screenshot-ocr.test.tsx",{"duration":54.37460299999998,"failed":false}],[":tests/voice-recorder.test.tsx",{"duration":138.534312,"failed":false}],[":tests/autosave.test.tsx",{"duration":268.9614950000001,"failed":false}],[":tests/settings-layout.test.tsx",{"duration":248.58253200000001,"failed":false}],[":tests/settings-nav.test.tsx",{"duration":311.78257699999995,"failed":false}],[":tests/components/doctor-panel.test.tsx",{"duration":82.92706099999998,"failed":false}],[":tests/chat-composer.test.tsx",{"duration":207.18291899999997,"failed":false}],[":tests/settings-mode-wiring.test.tsx",{"duration":221.4198530000001,"failed":false}],[":tests/minimax-bar.test.ts",{"duration":0,"failed":true}],[":tests/minimax-bar.test.tsx",{"duration":36.76302400000009,"failed":false}],[":tests/components/plugin-permissions.test.tsx",{"duration":104.179755,"failed":false}],[":tests/eval-web-ui.test.tsx",{"duration":152.385127,"failed":false}]]}
@@ -181,6 +181,10 @@ export async function createApiRouter({
181
181
  // v5.0.0 — Screenshot OCR routes. Accepts images, extracts text.
182
182
  const { createOcrRouter } = await import('./routes/ocr.mjs');
183
183
  router.use(await createOcrRouter({ projectRoot }));
184
+ // v5.2 — Tailscale auth key integration. Registered before misc.mjs so
185
+ // /tailscale/* routes here take precedence over any overlapping misc routes.
186
+ const { createTailscaleRouter } = await import('./routes/tailscale.mjs');
187
+ router.use(createTailscaleRouter({}));
184
188
  router.use(createMiscRouter({ state, broadcast }));
185
189
 
186
190
  // /api/auth/* must be reachable WITHOUT the bearer token so a fresh
@@ -134,10 +134,18 @@ export function createPluginsRouter() {
134
134
  userAgent: _req.headers?.['user-agent'],
135
135
  });
136
136
  const installed = store.listInstalled();
137
- span.setAttribute('plugin.installed.count', installed.length);
138
- res.json({ plugins: installed });
137
+ const enhanced = installed.map(p => ({
138
+ ...p,
139
+ permissions: p.permissions || [],
140
+ config: p.config || {},
141
+ methodCount: p.methodCount || 0,
142
+ invocations: p.invocations || 0,
143
+ lastInvokedAt: p.lastInvokedAt || null,
144
+ }));
145
+ span.setAttribute('plugin.installed.count', enhanced.length);
146
+ res.json({ plugins: enhanced });
139
147
  recordTrace('plugin.installed.list', {
140
- count_bucket: installed.length === 0 ? '0' : installed.length < 10 ? '1-9' : '10+',
148
+ count_bucket: enhanced.length === 0 ? '0' : enhanced.length < 10 ? '1-9' : '10+',
141
149
  });
142
150
  })));
143
151
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * src/server/routes/tailscale.mjs
3
+ *
4
+ * v5.2 — Tailscale auth key integration endpoints.
5
+ *
6
+ * Endpoints:
7
+ * GET /api/tailscale/status — tailscale state (installed, authenticated, serve config)
8
+ * POST /api/tailscale/setup — authenticate + set up tailscale serve
9
+ * POST /api/tailscale/unserve — remove tailscale serve
10
+ */
11
+ import { Router } from 'express';
12
+ import { tailscaleStore } from '../tailscale-store.mjs';
13
+ import { wrap } from './_shared.mjs';
14
+
15
+ /**
16
+ * @param {object} _deps
17
+ * @returns {import('express').Router}
18
+ */
19
+ export function createTailscaleRouter(_deps) {
20
+ const router = Router();
21
+
22
+ // GET /api/tailscale/status
23
+ router.get('/status', wrap(async (_req, res) => {
24
+ res.json(await tailscaleStore.status());
25
+ }));
26
+
27
+ // POST /api/tailscale/setup — authenticate + enable serve
28
+ // Body: { authKey?: string, port?: number, https?: boolean, hostname?: string }
29
+ router.post('/setup', wrap(async (req, res) => {
30
+ const { authKey, port = 4321, https = true, hostname = '' } = req.body || {};
31
+ // If authKey is provided, set it in env for tailscale-store to pick up
32
+ if (authKey) {
33
+ process.env.TAILSCALE_AUTHKEY = authKey;
34
+ }
35
+ // delegates to tailscaleStore.enable() which runs `tailscale serve ...`
36
+ const result = await tailscaleStore.enable({ port, https, hostname });
37
+ res.json(result);
38
+ }));
39
+
40
+ // POST /api/tailscale/unserve — remove tailscale serve
41
+ router.post('/unserve', wrap(async (_req, res) => {
42
+ res.json(await tailscaleStore.disable());
43
+ }));
44
+
45
+ return router;
46
+ }
@@ -2,9 +2,11 @@
2
2
  * src/server/routes/voice.mjs
3
3
  *
4
4
  * v5.0.0 — Voice notes REST API.
5
+ * v5.2 — Upload now saves audio immediately and enqueues background
6
+ * transcription instead of blocking the request on Whisper.
5
7
  *
6
8
  * Endpoints:
7
- * POST /api/voice/upload — multipart upload, transcribe, save
9
+ * POST /api/voice/upload — multipart upload, enqueue transcription
8
10
  * GET /api/voice/list?vaultPath= — list notes (optionally filtered)
9
11
  * GET /api/voice/:id — get note details
10
12
  * DELETE /api/voice/:id — delete note + audio + transcript
@@ -16,7 +18,7 @@ import { createReadStream, existsSync } from 'node:fs';
16
18
  import { join } from 'node:path';
17
19
  import { info, warn, child } from '../logger.mjs';
18
20
  import { saveVoiceNote, listVoiceNotes, getVoiceNote, deleteVoiceNote } from '../voice-store.mjs';
19
- import { transcribe } from '../voice-transcribe.mjs';
21
+ import { enqueueTranscription } from '../workers/transcription-worker.mjs';
20
22
  import { wrap } from './_shared.mjs';
21
23
  import formidable from 'formidable';
22
24
 
@@ -29,6 +31,14 @@ const log = child({ module: 'voice-routes' });
29
31
  export function createVoiceRouter(_deps) {
30
32
  const router = Router();
31
33
 
34
+ /**
35
+ * Whether Whisper is configured. When false, the worker is skipped
36
+ * entirely — uploads still succeed, just without transcripts.
37
+ */
38
+ function whisperAvailable() {
39
+ return Boolean(process.env.OPENAI_API_KEY) || Boolean(process.env.BIZAR_WHISPER_ENDPOINT);
40
+ }
41
+
32
42
  // POST /api/voice/upload — multipart: audio file + vaultPath field
33
43
  router.post('/voice/upload', wrap(async (req, res) => {
34
44
  const form = formidable({ maxFileSize: 50 * 1024 * 1024 }); // 50 MB limit
@@ -60,8 +70,11 @@ export function createVoiceRouter(_deps) {
60
70
  return;
61
71
  }
62
72
 
63
- // Transcribe
64
- const transcript = await transcribe(audioBuffer, { mimeType: 'audio/webm' });
73
+ // v5.2 — Transcription is now asynchronous. Save the audio first;
74
+ // the worker fills in the transcript when Whisper (or a local
75
+ // proxy) responds. Clients can poll GET /api/voice/:id or watch
76
+ // the `voice:updated` WS event for the transcript to appear.
77
+ const transcript = null;
65
78
 
66
79
  // Duration from audioFile (formidable provides size but not duration — we
67
80
  // store null and the frontmatter will show '?')
@@ -77,8 +90,31 @@ export function createVoiceRouter(_deps) {
77
90
  return;
78
91
  }
79
92
 
80
- log.info('voice note saved via upload', { id: saved.id, transcriptLength: transcript?.length ?? 0 });
81
- res.json({ notePath: saved.notePath, audioPath: saved.audioPath, id: saved.id, transcription: transcript });
93
+ // Kick the background worker ONLY when we have a place to send
94
+ // the audio. Without credentials the worker would just return
95
+ // null forever and waste an open file descriptor.
96
+ let transcriptionPending = false;
97
+ if (whisperAvailable()) {
98
+ try {
99
+ enqueueTranscription(saved.id);
100
+ transcriptionPending = true;
101
+ } catch (enqErr) {
102
+ warn('transcription enqueue failed', { id: saved.id, err: enqErr.message });
103
+ }
104
+ }
105
+
106
+ log.info('voice note saved via upload', {
107
+ id: saved.id,
108
+ transcriptionPending,
109
+ transcriptLength: 0,
110
+ });
111
+ res.json({
112
+ notePath: saved.notePath,
113
+ audioPath: saved.audioPath,
114
+ id: saved.id,
115
+ transcription: null,
116
+ transcriptionPending,
117
+ });
82
118
  });
83
119
  }));
84
120
 
@@ -473,6 +473,19 @@ export async function createServer({
473
473
  console.warn('[bizar-dash] headroom startup hook skipped:', err?.message || err);
474
474
  }
475
475
 
476
+ // v5.2 — Background transcription worker for voice notes. Uploads
477
+ // save audio immediately and enqueue the noteId here; the worker
478
+ // drains the queue, calls Whisper (or BIZAR_WHISPER_ENDPOINT), and
479
+ // broadcasts a `voice:updated` event so the dashboard updates in
480
+ // place. Errors here MUST NOT block boot — the worker is best-effort
481
+ // and a missing transcribe just leaves the note with no transcript.
482
+ try {
483
+ const { startTranscriptionWorker } = await import('./workers/transcription-worker.mjs');
484
+ startTranscriptionWorker({ broadcast: localBroadcast });
485
+ } catch (err) {
486
+ console.warn('[bizar-dash] transcription worker failed to start:', err?.message || err);
487
+ }
488
+
476
489
  // All /api/* routes go through apiRouter (after mod routes are checked).
477
490
  // IMPORTANT: mount v2 router FIRST so `/api/v2/*` matches before
478
491
  // apiRouter's internal 404 catch-all (api.mjs line ~109) can swallow it.
@@ -168,6 +168,33 @@ export function getVoiceNote(id) {
168
168
  return loadIndex().find((n) => n.id === id);
169
169
  }
170
170
 
171
+ /**
172
+ * v5.2 — Patch an existing note in place. Used by the background
173
+ * transcription worker to attach the transcript after Whisper returns.
174
+ *
175
+ * Atomic write via the same `atomicWriteJson` helper as `saveVoiceNote`.
176
+ * `updatedAtMs` is refreshed on every call so callers / the UI can see
177
+ * when the note last changed. `createdAtMs` is preserved.
178
+ *
179
+ * If the id is unknown, returns null without touching the index. Callers
180
+ * (e.g. the worker) treat null as "skip and log".
181
+ *
182
+ * @param {string} id
183
+ * @param {Partial<VoiceNote>} patch
184
+ * @returns {VoiceNote|null}
185
+ */
186
+ export function updateVoiceNote(id, patch) {
187
+ if (!id || !patch || typeof patch !== 'object') return null;
188
+ const notes = loadIndex();
189
+ const note = notes.find((n) => n.id === id);
190
+ if (!note) return null;
191
+
192
+ Object.assign(note, patch, { updatedAtMs: Date.now() });
193
+ saveIndex(notes);
194
+ log.info('voice note updated', { id, fields: Object.keys(patch) });
195
+ return note;
196
+ }
197
+
171
198
  /**
172
199
  * @param {string} id
173
200
  * @returns {boolean}
@@ -0,0 +1,213 @@
1
+ /**
2
+ * src/server/workers/transcription-worker.mjs
3
+ *
4
+ * v5.2 — Background transcription worker for voice notes.
5
+ *
6
+ * When a user uploads a voice note, the audio is saved immediately and a
7
+ * reference (noteId) is enqueued here. The worker drains the queue in
8
+ * FIFO order: it reads the audio from disk, calls `transcribe()` (the
9
+ * Whisper adapter), persists the result via `updateVoiceNote()`, and
10
+ * pushes a `voice:updated` event on the dashboard WebSocket so the UI
11
+ * can replace the "transcribing…" placeholder as soon as the text is
12
+ * ready.
13
+ *
14
+ * Design choices:
15
+ * - In-memory queue (per-process). No cross-instance persistence; if
16
+ * the server dies with work in the queue, those notes remain
17
+ * un-transcribed (the audio file is still on disk and can be
18
+ * re-triggered manually if needed).
19
+ * - Single-threaded drain (`processing` flag). Multiple callers
20
+ * enqueueing during a drain are captured by the while-loop's
21
+ * `queue.length > 0` check on each iteration.
22
+ * - Errors inside `processOne()` are caught and logged by
23
+ * `processQueue()`; a failure on note X does NOT stop note X+1.
24
+ * - `broadcast()` is injected at startup so the worker doesn't take
25
+ * a hard dependency on server.mjs. In tests, a no-op stub is fine.
26
+ * - `setInterval(..., 5000)` is a safety net: any work left in the
27
+ * queue (e.g. from a processQueue race during teardown) gets a
28
+ * retry tick. `.unref()` keeps the interval from blocking process
29
+ * exit — the worker never closes the server by itself.
30
+ *
31
+ * Test injection: the four setters / helpers exposed on the bottom of
32
+ * this file (`setTranscribe`, `setBroadcast`, `drainQueue`, `reset`)
33
+ * exist so tests can stub the Whisper adapter and observe the queue
34
+ * without booting a server or hitting the network. They have no effect
35
+ * in production unless called.
36
+ */
37
+
38
+ import { readFileSync, existsSync } from 'node:fs';
39
+ import { getVoiceNote, updateVoiceNote } from '../voice-store.mjs';
40
+ import { transcribe as defaultTranscribe } from '../voice-transcribe.mjs';
41
+ import { child } from '../logger.mjs';
42
+
43
+ const log = child({ module: 'transcription-worker' });
44
+
45
+ const POLL_INTERVAL_MS = 5000;
46
+
47
+ // ── Mutable worker state ────────────────────────────────────────────────────
48
+
49
+ /** @type {{ noteId: string, enqueuedAt: number }[]} */
50
+ const queue = [];
51
+ let processing = false;
52
+
53
+ /** Override hooks — defaults call through to the real Whisper adapter / no-op broadcast. */
54
+ let _transcribe = defaultTranscribe;
55
+ let _broadcast = () => {};
56
+
57
+ const idleWaiters = [];
58
+
59
+ // ── Core processing ─────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Process one note end-to-end. Returns a status object so tests (and
63
+ * the drain loop) can distinguish "skipped for a reason" from "ok".
64
+ *
65
+ * @param {string} noteId
66
+ * @returns {Promise<{ ok?: true, transcript?: string, skipped?: string }>}
67
+ */
68
+ export async function processOne(noteId) {
69
+ const note = getVoiceNote(noteId);
70
+ if (!note) {
71
+ log.debug('note missing, skipping', { noteId });
72
+ return { skipped: 'missing' };
73
+ }
74
+ if (note.transcript) {
75
+ log.debug('already transcribed', { noteId });
76
+ return { skipped: 'already_transcribed' };
77
+ }
78
+ if (!existsSync(note.audioPath)) {
79
+ log.error('audio file missing on disk', { noteId, audioPath: note.audioPath });
80
+ return { skipped: 'audio_missing' };
81
+ }
82
+
83
+ let transcript;
84
+ try {
85
+ const audioBuffer = readFileSync(note.audioPath);
86
+ transcript = await _transcribe(audioBuffer, { mimeType: 'audio/webm' });
87
+ } catch (err) {
88
+ log.error('transcription threw', { noteId, err: err.message });
89
+ return { skipped: 'error', err: err.message };
90
+ }
91
+
92
+ if (!transcript) {
93
+ log.warn('transcription returned null', { noteId });
94
+ return { skipped: 'no_transcript' };
95
+ }
96
+
97
+ const updated = updateVoiceNote(noteId, { transcript });
98
+ if (!updated) {
99
+ log.error('updateVoiceNote returned null after successful transcription', { noteId });
100
+ return { skipped: 'update_failed' };
101
+ }
102
+
103
+ try {
104
+ _broadcast({ type: 'voice:updated', noteId, patch: { transcript } });
105
+ } catch (err) {
106
+ // Broadcast failure must not corrupt the on-disk state.
107
+ log.warn('broadcast threw', { noteId, err: err.message });
108
+ }
109
+
110
+ log.info('transcription complete', { noteId, length: transcript.length });
111
+ return { ok: true, transcript };
112
+ }
113
+
114
+ /**
115
+ * Drain the queue. Guarded by the `processing` flag so concurrent
116
+ * callers don't double-drain. Errors inside `processOne` are logged
117
+ * and swallowed, NOT re-thrown — the next item must always get a
118
+ * chance.
119
+ */
120
+ async function processQueue() {
121
+ if (processing) return;
122
+ processing = true;
123
+ try {
124
+ while (queue.length > 0) {
125
+ const { noteId } = queue.shift();
126
+ try {
127
+ await processOne(noteId);
128
+ } catch (err) {
129
+ log.error('transcription failed', { noteId, err: err.message });
130
+ }
131
+ }
132
+ } finally {
133
+ processing = false;
134
+ if (queue.length === 0) {
135
+ const toResolve = idleWaiters.splice(0);
136
+ for (const r of toResolve) r();
137
+ }
138
+ }
139
+ }
140
+
141
+ // ── Public API ──────────────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Enqueue a note for background transcription.
145
+ *
146
+ * @param {string} noteId
147
+ */
148
+ export function enqueueTranscription(noteId) {
149
+ if (!noteId || typeof noteId !== 'string') return;
150
+ queue.push({ noteId, enqueuedAt: Date.now() });
151
+ // Drain is fire-and-forget — callers don't await per-note work.
152
+ void processQueue();
153
+ }
154
+
155
+ /**
156
+ * Start the worker. Idempotent — calling twice clears the previous
157
+ * interval and starts a fresh one.
158
+ *
159
+ * @param {{ broadcast?: (msg: unknown) => void }} [opts]
160
+ */
161
+ export function startTranscriptionWorker({ broadcast } = {}) {
162
+ if (typeof broadcast === 'function') _broadcast = broadcast;
163
+ // Replace any prior interval so tests + double-boot are safe.
164
+ if (startTranscriptionWorker._interval) {
165
+ clearInterval(startTranscriptionWorker._interval);
166
+ }
167
+ log.info('transcription worker started', { pollIntervalMs: POLL_INTERVAL_MS });
168
+ const handle = setInterval(() => void processQueue(), POLL_INTERVAL_MS);
169
+ if (typeof handle.unref === 'function') handle.unref();
170
+ startTranscriptionWorker._interval = handle;
171
+ }
172
+
173
+ // ── Test hooks (no-op in production) ────────────────────────────────────────
174
+
175
+ /** @param {(buf: Buffer|Uint8Array, opts?: object) => Promise<string|null>} fn */
176
+ export function setTranscribe(fn) {
177
+ _transcribe = typeof fn === 'function' ? fn : defaultTranscribe;
178
+ }
179
+
180
+ /** @param {(msg: unknown) => void} fn */
181
+ export function setBroadcast(fn) {
182
+ _broadcast = typeof fn === 'function' ? fn : () => {};
183
+ }
184
+
185
+ /** Number of items waiting or currently being processed. */
186
+ export function getQueueDepth() {
187
+ return queue.length + (processing ? 1 : 0);
188
+ }
189
+
190
+ /**
191
+ * Resolve once the queue is fully drained (no items + not processing).
192
+ * Returns a resolved promise immediately if already idle.
193
+ */
194
+ export function drainQueue() {
195
+ if (queue.length === 0 && !processing) return Promise.resolve();
196
+ return new Promise((resolve) => {
197
+ idleWaiters.push(resolve);
198
+ void processQueue();
199
+ });
200
+ }
201
+
202
+ /** Reset all mutable state. ONLY for tests. */
203
+ export function reset() {
204
+ queue.length = 0;
205
+ processing = false;
206
+ _transcribe = defaultTranscribe;
207
+ _broadcast = () => {};
208
+ idleWaiters.length = 0;
209
+ if (startTranscriptionWorker._interval) {
210
+ clearInterval(startTranscriptionWorker._interval);
211
+ startTranscriptionWorker._interval = null;
212
+ }
213
+ }
@@ -31,6 +31,7 @@ import { Activity } from './views/Activity';
31
31
  import { Config } from './views/Config';
32
32
  import { SettingsView } from './views/Settings';
33
33
  import { Mods } from './views/Mods';
34
+ import { Plugins } from './views/Plugins';
34
35
  import { ModView, type ModView as ModViewType } from './views/ModView';
35
36
  import { Schedules } from './views/Schedules';
36
37
  import { Skills } from './views/Skills';
@@ -39,6 +40,8 @@ import { History } from './views/History';
39
40
  import { MiniMaxUsage } from './views/MiniMaxUsage';
40
41
  import { BackgroundAgents } from './views/BackgroundAgents';
41
42
  import { Doctor } from './views/Doctor';
43
+ import { Eval } from './views/Eval';
44
+ import { EvalReport } from './views/EvalReport';
42
45
  import { SettingsNav } from './components/SettingsNav';
43
46
  import { Spinner } from './components/Spinner';
44
47
  import { Button } from './components/Button';
@@ -92,10 +95,16 @@ const VIEW_MAP: Record<string, (p: ViewProps) => React.ReactNode> = {
92
95
  skills: Skills,
93
96
  history: History,
94
97
  minimax: MiniMaxUsage,
98
+ plugins: Plugins,
95
99
  // v6.0.0 — Doctor page (system health + diagnostics surface).
96
100
  // Registered here so the tab id 'doctor' from Topbar.tsx TABS
97
101
  // resolves to a real view instead of falling back to Overview.
98
102
  doctor: Doctor,
103
+ // v5.2.0 — Eval framework web UI. `eval` is the sidebar tab
104
+ // (recent runs summary), `evalReport` is reachable from the
105
+ // Eval view's "Open Eval Report" button (full report + diff).
106
+ eval: Eval,
107
+ evalReport: EvalReport,
99
108
  };
100
109
 
101
110
  const VERSION = 'v4.5.0';
@@ -0,0 +1,157 @@
1
+ // src/components/EvalDiff.tsx — v5.2.0
2
+ //
3
+ // Side-by-side comparison of two eval runs. Categorizes each fixture
4
+ // as improved / regressed / unchanged and renders a summary block plus
5
+ // a list of changed fixtures.
6
+ //
7
+ // `runA` is the "before" run (typically an older baseline); `runB` is
8
+ // the "after" run (typically the newer run under review).
9
+
10
+ import type { ReactNode } from 'react';
11
+ import { cn } from '../lib/utils';
12
+
13
+ /** Per-fixture result row embedded in a run record. */
14
+ export type EvalFixtureResult = {
15
+ fixtureId: string;
16
+ ok: boolean;
17
+ checks?: { kind: string; ok: boolean; message?: string }[];
18
+ latencyMs?: number;
19
+ };
20
+
21
+ export type EvalRunWithResults = {
22
+ id: string;
23
+ startedAt: string;
24
+ finishedAt?: string;
25
+ suitePath?: string;
26
+ total: number;
27
+ passed: number;
28
+ failed: number;
29
+ results: EvalFixtureResult[];
30
+ };
31
+
32
+ export type EvalDiffEntry = {
33
+ fixtureId: string;
34
+ /** The fixture's pass/fail status in the "before" run, or null if absent. */
35
+ before: boolean | null;
36
+ /** The fixture's pass/fail status in the "after" run, or null if absent. */
37
+ after: boolean | null;
38
+ /** Bucket label derived from before/after. */
39
+ change: 'same' | 'improved' | 'regressed' | 'added' | 'removed';
40
+ };
41
+
42
+ export type EvalDiffProps = {
43
+ runA: EvalRunWithResults;
44
+ runB: EvalRunWithResults;
45
+ /**
46
+ * Optional override for the section heading. Defaults to
47
+ * "Regression Analysis".
48
+ */
49
+ title?: string;
50
+ };
51
+
52
+ /**
53
+ * Pure helper: compute the diff entries from two runs. Exported so
54
+ * tests can exercise the categorization logic without rendering.
55
+ */
56
+ export function computeEvalDiff(
57
+ runA: EvalRunWithResults,
58
+ runB: EvalRunWithResults,
59
+ ): EvalDiffEntry[] {
60
+ const mapA = new Map(runA.results.map((r) => [r.fixtureId, r]));
61
+ const mapB = new Map(runB.results.map((r) => [r.fixtureId, r]));
62
+ const ids = new Set<string>([...mapA.keys(), ...mapB.keys()]);
63
+
64
+ const out: EvalDiffEntry[] = [];
65
+ for (const id of ids) {
66
+ const a = mapA.get(id);
67
+ const b = mapB.get(id);
68
+ const before = a ? a.ok : null;
69
+ const after = b ? b.ok : null;
70
+
71
+ let change: EvalDiffEntry['change'];
72
+ if (a && b) {
73
+ if (before === after) change = 'same';
74
+ else if (before === false && after === true) change = 'improved';
75
+ else change = 'regressed';
76
+ } else if (b && !a) {
77
+ change = 'added';
78
+ } else {
79
+ change = 'removed';
80
+ }
81
+
82
+ out.push({ fixtureId: id, before, after, change });
83
+ }
84
+ // Stable sort: regressions first, then improvements, then others.
85
+ const order: Record<EvalDiffEntry['change'], number> = {
86
+ regressed: 0,
87
+ improved: 1,
88
+ added: 2,
89
+ removed: 3,
90
+ same: 4,
91
+ };
92
+ out.sort((x, y) => {
93
+ const d = order[x.change] - order[y.change];
94
+ if (d !== 0) return d;
95
+ return x.fixtureId.localeCompare(y.fixtureId);
96
+ });
97
+ return out;
98
+ }
99
+
100
+ export function EvalDiff({ runA, runB, title = 'Regression Analysis' }: EvalDiffProps): ReactNode {
101
+ const diff = computeEvalDiff(runA, runB);
102
+
103
+ const improved = diff.filter((d) => d.change === 'improved').length;
104
+ const regressed = diff.filter((d) => d.change === 'regressed').length;
105
+ const unchanged = diff.filter((d) => d.change === 'same').length;
106
+ const changed = diff.filter((d) => d.change !== 'same');
107
+
108
+ return (
109
+ <div className="eval-diff" data-testid="eval-diff">
110
+ <h3 className="eval-diff-title">{title}</h3>
111
+ <div className="eval-diff-subtitle muted">
112
+ Comparing <code className="mono">{runA.id}</code> →{' '}
113
+ <code className="mono">{runB.id}</code>
114
+ </div>
115
+
116
+ <div className="eval-diff-stats" aria-label="Diff summary">
117
+ <span className="eval-diff-stat improved">
118
+ <span className="eval-diff-stat-icon" aria-hidden>+</span>
119
+ {improved} improved
120
+ </span>
121
+ <span className="eval-diff-stat regressed">
122
+ <span className="eval-diff-stat-icon" aria-hidden>−</span>
123
+ {regressed} regressed
124
+ </span>
125
+ <span className="eval-diff-stat unchanged muted">
126
+ {unchanged} unchanged
127
+ </span>
128
+ </div>
129
+
130
+ {changed.length === 0 ? (
131
+ <p className="eval-diff-empty muted">No fixture-level changes between these runs.</p>
132
+ ) : (
133
+ <ul className="eval-diff-list" role="list">
134
+ {changed.map((d) => (
135
+ <li
136
+ key={d.fixtureId}
137
+ data-fixture-id={d.fixtureId}
138
+ className={cn('eval-diff-row', `is-${d.change}`)}
139
+ >
140
+ <span className="eval-diff-fixture mono">{d.fixtureId}</span>
141
+ <span className="eval-diff-arrow" aria-hidden>→</span>
142
+ <span className="eval-diff-states">
143
+ <span className={cn('eval-diff-state', d.before ? 'is-pass' : 'is-fail')}>
144
+ {d.before === null ? '—' : d.before ? 'pass' : 'fail'}
145
+ </span>
146
+ <span className="eval-diff-arrow-small" aria-hidden>→</span>
147
+ <span className={cn('eval-diff-state', d.after ? 'is-pass' : 'is-fail')}>
148
+ {d.after === null ? '—' : d.after ? 'pass' : 'fail'}
149
+ </span>
150
+ </span>
151
+ </li>
152
+ ))}
153
+ </ul>
154
+ )}
155
+ </div>
156
+ );
157
+ }