@rejacky/opencode-insights 0.1.3 → 0.1.5

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 CHANGED
@@ -12,58 +12,78 @@ opencode plugin @rejacky/opencode-insights --global
12
12
 
13
13
  Restart OpenCode after installing the plugin.
14
14
 
15
+ On startup, the plugin creates a user-local `opencode-insights` command shim in:
16
+
17
+ ```text
18
+ ~/.local/bin
19
+ ```
20
+
21
+ Make sure that directory is on your `PATH`, then run the CLI directly:
22
+
23
+ ```bash
24
+ opencode-insights doctor
25
+ ```
26
+
15
27
  ## Uninstall
16
28
 
17
29
  Remove this plugin from `opencode.json` / `opencode.jsonc`, remove it from `tui.json`, and delete the local Insights database files:
18
30
 
19
31
  ```bash
20
- npx opencode-insights uninstall
32
+ opencode-insights uninstall
21
33
  ```
22
34
 
23
35
  Preview the cleanup without changing files:
24
36
 
25
37
  ```bash
26
- npx opencode-insights uninstall --dry-run
38
+ opencode-insights uninstall --dry-run
27
39
  ```
28
40
 
29
41
  Keep captured data while removing only the plugin config entries:
30
42
 
31
43
  ```bash
32
- npx opencode-insights uninstall --keep-data
44
+ opencode-insights uninstall --keep-data
33
45
  ```
34
46
 
35
47
  Use a custom OpenCode config directory or data location:
36
48
 
37
49
  ```bash
38
- npx opencode-insights uninstall --config-dir ~/.config/opencode --data-dir ~/.opencode-insights
50
+ opencode-insights uninstall --config-dir ~/.config/opencode --data-dir ~/.opencode-insights
39
51
  ```
40
52
 
41
- After uninstalling, restart OpenCode. If you also want to remove the npm package from your OpenCode config/package directory, run:
53
+ After uninstalling, restart OpenCode. Packages installed with `opencode plugin ... --global` are stored under OpenCode's package cache. On macOS/Linux this is typically:
42
54
 
43
- ```bash
44
- cd ~/.config/opencode
45
- npm rm @rejacky/opencode-insights
55
+ ```text
56
+ ~/.cache/opencode/packages
57
+ ```
58
+
59
+ For this machine, that expands to:
60
+
61
+ ```text
62
+ /Users/zyao/.cache/opencode/packages
46
63
  ```
47
64
 
65
+ The `uninstall` command removes plugin config entries and local Insights data; it does not remove cached OpenCode package directories automatically.
66
+
48
67
  ## What You Get
49
68
 
50
- - Live TPS, average TPS, and average TTFT in the OpenCode prompt zone.
51
- - Right-sidebar subagent status: running, done, failed, elapsed time, and token/context usage when OpenCode exposes it.
69
+ - Live TPS, average TPS, and average TTFT in the OpenCode session prompt zone.
70
+ - Subagent status (running, done, failed, elapsed time, and token/context usage) in the sidebar.
52
71
  - Local capture of OpenCode hook/event data without redaction.
53
- - A local web viewer for sessions, messages, hooks, request context, system/messages transforms, and assistant responses.
72
+ - A local web viewer for reconstructed sessions, user turns, hidden request context, system/messages transforms, and assistant thinking/response sequences.
73
+ - Native OpenCode footer components (project directory and version) remain visible — the plugin does not override `sidebar_footer` or `home_prompt_right` slots.
54
74
 
55
75
  ## Open The Viewer
56
76
 
57
77
  Start the local web viewer and open it in your browser:
58
78
 
59
79
  ```bash
60
- npx opencode-insights open --limit 5000 --port 8765
80
+ opencode-insights open --limit 5000 --port 8765
61
81
  ```
62
82
 
63
83
  Or run the server only:
64
84
 
65
85
  ```bash
