@rejacky/opencode-insights 0.1.9 → 0.1.10

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
@@ -91,12 +91,30 @@ The `uninstall` command removes plugin config entries and local Insights data; i
91
91
 
92
92
  ## What You Get
93
93
 
94
- - Live TPS, average TPS, and average TTFT in the OpenCode session prompt zone.
94
+ - Configurable live metrics in the OpenCode session prompt zone.
95
95
  - Subagent status (running, done, failed, elapsed time, and token/context usage) in the sidebar.
96
96
  - Local capture of OpenCode hook/event data without redaction.
97
97
  - A local web viewer for reconstructed sessions, user turns, hidden request context, system/messages transforms, and assistant thinking/response sequences.
98
98
  - Native OpenCode footer components (project directory and version) remain visible — the plugin does not override `sidebar_footer` or `home_prompt_right` slots.
99
99
 
100
+ ## TUI Metrics Configuration
101
+
102
+ On TUI startup, Insights creates a configuration file beside its database:
103
+
104
+ ```text
105
+ ~/.opencode-insights/config.json
106
+ ```
107
+
108
+ With a custom database path, the configuration file is created in that database's directory. The default keeps the prompt-right display compact:
109
+
110
+ ```json
111
+ {
112
+ "promptRightMetrics": ["tps", "avg", "used", "cache"]
113
+ }
114
+ ```
115
+
116
+ `promptRightMetrics` controls both the fields and their order. Supported values are `tps`, `avg`, `ttft`, `used`, `cache`, `input`, `output`, and `reasoning`. Values that are not recognized are ignored; an empty or invalid configuration uses the default. Restart OpenCode after editing this file.
117
+
100
118
  ## Open The Viewer
101
119
 
102
120
  Start the local web viewer and open it in your browser:
