@rejacky/opencode-insights 0.1.4 → 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,45 +12,64 @@ 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
69
  - Live TPS, average TPS, and average TTFT in the OpenCode session prompt zone.
51
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.
54
73
  - Native OpenCode footer components (project directory and version) remain visible — the plugin does not override `sidebar_footer` or `home_prompt_right` slots.
55
74
 
56
75
  ## Open The Viewer
@@ -58,13 +77,13 @@ npm rm @rejacky/opencode-insights
58
77
  Start the local web viewer and open it in your browser:
59
78
 
60
79
  ```bash
61
- npx opencode-insights open --limit 5000 --port 8765
80
+ opencode-insights open --limit 5000 --port 8765
62
81
  ```
63
82
 
64
83
  Or run the server only:
65
84
 
66
85
  ```bash
67
- npx opencode-insights serve --limit 5000 --port 8765
86
+ opencode-insights serve --limit 5000 --port 8765
68
87
  ```
69
88
 
70
89
  Then open:
@@ -75,59 +94,66 @@ http://127.0.0.1:8765/
75
94
 
76
95
  The viewer shows:
77
96
 
78
- - `MSG` rows for user messages.
79
- - `HOOK` rows for OpenCode plugin hooks.
80
- - `Summary`, `Request`, `Response`, and `Raw` tabs.
81
- - 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.
82
102
 
83
103
  ## Common Commands
84
104
 
85
105
  List recent raw captures:
86
106
 
87
107
  ```bash
88
- npx opencode-insights recent --limit 20
108
+ opencode-insights recent --limit 20
89
109
  ```
90
110
 
91
111
  List reconstructed sessions:
92
112
 
93
113
  ```bash
94
- npx opencode-insights sessions --limit 5000
114
+ opencode-insights sessions --limit 5000
95
115
  ```
96
116
 
97
117
  Print one reconstructed session:
98
118
 
99
119
  ```bash
100
- npx opencode-insights show ses_xxx --limit 10000
120
+ opencode-insights show ses_xxx --limit 10000
101
121
  ```
102
122
 
103
123
  Export one session to JSON:
104
124
 
105
125
  ```bash
106
- npx opencode-insights export ses_xxx --limit 10000 --output ./session.json
126
+ opencode-insights export ses_xxx --limit 10000 --output ./session.json
107
127
  ```
108
128
 
109
129
  Check DB path, table health, row counts, and SQLite readability:
110
130
 
111
131
  ```bash
112
- npx opencode-insights doctor
132
+ opencode-insights doctor
113
133
  ```
114
134
 
115
135
  Compact the local SQLite DB after heavy testing:
116
136
 
117
137
  ```bash
118
- npx opencode-insights vacuum
138
+ opencode-insights vacuum
119
139
  ```
120
140
 
121
141
  Remove plugin config entries and delete local captured data:
122
142
 
123
143
  ```bash
124
- npx opencode-insights uninstall
144
+ opencode-insights uninstall
125
145
  ```
126
146
 
127
- 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:
128
148
 
129
149
  ```bash
130
- ./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
131
157
  ```
132
158
 
133
159
  ## Storage
@@ -144,7 +170,9 @@ If SQLite is unavailable in the plugin runtime, the fallback path is:
144
170
  ~/.opencode-insights/insights.sqlite.jsonl
145
171
  ```
146
172
 
147
- 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`:
148
176
 
149
177
  ```json
150
178
  {
@@ -152,7 +180,8 @@ You can override storage in `opencode.json` or `opencode.jsonc`:
152
180
  [
153
181
  "@rejacky/opencode-insights",
154
182
  {
155
- "dbPath": "/absolute/path/to/insights.sqlite"
183
+ "dbPath": "/absolute/path/to/insights.sqlite",
184
+ "retentionDays": 1
156
185
  }
157
186
  ]
158
187
  ]
@@ -165,24 +194,9 @@ This plugin intentionally does not redact anything. It stores data locally exact
165
194
 
166
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.
167
196
 
168
- ## Experimental Features
169
-
170
- The request-interception hooks (`chat.headers`, `experimental.chat.messages.transform`, `experimental.chat.system.transform`) are disabled by default. They capture additional request context (HTTP headers, transformed messages, system prompts) but are not yet mature.
197
+ ## Request Context Capture
171
198
 
172
- To enable them, pass `experimental: true` in the plugin options:
173
-
174
- ```json
175
- {
176
- "plugin": [
177
- [
178
- "@rejacky/opencode-insights",
179
- {
180
- "experimental": true
181
- }
182
- ]
183
- ]
184
- }
185
- ```
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.
186
200
 
187
201
  ## Captured Hooks
188
202
 
@@ -17,9 +17,7 @@ type CaptureStore = {
17
17
  type InsightsOptions = {
18
18
  dataDir?: unknown;
19
19
  dbPath?: unknown;
20
- /** Enable experimental request capture (chat.headers, messages/system transforms).
21
- * Disabled by default — these hooks intercept request data and are not yet mature. */
22
- experimental?: boolean;
20
+ retentionDays?: unknown;
23
21
  };
24
22
  declare function defaultDataDir(): string;
25
23
  declare function resolveCapturePath(options?: InsightsOptions): string;
@@ -32,20 +30,31 @@ declare function normalizeEventCapture(event: unknown, timestamp?: number): Capt
32
30
  declare function normalizeToolCapture(kind: "tool.execute.before" | "tool.execute.after", input: unknown, output: unknown, timestamp?: number): CaptureRecord;
33
31
  declare class JsonlCaptureStore implements CaptureStore {
34
32
  private readonly path;
35
- constructor(path: string);
33
+ private readonly retentionMs;
34
+ constructor(path: string, retentionMs?: number | undefined);
36
35
  initialize(): Promise<void>;
37
36
  append(record: CaptureRecord): Promise<void>;
37
+ private pruneExpired;
38
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>;
39
46
  declare class SqliteCaptureStore implements CaptureStore {
40
47
  private readonly path;
48
+ private readonly retentionMs;
41
49
  private db;
42
- private unavailable;
43
- constructor(path: string);
50
+ private fallbackStore;
51
+ constructor(path: string, retentionMs?: number | undefined);
44
52
  initialize(): Promise<void>;
45
53
  append(record: CaptureRecord): Promise<void>;
46
54
  close(): Promise<void>;
47
- private database;
55
+ private pruneExpired;
48
56
  }
49
57
  declare function createCaptureStore(options?: InsightsOptions): CaptureStore;
58
+ declare function resolveRetentionDays(value: unknown): number;
50
59
 
51
- 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-DkASdFpu.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
  };