66
- npx opencode-insights serve --limit 5000 --port 8765
86
+ opencode-insights serve --limit 5000 --port 8765
67
87
  ```
68
88
 
69
89
  Then open:
@@ -74,59 +94,66 @@ http://127.0.0.1:8765/
74
94
 
75
95
  The viewer shows:
76
96
 
77
- - `MSG` rows for user messages.
78
- - `HOOK` rows for OpenCode plugin hooks.
79
- - `Summary`, `Request`, `Response`, and `Raw` tabs.
80
- - Expandable/collapsible JSON trees with `Expand All` and `Collapse All`.
97
+ - Project/session filters with subagent sessions nested under their parent session.
98
+ - User-message rows only, with each row showing visible assistant steps and hidden context count.
99
+ - A `Summary` view with the agent thinking/response sequence.
100
+ - Collapsed hidden-context previews that expand to plain text system prompt or hidden prompt-like content.
101
+ - A dark/light theme switcher.
81
102
 
82
103
  ## Common Commands
83
104
 
84
105
  List recent raw captures:
85
106
 
86
107
  ```bash
87
- npx opencode-insights recent --limit 20
108
+ opencode-insights recent --limit 20
88
109
  ```
89
110
 
90
111
  List reconstructed sessions:
91
112
 
92
113
  ```bash
93
- npx opencode-insights sessions --limit 5000
114
+ opencode-insights sessions --limit 5000
94
115
  ```
95
116
 
96
117
  Print one reconstructed session:
97
118
 
98
119
  ```bash
99
- npx opencode-insights show ses_xxx --limit 10000
120
+ opencode-insights show ses_xxx --limit 10000
100
121
  ```
101
122
 
102
123
  Export one session to JSON:
103
124
 
104
125
  ```bash
105
- npx opencode-insights export ses_xxx --limit 10000 --output ./session.json
126
+ opencode-insights export ses_xxx --limit 10000 --output ./session.json
106
127
  ```
107
128
 
108
129
  Check DB path, table health, row counts, and SQLite readability:
109
130
 
110
131
  ```bash
111
- npx opencode-insights doctor
132
+ opencode-insights doctor
112
133
  ```
113
134
 
114
135
  Compact the local SQLite DB after heavy testing:
115
136
 
116
137
  ```bash
117
- npx opencode-insights vacuum
138
+ opencode-insights vacuum
118
139
  ```
119
140
 
120
141
  Remove plugin config entries and delete local captured data:
121
142
 
122
143
  ```bash
123
- npx opencode-insights uninstall
144
+ opencode-insights uninstall
124
145
  ```
125
146
 
126
- If the command is not available through `npx`, run the installed binary directly from your OpenCode config directory:
147
+ If the command is not available, confirm `~/.local/bin` is on `PATH`, or run the installed binary directly from OpenCode's package cache:
127
148
 
128
149
  ```bash
129
- ./node_modules/.bin/opencode-insights doctor
150
+ ~/.cache/opencode/packages/node_modules/.bin/opencode-insights doctor
151
+ ```
152
+
153
+ You can also run the published package through npm without relying on the OpenCode cache:
154
+
155
+ ```bash
156
+ npx -y -p @rejacky/opencode-insights opencode-insights doctor
130
157
  ```
131
158
 
132
159
  ## Storage
@@ -143,7 +170,9 @@ If SQLite is unavailable in the plugin runtime, the fallback path is:
143
170
  ~/.opencode-insights/insights.sqlite.jsonl
144
171
  ```
145
172
 
146
- You can override storage in `opencode.json` or `opencode.jsonc`:
173
+ The database keeps one day of captures by default and auto-cleans older rows on startup and after new captures. Set `retentionDays` to another number of days, or `0` to disable auto-cleaning.
174
+
175
+ You can override storage and retention in `opencode.json` or `opencode.jsonc`:
147
176
 