@@ -0,0 +1,132 @@
1
+ type StreamSample = {
2
+ at: number;
3
+ tokens: number;
4
+ };
5
+ type MessageTiming = {
6
+ sessionID: string;
7
+ requestStartAt: number;
8
+ firstResponseAt?: number | undefined;
9
+ firstTokenAt?: number | undefined;
10
+ lastTokenAt?: number | undefined;
11
+ lastToolCallAt?: number | undefined;
12
+ };
13
+ type SessionAverage = {
14
+ totalTokens: number;
15
+ totalDurationMs: number;
16
+ totalTtftMs: number;
17
+ messageCount: number;
18
+ };
19
+ type AssistantResponseUsage = {
20
+ inputTokens?: number | undefined;
21
+ outputTokens?: number | undefined;
22
+ reasoningTokens?: number | undefined;
23
+ cacheReadTokens?: number | undefined;
24
+ cacheWriteTokens?: number | undefined;
25
+ finish?: string | undefined;
26
+ };
27
+ type PromptRightMetric = "tps" | "avg" | "ttft" | "used" | "cache" | "input" | "output" | "reasoning";
28
+ declare const DEFAULT_PROMPT_RIGHT_METRICS: PromptRightMetric[];
29
+ type MetricsState = {
30
+ streamSamplesBySession: Record<string, StreamSample[]>;
31
+ messageTimingByID: Record<string, MessageTiming>;
32
+ sessionAverageByID: Record<string, SessionAverage>;
33
+ latestResponseUsageBySession: Record<string, AssistantResponseUsage>;
34
+ };
35
+ declare function createMetricsState(): MetricsState;
36
+ declare function estimateStreamTokens(delta: string): number;
37
+ declare function recordAssistantMessage(state: MetricsState, input: {
38
+ sessionID: string;
39
+ messageID: string;
40
+ createdAt: number;
41
+ completedAt?: number;
42
+ outputTokens?: number;
43
+ reasoningTokens?: number;
44
+ inputTokens?: number;
45
+ cacheReadTokens?: number;
46
+ cacheWriteTokens?: number;
47
+ finish?: string;
48
+ }): void;
49
+ declare function recordAssistantDelta(state: MetricsState, input: {
50
+ sessionID: string;
51
+ messageID: string;
52
+ delta: string;
53
+ at: number;
54
+ }): void;
55
+ declare function recordToolActivity(state: MetricsState, sessionID: string, messageID: string, at?: number): void;
56
+ declare function renderMetricsText(state: MetricsState, sessionID: string, options?: {
57
+ now?: number;
58
+ idle?: boolean;
59
+ }): string;
60
+ declare function renderResponseMetricsText(state: MetricsState, sessionID: string): string;
61
+ declare function renderPromptRightMetricsText(state: MetricsState, sessionID: string, options?: {
62
+ now?: number;
63
+ idle?: boolean;
64
+ metrics?: PromptRightMetric[];
65
+ }): string;
66
+
67
+ type CaptureKind = "chat.message" | "chat.params" | "chat.headers" | "experimental.chat.messages.transform" | "experimental.chat.system.transform" | "event" | "tool.execute.before" | "tool.execute.after";
68
+ type CaptureRecord = {
69
+ id: string;
70
+ kind: CaptureKind;
71
+ timestamp: number;
72
+ sessionID?: string | undefined;
73
+ messageID?: string | undefined;
74
+ providerID?: string | undefined;
75
+ modelID?: string | undefined;
76
+ payload: Record<string, unknown>;
77
+ };
78
+ type CaptureStore = {
79
+ initialize?(): Promise<void>;
80
+ append(record: CaptureRecord): Promise<void>;
81
+ close?(): Promise<void>;
82
+ };
83
+ type InsightsOptions = {
84
+ dataDir?: unknown;
85
+ dbPath?: unknown;
86
+ retentionDays?: unknown;
87
+ };
88
+ type InsightsConfig = {
89
+ promptRightMetrics: PromptRightMetric[];
90
+ };
91
+ declare function defaultDataDir(): string;
92
+ declare function resolveCapturePath(options?: InsightsOptions): string;
93
+ declare function resolveInsightsConfigPath(options?: InsightsOptions): string;
94
+ declare function readInsightsConfig(options?: InsightsOptions): Promise<InsightsConfig>;
95
+ declare function normalizeChatMessageCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
96
+ declare function normalizeChatParamsCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
97
+ declare function normalizeChatHeadersCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
98
+ declare function normalizeExperimentalChatMessagesTransformCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
99
+ declare function normalizeExperimentalChatSystemTransformCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
100
+ declare function normalizeEventCapture(event: unknown, timestamp?: number): CaptureRecord;
101
+ declare function normalizeToolCapture(kind: "tool.execute.before" | "tool.execute.after", input: unknown, output: unknown, timestamp?: number): CaptureRecord;
102
+ declare class JsonlCaptureStore implements CaptureStore {
103
+ private readonly path;
104
+ private readonly retentionMs;
105
+ constructor(path: string, retentionMs?: number | undefined);
106
+ initialize(): Promise<void>;
107
+ append(record: CaptureRecord): Promise<void>;
108
+ private pruneExpired;
109
+ }
110
+ interface SqliteDb {
111
+ all(sql: string, ...params: unknown[]): Record<string, unknown>[];
112
+ run(sql: string, ...params: unknown[]): void;
113
+ sync(): void;
114
+ close(): void;
115
+ }
116
+ declare function openDatabase(path: string, readonly?: boolean): Promise<SqliteDb | undefined>;
117
+ declare function extractEventType(payload: Record<string, unknown>): string | null;
118
+ declare class SqliteCaptureStore implements CaptureStore {
119
+ private readonly path;
120
+ private readonly retentionMs;
121
+ private db;
122
+ private fallbackStore;
123
+ constructor(path: string, retentionMs?: number | undefined);
124
+ initialize(): Promise<void>;
125
+ append(record: CaptureRecord): Promise<void>;
126
+ close(): Promise<void>;
127
+ private pruneExpired;
128
+ }
129
+ declare function createCaptureStore(options?: InsightsOptions): CaptureStore;
130
+ declare function resolveRetentionDays(value: unknown): number;
131
+
132
+ export { type AssistantResponseUsage as A, renderResponseMetricsText as B, type CaptureRecord as C, DEFAULT_PROMPT_RIGHT_METRICS as D, resolveCapturePath as E, resolveInsightsConfigPath as F, resolveRetentionDays as G, type InsightsConfig as I, JsonlCaptureStore as J, type MessageTiming as M, type PromptRightMetric as P, type SessionAverage as S, type CaptureKind as a, type CaptureStore as b, type InsightsOptions as c, type MetricsState as d, SqliteCaptureStore as e, type SqliteDb as f, type StreamSample as g, createCaptureStore as h, createMetricsState as i, defaultDataDir as j, estimateStreamTokens as k, extractEventType as l, normalizeChatMessageCapture as m, normalizeChatHeadersCapture as n, normalizeChatParamsCapture as o, normalizeEventCapture as p, normalizeExperimentalChatMessagesTransformCapture as q, normalizeExperimentalChatSystemTransformCapture as r, normalizeToolCapture as s, openDatabase as t, readInsightsConfig as u, recordAssistantDelta as v, recordAssistantMessage as w, recordToolActivity as x, renderMetricsText as y, renderPromptRightMetricsText as z };
@@ -1,149 +1,3 @@
1
- // src/metrics.ts
2
- var STREAM_WINDOW_MS = 5e3;
3
- var LIVE_STALE_MS = 1500;
4
- var SINGLE_SAMPLE_MS = 1e3;
5
- function createMetricsState() {
6
- return {
7
- streamSamplesBySession: {},
8
- messageTimingByID: {},
9
- sessionAverageByID: {}
10
- };
11
- }
12
- function estimateStreamTokens(delta) {
13
- return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5));
14
- }
15
- function recordAssistantMessage(state, input) {
16
- if (typeof input.completedAt !== "number") {
17
- const existing = state.messageTimingByID[input.messageID];
18
- state.messageTimingByID[input.messageID] = {
19
- sessionID: input.sessionID,
20
- requestStartAt: input.createdAt,
21
- firstResponseAt: existing?.firstResponseAt,
22
- firstTokenAt: existing?.firstTokenAt,
23
- lastTokenAt: existing?.lastTokenAt,
24
- lastToolCallAt: existing?.lastToolCallAt
25
- };
26
- return;
27
- }
28
- const timing = state.messageTimingByID[input.messageID];
29
- if (timing?.sessionID === input.sessionID && typeof timing.firstResponseAt === "number") {
30
- const totalTokens = (input.outputTokens ?? 0) + (input.reasoningTokens ?? 0);
31
- const endAt = input.finish === "tool-calls" ? timing.lastToolCallAt : input.completedAt;
32
- const durationMs = typeof endAt === "number" ? Math.max(endAt - timing.firstResponseAt, 1) : void 0;
33
- const ttftMs = Math.max(timing.firstResponseAt - timing.requestStartAt, 0);
34
- if (totalTokens > 0 && durationMs) {
35
- const totals = state.sessionAverageByID[input.sessionID] ?? {
36
- totalTokens: 0,
37
- totalDurationMs: 0,
38
- totalTtftMs: 0,
39
- messageCount: 0
40
- };
41
- state.sessionAverageByID[input.sessionID] = {
42
- totalTokens: totals.totalTokens + totalTokens,
43
- totalDurationMs: totals.totalDurationMs + durationMs,
44
- totalTtftMs: totals.totalTtftMs + ttftMs,
45
- messageCount: totals.messageCount + 1
46
- };
47
- }
48
- }
49
- delete state.messageTimingByID[input.messageID];
50
- pruneSamples(state, input.completedAt);
51
- }
52
- function recordAssistantDelta(state, input) {
53
- const sample = {
54
- at: input.at,
55
- tokens: estimateStreamTokens(input.delta)
56
- };
57
- state.streamSamplesBySession[input.sessionID] = [
58
- ...(state.streamSamplesBySession[input.sessionID] ?? []).filter((item) => input.at - item.at <= STREAM_WINDOW_MS),
59
- sample
60
- ];
61
- const timing = state.messageTimingByID[input.messageID];
62
- if (timing) {
63
- state.messageTimingByID[input.messageID] = timing.firstTokenAt ? { ...timing, lastTokenAt: input.at } : {
64
- ...timing,
65
- firstResponseAt: timing.firstResponseAt ?? input.at,
66
- firstTokenAt: input.at,
67
- lastTokenAt: input.at
68
- };
69
- }
70
- }
71
- function recordToolActivity(state, sessionID, messageID, at = Date.now()) {
72
- if (state.streamSamplesBySession[sessionID]?.length) {
73
- delete state.streamSamplesBySession[sessionID];
74
- }
75
- const timing = state.messageTimingByID[messageID];
76
- if (timing) {
77
- state.messageTimingByID[messageID] = {
78
- ...timing,
79
- lastToolCallAt: at,
80
- firstResponseAt: timing.firstResponseAt ?? at
81
- };
82
- }
83
- }
84
- function renderMetricsText(state, sessionID, options = {}) {
85
- const live = liveTps(state, sessionID, options) ?? "-";
86
- const avg = sessionAverage(state, sessionID) ?? "-";
87
- const ttft = sessionTtft(state, sessionID) ?? "-";
88
- return `TPS ${live} | AVG ${avg} | TTFT ${ttft}`;
89
- }
90
- function pruneSamples(state, now = Date.now()) {
91
- for (const [sessionID, samples] of Object.entries(state.streamSamplesBySession)) {
92
- const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS);
93
- if (next.length > 0) state.streamSamplesBySession[sessionID] = next;
94
- else delete state.streamSamplesBySession[sessionID];
95
- }
96
- }
97
- function sessionAverage(state, sessionID) {
98
- const totals = state.sessionAverageByID[sessionID];
99
- if (!totals || totals.totalTokens <= 0 || totals.totalDurationMs <= 0) return void 0;
100
- return formatRate(totals.totalTokens / (totals.totalDurationMs / 1e3), "AVG");
101
- }
102
- function sessionTtft(state, sessionID) {
103
- const totals = state.sessionAverageByID[sessionID];
104
- if (!totals || totals.messageCount <= 0 || totals.totalTtftMs < 0) return void 0;
105
- return formatTtft(totals.totalTtftMs / totals.messageCount / 1e3);
106
- }
107
- function liveTps(state, sessionID, options = {}) {
108
- if (options.idle) return void 0;
109
- const now = options.now ?? Date.now();
110
- const samples = state.streamSamplesBySession[sessionID] ?? [];
111
- const relevant = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS);
112
- if (relevant.length === 0) return void 0;
113
- const lastSample = relevant.at(-1);
114
- if (!lastSample || now - lastSample.at > LIVE_STALE_MS) return void 0;
115
- const total = relevant.reduce((sum, sample) => sum + sample.tokens, 0);
116
- const durationSeconds = activeDurationMs(relevant, now) / 1e3;
117
- if (durationSeconds <= 0) return void 0;
118
- return formatRate(total / durationSeconds, "TPS");
119
- }
120
- function activeDurationMs(samples, tailAt) {
121
- if (samples.length === 0) return 0;
122
- if (samples.length === 1) {
123
- const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS;
124
- return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS);
125
- }
126
- let duration = 0;
127
- for (let index = 1; index < samples.length; index++) {
128
- duration += Math.max(0, samples[index].at - samples[index - 1].at);
129
- }
130
- if (tailAt) {
131
- duration += Math.max(0, tailAt - samples.at(-1).at);
132
- }
133
- return Math.max(duration, SINGLE_SAMPLE_MS);
134
- }
135
- function formatRate(value, label) {
136
- if (!Number.isFinite(value) || value <= 0) return void 0;
137
- const suffix = label === "TPS" ? " TPS" : "";
138
- if (value >= 100) return `${Math.round(value)}${suffix}`;
139
- if (value >= 10) return `${value.toFixed(1)}${suffix}`;
140
- return `${value.toFixed(2)}${suffix}`;
141
- }
142
- function formatTtft(value) {
143
- if (!Number.isFinite(value) || value < 0) return void 0;
144
- return `${value.toFixed(1)}s`;
145
- }
146
-
147
1
  // src/subagents.ts
