@rejacky/opencode-insights 0.1.4 → 0.1.6

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/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ openDatabase,
3
4
  resolveCapturePath
4
- } from "./chunk-Q5ROKOOJ.js";
5
+ } from "./chunk-XCHFXQIL.js";
5
6
 
6
7
  // src/cli.ts
7
8
  import { execFile as execFile2 } from "child_process";
@@ -48,12 +49,55 @@ async function readRecentCaptures(options = {}) {
48
49
  const records = parseJsonlRecords(await readFile(jsonlPath, "utf8"));
49
50
  return records.slice(-Math.max(1, options.limit ?? 20)).reverse();
50
51
  }
52
+ async function readViewerCaptures(options = {}) {
53
+ const dbPath = resolveCapturePath(options);
54
+ const sqliteRecords = await readSqliteViewerCaptures(dbPath, options.limit ?? 5e3);
55
+ if (sqliteRecords) return sqliteRecords;
56
+ const jsonlPath = dbPath.endsWith(".sqlite") ? `${dbPath}.jsonl` : dbPath;
57
+ if (!existsSync(jsonlPath)) return [];
58
+ const records = parseJsonlRecords(await readFile(jsonlPath, "utf8"));
59
+ return records.filter((record) => isViewerCaptureKind(record.kind)).slice(-Math.max(1, options.limit ?? 5e3)).reverse();
60
+ }
61
+ async function readCaptureRecord(id, options = {}) {
62
+ const dbPath = resolveCapturePath(options);
63
+ if (!existsSync(dbPath)) return void 0;
64
+ try {
65
+ const db = await openDatabase(dbPath);
66
+ if (db) {
67
+ try {
68
+ const rows = db.all("select id, kind, timestamp, session_id, message_id, provider_id, model_id, payload_json from captures where id = ?", id);
69
+ const row = rows[0];
70
+ if (!row) return void 0;
71
+ return rowToCapture(row);
72
+ } finally {
73
+ db.close();
74
+ }
75
+ }
76
+ } catch {
77
+ }
78
+ try {
79
+ const escapedId = id.replace(/'/g, "'\\''");
80
+ const { stdout } = await execFileAsync("sqlite3", [
81
+ "-json",
82
+ dbPath,
83
+ `select id, kind, timestamp, session_id, message_id, provider_id, model_id, payload_json from captures where id = '${escapedId}'`
84
+ ], { maxBuffer: 128 * 1024 * 1024 });
85
+ if (!stdout.trim()) return void 0;
86
+ const rows = JSON.parse(stdout);
87
+ const row = rows[0];
88
+ if (!row) return void 0;
89
+ return rowToCapture(row);
90
+ } catch {
91
+ return void 0;
92
+ }
93
+ }
51
94
  function buildRequestHistory(records) {
52
95
  const sessions = /* @__PURE__ */ new Map();
53
96
  const messages = /* @__PURE__ */ new Map();
54
97
  const responses = /* @__PURE__ */ new Map();
55
- const responseByParent = /* @__PURE__ */ new Map();
98
+ const responsesByParent = /* @__PURE__ */ new Map();
56
99
  const requests = [];
100
+ const pendingSystemTransforms = [];
57
101
  const getSession = (sessionID) => {
58
102
  const existing = sessions.get(sessionID);
59
103
  if (existing) return existing;
@@ -84,7 +128,27 @@ function buildRequestHistory(records) {
84
128
  responses.set(key, created);
85
129
  return created;
86
130
  };
87
- for (const record of records.slice().sort((a, b) => a.timestamp - b.timestamp)) {
131
+ const addRequest = (request) => {
132
+ requests.push(request);
133
+ if (request.sessionID) {
134
+ getSession(request.sessionID).requests.push(request);
135
+ if (request.messageID) getMessage(request.sessionID, request.messageID, "user").requests.push(request);
136
+ }
137
+ };
138
+ const addResponseByParent = (response) => {
139
+ if (!response.parentID) return;
140
+ const key = `${response.sessionID}:${response.parentID}`;
141
+ const parentResponses = responsesByParent.get(key) ?? [];
142
+ if (!parentResponses.includes(response)) parentResponses.push(response);
143
+ responsesByParent.set(key, parentResponses);
144
+ };
145
+ const updateSessionPath = (session, path) => {
146
+ if (!isRecord(path)) return;
147
+ session.cwd = optionalString(path.cwd) ?? session.cwd;
148
+ session.root = optionalString(path.root) ?? session.root;
149
+ session.project = projectName(session.root) ?? projectName(session.cwd) ?? session.project;
150
+ };
151
+ for (const record of records.slice().sort((a, b) => a.timestamp - b.timestamp || a.id.localeCompare(b.id))) {
88
152
  if (record.sessionID) getSession(record.sessionID);
89
153
  if (record.kind === "chat.message") {
90
154
  const message = historyMessageFromChatMessage(record);
@@ -99,10 +163,11 @@ function buildRequestHistory(records) {
99
163
  if (!request.messageID && request.sessionID) {
100
164
  request.messageID = latestUserMessageBefore(messages, request.sessionID, request.timestamp)?.id;
101
165
  }
102
- requests.push(request);
103
- if (request.sessionID) {
104
- getSession(request.sessionID).requests.push(request);
105
- if (request.messageID) getMessage(request.sessionID, request.messageID, "user").requests.push(request);
166
+ if (record.kind === "experimental.chat.system.transform") {
167
+ pendingSystemTransforms.push(request);
168
+ } else {
169
+ attachPendingSystemTransform(pendingSystemTransforms, request);
170
+ addRequest(request);
106
171
  }
107
172
  }
108
173
  if (record.kind === "chat.headers") {
@@ -118,8 +183,10 @@ function buildRequestHistory(records) {
118
183
  const sessionID = optionalString(info.id) ?? optionalString(properties.sessionID);
119
184
  if (!sessionID) continue;
120
185
  const session = getSession(sessionID);
186
+ session.parentID = optionalString(info.parentID) ?? session.parentID;
121
187
  session.title = optionalString(info.title) ?? session.title;
122
188
  session.updatedAt = numberFromPath(info.time, "updated") ?? session.updatedAt;
189
+ updateSessionPath(session, info.path);
123
190
  continue;
124
191
  }
125
192
  if (type === "message.updated") {
@@ -128,6 +195,7 @@ function buildRequestHistory(records) {
128
195
  const messageID = optionalString(info.id);
129
196
  if (!sessionID || !messageID) continue;
130
197
  const role = optionalString(info.role) ?? "unknown";
198
+ updateSessionPath(getSession(sessionID), info.path);
131
199
  if (role === "assistant") {
132
200
  const response = getResponse(sessionID, messageID, role);
133
201
  response.createdAt = numberFromPath(info.time, "created") ?? response.createdAt;
@@ -137,7 +205,7 @@ function buildRequestHistory(records) {
137
205
  response.cost = typeof info.cost === "number" ? info.cost : response.cost;
138
206
  response.finish = optionalString(info.finish) ?? response.finish;
139
207
  response.events.push(record.payload);
140
- if (response.parentID) responseByParent.set(`${sessionID}:${response.parentID}`, response);
208
+ addResponseByParent(response);
141
209
  continue;
142
210
  }
143
211
  const message = getMessage(sessionID, messageID, role);
@@ -169,11 +237,18 @@ function buildRequestHistory(records) {
169
237
  message.text = text;
170
238
  }
171
239
  }
240
+ for (const request of pendingSystemTransforms) addRequest(request);
172
241
  for (const session of sessions.values()) {
173
242
  for (const message of session.messages) {
174
- message.response = responseByParent.get(`${message.sessionID}:${message.id}`);
175
- for (const request of message.requests) {
176
- if (requestShouldOwnAssistantResponse(request)) request.response = message.response;
243
+ const messageResponses = (responsesByParent.get(`${message.sessionID}:${message.id}`) ?? []).sort(
244
+ (a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)
245
+ );
246
+ message.response = messageResponses.at(-1);
247
+ let responseIndex = 0;
248
+ for (const request of message.requests.slice().sort((a, b) => a.timestamp - b.timestamp)) {
249
+ if (!requestShouldOwnAssistantResponse(request)) continue;
250
+ request.response = messageResponses[responseIndex] ?? message.response;
251
+ if (responseIndex < messageResponses.length) responseIndex += 1;
177
252
  }
178
253
  }
179
254
  session.messages.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
@@ -184,14 +259,21 @@ function buildRequestHistory(records) {
184
259
  requests: requests.sort((a, b) => b.timestamp - a.timestamp)
185
260
  };
186
261
  }
262
+ function ensureEventTypeColumn(db) {
263
+ const existing = db.all("select name from pragma_table_info('captures') where name = 'event_type'");
264
+ if (existing.length === 0) {
265
+ db.run(`alter table captures add column event_type text`);
266
+ }
267
+ db.run(`update captures set event_type = json_extract(payload_json, '$.event.type') where kind = 'event' and event_type is null`);
268
+ }
187
269
  async function readSqliteCaptures(path, limit) {
188
270
  if (!existsSync(path)) return void 0;
189
271
  try {
190
- const mod = await import("bun:sqlite").catch(() => void 0);
191
- if (!mod) return readSqliteCapturesWithCli(path, limit);
192
- const db = new mod.Database(path, { readonly: true });
272
+ const db = await openDatabase(path);
273
+ if (!db) return readSqliteCapturesWithCli(path, limit);
193
274
  try {
194
- const rows = db.query(recentCaptureSql(Math.max(1, limit))).all();
275
+ ensureEventTypeColumn(db);
276
+ const rows = db.all(recentCaptureSql(Math.max(1, limit)));
195
277
  return dedupeRows(rows).map(rowToCapture);
196
278
  } finally {
197
279
  db.close();
@@ -200,30 +282,86 @@ async function readSqliteCaptures(path, limit) {
200
282
  return readSqliteCapturesWithCli(path, limit);
201
283
  }
202
284
  }
203
- async function readSqliteCapturesWithCli(path, limit) {
204
- if (!existsSync(path)) return void 0;
285
+ async function runSqlite3Json(path, sql) {
205
286
  try {
206
- const { stdout } = await execFileAsync("sqlite3", ["-json", path, recentCaptureSql(Math.max(1, Math.trunc(limit)))], {
287
+ const { stdout } = await execFileAsync("sqlite3", ["-json", path, sql], {
207
288
  maxBuffer: 128 * 1024 * 1024
208
289
  });
209
- if (!stdout.trim()) return [];
210
- return dedupeRows(JSON.parse(stdout)).map(rowToCapture);
290
+ return stdout;
211
291
  } catch {
212
292
  return void 0;
213
293
  }
214
294
  }
295
+ async function readSqliteCapturesWithCli(path, limit) {
296
+ if (!existsSync(path)) return void 0;
297
+ await runSqlite3Json(path, "alter table captures add column event_type text");
298
+ await runSqlite3Json(path, "update captures set event_type = json_extract(payload_json, '$.event.type') where kind = 'event' and event_type is null");
299
+ const stdout = await runSqlite3Json(path, recentCaptureSql(Math.max(1, Math.trunc(limit))));
300
+ if (!stdout?.trim()) return [];
301
+ return dedupeRows(JSON.parse(stdout)).map(rowToCapture);
302
+ }
303
+ async function readSqliteViewerCaptures(path, limit) {
304
+ if (!existsSync(path)) return void 0;
305
+ try {
306
+ const db = await openDatabase(path);
307
+ if (!db) return readSqliteViewerCapturesWithCli(path, limit);
308
+ try {
309
+ ensureEventTypeColumn(db);
310
+ const rows = db.all(viewerCaptureSql(Math.max(1, limit)));
311
+ return dedupeRows(rows).map(rowToCapture);
312
+ } finally {
313
+ db.close();
314
+ }
315
+ } catch {
316
+ return readSqliteViewerCapturesWithCli(path, limit);
317
+ }
318
+ }
319
+ async function readSqliteViewerCapturesWithCli(path, limit) {
320
+ if (!existsSync(path)) return void 0;
321
+ await runSqlite3Json(path, "alter table captures add column event_type text");
322
+ await runSqlite3Json(path, "update captures set event_type = json_extract(payload_json, '$.event.type') where kind = 'event' and event_type is null");
323
+ const stdout = await runSqlite3Json(path, viewerCaptureSql(Math.max(1, Math.trunc(limit))));
324
+ if (!stdout?.trim()) return [];
325
+ return dedupeRows(JSON.parse(stdout)).map(rowToCapture);
326
+ }
215
327
  function recentCaptureSql(limit) {
216
328
  return `select id, kind, timestamp, session_id, message_id, provider_id, model_id, payload_json
217
329
  from captures
218
330
  where id in (
219
- select id from captures order by timestamp desc limit ${limit}
331
+ select id from captures
332
+ where kind in (
333
+ 'chat.params',
334
+ 'chat.message',
335
+ 'chat.headers',
336
+ 'experimental.chat.messages.transform',
337
+ 'experimental.chat.system.transform'
338
+ )
339
+ order by timestamp desc
340
+ limit ${limit}
220
341
  )
221
342
  or id in (
343
+ select id from captures
344
+ where kind = 'event'
345
+ and event_type in (
346
+ 'message.updated',
347
+ 'message.part.updated',
348
+ 'message.part.delta',
349
+ 'session.updated',
350
+ 'session.created'
351
+ )
352
+ order by timestamp desc
353
+ limit ${limit}
354
+ )
355
+ order by timestamp desc`;
356
+ }
357
+ function viewerCaptureSql(limit) {
358
+ return `select id, kind, timestamp, session_id, message_id, provider_id, model_id, payload_json
359
+ from captures
360
+ where id in (
222
361
  select id from captures
223
362
  where kind in (
224
363
  'chat.params',
225
364
  'chat.message',
226
- 'experimental.chat.messages.transform',
227
365
  'experimental.chat.system.transform'
228
366
  )
229
367
  order by timestamp desc
@@ -232,12 +370,21 @@ function recentCaptureSql(limit) {
232
370
  or id in (
233
371
  select id from captures
234
372
  where kind = 'event'
235
- and json_extract(payload_json, '$.event.type') in ('message.updated', 'message.part.updated', 'message.part.delta')
373
+ and event_type in (
374
+ 'message.updated',
375
+ 'message.part.updated',
376
+ 'message.part.delta',
377
+ 'session.updated',
378
+ 'session.created'
379
+ )
236
380
  order by timestamp desc
237
381
  limit ${limit}
238
382
  )
239
383
  order by timestamp desc`;
240
384
  }
385
+ function isViewerCaptureKind(kind) {
386
+ return kind === "chat.params" || kind === "chat.message" || kind === "experimental.chat.system.transform" || kind === "event";
387
+ }
241
388
  function dedupeRows(rows) {
242
389
  const seen = /* @__PURE__ */ new Set();
243
390
  return rows.filter((row) => {
@@ -297,18 +444,21 @@ function agentFromCapture(record, input) {
297
444
  return optionalString(input.agent);
298
445
  }
299
446
  function messageIDForCapture(record, input) {
300
- if (record.kind === "experimental.chat.messages.transform") {
301
- return latestUserMessageIDFromTransform(record.payload.output) ?? record.messageID;
302
- }
303
447
  return record.messageID ?? messageIDFromPayload(input.message);
304
448
  }
305
- function latestUserMessageIDFromTransform(output) {
306
- if (!isRecord(output) || !Array.isArray(output.messages)) return void 0;
307
- for (const message of output.messages.slice().reverse()) {
308
- if (!isRecord(message) || !isRecord(message.info)) continue;
309
- if (optionalString(message.info.role) === "user") return optionalString(message.info.id);
449
+ function attachPendingSystemTransform(pendingSystemTransforms, request) {
450
+ let index = -1;
451
+ for (let candidateIndex = pendingSystemTransforms.length - 1; candidateIndex >= 0; candidateIndex -= 1) {
452
+ const candidate = pendingSystemTransforms[candidateIndex];
453
+ if (candidate && candidate.sessionID === request.sessionID && candidate.providerID === request.providerID && candidate.modelID === request.modelID && candidate.timestamp <= request.timestamp && request.timestamp - candidate.timestamp <= 5e3) {
454
+ index = candidateIndex;
455
+ break;
456
+ }
310
457
  }
311
- return void 0;
458
+ if (index < 0) return;
459
+ const [system] = pendingSystemTransforms.splice(index, 1);
460
+ if (!system) return;
461
+ request.system = { id: system.id, timestamp: system.timestamp, payload: system.payload };
312
462
  }
313
463
  function latestUserMessageBefore(messages, sessionID, timestamp) {
314
464
  let latest;
@@ -373,6 +523,12 @@ function findFirstString(value, keys) {
373
523
  }
374
524
  return void 0;
375
525
  }
526
+ function projectName(path) {
527
+ if (!path) return void 0;
528
+ const normalized = path.replace(/\/+$/, "");
529
+ if (!normalized) return void 0;
530
+ return normalized.split("/").filter(Boolean).at(-1) ?? normalized;
531
+ }
376
532
  function numberFromPath(value, key) {
377
533
  if (!isRecord(value)) return void 0;
378
534
  const item = value[key];
@@ -381,6 +537,7 @@ function numberFromPath(value, key) {
381
537
 
382
538
  // src/viewer.ts
383
539
  import { createServer } from "http";
540
+ var REQUEST_PATH_RE = /^\/api\/request\/(.+)$/;
384
541
  async function serveViewer(options = {}) {
385
542
  const host = options.host ?? "127.0.0.1";
386
543
  const port = options.port ?? 8765;
@@ -391,6 +548,21 @@ async function serveViewer(options = {}) {
391
548
  sendJson(response, history);
392
549
  return;
393
550
  }
551
+ const requestMatch = REQUEST_PATH_RE.exec(url.pathname);
552
+ if (requestMatch) {
553
+ const requestId = requestMatch[1];
554
+ if (!requestId) {
555
+ sendJson(response, { error: "not found" });
556
+ return;
557
+ }
558
+ const record = await readCaptureRecord(requestId, options);
559
+ if (!record) {
560
+ sendJson(response, { error: "not found" });
561
+ return;
562
+ }
563
+ sendJson(response, record.payload);
564
+ return;
565
+ }
394
566
  if (url.pathname === "/" || url.pathname === "/index.html") {
395
567
  sendHtml(response, renderViewerHtml(resolveCapturePath(options)));
396
568
  return;
@@ -416,8 +588,152 @@ async function readHistory(options = {}) {
416
588
  dbPath: options.dbPath,
417
589
  limit: options.limit ?? 5e3
418
590
  };
419
- const records = await readRecentCaptures(readOptions);
420
- return buildRequestHistory(records);
591
+ const records = await readViewerCaptures(readOptions);
592
+ const history = buildRequestHistory(records);
593
+ prepareHistoryForViewer(history);
594
+ stripPayloadsForViewer(history);
595
+ return history;
596
+ }
597
+ function prepareHistoryForViewer(history) {
598
+ for (const session of history.sessions) {
599
+ for (const message of session.messages) {
600
+ const viewerMessage = message;
601
+ viewerMessage.visibleSteps = buildViewerVisibleSteps(message);
602
+ viewerMessage.hiddenContexts = buildViewerHiddenContexts(message);
603
+ }
604
+ }
605
+ }
606
+ function stripPayloadsForViewer(history) {
607
+ history.requests = history.requests.map(viewerRequestSummary);
608
+ for (const session of history.sessions) {
609
+ session.requests = [];
610
+ for (const message of session.messages) {
611
+ message.requests = [];
612
+ message.response = void 0;
613
+ }
614
+ }
615
+ }
616
+ function buildViewerVisibleSteps(message) {
617
+ const steps = [];
618
+ for (const request of (message.requests || []).filter((item) => item.agent !== "title" && item.agent !== "messages.transform")) {
619
+ const label = [request.agent || "agent", request.providerID, request.modelID].filter(Boolean).join(" \xB7 ");
620
+ const response = request.response;
621
+ const reasoning = response?.reasoning;
622
+ const text = response?.text;
623
+ if (reasoning) {
624
+ steps.push({ label: `${label} thinking`, text: reasoning });
625
+ }
626
+ for (const tool of viewerToolSteps(response)) {
627
+ steps.push({ label: `${label} tool`, text: tool });
628
+ }
629
+ if (text && normalizeDisplayText(text) !== normalizeDisplayText(reasoning)) {
630
+ steps.push({ label: `${label} response`, text });
631
+ }
632
+ if (!reasoning && !text && viewerToolSteps(response).length === 0) {
633
+ steps.push({ label, text: "Model step captured, but no visible thinking or response text was recorded." });
634
+ }
635
+ }
636
+ return steps;
637
+ }
638
+ function buildViewerHiddenContexts(message) {
639
+ const contexts = /* @__PURE__ */ new Map();
640
+ for (const request of message.requests || []) {
641
+ const step = [request.agent || "agent", request.providerID, request.modelID].filter(Boolean).join(" \xB7 ");
642
+ if (request.system?.payload?.output) {
643
+ addHiddenContext(contexts, "System Transform Output", step, request.system.payload.output);
644
+ }
645
+ if (request.agent === "messages.transform" && request.payload) {
646
+ addHiddenContext(contexts, "Messages Transform Output", step, request.payload.output || request.payload);
647
+ }
648
+ }
649
+ return [...contexts.values()];
650
+ }
651
+ function addHiddenContext(contexts, title, step, value) {
652
+ const text = hiddenContextText(value);
653
+ if (!text) return;
654
+ const key = `${title}:${normalizeDisplayText(text)}`;
655
+ const existing = contexts.get(key);
656
+ if (existing) {
657
+ existing.count += 1;
658
+ if (!existing.step.split(", ").includes(step)) existing.step = `${existing.step}, ${step}`;
659
+ return;
660
+ }
661
+ contexts.set(key, {
662
+ title,
663
+ step,
664
+ preview: previewText(text, 180),
665
+ text,
666
+ count: 1
667
+ });
668
+ }
669
+ function hiddenContextText(value) {
670
+ if (typeof value === "string") return value;
671
+ if (Array.isArray(value)) return value.map(hiddenContextText).filter(Boolean).join("\n\n");
672
+ if (!isRecord2(value)) return value === void 0 || value === null ? "" : String(value);
673
+ if (value.system !== void 0) return hiddenContextText(value.system);
674
+ if (Array.isArray(value.messages)) {
675
+ return value.messages.map(messageContextText).filter(Boolean).join("\n\n");
676
+ }
677
+ const strings = collectStrings(value);
678
+ return strings.length ? strings.join("\n\n") : JSON.stringify(value, null, 2);
679
+ }
680
+ function messageContextText(value) {
681
+ if (!isRecord2(value)) return hiddenContextText(value);
682
+ const info = isRecord2(value.info) ? value.info : {};
683
+ const role = typeof info.role === "string" ? info.role : "message";
684
+ const parts = Array.isArray(value.parts) ? value.parts : [];
685
+ const text = parts.map(partText).filter(Boolean).join("\n");
686
+ return text ? `${role}: ${text}` : `${role}: ${hiddenContextText(value)}`;
687
+ }
688
+ function partText(value) {
689
+ if (!isRecord2(value)) return "";
690
+ return typeof value.text === "string" ? value.text : typeof value.content === "string" ? value.content : "";
691
+ }
692
+ function viewerToolSteps(response) {
693
+ const tools = [];
694
+ const seen = /* @__PURE__ */ new Set();
695
+ for (const wrapper of response?.events || []) {
696
+ const event = isRecord2(wrapper.event) ? wrapper.event : void 0;
697
+ const properties = isRecord2(event?.properties) ? event.properties : void 0;
698
+ const part = isRecord2(properties?.part) ? properties.part : void 0;
699
+ if (event?.type !== "message.part.updated" || part?.type !== "tool") continue;
700
+ const state = isRecord2(part.state) ? part.state : void 0;
701
+ const key = [part.id, part.tool, state?.status].filter(Boolean).join(":");
702
+ if (seen.has(key)) continue;
703
+ seen.add(key);
704
+ tools.push([part.tool || "tool", state?.status].filter(Boolean).join(" \xB7 "));
705
+ }
706
+ return tools;
707
+ }
708
+ function collectStrings(value) {
709
+ if (typeof value === "string") return [value];
710
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
711
+ if (!isRecord2(value)) return [];
712
+ return Object.values(value).flatMap(collectStrings);
713
+ }
714
+ function previewText(value, size) {
715
+ const text = normalizeDisplayText(value);
716
+ return text.length > size ? `${text.slice(0, size - 1)}...` : text || "(empty)";
717
+ }
718
+ function normalizeDisplayText(value) {
719
+ return String(value || "").replace(/\s+/g, " ").trim();
720
+ }
721
+ function isRecord2(value) {
722
+ return !!value && typeof value === "object" && !Array.isArray(value);
723
+ }
724
+ function viewerRequestSummary(request) {
725
+ return {
726
+ id: request.id,
727
+ sessionID: request.sessionID,
728
+ messageID: request.messageID,
729
+ timestamp: request.timestamp,
730
+ agent: request.agent,
731
+ purpose: request.purpose,
732
+ providerID: request.providerID,
733
+ modelID: request.modelID,
734
+ summary: request.summary,
735
+ payload: {}
736
+ };
421
737
  }
422
738
  function sendJson(response, value) {
423
739
  response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
@@ -439,8 +755,11 @@ function renderViewerHtml(dbPath) {
439
755
  :root {
440
756
  color-scheme: dark;
441
757
  --bg: #101214;
758
+ --header: #0d0f11;
442
759
  --panel: #171a1d;
443
760
  --panel-2: #20242a;
761
+ --surface: #0b0d0f;
762
+ --field: #0d0f11;
444
763
  --line: #30363d;
445
764
  --text: #eef2f5;
446
765
  --muted: #9aa6b2;
@@ -448,6 +767,32 @@ function renderViewerHtml(dbPath) {
448
767
  --ok: #86efac;
449
768
  --warn: #fbbf24;
450
769
  --bad: #fca5a5;
770
+ --pill-text: #0d0f11;
771
+ --json-key: #bae6fd;
772
+ --json-string: #bbf7d0;
773
+ --json-number: #fde68a;
774
+ --json-boolean: #f0abfc;
775
+ }
776
+ body[data-theme="light"] {
777
+ color-scheme: light;
778
+ --bg: #f6f7f9;
779
+ --header: #ffffff;
780
+ --panel: #ffffff;
781
+ --panel-2: #edf2f7;
782
+ --surface: #ffffff;
783
+ --field: #ffffff;
784
+ --line: #d7dde5;
785
+ --text: #18212f;
786
+ --muted: #667085;
787
+ --accent: #2563eb;
788
+ --ok: #16a34a;
789
+ --warn: #b45309;
790
+ --bad: #dc2626;
791
+ --pill-text: #ffffff;
792
+ --json-key: #1d4ed8;
793
+ --json-string: #15803d;
794
+ --json-number: #a16207;
795
+ --json-boolean: #9333ea;
451
796
  }
452
797
  * { box-sizing: border-box; }
453
798
  body {
@@ -465,9 +810,35 @@ function renderViewerHtml(dbPath) {
465
810
  gap: 16px;
466
811
  padding: 0 18px;
467
812
  border-bottom: 1px solid var(--line);
468
- background: #0d0f11;
813
+ background: var(--header);
469
814
  }
470
815
  h1 { margin: 0; font-size: 15px; font-weight: 700; }
816
+ .brand, .header-side {
817
+ display: flex;
818
+ align-items: center;
819
+ gap: 14px;
820
+ min-width: 0;
821
+ }
822
+ .header-meta { min-width: 0; }
823
+ .theme-toggle {
824
+ display: inline-flex;
825
+ gap: 2px;
826
+ padding: 3px;
827
+ border: 1px solid var(--line);
828
+ border-radius: 8px;
829
+ background: var(--panel);
830
+ }
831
+ .theme-toggle button {
832
+ padding: 5px 8px;
833
+ border: 0;
834
+ border-radius: 5px;
835
+ background: transparent;
836
+ color: var(--muted);
837
+ }
838
+ .theme-toggle button.active {
839
+ background: var(--panel-2);
840
+ color: var(--text);
841
+ }
471
842
  main {
472
843
  display: grid;
473
844
  grid-template-columns: 300px minmax(340px, 0.95fr) minmax(460px, 1.25fr);
@@ -486,15 +857,17 @@ function renderViewerHtml(dbPath) {
486
857
  border-bottom: 1px solid var(--line);
487
858
  z-index: 2;
488
859
  }
489
- input {
860
+ input, select {
490
861
  width: 100%;
491
862
  border: 1px solid var(--line);
492
- background: #0d0f11;
863
+ background: var(--field);
493
864
  color: var(--text);
494
865
  border-radius: 6px;
495
866
  padding: 8px 10px;
496
867
  font: inherit;
497
868
  }
869
+ select { cursor: pointer; }
870
+ .toolbar.stack { flex-direction: column; }
498
871
  button {
499
872
  border: 1px solid var(--line);
500
873
  background: var(--panel-2);
@@ -517,7 +890,7 @@ function renderViewerHtml(dbPath) {
517
890
  padding: 11px 12px;
518
891
  }
519
892
  .item:hover, .item.active { background: var(--panel-2); }
520
- .request-item { padding-left: 28px; }
893
+ .session-child { padding-left: 28px; }
521
894
  .title { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
522
895
  .meta {
523
896
  margin-top: 4px;
@@ -528,7 +901,7 @@ function renderViewerHtml(dbPath) {
528
901
  }
529
902
  .muted { color: var(--muted); }
530
903
  .pill {
531
- color: #0d0f11;
904
+ color: var(--pill-text);
532
905
  background: var(--accent);
533
906
  border-radius: 999px;
534
907
  padding: 1px 6px;
@@ -548,7 +921,7 @@ function renderViewerHtml(dbPath) {
548
921
  overflow: auto;
549
922
  white-space: pre-wrap;
550
923
  word-break: break-word;
551
- background: #0b0d0f;
924
+ background: var(--surface);
552
925
  border: 1px solid var(--line);
553
926
  border-radius: 8px;
554
927
  min-height: 220px;
@@ -567,14 +940,45 @@ function renderViewerHtml(dbPath) {
567
940
  color: var(--text);
568
941
  font-weight: 700;
569
942
  }
943
+ .step {
944
+ margin: 0 0 10px;
945
+ padding: 12px;
946
+ border: 1px solid var(--line);
947
+ border-radius: 8px;
948
+ background: var(--surface);
949
+ }
950
+ .step-label {
951
+ color: var(--accent);
952
+ font-weight: 700;
953
+ margin-bottom: 6px;
954
+ }
955
+ .step-text { white-space: pre-wrap; word-break: break-word; }
956
+ details.hidden-context {
957
+ margin: 10px 0;
958
+ border: 1px solid var(--line);
959
+ border-radius: 8px;
960
+ background: var(--surface);
961
+ }
962
+ details.hidden-context summary {
963
+ cursor: pointer;
964
+ padding: 10px 12px;
965
+ color: var(--accent);
966
+ }
967
+ .hidden-body { padding: 0 12px 12px; }
968
+ .hidden-text {
969
+ margin: 0;
970
+ white-space: pre-wrap;
971
+ word-break: break-word;
972
+ color: var(--text);
973
+ }
570
974
  .json-tree { font-size: 12px; line-height: 1.55; }
571
975
  .json-tree details { margin-left: 14px; }
572
976
  .json-tree summary { cursor: pointer; color: var(--accent); }
573
977
  .json-tree .leaf { margin-left: 14px; }
574
- .json-key { color: #bae6fd; }
575
- .json-string { color: #bbf7d0; }
576
- .json-number { color: #fde68a; }
577
- .json-boolean { color: #f0abfc; }
978
+ .json-key { color: var(--json-key); }
979
+ .json-string { color: var(--json-string); }
980
+ .json-number { color: var(--json-number); }
981
+ .json-boolean { color: var(--json-boolean); }
578
982
  .json-null { color: var(--muted); }
579
983
  @media (max-width: 1000px) {
580
984
  main { grid-template-columns: 1fr; height: auto; }
@@ -582,38 +986,44 @@ function renderViewerHtml(dbPath) {
582
986
  }
583
987
  </style>
584
988
  </head>
585
- <body>
989
+ <body data-theme="dark">
586
990
  <header>
587
- <h1>OpenCode Insights</h1>
588
- <div>
589
- <div class="meta">${escapedDbPath}</div>
590
- <div id="status" class="status">Loading history...</div>
991
+ <div class="brand">
992
+ <h1>OpenCode Insights</h1>
993
+ <div id="theme-toggle" class="theme-toggle" aria-label="Theme">
994
+ <button type="button" data-theme-option="dark">Dark</button>
995
+ <button type="button" data-theme-option="light">Light</button>
996
+ </div>
997
+ </div>
998
+ <div class="header-side">
999
+ <div class="header-meta">
1000
+ <div class="meta">${escapedDbPath}</div>
1001
+ <div id="status" class="status">Loading history...</div>
1002
+ </div>
591
1003
  </div>
592
1004
  </header>
593
1005
  <main>
594
1006
  <section>
595
- <div class="toolbar"><input id="session-filter" placeholder="Filter sessions"></div>
1007
+ <div class="toolbar stack">
1008
+ <select id="project-filter"><option value="">All projects</option></select>
1009
+ <input id="session-filter" placeholder="Filter sessions">
1010
+ </div>
596
1011
  <div id="sessions"></div>
597
1012
  </section>
598
1013
  <section>
599
- <div class="toolbar"><input id="timeline-filter" placeholder="Filter messages and hooks"></div>
1014
+ <div class="toolbar"><input id="timeline-filter" placeholder="Filter conversation"></div>
600
1015
  <div id="timeline"></div>
601
1016
  </section>
602
1017
  <section>
603
1018
  <div class="toolbar">
604
- <button id="copy">Copy JSON</button>
1019
+ <button id="copy">Copy Summary</button>
605
1020
  <button id="refresh">Refresh</button>
606
1021
  </div>
607
1022
  <div class="detail">
608
1023
  <div class="tabs">
609
1024
  <button data-tab="summary" class="active">Summary</button>
610
- <button data-tab="request">Request</button>
611
- <button data-tab="response">Response</button>
612
- <button data-tab="raw">Raw</button>
613
- <button id="expand-json" class="json-control">Expand All</button>
614
- <button id="collapse-json" class="json-control">Collapse All</button>
615
1025
  </div>
616
- <div id="detail" class="panel">Select a message or request.</div>
1026
+ <div id="detail" class="panel">Select a user message.</div>
617
1027
  </div>
618
1028
  </section>
619
1029
  </main>
@@ -624,11 +1034,14 @@ function renderViewerHtml(dbPath) {
624
1034
  messageID: null,
625
1035
  requestID: null,
626
1036
  tab: "summary",
1037
+ project: "",
627
1038
  loading: true,
628
1039
  error: null,
629
- loadedMs: 0
1040
+ loadedMs: 0,
1041
+ payloadCache: {}
630
1042
  };
631
1043
 
1044
+ const THEME_KEY = "opencode-insights-theme";
632
1045
  const qs = (id) => document.getElementById(id);
633
1046
  const fmt = (ms) => ms ? new Date(ms).toLocaleString() : "-";
634
1047
  const short = (value, size = 90) => {
@@ -636,11 +1049,20 @@ function renderViewerHtml(dbPath) {
636
1049
  return text.length > size ? text.slice(0, size - 1) + "..." : text;
637
1050
  };
638
1051
 
1052
+ function applyTheme(theme) {
1053
+ const next = theme === "light" ? "light" : "dark";
1054
+ document.body.dataset.theme = next;
1055
+ localStorage.setItem(THEME_KEY, next);
1056
+ for (const item of document.querySelectorAll("[data-theme-option]")) {
1057
+ item.classList.toggle("active", item.dataset.themeOption === next);
1058
+ }
1059
+ }
1060
+
639
1061
  async function load() {
640
1062
  state.loading = true;
641
1063
  state.error = null;
642
1064
  renderStatus();
643
- qs("sessions").innerHTML = '<div class="empty">Loading sessions and hooks...</div>';
1065
+ qs("sessions").innerHTML = '<div class="empty">Loading sessions...</div>';
644
1066
  qs("timeline").innerHTML = '<div class="empty">Waiting for history data...</div>';
645
1067
  try {
646
1068
  const started = performance.now();
@@ -654,8 +1076,9 @@ function renderViewerHtml(dbPath) {
654
1076
  state.messageID = null;
655
1077
  state.requestID = null;
656
1078
  }
1079
+ renderProjectOptions();
657
1080
  const session = selectedSession();
658
- if (session && !state.messageID && session.messages[0]) state.messageID = session.messages[0].id;
1081
+ if (session && !state.messageID) state.messageID = firstUserMessage(session)?.id || null;
659
1082
  } catch (error) {
660
1083
  state.error = error instanceof Error ? error.message : String(error);
661
1084
  } finally {
@@ -673,17 +1096,8 @@ function renderViewerHtml(dbPath) {
673
1096
  return session?.messages.find((message) => message.id === state.messageID) || null;
674
1097
  }
675
1098
 
676
- function selectedRequest() {
677
- const message = selectedMessage();
678
- return message?.requests.find((request) => request.id === state.requestID)
679
- || state.history.requests.find((request) => request.id === state.requestID)
680
- || null;
681
- }
682
-
683
- function activeContext() {
684
- const message = selectedMessage();
685
- const request = selectedRequest();
686
- return { message, request: request || message?.requests[0] || null };
1099
+ function firstUserMessage(session) {
1100
+ return session?.messages.find((message) => message.role === "user") || session?.messages[0] || null;
687
1101
  }
688
1102
 
689
1103
  function render() {
@@ -704,33 +1118,67 @@ function renderViewerHtml(dbPath) {
704
1118
  status.classList.toggle("error", Boolean(state.error));
705
1119
  if (state.error) status.textContent = "Load failed";
706
1120
  else if (state.loading) status.textContent = "Loading history...";
707
- else status.textContent = state.history.sessions.length + " sessions \xB7 " + state.history.requests.length + " hooks \xB7 " + state.loadedMs + "ms";
1121
+ else status.textContent = state.history.sessions.length + " sessions \xB7 " + state.history.requests.length + " model steps \xB7 " + state.loadedMs + "ms";
708
1122
  qs("refresh").disabled = state.loading;
709
1123
  qs("refresh").textContent = state.loading ? "Loading..." : "Refresh";
710
1124
  }
711
1125
 
1126
+ function renderProjectOptions() {
1127
+ const select = qs("project-filter");
1128
+ const current = state.project;
1129
+ const projects = [...new Set(state.history.sessions.map((session) => session.project || session.cwd || "Unknown").filter(Boolean))].sort();
1130
+ select.innerHTML = '<option value="">All projects</option>' + projects.map((project) =>
1131
+ '<option value="' + escapeAttr(project) + '">' + escapeHtml(project) + '</option>'
1132
+ ).join("");
1133
+ if (projects.includes(current)) select.value = current;
1134
+ else state.project = "";
1135
+ }
1136
+
712
1137
  function renderSessions() {
713
1138
  const filter = qs("session-filter").value.toLowerCase();
714
- const sessions = state.history.sessions.filter((session) =>
715
- [session.id, session.title].filter(Boolean).join(" ").toLowerCase().includes(filter)
716
- );
717
- qs("sessions").innerHTML = sessions.length ? sessions.map((session) =>
718
- '<button class="item ' + (session.id === state.sessionID ? "active" : "") + '" data-session="' + escapeAttr(session.id) + '">' +
719
- '<div class="title">' + escapeHtml(session.title || session.id) + '</div>' +
720
- '<div class="meta">' + session.messages.length + ' messages \xB7 ' + session.requests.length + ' hooks \xB7 ' + fmt(session.updatedAt) + '</div>' +
721
- '</button>'
722
- ).join("") : '<div class="empty">No sessions found.</div>';
1139
+ const project = state.project;
1140
+ const all = state.history.sessions;
1141
+ const byParent = new Map();
1142
+ const byId = new Map(all.map((session) => [session.id, session]));
1143
+ for (const session of all) {
1144
+ const parent = session.parentID && byId.has(session.parentID) ? session.parentID : "";
1145
+ const list = byParent.get(parent) || [];
1146
+ list.push(session);
1147
+ byParent.set(parent, list);
1148
+ }
1149
+ for (const list of byParent.values()) list.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
1150
+ const matches = (session) => {
1151
+ const projectMatch = !project || (session.project || session.cwd || "Unknown") === project;
1152
+ const text = [session.id, session.title, session.project, session.cwd].filter(Boolean).join(" ").toLowerCase();
1153
+ return projectMatch && (!filter || text.includes(filter));
1154
+ };
1155
+ const renderNode = (session, depth = 0) => {
1156
+ const children = byParent.get(session.id) || [];
1157
+ const childHtml = children.map((child) => renderNode(child, depth + 1)).join("");
1158
+ if (!matches(session) && !childHtml) return "";
1159
+ const cls = "item" + (depth > 0 ? " session-child" : "") + (session.id === state.sessionID ? " active" : "");
1160
+ return '<button class="' + cls + '" data-session="' + escapeAttr(session.id) + '">' +
1161
+ '<div class="title">' + (depth > 0 ? "sub: " : "") + escapeHtml(session.title || session.id) + '</div>' +
1162
+ '<div class="meta">' + escapeHtml(session.project || session.cwd || "Unknown project") + ' \xB7 ' + userMessages(session).length + ' messages \xB7 ' + fmt(session.updatedAt) + '</div>' +
1163
+ '</button>' + childHtml;
1164
+ };
1165
+ const html = (byParent.get("") || []).map((session) => renderNode(session)).join("");
1166
+ qs("sessions").innerHTML = html || '<div class="empty">No sessions found.</div>';
723
1167
  for (const item of document.querySelectorAll("[data-session]")) {
724
1168
  item.onclick = () => {
725
1169
  state.sessionID = item.dataset.session;
726
1170
  const session = selectedSession();
727
- state.messageID = session?.messages[0]?.id || null;
1171
+ state.messageID = firstUserMessage(session)?.id || null;
728
1172
  state.requestID = null;
729
1173
  render();
730
1174
  };
731
1175
  }
732
1176
  }
733
1177
 
1178
+ function userMessages(session) {
1179
+ return (session?.messages || []).filter((message) => message.role === "user");
1180
+ }
1181
+
734
1182
  function renderTimeline() {
735
1183
  const session = selectedSession();
736
1184
  if (!session) {
@@ -739,79 +1187,71 @@ function renderViewerHtml(dbPath) {
739
1187
  }
740
1188
  const filter = qs("timeline-filter").value.toLowerCase();
741
1189
  const blocks = [];
742
- for (const message of session.messages) {
1190
+ for (const message of userMessages(session)) {
743
1191
  const searchable = JSON.stringify(message).toLowerCase();
744
1192
  if (filter && !searchable.includes(filter)) continue;
745
1193
  blocks.push(
746
- '<button class="item ' + (message.id === state.messageID && !state.requestID ? "active" : "") + '" data-message="' + escapeAttr(message.id) + '">' +
747
- '<div class="title"><span class="pill msg">MSG</span> ' + escapeHtml(message.role) + ' \xB7 ' + escapeHtml(short(message.text || message.id, 110)) + '</div>' +
748
- '<div class="meta">' + message.requests.length + ' hooks \xB7 response ' + (message.response?.text ? "captured" : "missing") + ' \xB7 ' + fmt(message.createdAt) + '</div>' +
1194
+ '<button class="item ' + (message.id === state.messageID ? "active" : "") + '" data-message="' + escapeAttr(message.id) + '">' +
1195
+ '<div class="title"><span class="pill msg">USER</span> ' + escapeHtml(short(message.text || message.id, 130)) + '</div>' +
1196
+ '<div class="meta">' + visibleSteps(message).length + ' visible steps \xB7 ' + hiddenContexts(message).length + ' hidden context items \xB7 ' + fmt(message.createdAt) + '</div>' +
749
1197
  '</button>'
750
1198
  );
751
- for (const request of message.requests) {
752
- blocks.push(
753
- '<button class="item request-item ' + (request.id === state.requestID ? "active" : "") + '" data-message="' + escapeAttr(message.id) + '" data-request="' + escapeAttr(request.id) + '">' +
754
- '<div class="title"><span class="pill">HOOK</span> ' + escapeHtml(request.agent || "agent") + ' \xB7 ' + escapeHtml([request.providerID, request.modelID].filter(Boolean).join("/") || "-") + '</div>' +
755
- '<div class="meta">' + escapeHtml(short(request.summary, 120)) + ' \xB7 ' + (request.headers ? "headers" : "no headers") + ' \xB7 ' + (request.response?.text ? "response" : "no response") + '</div>' +
756
- '</button>'
757
- );
758
- }
759
1199
  }
760
- qs("timeline").innerHTML = blocks.length ? blocks.join("") : '<div class="empty">No messages or hooks found.</div>';
1200
+ qs("timeline").innerHTML = blocks.length ? blocks.join("") : '<div class="empty">No user messages found.</div>';
761
1201
  for (const item of document.querySelectorAll("[data-message]")) {
762
1202
  item.onclick = () => {
763
1203
  state.messageID = item.dataset.message;
764
- state.requestID = item.dataset.request || null;
1204
+ state.requestID = null;
765
1205
  render();
766
1206
  };
767
1207
  }
768
1208
  }
769
1209
 
770
1210
  function renderDetail() {
771
- const { message, request } = activeContext();
772
- document.querySelector(".tabs").classList.toggle("json-mode", state.tab !== "summary");
773
- if (!message && !request) {
774
- qs("detail").textContent = "Select a message or request.";
1211
+ const message = selectedMessage();
1212
+ if (!message) {
1213
+ qs("detail").textContent = "Select a user message.";
775
1214
  return;
776
1215
  }
777
1216
 
778
- if (state.tab === "summary") {
779
- const response = request?.response || message?.response;
780
- qs("detail").innerHTML = '<div class="kv">' +
781
- kv("Message", message?.id || "-") +
782
- kv("Message text", message?.text || "(no user text captured)") +
783
- kv("Selected hook", request?.id || "-") +
784
- kv("Agent", request?.agent || "-") +
785
- kv("Purpose", request?.purpose || "-") +
786
- kv("Provider/model", [request?.providerID, request?.modelID].filter(Boolean).join("/") || "-") +
787
- kv("Request time", fmt(request?.timestamp)) +
788
- kv("Headers hook", request?.headers ? "captured" : "empty / not captured") +
789
- kv("Direct response", request?.response?.text ? "captured" : request ? "none for this hook" : "-") +
790
- kv("Message response", response?.id || "-") +
791
- kv("Response text", response?.text || "(no assistant text captured)") +
792
- kv("Finish", response?.finish || "-") +
793
- kv("Cost", response?.cost === undefined ? "-" : String(response.cost)) +
794
- '</div>';
795
- return;
796
- }
1217
+ const session = selectedSession();
1218
+ const steps = visibleSteps(message);
1219
+ const hidden = hiddenContexts(message);
1220
+ qs("detail").innerHTML =
1221
+ '<div class="kv">' +
1222
+ kv("Session", session?.title || session?.id || "-") +
1223
+ kv("Project", session?.project || session?.cwd || "Unknown") +
1224
+ kv("User message", message.text || "(no user text captured)") +
1225
+ kv("Visible steps", String(steps.length)) +
1226
+ kv("Hidden context", hidden.length ? hidden.length + " item(s)" : "none captured") +
1227
+ '</div>' +
1228
+ '<div class="subhead">Agent Thinking / Response Sequence</div>' +
1229
+ (steps.length ? steps.map(renderStep).join("") : '<div class="empty">No assistant thinking or response text captured for this message.</div>') +
1230
+ '<div class="subhead">Hidden Context</div>' +
1231
+ (hidden.length ? hidden.map(renderHiddenContext).join("") : '<div class="empty">No system prompt or hidden prompt-like context captured.</div>');
1232
+ }
797
1233
 
798
- if (state.tab === "request") {
799
- renderRequestDetail(request);
800
- return;
801
- }
1234
+ function visibleSteps(message) {
1235
+ return message.visibleSteps || [];
1236
+ }
802
1237
 
803
- if (state.tab === "response") {
804
- const response = state.requestID ? request?.response : message?.response;
805
- renderJson(response || {
806
- status: "missing",
807
- note: request?.agent === "title"
808
- ? "The title request does not produce the assistant reply. Select the build request or MSG row to inspect the conversation response."
809
- : "No assistant response events were captured for this message yet."
810
- });
811
- return;
812
- }
1238
+ function hiddenContexts(message) {
1239
+ return message.hiddenContexts || [];
1240
+ }
813
1241
 
814
- renderJson({ message, request });
1242
+ function renderStep(step) {
1243
+ return '<div class="step">' +
1244
+ '<div class="step-label">' + escapeHtml(step.label) + '</div>' +
1245
+ '<div class="step-text">' + escapeHtml(step.text) + '</div>' +
1246
+ '</div>';
1247
+ }
1248
+
1249
+ function renderHiddenContext(item) {
1250
+ const count = item.count > 1 ? ' \xB7 used by ' + item.count + ' model steps' : "";
1251
+ return '<details class="hidden-context">' +
1252
+ '<summary>' + escapeHtml(item.title) + ' \xB7 ' + escapeHtml(item.step || "-") + count + ' \xB7 ' + escapeHtml(item.preview) + '</summary>' +
1253
+ '<div class="hidden-body"><pre class="hidden-text">' + escapeHtml(item.text) + '</pre></div>' +
1254
+ '</details>';
815
1255
  }
816
1256
 
817
1257
  function kv(key, value) {
@@ -827,12 +1267,22 @@ function renderViewerHtml(dbPath) {
827
1267
  renderJson({ status: "missing", note: "Select a hook row to inspect hook details." });
828
1268
  return;
829
1269
  }
830
- const payload = request.payload || {};
1270
+ const cached = state.payloadCache[request.id];
1271
+ if (cached === undefined || cached === null) {
1272
+ qs("detail").innerHTML = '<div class="empty">Loading hook payload...</div>';
1273
+ return;
1274
+ }
1275
+ if (cached.error) {
1276
+ renderJson({ error: cached.error });
1277
+ return;
1278
+ }
1279
+ const payload = cached;
831
1280
  const hookInput = payload.input || {};
832
1281
  const hookOutput = payload.output || {};
1282
+ const systemOutput = request.system?.payload?.output || null;
833
1283
  const headerOutput = request.headers?.payload?.output || null;
834
1284
  qs("detail").innerHTML =
835
- '<p class="explain">These are OpenCode plugin hook values, not a raw HTTP request. <b>Hook input</b> is the context OpenCode passed to the plugin before the model call. <b>Hook output</b> is the model settings returned by the plugin hook. <b>Headers output</b> is the provider headers hook result.</p>' +
1285
+ '<p class="explain">These are OpenCode plugin hook values, not a raw HTTP request. <b>Hook input</b> is the context OpenCode passed to the plugin before the model call. <b>Hook output</b> is the model settings returned by the plugin hook. <b>System transform</b> is the system prompt OpenCode prepared for this same call. <b>Headers output</b> is the provider headers hook result.</p>' +
836
1286
  '<div class="kv">' +
837
1287
  kv("Hook id", request.id) +
838
1288
  kv("Agent", request.agent || "-") +
@@ -845,10 +1295,12 @@ function renderViewerHtml(dbPath) {
845
1295
  '<div class="json-tree">' + jsonNode(summarizeHookInput(hookInput), "hookInput", true) + '</div>' +
846
1296
  '<div class="subhead">Hook Output: model-call settings</div>' +
847
1297
  '<div class="json-tree">' + jsonNode(hookOutput, "hookOutput", true) + '</div>' +
1298
+ '<div class="subhead">System Transform Output</div>' +
1299
+ '<div class="json-tree">' + jsonNode(systemOutput, "systemOutput", true) + '</div>' +
848
1300
  '<div class="subhead">Headers Hook Output</div>' +
849
1301
  '<div class="json-tree">' + jsonNode(headerOutput, "headersOutput", true) + '</div>' +
850
1302
  '<div class="subhead">Raw Full-Fidelity Payload</div>' +
851
- '<div class="json-tree">' + jsonNode({ params: request.payload, headers: request.headers?.payload || null }, "raw", false) + '</div>';
1303
+ '<div class="json-tree">' + jsonNode({ system: request.system?.payload || null, params: payload, headers: request.headers?.payload || null }, "raw", false) + '</div>';
852
1304
  }
853
1305
 
854
1306
  function summarizeHookInput(input) {
@@ -915,16 +1367,23 @@ function renderViewerHtml(dbPath) {
915
1367
  }
916
1368
 
917
1369
  qs("session-filter").oninput = render;
1370
+ qs("project-filter").onchange = () => {
1371
+ state.project = qs("project-filter").value;
1372
+ render();
1373
+ };
918
1374
  qs("timeline-filter").oninput = render;
919
1375
  qs("refresh").onclick = load;
920
- qs("expand-json").onclick = () => setJsonExpanded(true);
921
- qs("collapse-json").onclick = () => setJsonExpanded(false);
1376
+ for (const item of document.querySelectorAll("[data-theme-option]")) {
1377
+ item.onclick = () => applyTheme(item.dataset.themeOption);
1378
+ }
922
1379
  qs("copy").onclick = async () => {
923
- const { message, request } = activeContext();
924
- const value = state.tab === "request" ? { params: request?.payload || null, headers: request?.headers?.payload || null }
925
- : state.tab === "response" ? (request?.response || message?.response || null)
926
- : state.tab === "raw" ? { message, request }
927
- : { messageID: message?.id, requestID: request?.id };
1380
+ const message = selectedMessage();
1381
+ const value = message ? {
1382
+ messageID: message.id,
1383
+ text: message.text,
1384
+ steps: visibleSteps(message),
1385
+ hiddenContext: hiddenContexts(message)
1386
+ } : null;
928
1387
  await navigator.clipboard.writeText(JSON.stringify(value, null, 2));
929
1388
  };
930
1389
  for (const tab of document.querySelectorAll("[data-tab]")) {
@@ -934,6 +1393,7 @@ function renderViewerHtml(dbPath) {
934
1393
  renderDetail();
935
1394
  };
936
1395
  }
1396
+ applyTheme(localStorage.getItem(THEME_KEY));
937
1397
  load();
938
1398
  </script>
939
1399
  </body>
@@ -1090,10 +1550,16 @@ function parseOptions(args) {
1090
1550
  const value = args[index + 1];
1091
1551
  if (value) options.configDir = value;
1092
1552
  index += 1;
1553
+ } else if (arg === "--retention-days") {
1554
+ options.retentionDays = Number.parseFloat(args[index + 1] ?? "");
1555
+ index += 1;
1093
1556
  }
1094
1557
  }
1095
1558
  if (!Number.isFinite(options.limit) || options.limit < 1) options.limit = DEFAULT_RECENT_LIMIT;
1096
1559
  if (options.port !== void 0 && (!Number.isFinite(options.port) || options.port < 1)) options.port = 8765;
1560
+ if (options.retentionDays !== void 0 && (!Number.isFinite(options.retentionDays) || options.retentionDays < 0)) {
1561
+ options.retentionDays = void 0;
1562
+ }
1097
1563
  return options;
1098
1564
  }
1099
1565
  function parsePositionals(args) {
@@ -1102,7 +1568,7 @@ function parsePositionals(args) {
1102
1568
  const arg = args[index];
1103
1569
  if (!arg) continue;
1104
1570
  if (arg.startsWith("--")) {
1105
- if (["--db", "--data-dir", "--limit", "--host", "--port", "--output", "--config-dir"].includes(arg)) index += 1;
1571
+ if (["--db", "--data-dir", "--limit", "--host", "--port", "--output", "--config-dir", "--retention-days"].includes(arg)) index += 1;
1106
1572
  continue;
1107
1573
  }
1108
1574
  if (arg === "-o") {
@@ -1234,7 +1700,7 @@ async function configureOpenCodeDebug(options) {
1234
1700
  }
1235
1701
  const opencodeConfig = await readJsonConfig(opencodePath, { plugin: [] });
1236
1702
  const tuiConfig = await readJsonConfig(tuiPath, { plugin: [] });
1237
- setSinglePluginSpec(opencodeConfig, SERVER_PLUGIN_SPEC, localServerEntry);
1703
+ setSinglePluginSpec(opencodeConfig, SERVER_PLUGIN_SPEC, [localServerEntry, debugServerOptions(options)], localServerEntry);
1238
1704
  setSinglePluginSpec(tuiConfig, TUI_PLUGIN_SPEC, localTuiEntry);
1239
1705
  removePlugin(tuiConfig, SUBPATH_TUI_PLUGIN_SPEC);
1240
1706
  const lines = [
@@ -1323,6 +1789,11 @@ function stripJsonCommentsAndTrailingCommas(input) {
1323
1789
  function isJsonObject(value) {
1324
1790
  return !!value && typeof value === "object" && !Array.isArray(value);
1325
1791
  }
1792
+ function debugServerOptions(options) {
1793
+ const serverOptions = {};
1794
+ if (options.retentionDays !== void 0) serverOptions.retentionDays = options.retentionDays;
1795
+ return serverOptions;
1796
+ }
1326
1797
  function addUniquePlugin(config, plugin) {
1327
1798
  const current = Array.isArray(config.plugin) ? config.plugin : [];
1328
1799
  if (current.includes(plugin)) {
@@ -1341,9 +1812,10 @@ function removePlugin(config, plugin) {
1341
1812
  function isPluginEntry(entry, plugin) {
1342
1813
  return entry === plugin || Array.isArray(entry) && entry[0] === plugin;
1343
1814
  }
1344
- function setSinglePluginSpec(config, previousPlugin, nextPlugin) {
1815
+ function setSinglePluginSpec(config, previousPlugin, nextPlugin, nextPluginSpec) {
1345
1816
  const current = Array.isArray(config.plugin) ? config.plugin : [];
1346
- const next = current.filter((entry) => !isInsightsPluginEntry(entry, previousPlugin, nextPlugin));
1817
+ const localPlugin = nextPluginSpec ?? (typeof nextPlugin === "string" ? nextPlugin : "");
1818
+ const next = current.filter((entry) => !isInsightsPluginEntry(entry, previousPlugin, localPlugin));
1347
1819
  config.plugin = [...next, nextPlugin];
1348
1820
  }
1349
1821
  function isInsightsPluginEntry(entry, packagePlugin, localPlugin) {
@@ -1411,7 +1883,7 @@ async function writeJsonConfig(path, config) {
1411
1883
  function usage() {
1412
1884
  return [
1413
1885
  "Usage:",
1414
- " opencode-insights debug [--config-dir DIR] [--dry-run]",
1886
+ " opencode-insights debug [--config-dir DIR] [--retention-days DAYS] [--dry-run]",
1415
1887
  " opencode-insights uninstall [--config-dir DIR] [--db PATH] [--data-dir DIR] [--keep-data] [--dry-run]",
1416
1888
  " opencode-insights recent [--db PATH] [--data-dir DIR] [--limit N] [--json]",
1417
1889
  " opencode-insights sessions [--db PATH] [--data-dir DIR] [--limit N] [--json]",