148
177
  ```json
149
178
  {
@@ -151,7 +180,8 @@ You can override storage in `opencode.json` or `opencode.jsonc`:
151
180
  [
152
181
  "@rejacky/opencode-insights",
153
182
  {
154
- "dbPath": "/absolute/path/to/insights.sqlite"
183
+ "dbPath": "/absolute/path/to/insights.sqlite",
184
+ "retentionDays": 1
155
185
  }
156
186
  ]
157
187
  ]
@@ -164,6 +194,10 @@ This plugin intentionally does not redact anything. It stores data locally exact
164
194
 
165
195
  Captured data can include prompts, system messages, provider metadata, API keys exposed inside hook payloads, tool arguments, headers, reasoning text, and response events. Use it only on machines where local full-fidelity capture is acceptable.
166
196
 
197
+ ## Request Context Capture
198
+
199
+ Request-context capture is enabled by default. The plugin records OpenCode's `chat.headers`, `experimental.chat.messages.transform`, and `experimental.chat.system.transform` hooks so the viewer can show provider headers, transformed conversation messages, and system prompt content when OpenCode exposes them. The `experimental.chat.*` names are OpenCode hook names; no `experimental` plugin option is required.
200
+
167
201
  ## Captured Hooks
168
202
 
169
203
  The viewer labels OpenCode hook records as `HOOK` because they are not raw HTTP requests.
@@ -17,6 +17,7 @@ type CaptureStore = {
17
17
  type InsightsOptions = {
18
18
  dataDir?: unknown;
19
19
  dbPath?: unknown;
20
+ retentionDays?: unknown;
20
21
  };
21
22
  declare function defaultDataDir(): string;
22
23
  declare function resolveCapturePath(options?: InsightsOptions): string;
@@ -29,20 +30,31 @@ declare function normalizeEventCapture(event: unknown, timestamp?: number): Capt
29
30
  declare function normalizeToolCapture(kind: "tool.execute.before" | "tool.execute.after", input: unknown, output: unknown, timestamp?: number): CaptureRecord;
30
31
  declare class JsonlCaptureStore implements CaptureStore {
31
32
  private readonly path;
32
- constructor(path: string);
33
+ private readonly retentionMs;
34
+ constructor(path: string, retentionMs?: number | undefined);
33
35
  initialize(): Promise<void>;
34
36
  append(record: CaptureRecord): Promise<void>;
37
+ private pruneExpired;
35
38
  }
39
+ interface SqliteDb {
40
+ all(sql: string, ...params: unknown[]): Record<string, unknown>[];
41
+ run(sql: string, ...params: unknown[]): void;
42
+ sync(): void;
43
+ close(): void;
44
+ }
45
+ declare function openDatabase(path: string): Promise<SqliteDb | undefined>;
36
46
  declare class SqliteCaptureStore implements CaptureStore {
37
47
  private readonly path;
48
+ private readonly retentionMs;
38
49
  private db;
39
- private unavailable;
40
- constructor(path: string);
50
+ private fallbackStore;
51
+ constructor(path: string, retentionMs?: number | undefined);
41
52
  initialize(): Promise<void>;
42
53
  append(record: CaptureRecord): Promise<void>;
43
54
  close(): Promise<void>;
44
- private database;
55
+ private pruneExpired;
45
56
  }
46
57
  declare function createCaptureStore(options?: InsightsOptions): CaptureStore;
58
+ declare function resolveRetentionDays(value: unknown): number;
47
59
 
48
- export { type CaptureRecord as C, type InsightsOptions as I, JsonlCaptureStore as J, SqliteCaptureStore as S, type CaptureKind as a, type CaptureStore as b, createCaptureStore as c, defaultDataDir as d, normalizeChatMessageCapture as e, normalizeChatParamsCapture as f, normalizeEventCapture as g, normalizeExperimentalChatMessagesTransformCapture as h, normalizeExperimentalChatSystemTransformCapture as i, normalizeToolCapture as j, normalizeChatHeadersCapture as n, resolveCapturePath as r };
60
+ export { type CaptureRecord as C, type InsightsOptions as I, JsonlCaptureStore as J, SqliteCaptureStore as S, type CaptureKind as a, type CaptureStore as b, type SqliteDb as c, createCaptureStore as d, defaultDataDir as e, normalizeChatMessageCapture as f, normalizeChatParamsCapture as g, normalizeEventCapture as h, normalizeExperimentalChatMessagesTransformCapture as i, normalizeExperimentalChatSystemTransformCapture as j, normalizeToolCapture as k, resolveRetentionDays as l, normalizeChatHeadersCapture as n, openDatabase as o, resolveCapturePath as r };
@@ -1,7 +1,10 @@
1
1
  // src/capture.ts
2
- import { mkdir, appendFile, writeFile } from "fs/promises";
2
+ import { mkdir, appendFile, readFile, writeFile } from "fs/promises";
3
+ import { existsSync } from "fs";
3
4
  import { dirname, join } from "path";
4
5
  import { homedir } from "os";
6
+ var DEFAULT_RETENTION_DAYS = 1;
7
+ var DAY_MS = 24 * 60 * 60 * 1e3;
5
8
  var sequence = 0;
6
9
  function nextID(timestamp) {
7
10
  sequence += 1;
@@ -100,13 +103,14 @@ function normalizeChatHeadersCapture(input, output, timestamp = Date.now()) {
100
103
  });
101
104
  }
102
105
  function normalizeExperimentalChatMessagesTransformCapture(input, output, timestamp = Date.now()) {
106
+ const inputRecord = isRecord(input) ? input : {};
103
107
  const latestUserMessage = latestUserTransformedMessage(output);
104
108
  const info = infoFromTransformedMessage(latestUserMessage);
105
109
  return captureRecord({
106
110
  id: nextID(timestamp),
107
111
  kind: "experimental.chat.messages.transform",
108
112
  timestamp,
109
- sessionID: sessionIDFrom(info),
113
+ sessionID: sessionIDFrom(inputRecord),
110
114
  messageID: messageIDFrom(info) ?? optionalString(info?.id),
111
115
  payload: { input, output }
112
116
  });
@@ -149,42 +153,143 @@ function normalizeToolCapture(kind, input, output, timestamp = Date.now()) {
149
153
  });
150
154
  }
151
155
  var JsonlCaptureStore = class {
152
- constructor(path) {
156
+ constructor(path, retentionMs = retentionMsFromDays(DEFAULT_RETENTION_DAYS)) {
153
157
  this.path = path;
158
+ this.retentionMs = retentionMs;
154
159
  }
155
160
  path;
161
+ retentionMs;
156
162
  async initialize() {
157
163
  await mkdir(dirname(this.path), { recursive: true });
158
164
  await writeFile(this.path, "", { flag: "a" });
165
+ await this.pruneExpired();
159
166
  }
160
167
  async append(record) {
161
168
  await mkdir(dirname(this.path), { recursive: true });
162
169
  await appendFile(this.path, `${JSON.stringify(record)}
