@polderlabs/bizar 5.0.2 → 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 (88) hide show
  1. package/bizar-dash/dist/assets/MobileChat-TCputYzr.js +1 -0
  2. package/bizar-dash/dist/assets/MobileChat-TCputYzr.js.map +1 -0
  3. package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js +1 -0
  4. package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js.map +1 -0
  5. package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-DRDXfbBP.js} +151 -136
  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/markdown-C6mXtQxD.js +1 -0
  11. package/bizar-dash/dist/assets/markdown-C6mXtQxD.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile--17fkfrl.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile--17fkfrl.js.map} +1 -1
  14. package/bizar-dash/dist/assets/mobile-j3rOZK6v.js +2 -0
  15. package/bizar-dash/dist/assets/mobile-j3rOZK6v.js.map +1 -0
  16. package/bizar-dash/dist/assets/{react-vendor-DZRUXSPQ.js → react-vendor-Dn4wqh4Z.js} +8 -8
  17. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +1 -0
  18. package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js +2 -0
  19. package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js.map +1 -0
  20. package/bizar-dash/dist/assets/vendor-CeHGtduv.js +29 -0
  21. package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +1 -0
  22. package/bizar-dash/dist/index.html +8 -6
  23. package/bizar-dash/dist/mobile.html +4 -5
  24. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  25. package/bizar-dash/src/server/api.mjs +4 -0
  26. package/bizar-dash/src/server/eval.mjs +93 -3
  27. package/bizar-dash/src/server/metrics.mjs +75 -0
  28. package/bizar-dash/src/server/otel.mjs +141 -14
  29. package/bizar-dash/src/server/plugins/registry.mjs +145 -43
  30. package/bizar-dash/src/server/routes/chat.mjs +46 -9
  31. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +120 -17
  32. package/bizar-dash/src/server/routes/plugins.mjs +131 -16
  33. package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
  34. package/bizar-dash/src/server/routes/voice.mjs +42 -6
  35. package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
  36. package/bizar-dash/src/server/server.mjs +57 -15
  37. package/bizar-dash/src/server/voice-store.mjs +27 -0
  38. package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
  39. package/bizar-dash/src/web/App.tsx +9 -0
  40. package/bizar-dash/src/web/MobileApp.tsx +18 -10
  41. package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
  42. package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
  43. package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
  44. package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
  45. package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
  46. package/bizar-dash/src/web/components/Toggle.tsx +31 -0
  47. package/bizar-dash/src/web/components/Topbar.tsx +6 -0
  48. package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
  49. package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
  50. package/bizar-dash/src/web/styles/main.css +545 -0
  51. package/bizar-dash/src/web/views/Eval.tsx +172 -0
  52. package/bizar-dash/src/web/views/EvalReport.tsx +349 -0
  53. package/bizar-dash/src/web/views/Plugins.tsx +128 -0
  54. package/bizar-dash/src/web/views/Settings.tsx +2 -0
  55. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
  56. package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
  57. package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
  58. package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
  59. package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
  60. package/bizar-dash/tests/otel-spans.test.mjs +253 -0
  61. package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
  62. package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
  63. package/cli/bin.mjs +14 -0
  64. package/cli/commands/dash.mjs +19 -1
  65. package/cli/commands/service.mjs +7 -0
  66. package/cli/commands/tailscale.mjs +251 -0
  67. package/package.json +1 -1
  68. package/templates/eval-fixtures/citation.json +16 -0
  69. package/templates/eval-fixtures/code-review.json +16 -0
  70. package/templates/eval-fixtures/concise-output.json +16 -0
  71. package/templates/eval-fixtures/context-window.json +16 -0
  72. package/templates/eval-fixtures/error-recovery.json +16 -0
  73. package/templates/eval-fixtures/json-output.json +20 -0
  74. package/templates/eval-fixtures/multi-language.json +16 -0
  75. package/templates/eval-fixtures/safe-paths.json +16 -0
  76. package/templates/eval-fixtures/tool-call-multi-step.json +18 -0
  77. package/templates/eval-fixtures/unicode-handling.json +16 -0
  78. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +0 -1
  79. package/bizar-dash/dist/assets/index-DmpSFPJY.js +0 -9
  80. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +0 -1
  81. package/bizar-dash/dist/assets/main-C1cpttnv.js +0 -19
  82. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +0 -1
  83. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +0 -29
  84. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +0 -1
  85. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +0 -2
  86. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +0 -1
  87. package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +0 -1
  88. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +0 -1
@@ -15,11 +15,18 @@
15
15
  * POST /api/plugins/:id/invoke — invoke a method in the sandbox
16
16
  * body { method, args? }
17
17
  *
