@ljw1004/opencode-trace 0.1.0

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/index.ts +578 -0
  4. package/package.json +44 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucian Wischik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # opencode-trace
2
+
3
+ This opencode plugin lets you see the raw json requests made to the LLM, and the responses.
4
+ * Example: https://ljw1004.github.io/opencode-trace/example.html
5
+
6
+ ## Installation
7
+
8
+ Add the package to your OpenCode config:
9
+
10
+ ```json
11
+ {
12
+ "$schema": "https://opencode.ai/config.json",
13
+ "plugin": ["@ljw1004/opencode-trace"]
14
+ }
15
+ ```
16
+
17
+ Restart OpenCode and you'll see each transcript stored in `~/opencode-trace`.
package/index.ts ADDED
@@ -0,0 +1,578 @@
1
+ /**
2
+ * OpenCode server plugin which captures raw LLM HTTP payloads to `~/opencode-trace`.
3
+ *
4
+ * Each trace is an interactive html file whose trailing unterminated html-comment contains
5
+ * one json object per line. To read the logs programmatically, strip everything up to that
6
+ * final comment. To browse them interactively, open the html file directly in a browser.
7
+ *
8
+ * The plugin loads `./viewer.js` at runtime and embeds it into each new html trace, while also
9
+ * preferring a sibling `viewer.js` if one exists next to the saved html file.
10
+ */
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"
12
+ import { createHash } from "node:crypto"
13
+ import os from "node:os"
14
+ import path from "node:path"
15
+
16
+ const root = path.join(os.homedir(), "opencode-trace")
17
+ const PREAMBLE = `<!DOCTYPE html>
18
+ <html>
19
+ <head>
20
+ <style>
21
+ body {font-family: system-ui, -apple-system, sans-serif; margin: 0;}
22
+ body>details {margin-top: 1ex; padding-top: 1ex; border-top: 1px solid lightgray;}
23
+ details {position: relative; padding-left: 1.25em;}
24
+ summary {list-style: none; cursor: pointer;}
25
+ summary::-webkit-details-marker {display: none;}
26
+ summary::before {content: '▷';position: absolute;left: 0;color: #666;}
27
+ details[open]>summary::before {content: '▽';}
28
+ details>div {margin-left: 1.25em;}
29
+ details[open]>summary output {display: none;}
30
+ </style>
31
+ <script src="viewer.js"></script>
32
+ <script>
33
+ if (window.buildNode === undefined) {
34
+ // {viewer.js}
35
+ }
36
+ </script>
37
+ </head>
38
+ <body>
39
+ </body>
40
+ </html>
41
+ ${"<!" + "--"}
42
+ `
43
+
44
+ /** Mutable global state: maps OpenCode session ids to the html logfile path for that session. */
45
+ const files = new Map<string, string>()
46
+
47
+ /** Mutable global state: maps `session\nmethod\nurl\n_kind\nmeta|real` to the previous raw body used as the delta base. */
48
+ const prevs = new Map<string, object>()
49
+
50
+ /** Mutable global state: maps OpenCode session ids to the next per-session fetch sequence number. */
51
+ const ids = new Map<string, number>()
52
+
53
+ /** Mutable module state: the unpatched global fetch for this module instance, assigned inside `server()`. */
54
+ let orig: typeof globalThis.fetch | undefined
55
+
56
+ function isRecord(v: unknown): v is Record<string, unknown> {
57
+ return !!v && typeof v === "object" && !Array.isArray(v)
58
+ }
59
+
60
+ /**
61
+ * Given a parsed LLM request body, returns the first user prompt text if one is present, e.g.
62
+ * {input:[{role:"user",content:[{type:"input_text",text:"why is the sky blue?"}]}]}
63
+ * ==> "why is the sky blue?"
64
+ */
65
+ function extractPromptFromRequestBody(v: Record<string, unknown>): string | undefined {
66
+ const usable = (text: string | undefined): string | undefined => {
67
+ const trimmed = text?.trim()
68
+ return trimmed && trimmed !== "Generate a title for this conversation:" ? trimmed : undefined
69
+ }
70
+ const text = (part: unknown): string | undefined => {
71
+ if (typeof part === "string") return usable(part)
72
+ if (!isRecord(part)) return
73
+ if (typeof part.text === "string") return usable(part.text)
74
+ if (typeof part.input_text === "string") return usable(part.input_text)
75
+ return undefined
76
+ }
77
+ const content = (v: unknown): string | undefined => {
78
+ if (typeof v === "string") return v
79
+ if (!Array.isArray(v)) return undefined
80
+ for (const part of v) {
81
+ const found = text(part)
82
+ if (found) return found
83
+ }
84
+ return undefined
85
+ }
86
+ const first = (list: unknown, key: "input" | "messages"): string | undefined => {
87
+ if (!Array.isArray(list)) return undefined
88
+ for (const item of list) {
89
+ if (!isRecord(item)) continue
90
+ if (item.role !== "user") continue
91
+ const found = content(item.content) ?? (key === "input" ? text(item) : undefined)
92
+ if (found) return found
93
+ }
94
+ return undefined
95
+ }
96
+ return first(v.input, "input") ?? first(v.messages, "messages") ?? (typeof v.prompt === "string" ? v.prompt : undefined)
97
+ }
98
+
99
+ /**
100
+ * Appends one row to the session logfile.
101
+ * If this is the first row for the session, also chooses the filename and writes the html preamble.
102
+ * Side effects: may create directories, create a logfile, mutate `files`, and append to disk.
103
+ */
104
+ function write(id: string, name: string, row: Record<string, unknown>): void {
105
+ const prev = files.get(id)
106
+ const d = new Date()
107
+ const file = prev ?? path.join(
108
+ root,
109
+ `${d.getFullYear()}.${d.getMonth() + 1}.${d.getDate()} ${d.getHours()}.${d.getMinutes()}.${d.getSeconds()} ${name}.html`,
110
+ )
111
+ mkdirSync(root, { recursive: true })
112
+ if (!existsSync(file)) {
113
+ const html = PREAMBLE.replace(
114
+ "// {viewer.js}",
115
+ readFileSync(new URL("./viewer.js", import.meta.url), "utf8"),
116
+ )
117
+ appendFileSync(
118
+ file,
119
+ html,
120
+ )
121
+ }
122
+ files.set(id, file)
123
+ appendFileSync(file, `${JSON.stringify(row).replace(/-->/g, "--\\u003e")}\n`)
124
+ }
125
+
126
+ /**
127
+ * Given two json values, returns a bool for whether they are identical, plus a representation
128
+ * of the difference intended for humans to read.
129
+ *
130
+ * The representation always has the same type as `next`.
131
+ *
132
+ * For changed lists, the representation is either the full new list, or, when shorter,
133
+ * `['...', additions, '---', removals]`.
134
+ *
135
+ * For dicts, removed keys appear as `-k: null`, added keys as `+k: v`, and changed keys as
136
+ * `*k: v`. If a changed dict field is itself a compact list diff, that is rendered as
137
+ * `k+: [...]` and `k-: [...]` for readability.
138
+ */
139
+ function delta(prev: unknown, next: unknown): [unknown, boolean] {
140
+ const hash = (v: unknown): string => {
141
+ const sort = (v: unknown): unknown => {
142
+ if (Array.isArray(v)) return v.map(sort)
143
+ if (!v || typeof v !== "object") return v
144
+ return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sort((v as Record<string, unknown>)[k])]))
145
+ }
146
+ return createHash("blake2b512").update(JSON.stringify(sort(v))).digest("hex")
147
+ }
148
+ if (isRecord(prev) && isRecord(next)) {
149
+ const out: Record<string, unknown> = {}
150
+ const pk = new Set(Object.keys(prev))
151
+ const nk = new Set(Object.keys(next))
152
+ for (const k of [...pk].filter((k) => !nk.has(k)).sort()) out[`-${k}`] = null
153
+ for (const k of [...nk].filter((k) => !pk.has(k)).sort()) out[`+${k}`] = next[k]
154
+ for (const k of [...pk].filter((k) => nk.has(k)).sort()) {
155
+ const [sub, same] = delta(prev[k], next[k])
156
+ if (same) {
157
+ const raw = JSON.stringify(next[k]) ?? ""
158
+ out[k] =
159
+ raw.length < 128
160
+ ? next[k]
161
+ : Array.isArray(next[k])
162
+ ? ["..."]
163
+ : isRecord(next[k])
164
+ ? { "[unchanged]": "[unchanged]" }
165
+ : typeof next[k] === "string"
166
+ ? "[unchanged]"
167
+ : next[k]
168
+ continue
169
+ }
170
+ if (!Array.isArray(sub) || (sub[0] !== "..." && sub[0] !== "---")) {
171
+ out[`*${k}`] = sub
172
+ continue
173
+ }
174
+ const cut = sub.findIndex((item) => item === "---")
175
+ const cut2 = cut === -1 ? undefined : cut
176
+ const add = cut2 === 0 ? [] : cut2 == null ? sub.slice(1) : sub.slice(1, cut2)
177
+ const del = cut2 == null ? [] : sub.slice(cut2 + 1)
178
+ if (del.length > 0) out[`${k}-`] = del
179
+ if (add.length > 0) out[`${k}+`] = add
180
+ }
181
+ return Object.keys(out).length === 0 ? [{ "[repeat]": "[repeat]" }, true] : [out, false]
182
+ }
183
+ if (Array.isArray(prev) && Array.isArray(next)) {
184
+ const left: Array<readonly [unknown, string]> = prev.map((v) => [v, hash(v)] as const)
185
+ let right: Array<readonly [unknown, string]> = next.map((v) => [v, hash(v)] as const)
186
+ const add: unknown[] = []
187
+ const del: unknown[] = []
188
+ for (const [value, sig] of left) {
189
+ const ix = right.findIndex((item) => item[1] === sig)
190
+ if (ix === -1) {
191
+ del.push(value)
192
+ continue
193
+ }
194
+ add.push(...right.slice(0, ix).map((item) => item[0]))
195
+ right = right.slice(ix + 1)
196
+ }
197
+ add.push(...right.map((item) => item[0]))
198
+ if (add.length === 0 && del.length === 0) return [next, true]
199
+ if (add.length + del.length < next.length) {
200
+ return del.length === 0
201
+ ? [["...", ...add], false]
202
+ : add.length === 0
203
+ ? [["---", ...del], false]
204
+ : [["...", ...add, "---", ...del], false]
205
+ }
206
+ return [next, false]
207
+ }
208
+ return [next, prev === next]
209
+ }
210
+
211
+ /** Given two objects, returns their shallow merge, else just returns the right-hand side. */
212
+ function merge(a: unknown, b: unknown): unknown {
213
+ if (!isRecord(a) || !isRecord(b)) return b
214
+ return { ...a, ...b }
215
+ }
216
+
217
+ /**
218
+ * Given an SSE response body, returns parsed `{event?, data}` blocks.
219
+ * Returns undefined if the body is not parseable SSE json.
220
+ */
221
+ function events(text: string): Array<{ event: string | undefined; data: unknown }> | undefined {
222
+ const out: Array<{ event: string | undefined; data: unknown }> = []
223
+ for (const block of text.split(/\r?\n\r?\n/)) {
224
+ if (!block.trim()) continue
225
+ let name: string | undefined
226
+ const data = block
227
+ .split(/\r?\n/)
228
+ .flatMap((line) => {
229
+ if (line.startsWith("event:")) {
230
+ name = line.slice(6).trim()
231
+ return []
232
+ }
233
+ if (line.startsWith("data:")) return [line.slice(5).trimStart()]
234
+ return []
235
+ })
236
+ .join("\n")
237
+ if (!data || data === "[DONE]") continue
238
+ try {
239
+ out.push({ event: name, data: JSON.parse(data) as unknown })
240
+ } catch {
241
+ return
242
+ }
243
+ }
244
+ return out.length > 0 ? out : undefined
245
+ }
246
+
247
+ /** Given parsed OpenAI `/responses` SSE blocks, reconstructs the final response object. */
248
+ function openaiResponses(list: Array<{ data: unknown }>): Record<string, unknown> {
249
+ let base: Record<string, unknown> = { object: "response" }
250
+ const ids: string[] = []
251
+ const items = new Map<string, Record<string, unknown>>()
252
+ const sums = new Map<string, string>()
253
+ for (const row of list) {
254
+ if (!isRecord(row.data) || typeof row.data.type !== "string") continue
255
+ if (isRecord(row.data.response)) base = merge(base, row.data.response) as Record<string, unknown>
256
+ if (row.data.type === "response.output_item.added" && isRecord(row.data.item) && typeof row.data.item.id === "string") {
257
+ const id = row.data.item.id
258
+ const item = { ...row.data.item }
259
+ if (item.type === "message") item.content = (items.get(id)?.content as unknown[]) ?? []
260
+ if (!ids.includes(id)) ids.push(id)
261
+ items.set(id, item)
262
+ continue
263
+ }
264
+ if (row.data.type === "response.output_text.delta" && typeof row.data.item_id === "string") {
265
+ const prev = items.get(row.data.item_id) ?? { id: row.data.item_id, type: "message", role: "assistant", content: [] }
266
+ const content: unknown[] = Array.isArray(prev.content) ? [...(prev.content as unknown[])] : []
267
+ const last = content[content.length - 1]
268
+ if (isRecord(last) && last.type === "output_text" && typeof last.text === "string") {
269
+ last.text += typeof row.data.delta === "string" ? row.data.delta : ""
270
+ } else {
271
+ content.push({ type: "output_text", text: typeof row.data.delta === "string" ? row.data.delta : "" })
272
+ }
273
+ items.set(row.data.item_id, { ...prev, content })
274
+ if (!ids.includes(row.data.item_id)) ids.push(row.data.item_id)
275
+ continue
276
+ }
277
+ if (row.data.type === "response.function_call_arguments.delta" && typeof row.data.item_id === "string") {
278
+ const prev = items.get(row.data.item_id) ?? { id: row.data.item_id, type: "function_call", arguments: "" }
279
+ items.set(row.data.item_id, {
280
+ ...prev,
281
+ arguments: `${typeof prev.arguments === "string" ? prev.arguments : ""}${typeof row.data.delta === "string" ? row.data.delta : ""}`,
282
+ })
283
+ if (!ids.includes(row.data.item_id)) ids.push(row.data.item_id)
284
+ continue
285
+ }
286
+ if (row.data.type === "response.function_call_arguments.done" && typeof row.data.item_id === "string") {
287
+ const prev = items.get(row.data.item_id) ?? { id: row.data.item_id, type: "function_call" }
288
+ items.set(row.data.item_id, {
289
+ ...prev,
290
+ arguments: typeof row.data.arguments === "string" ? row.data.arguments : prev.arguments,
291
+ })
292
+ if (!ids.includes(row.data.item_id)) ids.push(row.data.item_id)
293
+ continue
294
+ }
295
+ if (row.data.type === "response.reasoning_summary_text.delta" && typeof row.data.item_id === "string") {
296
+ sums.set(row.data.item_id, `${sums.get(row.data.item_id) ?? ""}${typeof row.data.delta === "string" ? row.data.delta : ""}`)
297
+ continue
298
+ }
299
+ if (row.data.type === "response.output_item.done" && isRecord(row.data.item) && typeof row.data.item.id === "string") {
300
+ const prev = items.get(row.data.item.id) ?? {}
301
+ const next = { ...prev, ...row.data.item }
302
+ if (Array.isArray(prev.content) && !Array.isArray(next.content)) next.content = prev.content
303
+ if (typeof prev.arguments === "string" && typeof next.arguments !== "string") next.arguments = prev.arguments
304
+ items.set(row.data.item.id, next)
305
+ if (!ids.includes(row.data.item.id)) ids.push(row.data.item.id)
306
+ }
307
+ }
308
+ const output = ids.map((id) => {
309
+ const item = { ...(items.get(id) ?? { id }) }
310
+ if (item.type === "reasoning" && sums.has(id)) item.summary = [{ type: "summary_text", text: sums.get(id) }]
311
+ return item
312
+ })
313
+ return { ...base, output }
314
+ }
315
+
316
+ /** Given parsed OpenAI chat-completions SSE blocks, reconstructs the final completion json. */
317
+ function openaiChat(list: Array<{ data: unknown }>): Record<string, unknown> {
318
+ const choices = new Map<number, Record<string, unknown>>()
319
+ let id = ""
320
+ let model = ""
321
+ let created = 0
322
+ let usage: unknown
323
+ for (const row of list) {
324
+ if (!isRecord(row.data)) continue
325
+ if (typeof row.data.id === "string") id = row.data.id
326
+ if (typeof row.data.model === "string") model = row.data.model
327
+ if (typeof row.data.created === "number") created = row.data.created
328
+ if (isRecord(row.data.usage)) usage = row.data.usage
329
+ if (!Array.isArray(row.data.choices)) continue
330
+ for (const part of row.data.choices) {
331
+ if (!isRecord(part)) continue
332
+ const ix = typeof part.index === "number" ? part.index : 0
333
+ const prev = choices.get(ix) ?? { index: ix, message: { role: "assistant" }, finish_reason: null }
334
+ const msg: Record<string, unknown> = isRecord(prev.message) ? { ...prev.message } : { role: "assistant" }
335
+ if (isRecord(part.delta)) {
336
+ if (typeof part.delta.role === "string") msg.role = part.delta.role
337
+ if (typeof part.delta.content === "string") msg.content = `${typeof msg.content === "string" ? msg.content : ""}${part.delta.content}`
338
+ if (typeof part.delta.reasoning_content === "string") {
339
+ msg.reasoning_content = `${typeof msg.reasoning_content === "string" ? msg.reasoning_content : ""}${part.delta.reasoning_content}`
340
+ }
341
+ if (Array.isArray(part.delta.tool_calls)) {
342
+ const calls: unknown[] = Array.isArray(msg.tool_calls) ? [...(msg.tool_calls as unknown[])] : []
343
+ for (const call of part.delta.tool_calls) {
344
+ if (!isRecord(call)) continue
345
+ const jx = typeof call.index === "number" ? call.index : calls.length
346
+ const prevCall = isRecord(calls[jx])
347
+ ? { ...calls[jx] }
348
+ : { index: jx, id: call.id, type: call.type ?? "function", function: { name: "", arguments: "" } }
349
+ const fn = isRecord(prevCall.function) ? { ...prevCall.function } : { name: "", arguments: "" }
350
+ if (isRecord(call.function) && typeof call.function.name === "string") fn.name = call.function.name
351
+ if (isRecord(call.function) && typeof call.function.arguments === "string") {
352
+ fn.arguments = `${typeof fn.arguments === "string" ? fn.arguments : ""}${call.function.arguments}`
353
+ }
354
+ calls[jx] = { ...prevCall, ...call, function: fn }
355
+ }
356
+ msg.tool_calls = calls
357
+ }
358
+ }
359
+ choices.set(ix, {
360
+ ...prev,
361
+ message: msg,
362
+ finish_reason: part.finish_reason ?? prev.finish_reason ?? null,
363
+ })
364
+ }
365
+ }
366
+ return {
367
+ id,
368
+ object: "chat.completion",
369
+ created,
370
+ model,
371
+ choices: [...choices.values()].sort((a, b) => Number(a.index) - Number(b.index)),
372
+ ...(usage ? { usage } : {}),
373
+ }
374
+ }
375
+
376
+ /** Given parsed Anthropic SSE blocks, reconstructs the final message json. */
377
+ function anthropic(list: Array<{ data: unknown }>): Record<string, unknown> {
378
+ let base: Record<string, unknown> = { type: "message", role: "assistant" }
379
+ const blocks = new Map<number, Record<string, unknown>>()
380
+ const json = new Map<number, string>()
381
+ for (const row of list) {
382
+ if (!isRecord(row.data) || typeof row.data.type !== "string") continue
383
+ if (row.data.type === "message_start" && isRecord(row.data.message)) {
384
+ base = { ...base, ...row.data.message }
385
+ continue
386
+ }
387
+ if (row.data.type === "content_block_start" && typeof row.data.index === "number" && isRecord(row.data.content_block)) {
388
+ blocks.set(row.data.index, { ...row.data.content_block })
389
+ continue
390
+ }
391
+ if (row.data.type === "content_block_delta" && typeof row.data.index === "number" && isRecord(row.data.delta)) {
392
+ const prev = blocks.get(row.data.index) ?? { type: "text", text: "" }
393
+ if (row.data.delta.type === "text_delta") blocks.set(row.data.index, { ...prev, text: `${typeof prev.text === "string" ? prev.text : ""}${typeof row.data.delta.text === "string" ? row.data.delta.text : ""}` })
394
+ if (row.data.delta.type === "input_json_delta") json.set(row.data.index, `${json.get(row.data.index) ?? ""}${typeof row.data.delta.partial_json === "string" ? row.data.delta.partial_json : ""}`)
395
+ continue
396
+ }
397
+ if (row.data.type === "message_delta") {
398
+ if (isRecord(row.data.delta)) base = { ...base, ...row.data.delta }
399
+ if (isRecord(row.data.usage)) base.usage = merge(base.usage, row.data.usage)
400
+ continue
401
+ }
402
+ if (isRecord(row.data.usage)) base.usage = merge(base.usage, row.data.usage)
403
+ }
404
+ const content = [...blocks.entries()]
405
+ .sort((a, b) => a[0] - b[0])
406
+ .map(([ix, block]) => {
407
+ if (block.type !== "tool_use" || !json.has(ix)) return block
408
+ const raw = json.get(ix) ?? ""
409
+ try {
410
+ return { ...block, input: JSON.parse(raw) as unknown }
411
+ } catch {
412
+ return { ...block, input: raw }
413
+ }
414
+ })
415
+ return { ...base, content }
416
+ }
417
+
418
+ /**
419
+ * Given a raw response body and url, returns the final json to log.
420
+ * Plain json is returned directly; known SSE formats are consolidated; failures become `{_body}`.
421
+ */
422
+ function responseAsJson(text: string, url: string): Record<string, unknown> {
423
+ try {
424
+ const body = JSON.parse(text) as unknown
425
+ return isRecord(body) ? body : { _body: text }
426
+ } catch {
427
+ const list = events(text)
428
+ if (!list) return { _body: text }
429
+ const path = ((): string => {
430
+ try {
431
+ return new URL(url).pathname
432
+ } catch {
433
+ return url
434
+ }
435
+ })()
436
+ const first = list[0]?.data
437
+ if (path.endsWith("/responses") || (isRecord(first) && typeof first.type === "string" && first.type.startsWith("response."))) {
438
+ return openaiResponses(list)
439
+ }
440
+ if (path.endsWith("/chat/completions") || (isRecord(first) && first.object === "chat.completion.chunk")) {
441
+ return openaiChat(list)
442
+ }
443
+ if (path.endsWith("/messages") || (isRecord(first) && typeof first.type === "string" && (first.type === "message_start" || first.type === "content_block_start"))) {
444
+ return anthropic(list)
445
+ }
446
+ return { _body: text }
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Intercepts matching OpenCode LLM fetches and logs request/response rows.
452
+ * Side effects: mutates `prevs`, mutates `ids`, and writes logs to disk.
453
+ */
454
+ async function tracedFetch(
455
+ input: Parameters<typeof globalThis.fetch>[0],
456
+ init?: Parameters<typeof globalThis.fetch>[1],
457
+ ): Promise<Response> {
458
+ const now = (): string => new Date().toISOString()
459
+ const error = (err: unknown): { _error: string; _stack?: string } =>
460
+ err instanceof Error
461
+ ? err.stack === undefined
462
+ ? { _error: err.message }
463
+ : { _error: err.message, _stack: err.stack }
464
+ : { _error: String(err) }
465
+
466
+ const req = new Request(input, init)
467
+ const session = req.headers.get("x-opencode-session") ?? req.headers.get("x-session-affinity") ?? req.headers.get("session_id") ?? undefined;
468
+ if (session === undefined) return orig!(input,init);
469
+
470
+ const text = await req.clone().text().catch(() => "")
471
+ const raw = ((): Record<string, unknown> => {
472
+ try {
473
+ const body = JSON.parse(text) as unknown
474
+ return isRecord(body) ? body : { _body: text }
475
+ } catch {
476
+ return { _body: text }
477
+ }
478
+ })()
479
+ const title = isRecord(raw) && typeof raw._body !== "string" ? extractPromptFromRequestBody(raw) : undefined
480
+ const purpose = isRecord(raw) && Array.isArray(raw.tools) && raw.tools.length > 0 ? '' : '[meta]';
481
+ const seq = (ids.get(session) ?? 0) + 1
482
+ ids.set(session, seq)
483
+ const common = { _id: seq, _purpose: purpose, _url: req.url }
484
+ const name = (title ?? session ?? '')
485
+ .replace(/[^A-Za-z0-9 _-]+/g, " ")
486
+ .trim()
487
+ .split(/\s+/)
488
+ .slice(0, 10)
489
+ .join(" ")
490
+ .slice(0, 50)
491
+ .trim() || "session";
492
+ const requestKey = `${session}\n${req.method}\n${req.url}\nrequest\n${purpose}`
493
+ const requestNext = raw as object
494
+ const [requestRow] = delta(prevs.get(requestKey), requestNext)
495
+ prevs.set(requestKey, requestNext)
496
+ write(session, name, {
497
+ ...(requestRow as Record<string, unknown>),
498
+ ...common,
499
+ _kind: "request",
500
+ _ts: now(),
501
+ })
502
+
503
+ const res = await orig!(req).catch((err) => {
504
+ write(session, name, {
505
+ ...common,
506
+ _kind: "error",
507
+ _ts: now(),
508
+ ...error(err),
509
+ })
510
+ throw err
511
+ });
512
+ // We'll register background processing of the response, once it comes. But return 'res' immediately.
513
+ void res
514
+ .clone()
515
+ .text()
516
+ .then((body) => {
517
+ const json = responseAsJson(body, req.url)
518
+ const detail = isRecord(json) && isRecord(json.error) && typeof json.error.message === "string"
519
+ ? json.error.message
520
+ : isRecord(json) && typeof json.error === "string"
521
+ ? json.error
522
+ : `${res.status} ${res.statusText}`
523
+ const responseNext = (!res.ok
524
+ ? { ...json, _status: res.status, _status_text: res.statusText, _error: detail }
525
+ : json) as object
526
+ const responseKey = `${session}\n${req.method}\n${req.url}\nresponse\n${purpose}`
527
+ const [responseRow] = delta(prevs.get(responseKey), responseNext)
528
+ prevs.set(responseKey, responseNext)
529
+ write(session, name, {
530
+ ...(responseRow as Record<string, unknown>),
531
+ ...common,
532
+ _kind: "response",
533
+ _ts: now(),
534
+ })
535
+ })
536
+ .catch((err) => {
537
+ write(session, name, {
538
+ ...common,
539
+ _kind: "error",
540
+ _ts: now(),
541
+ ...error(err),
542
+ })
543
+ })
544
+ return res
545
+ }
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
+ }
564
+
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
+ // }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@ljw1004/opencode-trace",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin that saves raw json LLM request+responses as jsonl in ~/opencode-trace, with built-in HTML interactive viewer",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": "./index.ts",
8
+ "main": "./index.ts",
9
+ "files": [
10
+ "index.ts",
11
+ "viewer.js",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ljw1004/opencode-trace.git"
18
+ },
19
+ "homepage": "https://github.com/ljw1004/opencode-trace#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/ljw1004/opencode-trace/issues"
22
+ },
23
+ "keywords": [
24
+ "opencode",
25
+ "opencode-plugin",
26
+ "trace",
27
+ "llm"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "lint": "eslint index.ts",
34
+ "typecheck": "tsc"
35
+ },
36
+ "devDependencies": {
37
+ "@eslint/js": "9.24.0",
38
+ "@tsconfig/node22": "22.0.2",
39
+ "@types/node": "22.13.9",
40
+ "eslint": "9.24.0",
41
+ "typescript-eslint": "8.29.0",
42
+ "typescript": "5.8.2"
43
+ }
44
+ }