@ljw1004/opencode-trace 0.1.0 → 0.1.2

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 (3) hide show
  1. package/index.ts +20 -31
  2. package/package.json +1 -1
  3. package/viewer.js +248 -0
package/index.ts CHANGED
@@ -544,35 +544,24 @@ async function tracedFetch(
544
544
  return res
545
545
  }
546
546
 
547
- export default {
548
- id: "ljw.opencode-trace",
549
- async server(): Promise<object> {
550
- // OpenCode loads this module with dynamic import() when plugin state is initialized for
551
- // an instance/directory. In current OpenCode this module import is normally cached, so
552
- // top-level state like `orig`, `files`, and `prevs` survives repeated hook initialization.
553
- //
554
- // OpenCode then calls `server()` when it initializes this plugin's server hooks for that
555
- // instance. That can happen more than once per process across instance reload/dispose, so
556
- // the fetch patch must be guarded even though the module itself is usually only loaded once.
557
- if (!orig) {
558
- orig = globalThis.fetch.bind(globalThis)
559
- globalThis.fetch = tracedFetch
560
- }
561
- return {}
562
- },
563
- }
547
+ /* Plugin model:
548
+ * - This module is loaded with dynamic import() when plugin state is initialized for an instance/directory.
549
+ * The module import is normally cached, so our top-level state like `orig` survives repeated hook initialization
550
+ * - Opencode calls `default.server()` when it initializes this plugin's server hooks for that instance.
551
+ * This can happen more than once per process across instance reload/dispose, which is why our fetch()
552
+ * patch is guarded even though the module itself is loaded only once.
553
+ * - Opencode v1.3 has a single unified process for both TUI and server, so its plugin entrypoint `default` is just a function.
554
+ * - Opencode v1.4 has two separate entrypoints, `default.tui()` and `default.server()`
555
+ */
556
+ const main: (() => Promise<object>) & {id?: unknown, server?: unknown} = async () => {
557
+ if (!orig) {
558
+ orig = globalThis.fetch.bind(globalThis);
559
+ globalThis.fetch = tracedFetch;
560
+ }
561
+ return {};
562
+ };
564
563
 
565
- // For opencode versions prior to 1.4, it doesn't get picked up automatically from the plugins
566
- // directory so you have to add this to your ~/.config/opencode/opencode.json
567
- // "plugin": [
568
- // "file:///path/to/.config/opencode/plugins/index.ts"
569
- // ]
570
- //
571
- // And it uses a different default export:
572
- // export default async function opencodeTracePlugin() {
573
- // if (!orig) {
574
- // orig = globalThis.fetch.bind(globalThis);
575
- // globalThis.fetch = tracedFetch;
576
- // }
577
- // return {}
578
- // }
564
+ const entrypoint = main; // opencode v1.3 expects default export to be a function
565
+ entrypoint.id = "ljw1004.opencode-trace";
566
+ entrypoint.server = main; // opencode v1.4 expects default export to be an object, with server() being what executes
567
+ export default entrypoint;
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.2",
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
+ });