@ljw1004/opencode-trace 0.1.1 → 0.1.3
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/README.md +2 -0
- package/index.ts +60 -60
- package/package.json +1 -1
- package/viewer.js +13 -10
package/README.md
CHANGED
|
@@ -15,3 +15,5 @@ Add the package to your OpenCode config:
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Restart OpenCode and you'll see each transcript stored in `~/opencode-trace`.
|
|
18
|
+
|
|
19
|
+
That installation path by default uses `@latest`, which opencode currently does't refresh when latest changes. You can force a refresh with `rm -rf ~/.cache/opencode/packages/@ljw1004/opencode-trace@latest`
|
package/index.ts
CHANGED
|
@@ -75,7 +75,7 @@ function extractPromptFromRequestBody(v: Record<string, unknown>): string | unde
|
|
|
75
75
|
return undefined
|
|
76
76
|
}
|
|
77
77
|
const content = (v: unknown): string | undefined => {
|
|
78
|
-
if (typeof v === "string") return v
|
|
78
|
+
if (typeof v === "string") return usable(v)
|
|
79
79
|
if (!Array.isArray(v)) return undefined
|
|
80
80
|
for (const part of v) {
|
|
81
81
|
const found = text(part)
|
|
@@ -97,35 +97,42 @@ function extractPromptFromRequestBody(v: Record<string, unknown>): string | unde
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* Appends one row to the session logfile.
|
|
101
|
-
*
|
|
102
|
-
*
|
|
100
|
+
* Appends one row to the session logfile. If logging fails, them skips silently.
|
|
101
|
+
* Session logfiles are like `~/opencode-trace/2024.6.10 15.30.45 why is the sky blue.html`
|
|
102
|
+
* In the vanishingly rare case of filename collision (because a user asked two different sessions
|
|
103
|
+
* the same prompt at the exact same second) then there'll be a clash, and that's the user's fault:
|
|
104
|
+
* we tradeoff theoretical perfection for user convenience in the common case.
|
|
103
105
|
*/
|
|
104
|
-
function
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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,
|
|
106
|
+
function writeNoThrow(id: string, name: string, row: Record<string, unknown>): void {
|
|
107
|
+
try {
|
|
108
|
+
const prev = files.get(id)
|
|
109
|
+
const d = new Date()
|
|
110
|
+
const file = prev ?? path.join(
|
|
111
|
+
root,
|
|
112
|
+
`${d.getFullYear()}.${d.getMonth() + 1}.${d.getDate()} ${d.getHours()}.${d.getMinutes()}.${d.getSeconds()} ${name}.html`,
|
|
120
113
|
)
|
|
114
|
+
mkdirSync(root, { recursive: true })
|
|
115
|
+
if (!existsSync(file)) {
|
|
116
|
+
const html = PREAMBLE.replace(
|
|
117
|
+
"// {viewer.js}",
|
|
118
|
+
() => readFileSync(new URL("./viewer.js", import.meta.url), "utf8"),
|
|
119
|
+
)
|
|
120
|
+
appendFileSync(
|
|
121
|
+
file,
|
|
122
|
+
html,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
files.set(id, file)
|
|
126
|
+
appendFileSync(file, `${JSON.stringify(row).replace(/-->/g, "--\\u003e")}\n`)
|
|
127
|
+
} catch {
|
|
128
|
+
// Intentionally swallow tracing I/O failures so plugin logging can't crash OpenCode.
|
|
121
129
|
}
|
|
122
|
-
files.set(id, file)
|
|
123
|
-
appendFileSync(file, `${JSON.stringify(row).replace(/-->/g, "--\\u003e")}\n`)
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
/**
|
|
127
|
-
* Given two json values, returns a bool for whether they are identical, plus a
|
|
128
|
-
* of the difference intended for humans to read
|
|
133
|
+
* Given two json values, returns a bool for whether they are identical, plus a
|
|
134
|
+
* (lossy) representation of the difference intended for humans to read, which
|
|
135
|
+
* still roughly captures the shape even of unchanged objects.
|
|
129
136
|
*
|
|
130
137
|
* The representation always has the same type as `next`.
|
|
131
138
|
*
|
|
@@ -465,7 +472,7 @@ async function tracedFetch(
|
|
|
465
472
|
|
|
466
473
|
const req = new Request(input, init)
|
|
467
474
|
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!(
|
|
475
|
+
if (session === undefined) return orig!(req);
|
|
469
476
|
|
|
470
477
|
const text = await req.clone().text().catch(() => "")
|
|
471
478
|
const raw = ((): Record<string, unknown> => {
|
|
@@ -478,6 +485,10 @@ async function tracedFetch(
|
|
|
478
485
|
})()
|
|
479
486
|
const title = isRecord(raw) && typeof raw._body !== "string" ? extractPromptFromRequestBody(raw) : undefined
|
|
480
487
|
const purpose = isRecord(raw) && Array.isArray(raw.tools) && raw.tools.length > 0 ? '' : '[meta]';
|
|
488
|
+
// The purpose field is "[meta]" for LLM requests that appear to be not part of the conversation, e.g. "generate a title".
|
|
489
|
+
// I tried a bunch of heuristics, and this one "no tools" was the one that worked best across a variety of models.
|
|
490
|
+
// We calculate it here based on the request, and store it on both request and response, since otherwise
|
|
491
|
+
// there are no reliable indicators on the response jsonl for our viewer to key off.
|
|
481
492
|
const seq = (ids.get(session) ?? 0) + 1
|
|
482
493
|
ids.set(session, seq)
|
|
483
494
|
const common = { _id: seq, _purpose: purpose, _url: req.url }
|
|
@@ -493,7 +504,7 @@ async function tracedFetch(
|
|
|
493
504
|
const requestNext = raw as object
|
|
494
505
|
const [requestRow] = delta(prevs.get(requestKey), requestNext)
|
|
495
506
|
prevs.set(requestKey, requestNext)
|
|
496
|
-
|
|
507
|
+
writeNoThrow(session, name, {
|
|
497
508
|
...(requestRow as Record<string, unknown>),
|
|
498
509
|
...common,
|
|
499
510
|
_kind: "request",
|
|
@@ -501,7 +512,7 @@ async function tracedFetch(
|
|
|
501
512
|
})
|
|
502
513
|
|
|
503
514
|
const res = await orig!(req).catch((err) => {
|
|
504
|
-
|
|
515
|
+
writeNoThrow(session, name, {
|
|
505
516
|
...common,
|
|
506
517
|
_kind: "error",
|
|
507
518
|
_ts: now(),
|
|
@@ -526,7 +537,7 @@ async function tracedFetch(
|
|
|
526
537
|
const responseKey = `${session}\n${req.method}\n${req.url}\nresponse\n${purpose}`
|
|
527
538
|
const [responseRow] = delta(prevs.get(responseKey), responseNext)
|
|
528
539
|
prevs.set(responseKey, responseNext)
|
|
529
|
-
|
|
540
|
+
writeNoThrow(session, name, {
|
|
530
541
|
...(responseRow as Record<string, unknown>),
|
|
531
542
|
...common,
|
|
532
543
|
_kind: "response",
|
|
@@ -534,7 +545,7 @@ async function tracedFetch(
|
|
|
534
545
|
})
|
|
535
546
|
})
|
|
536
547
|
.catch((err) => {
|
|
537
|
-
|
|
548
|
+
writeNoThrow(session, name, {
|
|
538
549
|
...common,
|
|
539
550
|
_kind: "error",
|
|
540
551
|
_ts: now(),
|
|
@@ -544,35 +555,24 @@ async function tracedFetch(
|
|
|
544
555
|
return res
|
|
545
556
|
}
|
|
546
557
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
}
|
|
558
|
+
/* Plugin model:
|
|
559
|
+
* - This module is loaded with dynamic import() when plugin state is initialized for an instance/directory.
|
|
560
|
+
* The module import is normally cached, so our top-level state like `orig` survives repeated hook initialization
|
|
561
|
+
* - Opencode calls `default.server()` when it initializes this plugin's server hooks for that instance.
|
|
562
|
+
* This can happen more than once per process across instance reload/dispose, which is why our fetch()
|
|
563
|
+
* patch is guarded even though the module itself is loaded only once.
|
|
564
|
+
* - Opencode v1.3 has a single unified process for both TUI and server, so its plugin entrypoint `default` is just a function.
|
|
565
|
+
* - Opencode v1.4 has two separate entrypoints, `default.tui()` and `default.server()`
|
|
566
|
+
*/
|
|
567
|
+
const main: (() => Promise<object>) & {id?: unknown, server?: unknown} = async () => {
|
|
568
|
+
if (!orig) {
|
|
569
|
+
orig = globalThis.fetch.bind(globalThis);
|
|
570
|
+
globalThis.fetch = tracedFetch;
|
|
571
|
+
}
|
|
572
|
+
return {};
|
|
573
|
+
};
|
|
564
574
|
|
|
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
|
-
// }
|
|
575
|
+
const entrypoint = main; // opencode v1.3 expects default export to be a function
|
|
576
|
+
entrypoint.id = "ljw1004.opencode-trace";
|
|
577
|
+
entrypoint.server = main; // opencode v1.4 expects default export to be an object, with server() being what executes
|
|
578
|
+
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.3",
|
|
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
CHANGED
|
@@ -32,6 +32,7 @@ function fromHTML(html) {
|
|
|
32
32
|
* Escapes string for safe insertion into HTML
|
|
33
33
|
*/
|
|
34
34
|
function esc(s) {
|
|
35
|
+
s = String(s ?? '');
|
|
35
36
|
return s
|
|
36
37
|
.replace(/&/g, '&')
|
|
37
38
|
.replace(/</g, '<')
|
|
@@ -51,7 +52,7 @@ function ts(data) {
|
|
|
51
52
|
* Puts a string onto a single line and truncates to 80 chars, for display in INLINE part of a node
|
|
52
53
|
*/
|
|
53
54
|
function short(s) {
|
|
54
|
-
return s.replace(/\n/g, ' ').slice(0, 80);
|
|
55
|
+
return String(s ?? '').replace(/\n/g, ' ').slice(0, 80);
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/**
|
|
@@ -63,10 +64,10 @@ function contentText(contents) {
|
|
|
63
64
|
if (!Array.isArray(contents)) return JSON.stringify(contents ?? '');
|
|
64
65
|
let r = [];
|
|
65
66
|
for (const c of contents ?? []) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
else r.push(`[${
|
|
67
|
+
const type = c && typeof c === 'object' ? c.type : undefined;
|
|
68
|
+
const text = c && typeof c === 'object' ? c.text : c;
|
|
69
|
+
if (type === 'input_text' || type === 'output_text' || type === 'text') r.push(String(text ?? ''));
|
|
70
|
+
else r.push(`[${String(type ?? '?')}]`);
|
|
70
71
|
}
|
|
71
72
|
return r.join('\n');
|
|
72
73
|
}
|
|
@@ -91,7 +92,7 @@ function renderPayload(elements) {
|
|
|
91
92
|
} else if (e.type === 'input_text' || e.type === 'output_text' || e.type === 'text') {
|
|
92
93
|
payload.push({
|
|
93
94
|
[TITLE]: `${payload.length}: ${e.type}: `,
|
|
94
|
-
[INLINE]: esc(short(e.text)),
|
|
95
|
+
[INLINE]: esc(short(String(e.text ?? ''))),
|
|
95
96
|
body: e.text,
|
|
96
97
|
});
|
|
97
98
|
|
|
@@ -131,13 +132,15 @@ function renderPayload(elements) {
|
|
|
131
132
|
|
|
132
133
|
/**
|
|
133
134
|
* Renders a node in the tree.
|
|
134
|
-
* If it looks like a REQUEST or RESPONSE payload (has ._kind property) then
|
|
135
|
+
* If it looks like a REQUEST or RESPONSE payload (has ._kind property) then pretty-prints it.
|
|
136
|
+
* The goal of this pretty-printing is not to be 100% faithful; instead it's solely to surface
|
|
137
|
+
* to the users some of the most important lines, for their attention.
|
|
135
138
|
* Otherwise, renders primtives, objects, arrays in the obvious way.
|
|
136
139
|
*/
|
|
137
140
|
function render(data, label) {
|
|
138
141
|
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}` : '';
|
|
142
|
+
const id = data?._id !== undefined ? ` #${esc(String(data._id))}` : '';
|
|
143
|
+
const purpose = data?._purpose ? ` ${esc(String(data._purpose))}` : '';
|
|
141
144
|
if (data?.[TITLE] !== undefined) {
|
|
142
145
|
return data;
|
|
143
146
|
} else if (data?._kind === 'request') {
|
|
@@ -162,7 +165,7 @@ function render(data, label) {
|
|
|
162
165
|
const raw = {...data};
|
|
163
166
|
return {
|
|
164
167
|
[TITLE]: `[${esc(ts(data))}] <b>ERROR${id}${purpose}</b> `,
|
|
165
|
-
[INLINE]: esc(short(data._error
|
|
168
|
+
[INLINE]: esc(short(data._error)),
|
|
166
169
|
body: [
|
|
167
170
|
data._error ?? '???',
|
|
168
171
|
...(data._stack ? [{[TITLE]: 'stack', body: data._stack}] : []),
|