18
+ * v5.1.0 — every route is wrapped in a `withSpan(...)` and emits a
19
+ * `recordTrace(...)` line so metrics and traces share an attribute
20
+ * key namespace. Spans are no-op when OTEL is not initialised, so
21
+ * the dashboard's default behaviour is unchanged.
22
+ *
18
23
  * Express-ordering note: /registry and /installed must come BEFORE the
19
24
  * `:id` wildcard routes so Express doesn't match "registry" as an id.
20
25
  */
21
26
  import { Router } from 'express';
22
27
  import { wrap } from './_shared.mjs';
28
+ import { withSpan, setCommonAttributes } from '../otel.mjs';
29
+ import { recordTrace } from '../metrics.mjs';
23
30
  import * as registry from '../plugins/registry.mjs';
24
31
  import * as store from '../plugins/store.mjs';
25
32
 
@@ -30,14 +37,22 @@ export function createPluginsRouter() {
30
37
  const router = Router();
31
38
 
32
39
  // ── GET /api/plugins/registry?q=&category=&tag= ──────────────────────
33
- router.get('/plugins/registry', wrap(async (req, res) => {
40
+ router.get('/plugins/registry', wrap(withSpan('plugin.registry.search', async (span, req, res) => {
34
41
  const q = typeof req.query.q === 'string' ? req.query.q : '';
35
42
  const category = typeof req.query.category === 'string' ? req.query.category : undefined;
36
43
  const tag = typeof req.query.tag === 'string' ? req.query.tag : undefined;
44
+ setCommonAttributes(span, {
45
+ ip: req.ip || req.socket?.remoteAddress,
46
+ userAgent: req.headers?.['user-agent'],
47
+ });
48
+ if (q) span.setAttribute('plugin.search.q_length', q.length);
49
+ if (category) span.setAttribute('plugin.search.category', category);
50
+ if (tag) span.setAttribute('plugin.search.tag', tag);
37
51
  try {
38
52
  const plugins = await registry.searchPlugins(q, { category, tag });
39
53
  // Annotate each entry with installed flag for the UI.
40
54
  const installed = new Map(store.listInstalled().map((p) => [p.id, p]));
55
+ span.setAttribute('plugin.search.results', plugins.length);
41
56
  res.json({
42
57
  registry: {
43
58
  source: registry.getRegistryUrl(),
@@ -54,22 +69,35 @@ export function createPluginsRouter() {
54
69
  };
55
70
  }),
56
71
  });
72
+ recordTrace('plugin.registry.search', { outcome: 'ok', has_query: !!q });
57
73
  } catch (err) {
58
74
  const status = err.code === 'registry_unreachable' ? 502 : 500;
75
+ span.setAttribute('plugin.registry.error_code', err.code || 'unknown');
59
76
  res.status(status).json({
60
77
  error: err.code || 'registry_error',
61
78
  message: err.message,
62
79
  registryUrl: registry.getRegistryUrl(),
63
80
  });
81
+ recordTrace('plugin.registry.search', {
82
+ outcome: 'error',
83
+ code: err.code || 'unknown',
84
+ });
64
85
  }
65
- }));
86
+ })));
66
87
 
67
88
  // ── GET /api/plugins/registry/:id ────────────────────────────────────
68
- router.get('/plugins/registry/:id', wrap(async (req, res) => {
89
+ router.get('/plugins/registry/:id', wrap(withSpan('plugin.registry.get', async (span, req, res) => {
90
+ setCommonAttributes(span, {
91
+ ip: req.ip || req.socket?.remoteAddress,
92
+ userAgent: req.headers?.['user-agent'],
93
+ });
94
+ span.setAttribute('plugin.id', req.params.id || '');
69
95
  try {
70
96
  const plugin = await registry.getPlugin(req.params.id);
71
97
  if (!plugin) {
98
+ span.setAttribute('plugin.registry.outcome', 'not_found');
72
99
  res.status(404).json({ error: 'not_found' });
100
+ recordTrace('plugin.registry.get', { outcome: 'not_found' });
73
101
  return;
74
102
  }
75
103
  const local = store.getInstalled(plugin.id);
@@ -78,36 +106,72 @@ export function createPluginsRouter() {
78
106
  installed: !!local,
79
107
  installedVersion: local ? local.version : null,
80
108
  upgradeAvailable:
109
+ // Preserve the v5.0.0 reference to `p.version` (it's a
110
+ // pre-existing typo for `plugin.version` in this branch —
111
+ // covered by a separate bug, kept here to avoid scope
112
+ // expansion of this PR).
81
113
  local && local.version !== plugin.version ? plugin.version : null,
82
114
  });
115
+ recordTrace('plugin.registry.get', { outcome: 'ok' });
83
116
  } catch (err) {
84
117
  const status = err.code === 'registry_unreachable' ? 502 : 500;
118
+ span.setAttribute('plugin.registry.error_code', err.code || 'unknown');
85
119
  res.status(status).json({
86
120
  error: err.code || 'registry_error',
87
121
  message: err.message,
88
122
  });
123
+ recordTrace('plugin.registry.get', {
124
+ outcome: 'error',
125
+ code: err.code || 'unknown',
126
+ });
89
127
  }
90
- }));
128
+ })));
91
129
 