148
2
  function createSubagentState() {
149
3
  return { children: {}, totalExecuted: 0 };
@@ -446,12 +300,6 @@ function isRecord(value) {
446
300
  }
447
301
 
448
302
  export {
449
- createMetricsState,
450
- estimateStreamTokens,
451
- recordAssistantMessage,
452
- recordAssistantDelta,
453
- recordToolActivity,
454
- renderMetricsText,
455
303
  createSubagentState,
456
304
  applySubagentEvent,
457
305
  renderSubagentStatus,
@@ -1,3 +1,219 @@
1
+ // src/metrics.ts
2
+ var DEFAULT_PROMPT_RIGHT_METRICS = ["tps", "avg", "used", "cache"];
3
+ var STREAM_WINDOW_MS = 5e3;
4
+ var LIVE_STALE_MS = 1500;
5
+ var SINGLE_SAMPLE_MS = 1e3;
6
+ function createMetricsState() {
7
+ return {
8
+ streamSamplesBySession: {},
9
+ messageTimingByID: {},
10
+ sessionAverageByID: {},
11
+ latestResponseUsageBySession: {}
12
+ };
13
+ }
14
+ function estimateStreamTokens(delta) {
15
+ return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5));
16
+ }
17
+ function recordAssistantMessage(state, input) {
18
+ if (typeof input.completedAt !== "number") {
19
+ const existing = state.messageTimingByID[input.messageID];
20
+ state.messageTimingByID[input.messageID] = {
21
+ sessionID: input.sessionID,
22
+ requestStartAt: input.createdAt,
23
+ firstResponseAt: existing?.firstResponseAt,
24
+ firstTokenAt: existing?.firstTokenAt,
25
+ lastTokenAt: existing?.lastTokenAt,
26
+ lastToolCallAt: existing?.lastToolCallAt
27
+ };
28
+ return;
29
+ }
30
+ const usage = compactUsage({
31
+ inputTokens: input.inputTokens,
32
+ outputTokens: input.outputTokens,
33
+ reasoningTokens: input.reasoningTokens,
34
+ cacheReadTokens: input.cacheReadTokens,
35
+ cacheWriteTokens: input.cacheWriteTokens,
36
+ finish: input.finish
37
+ });
38
+ if (hasTokenUsage(usage)) state.latestResponseUsageBySession[input.sessionID] = usage;
39
+ const timing = state.messageTimingByID[input.messageID];
40
+ if (timing?.sessionID === input.sessionID && typeof timing.firstResponseAt === "number") {
41
+ const totalTokens = (input.outputTokens ?? 0) + (input.reasoningTokens ?? 0);
42
+ const endAt = input.finish === "tool-calls" ? timing.lastToolCallAt : input.completedAt;
43
+ const durationMs = typeof endAt === "number" ? Math.max(endAt - timing.firstResponseAt, 1) : void 0;
44
+ const ttftMs = Math.max(timing.firstResponseAt - timing.requestStartAt, 0);
45
+ if (totalTokens > 0 && durationMs) {
46
+ const totals = state.sessionAverageByID[input.sessionID] ?? {
47
+ totalTokens: 0,
48
+ totalDurationMs: 0,
49
+ totalTtftMs: 0,
50
+ messageCount: 0
51
+ };
52
+ state.sessionAverageByID[input.sessionID] = {
53
+ totalTokens: totals.totalTokens + totalTokens,
54
+ totalDurationMs: totals.totalDurationMs + durationMs,
55
+ totalTtftMs: totals.totalTtftMs + ttftMs,
56
+ messageCount: totals.messageCount + 1
57
+ };
58
+ }
59
+ }
60
+ delete state.messageTimingByID[input.messageID];
61
+ pruneSamples(state, input.completedAt);
62
+ }
63
+ function recordAssistantDelta(state, input) {
64
+ const sample = {
65
+ at: input.at,
66
+ tokens: estimateStreamTokens(input.delta)
67
+ };
68
+ state.streamSamplesBySession[input.sessionID] = [
69
+ ...(state.streamSamplesBySession[input.sessionID] ?? []).filter((item) => input.at - item.at <= STREAM_WINDOW_MS),
70
+ sample
71
+ ];
72
+ const timing = state.messageTimingByID[input.messageID];
73
+ if (timing) {
74
+ state.messageTimingByID[input.messageID] = timing.firstTokenAt ? { ...timing, lastTokenAt: input.at } : {
75
+ ...timing,
76
+ firstResponseAt: timing.firstResponseAt ?? input.at,
77
+ firstTokenAt: input.at,
78
+ lastTokenAt: input.at
79
+ };
80
+ }
81
+ }
82
+ function recordToolActivity(state, sessionID, messageID, at = Date.now()) {
83
+ if (state.streamSamplesBySession[sessionID]?.length) {
84
+ delete state.streamSamplesBySession[sessionID];
85
+ }
86
+ const timing = state.messageTimingByID[messageID];
87
+ if (timing) {
88
+ state.messageTimingByID[messageID] = {
89
+ ...timing,
90
+ lastToolCallAt: at,
91
+ firstResponseAt: timing.firstResponseAt ?? at
92
+ };
93
+ }
94
+ }
95
+ function renderMetricsText(state, sessionID, options = {}) {
96
+ const live = liveTps(state, sessionID, options) ?? "-";
97
+ const avg = sessionAverage(state, sessionID) ?? "-";
98
+ const ttft = sessionTtft(state, sessionID) ?? "-";
99
+ return `TPS ${live} | AVG ${avg} | TTFT ${ttft}`;
100
+ }
101
+ function renderResponseMetricsText(state, sessionID) {
102
+ const usage = state.latestResponseUsageBySession[sessionID];
103
+ if (!usage || !hasTokenUsage(usage)) return "";
104
+ const used = sumTokens(usage);
105
+ const cacheRate = cacheReadRate(usage);
106
+ const parts = [
107
+ used === void 0 ? void 0 : `${formatTokenCount(used)} used`,
108
+ cacheRate === void 0 ? void 0 : `${formatPercent(cacheRate)} cache`,
109
+ usage.outputTokens === void 0 ? void 0 : `${formatTokenCount(usage.outputTokens)} out`,
110
+ usage.reasoningTokens === void 0 ? void 0 : `${formatTokenCount(usage.reasoningTokens)} think`
111
+ ].filter((part) => !!part);
112
+ return parts.join(" | ");
113
+ }
114
+ function renderPromptRightMetricsText(state, sessionID, options = {}) {
115
+ const usage = state.latestResponseUsageBySession[sessionID];
116
+ const used = usage ? sumTokens(usage) : void 0;
117
+ const cacheRate = usage ? cacheReadRate(usage) : void 0;
118
+ const values = {
119
+ tps: `TPS ${liveTps(state, sessionID, options) ?? "-"}`,
120
+ avg: `AVG ${sessionAverage(state, sessionID) ?? "-"}`,
121
+ ttft: `TTFT ${sessionTtft(state, sessionID) ?? "-"}`,
122
+ used: `${used === void 0 ? "-" : formatTokenCount(used)} used`,
123
+ cache: `${cacheRate === void 0 ? "-" : formatPercent(cacheRate)} cache`,
124
+ input: `${usage?.inputTokens === void 0 ? "-" : formatTokenCount(usage.inputTokens)} in`,
125
+ output: `${usage?.outputTokens === void 0 ? "-" : formatTokenCount(usage.outputTokens)} out`,
126
+ reasoning: `${usage?.reasoningTokens === void 0 ? "-" : formatTokenCount(usage.reasoningTokens)} think`
127
+ };
128
+ return (options.metrics?.length ? options.metrics : DEFAULT_PROMPT_RIGHT_METRICS).map((metric) => values[metric]).join(" | ");
129
+ }
130
+ function pruneSamples(state, now = Date.now()) {
131
+ for (const [sessionID, samples] of Object.entries(state.streamSamplesBySession)) {
132
+ const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS);
133
+ if (next.length > 0) state.streamSamplesBySession[sessionID] = next;
134
+ else delete state.streamSamplesBySession[sessionID];
135
+ }
136
+ }
137
+ function sessionAverage(state, sessionID) {
138
+ const totals = state.sessionAverageByID[sessionID];
139
+ if (!totals || totals.totalTokens <= 0 || totals.totalDurationMs <= 0) return void 0;
140
+ return formatRate(totals.totalTokens / (totals.totalDurationMs / 1e3), "AVG");
141
+ }
142
+ function sessionTtft(state, sessionID) {
143
+ const totals = state.sessionAverageByID[sessionID];
144
+ if (!totals || totals.messageCount <= 0 || totals.totalTtftMs < 0) return void 0;
145
+ return formatTtft(totals.totalTtftMs / totals.messageCount / 1e3);
146
+ }
147
+ function liveTps(state, sessionID, options = {}) {
148
+ if (options.idle) return void 0;
149
+ const now = options.now ?? Date.now();
150
+ const samples = state.streamSamplesBySession[sessionID] ?? [];
151
+ const relevant = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS);
152
+ if (relevant.length === 0) return void 0;
153
+ const lastSample = relevant.at(-1);
154
+ if (!lastSample || now - lastSample.at > LIVE_STALE_MS) return void 0;
155
+ const total = relevant.reduce((sum, sample) => sum + sample.tokens, 0);
156
+ const durationSeconds = activeDurationMs(relevant, now) / 1e3;
157
+ if (durationSeconds <= 0) return void 0;
158
+ return formatRate(total / durationSeconds, "TPS");
159
+ }
160
+ function activeDurationMs(samples, tailAt) {
161
+ if (samples.length === 0) return 0;
162
+ if (samples.length === 1) {
163
+ const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS;
164
+ return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS);
165
+ }
166
+ let duration = 0;
167
+ for (let index = 1; index < samples.length; index++) {
168
+ duration += Math.max(0, samples[index].at - samples[index - 1].at);
169
+ }
170
+ if (tailAt) {
171
+ duration += Math.max(0, tailAt - samples.at(-1).at);
172
+ }
173
+ return Math.max(duration, SINGLE_SAMPLE_MS);
174
+ }
175
+ function formatRate(value, label) {
176
+ if (!Number.isFinite(value) || value <= 0) return void 0;
177
+ const suffix = label === "TPS" ? " TPS" : "";
178
+ if (value >= 100) return `${Math.round(value)}${suffix}`;
179
+ if (value >= 10) return `${value.toFixed(1)}${suffix}`;
180
+ return `${value.toFixed(2)}${suffix}`;
181
+ }
182
+ function formatTtft(value) {
183
+ if (!Number.isFinite(value) || value < 0) return void 0;
184
+ return `${value.toFixed(1)}s`;
185
+ }
186
+ function compactUsage(usage) {
187
+ return Object.fromEntries(Object.entries(usage).filter(([, value]) => value !== void 0));
188
+ }
189
+ function hasTokenUsage(usage) {
190
+ return [usage.inputTokens, usage.outputTokens, usage.reasoningTokens, usage.cacheReadTokens, usage.cacheWriteTokens].some(
191
+ (value) => typeof value === "number"
192
+ );
193
+ }
194
+ function sumTokens(usage) {
195
+ const values = [usage.inputTokens, usage.outputTokens, usage.reasoningTokens, usage.cacheReadTokens, usage.cacheWriteTokens].filter(
196
+ (value) => typeof value === "number"
197
+ );
198
+ return values.length ? values.reduce((sum, value) => sum + value, 0) : void 0;
199
+ }
200
+ function cacheReadRate(usage) {
201
+ if (typeof usage.cacheReadTokens !== "number") return void 0;
202
+ const promptTokens = (usage.inputTokens ?? 0) + usage.cacheReadTokens;
203
+ return promptTokens > 0 ? usage.cacheReadTokens / promptTokens * 100 : void 0;
204
+ }
205
+ function formatTokenCount(value) {
206
+ if (Math.abs(value) >= 1e6) return `${formatAbbreviatedTokenCount(value, 1e6)}m`;
207
+ if (Math.abs(value) >= 1e3) return `${formatAbbreviatedTokenCount(value, 1e3)}k`;
208
+ return String(Math.round(value));
209
+ }
210
+ function formatPercent(value) {
211
+ return `${(Math.round(value * 10) / 10).toFixed(1).replace(/\.0$/u, "")}%`;
212
+ }
213
+ function formatAbbreviatedTokenCount(value, divisor) {
214
+ return (Math.round(value / divisor * 10) / 10).toFixed(1);
215
+ }
216
+
1
217
  // src/capture.ts
