@polderlabs/bizar 4.9.0 → 5.0.1

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 (183) hide show
  1. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js +701 -0
  2. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
  3. package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
  4. package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
  5. package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
  6. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
  7. package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DD-FZrTC.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.package-lock.json +6 -0
  19. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  20. package/bizar-dash/package-lock.json +6 -0
  21. package/bizar-dash/skills/eval/SKILL.md +237 -0
  22. package/bizar-dash/src/server/api.mjs +35 -0
  23. package/bizar-dash/src/server/auth.mjs +155 -1
  24. package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
  25. package/bizar-dash/src/server/eval-store.mjs +226 -0
  26. package/bizar-dash/src/server/eval.mjs +347 -0
  27. package/bizar-dash/src/server/memory-store.mjs +46 -0
  28. package/bizar-dash/src/server/ocr.mjs +55 -0
  29. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  30. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  31. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  32. package/bizar-dash/src/server/providers-store.mjs +11 -4
  33. package/bizar-dash/src/server/routes/_shared.mjs +2 -2
  34. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  35. package/bizar-dash/src/server/routes/doctor.mjs +71 -0
  36. package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
  37. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +3 -0
  39. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  40. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
  41. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  42. package/bizar-dash/src/server/routes/schedules.mjs +55 -0
  43. package/bizar-dash/src/server/routes/users.mjs +84 -0
  44. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  45. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  46. package/bizar-dash/src/server/serve-info.mjs +172 -0
  47. package/bizar-dash/src/server/voice-store.mjs +202 -0
  48. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  49. package/bizar-dash/src/server/workspaces.mjs +626 -0
  50. package/bizar-dash/src/web/App.tsx +48 -11
  51. package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
  52. package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
  53. package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
  54. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  55. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
  56. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  57. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  58. package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
  59. package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
  60. package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
  61. package/bizar-dash/src/web/components/Topbar.tsx +11 -0
  62. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  63. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  64. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  65. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
  66. package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
  67. package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
  68. package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
  69. package/bizar-dash/src/web/lib/types.ts +134 -0
  70. package/bizar-dash/src/web/styles/chat.css +8 -5
  71. package/bizar-dash/src/web/styles/main.css +77 -2
  72. package/bizar-dash/src/web/styles/memory.css +82 -0
  73. package/bizar-dash/src/web/styles/settings.css +265 -0
  74. package/bizar-dash/src/web/views/Chat.tsx +15 -1
  75. package/bizar-dash/src/web/views/Doctor.tsx +317 -0
  76. package/bizar-dash/src/web/views/Memory.tsx +17 -2
  77. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
  78. package/bizar-dash/src/web/views/Schedules.tsx +94 -12
  79. package/bizar-dash/src/web/views/Settings.tsx +78 -45
  80. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
  82. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  83. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  84. package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
  85. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
  86. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
  87. package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
  88. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  89. package/bizar-dash/tests/autosave.test.tsx +276 -0
  90. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
  91. package/bizar-dash/tests/chat-composer.test.tsx +140 -0
  92. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  93. package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
  94. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  95. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  96. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  97. package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
  98. package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
  99. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  100. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  101. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  102. package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
  103. package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
  104. package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
  105. package/bizar-dash/tests/minimax-models.test.mjs +123 -0
  106. package/bizar-dash/tests/ocr.test.mjs +87 -0
  107. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
  108. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  109. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  110. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  111. package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
  112. package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
  113. package/bizar-dash/tests/settings-layout.test.tsx +129 -0
  114. package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
  115. package/bizar-dash/tests/settings-nav.test.tsx +126 -0
  116. package/bizar-dash/tests/users.test.mjs +108 -0
  117. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  118. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  119. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  120. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  121. package/cli/bin.mjs +72 -2
  122. package/cli/commands/clip.mjs +146 -0
  123. package/cli/commands/deploy/cloudflare.mjs +250 -0
  124. package/cli/commands/deploy/docker.mjs +221 -0
  125. package/cli/commands/deploy/fly.mjs +161 -0
  126. package/cli/commands/deploy/vercel.mjs +225 -0
  127. package/cli/commands/deploy.mjs +240 -0
  128. package/cli/commands/eval.mjs +378 -0
  129. package/cli/commands/marketplace.mjs +64 -0
  130. package/cli/commands/minimax.mjs +5 -2
  131. package/cli/commands/ocr.mjs +165 -0
  132. package/cli/commands/plugin.mjs +358 -0
  133. package/cli/commands/voice.mjs +211 -0
  134. package/cli/commands/workspace.mjs +247 -0
  135. package/cli/tests/minimax-cli.test.mjs +79 -0
  136. package/config/agents/frigg.md +1 -1
  137. package/config/agents/heimdall.md +1 -1
  138. package/config/agents/mimir.md +1 -1
  139. package/config/agents/quick.md +1 -1
  140. package/config/agents/semble-search.md +1 -1
  141. package/config/agents/vor.md +1 -1
  142. package/config/opencode.json +28 -21
  143. package/config/opencode.json.template +8 -1
  144. package/package.json +12 -8
  145. package/plugins/bizar/index.ts +77 -0
  146. package/plugins/bizar/src/compaction.d.mts +48 -0
  147. package/plugins/bizar/src/compaction.mjs +192 -0
  148. package/plugins/bizar/tests/compaction.test.ts +264 -0
  149. package/templates/deploy/cloudflare/README.md +32 -0
  150. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  151. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  152. package/templates/deploy/docker/.env.template +16 -0
  153. package/templates/deploy/docker/README.md +58 -0
  154. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  155. package/templates/deploy/fly/README.md +35 -0
  156. package/templates/deploy/fly/fly.toml.template +28 -0
  157. package/templates/deploy/vercel/README.md +29 -0
  158. package/templates/deploy/vercel/api-index.template.js +18 -0
  159. package/templates/deploy/vercel/vercel.json.template +16 -0
  160. package/templates/eval-fixtures/README.md +58 -0
  161. package/templates/eval-fixtures/code-search-basic.json +28 -0
  162. package/templates/eval-fixtures/latency-bounds.json +16 -0
  163. package/templates/eval-fixtures/regression-suite.json +79 -0
  164. package/templates/eval-fixtures/response-format.json +30 -0
  165. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  166. package/templates/plugin-template/README.md +121 -0
  167. package/templates/plugin-template/index.js +66 -0
  168. package/templates/plugin-template/plugin.json +42 -0
  169. package/templates/plugin-template/tests/plugin.test.js +83 -0
  170. package/templates/schedules/daily-backup.json +12 -0
  171. package/templates/schedules/daily-cleanup.json +12 -0
  172. package/templates/schedules/hourly-health-check.json +12 -0
  173. package/templates/schedules/webhook-on-push.json +13 -0
  174. package/templates/schedules/weekly-digest.json +13 -0
  175. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
  176. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
  177. package/bizar-dash/dist/assets/main-DfmIfOUS.css +0 -1
  178. package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
  179. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
  180. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
  181. package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
  182. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
  183. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