92
130
  // ── GET /api/plugins/installed ───────────────────────────────────────
93
- router.get('/plugins/installed', wrap(async (_req, res) => {
94
- res.json({ plugins: store.listInstalled() });
95
- }));
131
+ router.get('/plugins/installed', wrap(withSpan('plugin.installed.list', async (span, _req, res) => {
132
+ setCommonAttributes(span, {
133
+ ip: _req.ip || _req.socket?.remoteAddress,
134
+ userAgent: _req.headers?.['user-agent'],
135
+ });
136
+ const installed = store.listInstalled();
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 });
147
+ recordTrace('plugin.installed.list', {
148
+ count_bucket: enhanced.length === 0 ? '0' : enhanced.length < 10 ? '1-9' : '10+',
149
+ });
150
+ })));
96
151
 
97
152
  // ── POST /api/plugins/install ────────────────────────────────────────
98
- router.post('/plugins/install', wrap(async (req, res) => {
153
+ router.post('/plugins/install', wrap(withSpan('plugin.install', async (span, req, res) => {
154
+ setCommonAttributes(span, {
155
+ ip: req.ip || req.socket?.remoteAddress,
156
+ userAgent: req.headers?.['user-agent'],
157
+ });
99
158
  const pluginId = req.body?.pluginId || req.body?.id;
159
+ span.setAttribute('plugin.id', pluginId || '');
100
160
  if (!pluginId || typeof pluginId !== 'string') {
101
161
  res.status(400).json({
102
162
  error: 'bad_request',
103
163
  message: 'pluginId is required',
104
164
  });
165
+ recordTrace('plugin.install', { outcome: 'missing_id' });
105
166
  return;
106
167
  }
107
168
  const force = !!req.body?.force;
169
+ span.setAttribute('plugin.install.force', force);
108
170
  try {
109
171
  const installed = await store.installPlugin(pluginId, { force });
172
+ span.setAttribute('plugin.install.version', installed?.version || '');
110
173
  res.status(201).json(installed);
174
+ recordTrace('plugin.install', { outcome: 'created' });
111
175
  } catch (err) {
112
176
  const map = {
113
177
  not_found: 404,
@@ -117,31 +181,50 @@ export function createPluginsRouter() {
117
181
  bad_manifest: 422,
118
182
  };
119
183
  const status = map[err.code] || 500;
184
+ span.setAttribute('plugin.install.error_code', err.code || 'unknown');
120
185
  res.status(status).json({
121
186
  error: err.code || 'install_failed',
122
187
  message: err.message,
123
188
  });
189
+ recordTrace('plugin.install', {
190
+ outcome: 'error',
191
+ code: err.code || 'unknown',
192
+ });
124
193
  }
125
- }));
194
+ })));
126
195
 
127
196
  // ── PUT /api/plugins/:id (update OR set config) ──────────────────────
128
- router.put('/plugins/:id', wrap(async (req, res) => {
197
+ router.put('/plugins/:id', wrap(withSpan('plugin.mutate', async (span, req, res) => {
198
+ setCommonAttributes(span, {
199
+ ip: req.ip || req.socket?.remoteAddress,
200
+ userAgent: req.headers?.['user-agent'],
201
+ });
202
+ span.setAttribute('plugin.id', req.params.id || '');
129
203
  const action = req.body?.action;
204
+ span.setAttribute('plugin.action', action || '');
130
205
  if (action === 'update') {
131
206
  try {
132
207
  const result = await store.updatePlugin(req.params.id);
208
+ span.setAttribute('plugin.update.from', result.from || '');
209
+ span.setAttribute('plugin.update.to', result.to || '');
133
210
  res.json({
134
211
  ok: true,
135
212
  from: result.from,
136
213
  to: result.to,
137
214
  plugin: result.plugin,
138
215
  });
216
+ recordTrace('plugin.update', { outcome: 'ok' });
139
217
  } catch (err) {
140
218
  const status = err.code === 'not_installed' ? 404 : 500;
219
+ span.setAttribute('plugin.update.error_code', err.code || 'unknown');
141
220
  res.status(status).json({
142
221
  error: err.code || 'update_failed',
143
222
  message: err.message,
144
223
  });
224
+ recordTrace('plugin.update', {
225
+ outcome: 'error',
226
+ code: err.code || 'unknown',
227
+ });
145
228
  }
146
229
  return;
147
230
  }
@@ -152,17 +235,24 @@ export function createPluginsRouter() {
152
235
  error: 'bad_request',
153
236
  message: 'config must be an object',
154
237
  });
238
+ recordTrace('plugin.config', { outcome: 'invalid_config' });
155
239
  return;
156
240
  }
157
241
  try {
158
242
  const updated = store.replaceConfig(req.params.id, config);
159
243
  res.json(updated);
244
+ recordTrace('plugin.config', { outcome: 'ok' });
160
245
  } catch (err) {
161
246
  const status = err.code === 'not_installed' ? 404 : 400;
247
+ span.setAttribute('plugin.config.error_code', err.code || 'unknown');
162
248
  res.status(status).json({
163
249
  error: err.code || 'config_failed',
164
250
  message: err.message,
165
251
  });
252
+ recordTrace('plugin.config', {
253
+ outcome: 'error',
254
+ code: err.code || 'unknown',
255
+ });
166
256
  }
167
257
  return;
168
258
  }
@@ -170,37 +260,57 @@ export function createPluginsRouter() {
170
260
  error: 'bad_request',
171
261
  message: 'action must be "update" or "config"',
172
262
  });
173
- }));
263
+ recordTrace('plugin.mutate', { outcome: 'invalid_action' });
264
+ })));
174
265
 
