@rejacky/opencode-insights 0.1.3 → 0.1.5

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-PBX4AJCR.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));
@@ -187,11 +262,10 @@ function buildRequestHistory(records) {
187
262
  async function readSqliteCaptures(path, limit) {
188
263
  if (!existsSync(path)) return void 0;
189
264
  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 });
265
+ const db = await openDatabase(path);
266
+ if (!db) return readSqliteCapturesWithCli(path, limit);
193
267
  try {
194
- const rows = db.query(recentCaptureSql(Math.max(1, limit))).all();
268
+ const rows = db.all(recentCaptureSql(Math.max(1, limit)));
195
269
  return dedupeRows(rows).map(rowToCapture);
196
270
  } finally {
197
271
  db.close();
@@ -212,18 +286,71 @@ async function readSqliteCapturesWithCli(path, limit) {
212
286
  return void 0;
213
287
  }
214
288
  }
289
+ async function readSqliteViewerCaptures(path, limit) {
290
+ if (!existsSync(path)) return void 0;
291
+ try {
292
+ const db = await openDatabase(path);
293
+ if (!db) return readSqliteViewerCapturesWithCli(path, limit);
294
+ try {
295
+ const rows = db.all(viewerCaptureSql(Math.max(1, limit)));
296
+ return dedupeRows(rows).map(rowToCapture);
297
+ } finally {
298
+ db.close();
299
+ }
300
+ } catch {
301
+ return readSqliteViewerCapturesWithCli(path, limit);
302
+ }
303
+ }
304
+ async function readSqliteViewerCapturesWithCli(path, limit) {
305
+ if (!existsSync(path)) return void 0;
306
+ try {
307
+ const { stdout } = await execFileAsync("sqlite3", ["-json", path, viewerCaptureSql(Math.max(1, Math.trunc(limit)))], {
308
+ maxBuffer: 128 * 1024 * 1024
309
+ });
310
+ if (!stdout.trim()) return [];
311
+ return dedupeRows(JSON.parse(stdout)).map(rowToCapture);
312
+ } catch {
313
+ return void 0;
314
+ }
315
+ }
215
316
  function recentCaptureSql(limit) {
216
317
  return `select id, kind, timestamp, session_id, message_id, provider_id, model_id, payload_json
217
318
  from captures
218
319
  where id in (
219
- select id from captures order by timestamp desc limit ${limit}
320
+ select id from captures
321
+ where kind in (
322
+ 'chat.params',
323
+ 'chat.message',
324
+ 'chat.headers',
325
+ 'experimental.chat.messages.transform',
326
+ 'experimental.chat.system.transform'
327
+ )
328
+ order by timestamp desc
329
+ limit ${limit}
220
330
  )
221
331
  or id in (
332
+ select id from captures
333
+ where kind = 'event'
334
+ and json_extract(payload_json, '$.event.type') in (
335
+ 'message.updated',
336
+ 'message.part.updated',
337
+ 'message.part.delta',
338
+ 'session.updated',
339
+ 'session.created'
340
+ )
341
+ order by timestamp desc
342
+ limit ${limit}
343
+ )
344
+ order by timestamp desc`;
345
+ }
346
+ function viewerCaptureSql(limit) {
347
+ return `select id, kind, timestamp, session_id, message_id, provider_id, model_id, payload_json
348
+ from captures
349
+ where id in (
222
350
  select id from captures
223
351
  where kind in (
224
352
  'chat.params',
225
353
  'chat.message',
226
- 'experimental.chat.messages.transform',
227
354
  'experimental.chat.system.transform'
228
355
  )
229
356
  order by timestamp desc
@@ -232,12 +359,21 @@ function recentCaptureSql(limit) {
232
359
  or id in (
233
360
  select id from captures
234
361
  where kind = 'event'
235
- and json_extract(payload_json, '$.event.type') in ('message.updated', 'message.part.updated', 'message.part.delta')
362
+ and json_extract(payload_json, '$.event.type') in (
363
+ 'message.updated',
364
+ 'message.part.updated',
365
+ 'message.part.delta',
366
+ 'session.updated',
367
+ 'session.created'
368
+ )
236
369
  order by timestamp desc
237
370
  limit ${limit}
238
371
  )
239
372
  order by timestamp desc`;
240
373
  }
