@ljw1004/opencode-trace 0.1.0 → 0.1.1

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/viewer.js +248 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ljw1004/opencode-trace",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "OpenCode plugin that saves raw json LLM request+responses as jsonl in ~/opencode-trace, with built-in HTML interactive viewer",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/viewer.js ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Rendering library for codex-trace logs.
3
+ *
4
+ * A codex-trace log is a sequence of jsonl lines in an unterminated `<!` + `--` comment at the end of an HTML document.
5
+ * This module extracts that log and renders each line as a collapsible tree-structure.
6
+ *
7
+ * There's a little bit of cleverness. This module uses a `render()` function to determine how nodes in the tree
8
+ * should be rendered. Normally they're rendered in the normal way (primitives as leaf nodes, objects and arrays as
9
+ * collapsible nodes whose children are recursively rendered). But if the `render()` function at any level returns
10
+ * an object with special properties `{Symbol('TITLE'): ..., Symbol('INLINE'): ..., body: ..., open: ...}`
11
+ * then that object decides how it should be rendered in the tree. Within an object/array, it will be rendered
12
+ * as "▷ TITLE: INLINE" when collapsed, or "▽ TITLE" when expanded, with 'body' an object/array/primitive for
13
+ * the contents of that expanded node. The `open` flag says whether it should be initially expanded.
14
+ *
15
+ * This module has special handling for REQUEST and RESPONSE json payloads for Codex's communication with an LLM.
16
+ */
17
+
18
+ const TITLE = Symbol('TITLE');
19
+ const INLINE = Symbol('INLINE');
20
+ // Invariant: the contents of [TITLE] and [INLINE] have both been escaped
21
+
22
+ /**
23
+ * Turns an html string into a DOM node
24
+ */
25
+ function fromHTML(html) {
26
+ const t = document.createElement('template');
27
+ t.innerHTML = html.trim();
28
+ return t.content.firstElementChild;
29
+ }
30
+
31
+ /**
32
+ * Escapes string for safe insertion into HTML
33
+ */
34
+ function esc(s) {
35
+ return s
36
+ .replace(/&/g, '&amp;')
37
+ .replace(/</g, '&lt;')
38
+ .replace(/>/g, '&gt;')
39
+ .replace(/\n/g, '<br/>')
40
+ .replace(/\\n/g, '<br/>');
41
+ }
42
+
43
+ /**
44
+ * Given a datstring in ISO format, returns HH:MM:SS
45
+ */
46
+ function ts(data) {
47
+ return data?._ts?.slice(11, 19) ?? '?';
48
+ }
49
+
50
+ /**
51
+ * Puts a string onto a single line and truncates to 80 chars, for display in INLINE part of a node
52
+ */
53
+ function short(s) {
54
+ return s.replace(/\n/g, ' ').slice(0, 80);
55
+ }
56
+
57
+ /**
58
+ * Interprets a message-content array into text for display.
59
+ * If we're given undefined, returns an empty string.
60
+ */
61
+ function contentText(contents) {
62
+ if (typeof contents === 'string') return contents;
63
+ if (!Array.isArray(contents)) return JSON.stringify(contents ?? '');
64
+ let r = [];
65
+ for (const c of contents ?? []) {
66
+ if (c.type === 'input_text') r.push(c.text);
67
+ else if (c.type === 'output_text') r.push(c.text);
68
+ else if (c.type === 'text') r.push(c.text);
69
+ else r.push(`[${c.type}]`);
70
+ }
71
+ return r.join('\n');
72
+ }
73
+
74
+ /**
75
+ * Renders a sequence of payload elements from either OpenAI or Anthropic payloads.
76
+ */
77
+ function renderPayload(elements) {
78
+ if (!Array.isArray(elements)) return [];
79
+ const payload = [];
80
+ for (const e of elements) {
81
+ if (e === '...') {
82
+ payload.push({[TITLE]: 'Added...'});
83
+ } else if (e === '---') {
84
+ payload.push({[TITLE]: 'Removed...'});
85
+ } else if (e.type === 'message' || (e.type === undefined && e.role !== undefined && e.content !== undefined)) {
86
+ payload.push({
87
+ [TITLE]: `${payload.length}: message(${esc(e.role)}): `,
88
+ [INLINE]: esc(short(contentText(e.content))),
89
+ body: contentText(e.content),
90
+ });
91
+ } else if (e.type === 'input_text' || e.type === 'output_text' || e.type === 'text') {
92
+ payload.push({
93
+ [TITLE]: `${payload.length}: ${e.type}: `,
94
+ [INLINE]: esc(short(e.text)),
95
+ body: e.text,
96
+ });
97
+
98
+ } else if (e.type === 'function_call_output' || e.type === 'tool_result') {
99
+ const result = (e.type === 'function_call_output') ?
100
+ (typeof e.output === 'string' ? e.output : JSON.stringify(e.output ?? ''))
101
+ : typeof e.content === 'string' ? e.content : contentText(e.content);
102
+ payload.push({
103
+ [TITLE]: `${payload.length}: `,
104
+ [INLINE]: `${esc(e.type)}: ${esc(short(result))}`,
105
+ body: e,
106
+ });
107
+ } else if (e.type === 'function_call' || e.type === 'tool_use') {
108
+ let arg = "";
109
+ try {
110
+ const raw = e.type === 'function_call' ? JSON.parse(e.arguments) : e.input;
111
+ const rawArg = raw?.cmd ?? raw?.pattern ?? raw;
112
+ arg = typeof rawArg === 'string' ? rawArg : JSON.stringify(rawArg ?? '');
113
+ } catch (e) {
114
+ arg = '...';
115
+ }
116
+ payload.push({
117
+ [TITLE]: `${payload.length}: `,
118
+ [INLINE]: `${esc(e.type)}: ${esc(e.name ?? '???')}(${esc(short(arg))})`,
119
+ body: e,
120
+ });
121
+ } else {
122
+ payload.push({
123
+ [TITLE]: `${payload.length}: `,
124
+ [INLINE]: esc(e.type ?? '???'),
125
+ body: e,
126
+ });
127
+ }
128
+ }
129
+ return payload;
130
+ }
131
+
132
+ /**
133
+ * Renders a node in the tree.
134
+ * If it looks like a REQUEST or RESPONSE payload (has ._kind property) then renders it conveniently.
135
+ * Otherwise, renders primtives, objects, arrays in the obvious way.
136
+ */
137
+ function render(data, label) {
138
+ const deltaField = (key) => data[key] ?? data[`${key}+`] ?? data[`${key}-`] ?? data[`*${key}`];
139
+ const id = data?._id !== undefined ? ` #${data._id}` : '';
140
+ const purpose = data?._purpose ? ` ${data._purpose}` : '';
141
+ if (data?.[TITLE] !== undefined) {
142
+ return data;
143
+ } else if (data?._kind === 'request') {
144
+ const rendered = renderPayload(deltaField('input') ?? deltaField('messages'));
145
+ const raw = {...data};
146
+ delete raw._kind;
147
+ return {
148
+ [TITLE]: `[${esc(ts(data))}] <b>REQUEST${id}${purpose}</b> `,
149
+ body: [...rendered, {[TITLE]: 'raw', body: raw}],
150
+ open: true,
151
+ };
152
+ } else if (data?._kind === 'response') {
153
+ const payload = renderPayload(deltaField('output') ?? deltaField('content'));
154
+ const raw = {...data};
155
+ delete raw._kind;
156
+ return {
157
+ [TITLE]: `[${esc(ts(data))}] <b>RESPONSE${id}${purpose}</b> `,
158
+ body: [...payload, {[TITLE]: 'raw', body: raw}],
159
+ open: true,
160
+ };
161
+ } else if (data?._kind === 'error') {
162
+ const raw = {...data};
163
+ return {
164
+ [TITLE]: `[${esc(ts(data))}] <b>ERROR${id}${purpose}</b> `,
165
+ [INLINE]: esc(short(data._error ?? '')),
166
+ body: [
167
+ data._error ?? '???',
168
+ ...(data._stack ? [{[TITLE]: 'stack', body: data._stack}] : []),
169
+ {[TITLE]: 'raw', body: raw},
170
+ ],
171
+ open: true,
172
+ };
173
+ } else if (data?._kind !== undefined && data?._ts !== undefined) {
174
+ const raw = {...data};
175
+ return {
176
+ [TITLE]: `[${esc(ts(data))}] <b>${esc(data._kind)}</b> `,
177
+ body: raw,
178
+ open: true,
179
+ };
180
+ } else {
181
+ return {
182
+ [TITLE]: esc(label),
183
+ [INLINE]: esc(
184
+ Array.isArray(data)
185
+ ? `[...${data.length} items]`
186
+ : '{' +
187
+ Object.keys(data)
188
+ .map(k => `${JSON.stringify(k)}:`)
189
+ .join(',') +
190
+ '}',
191
+ ),
192
+ body: data,
193
+ numbered: true,
194
+ };
195
+ }
196
+ }
197
+
198
+ function buildNode(data, label) {
199
+ if (data && typeof data === 'object') {
200
+ const r = render(data, label);
201
+ const d = fromHTML(
202
+ `<details><summary>${r[TITLE]}<output>${r[INLINE] ?? ''}</output></summary></details>`,
203
+ );
204
+ d.addEventListener(
205
+ 'toggle',
206
+ () => {
207
+ if (r.body === undefined) {
208
+ // skip
209
+ } else if (Array.isArray(r.body)) {
210
+ r.body.forEach((item, i) =>
211
+ d.appendChild(buildNode(item, r?.numbered ? `${i + 1}: ` : '')),
212
+ );
213
+ } else if (r.body && typeof r.body === 'object') {
214
+ Object.keys(r.body).forEach(k =>
215
+ d.appendChild(buildNode(r.body[k], `${JSON.stringify(k)}: `)),
216
+ );
217
+ } else {
218
+ d.appendChild(buildNode(r.body, ''));
219
+ }
220
+ },
221
+ {once: true},
222
+ );
223
+ d.open = r.open;
224
+ return d;
225
+ } else {
226
+ return fromHTML(`<div>${esc(label)}${esc(JSON.stringify(data))}</div>`);
227
+ }
228
+ }
229
+
230
+ window.addEventListener('DOMContentLoaded', () => {
231
+ if (
232
+ document.lastChild &&
233
+ document.lastChild.nodeType === Node.COMMENT_NODE &&
234
+ document.lastChild.data.trim()
235
+ ) {
236
+ for (const line of document.lastChild.data.split(/\r?\n/).filter(Boolean)) {
237
+ let data = '';
238
+ try {
239
+ data = JSON.parse(line);
240
+ } catch (e) {
241
+ data = {error: String(e), raw: line};
242
+ }
243
+ const node = buildNode(data, 'json:');
244
+ node.classList.add('log-entry');
245
+ document.body.appendChild(node);
246
+ }
247
+ }
248
+ });