@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.
Files changed (4) hide show
  1. package/README.md +2 -0
  2. package/index.ts +60 -60
  3. package/package.json +1 -1
  4. 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
- * 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.
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 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,
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 representation
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!(input,init);
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
- write(session, name, {
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
- write(session, name, {
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
- write(session, name, {
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
- write(session, name, {
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
- 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
- }
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
- // 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
- // }
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.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, '&amp;')
37
38
  .replace(/</g, '&lt;')
@@ -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
- 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}]`);
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 renders it conveniently.
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}] : []),