374
+ function isViewerCaptureKind(kind) {
375
+ return kind === "chat.params" || kind === "chat.message" || kind === "experimental.chat.system.transform" || kind === "event";
376
+ }
241
377
  function dedupeRows(rows) {
242
378
  const seen = /* @__PURE__ */ new Set();
243
379
  return rows.filter((row) => {
@@ -297,18 +433,21 @@ function agentFromCapture(record, input) {
297
433
  return optionalString(input.agent);
298
434
  }
299
435
  function messageIDForCapture(record, input) {
300
- if (record.kind === "experimental.chat.messages.transform") {
301
- return latestUserMessageIDFromTransform(record.payload.output) ?? record.messageID;
302
- }
303
436
  return record.messageID ?? messageIDFromPayload(input.message);
304
437
  }
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);
438
+ function attachPendingSystemTransform(pendingSystemTransforms, request) {
439
+ let index = -1;
440
+ for (let candidateIndex = pendingSystemTransforms.length - 1; candidateIndex >= 0; candidateIndex -= 1) {
441
+ const candidate = pendingSystemTransforms[candidateIndex];
442
+ if (candidate && candidate.sessionID === request.sessionID && candidate.providerID === request.providerID && candidate.modelID === request.modelID && candidate.timestamp <= request.timestamp && request.timestamp - candidate.timestamp <= 5e3) {
443
+ index = candidateIndex;
444
+ break;
445
+ }
310
446
  }
311
- return void 0;
447
+ if (index < 0) return;
448
+ const [system] = pendingSystemTransforms.splice(index, 1);
449
+ if (!system) return;
450
+ request.system = { id: system.id, timestamp: system.timestamp, payload: system.payload };
312
451
  }
313
452
  function latestUserMessageBefore(messages, sessionID, timestamp) {
314
453
  let latest;
@@ -373,6 +512,12 @@ function findFirstString(value, keys) {
373
512
  }
374
513
  return void 0;
375
514
  }
515
+ function projectName(path) {
516
+ if (!path) return void 0;
517
+ const normalized = path.replace(/\/+$/, "");
518
+ if (!normalized) return void 0;
519
+ return normalized.split("/").filter(Boolean).at(-1) ?? normalized;
520
+ }
376
521
  function numberFromPath(value, key) {
377
522
  if (!isRecord(value)) return void 0;
378
523
  const item = value[key];
@@ -381,6 +526,7 @@ function numberFromPath(value, key) {
381
526
 
382
527
  // src/viewer.ts
383
528
  import { createServer } from "http";
529
+ var REQUEST_PATH_RE = /^\/api\/request\/(.+)$/;
384
530
  async function serveViewer(options = {}) {
385
531
  const host = options.host ?? "127.0.0.1";
386
532
  const port = options.port ?? 8765;
@@ -391,6 +537,21 @@ async function serveViewer(options = {}) {
391
537
  sendJson(response, history);
392
538
  return;
393
539
  }
540
+ const requestMatch = REQUEST_PATH_RE.exec(url.pathname);
541
+ if (requestMatch) {
542
+ const requestId = requestMatch[1];
543
+ if (!requestId) {
544
+ sendJson(response, { error: "not found" });
545
+ return;
546
+ }
547
+ const record = await readCaptureRecord(requestId, options);
548
+ if (!record) {
549
+ sendJson(response, { error: "not found" });
550
+ return;
551
+ }
552
+ sendJson(response, record.payload);
553
+ return;
554
+ }
394
555
  if (url.pathname === "/" || url.pathname === "/index.html") {
395
556
  sendHtml(response, renderViewerHtml(resolveCapturePath(options)));
396
557
  return;
@@ -416,8 +577,152 @@ async function readHistory(options = {}) {
416
577
  dbPath: options.dbPath,
417
578
  limit: options.limit ?? 5e3
418
579
  };
419
- const records = await readRecentCaptures(readOptions);
420
- return buildRequestHistory(records);
580
+ const records = await readViewerCaptures(readOptions);
581
+ const history = buildRequestHistory(records);
582
+ prepareHistoryForViewer(history);
583
+ stripPayloadsForViewer(history);
584
+ return history;
585
+ }
586
+ function prepareHistoryForViewer(history) {
587
+ for (const session of history.sessions) {
588
+ for (const message of session.messages) {
589
+ const viewerMessage = message;
590
+ viewerMessage.visibleSteps = buildViewerVisibleSteps(message);
591
+ viewerMessage.hiddenContexts = buildViewerHiddenContexts(message);
592
+ }
593
+ }
594
+ }
595
+ function stripPayloadsForViewer(history) {
596
+ history.requests = history.requests.map(viewerRequestSummary);
597
+ for (const session of history.sessions) {
598
+ session.requests = [];
599
+ for (const message of session.messages) {
600
+ message.requests = [];
601
+ message.response = void 0;
602
+ }
603
+ }
604
+ }
605
+ function buildViewerVisibleSteps(message) {
606
+ const steps = [];
607
+ for (const request of (message.requests || []).filter((item) => item.agent !== "title" && item.agent !== "messages.transform")) {
608
+ const label = [request.agent || "agent", request.providerID, request.modelID].filter(Boolean).join(" \xB7 ");
609
+ const response = request.response;
610
+ const reasoning = response?.reasoning;
611
+ const text = response?.text;
612
+ if (reasoning) {
613
+ steps.push({ label: `${label} thinking`, text: reasoning });
614
+ }
615
+ for (const tool of viewerToolSteps(response)) {
616
+ steps.push({ label: `${label} tool`, text: tool });
617
+ }
618
+ if (text && normalizeDisplayText(text) !== normalizeDisplayText(reasoning)) {
619
+ steps.push({ label: `${label} response`, text });
620
+ }
621
+ if (!reasoning && !text && viewerToolSteps(response).length === 0) {
622
+ steps.push({ label, text: "Model step captured, but no visible thinking or response text was recorded." });
623
+ }
624
+ }
625
+ return steps;
626
+ }
627
+ function buildViewerHiddenContexts(message) {
628
+ const contexts = /* @__PURE__ */ new Map();
629
+ for (const request of message.requests || []) {
630
+ const step = [request.agent || "agent", request.providerID, request.modelID].filter(Boolean).join(" \xB7 ");
631
+ if (request.system?.payload?.output) {
632
+ addHiddenContext(contexts, "System Transform Output", step, request.system.payload.output);
633
+ }
634
+ if (request.agent === "messages.transform" && request.payload) {
635
+ addHiddenContext(contexts, "Messages Transform Output", step, request.payload.output || request.payload);
636
+ }
637
+ }
638
+ return [...contexts.values()];
639
+ }
640
+ function addHiddenContext(contexts, title, step, value) {
641
+ const text = hiddenContextText(value);
642
+ if (!text) return;
643
+ const key = `${title}:${normalizeDisplayText(text)}`;
644
+ const existing = contexts.get(key);
645
+ if (existing) {
646
+ existing.count += 1;
647
+ if (!existing.step.split(", ").includes(step)) existing.step = `${existing.step}, ${step}`;
648
+ return;
649
+ }
650
+ contexts.set(key, {
651
+ title,
652
+ step,
653
+ preview: previewText(text, 180),
654
+ text,
655
+ count: 1
656
+ });
657
+ }
658
+ function hiddenContextText(value) {
659
+ if (typeof value === "string") return value;
660
+ if (Array.isArray(value)) return value.map(hiddenContextText).filter(Boolean).join("\n\n");
661
+ if (!isRecord2(value)) return value === void 0 || value === null ? "" : String(value);
662
+ if (value.system !== void 0) return hiddenContextText(value.system);
663
+ if (Array.isArray(value.messages)) {
664
+ return value.messages.map(messageContextText).filter(Boolean).join("\n\n");
665
+ }
666
+ const strings = collectStrings(value);
667
+ return strings.length ? strings.join("\n\n") : JSON.stringify(value, null, 2);
668
+ }
669
+ function messageContextText(value) {
670
+ if (!isRecord2(value)) return hiddenContextText(value);
671
+ const info = isRecord2(value.info) ? value.info : {};
672
+ const role = typeof info.role === "string" ? info.role : "message";
673
+ const parts = Array.isArray(value.parts) ? value.parts : [];
674
+ const text = parts.map(partText).filter(Boolean).join("\n");
675
+ return text ? `${role}: ${text}` : `${role}: ${hiddenContextText(value)}`;
676
+ }
677
+ function partText(value) {
678
+ if (!isRecord2(value)) return "";
679
+ return typeof value.text === "string" ? value.text : typeof value.content === "string" ? value.content : "";
680
+ }
681
+ function viewerToolSteps(response) {
682
+ const tools = [];
683
+ const seen = /* @__PURE__ */ new Set();
684
+ for (const wrapper of response?.events || []) {
685
+ const event = isRecord2(wrapper.event) ? wrapper.event : void 0;
686
+ const properties = isRecord2(event?.properties) ? event.properties : void 0;
687
+ const part = isRecord2(properties?.part) ? properties.part : void 0;
688
+ if (event?.type !== "message.part.updated" || part?.type !== "tool") continue;
689
+ const state = isRecord2(part.state) ? part.state : void 0;
690
+ const key = [part.id, part.tool, state?.status].filter(Boolean).join(":");
691
+ if (seen.has(key)) continue;
692
+ seen.add(key);
693
+ tools.push([part.tool || "tool", state?.status].filter(Boolean).join(" \xB7 "));
694
+ }
695
+ return tools;
696
+ }
697
+ function collectStrings(value) {
698
+ if (typeof value === "string") return [value];
699
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
700
+ if (!isRecord2(value)) return [];
701
+ return Object.values(value).flatMap(collectStrings);
702
+ }
703
+ function previewText(value, size) {
704
+ const text = normalizeDisplayText(value);
705
+ return text.length > size ? `${text.slice(0, size - 1)}...` : text || "(empty)";
706
+ }
707
+ function normalizeDisplayText(value) {
708
+ return String(value || "").replace(/\s+/g, " ").trim();
709
+ }
710
+ function isRecord2(value) {
711
+ return !!value && typeof value === "object" && !Array.isArray(value);
712
+ }
713
+ function viewerRequestSummary(request) {
714
+ return {
715
+ id: request.id,
716
+ sessionID: request.sessionID,
717
+ messageID: request.messageID,
718
+ timestamp: request.timestamp,
719
+ agent: request.agent,
720
+ purpose: request.purpose,
721
+ providerID: request.providerID,
722
+ modelID: request.modelID,
723
+ summary: request.summary,
724
+ payload: {}
725
+ };
421
726
  }
422
727
  function sendJson(response, value) {
423
728
  response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
@@ -439,8 +744,11 @@ function renderViewerHtml(dbPath) {
439
744
  :root {
440
745
  color-scheme: dark;
441
746
  --bg: #101214;
747
+ --header: #0d0f11;
442
748
  --panel: #171a1d;
443
749
  --panel-2: #20242a;
750
+ --surface: #0b0d0f;
751
+ --field: #0d0f11;
444
752
  --line: #30363d;
445
753
  --text: #eef2f5;
446
754
  --muted: #9aa6b2;
@@ -448,6 +756,32 @@ function renderViewerHtml(dbPath) {
448
756
  --ok: #86efac;
449
757
  --warn: #fbbf24;
450
758
  --bad: #fca5a5;
759
+ --pill-text: #0d0f11;
760
+ --json-key: #bae6fd;
761
+ --json-string: #bbf7d0;
762
+ --json-number: #fde68a;
763
+ --json-boolean: #f0abfc;
764
+ }
765
+ body[data-theme="light"] {
766
+ color-scheme: light;
767
+ --bg: #f6f7f9;
768
+ --header: #ffffff;
769
+ --panel: #ffffff;
770
+ --panel-2: #edf2f7;
771
+ --surface: #ffffff;
772
+ --field: #ffffff;
773
+ --line: #d7dde5;
774
+ --text: #18212f;
775
+ --muted: #667085;
776
+ --accent: #2563eb;
777
+ --ok: #16a34a;
778
+ --warn: #b45309;
779
+ --bad: #dc2626;
780
+ --pill-text: #ffffff;
781
+ --json-key: #1d4ed8;
782
+ --json-string: #15803d;
783
+ --json-number: #a16207;
784
+ --json-boolean: #9333ea;
451
785
  }
452
786
  * { box-sizing: border-box; }
453
787
  body {
@@ -465,9 +799,35 @@ function renderViewerHtml(dbPath) {
465
799
  gap: 16px;
466
800
  padding: 0 18px;
467
801
  border-bottom: 1px solid var(--line);
468
- background: #0d0f11;
802
+ background: var(--header);
469
803
  }
470
804
  h1 { margin: 0; font-size: 15px; font-weight: 700; }
805
+ .brand, .header-side {
806
+ display: flex;
807
+ align-items: center;
808
+ gap: 14px;
809
+ min-width: 0;
810
+ }
811
+ .header-meta { min-width: 0; }
812
+ .theme-toggle {
813
+ display: inline-flex;
814
+ gap: 2px;
815
+ padding: 3px;
816
+ border: 1px solid var(--line);
817
+ border-radius: 8px;
818
+ background: var(--panel);
819
+ }
820
+ .theme-toggle button {
821
+ padding: 5px 8px;
822
+ border: 0;
823
+ border-radius: 5px;
824
+ background: transparent;
825
+ color: var(--muted);
826
+ }
827
+ .theme-toggle button.active {
828
+ background: var(--panel-2);
829
+ color: var(--text);
830
+ }
471
831
  main {
472
832
  display: grid;
473
833
  grid-template-columns: 300px minmax(340px, 0.95fr) minmax(460px, 1.25fr);
@@ -486,15 +846,17 @@ function renderViewerHtml(dbPath) {
486
846
  border-bottom: 1px solid var(--line);
487
847
  z-index: 2;
488
848
  }
489
- input {
849
+ input, select {
490
850
  width: 100%;
491
851
  border: 1px solid var(--line);
492
- background: #0d0f11;
852
+ background: var(--field);
493
853
  color: var(--text);
494
854
  border-radius: 6px;
495
855
  padding: 8px 10px;
496
856
  font: inherit;
497
857
  }
858
+ select { cursor: pointer; }
859
+ .toolbar.stack { flex-direction: column; }
498
860
  button {
499
861
  border: 1px solid var(--line);
500
862
  background: var(--panel-2);
@@ -517,7 +879,7 @@ function renderViewerHtml(dbPath) {
517
879
  padding: 11px 12px;
518
880
  }
519
881
  .item:hover, .item.active { background: var(--panel-2); }
520
- .request-item { padding-left: 28px; }
882
+ .session-child { padding-left: 28px; }
521
883
  .title { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
522
884
  .meta {
523
885
  margin-top: 4px;
@@ -528,7 +890,7 @@ function renderViewerHtml(dbPath) {
528
890
  }
529
891
  .muted { color: var(--muted); }
530
892
  .pill {
531
- color: #0d0f11;
893
+ color: var(--pill-text);
532
894
  background: var(--accent);
533
895
  border-radius: 999px;
534
896
  padding: 1px 6px;
@@ -548,7 +910,7 @@ function renderViewerHtml(dbPath) {
548
910
  overflow: auto;
549
911
  white-space: pre-wrap;
550
912
  word-break: break-word;
551
- background: #0b0d0f;
913
+ background: var(--surface);
552
914
  border: 1px solid var(--line);
553
915
  border-radius: 8px;
554
916
  min-height: 220px;
@@ -567,14 +929,45 @@ function renderViewerHtml(dbPath) {
567
929
  color: var(--text);
568
930
  font-weight: 700;
569
931
  }
932
+ .step {
933
+ margin: 0 0 10px;
934
+ padding: 12px;
935
+ border: 1px solid var(--line);
936
+ border-radius: 8px;
937
+ background: var(--surface);
938
+ }
939
+ .step-label {
940
+ color: var(--accent);
941
+ font-weight: 700;
942
+ margin-bottom: 6px;
943
+ }
944
+ .step-text { white-space: pre-wrap; word-break: break-word; }
945
+ details.hidden-context {
946
+ margin: 10px 0;
947
+ border: 1px solid var(--line);
948
+ border-radius: 8px;
949
+ background: var(--surface);
950
+ }
951
+ details.hidden-context summary {
952
+ cursor: pointer;
953
+ padding: 10px 12px;
954
+ color: var(--accent);
955
+ }
956
+ .hidden-body { padding: 0 12px 12px; }
957
+ .hidden-text {
958
+ margin: 0;
959
+ white-space: pre-wrap;
960
+ word-break: break-word;
961
+ color: var(--text);
962
+ }
570
963
  .json-tree { font-size: 12px; line-height: 1.55; }
571
964
  .json-tree details { margin-left: 14px; }
572
965
  .json-tree summary { cursor: pointer; color: var(--accent); }
573
966
  .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; }
967
+ .json-key { color: var(--json-key); }
968
+ .json-string { color: var(--json-string); }
969
+ .json-number { color: var(--json-number); }
970
+ .json-boolean { color: var(--json-boolean); }
578
971
  .json-null { color: var(--muted); }
579
972
  @media (max-width: 1000px) {
580
973
  main { grid-template-columns: 1fr; height: auto; }
@@ -582,38 +975,44 @@ function renderViewerHtml(dbPath) {
582
975
  }
583
976
  </style>
584
977
  </head>
585
- <body>
978
+ <body data-theme="dark">
586
979
  <header>
587
- <h1>OpenCode Insights</h1>
588
- <div>
589
- <div class="meta">${escapedDbPath}</div>
590
- <div id="status" class="status">Loading history...</div>
980
+ <div class="brand">
981
+ <h1>OpenCode Insights</h1>
982
+ <div id="theme-toggle" class="theme-toggle" aria-label="Theme">
983
+ <button type="button" data-theme-option="dark">Dark</button>
984
+ <button type="button" data-theme-option="light">Light</button>
985
+ </div>
986
+ </div>
987
+ <div class="header-side">
988
+ <div class="header-meta">
989
+ <div class="meta">${escapedDbPath}</div>
990
+ <div id="status" class="status">Loading history...</div>
991
+ </div>
591
992
  </div>
592
993
  </header>
593
994
  <main>
594
995
  <section>
595
- <div class="toolbar"><input id="session-filter" placeholder="Filter sessions"></div>
996
+ <div class="toolbar stack">
997
+ <select id="project-filter"><option value="">All projects</option></select>
998
+ <input id="session-filter" placeholder="Filter sessions">
999
+ </div>
596
1000
  <div id="sessions"></div>
597
1001
  </section>
598
1002
  <section>
599
- <div class="toolbar"><input id="timeline-filter" placeholder="Filter messages and hooks"></div>
1003
+ <div class="toolbar"><input id="timeline-filter" placeholder="Filter conversation"></div>
600
1004
  <div id="timeline"></div>
601
1005
  </section>
602
1006
  <section>
603
1007
  <div class="toolbar">
604
- <button id="copy">Copy JSON</button>
1008
+ <button id="copy">Copy Summary</button>
605
1009
  <button id="refresh">Refresh</button>
606
1010
  </div>
607
1011
  <div class="detail">
608
1012
  <div class="tabs">
609
1013
  <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
1014
  </div>
616
- <div id="detail" class="panel">Select a message or request.</div>
1015
+ <div id="detail" class="panel">Select a user message.</div>
617
1016
  </div>
618
1017
  </section>
619
1018
  </main>
@@ -624,11 +1023,14 @@ function renderViewerHtml(dbPath) {
624
1023
  messageID: null,
625
1024
  requestID: null,
626
1025
  tab: "summary",
1026
+ project: "",
627
1027
  loading: true,
628
1028
  error: null,
629
- loadedMs: 0
1029
+ loadedMs: 0,
1030
+ payloadCache: {}
630
1031
  };
631
1032
 
1033
+ const THEME_KEY = "opencode-insights-theme";
632
1034
  const qs = (id) => document.getElementById(id);
633
1035
  const fmt = (ms) => ms ? new Date(ms).toLocaleString() : "-";
634
1036
  const short = (value, size = 90) => {
@@ -636,11 +1038,20 @@ function renderViewerHtml(dbPath) {
636
1038
  return text.length > size ? text.slice(0, size - 1) + "..." : text;
637
1039
  };
638
1040
 
1041
+ function applyTheme(theme) {
1042
+ const next = theme === "light" ? "light" : "dark";
1043
+ document.body.dataset.theme = next;
1044
+ localStorage.setItem(THEME_KEY, next);
1045
+ for (const item of document.querySelectorAll("[data-theme-option]")) {
1046
+ item.classList.toggle("active", item.dataset.themeOption === next);
1047
+ }
1048
+ }
1049
+
639
1050
  async function load() {
640
1051
  state.loading = true;
641
1052
  state.error = null;
642
1053
  renderStatus();
643
- qs("sessions").innerHTML = '<div class="empty">Loading sessions and hooks...</div>';
1054
+ qs("sessions").innerHTML = '<div class="empty">Loading sessions...</div>';
644
1055
  qs("timeline").innerHTML = '<div class="empty">Waiting for history data...</div>';
645
1056
  try {
646
1057
  const started = performance.now();
@@ -654,8 +1065,9 @@ function renderViewerHtml(dbPath) {
654
1065
  state.messageID = null;
655
1066
  state.requestID = null;
656
1067
  }
1068
+ renderProjectOptions();
657
1069
  const session = selectedSession();
658
- if (session && !state.messageID && session.messages[0]) state.messageID = session.messages[0].id;
1070
+ if (session && !state.messageID) state.messageID = firstUserMessage(session)?.id || null;
659
1071
  } catch (error) {
660
1072
  state.error = error instanceof Error ? error.message : String(error);
661
1073
  } finally {
@@ -673,17 +1085,8 @@ function renderViewerHtml(dbPath) {
673
1085
  return session?.messages.find((message) => message.id === state.messageID) || null;
674
1086
  }
675
1087
 
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 };
1088
+ function firstUserMessage(session) {
1089
+ return session?.messages.find((message) => message.role === "user") || session?.messages[0] || null;
687
1090
  }
688
1091
 
689
1092
  function render() {
@@ -704,33 +1107,67 @@ function renderViewerHtml(dbPath) {
704
1107
  status.classList.toggle("error", Boolean(state.error));
705
1108
  if (state.error) status.textContent = "Load failed";
706
1109
  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";
1110
+ else status.textContent = state.history.sessions.length + " sessions \xB7 " + state.history.requests.length + " model steps \xB7 " + state.loadedMs + "ms";
708
1111
  qs("refresh").disabled = state.loading;
709
1112
  qs("refresh").textContent = state.loading ? "Loading..." : "Refresh";
710
1113
  }
711
1114
 
1115
+ function renderProjectOptions() {
1116
+ const select = qs("project-filter");
1117
+ const current = state.project;
1118
+ const projects = [...new Set(state.history.sessions.map((session) => session.project || session.cwd || "Unknown").filter(Boolean))].sort();
1119
+ select.innerHTML = '<option value="">All projects</option>' + projects.map((project) =>
1120
+ '<option value="' + escapeAttr(project) + '">' + escapeHtml(project) + '</option>'
1121
+ ).join("");
1122
+ if (projects.includes(current)) select.value = current;
1123
+ else state.project = "";
1124
+ }
1125
+
712
1126
  function renderSessions() {
713
1127
  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>';
1128
+ const project = state.project;
1129
+ const all = state.history.sessions;
1130
+ const byParent = new Map();
1131
+ const byId = new Map(all.map((session) => [session.id, session]));
1132
+ for (const session of all) {
1133
+ const parent = session.parentID && byId.has(session.parentID) ? session.parentID : "";
1134
+ const list = byParent.get(parent) || [];
1135
+ list.push(session);
1136
+ byParent.set(parent, list);
1137
+ }
1138
+ for (const list of byParent.values()) list.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
1139
+ const matches = (session) => {
1140
+ const projectMatch = !project || (session.project || session.cwd || "Unknown") === project;
1141
+ const text = [session.id, session.title, session.project, session.cwd].filter(Boolean).join(" ").toLowerCase();
1142
+ return projectMatch && (!filter || text.includes(filter));
1143
+ };
1144
+ const renderNode = (session, depth = 0) => {
1145
+ const children = byParent.get(session.id) || [];
1146
+ const childHtml = children.map((child) => renderNode(child, depth + 1)).join("");
1147
+ if (!matches(session) && !childHtml) return "";
1148
+ const cls = "item" + (depth > 0 ? " session-child" : "") + (session.id === state.sessionID ? " active" : "");
1149
+ return '<button class="' + cls + '" data-session="' + escapeAttr(session.id) + '">' +
1150
+ '<div class="title">' + (depth > 0 ? "sub: " : "") + escapeHtml(session.title || session.id) + '</div>' +
1151
+ '<div class="meta">' + escapeHtml(session.project || session.cwd || "Unknown project") + ' \xB7 ' + userMessages(session).length + ' messages \xB7 ' + fmt(session.updatedAt) + '</div>' +
1152
+ '</button>' + childHtml;
1153
+ };
1154
+ const html = (byParent.get("") || []).map((session) => renderNode(session)).join("");
1155
+ qs("sessions").innerHTML = html || '<div class="empty">No sessions found.</div>';
723
1156
  for (const item of document.querySelectorAll("[data-session]")) {
724
1157
  item.onclick = () => {
725
1158
  state.sessionID = item.dataset.session;
726
1159
  const session = selectedSession();
727
- state.messageID = session?.messages[0]?.id || null;
1160
+ state.messageID = firstUserMessage(session)?.id || null;
728
1161
  state.requestID = null;
729
1162
  render();
730
1163
  };
731
1164
  }
732
1165
  }
733
1166
 
1167
+ function userMessages(session) {
1168
+ return (session?.messages || []).filter((message) => message.role === "user");
1169
+ }
1170
+
734
1171
  function renderTimeline() {
735
1172
  const session = selectedSession();
736
1173
  if (!session) {
@@ -739,79 +1176,71 @@ function renderViewerHtml(dbPath) {
739
1176
  }
740
1177
  const filter = qs("timeline-filter").value.toLowerCase();
741
1178
  const blocks = [];
742
- for (const message of session.messages) {
1179
+ for (const message of userMessages(session)) {
743
1180
  const searchable = JSON.stringify(message).toLowerCase();
744
1181
  if (filter && !searchable.includes(filter)) continue;
745
1182
  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>' +
1183
+ '<button class="item ' + (message.id === state.messageID ? "active" : "") + '" data-message="' + escapeAttr(message.id) + '">' +
1184
+ '<div class="title"><span class="pill msg">USER</span> ' + escapeHtml(short(message.text || message.id, 130)) + '</div>' +
1185
+ '<div class="meta">' + visibleSteps(message).length + ' visible steps \xB7 ' + hiddenContexts(message).length + ' hidden context items \xB7 ' + fmt(message.createdAt) + '</div>' +
749
1186
  '</button>'
750
1187
  );
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
1188
  }
760
- qs("timeline").innerHTML = blocks.length ? blocks.join("") : '<div class="empty">No messages or hooks found.</div>';
1189
+ qs("timeline").innerHTML = blocks.length ? blocks.join("") : '<div class="empty">No user messages found.</div>';
761
1190
  for (const item of document.querySelectorAll("[data-message]")) {
762
1191
  item.onclick = () => {
763
1192
  state.messageID = item.dataset.message;
764
- state.requestID = item.dataset.request || null;
1193
+ state.requestID = null;
765
1194
  render();
766
1195
  };
767
1196
  }
768
1197
  }
769
1198
 
770
1199
  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.";
1200
+ const message = selectedMessage();
1201
+ if (!message) {
1202
+ qs("detail").textContent = "Select a user message.";
775
1203
  return;
776
1204
  }
777
1205
 
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
- }
1206
+ const session = selectedSession();
1207
+ const steps = visibleSteps(message);
1208
+ const hidden = hiddenContexts(message);
1209
+ qs("detail").innerHTML =
1210
+ '<div class="kv">' +
1211
+ kv("Session", session?.title || session?.id || "-") +
1212
+ kv("Project", session?.project || session?.cwd || "Unknown") +
1213
+ kv("User message", message.text || "(no user text captured)") +
1214
+ kv("Visible steps", String(steps.length)) +
1215
+ kv("Hidden context", hidden.length ? hidden.length + " item(s)" : "none captured") +
1216
+ '</div>' +
1217
+ '<div class="subhead">Agent Thinking / Response Sequence</div>' +
1218
+ (steps.length ? steps.map(renderStep).join("") : '<div class="empty">No assistant thinking or response text captured for this message.</div>') +
1219
+ '<div class="subhead">Hidden Context</div>' +
1220
+ (hidden.length ? hidden.map(renderHiddenContext).join("") : '<div class="empty">No system prompt or hidden prompt-like context captured.</div>');
1221
+ }
797
1222
 
798
- if (state.tab === "request") {
799
- renderRequestDetail(request);
800
- return;
801
- }
1223
+ function visibleSteps(message) {
1224
+ return message.visibleSteps || [];
1225
+ }
802
1226
 
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
- }
1227
+ function hiddenContexts(message) {
1228
+ return message.hiddenContexts || [];
1229
+ }
813
1230
 
814
- renderJson({ message, request });
1231
+ function renderStep(step) {
1232
+ return '<div class="step">' +
1233
+ '<div class="step-label">' + escapeHtml(step.label) + '</div>' +
1234
+ '<div class="step-text">' + escapeHtml(step.text) + '</div>' +
1235
+ '</div>';
1236
+ }
1237
+
1238
+ function renderHiddenContext(item) {
1239
+ const count = item.count > 1 ? ' \xB7 used by ' + item.count + ' model steps' : "";
1240
+ return '<details class="hidden-context">' +
1241
+ '<summary>' + escapeHtml(item.title) + ' \xB7 ' + escapeHtml(item.step || "-") + count + ' \xB7 ' + escapeHtml(item.preview) + '</summary>' +
1242
+ '<div class="hidden-body"><pre class="hidden-text">' + escapeHtml(item.text) + '</pre></div>' +
1243
+ '</details>';
815
1244
  }
816
1245
 
817
1246
  function kv(key, value) {
@@ -827,12 +1256,22 @@ function renderViewerHtml(dbPath) {
827
1256
  renderJson({ status: "missing", note: "Select a hook row to inspect hook details." });
828
1257
  return;
829
1258
  }
830
- const payload = request.payload || {};
1259
+ const cached = state.payloadCache[request.id];
1260
+ if (cached === undefined || cached === null) {
1261
+ qs("detail").innerHTML = '<div class="empty">Loading hook payload...</div>';
1262
+ return;
1263
+ }
1264
+ if (cached.error) {
1265
+ renderJson({ error: cached.error });
1266
+ return;
1267
+ }
1268
+ const payload = cached;
831
1269
  const hookInput = payload.input || {};
832
1270
  const hookOutput = payload.output || {};
1271
+ const systemOutput = request.system?.payload?.output || null;
833
1272
  const headerOutput = request.headers?.payload?.output || null;
834
1273
  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>' +
1274
+ '<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
1275
  '<div class="kv">' +
837
1276
  kv("Hook id", request.id) +
838
1277
  kv("Agent", request.agent || "-") +
@@ -845,10 +1284,12 @@ function renderViewerHtml(dbPath) {
845
1284
  '<div class="json-tree">' + jsonNode(summarizeHookInput(hookInput), "hookInput", true) + '</div>' +
846
1285
  '<div class="subhead">Hook Output: model-call settings</div>' +
847
1286
  '<div class="json-tree">' + jsonNode(hookOutput, "hookOutput", true) + '</div>' +
1287
+ '<div class="subhead">System Transform Output</div>' +
1288
+ '<div class="json-tree">' + jsonNode(systemOutput, "systemOutput", true) + '</div>' +
848
1289
  '<div class="subhead">Headers Hook Output</div>' +
849
1290
  '<div class="json-tree">' + jsonNode(headerOutput, "headersOutput", true) + '</div>' +
850
1291
  '<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>';
1292
+ '<div class="json-tree">' + jsonNode({ system: request.system?.payload || null, params: payload, headers: request.headers?.payload || null }, "raw", false) + '</div>';
852
1293
  }
853
1294
 
854
1295
  function summarizeHookInput(input) {
@@ -915,16 +1356,23 @@ function renderViewerHtml(dbPath) {
915
1356
  }
916
1357
 
917
1358
  qs("session-filter").oninput = render;
1359
+ qs("project-filter").onchange = () => {
1360
+ state.project = qs("project-filter").value;
1361
+ render();
1362
+ };
918
1363
  qs("timeline-filter").oninput = render;
919
1364
  qs("refresh").onclick = load;
920
- qs("expand-json").onclick = () => setJsonExpanded(true);
921
- qs("collapse-json").onclick = () => setJsonExpanded(false);
1365
+ for (const item of document.querySelectorAll("[data-theme-option]")) {
1366
+ item.onclick = () => applyTheme(item.dataset.themeOption);
1367
+ }
922
1368
  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 };
1369
+ const message = selectedMessage();
1370
+ const value = message ? {
1371
+ messageID: message.id,
1372
+ text: message.text,
1373
+ steps: visibleSteps(message),
1374
+ hiddenContext: hiddenContexts(message)
1375
+ } : null;
928
1376
  await navigator.clipboard.writeText(JSON.stringify(value, null, 2));
929
1377
  };
930
1378
  for (const tab of document.querySelectorAll("[data-tab]")) {
@@ -934,6 +1382,7 @@ function renderViewerHtml(dbPath) {
934
1382
  renderDetail();
935
1383
  };
936
1384
  }
1385
+ applyTheme(localStorage.getItem(THEME_KEY));
937
1386
  load();
938
1387
  </script>
939
1388
  </body>
@@ -1090,10 +1539,16 @@ function parseOptions(args) {
1090
1539
  const value = args[index + 1];
1091
1540
  if (value) options.configDir = value;
1092
1541
  index += 1;
1542
+ } else if (arg === "--retention-days") {
1543
+ options.retentionDays = Number.parseFloat(args[index + 1] ?? "");
1544
+ index += 1;
1093
1545
  }
1094
1546
  }
1095
1547
  if (!Number.isFinite(options.limit) || options.limit < 1) options.limit = DEFAULT_RECENT_LIMIT;
1096
1548
  if (options.port !== void 0 && (!Number.isFinite(options.port) || options.port < 1)) options.port = 8765;
1549
+ if (options.retentionDays !== void 0 && (!Number.isFinite(options.retentionDays) || options.retentionDays < 0)) {
1550
+ options.retentionDays = void 0;
1551
+ }
1097
1552
  return options;
1098
1553
  }
1099
1554
  function parsePositionals(args) {
@@ -1102,7 +1557,7 @@ function parsePositionals(args) {
1102
1557
  const arg = args[index];
1103
1558
  if (!arg) continue;
1104
1559
  if (arg.startsWith("--")) {
1105
- if (["--db", "--data-dir", "--limit", "--host", "--port", "--output", "--config-dir"].includes(arg)) index += 1;
1560
+ if (["--db", "--data-dir", "--limit", "--host", "--port", "--output", "--config-dir", "--retention-days"].includes(arg)) index += 1;
1106
1561
  continue;
1107
1562
  }
1108
1563
  if (arg === "-o") {
@@ -1234,7 +1689,7 @@ async function configureOpenCodeDebug(options) {
1234
1689
  }
1235
1690
  const opencodeConfig = await readJsonConfig(opencodePath, { plugin: [] });
1236
1691
  const tuiConfig = await readJsonConfig(tuiPath, { plugin: [] });
1237
- setSinglePluginSpec(opencodeConfig, SERVER_PLUGIN_SPEC, localServerEntry);
1692
+ setSinglePluginSpec(opencodeConfig, SERVER_PLUGIN_SPEC, [localServerEntry, debugServerOptions(options)], localServerEntry);
1238
1693
  setSinglePluginSpec(tuiConfig, TUI_PLUGIN_SPEC, localTuiEntry);
1239
1694
  removePlugin(tuiConfig, SUBPATH_TUI_PLUGIN_SPEC);
1240
1695
  const lines = [
@@ -1323,6 +1778,11 @@ function stripJsonCommentsAndTrailingCommas(input) {
1323
1778
  function isJsonObject(value) {
1324
1779
  return !!value && typeof value === "object" && !Array.isArray(value);
1325
1780
  }
1781
+ function debugServerOptions(options) {
1782
+ const serverOptions = {};
1783
+ if (options.retentionDays !== void 0) serverOptions.retentionDays = options.retentionDays;
1784
+ return serverOptions;
1785
+ }
1326
1786
  function addUniquePlugin(config, plugin) {
1327
1787
  const current = Array.isArray(config.plugin) ? config.plugin : [];
1328
1788
  if (current.includes(plugin)) {
@@ -1341,9 +1801,10 @@ function removePlugin(config, plugin) {
1341
1801
  function isPluginEntry(entry, plugin) {
1342
1802
  return entry === plugin || Array.isArray(entry) && entry[0] === plugin;
1343
1803
  }
1344
- function setSinglePluginSpec(config, previousPlugin, nextPlugin) {
1804
+ function setSinglePluginSpec(config, previousPlugin, nextPlugin, nextPluginSpec) {
1345
1805
  const current = Array.isArray(config.plugin) ? config.plugin : [];
1346
- const next = current.filter((entry) => !isInsightsPluginEntry(entry, previousPlugin, nextPlugin));
1806
+ const localPlugin = nextPluginSpec ?? (typeof nextPlugin === "string" ? nextPlugin : "");
1807
+ const next = current.filter((entry) => !isInsightsPluginEntry(entry, previousPlugin, localPlugin));
1347
1808
  config.plugin = [...next, nextPlugin];
1348
1809
  }
1349
1810
  function isInsightsPluginEntry(entry, packagePlugin, localPlugin) {
@@ -1411,7 +1872,7 @@ async function writeJsonConfig(path, config) {
1411
1872
  function usage() {
1412
1873
  return [
1413
1874
  "Usage:",
1414
- " opencode-insights debug [--config-dir DIR] [--dry-run]",
1875
+ " opencode-insights debug [--config-dir DIR] [--retention-days DAYS] [--dry-run]",
1415
1876
  " opencode-insights uninstall [--config-dir DIR] [--db PATH] [--data-dir DIR] [--keep-data] [--dry-run]",
1416
1877
  " opencode-insights recent [--db PATH] [--data-dir DIR] [--limit N] [--json]",
1417
1878
  " opencode-insights sessions [--db PATH] [--data-dir DIR] [--limit N] [--json]",