@@ -0,0 +1,204 @@
1
+ /**
2
+ * src/server/routes/workspaces.mjs
3
+ *
4
+ * v5.0.0 — Workspace management REST API.
5
+ *
6
+ * Endpoints:
7
+ * GET /api/workspaces — list user's workspaces
8
+ * POST /api/workspaces — create workspace
9
+ * GET /api/workspaces/:id — get workspace details
10
+ * DELETE /api/workspaces/:id — delete workspace (admin only)
11
+ * POST /api/workspaces/:id/invites — create invite
12
+ * GET /api/workspaces/:id/invites — list pending invites
13
+ * DELETE /api/workspaces/:id/invites/:token — revoke invite
14
+ * POST /api/workspaces/:id/members/:userId — update member role
15
+ * DELETE /api/workspaces/:id/members/:userId — remove member
16
+ * POST /api/invites/:token/accept — accept invite
17
+ */
18
+ import { Router } from 'express';
19
+ import {
20
+ createWorkspace,
21
+ getWorkspace,
22
+ listWorkspacesWithRoles,
23
+ deleteWorkspace,
24
+ createInvite,
25
+ listInvites,
26
+ revokeInvite,
27
+ addMember,
28
+ removeMember,
29
+ updateMemberRole,
30
+ acceptInvite,
31
+ checkPermission,
32
+ listMembers,
33
+ ROLES,
34
+ } from '../workspaces.mjs';
35
+ import { getCurrentUserId, getCurrentWorkspaceId } from '../auth.mjs';
36
+ import { wrap } from './_shared.mjs';
37
+
38
+ /**
39
+ * @returns {import('express').Router}
40
+ */
41
+ export function createWorkspacesRouter() {
42
+ const router = Router();
43
+
44
+ // GET /api/workspaces — list user's workspaces
45
+ router.get('/workspaces', wrap(async (req, res) => {
46
+ const userId = getCurrentUserId(req);
47
+ if (!userId) {
48
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
49
+ }
50
+ const workspaces = await listWorkspacesWithRoles(userId);
51
+ res.json({ workspaces });
52
+ }));
53
+
54
+ // POST /api/workspaces — create workspace
55
+ router.post('/workspaces', wrap(async (req, res) => {
56
+ const userId = getCurrentUserId(req);
57
+ if (!userId) {
58
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
59
+ }
60
+ const { name } = req.body || {};
61
+ if (!name || typeof name !== 'string' || !name.trim()) {
62
+ return res.status(400).json({ error: 'bad_request', message: 'Workspace name is required' });
63
+ }
64
+ const workspace = await createWorkspace({ name: name.trim(), ownerId: userId });
65
+ res.status(201).json({ workspace });
66
+ }));
67
+
68
+ // GET /api/workspaces/:id — get workspace details
69
+ router.get('/workspaces/:id', wrap(async (req, res) => {
70
+ const userId = getCurrentUserId(req);
71
+ if (!userId) {
72
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
73
+ }
74
+ const { id } = req.params;
75
+ const workspace = await getWorkspace(id);
76
+ if (!workspace) {
77
+ return res.status(404).json({ error: 'not_found', message: 'Workspace not found' });
78
+ }
79
+ const hasAccess = await checkPermission(id, userId, ROLES.VIEWER);
80
+ if (!hasAccess) {
81
+ return res.status(403).json({ error: 'forbidden', message: 'Not a member of this workspace' });
82
+ }
83
+ const members = await listMembers(id);
84
+ res.json({ workspace, members });
85
+ }));
86
+
87
+ // DELETE /api/workspaces/:id — delete workspace (admin only)
88
+ router.delete('/workspaces/:id', wrap(async (req, res) => {
89
+ const userId = getCurrentUserId(req);
90
+ if (!userId) {
91
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
92
+ }
93
+ const { id } = req.params;
94
+ const canDelete = await checkPermission(id, userId, ROLES.ADMIN);
95
+ if (!canDelete) {
96
+ return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
97
+ }
98
+ await deleteWorkspace(id);
99
+ res.json({ ok: true });
100
+ }));
101
+
102
+ // POST /api/workspaces/:id/invites — create invite
103
+ router.post('/workspaces/:id/invites', wrap(async (req, res) => {
104
+ const userId = getCurrentUserId(req);
105
+ if (!userId) {
106
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
107
+ }
108
+ const { id } = req.params;
109
+ const { email, role } = req.body || {};
110
+ if (!email || typeof email !== 'string') {
111
+ return res.status(400).json({ error: 'bad_request', message: 'Email is required' });
112
+ }
113
+ if (!role || !Object.values(ROLES).includes(role)) {
114
+ return res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
115
+ }
116
+ const canInvite = await checkPermission(id, userId, ROLES.ADMIN);
117
+ if (!canInvite) {
118
+ return res.status(403).json({ error: 'forbidden', message: 'Admin role required to invite' });
119
+ }
120
+ const result = await createInvite(id, email, role, userId);
121
+ res.status(201).json(result);
122
+ }));
123
+
124
+ // GET /api/workspaces/:id/invites — list pending invites
125
+ router.get('/workspaces/:id/invites', wrap(async (req, res) => {
126
+ const userId = getCurrentUserId(req);
127
+ if (!userId) {
128
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
129
+ }
130
+ const { id } = req.params;
131
+ const canView = await checkPermission(id, userId, ROLES.ADMIN);
132
+ if (!canView) {
133
+ return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
134
+ }
135
+ const invites = await listInvites(id);
136
+ res.json({ invites });
137
+ }));
138
+
139
+ // DELETE /api/workspaces/:id/invites/:token — revoke invite
140
+ router.delete('/workspaces/:id/invites/:token', wrap(async (req, res) => {
141
+ const userId = getCurrentUserId(req);
142
+ if (!userId) {
143
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
144
+ }
145
+ const { id, token } = req.params;
146
+ const canRevoke = await checkPermission(id, userId, ROLES.ADMIN);
147
+ if (!canRevoke) {
148
+ return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
149
+ }
150
+ await revokeInvite(id, token);
151
+ res.json({ ok: true });
152
+ }));
153
+
154
+ // POST /api/workspaces/:id/members/:userId — update member role
155
+ router.post('/workspaces/:id/members/:userId', wrap(async (req, res) => {
156
+ const userId = getCurrentUserId(req);
157
+ if (!userId) {
158
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
159
+ }
160
+ const { id, userId: targetUserId } = req.params;
161
+ const { role } = req.body || {};
162
+ if (!role || !Object.values(ROLES).includes(role)) {
163
+ return res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
164
+ }
165
+ const canUpdate = await checkPermission(id, userId, ROLES.ADMIN);
166
+ if (!canUpdate) {
167
+ return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
168
+ }
169
+ await updateMemberRole(id, targetUserId, role);
170
+ res.json({ ok: true, role });
171
+ }));
172
+
173
+ // DELETE /api/workspaces/:id/members/:userId — remove member
174
+ router.delete('/workspaces/:id/members/:userId', wrap(async (req, res) => {
175
+ const userId = getCurrentUserId(req);
176
+ if (!userId) {
177
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
178
+ }
179
+ const { id, userId: targetUserId } = req.params;
180
+ const canRemove = await checkPermission(id, userId, ROLES.ADMIN);
181
+ if (!canRemove) {
182
+ return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
183
+ }
184
+ await removeMember(id, targetUserId);
185
+ res.json({ ok: true });
186
+ }));
187
+
188
+ // POST /api/invites/:token/accept — accept invite
189
+ router.post('/invites/:token/accept', wrap(async (req, res) => {
190
+ const userId = getCurrentUserId(req);
191
+ if (!userId) {
192
+ return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
193
+ }
194
+ const { token } = req.params;
195
+ const { email, name } = req.body || {};
196
+ if (!email || !name) {
197
+ return res.status(400).json({ error: 'bad_request', message: 'Email and name are required' });
198
+ }
199
+ const result = await acceptInvite(token, userId, email, name);
200
+ res.json({ ok: true, workspace: result.workspace, role: result.role });
201
+ }));
202
+
203
+ return router;
204
+ }
@@ -56,6 +56,7 @@ import { readFileSync, existsSync } from 'node:fs';
56
56
  import { join } from 'node:path';