163
170
  `, "utf8");
171
+ await this.pruneExpired();
172
+ }
173
+ async pruneExpired(now = Date.now()) {
174
+ const cutoff = retentionCutoff(now, this.retentionMs);
175
+ if (cutoff === void 0 || !existsSync(this.path)) return;
176
+ try {
177
+ const lines = (await readFile(this.path, "utf8")).split(/\r?\n/);
178
+ const kept = lines.filter((line) => {
179
+ const trimmed = line.trim();
180
+ if (!trimmed) return false;
181
+ try {
182
+ const record = JSON.parse(trimmed);
183
+ return typeof record.timestamp !== "number" || record.timestamp >= cutoff;
184
+ } catch {
185
+ return true;
186
+ }
187
+ });
188
+ await writeFile(this.path, kept.length ? `${kept.join("\n")}
189
+ ` : "", "utf8");
190
+ } catch {
191
+ }
164
192
  }
165
193
  };
194
+ async function openDatabase(path) {
195
+ try {
196
+ const mod = await import("bun:sqlite").catch(() => void 0);
197
+ if (mod) {
198
+ const db = new mod.Database(path);
199
+ return {
200
+ all(sql, ...params) {
201
+ return db.query(sql).all(...params);
202
+ },
203
+ run(sql, ...params) {
204
+ db.run(sql, ...params);
205
+ },
206
+ sync() {
207
+ },
208
+ close() {
209
+ db.close();
210
+ }
211
+ };
212
+ }
213
+ } catch {
214
+ }
215
+ try {
216
+ const mod = await import("better-sqlite3").catch(() => void 0);
217
+ const Database = mod?.default;
218
+ if (Database) {
219
+ const db = new Database(path);
220
+ return {
221
+ all(sql, ...params) {
222
+ return db.prepare(sql).all(...params);
223
+ },
224
+ run(sql, ...params) {
225
+ db.prepare(sql).run(...params);
226
+ },
227
+ sync() {
228
+ },
229
+ close() {
230
+ db.close();
231
+ }
232
+ };
233
+ }
234
+ } catch {
235
+ }
236
+ return void 0;
237
+ }
166
238
  var SqliteCaptureStore = class {
167
- constructor(path) {
239
+ constructor(path, retentionMs = retentionMsFromDays(DEFAULT_RETENTION_DAYS)) {
168
240
  this.path = path;
241
+ this.retentionMs = retentionMs;
169
242
  }
170
243
  path;
244
+ retentionMs;
171
245
  db;
172
- unavailable = false;
246
+ fallbackStore;
173
247
  async initialize() {
174
- const db = await this.database();
248
+ await mkdir(dirname(this.path), { recursive: true });
249
+ const db = await openDatabase(this.path);
175
250
  if (!db) {
176
251
  const fallbackPath = this.path.endsWith(".sqlite") ? `${this.path}.jsonl` : this.path;
177
- await new JsonlCaptureStore(fallbackPath).initialize();
252
+ this.fallbackStore = new JsonlCaptureStore(fallbackPath, this.retentionMs);
253
+ await this.fallbackStore.initialize();
254
+ return;
178
255
  }
256
+ this.db = db;
257
+ this.db.run(
258
+ `create table if not exists captures (
259
+ id text primary key,
260
+ kind text not null,
261
+ timestamp integer not null,
262
+ session_id text,
263
+ message_id text,
264
+ provider_id text,
265
+ model_id text,
266
+ payload_json text not null
267
+ )`
268
+ );
269
+ this.db.run(`create index if not exists captures_timestamp_idx on captures(timestamp)`);
270
+ this.db.run(`create index if not exists captures_session_idx on captures(session_id)`);
271
+ this.db.run(`create index if not exists captures_kind_timestamp_idx on captures(kind, timestamp)`);
272
+ this.pruneExpired();
273
+ this.db.sync();
179
274
  }
180
275
  async append(record) {
181
- const db = await this.database();
182
- if (!db) {
183
- const fallbackPath = this.path.endsWith(".sqlite") ? `${this.path}.jsonl` : this.path;
184
- await new JsonlCaptureStore(fallbackPath).append(record);
276
+ if (this.fallbackStore) {
277
+ await this.fallbackStore.append(record);
185
278
  return;
186
279
  }
187
- db.run(
280
+ if (!this.db) {
281
+ await mkdir(dirname(this.path), { recursive: true });
282
+ const db = await openDatabase(this.path);
283
+ if (!db) {
284
+ const fallbackPath = this.path.endsWith(".sqlite") ? `${this.path}.jsonl` : this.path;
285
+ this.fallbackStore = new JsonlCaptureStore(fallbackPath, this.retentionMs);
286
+ await this.fallbackStore.initialize();
287
+ await this.fallbackStore.append(record);
288
+ return;
289
+ }
290
+ this.db = db;
291
+ }
292
+ this.db.run(
188
293
  `insert into captures (
189
294
  id, kind, timestamp, session_id, message_id, provider_id, model_id, payload_json
190
295
  ) values (?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -197,47 +302,34 @@ var SqliteCaptureStore = class {
197
302
  record.modelID ?? null,
198
303
  JSON.stringify(record.payload)
199
304
  );
305
+ this.pruneExpired();
306
+ this.db.sync();
200
307
  }
