@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,482 @@
1
+ import { Database, type SQLQueryBindings } from "bun:sqlite";
2
+ import { randomInt, randomUUID } from "node:crypto";
3
+ import { existsSync, readdirSync, statSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { extname, join, normalize } from "node:path";
6
+
7
+ export const PORT = Number(process.env.OCV_PORT) || randomInt(3000, 4500);
8
+ const DB_PATH =
9
+ process.env.OCV_DB_PATH ||
10
+ join(homedir(), ".local", "share", "opencode", "opencode.db");
11
+ export const PUBLIC_DIR = join(import.meta.dir, "..", "public");
12
+
13
+ export const WRITE_ENABLED = process.env.OCV_WRITE === "1";
14
+
15
+ if (!existsSync(DB_PATH)) {
16
+ console.error(`OpenCode database not found at: ${DB_PATH}`);
17
+ process.exit(1);
18
+ }
19
+
20
+ let db: Database;
21
+
22
+ try {
23
+ db = WRITE_ENABLED ? new Database(DB_PATH) : new Database(DB_PATH, { readonly: true });
24
+ } catch (error) {
25
+ console.error("Failed to open OpenCode database:", error);
26
+ process.exit(1);
27
+ }
28
+
29
+ if (WRITE_ENABLED) {
30
+ try {
31
+ db.exec("PRAGMA foreign_keys = ON");
32
+ } catch {
33
+ /* foreign keys already enabled or unsupported; cascades still defined in schema */
34
+ }
35
+ console.warn(
36
+ "Write mode ENABLED (OCV_WRITE=1): create/update/delete operations are available.",
37
+ );
38
+ }
39
+
40
+ export type JsonObject = Record<string, unknown>;
41
+
42
+ export type ProjectRow = {
43
+ id: string;
44
+ worktree: string | null;
45
+ name: string | null;
46
+ icon_color: string | null;
47
+ time_created: number | string | null;
48
+ time_updated: number | string | null;
49
+ };
50
+
51
+ export type SessionRow = {
52
+ id: string;
53
+ project_id?: string | null;
54
+ project_name?: string | null;
55
+ project_worktree?: string | null;
56
+ title: string | null;
57
+ summary: string | null;
58
+ directory: string | null;
59
+ time_created: number | string | null;
60
+ time_updated: number | string | null;
61
+ };
62
+
63
+ export type Scope = "all" | "user" | "assistant";
64
+
65
+ export type SessionDetailRow = {
66
+ id: string;
67
+ project_id: string | null;
68
+ parent_id: string | null;
69
+ title: string | null;
70
+ summary: string | null;
71
+ directory: string | null;
72
+ time_created: number | string | null;
73
+ time_updated: number | string | null;
74
+ };
75
+
76
+ export type MessagePartJoinRow = {
77
+ id: string;
78
+ time_created: number | string | null;
79
+ time_updated: number | string | null;
80
+ message_data: string | null;
81
+ part_id: string | null;
82
+ part_data: string | null;
83
+ part_time: number | string | null;
84
+ };
85
+
86
+ export const corsHeaders: HeadersInit = {
87
+ "Access-Control-Allow-Origin": "*",
88
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
89
+ "Access-Control-Allow-Headers": "Content-Type",
90
+ };
91
+
92
+ export const allowedPartTypes = new Set([
93
+ "text",
94
+ "reasoning",
95
+ "tool",
96
+ "patch",
97
+ "file",
98
+ "agent",
99
+ "step-finish",
100
+ "step-start",
101
+ "snapshot",
102
+ "compaction",
103
+ "retry",
104
+ ]);
105
+
106
+ export const projectStmt = db.query<ProjectRow, SQLQueryBindings[]>(
107
+ "SELECT id, worktree, name, icon_color, time_created, time_updated FROM project ORDER BY time_updated DESC",
108
+ );
109
+
110
+ export const sessionsCountNoQueryStmt = db.query<{ total: number }, SQLQueryBindings[]>(
111
+ `SELECT COUNT(*) as total
112
+ FROM session
113
+ WHERE project_id = ? AND parent_id IS NULL`,
114
+ );
115
+
116
+ export const projectSessionsNoQueryStmt = db.query<SessionRow, SQLQueryBindings[]>(
117
+ `SELECT id, title, NULL as summary, directory, time_created, time_updated
118
+ FROM session
119
+ WHERE project_id = ? AND parent_id IS NULL
120
+ ORDER BY time_updated DESC, id DESC
121
+ LIMIT ? OFFSET ?`,
122
+ );
123
+
124
+ export function buildSearchStatements(scope: Scope) {
125
+ const roleClause =
126
+ scope === "all" ? "" : `AND json_extract(m.data, '$.role') = '${scope}'`;
127
+ const commonWhere = `
128
+ WHERE s.project_id = ?
129
+ AND s.parent_id IS NULL
130
+ AND (
131
+ instr(lower(s.title), ?) > 0
132
+ OR EXISTS (
133
+ SELECT 1
134
+ FROM message m
135
+ JOIN part p ON p.message_id = m.id
136
+ WHERE m.session_id = s.id
137
+ AND json_extract(p.data, '$.type') IN ('text','reasoning')
138
+ AND instr(lower(json_extract(p.data, '$.text')), ?) > 0
139
+ ${roleClause}
140
+ )
141
+ )
142
+ `;
143
+
144
+ return {
145
+ list: db.query<SessionRow, SQLQueryBindings[]>(
146
+ `SELECT s.id, s.title, NULL as summary, s.directory, s.time_created, s.time_updated
147
+ FROM session s
148
+ ${commonWhere}
149
+ ORDER BY s.time_updated DESC, s.id DESC
150
+ LIMIT ? OFFSET ?`,
151
+ ),
152
+ count: db.query<{ total: number }, SQLQueryBindings[]>(
153
+ `SELECT COUNT(*) as total
154
+ FROM session s
155
+ ${commonWhere}`,
156
+ ),
157
+ };
158
+ }
159
+
160
+ export const searchStmts: Record<Scope, ReturnType<typeof buildSearchStatements>> = {
161
+ all: buildSearchStatements("all"),
162
+ user: buildSearchStatements("user"),
163
+ assistant: buildSearchStatements("assistant"),
164
+ };
165
+
166
+ export function buildGlobalSearchStatements(scope: Scope) {
167
+ const roleClause =
168
+ scope === "all" ? "" : `AND json_extract(m.data, '$.role') = '${scope}'`;
169
+ const commonWhere = `
170
+ WHERE s.parent_id IS NULL
171
+ AND (
172
+ instr(lower(s.title), ?) > 0
173
+ OR EXISTS (
174
+ SELECT 1
175
+ FROM message m
176
+ JOIN part p ON p.message_id = m.id
177
+ WHERE m.session_id = s.id
178
+ AND json_extract(p.data, '$.type') IN ('text','reasoning')
179
+ AND instr(lower(json_extract(p.data, '$.text')), ?) > 0
180
+ ${roleClause}
181
+ )
182
+ )
183
+ `;
184
+
185
+ return {
186
+ list: db.query<SessionRow, SQLQueryBindings[]>(
187
+ `SELECT s.id, s.project_id, project.name as project_name, project.worktree as project_worktree,
188
+ s.title, NULL as summary, s.directory, s.time_created, s.time_updated
189
+ FROM session s
190
+ LEFT JOIN project ON project.id = s.project_id
191
+ ${commonWhere}
192
+ ORDER BY s.time_updated DESC, s.id DESC
193
+ LIMIT ? OFFSET ?`,
194
+ ),
195
+ count: db.query<{ total: number }, SQLQueryBindings[]>(
196
+ `SELECT COUNT(*) as total
197
+ FROM session s
198
+ ${commonWhere}`,
199
+ ),
200
+ };
201
+ }
202
+
203
+ export const globalSearchStmts: Record<Scope, ReturnType<typeof buildGlobalSearchStatements>> = {
204
+ all: buildGlobalSearchStatements("all"),
205
+ user: buildGlobalSearchStatements("user"),
206
+ assistant: buildGlobalSearchStatements("assistant"),
207
+ };
208
+
209
+ export const sessionStmt = db.query<SessionDetailRow, SQLQueryBindings[]>(
210
+ "SELECT id, project_id, parent_id, title, NULL as summary, directory, time_created, time_updated FROM session WHERE id = ?",
211
+ );
212
+
213
+ export const sessionMessagesStmt = db.query<MessagePartJoinRow, SQLQueryBindings[]>(
214
+ `SELECT m.id, m.time_created, m.time_updated, m.data as message_data,
215
+ p.id as part_id, p.data as part_data, p.time_created as part_time
216
+ FROM message m
217
+ LEFT JOIN part p ON p.message_id = m.id
218
+ WHERE m.session_id = ?
219
+ ORDER BY m.time_created ASC, p.time_created ASC`,
220
+ );
221
+
222
+ export const subagentsStmt = db.query<SessionRow, SQLQueryBindings[]>(
223
+ `SELECT id, title, NULL as summary, NULL as directory, time_created, time_updated
224
+ FROM session
225
+ WHERE parent_id = ?
226
+ ORDER BY time_created ASC`,
227
+ );
228
+
229
+ export const partImageStmt = db.query<{ data: string }, SQLQueryBindings[]>("SELECT data FROM part WHERE id = ?");
230
+
231
+ export function listPlugins(): string[] {
232
+ const pluginsDir = join(PUBLIC_DIR, "plugins");
233
+ if (!existsSync(pluginsDir)) return [];
234
+ return readdirSync(pluginsDir)
235
+ .filter((name) => name.endsWith(".js"))
236
+ .sort();
237
+ }
238
+
239
+ export function newId(prefix: string): string {
240
+ return `${prefix}_${randomUUID().replace(/-/g, "")}`;
241
+ }
242
+
243
+ export const insertProjectStmt = db.query(
244
+ `INSERT INTO project (id, worktree, name, icon_color, time_created, time_updated, sandboxes)
245
+ VALUES (?, ?, ?, ?, ?, ?, '[]')`,
246
+ );
247
+
248
+ export const insertSessionStmt = db.query(
249
+ `INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated)
250
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
251
+ );
252
+
253
+ export const insertMessageStmt = db.query(
254
+ `INSERT INTO message (id, session_id, time_created, time_updated, data)
255
+ VALUES (?, ?, ?, ?, ?)`,
256
+ );
257
+
258
+ export const insertPartStmt = db.query(
259
+ `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
260
+ VALUES (?, ?, ?, ?, ?, ?)`,
261
+ );
262
+
263
+ export const deleteProjectStmt = db.query("DELETE FROM project WHERE id = ?");
264
+ export const deleteSessionStmt = db.query("DELETE FROM session WHERE id = ?");
265
+ export const deleteMessageStmt = db.query("DELETE FROM message WHERE id = ?");
266
+ export const deletePartStmt = db.query("DELETE FROM part WHERE id = ?");
267
+
268
+ const UPDATABLE_COLUMNS: Record<string, string[]> = {
269
+ project: ["worktree", "name", "icon_color", "icon_url", "icon_url_override", "vcs"],
270
+ session: [
271
+ "project_id",
272
+ "parent_id",
273
+ "title",
274
+ "slug",
275
+ "directory",
276
+ "version",
277
+ "share_url",
278
+ "agent",
279
+ "model",
280
+ "summary_additions",
281
+ "summary_deletions",
282
+ "summary_files",
283
+ "time_archived",
284
+ "time_compacting",
285
+ ],
286
+ message: ["session_id", "data"],
287
+ part: ["message_id", "session_id", "data"],
288
+ };
289
+
290
+ export function updateRow(
291
+ table: keyof typeof UPDATABLE_COLUMNS,
292
+ id: string,
293
+ fields: JsonObject,
294
+ ): number {
295
+ const allowed = UPDATABLE_COLUMNS[table];
296
+ const keys = Object.keys(fields).filter((key) => allowed.includes(key));
297
+ const values = keys.map((key) => fields[key] as string | number | null);
298
+
299
+ keys.push("time_updated");
300
+ values.push(Date.now());
301
+
302
+ const setClause = keys.map((key) => `${key} = ?`).join(", ");
303
+ const statement = db.query(`UPDATE ${table} SET ${setClause} WHERE id = ?`);
304
+ const result = statement.run(...values, id);
305
+ return result.changes;
306
+ }
307
+
308
+ export function parseJSON(value: string | null): JsonObject | null {
309
+ if (!value) return null;
310
+
311
+ try {
312
+ const parsed = JSON.parse(value) as unknown;
313
+ if (parsed && typeof parsed === "object") return parsed as JsonObject;
314
+ return null;
315
+ } catch {
316
+ return null;
317
+ }
318
+ }
319
+
320
+ export function getString(value: unknown): string | null {
321
+ return typeof value === "string" ? value : null;
322
+ }
323
+
324
+ export function isBase64ImageData(value: unknown): boolean {
325
+ return typeof value === "string" && value.startsWith("data:image");
326
+ }
327
+
328
+ export function sanitizePartData(partData: JsonObject, partType: string): JsonObject {
329
+ const sanitizedPartData: JsonObject = { ...partData };
330
+
331
+ if (partType === "file") {
332
+ const fileUrl = getString(sanitizedPartData.url);
333
+ if (fileUrl && fileUrl.startsWith("data:image")) {
334
+ sanitizedPartData.url = "[base64-image]";
335
+ sanitizedPartData.hasImage = true;
336
+ }
337
+ }
338
+
339
+ if (partType === "tool") {
340
+ const state = sanitizedPartData.state;
341
+ if (state && typeof state === "object" && !Array.isArray(state)) {
342
+ const stateObject = state as JsonObject;
343
+ const attachments = stateObject.attachments;
344
+
345
+ if (Array.isArray(attachments)) {
346
+ const hasBase64ImageAttachment = attachments.some((attachment) => {
347
+ if (isBase64ImageData(attachment)) {
348
+ return true;
349
+ }
350
+
351
+ if (attachment && typeof attachment === "object") {
352
+ const attachmentObject = attachment as JsonObject;
353
+ return (
354
+ isBase64ImageData(attachmentObject.url) ||
355
+ isBase64ImageData(attachmentObject.data) ||
356
+ isBase64ImageData(attachmentObject.content)
357
+ );
358
+ }
359
+
360
+ return false;
361
+ });
362
+
363
+ if (hasBase64ImageAttachment) {
364
+ sanitizedPartData.state = {
365
+ ...stateObject,
366
+ attachments: [],
367
+ };
368
+ sanitizedPartData.hasAttachments = true;
369
+ }
370
+ }
371
+ }
372
+ }
373
+
374
+ return sanitizedPartData;
375
+ }
376
+
377
+ export function extractPartImageDataUri(partData: JsonObject): string | null {
378
+ const url = partData.url;
379
+ if (typeof url === "string" && url.startsWith("data:image")) {
380
+ return url;
381
+ }
382
+
383
+ const state = partData.state;
384
+ if (!state || typeof state !== "object" || Array.isArray(state)) {
385
+ return null;
386
+ }
387
+
388
+ const attachments = (state as JsonObject).attachments;
389
+ if (!Array.isArray(attachments)) {
390
+ return null;
391
+ }
392
+
393
+ for (const attachment of attachments) {
394
+ if (typeof attachment === "string" && attachment.startsWith("data:image")) {
395
+ return attachment;
396
+ }
397
+
398
+ if (attachment && typeof attachment === "object" && !Array.isArray(attachment)) {
399
+ const attachmentUrl = (attachment as JsonObject).url;
400
+ if (typeof attachmentUrl === "string" && attachmentUrl.startsWith("data:image")) {
401
+ return attachmentUrl;
402
+ }
403
+ }
404
+ }
405
+
406
+ return null;
407
+ }
408
+
409
+ export function getPagination(url: URL): { page: number; limit: number; offset: number } {
410
+ const pageParam = Number(url.searchParams.get("page") ?? "1");
411
+ const limitParam = Number(url.searchParams.get("limit") ?? "50");
412
+
413
+ const page = Number.isFinite(pageParam) && pageParam > 0 ? Math.floor(pageParam) : 1;
414
+ const limit =
415
+ Number.isFinite(limitParam) && limitParam > 0 ? Math.min(Math.floor(limitParam), 200) : 50;
416
+
417
+ return { page, limit, offset: (page - 1) * limit };
418
+ }
419
+
420
+ export function normalizeScope(raw: string | null): Scope {
421
+ const value = (raw ?? "all").toLowerCase();
422
+ return value === "user" || value === "assistant" ? value : "all";
423
+ }
424
+
425
+ export function json(data: unknown, status = 200): Response {
426
+ return new Response(JSON.stringify(data), {
427
+ status,
428
+ headers: {
429
+ ...corsHeaders,
430
+ "Content-Type": "application/json; charset=utf-8",
431
+ },
432
+ });
433
+ }
434
+
435
+ export function withCors(response: Response): Response {
436
+ const headers = new Headers(response.headers);
437
+ for (const [key, value] of Object.entries(corsHeaders)) {
438
+ headers.set(key, value);
439
+ }
440
+ return new Response(response.body, {
441
+ status: response.status,
442
+ statusText: response.statusText,
443
+ headers,
444
+ });
445
+ }
446
+
447
+ export function notFound(message = "Not found"): Response {
448
+ return json({ error: message }, 404);
449
+ }
450
+
451
+ export function badRequest(message: string): Response {
452
+ return json({ error: message }, 400);
453
+ }
454
+
455
+ export async function serveStatic(pathname: string): Promise<Response> {
456
+ const relativePath = pathname === "/" ? "index.html" : pathname.slice(1);
457
+ const safePath = normalize(relativePath).replace(/^\.{2}(\/|\\|$)/, "");
458
+ const filePath = join(PUBLIC_DIR, safePath);
459
+
460
+ if (!filePath.startsWith(PUBLIC_DIR)) {
461
+ return notFound();
462
+ }
463
+
464
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
465
+ return notFound();
466
+ }
467
+
468
+ const file = Bun.file(filePath);
469
+
470
+ if (extname(filePath) === ".html") {
471
+ return withCors(
472
+ new Response(file, {
473
+ headers: {
474
+ "Content-Type": "text/html; charset=utf-8",
475
+ },
476
+ }),
477
+ );
478
+ }
479
+
480
+ return withCors(new Response(file));
481
+ }
482
+