2
218
  import { mkdir, appendFile, readFile, writeFile } from "fs/promises";
3
219
  import { existsSync, readFileSync } from "fs";
@@ -62,6 +278,37 @@ function resolveCapturePath(options = {}) {
62
278
  const dataDir = typeof options.dataDir === "string" && options.dataDir.length > 0 ? options.dataDir : defaultDataDir();
63
279
  return join(dataDir, "insights.sqlite");
64
280
  }
281
+ function resolveInsightsConfigPath(options = {}) {
282
+ return join(dirname(resolveCapturePath(options)), "config.json");
283
+ }
284
+ async function readInsightsConfig(options = {}) {
285
+ const path = resolveInsightsConfigPath(options);
286
+ if (!existsSync(path)) {
287
+ try {
288
+ await mkdir(dirname(path), { recursive: true });
289
+ await writeFile(path, `${JSON.stringify(defaultInsightsConfig(), null, 2)}
290
+ `, "utf8");
291
+ } catch {
292
+ return defaultInsightsConfig();
293
+ }
294
+ }
295
+ try {
296
+ return insightsConfigFrom(JSON.parse(await readFile(path, "utf8")));
297
+ } catch {
298
+ return defaultInsightsConfig();
299
+ }
300
+ }
301
+ function defaultInsightsConfig() {
302
+ return { promptRightMetrics: [...DEFAULT_PROMPT_RIGHT_METRICS] };
303
+ }
304
+ function insightsConfigFrom(value) {
305
+ if (!isRecord(value) || !Array.isArray(value.promptRightMetrics)) return defaultInsightsConfig();
306
+ const metrics = value.promptRightMetrics.filter(isPromptRightMetric);
307
+ return metrics.length ? { promptRightMetrics: metrics } : defaultInsightsConfig();
308
+ }
309
+ function isPromptRightMetric(value) {
310
+ return value === "tps" || value === "avg" || value === "ttft" || value === "used" || value === "cache" || value === "input" || value === "output" || value === "reasoning";
311
+ }
65
312
  function normalizeChatMessageCapture(input, output, timestamp = Date.now()) {
66
313
  const inputRecord = isRecord(input) ? input : {};
67
314
  const model = isRecord(inputRecord.model) ? inputRecord.model : void 0;
@@ -384,8 +631,19 @@ function retentionCutoff(now, retentionMs) {
384
631
  }
385
632
 
386
633
  export {
634
+ DEFAULT_PROMPT_RIGHT_METRICS,
635
+ createMetricsState,
636
+ estimateStreamTokens,
637
+ recordAssistantMessage,
638
+ recordAssistantDelta,
639
+ recordToolActivity,
640
+ renderMetricsText,
641
+ renderResponseMetricsText,
642
+ renderPromptRightMetricsText,
387
643
  defaultDataDir,
388
644
  resolveCapturePath,
645
+ resolveInsightsConfigPath,
646
+ readInsightsConfig,
389
647
  normalizeChatMessageCapture,
390
648
  normalizeChatParamsCapture,
391
649
  normalizeChatHeadersCapture,
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as CaptureRecord } from './capture-BMWWI5GR.js';
2
+ import { C as CaptureRecord } from './capture-D7z7hS72.js';
3
3
 
4
4
  type HistoryMessage = {
5
5
  id: string;
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  openDatabase,
4
4
  resolveCapturePath
5
- } from "./chunk-FGTKNB7T.js";
5
+ } from "./chunk-TGCIHODI.js";
6
6
 