201
308
  async close() {
202
- this.db?.close?.();
309
+ this.db?.close();
203
310
  this.db = void 0;
311
+ this.fallbackStore = void 0;
204
312
  }
205
- async database() {
206
- if (this.db) return this.db;
207
- if (this.unavailable) return void 0;
208
- try {
209
- await mkdir(dirname(this.path), { recursive: true });
210
- const mod = await import("bun:sqlite").catch(() => void 0);
211
- if (!mod) {
212
- this.unavailable = true;
213
- return void 0;
214
- }
215
- const db = new mod.Database(this.path);
216
- initializeSchema(db);
217
- this.db = db;
218
- return db;
219
- } catch {
220
- this.unavailable = true;
221
- return void 0;
222
- }
313
+ pruneExpired(now = Date.now()) {
314
+ const cutoff = retentionCutoff(now, this.retentionMs);
315
+ if (cutoff === void 0) return;
316
+ this.db?.run("delete from captures where timestamp < ?", cutoff);
223
317
  }
224
318
  };
225
- function initializeSchema(db) {
226
- db.run(`create table if not exists captures (
227
- id text primary key,
228
- kind text not null,
229
- timestamp integer not null,
230
- session_id text,
231
- message_id text,
232
- provider_id text,
233
- model_id text,
234
- payload_json text not null
235
- )`);
236
- db.run(`create index if not exists captures_timestamp_idx on captures(timestamp)`);
237
- db.run(`create index if not exists captures_session_idx on captures(session_id)`);
238
- }
239
319
  function createCaptureStore(options = {}) {
240
- return new SqliteCaptureStore(resolveCapturePath(options));
320
+ return new SqliteCaptureStore(resolveCapturePath(options), retentionMsFromDays(resolveRetentionDays(options.retentionDays)));
321
+ }
322
+ function resolveRetentionDays(value) {
323
+ if (value === void 0 || value === null || value === "") return DEFAULT_RETENTION_DAYS;
324
+ const numeric = typeof value === "number" ? value : typeof value === "string" ? Number.parseFloat(value) : Number.NaN;
325
+ if (!Number.isFinite(numeric) || numeric < 0) return DEFAULT_RETENTION_DAYS;
326
+ return numeric;
327
+ }
328
+ function retentionMsFromDays(days) {
329
+ return days <= 0 ? void 0 : days * DAY_MS;
330
+ }
331
+ function retentionCutoff(now, retentionMs) {
332
+ return retentionMs === void 0 ? void 0 : now - retentionMs;
241
333
  }
