@homescriptone/opencode-viewer-extender 0.0.0-stage → 1.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.
@@ -0,0 +1,545 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ import {
4
+ PORT,
5
+ WRITE_ENABLED,
6
+ corsHeaders,
7
+ json,
8
+ notFound,
9
+ badRequest,
10
+ serveStatic,
11
+ partImageStmt,
12
+ allowedPartTypes,
13
+ sanitizePartData,
14
+ getString,
15
+ getPagination,
16
+ normalizeScope,
17
+ searchStmts,
18
+ globalSearchStmts,
19
+ projectStmt,
20
+ projectSessionsNoQueryStmt,
21
+ sessionsCountNoQueryStmt,
22
+ sessionStmt,
23
+ sessionMessagesStmt,
24
+ subagentsStmt,
25
+ SessionDetailRow,
26
+ JsonObject,
27
+ parseJSON,
28
+ extractPartImageDataUri,
29
+ newId,
30
+ updateRow,
31
+ insertProjectStmt,
32
+ insertSessionStmt,
33
+ insertMessageStmt,
34
+ insertPartStmt,
35
+ deleteProjectStmt,
36
+ deleteSessionStmt,
37
+ deleteMessageStmt,
38
+ deletePartStmt,
39
+ listPlugins,
40
+ } from "./database";
41
+
42
+ const ENTITY_ROUTES: Array<{
43
+ pattern: RegExp;
44
+ table: "project" | "session" | "message" | "part";
45
+ }> = [
46
+ { pattern: /^\/api\/projects\/([^/]+)$/, table: "project" },
47
+ { pattern: /^\/api\/sessions\/([^/]+)$/, table: "session" },
48
+ { pattern: /^\/api\/messages\/([^/]+)$/, table: "message" },
49
+ { pattern: /^\/api\/parts\/([^/]+)$/, table: "part" },
50
+ ];
51
+
52
+ const DELETE_BY_TABLE = {
53
+ project: deleteProjectStmt,
54
+ session: deleteSessionStmt,
55
+ message: deleteMessageStmt,
56
+ part: deletePartStmt,
57
+ } as const;
58
+
59
+ function matchEntity(pathname: string): { table: "project" | "session" | "message" | "part"; id: string } | null {
60
+ for (const route of ENTITY_ROUTES) {
61
+ const match = pathname.match(route.pattern);
62
+ if (match) return { table: route.table, id: decodeURIComponent(match[1]) };
63
+ }
64
+ return null;
65
+ }
66
+
67
+ async function readJsonBody(req: Request): Promise<JsonObject | null> {
68
+ try {
69
+ const parsed = (await req.json()) as unknown;
70
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
71
+ return parsed as JsonObject;
72
+ }
73
+ return null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function toJsonText(value: unknown): string | null {
80
+ if (typeof value === "string") return value;
81
+ if (value !== null && typeof value === "object") return JSON.stringify(value);
82
+ return null;
83
+ }
84
+
85
+ type SessionMessage = {
86
+ id: string;
87
+ _type?: "subagent";
88
+ time_created: number | string | null;
89
+ time_updated: number | string | null;
90
+ title?: string | null;
91
+ summary?: string | null;
92
+ role: string | null;
93
+ modelID: string | null;
94
+ providerID: string | null;
95
+ agent: string | null;
96
+ cost: number | null;
97
+ tokens: unknown;
98
+ parts: Array<{ id: string; time_created: number | string | null } & JsonObject>;
99
+ };
100
+
101
+ function buildSessionMessages(sessionId: string): SessionMessage[] {
102
+ const rows = sessionMessagesStmt.all(sessionId);
103
+ const subagentRows = subagentsStmt.all(sessionId);
104
+ const messageMap = new Map<string, SessionMessage>();
105
+
106
+ for (const row of rows) {
107
+ if (!messageMap.has(row.id)) {
108
+ const messageData = parseJSON(row.message_data);
109
+ const costValue = messageData?.cost;
110
+
111
+ messageMap.set(row.id, {
112
+ id: row.id,
113
+ time_created: row.time_created,
114
+ time_updated: row.time_updated,
115
+ role: getString(messageData?.role),
116
+ modelID: getString(messageData?.modelID),
117
+ providerID: getString(messageData?.providerID),
118
+ agent: getString(messageData?.agent),
119
+ cost: typeof costValue === "number" ? costValue : null,
120
+ tokens: messageData?.tokens ?? null,
121
+ parts: [],
122
+ });
123
+ }
124
+
125
+ if (!row.part_id || !row.part_data) continue;
126
+
127
+ const partData = parseJSON(row.part_data);
128
+ const partType = getString(partData?.type);
129
+ if (!partData || !partType || !allowedPartTypes.has(partType)) continue;
130
+
131
+ const message = messageMap.get(row.id);
132
+ if (!message) continue;
133
+
134
+ message.parts.push({
135
+ id: row.part_id,
136
+ time_created: row.part_time,
137
+ ...sanitizePartData(partData, partType),
138
+ });
139
+ }
140
+
141
+ const messages = Array.from(messageMap.values());
142
+
143
+ for (const sub of subagentRows) {
144
+ messages.push({
145
+ id: sub.id,
146
+ _type: "subagent",
147
+ time_created: sub.time_created,
148
+ time_updated: sub.time_updated,
149
+ title: sub.title,
150
+ summary: sub.summary,
151
+ role: null,
152
+ modelID: null,
153
+ providerID: null,
154
+ agent: null,
155
+ cost: null,
156
+ tokens: null,
157
+ parts: [],
158
+ });
159
+ }
160
+
161
+ messages.sort((a, b) => {
162
+ const taValue = Number(a.time_created);
163
+ const tbValue = Number(b.time_created);
164
+ const ta = Number.isFinite(taValue) ? taValue : 0;
165
+ const tb = Number.isFinite(tbValue) ? tbValue : 0;
166
+ return ta - tb;
167
+ });
168
+
169
+ return messages;
170
+ }
171
+
172
+ function stringifyValue(value: unknown): string {
173
+ if (value === null || value === undefined) return "";
174
+ if (typeof value === "string") return value;
175
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
176
+ try {
177
+ return JSON.stringify(value, null, 2);
178
+ } catch {
179
+ return String(value);
180
+ }
181
+ }
182
+
183
+ function slugify(value: string): string {
184
+ const slug = String(value ?? "")
185
+ .toLowerCase()
186
+ .replace(/[^a-z0-9]+/g, "-")
187
+ .replace(/^-+|-+$/g, "")
188
+ .slice(0, 60);
189
+ return slug || "session";
190
+ }
191
+
192
+ function sessionToMarkdown(session: SessionDetailRow, messages: SessionMessage[]): string {
193
+ const lines: string[] = [];
194
+ lines.push(`# ${session.title ?? "Untitled session"}`);
195
+ lines.push("");
196
+ lines.push(`- **Session ID:** \`${session.id}\``);
197
+ if (session.project_id) lines.push(`- **Project ID:** \`${session.project_id}\``);
198
+ if (session.directory) lines.push(`- **Directory:** \`${session.directory}\``);
199
+ const updated = Number(session.time_updated);
200
+ if (Number.isFinite(updated) && updated > 0) {
201
+ lines.push(`- **Updated:** ${new Date(updated).toISOString()}`);
202
+ }
203
+ lines.push("");
204
+
205
+ for (const message of messages) {
206
+ if (message._type === "subagent") {
207
+ lines.push(`## Subagent: ${message.title ?? message.id}`);
208
+ lines.push("");
209
+ continue;
210
+ }
211
+
212
+ const role = String(message.role ?? "unknown");
213
+ lines.push(`## ${role.charAt(0).toUpperCase()}${role.slice(1)}`);
214
+ if (message.modelID) lines.push(`*model: ${message.modelID}*`);
215
+ lines.push("");
216
+
217
+ for (const part of message.parts) {
218
+ const type = String(part.type ?? "text");
219
+ if (type === "text" || type === "reasoning") {
220
+ lines.push(stringifyValue(part.text).trim());
221
+ lines.push("");
222
+ } else if (type === "tool") {
223
+ lines.push(`### Tool: ${String(part.tool ?? "unknown")}`);
224
+ lines.push("```json");
225
+ lines.push(stringifyValue(part.state && (part.state as JsonObject).input) || "{}");
226
+ lines.push("```");
227
+ const output = part.state && (part.state as JsonObject).output;
228
+ if (output !== undefined && output !== null) {
229
+ lines.push("");
230
+ lines.push("Output:");
231
+ lines.push("```");
232
+ lines.push(stringifyValue(output).trim());
233
+ lines.push("```");
234
+ }
235
+ lines.push("");
236
+ } else if (type === "patch") {
237
+ const hash = String(part.hash ?? "").slice(0, 7);
238
+ lines.push(`### Patch \`${hash || "unknown"}\``);
239
+ lines.push("");
240
+ } else if (type === "file") {
241
+ lines.push(`### File: ${String(part.filename ?? "attachment")}`);
242
+ lines.push("");
243
+ }
244
+ }
245
+ }
246
+
247
+ return lines.join("\n");
248
+ }
249
+
250
+ function textResponse(body: string, contentType: string, filename: string): Response {
251
+ return new Response(body, {
252
+ headers: {
253
+ ...corsHeaders,
254
+ "Content-Type": `${contentType}; charset=utf-8`,
255
+ "Content-Disposition": `attachment; filename="${filename}"`,
256
+ },
257
+ });
258
+ }
259
+
260
+ async function handleWrite(req: Request, url: URL): Promise<Response> {
261
+ const { pathname } = url;
262
+ const method = req.method.toUpperCase();
263
+ const now = Date.now();
264
+
265
+ if (!WRITE_ENABLED) {
266
+ return json({ error: "Edit mode disabled. Start with --write (or OCV_WRITE=1)." }, 403);
267
+ }
268
+
269
+ if (method === "POST") {
270
+ const body = await readJsonBody(req);
271
+ if (!body) return badRequest("Invalid JSON body");
272
+
273
+ if (pathname === "/api/projects") {
274
+ const worktree = getString(body.worktree);
275
+ if (!worktree) return badRequest("worktree is required");
276
+ const id = getString(body.id) ?? newId("proj");
277
+ insertProjectStmt.run(id, worktree, getString(body.name), getString(body.icon_color), now, now);
278
+ return json({ ok: true, id }, 201);
279
+ }
280
+
281
+ if (pathname === "/api/sessions") {
282
+ const projectId = getString(body.project_id);
283
+ if (!projectId) return badRequest("project_id is required");
284
+ const id = getString(body.id) ?? newId("ses");
285
+ const parentId = getString(body.parent_id);
286
+ const slug = getString(body.slug) ?? newId("ses");
287
+ const directory = getString(body.directory) ?? "";
288
+ const title = getString(body.title) ?? "Untitled session";
289
+ const version = getString(body.version) ?? "0.0.0";
290
+ try {
291
+ insertSessionStmt.run(id, projectId, parentId, slug, directory, title, version, now, now);
292
+ } catch (error) {
293
+ return badRequest(`Failed to create session: ${String(error)}`);
294
+ }
295
+ return json({ ok: true, id }, 201);
296
+ }
297
+
298
+ const messagesMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/);
299
+ if (messagesMatch) {
300
+ const sessionId = decodeURIComponent(messagesMatch[1]);
301
+ const id = getString(body.id) ?? newId("msg");
302
+ const data = toJsonText(body.data) ?? "{}";
303
+ try {
304
+ insertMessageStmt.run(id, sessionId, now, now, data);
305
+ } catch (error) {
306
+ return badRequest(`Failed to create message: ${String(error)}`);
307
+ }
308
+ return json({ ok: true, id }, 201);
309
+ }
310
+
311
+ const partsMatch = pathname.match(/^\/api\/messages\/([^/]+)\/parts$/);
312
+ if (partsMatch) {
313
+ const messageId = decodeURIComponent(partsMatch[1]);
314
+ const sessionId = getString(body.session_id);
315
+ if (!sessionId) return badRequest("session_id is required");
316
+ const id = getString(body.id) ?? newId("prt");
317
+ const data = toJsonText(body.data) ?? "{}";
318
+ try {
319
+ insertPartStmt.run(id, messageId, sessionId, now, now, data);
320
+ } catch (error) {
321
+ return badRequest(`Failed to create part: ${String(error)}`);
322
+ }
323
+ return json({ ok: true, id }, 201);
324
+ }
325
+
326
+ return notFound();
327
+ }
328
+
329
+ if (method === "PATCH" || method === "PUT") {
330
+ const body = await readJsonBody(req);
331
+ if (!body) return badRequest("Invalid JSON body");
332
+ const target = matchEntity(pathname);
333
+ if (!target) return notFound();
334
+ const changes = updateRow(target.table, target.id, body);
335
+ return json({ ok: true, id: target.id, changed: changes });
336
+ }
337
+
338
+ if (method === "DELETE") {
339
+ const target = matchEntity(pathname);
340
+ if (!target) return notFound();
341
+ const result = DELETE_BY_TABLE[target.table].run(target.id);
342
+ return json({ ok: true, id: target.id, deleted: result.changes });
343
+ }
344
+
345
+ return json({ error: "Method not allowed" }, 405);
346
+ }
347
+
348
+ const server = Bun.serve({
349
+ port: PORT,
350
+ async fetch(req) {
351
+ const url = new URL(req.url);
352
+ const { pathname } = url;
353
+
354
+ if (req.method === "OPTIONS") {
355
+ return new Response(null, { status: 204, headers: corsHeaders });
356
+ }
357
+
358
+ if (req.method !== "GET") {
359
+ try {
360
+ return await handleWrite(req, url);
361
+ } catch (error) {
362
+ console.error("Request error:", error);
363
+ return json({ error: "Internal server error" }, 500);
364
+ }
365
+ }
366
+
367
+ try {
368
+ const partImageMatch = pathname.match(/^\/api\/parts\/([^/]+)\/image$/);
369
+ if (partImageMatch) {
370
+ const partId = decodeURIComponent(partImageMatch[1]);
371
+ const row = partImageStmt.get(partId) as { data: string } | null;
372
+
373
+ if (!row?.data) {
374
+ return notFound("Part not found");
375
+ }
376
+
377
+ const partData = parseJSON(row.data);
378
+ if (!partData) {
379
+ return notFound("Invalid part data");
380
+ }
381
+
382
+ const dataUri = extractPartImageDataUri(partData);
383
+ if (!dataUri) {
384
+ return notFound("No image in part");
385
+ }
386
+
387
+ const match = dataUri.match(/^data:(image\/[^;]+);base64,([\s\S]+)$/);
388
+ if (!match) {
389
+ return notFound("Invalid image data");
390
+ }
391
+
392
+ const mime = match[1];
393
+ const base64Payload = match[2].replace(/\s+/g, "");
394
+ const buffer = Buffer.from(base64Payload, "base64");
395
+
396
+ if (!buffer.length) {
397
+ return notFound("Invalid image data");
398
+ }
399
+
400
+ return new Response(buffer, {
401
+ headers: {
402
+ ...corsHeaders,
403
+ "Content-Type": mime,
404
+ "Cache-Control": "public, max-age=86400",
405
+ },
406
+ });
407
+ }
408
+
409
+ if (pathname === "/api/projects") {
410
+ const projects = projectStmt.all();
411
+ return json({ projects });
412
+ }
413
+
414
+ if (pathname === "/api/config") {
415
+ return json({ write: WRITE_ENABLED });
416
+ }
417
+
418
+ if (pathname === "/api/plugins") {
419
+ return json({ plugins: listPlugins() });
420
+ }
421
+
422
+ const projectSessionsMatch = pathname.match(/^\/api\/projects\/([^/]+)\/sessions$/);
423
+ if (projectSessionsMatch) {
424
+ const projectId = decodeURIComponent(projectSessionsMatch[1]);
425
+ const { page, limit, offset } = getPagination(url);
426
+ const qRaw = (url.searchParams.get("q") ?? "").trim();
427
+ const scope = normalizeScope(url.searchParams.get("scope"));
428
+
429
+ if (!qRaw) {
430
+ const totalRow = sessionsCountNoQueryStmt.get(projectId) as { total: number } | null;
431
+ const sessions = projectSessionsNoQueryStmt.all(projectId, limit, offset);
432
+
433
+ return json({
434
+ projectId,
435
+ page,
436
+ limit,
437
+ total: totalRow?.total ?? 0,
438
+ sessions,
439
+ q: "",
440
+ scope,
441
+ });
442
+ }
443
+
444
+ const needle = qRaw.toLowerCase();
445
+ const stmts = searchStmts[scope];
446
+ const totalRow = stmts.count.get(projectId, needle, needle) as { total: number } | null;
447
+ const sessions = stmts.list.all(projectId, needle, needle, limit, offset);
448
+
449
+ return json({
450
+ projectId,
451
+ page,
452
+ limit,
453
+ total: totalRow?.total ?? 0,
454
+ sessions,
455
+ q: qRaw,
456
+ scope,
457
+ });
458
+ }
459
+
460
+ if (pathname === "/api/sessions/search") {
461
+ const { page, limit, offset } = getPagination(url);
462
+ const qRaw = (url.searchParams.get("q") ?? "").trim();
463
+ const scope = normalizeScope(url.searchParams.get("scope"));
464
+
465
+ if (!qRaw) {
466
+ return json({
467
+ page,
468
+ limit,
469
+ total: 0,
470
+ sessions: [],
471
+ q: "",
472
+ scope,
473
+ });
474
+ }
475
+
476
+ const needle = qRaw.toLowerCase();
477
+ const stmts = globalSearchStmts[scope];
478
+ const totalRow = stmts.count.get(needle, needle) as { total: number } | null;
479
+ const sessions = stmts.list.all(needle, needle, limit, offset);
480
+
481
+ return json({
482
+ page,
483
+ limit,
484
+ total: totalRow?.total ?? 0,
485
+ sessions,
486
+ q: qRaw,
487
+ scope,
488
+ });
489
+ }
490
+
491
+ const exportMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/export$/);
492
+ if (exportMatch) {
493
+ const sessionId = decodeURIComponent(exportMatch[1]);
494
+ const session = sessionStmt.get(sessionId) as SessionDetailRow | null;
495
+
496
+ if (!session) {
497
+ return notFound("Session not found");
498
+ }
499
+
500
+ const messages = buildSessionMessages(sessionId);
501
+ const format = (url.searchParams.get("format") ?? "md").toLowerCase();
502
+ const slug = slugify(session.title ?? "session");
503
+
504
+ if (format === "json") {
505
+ return textResponse(
506
+ JSON.stringify({ exportedAt: new Date().toISOString(), session, messages }, null, 2),
507
+ "application/json",
508
+ `${slug}.json`,
509
+ );
510
+ }
511
+
512
+ return textResponse(sessionToMarkdown(session, messages), "text/markdown", `${slug}.md`);
513
+ }
514
+
515
+ const sessionDetailMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/);
516
+ if (sessionDetailMatch) {
517
+ const sessionId = decodeURIComponent(sessionDetailMatch[1]);
518
+ const session = sessionStmt.get(sessionId) as SessionDetailRow | null;
519
+
520
+ if (!session) {
521
+ return notFound("Session not found");
522
+ }
523
+
524
+ return json({
525
+ session,
526
+ messages: buildSessionMessages(sessionId),
527
+ });
528
+ }
529
+
530
+ const subagentsMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/subagents$/);
531
+ if (subagentsMatch) {
532
+ const sessionId = decodeURIComponent(subagentsMatch[1]);
533
+ const subagents = subagentsStmt.all(sessionId);
534
+ return json({ sessionId, subagents });
535
+ }
536
+
537
+ return await serveStatic(pathname);
538
+ } catch (error) {
539
+ console.error("Request error:", error);
540
+ return json({ error: "Internal server error" }, 500);
541
+ }
542
+ },
543
+ });
544
+
545
+ console.log(`OpenCode Viewer running at http://localhost:${server.port}`);