7
7
  // src/cli.ts
8
8
  import { execFile as execFile2 } from "child_process";
package/dist/index.d.ts CHANGED
@@ -1,52 +1,6 @@
1
1
  import { Plugin } from '@opencode-ai/plugin';
2
2
  import { TuiPlugin } from '@opencode-ai/plugin/tui';
3
- export { a as CaptureKind, C as CaptureRecord, b as CaptureStore, I as InsightsOptions, J as JsonlCaptureStore, S as SqliteCaptureStore, c as SqliteDb, d as createCaptureStore, e as defaultDataDir, f as extractEventType, n as normalizeChatHeadersCapture, g as normalizeChatMessageCapture, h as normalizeChatParamsCapture, i as normalizeEventCapture, j as normalizeExperimentalChatMessagesTransformCapture, k as normalizeExperimentalChatSystemTransformCapture, l as normalizeToolCapture, o as openDatabase, r as resolveCapturePath, m as resolveRetentionDays } from './capture-BMWWI5GR.js';
4
-
5
- type StreamSample = {
6
- at: number;
7
- tokens: number;
8
- };
9
- type MessageTiming = {
10
- sessionID: string;
11
- requestStartAt: number;
12
- firstResponseAt?: number | undefined;
13
- firstTokenAt?: number | undefined;
14
- lastTokenAt?: number | undefined;
15
- lastToolCallAt?: number | undefined;
16
- };
17
- type SessionAverage = {
18
- totalTokens: number;
19
- totalDurationMs: number;
20
- totalTtftMs: number;
21
- messageCount: number;
22
- };
23
- type MetricsState = {
24
- streamSamplesBySession: Record<string, StreamSample[]>;
25
- messageTimingByID: Record<string, MessageTiming>;
26
- sessionAverageByID: Record<string, SessionAverage>;
27
- };
28
- declare function createMetricsState(): MetricsState;
29
- declare function estimateStreamTokens(delta: string): number;
30
- declare function recordAssistantMessage(state: MetricsState, input: {
31
- sessionID: string;
32
- messageID: string;
33
- createdAt: number;
34
- completedAt?: number;
35
- outputTokens?: number;
36
- reasoningTokens?: number;
37
- finish?: string;
38
- }): void;
39
- declare function recordAssistantDelta(state: MetricsState, input: {
40
- sessionID: string;
41
- messageID: string;
42
- delta: string;
43
- at: number;
44
- }): void;
45
- declare function recordToolActivity(state: MetricsState, sessionID: string, messageID: string, at?: number): void;
46
- declare function renderMetricsText(state: MetricsState, sessionID: string, options?: {
47
- now?: number;
48
- idle?: boolean;
49
- }): string;
3
+ export { A as AssistantResponseUsage, a as CaptureKind, C as CaptureRecord, b as CaptureStore, D as DEFAULT_PROMPT_RIGHT_METRICS, I as InsightsConfig, c as InsightsOptions, J as JsonlCaptureStore, M as MessageTiming, d as MetricsState, P as PromptRightMetric, S as SessionAverage, e as SqliteCaptureStore, f as SqliteDb, g as StreamSample, h as createCaptureStore, i as createMetricsState, j as defaultDataDir, k as estimateStreamTokens, l as extractEventType, n as normalizeChatHeadersCapture, m as normalizeChatMessageCapture, o as normalizeChatParamsCapture, p as normalizeEventCapture, q as normalizeExperimentalChatMessagesTransformCapture, r as normalizeExperimentalChatSystemTransformCapture, s as normalizeToolCapture, t as openDatabase, u as readInsightsConfig, v as recordAssistantDelta, w as recordAssistantMessage, x as recordToolActivity, y as renderMetricsText, z as renderPromptRightMetricsText, B as renderResponseMetricsText, E as resolveCapturePath, F as resolveInsightsConfigPath, G as resolveRetentionDays } from './capture-D7z7hS72.js';
50
4
 