242
334
 
243
335
  export {
@@ -251,6 +343,8 @@ export {
251
343
  normalizeEventCapture,
252
344
  normalizeToolCapture,
253
345
  JsonlCaptureStore,
346
+ openDatabase,
254
347
  SqliteCaptureStore,
255
- createCaptureStore
348
+ createCaptureStore,
349
+ resolveRetentionDays
256
350
  };
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as CaptureRecord } from './capture-gQauLsdn.js';
2
+ import { C as CaptureRecord } from './capture-B6sM1QQA.js';
3
3
 
4
4
  type HistoryMessage = {
5
5
  id: string;
@@ -22,6 +22,7 @@ type HistoryRequest = {
22
22
  modelID?: string | undefined;
23
23
  summary: string;
24
24
  payload: Record<string, unknown>;
25
+ system?: HistoryRequestHeaders | undefined;
25
26
  headers?: HistoryRequestHeaders | undefined;
26
27
  response?: HistoryResponse | undefined;
27
28
  };
@@ -46,8 +47,12 @@ type HistoryResponse = {
46
47
  };
47
48
  type HistorySession = {
48
49
  id: string;
50
+ parentID?: string | undefined;
49
51
  title?: string | undefined;
50
52
  updatedAt?: number | undefined;
53
+ cwd?: string | undefined;
54
+ root?: string | undefined;
55
+ project?: string | undefined;
51
56
  messages: HistoryMessage[];
52
57
  requests: HistoryRequest[];
53
58
  };
@@ -67,6 +72,7 @@ type CliOptions = {
67
72
  port?: number | undefined;
68
73
  output?: string | undefined;
69
74
  configDir?: string | undefined;
75
+ retentionDays?: number | undefined;
70
76
  dryRun: boolean;
71
77
  keepData: boolean;
72
78
  };