57
57
  import { homedir } from 'node:os';
58
58
  import { createConnection, isIP } from 'node:net';
59
+ import { warn as loggerWarn, error as loggerError } from './logger.mjs';
59
60
 
60
61
  const HOME = homedir();
61
62
 
@@ -321,6 +322,147 @@ export async function listOpencodeSessions(info, timeoutMs = 5000) {
321
322
 
322
323
  export const SERVE_INFO_FILE_PATHS = DEFAULT_SERVE_INFO_FILES;
323
324
 
325
+ // ── v5.0.0 — bug #4 — directory resolver (shared between route modules) ──
326
+ //
327
+ // Both `routes/opencode-sessions.mjs` and `routes/opencode-session-detail.mjs`
328
+ // need the same logic: given a sessionId and the plugin's serve-info, figure
329
+ // out which `directory` (worktree) to pass as a `?directory=…` query param
330
+ // to opencode's HTTP API. We previously had two copies of this resolver
331
+ // inline in each route; now there's one in serve-info.mjs so a fix here
332
+ // reaches both routes.
333
+ //
334
+ // Strategy (per issue #4 brief):
335
+ // 1. If serve.json has a `worktree` AND a fast probe (`GET
336
+ // /api/session/{id}?directory=<worktree>`) succeeds (HTTP 200), use
337
+ // the worktree — no need to list every opencode session.
338
+ // 2. Otherwise, list every session via `GET /api/session` and find the
339
+ // one whose id matches. Use its `location.directory` (or the legacy
340
+ // `worktree` field, if present) as the directory.
341
+ // 3. If neither yields a directory, return null — callers should 503
342
+ // so the UI can show "directory unknown" rather than sending the
343
+ // request with a blank `?directory=` (opencode 400s on that).
344
+ //
345
+ // `worktreeHasSession` is a small `GET /api/session/{id}?directory=...`
346
+ // probe used as a fast-path so we don't pay the cost of listing every
347
+ // session on every listMessages call. The endpoint returns 200 when the
348
+ // session exists in that worktree, 404 when it doesn't. We treat 2xx
349
+ // other than 200 as success (opencode has shipped both `200` and `204`
350
+ // on this endpoint across versions).
351
+
352
+ /**
353
+ * Lightweight probe — does `sessionId` exist in `worktree`?
354
+ *
355
+ * Implemented as `GET /api/session/{id}?directory={worktree}`. Returns
356
+ * `true` on 2xx, `false` on 404, `null` on network / auth / other
357
+ * errors (the caller should fall back to the slower list path and
358
+ * trust THAT result).
359
+ *
360
+ * Never throws.
361
+ *
362
+ * @param {ServeInfo} info
363
+ * @param {string} sessionId
364
+ * @param {string} worktree
365
+ * @param {number} [timeoutMs]
366
+ * @returns {Promise<boolean|null>}
367
+ */
368
+ async function worktreeHasSession(info, sessionId, worktree, timeoutMs = 2_500) {
369
+ if (!info || !sessionId || !worktree) return null;
370
+ const url = `${info.baseUrl}/api/session/${encodeURIComponent(sessionId)}?directory=${encodeURIComponent(worktree)}`;
371
+ const ac = new AbortController();
372
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
373
+ try {
374
+ const res = await fetch(url, {
375
+ method: 'GET',
376
+ headers: {
377
+ Authorization: buildAuthHeader(info),
378
+ Accept: 'application/json',
379
+ },
380
+ signal: ac.signal,
381
+ });
382
+ if (res.status >= 200 && res.status < 300) return true;
383
+ if (res.status === 404) return false;
384
+ // 401/403/5xx → don't trust the result, let the fallback path decide.
385
+ return null;
386
+ } catch {
387
+ return null;
388
+ } finally {
389
+ clearTimeout(timer);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Resolve the opencode `directory` (worktree) for a session.
395
+ *
396
+ * Per the brief:
397
+ * 1. If we have a worktree AND a fast probe confirms the session
398
+ * lives there, return it.
399
+ * 2. Otherwise, list every session and find the matching one; use
400
+ * its `location.directory` (or legacy `worktree`).
401
+ * 3. Return null if neither yields a directory.
402
+ *
403
+ * Signature is `(sessionId, serveInfo)` (positional, sessionId first)
404
+ * to match the brief. Callers that only have `serveInfo` can pass
405
+ * `null` for `sessionId` and the function will fall through to the
406
+ * list path.
407
+ *
408
+ * Never throws.
409
+ *
410
+ * @param {string|null|undefined} sessionId
411
+ * @param {ServeInfo|null|undefined} serveInfo
412
+ * @returns {Promise<string|null>}
413
+ */
414
+ export async function resolveSessionDirectory(sessionId, serveInfo) {
415
+ if (!serveInfo) return null;
416
+
417
+ // Fast path: probe the recorded worktree first. If the session lives
418
+ // there, we save the round-trip to /api/session.
419
+ if (typeof serveInfo.worktree === 'string' && serveInfo.worktree.length > 0) {
420
+ const probe = await worktreeHasSession(serveInfo, sessionId || '', serveInfo.worktree);
421
+ if (probe === true) return serveInfo.worktree;
422
+ }
423
+
424
+ // Slower fallback: list every session and find the match.
425
+ try {
426
+ const sessions = await listOpencodeSessions(serveInfo, 5_000);
427
+ if (Array.isArray(sessions)) {
428
+ const entry = sessionId
429
+ ? sessions.find(
430
+ (s) =>
431
+ s && (s.id === sessionId || (typeof s.sessionId === 'string' && s.sessionId === sessionId)),
432
+ )
433
+ : null;
434
+ if (entry) {
435
+ const dir = entry?.location?.directory;
436
+ if (typeof dir === 'string' && dir.length > 0) return dir;
437
+ // Defensive: some opencode builds store the worktree on the
438
+ // session at the top level instead of under `location`.
439
+ const legacy = entry?.worktree;
440
+ if (typeof legacy === 'string' && legacy.length > 0) return legacy;
441
+ }
442
+ // No matching session, but we have a worktree from serve.json —
443
+ // last-chance fallback. This mirrors the pre-v5.0.0 inline
444
+ // resolver behaviour so an unmatched session still routes to
445
+ // the plugin's recorded cwd rather than 503'ing.
446
+ if (!entry && typeof serveInfo.worktree === 'string' && serveInfo.worktree.length > 0) {
447
+ return serveInfo.worktree;
448
+ }
449
+ } else if (typeof serveInfo.worktree === 'string' && serveInfo.worktree.length > 0) {
450
+ // listOpencodeSessions returned null (serve offline / auth fail).
451
+ // Fall back to the worktree so we at least try the same
452
+ // directory the plugin recorded — the upstream 404/502 will
453
+ // surface a clearer error than a 503 directory_unknown.
454
+ return serveInfo.worktree;
455
+ }
456
+ } catch (err) {
457
+ loggerWarn('resolveSessionDirectory: listOpencodeSessions failed', {
458
+ sessionId: sessionId || null,
459
+ err: err instanceof Error ? err.message : String(err),
460
+ });
461
+ }
462
+
463
+ return null;
464
+ }
465
+
324
466
  // ── v3.5.4 (bug: dispatch stuck) — spawn helpers ─────────────────────────
325
467
  //
326
468
  // The dashboard's task delegator used to shell out to
@@ -473,8 +615,21 @@ export async function sendOpencodePrompt(info, opts, directory, timeoutMs = DEFA
473
615
  } catch (err) {
474
616
  const msg = err instanceof Error ? err.message : String(err);
475
617
  const isAbort = err instanceof Error && err.name === 'AbortError';
618
+ let cause = 'unknown';
619
+ if (isAbort) cause = 'timeout';
620
+ else if (err && typeof err === 'object') {
621
+ const code = /** @type {any} */ (err).code;
622
+ if (typeof code === 'string' && code.length > 0) {
623
+ cause = code.startsWith('ECONN') || code.startsWith('UND_ERR') || code === 'ENOTFOUND'
624
+ ? 'network'
625
+ : code;
626
+ } else {
627
+ cause = 'network';
628
+ }
629
+ }
476
630
  return {
477
631
  ok: false,
632
+ cause,
478
633
  error: isAbort ? `sendPrompt timed out after ${timeoutMs}ms` : `sendPrompt network error: ${msg}`,
479
634
  };
480
635
  } finally {
@@ -543,8 +698,25 @@ export async function listOpencodeMessages(info, sessionId, directory, timeoutMs
543
698
  } catch (err) {
544
699
  const msg = err instanceof Error ? err.message : String(err);
545
700
  const isAbort = err instanceof Error && err.name === 'AbortError';
701
+ // v5.0.0 — bug #4: surface a structured `cause` to the route so it
702
+ // can render a useful suggestion without substring-sniffing the
703
+ // error message. Prefers the underlying network error code
704
+ // (ECONNREFUSED, UND_ERR_SOCKET, …) when present.
705
+ let cause = 'unknown';
706
+ if (isAbort) cause = 'timeout';
707
+ else if (err && typeof err === 'object') {
708
+ const code = /** @type {any} */ (err).code;
709
+ if (typeof code === 'string' && code.length > 0) {
710
+ cause = code.startsWith('ECONN') || code.startsWith('UND_ERR') || code === 'ENOTFOUND'
711
+ ? 'network'
712
+ : code;
713
+ } else {
714
+ cause = 'network';
715
+ }
716
+ }
546
717
  return {
547
718
  ok: false,
719
+ cause,
548
720
  error: isAbort ? `listMessages timed out after ${timeoutMs}ms` : `listMessages network error: ${msg}`,
549
721
  };
550
722
  } finally {
@@ -0,0 +1,202 @@
1
+ /**
2
+ * src/server/voice-store.mjs
3
+ *
4
+ * v5.0.0 — Voice note persistence layer.
5
+ *
6
+ * Audio files are stored at:
7
+ * ~/.local/share/bizar/voice-notes/<id>.webm
8
+ *
9
+ * Markdown transcripts are written to the user's vault at:
10
+ * <vaultPath>/.obsidian/voice-notes/<id>.md
11
+ * or (if no vaultPath is set) to the default vault:
12
+ * ~/.local/share/bizar/voice-notes/transcripts/<id>.md
13
+ *
14
+ * Metadata index is at:
15
+ * ~/.local/share/bizar/voice-notes/index.json
16
+ */
17
+
18
+ import {
19
+ existsSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ renameSync,
23
+ mkdirSync,
24
+ readdirSync,
25
+ unlinkSync,
26
+ } from 'node:fs';
27
+ import { join, dirname } from 'node:path';
28
+ import { homedir } from 'node:os';
29
+ import { randomBytes } from 'node:crypto';
30
+ import { info, warn, child } from './logger.mjs';
31
+
32
+ const log = child({ module: 'voice-store' });
33
+
34
+ const HOME = homedir();
35
+ const VOICE_DIR = join(HOME, '.local', 'share', 'bizar', 'voice-notes');
36
+ const INDEX_FILE = join(VOICE_DIR, 'index.json');
37
+ const TRANSCRIPT_DIR = join(VOICE_DIR, 'transcripts');
38
+
39
+ // ── Paths ───────────────────────────────────────────────────────────────────
40
+
41
+ function audioPath(id) {
42
+ return join(VOICE_DIR, `${id}.webm`);
43
+ }
44
+
45
+ function transcriptPath(id, vaultPath) {
46
+ if (vaultPath) {
47
+ const dir = join(vaultPath, '.obsidian', 'voice-notes');
48
+ return join(dir, `${id}.md`);
49
+ }
50
+ return join(TRANSCRIPT_DIR, `${id}.md`);
51
+ }
52
+
53
+ // ── Atomic helpers ──────────────────────────────────────────────────────────
54
+
55
+ function atomicWriteJson(filePath, data) {
56
+ const tmp = `${filePath}.tmp.${process.pid}`;
57
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
58
+ renameSync(tmp, filePath);
59
+ }
60
+
61
+ // ── Index ───────────────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * @typedef {Object} VoiceNote
65
+ * @property {string} id
66
+ * @property {string} audioPath
67
+ * @property {string|null} notePath - markdown transcript path (may not exist yet)
68
+ * @property {string|null} transcript
69
+ * @property {number|null} durationSec
70
+ * @property {string|null} vaultPath
71
+ * @property {number} createdAtMs
72
+ * @property {number} updatedAtMs
73
+ */
74
+
75
+ /** @returns {VoiceNote[]} */
76
+ function loadIndex() {
77
+ try {
78
+ if (!existsSync(INDEX_FILE)) return [];
79
+ const text = readFileSync(INDEX_FILE, 'utf8');
80
+ if (!text.trim()) return [];
81
+ return JSON.parse(text);
82
+ } catch {
83
+ return [];
84
+ }
85
+ }
86
+
87
+ function saveIndex(notes) {
88
+ mkdirSync(dirname(INDEX_FILE), { recursive: true });
89
+ atomicWriteJson(INDEX_FILE, notes);
90
+ }
91
+
92
+ // ── Public API ───────────────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Save a voice note: write audio file + markdown transcript + update index.
96
+ *
97
+ * @param {{ audioBuffer: Buffer|Uint8Array, transcript: string|null, durationSec: number|null, vaultPath: string|null }} opts
98
+ * @returns {Promise<{ notePath: string, audioPath: string, id: string }>}
99
+ */
100
+ export async function saveVoiceNote({ audioBuffer, transcript, durationSec, vaultPath }) {
101
+ const id = randomBytes(8).toString('hex');
102
+ const ap = audioPath(id);
103
+
104
+ // 1. Write audio (binary, no atomic needed for raw bytes — just ensure dir)
105
+ mkdirSync(dirname(ap), { recursive: true });
106
+ writeFileSync(ap, Buffer.from(audioBuffer));
107
+
108
+ // 2. Write markdown transcript if we have one
109
+ let notePath = null;
110
+ if (transcript) {
111
+ const tp = transcriptPath(id, vaultPath);
112
+ const tpDir = dirname(tp);
113
+ mkdirSync(tpDir, { recursive: true });
114
+ const md = [
115
+ '---',
116
+ `title: Voice Note ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`,
117
+ `date: ${new Date().toISOString()}`,
118
+ `type: voice-note`,
119
+ `audio: ${id}.webm`,
120
+ `duration: ${durationSec ?? '?'}`,
121
+ '---',
122
+ '',
123
+ `# ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}`,
124
+ '',
125
+ transcript,
126
+ ].join('\n');
127
+ writeFileSync(tp, md, 'utf8');
128
+ notePath = tp;
129
+ }
130
+
131
+ // 3. Update index
132
+ const notes = loadIndex();
133
+ /** @type {VoiceNote} */
134
+ const entry = {
135
+ id,
136
+ audioPath: ap,
137
+ notePath,
138
+ transcript: transcript || null,
139
+ durationSec: durationSec || null,
140
+ vaultPath: vaultPath || null,
141
+ createdAtMs: Date.now(),
142
+ updatedAtMs: Date.now(),
143
+ };
144
+ notes.unshift(entry); // newest first
145
+ saveIndex(notes);
146
+
147
+ log.info('voice note saved', { id, notePath, audioPath: ap });
148
+ return { notePath, audioPath: ap, id };
149
+ }
150
+
151
+ /**
152
+ * @param {{ vaultPath?: string|null, limit?: number }} opts
153
+ * @returns {VoiceNote[]}
154
+ */
155
+ export function listVoiceNotes({ vaultPath = null, limit = 50 } = {}) {
156
+ const notes = loadIndex();
157
+ const filtered = vaultPath
158
+ ? notes.filter((n) => n.vaultPath === vaultPath)
159
+ : notes;
160
+ return filtered.slice(0, limit);
161
+ }
162
+
163
+ /**
164
+ * @param {string} id
165
+ * @returns {VoiceNote|undefined}
166
+ */
167
+ export function getVoiceNote(id) {
168
+ return loadIndex().find((n) => n.id === id);
169
+ }
170
+
171
+ /**
172
+ * @param {string} id
173
+ * @returns {boolean}
174
+ */
175
+ export function deleteVoiceNote(id) {
176
+ const notes = loadIndex();
177
+ const idx = notes.findIndex((n) => n.id === id);
178
+ if (idx === -1) return false;
179
+
180
+ const { audioPath: ap, notePath: tp } = notes[idx];
181
+
182
+ // Remove audio file
183
+ try {
184
+ if (existsSync(ap)) unlinkSync(ap);
185
+ } catch (err) {
186
+ warn('failed to remove audio file', { id, ap, err: err.message });
187
+ }
188
+
189
+ // Remove transcript file
190
+ if (tp) {
191
+ try {
192
+ if (existsSync(tp)) unlinkSync(tp);
193
+ } catch (err) {
194
+ warn('failed to remove transcript file', { id, tp, err: err.message });
195
+ }
196
+ }
197
+
198
+ notes.splice(idx, 1);
199
+ saveIndex(notes);
200
+ log.info('voice note deleted', { id });
201
+ return true;
202
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * src/server/voice-transcribe.mjs
3
+ *
4
+ * v5.0.0 — Whisper transcription for voice notes.
5
+ *
6
+ * Uses OpenAI Whisper API (or a compatible BIZAR_WHISPER_ENDPOINT).
7
+ * Returns null on failure so callers can degrade gracefully.
8
+ */
9
+
10
+ import { info, warn, child } from './logger.mjs';
11
+
12
+ const log = child({ module: 'voice-transcribe' });
13
+
14
+ /**
15
+ * Transcribe an audio buffer via Whisper.
16
+ *
17
+ * @param {Buffer|Uint8Array} audioBuffer - raw audio bytes
18
+ * @param {{ mimeType?: string }} opts
19
+ * @returns {Promise<string|null>} transcript text or null
20
+ */
21
+ export async function transcribe(audioBuffer, { mimeType = 'audio/webm' } = {}) {
22
+ if (!process.env.OPENAI_API_KEY && !process.env.BIZAR_WHISPER_ENDPOINT) {
23
+ log.debug('no whisper credentials configured, skipping transcription');
24
+ return null;
25
+ }
26
+
27
+ const endpoint =
28
+ process.env.BIZAR_WHISPER_ENDPOINT || 'https://api.openai.com/v1/audio/transcriptions';
29
+
30
+ const body = new FormData();
31
+ body.append(
32
+ 'file',
33
+ new Blob([audioBuffer], { type: mimeType }),
34
+ 'audio.webm',
35
+ );
36
+ body.append('model', 'whisper-1');
37
+
38
+ const headers = {};
39
+ if (process.env.OPENAI_API_KEY) {
40
+ headers['Authorization'] = `Bearer ${process.env.OPENAI_API_KEY}`;
41
+ }
42
+ // BIZAR_WHISPER_ENDPOINT may be a local proxy that doesn't need auth
43
+ // but may also support Authorization header — only set if key exists
44
+
45
+ log.info('submitting audio to whisper', { endpoint, sizeBytes: audioBuffer.byteLength });
46
+
47
+ let resp;
48
+ try {
49
+ resp = await fetch(endpoint, { method: 'POST', body, headers });
50
+ } catch (err) {
51
+ warn('whisper fetch failed', { err: err.message });
52
+ return null;
53
+ }
54
+
55
+ if (!resp.ok) {
56
+ const text = await resp.text().catch(() => '');
57
+ warn('whisper returned error', { status: resp.status, body: text });
58
+ return null;
59
+ }
60
+
61
+ let data;
62
+ try {
63
+ data = await resp.json();
64
+ } catch {
65
+ warn('whisper returned non-JSON');
66
+ return null;
67
+ }
68
+
69
+ const text = typeof data.text === 'string' ? data.text.trim() : null;
70
+ log.info('transcription complete', { textLength: text?.length ?? 0 });
71
+ return text;
72
+ }