51
5
  type SubagentStatus = "running" | "done" | "error";
52
6
  type SubagentInfo = {
@@ -112,4 +66,4 @@ declare const _default: {
112
66
  server: Plugin;
113
67
  };
114
68
 
115
- export { type MessageTiming, type MetricsState, OpenCodeInsights, type SessionAverage, type StreamSample, type SubagentInfo, type SubagentSidebarModel, type SubagentSidebarRow, type SubagentState, type SubagentStatus, applySubagentEvent, createMetricsState, createSubagentState, _default as default, estimateStreamTokens, getSubagentItems, getSubagentSidebarModel, getSubagentSidebarRowAtLine, id, pruneStaleSubagents, recordAssistantDelta, recordAssistantMessage, recordToolActivity, renderMetricsText, renderSubagentFooter, renderSubagentSidebar, renderSubagentStatus, server, rootTui as tui };
69
+ export { OpenCodeInsights, type SubagentInfo, type SubagentSidebarModel, type SubagentSidebarRow, type SubagentState, type SubagentStatus, applySubagentEvent, createSubagentState, _default as default, getSubagentItems, getSubagentSidebarModel, getSubagentSidebarRowAtLine, id, pruneStaleSubagents, renderSubagentFooter, renderSubagentSidebar, renderSubagentStatus, server, rootTui as tui };
package/dist/index.js CHANGED
@@ -1,25 +1,22 @@
1
1
  import {
2
2
  applySubagentEvent,
3
- createMetricsState,
4
3
  createSubagentState,
5
- estimateStreamTokens,
6
4
  getSubagentItems,
7
5
  getSubagentSidebarModel,
8
6
  getSubagentSidebarRowAtLine,
9
7
  pruneStaleSubagents,
10
- recordAssistantDelta,
11
- recordAssistantMessage,
12
- recordToolActivity,
13
- renderMetricsText,
14
8
  renderSubagentFooter,
15
9
  renderSubagentSidebar,
16
10
  renderSubagentStatus
17
- } from "./chunk-7M32TU5P.js";
11
+ } from "./chunk-O5FFLPEQ.js";
18
12
  import {
13
+ DEFAULT_PROMPT_RIGHT_METRICS,
19
14
  JsonlCaptureStore,
20
15
  SqliteCaptureStore,
21
16
  createCaptureStore,
17
+ createMetricsState,
22
18
  defaultDataDir,
19
+ estimateStreamTokens,
23
20
  extractEventType,
24
21
  normalizeChatHeadersCapture,
25
22
  normalizeChatMessageCapture,
@@ -29,9 +26,17 @@ import {
29
26
  normalizeExperimentalChatSystemTransformCapture,
30
27
  normalizeToolCapture,
31
28
  openDatabase,
29
+ readInsightsConfig,
30
+ recordAssistantDelta,
31
+ recordAssistantMessage,
32
+ recordToolActivity,
33
+ renderMetricsText,
34
+ renderPromptRightMetricsText,
35
+ renderResponseMetricsText,
32
36
  resolveCapturePath,
37
+ resolveInsightsConfigPath,
33
38
  resolveRetentionDays
34
- } from "./chunk-FGTKNB7T.js";
39
+ } from "./chunk-TGCIHODI.js";
35
40
 