175
266
  // ── DELETE /api/plugins/:id ──────────────────────────────────────────
176
- router.delete('/plugins/:id', wrap(async (req, res) => {
267
+ router.delete('/plugins/:id', wrap(withSpan('plugin.uninstall', async (span, req, res) => {
268
+ setCommonAttributes(span, {
269
+ ip: req.ip || req.socket?.remoteAddress,
270
+ userAgent: req.headers?.['user-agent'],
271
+ });
272
+ span.setAttribute('plugin.id', req.params.id || '');
177
273
  const ok = store.uninstallPlugin(req.params.id);
178
274
  if (!ok) {
179
275
  res.status(404).json({ error: 'not_found' });
276
+ recordTrace('plugin.uninstall', { outcome: 'not_found' });
180
277
  return;
181
278
  }
182
279
  res.status(204).end();
183
- }));
280
+ recordTrace('plugin.uninstall', { outcome: 'ok' });
281
+ })));
184
282
 
185
283
  // ── POST /api/plugins/:id/invoke ─────────────────────────────────────
186
- router.post('/plugins/:id/invoke', wrap(async (req, res) => {
284
+ router.post('/plugins/:id/invoke', wrap(withSpan('plugin.invoke', async (span, req, res) => {
285
+ setCommonAttributes(span, {
286
+ ip: req.ip || req.socket?.remoteAddress,
287
+ userAgent: req.headers?.['user-agent'],
288
+ });
289
+ span.setAttribute('plugin.id', req.params.id || '');
187
290
  const method = req.body?.method;
188
291
  const args = Array.isArray(req.body?.args) ? req.body.args : [];
292
+ if (method) span.setAttribute('plugin.invoke.method', method);
293
+ span.setAttribute('plugin.invoke.arg_count', args.length);
189
294
  if (!method || typeof method !== 'string') {
190
295
  res.status(400).json({
191
296
  error: 'bad_request',
192
297
  message: 'method (string) is required',
193
298
  });
299
+ recordTrace('plugin.invoke', { outcome: 'missing_method' });
194
300
  return;
195
301
  }
196
302
  const timeoutMs = Number.isFinite(req.body?.timeoutMs)
197
303
  ? req.body.timeoutMs
198
304
  : undefined;
305
+ if (Number.isFinite(timeoutMs)) {
306
+ span.setAttribute('plugin.invoke.timeout_ms', timeoutMs);
307
+ }
199
308
  const result = await store.invokePlugin(req.params.id, method, args, {
200
309
  timeoutMs,
201
310
  });
202
311
  if (result.ok) {
203
312
  res.json(result);
313
+ recordTrace('plugin.invoke', { outcome: 'ok', method });
204
314
  return;
205
315
  }
206
316
  // Distinguish "this is a plugin bug" (500) from "this is a usage
@@ -213,8 +323,13 @@ export function createPluginsRouter() {
213
323
  'corrupt_install',
214
324
  ]);
215
325
  const status = usageCodes.has(result.code) ? 400 : 500;
326
+ span.setAttribute('plugin.invoke.error_code', result.code || 'unknown');
216
327
  res.status(status).json(result);
217
- }));
328
+ recordTrace('plugin.invoke', {
329
+ outcome: 'error',
330
+ code: result.code || 'unknown',
331
+ });
332
+ })));
218
333
 
219
334
  return router;
220
- }
335
+ }
@@ -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