@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.
- package/index.ts +20 -31
- package/package.json +1 -1
- 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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
//
|
|
568
|
-
|
|
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.
|
|
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, '&')
|
|
37
|
+
.replace(/</g, '<')
|
|
38
|
+
.replace(/>/g, '>')
|
|
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
|
+
});
|