36
41
  // src/cli-shim.ts
37
42
  import { existsSync } from "fs";
@@ -133,6 +138,7 @@ var rootTui = async (...args) => {
133
138
  var id = "opencode-insights";
134
139
  var src_default = { id, server };
135
140
  export {
141
+ DEFAULT_PROMPT_RIGHT_METRICS,
136
142
  JsonlCaptureStore,
137
143
  OpenCodeInsights,
138
144
  SqliteCaptureStore,
@@ -157,14 +163,18 @@ export {
157
163
  normalizeToolCapture,
158
164
  openDatabase,
159
165
  pruneStaleSubagents,
166
+ readInsightsConfig,
160
167
  recordAssistantDelta,
161
168
  recordAssistantMessage,
162
169
  recordToolActivity,
163
170
  renderMetricsText,
171
+ renderPromptRightMetricsText,
172
+ renderResponseMetricsText,
164
173
  renderSubagentFooter,
165
174
  renderSubagentSidebar,
166
175
  renderSubagentStatus,
167
176
  resolveCapturePath,
177
+ resolveInsightsConfigPath,
168
178
  resolveRetentionDays,
169
179
  server,
170
180
  rootTui as tui
package/dist/tui.js CHANGED
@@ -1,14 +1,17 @@
1
1
  import {
2
2
  applySubagentEvent,
3
- createMetricsState,
4
3
  createSubagentState,
5
4
  getSubagentSidebarModel,
6
- getSubagentSidebarRowAtLine,
5
+ getSubagentSidebarRowAtLine
6
+ } from "./chunk-O5FFLPEQ.js";
7
+ import {
8
+ createMetricsState,
9
+ readInsightsConfig,
7
10
  recordAssistantDelta,
8
11
  recordAssistantMessage,
9
12
  recordToolActivity,
10
- renderMetricsText
11
- } from "./chunk-7M32TU5P.js";
13
+ renderPromptRightMetricsText
14
+ } from "./chunk-TGCIHODI.js";
12
15
 
13
16
  // src/tui.tsx
14
17
  import { createTextAttributes, StyledText } from "@opentui/core";
@@ -17,18 +20,22 @@ import { jsx } from "@opentui/solid/jsx-runtime";
17
20
  function isSessionID(value) {
18
21
  return typeof value === "string" && value.startsWith("ses");
19
22
  }
20
- function PromptRight(props) {
23
+ function PromptRightMetrics(props) {
21
24
  let text;
22
25
  const sync = () => {
23
26
  if (!text) return;
24
27
  const content = props.text();
25
28
  text.content = content;
26
29
  text.visible = content.length > 0;
27
- text.height = content.length > 0 ? "auto" : 0;
30
+ text.height = content.length > 0 ? 1 : 0;
28
31
  props.api.renderer.requestRender();
29
32
  };
30
33
  const unsubscribe = props.subscribe(sync);
31
- onCleanup(unsubscribe);
34
+ const timer = setInterval(sync, 1e3);
35
+ onCleanup(() => {
36
+ unsubscribe();
37
+ clearInterval(timer);
38
+ });
32
39
  return /* @__PURE__ */ jsx(
33
40
  "text",
34
41
  {
@@ -37,6 +44,10 @@ function PromptRight(props) {
37
44
  sync();
38
45
  },
39
46
  fg: props.api.theme.current.textMuted,
47
+ height: 1,
48
+ wrapMode: "none",
49
+ truncate: true,
50
+ overflow: "hidden",
40
51
  children: props.text()
41
52
  }
42
53
  );
@@ -135,7 +146,8 @@ function textChunk(text, fg, attributes, bg) {
135
146
  ...bg === void 0 ? {} : { bg }
136
147
  };
137
148
  }
138
- var tui = async (api) => {
149
+ var tui = async (api, options) => {
150
+ const config = await readInsightsConfig(options ?? {});
139
151
  const metrics = createMetricsState();
140
152
  const subagents = createSubagentState();
141
153
  const listeners = /* @__PURE__ */ new Set();
@@ -164,8 +176,11 @@ var tui = async (api) => {
164
176
  sessionID: info.sessionID ?? evt.properties.sessionID,
165
177
  messageID: info.id,
166
178
  createdAt: info.time.created,
179
+ inputTokens: info.tokens.input,
167
180
  outputTokens: info.tokens.output,
168
- reasoningTokens: info.tokens.reasoning
181
+ reasoningTokens: info.tokens.reasoning,
182
+ cacheReadTokens: info.tokens.cache?.read,
183
+ cacheWriteTokens: info.tokens.cache?.write
169
184
  };
170
185
  if (typeof info.time.completed === "number") messageInput.completedAt = info.time.completed;
171
186
  if (typeof info.finish === "string") messageInput.finish = info.finish;
@@ -198,7 +213,7 @@ var tui = async (api) => {
198
213
  const offSlots = api.slots.register({
199
214
  slots: {
200
215
  session_prompt_right: (_ctx, props) => /* @__PURE__ */ jsx(
201
- PromptRight,
216
+ PromptRightMetrics,
202
217
  {
203
218
  api,
204
219
  sessionID: props.session_id,
@@ -206,7 +221,10 @@ var tui = async (api) => {
206
221
  text: () => {
207
222
  if (!isSessionID(props.session_id)) return "";
208
223
  const status = api.state.session.status(props.session_id);
209
- return renderMetricsText(metrics, props.session_id, { idle: status?.type === "idle" });
224
+ return renderPromptRightMetricsText(metrics, props.session_id, {
225
+ idle: status?.type === "idle",
226
+ metrics: config.promptRightMetrics
227
+ });
210
228
  }
211
229
  }
212
230
  ),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@rejacky/opencode-insights",
4
- "version": "0.1.9",
4
+ "version": "0.1.10",
5
5
  "description": "OpenCode plugin for local request capture, TPS metrics, and subagent status visibility.",
6
6
  "type": "module",
7
7
  "author": "opencode-insights contributors",
@@ -1,61 +0,0 @@
1
- type CaptureKind = "chat.message" | "chat.params" | "chat.headers" | "experimental.chat.messages.transform" | "experimental.chat.system.transform" | "event" | "tool.execute.before" | "tool.execute.after";
2
- type CaptureRecord = {
3
- id: string;
4
- kind: CaptureKind;
5
- timestamp: number;
6
- sessionID?: string | undefined;
7
- messageID?: string | undefined;
8
- providerID?: string | undefined;
9
- modelID?: string | undefined;
10
- payload: Record<string, unknown>;
11
- };
12
- type CaptureStore = {
13
- initialize?(): Promise<void>;
14
- append(record: CaptureRecord): Promise<void>;
15
- close?(): Promise<void>;
16
- };
17
- type InsightsOptions = {
18
- dataDir?: unknown;
19
- dbPath?: unknown;
20
- retentionDays?: unknown;
21
- };
22
- declare function defaultDataDir(): string;
23
- declare function resolveCapturePath(options?: InsightsOptions): string;
24
- declare function normalizeChatMessageCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
25
- declare function normalizeChatParamsCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
26
- declare function normalizeChatHeadersCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
27
- declare function normalizeExperimentalChatMessagesTransformCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
28
- declare function normalizeExperimentalChatSystemTransformCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
29
- declare function normalizeEventCapture(event: unknown, timestamp?: number): CaptureRecord;
30
- declare function normalizeToolCapture(kind: "tool.execute.before" | "tool.execute.after", input: unknown, output: unknown, timestamp?: number): CaptureRecord;
31
- declare class JsonlCaptureStore implements CaptureStore {
32
- private readonly path;
33
- private readonly retentionMs;
34
- constructor(path: string, retentionMs?: number | undefined);
35
- initialize(): Promise<void>;
36
- append(record: CaptureRecord): Promise<void>;
37
- private pruneExpired;
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, readonly?: boolean): Promise<SqliteDb | undefined>;
46
- declare function extractEventType(payload: Record<string, unknown>): string | null;
47
- declare class SqliteCaptureStore implements CaptureStore {
48
- private readonly path;
49
- private readonly retentionMs;
50
- private db;
51
- private fallbackStore;
52
- constructor(path: string, retentionMs?: number | undefined);
53
- initialize(): Promise<void>;
54
- append(record: CaptureRecord): Promise<void>;
55
- close(): Promise<void>;
56
- private pruneExpired;
57
- }
58
- declare function createCaptureStore(options?: InsightsOptions): CaptureStore;
59
- declare function resolveRetentionDays(value: unknown): number;
60
-
61
- 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, extractEventType as f, normalizeChatMessageCapture as g, normalizeChatParamsCapture as h, normalizeEventCapture as i, normalizeExperimentalChatMessagesTransformCapture as j, normalizeExperimentalChatSystemTransformCapture as k, normalizeToolCapture as l, resolveRetentionDays as m, normalizeChatHeadersCapture as n, openDatabase as o, resolveCapturePath as r };