@polderlabs/bizar 4.8.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
- package/bizar-dash/dist/assets/index-DmpSFPJY.js +9 -0
- package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-ZAfGKENE.css} +1 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
- package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-DHXXbn1A.js.map} +1 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
- package/bizar-dash/dist/index.html +6 -3
- package/bizar-dash/dist/mobile.html +5 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/eval/SKILL.md +237 -0
- package/bizar-dash/src/server/api.mjs +28 -0
- package/bizar-dash/src/server/auth.mjs +155 -1
- package/bizar-dash/src/server/eval-store.mjs +226 -0
- package/bizar-dash/src/server/eval.mjs +347 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
- package/bizar-dash/src/server/memory-store.mjs +121 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/otel.mjs +133 -0
- package/bizar-dash/src/server/plugins/registry.mjs +363 -0
- package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
- package/bizar-dash/src/server/plugins/store.mjs +659 -0
- package/bizar-dash/src/server/routes/chat.mjs +246 -170
- package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
- package/bizar-dash/src/server/routes/eval.mjs +147 -0
- package/bizar-dash/src/server/routes/memory.mjs +46 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
- package/bizar-dash/src/server/routes/plugins.mjs +220 -0
- package/bizar-dash/src/server/routes/users.mjs +84 -0
- package/bizar-dash/src/server/routes/voice.mjs +131 -0
- package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
- package/bizar-dash/src/server/server.mjs +40 -0
- package/bizar-dash/src/server/voice-store.mjs +202 -0
- package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
- package/bizar-dash/src/server/workspaces.mjs +626 -0
- package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
- package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
- package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
- package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
- package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
- package/bizar-dash/src/web/lib/search.ts +115 -0
- package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
- package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
- package/bizar-dash/src/web/styles/memory.css +166 -1
- package/bizar-dash/src/web/styles/settings.css +80 -0
- package/bizar-dash/src/web/views/Memory.tsx +22 -2
- package/bizar-dash/src/web/views/Settings.tsx +99 -0
- package/bizar-dash/src/web/views/Workspace.tsx +294 -0
- package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
- package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
- package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +73 -0
- package/bizar-dash/tests/clipboard.test.mjs +147 -0
- package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
- package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
- package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
- package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
- package/bizar-dash/tests/docker-build.test.mjs +96 -0
- package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
- package/bizar-dash/tests/eval/report.test.mjs +284 -0
- package/bizar-dash/tests/eval/runner.test.mjs +471 -0
- package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
- package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
- package/bizar-dash/tests/memory-graph.test.mjs +95 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/otel.test.mjs +188 -0
- package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
- package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
- package/bizar-dash/tests/plugins-store.test.mjs +455 -0
- package/bizar-dash/tests/users.test.mjs +108 -0
- package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
- package/bizar-dash/tests/voice-store.test.mjs +148 -0
- package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
- package/bizar-dash/tests/workspaces.test.mjs +527 -0
- package/cli/bin.mjs +72 -2
- package/cli/commands/clip.mjs +146 -0
- package/cli/commands/dash.mjs +6 -0
- package/cli/commands/deploy/cloudflare.mjs +250 -0
- package/cli/commands/deploy/docker.mjs +221 -0
- package/cli/commands/deploy/fly.mjs +161 -0
- package/cli/commands/deploy/vercel.mjs +225 -0
- package/cli/commands/deploy.mjs +240 -0
- package/cli/commands/eval.mjs +378 -0
- package/cli/commands/marketplace.mjs +64 -0
- package/cli/commands/ocr.mjs +165 -0
- package/cli/commands/plugin.mjs +358 -0
- package/cli/commands/voice.mjs +211 -0
- package/cli/commands/workspace.mjs +247 -0
- package/package.json +12 -2
- package/templates/deploy/cloudflare/README.md +32 -0
- package/templates/deploy/cloudflare/functions-index.template.js +15 -0
- package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
- package/templates/deploy/docker/.env.template +16 -0
- package/templates/deploy/docker/README.md +58 -0
- package/templates/deploy/docker/docker-compose.template.yml +23 -0
- package/templates/deploy/fly/README.md +35 -0
- package/templates/deploy/fly/fly.toml.template +28 -0
- package/templates/deploy/vercel/README.md +29 -0
- package/templates/deploy/vercel/api-index.template.js +18 -0
- package/templates/deploy/vercel/vercel.json.template +16 -0
- package/templates/eval-fixtures/README.md +58 -0
- package/templates/eval-fixtures/code-search-basic.json +28 -0
- package/templates/eval-fixtures/latency-bounds.json +16 -0
- package/templates/eval-fixtures/regression-suite.json +79 -0
- package/templates/eval-fixtures/response-format.json +30 -0
- package/templates/eval-fixtures/tool-call-correctness.json +24 -0
- package/templates/plugin-template/README.md +121 -0
- package/templates/plugin-template/index.js +66 -0
- package/templates/plugin-template/plugin.json +42 -0
- package/templates/plugin-template/tests/plugin.test.js +83 -0
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-Chvf9u_B.js +0 -1
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
* - Upstream SSE error: clean up and return without crashing.
|
|
23
23
|
*/
|
|
24
24
|
import { Router } from 'express';
|
|
25
|
+
import { SpanStatusCode } from '@opentelemetry/api';
|
|
25
26
|
import { warn as logWarn } from '../logger.mjs';
|
|
27
|
+
import { tracer } from '../otel.mjs';
|
|
26
28
|
import {
|
|
27
29
|
existsSync,
|
|
28
30
|
mkdirSync,
|
|
@@ -109,11 +111,31 @@ export function createChatRouter({ state, broadcast }) {
|
|
|
109
111
|
});
|
|
110
112
|
router.use(chatLimiter);
|
|
111
113
|
|
|
114
|
+
// v4.9.0 — Wrap chat reads in a span. Status flips to ERROR only on
|
|
115
|
+
// thrown errors; a 200 with empty history is still OK. We bind the
|
|
116
|
+
// session id up front so a trace collector can filter by chat
|
|
117
|
+
// session across the SSE pump span opened further downstream.
|
|
112
118
|
router.get('/chat', wrap(async (req, res) => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
return tracer.startActiveSpan('chat.history', async (span) => {
|
|
120
|
+
try {
|
|
121
|
+
const sessionId = req.query.session ? String(req.query.session) : null;
|
|
122
|
+
const requestedLimit = req.query.limit ? Number(req.query.limit) : 200;
|
|
123
|
+
const limit = Math.min(
|
|
124
|
+
500,
|
|
125
|
+
Math.max(1, Number.isFinite(requestedLimit) ? requestedLimit : 200),
|
|
126
|
+
);
|
|
127
|
+
span.setAttribute('chat.session_id', sessionId || '');
|
|
128
|
+
span.setAttribute('chat.history_limit', limit);
|
|
129
|
+
res.json(state.getChat({ sessionId, limit }));
|
|
130
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
131
|
+
} catch (err) {
|
|
132
|
+
span.recordException(err);
|
|
133
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
134
|
+
throw err;
|
|
135
|
+
} finally {
|
|
136
|
+
span.end();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
117
139
|
}));
|
|
118
140
|
|
|
119
141
|
// ── POST /api/chat ─────────────────────────────────────────────────────
|
|
@@ -123,186 +145,240 @@ export function createChatRouter({ state, broadcast }) {
|
|
|
123
145
|
// 5: Instead of polling, subscribe to SSE and stream deltas via WS.
|
|
124
146
|
// 6: On session.idle, persist + broadcast final message, return 200.
|
|
125
147
|
//
|
|
148
|
+
// v4.9.0 — POST /api/chat now runs entirely inside a 'chat.send'
|
|
149
|
+
// span. The span's status is driven by the final HTTP status code
|
|
150
|
+
// (captured via `res.on('finish')`), so every early-return branch —
|
|
151
|
+
// 202 queued fallback, 502 upstream error, 503 subscription cap,
|
|
152
|
+
// 400 missing message — automatically lands on the right span
|
|
153
|
+
// status. The async SSE pump launched at the tail of the handler is
|
|
154
|
+
// intentionally NOT parented to this span: that work continues for
|
|
155
|
+
// seconds after the response flushes, and riding one span for the
|
|
156
|
+
// whole pump would defeat the point of distributed tracing.
|
|
126
157
|
router.post('/chat', wrap(async (req, res) => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
mkdirSync(sessionsDir, { recursive: true });
|
|
143
|
-
const requestedSessionId = typeof body.session === 'string' ? body.session.trim() : '';
|
|
144
|
-
chatSessionId = SESSION_ID_RE.test(requestedSessionId)
|
|
145
|
-
? requestedSessionId
|
|
146
|
-
: `sess_${Date.now().toString(36)}`;
|
|
147
|
-
file = join(sessionsDir, `${chatSessionId}.jsonl`);
|
|
148
|
-
record = {
|
|
149
|
-
id: `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
150
|
-
ts: new Date().toISOString(),
|
|
151
|
-
role: 'user',
|
|
152
|
-
agent: body.agent || null,
|
|
153
|
-
model: body.model || null,
|
|
154
|
-
content: message,
|
|
155
|
-
attachments: body.attachments || [],
|
|
158
|
+
return tracer.startActiveSpan('chat.send', async (span) => {
|
|
159
|
+
let spanEnded = false;
|
|
160
|
+
const finishSpan = () => {
|
|
161
|
+
if (spanEnded) return;
|
|
162
|
+
spanEnded = true;
|
|
163
|
+
try {
|
|
164
|
+
const code = res.statusCode || 0;
|
|
165
|
+
if (code >= 400) {
|
|
166
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${code}` });
|
|
167
|
+
} else {
|
|
168
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
span.end();
|
|
172
|
+
}
|
|
156
173
|
};
|
|
174
|
+
res.once('finish', finishSpan);
|
|
157
175
|
try {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
176
|
+
const body = req.body || {};
|
|
177
|
+
const message = typeof body.message === 'string' ? body.message.trim() : '';
|
|
178
|
+
span.setAttribute('chat.message_length', message.length);
|
|
179
|
+
span.setAttribute('chat.requested_session', typeof body.session === 'string' ? body.session.trim() : '');
|
|
180
|
+
span.setAttribute('chat.requested_agent', typeof body.agent === 'string' ? body.agent : '');
|
|
181
|
+
if (!message) {
|
|
182
|
+
res.status(400).json({ error: 'bad_request', message: 'message is required' });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const active = projectsStore.active();
|
|
186
|
+
span.setAttribute('chat.has_active_project', active ? true : false);
|
|
187
|
+
|
|
188
|
+
// 1. Persist the user message to the per-project .jsonl log.
|
|
189
|
+
let chatSessionId = null;
|
|
190
|
+
let file = null;
|
|
191
|
+
let record = null;
|
|
192
|
+
const requestedSessionRaw = typeof body.session === 'string' ? body.session.trim() : '';
|
|
193
|
+
if (active) {
|
|
194
|
+
const dir = projectsStore.ensureProjectDir(active.id);
|
|
195
|
+
const sessionsDir = join(dir, 'sessions');
|
|
196
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
197
|
+
chatSessionId = SESSION_ID_RE.test(requestedSessionRaw)
|
|
198
|
+
? requestedSessionRaw
|
|
199
|
+
: `sess_${Date.now().toString(36)}`;
|
|
200
|
+
span.setAttribute('chat.session_id', chatSessionId);
|
|
201
|
+
file = join(sessionsDir, `${chatSessionId}.jsonl`);
|
|
202
|
+
record = {
|
|
203
|
+
id: `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
204
|
+
ts: new Date().toISOString(),
|
|
205
|
+
role: 'user',
|
|
206
|
+
agent: body.agent || null,
|
|
207
|
+
model: body.model || null,
|
|
208
|
+
content: message,
|
|
209
|
+
attachments: body.attachments || [],
|
|
210
|
+
};
|
|
211
|
+
try {
|
|
212
|
+
appendFileSync(file, JSON.stringify(record) + '\n', 'utf8');
|
|
213
|
+
} catch {
|
|
214
|
+
// best effort
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
chatSessionId = SESSION_ID_RE.test(requestedSessionRaw)
|
|
218
|
+
? requestedSessionRaw
|
|
219
|
+
: `sess_${Date.now().toString(36)}`;
|
|
220
|
+
span.setAttribute('chat.session_id', chatSessionId);
|
|
221
|
+
record = {
|
|
222
|
+
id: `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
223
|
+
ts: new Date().toISOString(),
|
|
224
|
+
role: 'user',
|
|
225
|
+
agent: body.agent || null,
|
|
226
|
+
model: body.model || null,
|
|
227
|
+
content: message,
|
|
228
|
+
attachments: body.attachments || [],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
177
231
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
232
|
+
state.appendActivity({
|
|
233
|
+
kind: 'chat.message',
|
|
234
|
+
agent: body.agent || null,
|
|
235
|
+
message: message.slice(0, 500),
|
|
236
|
+
});
|
|
237
|
+
broadcast({ type: 'chat:message', sessionId: chatSessionId, message: record });
|
|
238
|
+
|
|
239
|
+
// 2. No active project → legacy 202.
|
|
240
|
+
if (!active) {
|
|
241
|
+
res.status(202).json({
|
|
242
|
+
accepted: true,
|
|
243
|
+
agent: body.agent || null,
|
|
244
|
+
queued: true,
|
|
245
|
+
reason: 'no_active_project',
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
184
249
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
250
|
+
// 3. No plugin running → queued fallback.
|
|
251
|
+
const serveInfo = readServeInfo();
|
|
252
|
+
if (!serveInfo) {
|
|
253
|
+
res.status(202).json({
|
|
254
|
+
accepted: true,
|
|
255
|
+
agent: body.agent || null,
|
|
256
|
+
queued: true,
|
|
257
|
+
session: chatSessionId,
|
|
258
|
+
reason: 'plugin_offline',
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
194
262
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
263
|
+
const sessionsDir = join(projectsStore.ensureProjectDir(active.id), 'sessions');
|
|
264
|
+
const sidecarPath = join(sessionsDir, `${chatSessionId}.opencode.json`);
|
|
265
|
+
|
|
266
|
+
// 4. Resolve or create the opencode session that backs this chat.
|
|
267
|
+
let opencodeSessionId = null;
|
|
268
|
+
try {
|
|
269
|
+
if (existsSync(sidecarPath)) {
|
|
270
|
+
const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8'));
|
|
271
|
+
opencodeSessionId = sidecar?.opencodeSessionId || null;
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
opencodeSessionId = null;
|
|
275
|
+
}
|
|
276
|
+
span.setAttribute('chat.opencode_session_id', opencodeSessionId || '');
|
|
277
|
+
|
|
278
|
+
if (!opencodeSessionId) {
|
|
279
|
+
const agentName = body.agent || active.defaultAgent || 'odin';
|
|
280
|
+
const create = await createOpencodeSession(
|
|
281
|
+
serveInfo,
|
|
282
|
+
{ title: `Chat: ${agentName}`, agent: agentName },
|
|
283
|
+
active.path || serveInfo.worktree,
|
|
284
|
+
);
|
|
285
|
+
if (!create.ok || !create.sessionId) {
|
|
286
|
+
res.status(502).json({
|
|
287
|
+
error: 'create_session_failed',
|
|
288
|
+
message: create.error || 'failed to create opencode session',
|
|
289
|
+
session: chatSessionId,
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
opencodeSessionId = create.sessionId;
|
|
294
|
+
try {
|
|
295
|
+
const sidecar = {
|
|
296
|
+
opencodeSessionId,
|
|
297
|
+
agent: agentName,
|
|
298
|
+
createdAt: Date.now(),
|
|
299
|
+
chatSessionId,
|
|
300
|
+
};
|
|
301
|
+
writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2) + '\n', 'utf8');
|
|
302
|
+
} catch {
|
|
303
|
+
// best effort
|
|
304
|
+
}
|
|
305
|
+
}
|
|
206
306
|
|
|
207
|
-
|
|
208
|
-
|
|
307
|
+
// 5. POST the prompt.
|
|
308
|
+
const agentName = body.agent || active.defaultAgent || 'odin';
|
|
309
|
+
const send = await sendOpencodePrompt(
|
|
310
|
+
serveInfo,
|
|
311
|
+
{
|
|
312
|
+
sessionId: opencodeSessionId,
|
|
313
|
+
agent: agentName,
|
|
314
|
+
text: message,
|
|
315
|
+
messageID: record.id,
|
|
316
|
+
},
|
|
317
|
+
active.path || serveInfo.worktree,
|
|
318
|
+
);
|
|
319
|
+
if (!send.ok) {
|
|
320
|
+
res.status(502).json({
|
|
321
|
+
error: 'send_prompt_failed',
|
|
322
|
+
message: send.error || 'failed to send prompt to opencode',
|
|
323
|
+
session: chatSessionId,
|
|
324
|
+
opencodeSessionId,
|
|
325
|
+
});
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
209
328
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
329
|
+
// 6. Enforce the concurrent subscription cap.
|
|
330
|
+
if (activeChatSubscriptions >= MAX_CHAT_SUBSCRIPTIONS) {
|
|
331
|
+
res.status(503).json({
|
|
332
|
+
error: 'too_many_subscriptions',
|
|
333
|
+
message: `Chat subscription cap (${MAX_CHAT_SUBSCRIPTIONS}) reached; try again later.`,
|
|
334
|
+
accepted: true,
|
|
335
|
+
session: chatSessionId,
|
|
336
|
+
opencodeSessionId,
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
activeChatSubscriptions++;
|
|
220
341
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
serveInfo,
|
|
225
|
-
{ title: `Chat: ${agentName}`, agent: agentName },
|
|
226
|
-
active.path || serveInfo.worktree,
|
|
227
|
-
);
|
|
228
|
-
if (!create.ok || !create.sessionId) {
|
|
229
|
-
return res.status(502).json({
|
|
230
|
-
error: 'create_session_failed',
|
|
231
|
-
message: create.error || 'failed to create opencode session',
|
|
342
|
+
// 7. Return 200 immediately. The SSE subscription streams deltas via WS.
|
|
343
|
+
res.json({
|
|
344
|
+
accepted: true,
|
|
232
345
|
session: chatSessionId,
|
|
346
|
+
opencodeSessionId,
|
|
347
|
+
userMessage: record,
|
|
233
348
|
});
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
349
|
+
|
|
350
|
+
// 8. Subscribe to opencode SSE and forward deltas via WS broadcast.
|
|
351
|
+
// On session.idle: persist the final message, broadcast chat:message,
|
|
352
|
+
// then decrement the counter.
|
|
353
|
+
void streamOpencodeSession({
|
|
354
|
+
serveInfo,
|
|
238
355
|
opencodeSessionId,
|
|
239
|
-
|
|
240
|
-
createdAt: Date.now(),
|
|
356
|
+
directory: active.path || serveInfo.worktree,
|
|
241
357
|
chatSessionId,
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
358
|
+
agentName,
|
|
359
|
+
file,
|
|
360
|
+
record,
|
|
361
|
+
broadcast,
|
|
362
|
+
state,
|
|
363
|
+
onDone: () => {
|
|
364
|
+
activeChatSubscriptions = Math.max(0, activeChatSubscriptions - 1);
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if (!spanEnded) {
|
|
369
|
+
// Errors caught here propagate to `wrap()`, which writes
|
|
370
|
+
// an error JSON response and triggers `res.on('finish')`
|
|
371
|
+
// later. We must end the span NOW (before rethrowing) so
|
|
372
|
+
// finishSpan sees `spanEnded === true` and skips its own
|
|
373
|
+
// end — calling span.end() twice is invalid in the OTel
|
|
374
|
+
// API.
|
|
375
|
+
spanEnded = true;
|
|
376
|
+
span.recordException(err);
|
|
377
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
378
|
+
span.end();
|
|
379
|
+
}
|
|
380
|
+
throw err;
|
|
246
381
|
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// 5. POST the prompt.
|
|
250
|
-
const agentName = body.agent || active.defaultAgent || 'odin';
|
|
251
|
-
const send = await sendOpencodePrompt(
|
|
252
|
-
serveInfo,
|
|
253
|
-
{
|
|
254
|
-
sessionId: opencodeSessionId,
|
|
255
|
-
agent: agentName,
|
|
256
|
-
text: message,
|
|
257
|
-
messageID: record.id,
|
|
258
|
-
},
|
|
259
|
-
active.path || serveInfo.worktree,
|
|
260
|
-
);
|
|
261
|
-
if (!send.ok) {
|
|
262
|
-
return res.status(502).json({
|
|
263
|
-
error: 'send_prompt_failed',
|
|
264
|
-
message: send.error || 'failed to send prompt to opencode',
|
|
265
|
-
session: chatSessionId,
|
|
266
|
-
opencodeSessionId,
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// 6. Enforce the concurrent subscription cap.
|
|
271
|
-
if (activeChatSubscriptions >= MAX_CHAT_SUBSCRIPTIONS) {
|
|
272
|
-
return res.status(503).json({
|
|
273
|
-
error: 'too_many_subscriptions',
|
|
274
|
-
message: `Chat subscription cap (${MAX_CHAT_SUBSCRIPTIONS}) reached; try again later.`,
|
|
275
|
-
accepted: true,
|
|
276
|
-
session: chatSessionId,
|
|
277
|
-
opencodeSessionId,
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
activeChatSubscriptions++;
|
|
281
|
-
|
|
282
|
-
// 7. Return 200 immediately. The SSE subscription streams deltas via WS.
|
|
283
|
-
res.json({
|
|
284
|
-
accepted: true,
|
|
285
|
-
session: chatSessionId,
|
|
286
|
-
opencodeSessionId,
|
|
287
|
-
userMessage: record,
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
// 8. Subscribe to opencode SSE and forward deltas via WS broadcast.
|
|
291
|
-
// On session.idle: persist the final message, broadcast chat:message,
|
|
292
|
-
// then decrement the counter.
|
|
293
|
-
void streamOpencodeSession({
|
|
294
|
-
serveInfo,
|
|
295
|
-
opencodeSessionId,
|
|
296
|
-
directory: active.path || serveInfo.worktree,
|
|
297
|
-
chatSessionId,
|
|
298
|
-
agentName,
|
|
299
|
-
file,
|
|
300
|
-
record,
|
|
301
|
-
broadcast,
|
|
302
|
-
state,
|
|
303
|
-
onDone: () => {
|
|
304
|
-
activeChatSubscriptions = Math.max(0, activeChatSubscriptions - 1);
|
|
305
|
-
},
|
|
306
382
|
});
|
|
307
383
|
}));
|
|
308
384
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/routes/clipboard.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Web Clipper endpoints.
|
|
5
|
+
*
|
|
6
|
+
* Accepts content pasted from the browser extension or bookmarklet,
|
|
7
|
+
* saves it as a markdown note in the vault with YAML frontmatter.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Router } from 'express';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
let _wrap = null;
|
|
15
|
+
async function getShared() {
|
|
16
|
+
if (!_wrap) {
|
|
17
|
+
const mod = await import('./_shared.mjs');
|
|
18
|
+
_wrap = mod.wrap;
|
|
19
|
+
}
|
|
20
|
+
return { wrap: _wrap };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* In-memory logs for recent clips. Persisted to a JSON file for reboot survival.
|
|
25
|
+
*/
|
|
26
|
+
function getLogPath(projectRoot) {
|
|
27
|
+
return join(projectRoot, '.bizar', 'clipboard-log.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadLog(projectRoot) {
|
|
31
|
+
try {
|
|
32
|
+
const p = getLogPath(projectRoot);
|
|
33
|
+
if (!existsSync(p)) return [];
|
|
34
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function saveLog(projectRoot, log) {
|
|
41
|
+
const p = getLogPath(projectRoot);
|
|
42
|
+
mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
|
|
43
|
+
writeFileSync(p, JSON.stringify(log, null, 2) + '\n', 'utf8');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function appendLog(projectRoot, entry) {
|
|
47
|
+
const log = loadLog(projectRoot);
|
|
48
|
+
log.unshift(entry);
|
|
49
|
+
if (log.length > 100) log.length = 100; // keep last 100
|
|
50
|
+
saveLog(projectRoot, log);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function deleteLogEntry(projectRoot, id) {
|
|
54
|
+
const log = loadLog(projectRoot);
|
|
55
|
+
const idx = log.findIndex((e) => e.id === id);
|
|
56
|
+
if (idx === -1) return false;
|
|
57
|
+
log.splice(idx, 1);
|
|
58
|
+
saveLog(projectRoot, log);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function createClipboardRouter({ projectRoot }) {
|
|
63
|
+
const router = Router();
|
|
64
|
+
const { wrap } = await getShared();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* POST /api/clipboard/save
|
|
68
|
+
*
|
|
69
|
+
* Accepts { url, title, content, selection?, savedAt? }
|
|
70
|
+
* Saves a markdown note to the vault at clips/<slug>.md
|
|
71
|
+
*/
|
|
72
|
+
router.post('/clipboard/save', wrap(async (req, res) => {
|
|
73
|
+
const { url, title, content, selection, savedAt } = req.body || {};
|
|
74
|
+
if (!url && !content) {
|
|
75
|
+
res.status(400).json({ error: 'bad_request', message: 'url or content is required' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Build a safe slug from the title or URL
|
|
80
|
+
const slugBase = (title || url || 'clip')
|
|
81
|
+
.replace(/^https?:\/\//, '')
|
|
82
|
+
.replace(/[^a-zA-Z0-9_\- ]/g, '')
|
|
83
|
+
.trim()
|
|
84
|
+
.substring(0, 60)
|
|
85
|
+
.replace(/\s+/g, '_')
|
|
86
|
+
.toLowerCase() || 'clip';
|
|
87
|
+
|
|
88
|
+
const timestamp = Date.now();
|
|
89
|
+
const slug = `${slugBase}_${timestamp}`;
|
|
90
|
+
const relPath = `clips/${slug}.md`;
|
|
91
|
+
|
|
92
|
+
// Build frontmatter
|
|
93
|
+
const noteTitle = title || 'Web Clip';
|
|
94
|
+
const fm = {
|
|
95
|
+
title: noteTitle,
|
|
96
|
+
url: url || '',
|
|
97
|
+
savedAt: savedAt || new Date().toISOString(),
|
|
98
|
+
type: 'webclip',
|
|
99
|
+
tags: ['webclip'],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Build body
|
|
103
|
+
const body = [
|
|
104
|
+
selection ? `> ${selection.replace(/\n/g, '\n> ')}` : '',
|
|
105
|
+
'',
|
|
106
|
+
'## Source',
|
|
107
|
+
'',
|
|
108
|
+
`- **URL**: ${url || '(no URL)'}`,
|
|
109
|
+
`- **Date**: ${fm.savedAt}`,
|
|
110
|
+
'',
|
|
111
|
+
'## Content',
|
|
112
|
+
'',
|
|
113
|
+
'```html',
|
|
114
|
+
(content || '').substring(0, 50000),
|
|
115
|
+
'```',
|
|
116
|
+
'',
|
|
117
|
+
].join('\n');
|
|
118
|
+
|
|
119
|
+
// Write to vault
|
|
120
|
+
const vaultDir = join(projectRoot, '.obsidian');
|
|
121
|
+
mkdirSync(join(vaultDir, 'clips'), { recursive: true });
|
|
122
|
+
const notePath = join(vaultDir, relPath);
|
|
123
|
+
|
|
124
|
+
const noteContent = [
|
|
125
|
+
'---',
|
|
126
|
+
...Object.entries(fm).map(([k, v]) => `${k}: ${JSON.stringify(v)}`),
|
|
127
|
+
'---',
|
|
128
|
+
'',
|
|
129
|
+
body,
|
|
130
|
+
].join('\n');
|
|
131
|
+
|
|
132
|
+
writeFileSync(notePath, noteContent, 'utf8');
|
|
133
|
+
|
|
134
|
+
// Log for recent-clips listing
|
|
135
|
+
const entry = {
|
|
136
|
+
id: slug,
|
|
137
|
+
title: noteTitle,
|
|
138
|
+
url,
|
|
139
|
+
notePath: relPath,
|
|
140
|
+
savedAt: fm.savedAt,
|
|
141
|
+
};
|
|
142
|
+
appendLog(projectRoot, entry);
|
|
143
|
+
|
|
144
|
+
res.status(201).json({ ok: true, notePath: relPath, id: slug });
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* GET /api/clipboard/list
|
|
149
|
+
*
|
|
150
|
+
* Returns recent clips from the log.
|
|
151
|
+
*/
|
|
152
|
+
router.get('/clipboard/list', wrap(async (_req, res) => {
|
|
153
|
+
const clips = loadLog(projectRoot);
|
|
154
|
+
res.json({ clips });
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* DELETE /api/clipboard/:id
|
|
159
|
+
*
|
|
160
|
+
* Remove a clip from the log (does not delete the note file).
|
|
161
|
+
*/
|
|
162
|
+
router.delete('/clipboard/:id', wrap(async (req, res) => {
|
|
163
|
+
const { id } = req.params;
|
|
164
|
+
const ok = deleteLogEntry(projectRoot, id);
|
|
165
|
+
if (!ok) {
|
|
166
|
+
res.status(404).json({ error: 'not_found', message: `clip ${id} not found` });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
res.json({ ok: true });
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
return router;
|
|
173
|
+
}
|