@rejacky/opencode-insights 0.1.9 → 0.1.11
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 +22 -1
- package/dist/capture-BIiGg2nW.d.ts +148 -0
- package/dist/{chunk-FGTKNB7T.js → chunk-3YLLHABZ.js} +307 -0
- package/dist/{chunk-7M32TU5P.js → chunk-O5FFLPEQ.js} +0 -152
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +28 -12
- package/dist/index.d.ts +2 -48
- package/dist/index.js +22 -8
- package/dist/tui.js +186 -48
- package/package.json +2 -1
- package/dist/capture-BMWWI5GR.d.ts +0 -61
package/README.md
CHANGED
|
@@ -91,12 +91,33 @@ The `uninstall` command removes plugin config entries and local Insights data; i
|
|
|
91
91
|
|
|
92
92
|
## What You Get
|
|
93
93
|
|
|
94
|
-
-
|
|
94
|
+
- Configurable live metrics in the OpenCode session prompt zone.
|
|
95
|
+
- A collapsible session-wide `Token Usage` sidebar showing total tokens, response count, input/output/reasoning usage, cache read/write usage, and aggregate cache rate. It loads completed responses already present in the session and continues updating live.
|
|
95
96
|
- Subagent status (running, done, failed, elapsed time, and token/context usage) in the sidebar.
|
|
96
97
|
- Local capture of OpenCode hook/event data without redaction.
|
|
97
98
|
- A local web viewer for reconstructed sessions, user turns, hidden request context, system/messages transforms, and assistant thinking/response sequences.
|
|
98
99
|
- Native OpenCode footer components (project directory and version) remain visible — the plugin does not override `sidebar_footer` or `home_prompt_right` slots.
|
|
99
100
|
|
|
101
|
+
The right sidebar contains two independent plugin sections: `Token Usage` and `Subagents`. Click either section header to collapse or expand it. Token usage is aggregated across the full session; prompt-right `used` and `cache` values continue to represent the latest completed assistant response.
|
|
102
|
+
|
|
103
|
+
## TUI Metrics Configuration
|
|
104
|
+
|
|
105
|
+
On TUI startup, Insights creates a configuration file beside its database:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
~/.opencode-insights/config.json
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
With a custom database path, the configuration file is created in that database's directory. The default keeps the prompt-right display compact:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"promptRightMetrics": ["tps", "avg", "used", "cache"]
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`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.
|
|
120
|
+
|
|
100
121
|
## Open The Viewer
|
|
101
122
|
|
|
102
123
|
Start the local web viewer and open it in your browser:
|
|
@@ -0,0 +1,148 @@
|
|
|
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 SessionTokenUsage = {
|
|
28
|
+
inputTokens: number;
|
|
29
|
+
outputTokens: number;
|
|
30
|
+
reasoningTokens: number;
|
|
31
|
+
cacheReadTokens: number;
|
|
32
|
+
cacheWriteTokens: number;
|
|
33
|
+
totalTokens: number;
|
|
34
|
+
responseCount: number;
|
|
35
|
+
};
|
|
36
|
+
type PromptRightMetric = "tps" | "avg" | "ttft" | "used" | "cache" | "input" | "output" | "reasoning";
|
|
37
|
+
declare const DEFAULT_PROMPT_RIGHT_METRICS: PromptRightMetric[];
|
|
38
|
+
type MetricsState = {
|
|
39
|
+
streamSamplesBySession: Record<string, StreamSample[]>;
|
|
40
|
+
messageTimingByID: Record<string, MessageTiming>;
|
|
41
|
+
sessionAverageByID: Record<string, SessionAverage>;
|
|
42
|
+
latestResponseUsageBySession: Record<string, AssistantResponseUsage>;
|
|
43
|
+
responseUsageByMessageID: Record<string, {
|
|
44
|
+
sessionID: string;
|
|
45
|
+
usage: AssistantResponseUsage;
|
|
46
|
+
}>;
|
|
47
|
+
sessionTokenUsageByID: Record<string, SessionTokenUsage>;
|
|
48
|
+
};
|
|
49
|
+
declare function createMetricsState(): MetricsState;
|
|
50
|
+
declare function estimateStreamTokens(delta: string): number;
|
|
51
|
+
declare function recordAssistantMessage(state: MetricsState, input: {
|
|
52
|
+
sessionID: string;
|
|
53
|
+
messageID: string;
|
|
54
|
+
createdAt: number;
|
|
55
|
+
completedAt?: number;
|
|
56
|
+
outputTokens?: number;
|
|
57
|
+
reasoningTokens?: number;
|
|
58
|
+
inputTokens?: number;
|
|
59
|
+
cacheReadTokens?: number;
|
|
60
|
+
cacheWriteTokens?: number;
|
|
61
|
+
finish?: string;
|
|
62
|
+
}): void;
|
|
63
|
+
declare function recordAssistantDelta(state: MetricsState, input: {
|
|
64
|
+
sessionID: string;
|
|
65
|
+
messageID: string;
|
|
66
|
+
delta: string;
|
|
67
|
+
at: number;
|
|
68
|
+
}): void;
|
|
69
|
+
declare function recordToolActivity(state: MetricsState, sessionID: string, messageID: string, at?: number): void;
|
|
70
|
+
declare function renderMetricsText(state: MetricsState, sessionID: string, options?: {
|
|
71
|
+
now?: number;
|
|
72
|
+
idle?: boolean;
|
|
73
|
+
}): string;
|
|
74
|
+
declare function renderResponseMetricsText(state: MetricsState, sessionID: string): string;
|
|
75
|
+
declare function renderPromptRightMetricsText(state: MetricsState, sessionID: string, options?: {
|
|
76
|
+
now?: number;
|
|
77
|
+
idle?: boolean;
|
|
78
|
+
metrics?: PromptRightMetric[];
|
|
79
|
+
}): string;
|
|
80
|
+
declare function getSessionTokenUsage(state: MetricsState, sessionID: string): SessionTokenUsage | undefined;
|
|
81
|
+
declare function renderSessionTokenUsage(state: MetricsState, sessionID: string): string;
|
|
82
|
+
|
|
83
|
+
type CaptureKind = "chat.message" | "chat.params" | "chat.headers" | "experimental.chat.messages.transform" | "experimental.chat.system.transform" | "event" | "tool.execute.before" | "tool.execute.after";
|
|
84
|
+
type CaptureRecord = {
|
|
85
|
+
id: string;
|
|
86
|
+
kind: CaptureKind;
|
|
87
|
+
timestamp: number;
|
|
88
|
+
sessionID?: string | undefined;
|
|
89
|
+
messageID?: string | undefined;
|
|
90
|
+
providerID?: string | undefined;
|
|
91
|
+
modelID?: string | undefined;
|
|
92
|
+
payload: Record<string, unknown>;
|
|
93
|
+
};
|
|
94
|
+
type CaptureStore = {
|
|
95
|
+
initialize?(): Promise<void>;
|
|
96
|
+
append(record: CaptureRecord): Promise<void>;
|
|
97
|
+
close?(): Promise<void>;
|
|
98
|
+
};
|
|
99
|
+
type InsightsOptions = {
|
|
100
|
+
dataDir?: unknown;
|
|
101
|
+
dbPath?: unknown;
|
|
102
|
+
retentionDays?: unknown;
|
|
103
|
+
};
|
|
104
|
+
type InsightsConfig = {
|
|
105
|
+
promptRightMetrics: PromptRightMetric[];
|
|
106
|
+
};
|
|
107
|
+
declare function defaultDataDir(): string;
|
|
108
|
+
declare function resolveCapturePath(options?: InsightsOptions): string;
|
|
109
|
+
declare function resolveInsightsConfigPath(options?: InsightsOptions): string;
|
|
110
|
+
declare function readInsightsConfig(options?: InsightsOptions): Promise<InsightsConfig>;
|
|
111
|
+
declare function normalizeChatMessageCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
|
|
112
|
+
declare function normalizeChatParamsCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
|
|
113
|
+
declare function normalizeChatHeadersCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
|
|
114
|
+
declare function normalizeExperimentalChatMessagesTransformCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
|
|
115
|
+
declare function normalizeExperimentalChatSystemTransformCapture(input: unknown, output: unknown, timestamp?: number): CaptureRecord;
|
|
116
|
+
declare function normalizeEventCapture(event: unknown, timestamp?: number): CaptureRecord;
|
|
117
|
+
declare function normalizeToolCapture(kind: "tool.execute.before" | "tool.execute.after", input: unknown, output: unknown, timestamp?: number): CaptureRecord;
|
|
118
|
+
declare class JsonlCaptureStore implements CaptureStore {
|
|
119
|
+
private readonly path;
|
|
120
|
+
private readonly retentionMs;
|
|
121
|
+
constructor(path: string, retentionMs?: number | undefined);
|
|
122
|
+
initialize(): Promise<void>;
|
|
123
|
+
append(record: CaptureRecord): Promise<void>;
|
|
124
|
+
private pruneExpired;
|
|
125
|
+
}
|
|
126
|
+
interface SqliteDb {
|
|
127
|
+
all(sql: string, ...params: unknown[]): Record<string, unknown>[];
|
|
128
|
+
run(sql: string, ...params: unknown[]): void;
|
|
129
|
+
sync(): void;
|
|
130
|
+
close(): void;
|
|
131
|
+
}
|
|
132
|
+
declare function openDatabase(path: string, readonly?: boolean): Promise<SqliteDb | undefined>;
|
|
133
|
+
declare function extractEventType(payload: Record<string, unknown>): string | null;
|
|
134
|
+
declare class SqliteCaptureStore implements CaptureStore {
|
|
135
|
+
private readonly path;
|
|
136
|
+
private readonly retentionMs;
|
|
137
|
+
private db;
|
|
138
|
+
private fallbackStore;
|
|
139
|
+
constructor(path: string, retentionMs?: number | undefined);
|
|
140
|
+
initialize(): Promise<void>;
|
|
141
|
+
append(record: CaptureRecord): Promise<void>;
|
|
142
|
+
close(): Promise<void>;
|
|
143
|
+
private pruneExpired;
|
|
144
|
+
}
|
|
145
|
+
declare function createCaptureStore(options?: InsightsOptions): CaptureStore;
|
|
146
|
+
declare function resolveRetentionDays(value: unknown): number;
|
|
147
|
+
|
|
148
|
+
export { type AssistantResponseUsage as A, renderMetricsText as B, type CaptureRecord as C, DEFAULT_PROMPT_RIGHT_METRICS as D, renderPromptRightMetricsText as E, renderResponseMetricsText as F, renderSessionTokenUsage as G, resolveCapturePath as H, type InsightsConfig as I, JsonlCaptureStore as J, resolveInsightsConfigPath as K, resolveRetentionDays as L, 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, type SessionTokenUsage as e, SqliteCaptureStore as f, type SqliteDb as g, type StreamSample as h, createCaptureStore as i, createMetricsState as j, defaultDataDir as k, estimateStreamTokens as l, extractEventType as m, getSessionTokenUsage as n, normalizeChatHeadersCapture as o, normalizeChatMessageCapture as p, normalizeChatParamsCapture as q, normalizeEventCapture as r, normalizeExperimentalChatMessagesTransformCapture as s, normalizeExperimentalChatSystemTransformCapture as t, normalizeToolCapture as u, openDatabase as v, readInsightsConfig as w, recordAssistantDelta as x, recordAssistantMessage as y, recordToolActivity as z };
|
|
@@ -1,3 +1,266 @@
|
|
|
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
|
+
responseUsageByMessageID: {},
|
|
13
|
+
sessionTokenUsageByID: {}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function estimateStreamTokens(delta) {
|
|
17
|
+
return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5));
|
|
18
|
+
}
|
|
19
|
+
function recordAssistantMessage(state, input) {
|
|
20
|
+
if (typeof input.completedAt !== "number") {
|
|
21
|
+
const existing = state.messageTimingByID[input.messageID];
|
|
22
|
+
state.messageTimingByID[input.messageID] = {
|
|
23
|
+
sessionID: input.sessionID,
|
|
24
|
+
requestStartAt: input.createdAt,
|
|
25
|
+
firstResponseAt: existing?.firstResponseAt,
|
|
26
|
+
firstTokenAt: existing?.firstTokenAt,
|
|
27
|
+
lastTokenAt: existing?.lastTokenAt,
|
|
28
|
+
lastToolCallAt: existing?.lastToolCallAt
|
|
29
|
+
};
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const usage = compactUsage({
|
|
33
|
+
inputTokens: input.inputTokens,
|
|
34
|
+
outputTokens: input.outputTokens,
|
|
35
|
+
reasoningTokens: input.reasoningTokens,
|
|
36
|
+
cacheReadTokens: input.cacheReadTokens,
|
|
37
|
+
cacheWriteTokens: input.cacheWriteTokens,
|
|
38
|
+
finish: input.finish
|
|
39
|
+
});
|
|
40
|
+
if (hasTokenUsage(usage)) {
|
|
41
|
+
state.latestResponseUsageBySession[input.sessionID] = usage;
|
|
42
|
+
state.responseUsageByMessageID[input.messageID] = { sessionID: input.sessionID, usage };
|
|
43
|
+
rebuildSessionTokenUsage(state, input.sessionID);
|
|
44
|
+
}
|
|
45
|
+
const timing = state.messageTimingByID[input.messageID];
|
|
46
|
+
if (timing?.sessionID === input.sessionID && typeof timing.firstResponseAt === "number") {
|
|
47
|
+
const totalTokens = (input.outputTokens ?? 0) + (input.reasoningTokens ?? 0);
|
|
48
|
+
const endAt = input.finish === "tool-calls" ? timing.lastToolCallAt : input.completedAt;
|
|
49
|
+
const durationMs = typeof endAt === "number" ? Math.max(endAt - timing.firstResponseAt, 1) : void 0;
|
|
50
|
+
const ttftMs = Math.max(timing.firstResponseAt - timing.requestStartAt, 0);
|
|
51
|
+
if (totalTokens > 0 && durationMs) {
|
|
52
|
+
const totals = state.sessionAverageByID[input.sessionID] ?? {
|
|
53
|
+
totalTokens: 0,
|
|
54
|
+
totalDurationMs: 0,
|
|
55
|
+
totalTtftMs: 0,
|
|
56
|
+
messageCount: 0
|
|
57
|
+
};
|
|
58
|
+
state.sessionAverageByID[input.sessionID] = {
|
|
59
|
+
totalTokens: totals.totalTokens + totalTokens,
|
|
60
|
+
totalDurationMs: totals.totalDurationMs + durationMs,
|
|
61
|
+
totalTtftMs: totals.totalTtftMs + ttftMs,
|
|
62
|
+
messageCount: totals.messageCount + 1
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
delete state.messageTimingByID[input.messageID];
|
|
67
|
+
pruneSamples(state, input.completedAt);
|
|
68
|
+
}
|
|
69
|
+
function recordAssistantDelta(state, input) {
|
|
70
|
+
const sample = {
|
|
71
|
+
at: input.at,
|
|
72
|
+
tokens: estimateStreamTokens(input.delta)
|
|
73
|
+
};
|
|
74
|
+
state.streamSamplesBySession[input.sessionID] = [
|
|
75
|
+
...(state.streamSamplesBySession[input.sessionID] ?? []).filter((item) => input.at - item.at <= STREAM_WINDOW_MS),
|
|
76
|
+
sample
|
|
77
|
+
];
|
|
78
|
+
const timing = state.messageTimingByID[input.messageID];
|
|
79
|
+
if (timing) {
|
|
80
|
+
state.messageTimingByID[input.messageID] = timing.firstTokenAt ? { ...timing, lastTokenAt: input.at } : {
|
|
81
|
+
...timing,
|
|
82
|
+
firstResponseAt: timing.firstResponseAt ?? input.at,
|
|
83
|
+
firstTokenAt: input.at,
|
|
84
|
+
lastTokenAt: input.at
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function recordToolActivity(state, sessionID, messageID, at = Date.now()) {
|
|
89
|
+
if (state.streamSamplesBySession[sessionID]?.length) {
|
|
90
|
+
delete state.streamSamplesBySession[sessionID];
|
|
91
|
+
}
|
|
92
|
+
const timing = state.messageTimingByID[messageID];
|
|
93
|
+
if (timing) {
|
|
94
|
+
state.messageTimingByID[messageID] = {
|
|
95
|
+
...timing,
|
|
96
|
+
lastToolCallAt: at,
|
|
97
|
+
firstResponseAt: timing.firstResponseAt ?? at
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function renderMetricsText(state, sessionID, options = {}) {
|
|
102
|
+
const live = liveTps(state, sessionID, options) ?? "-";
|
|
103
|
+
const avg = sessionAverage(state, sessionID) ?? "-";
|
|
104
|
+
const ttft = sessionTtft(state, sessionID) ?? "-";
|
|
105
|
+
return `TPS ${live} | AVG ${avg} | TTFT ${ttft}`;
|
|
106
|
+
}
|
|
107
|
+
function renderResponseMetricsText(state, sessionID) {
|
|
108
|
+
const usage = state.latestResponseUsageBySession[sessionID];
|
|
109
|
+
if (!usage || !hasTokenUsage(usage)) return "";
|
|
110
|
+
const used = sumTokens(usage);
|
|
111
|
+
const cacheRate = cacheReadRate(usage);
|
|
112
|
+
const parts = [
|
|
113
|
+
used === void 0 ? void 0 : `${formatTokenCount(used)} used`,
|
|
114
|
+
cacheRate === void 0 ? void 0 : `${formatPercent(cacheRate)} cache`,
|
|
115
|
+
usage.outputTokens === void 0 ? void 0 : `${formatTokenCount(usage.outputTokens)} out`,
|
|
116
|
+
usage.reasoningTokens === void 0 ? void 0 : `${formatTokenCount(usage.reasoningTokens)} think`
|
|
117
|
+
].filter((part) => !!part);
|
|
118
|
+
return parts.join(" | ");
|
|
119
|
+
}
|
|
120
|
+
function renderPromptRightMetricsText(state, sessionID, options = {}) {
|
|
121
|
+
const usage = state.latestResponseUsageBySession[sessionID];
|
|
122
|
+
const used = usage ? sumTokens(usage) : void 0;
|
|
123
|
+
const cacheRate = usage ? cacheReadRate(usage) : void 0;
|
|
124
|
+
const values = {
|
|
125
|
+
tps: `TPS ${liveTps(state, sessionID, options) ?? "-"}`,
|
|
126
|
+
avg: `AVG ${sessionAverage(state, sessionID) ?? "-"}`,
|
|
127
|
+
ttft: `TTFT ${sessionTtft(state, sessionID) ?? "-"}`,
|
|
128
|
+
used: `${used === void 0 ? "-" : formatTokenCount(used)} used`,
|
|
129
|
+
cache: `${cacheRate === void 0 ? "-" : formatPercent(cacheRate)} cache`,
|
|
130
|
+
input: `${usage?.inputTokens === void 0 ? "-" : formatTokenCount(usage.inputTokens)} in`,
|
|
131
|
+
output: `${usage?.outputTokens === void 0 ? "-" : formatTokenCount(usage.outputTokens)} out`,
|
|
132
|
+
reasoning: `${usage?.reasoningTokens === void 0 ? "-" : formatTokenCount(usage.reasoningTokens)} think`
|
|
133
|
+
};
|
|
134
|
+
return (options.metrics?.length ? options.metrics : DEFAULT_PROMPT_RIGHT_METRICS).map((metric) => values[metric]).join(" | ");
|
|
135
|
+
}
|
|
136
|
+
function getSessionTokenUsage(state, sessionID) {
|
|
137
|
+
return state.sessionTokenUsageByID[sessionID];
|
|
138
|
+
}
|
|
139
|
+
function renderSessionTokenUsage(state, sessionID) {
|
|
140
|
+
const usage = getSessionTokenUsage(state, sessionID);
|
|
141
|
+
if (!usage) return "";
|
|
142
|
+
const cachePromptTokens = usage.inputTokens + usage.cacheReadTokens;
|
|
143
|
+
const cacheRate = cachePromptTokens > 0 ? usage.cacheReadTokens / cachePromptTokens * 100 : void 0;
|
|
144
|
+
return [
|
|
145
|
+
"Token Usage",
|
|
146
|
+
`${formatTokenCount(usage.totalTokens)} total \xB7 ${usage.responseCount} responses`,
|
|
147
|
+
`${formatTokenCount(usage.inputTokens)} input`,
|
|
148
|
+
`${formatTokenCount(usage.outputTokens)} output`,
|
|
149
|
+
`${formatTokenCount(usage.reasoningTokens)} reasoning`,
|
|
150
|
+
`${formatTokenCount(usage.cacheReadTokens)} cache read`,
|
|
151
|
+
`${formatTokenCount(usage.cacheWriteTokens)} cache write`,
|
|
152
|
+
`${cacheRate === void 0 ? "-" : formatPercent(cacheRate)} cache rate`
|
|
153
|
+
].join("\n");
|
|
154
|
+
}
|
|
155
|
+
function pruneSamples(state, now = Date.now()) {
|
|
156
|
+
for (const [sessionID, samples] of Object.entries(state.streamSamplesBySession)) {
|
|
157
|
+
const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS);
|
|
158
|
+
if (next.length > 0) state.streamSamplesBySession[sessionID] = next;
|
|
159
|
+
else delete state.streamSamplesBySession[sessionID];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function rebuildSessionTokenUsage(state, sessionID) {
|
|
163
|
+
const usage = {
|
|
164
|
+
inputTokens: 0,
|
|
165
|
+
outputTokens: 0,
|
|
166
|
+
reasoningTokens: 0,
|
|
167
|
+
cacheReadTokens: 0,
|
|
168
|
+
cacheWriteTokens: 0,
|
|
169
|
+
totalTokens: 0,
|
|
170
|
+
responseCount: 0
|
|
171
|
+
};
|
|
172
|
+
for (const response of Object.values(state.responseUsageByMessageID)) {
|
|
173
|
+
if (response.sessionID !== sessionID) continue;
|
|
174
|
+
usage.inputTokens += response.usage.inputTokens ?? 0;
|
|
175
|
+
usage.outputTokens += response.usage.outputTokens ?? 0;
|
|
176
|
+
usage.reasoningTokens += response.usage.reasoningTokens ?? 0;
|
|
177
|
+
usage.cacheReadTokens += response.usage.cacheReadTokens ?? 0;
|
|
178
|
+
usage.cacheWriteTokens += response.usage.cacheWriteTokens ?? 0;
|
|
179
|
+
usage.responseCount += 1;
|
|
180
|
+
}
|
|
181
|
+
usage.totalTokens = usage.inputTokens + usage.outputTokens + usage.reasoningTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
182
|
+
state.sessionTokenUsageByID[sessionID] = usage;
|
|
183
|
+
}
|
|
184
|
+
function sessionAverage(state, sessionID) {
|
|
185
|
+
const totals = state.sessionAverageByID[sessionID];
|
|
186
|
+
if (!totals || totals.totalTokens <= 0 || totals.totalDurationMs <= 0) return void 0;
|
|
187
|
+
return formatRate(totals.totalTokens / (totals.totalDurationMs / 1e3), "AVG");
|
|
188
|
+
}
|
|
189
|
+
function sessionTtft(state, sessionID) {
|
|
190
|
+
const totals = state.sessionAverageByID[sessionID];
|
|
191
|
+
if (!totals || totals.messageCount <= 0 || totals.totalTtftMs < 0) return void 0;
|
|
192
|
+
return formatTtft(totals.totalTtftMs / totals.messageCount / 1e3);
|
|
193
|
+
}
|
|
194
|
+
function liveTps(state, sessionID, options = {}) {
|
|
195
|
+
if (options.idle) return void 0;
|
|
196
|
+
const now = options.now ?? Date.now();
|
|
197
|
+
const samples = state.streamSamplesBySession[sessionID] ?? [];
|
|
198
|
+
const relevant = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS);
|
|
199
|
+
if (relevant.length === 0) return void 0;
|
|
200
|
+
const lastSample = relevant.at(-1);
|
|
201
|
+
if (!lastSample || now - lastSample.at > LIVE_STALE_MS) return void 0;
|
|
202
|
+
const total = relevant.reduce((sum, sample) => sum + sample.tokens, 0);
|
|
203
|
+
const durationSeconds = activeDurationMs(relevant, now) / 1e3;
|
|
204
|
+
if (durationSeconds <= 0) return void 0;
|
|
205
|
+
return formatRate(total / durationSeconds, "TPS");
|
|
206
|
+
}
|
|
207
|
+
function activeDurationMs(samples, tailAt) {
|
|
208
|
+
if (samples.length === 0) return 0;
|
|
209
|
+
if (samples.length === 1) {
|
|
210
|
+
const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS;
|
|
211
|
+
return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS);
|
|
212
|
+
}
|
|
213
|
+
let duration = 0;
|
|
214
|
+
for (let index = 1; index < samples.length; index++) {
|
|
215
|
+
duration += Math.max(0, samples[index].at - samples[index - 1].at);
|
|
216
|
+
}
|
|
217
|
+
if (tailAt) {
|
|
218
|
+
duration += Math.max(0, tailAt - samples.at(-1).at);
|
|
219
|
+
}
|
|
220
|
+
return Math.max(duration, SINGLE_SAMPLE_MS);
|
|
221
|
+
}
|
|
222
|
+
function formatRate(value, label) {
|
|
223
|
+
if (!Number.isFinite(value) || value <= 0) return void 0;
|
|
224
|
+
const suffix = label === "TPS" ? " TPS" : "";
|
|
225
|
+
if (value >= 100) return `${Math.round(value)}${suffix}`;
|
|
226
|
+
if (value >= 10) return `${value.toFixed(1)}${suffix}`;
|
|
227
|
+
return `${value.toFixed(2)}${suffix}`;
|
|
228
|
+
}
|
|
229
|
+
function formatTtft(value) {
|
|
230
|
+
if (!Number.isFinite(value) || value < 0) return void 0;
|
|
231
|
+
return `${value.toFixed(1)}s`;
|
|
232
|
+
}
|
|
233
|
+
function compactUsage(usage) {
|
|
234
|
+
return Object.fromEntries(Object.entries(usage).filter(([, value]) => value !== void 0));
|
|
235
|
+
}
|
|
236
|
+
function hasTokenUsage(usage) {
|
|
237
|
+
return [usage.inputTokens, usage.outputTokens, usage.reasoningTokens, usage.cacheReadTokens, usage.cacheWriteTokens].some(
|
|
238
|
+
(value) => typeof value === "number"
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
function sumTokens(usage) {
|
|
242
|
+
const values = [usage.inputTokens, usage.outputTokens, usage.reasoningTokens, usage.cacheReadTokens, usage.cacheWriteTokens].filter(
|
|
243
|
+
(value) => typeof value === "number"
|
|
244
|
+
);
|
|
245
|
+
return values.length ? values.reduce((sum, value) => sum + value, 0) : void 0;
|
|
246
|
+
}
|
|
247
|
+
function cacheReadRate(usage) {
|
|
248
|
+
if (typeof usage.cacheReadTokens !== "number") return void 0;
|
|
249
|
+
const promptTokens = (usage.inputTokens ?? 0) + usage.cacheReadTokens;
|
|
250
|
+
return promptTokens > 0 ? usage.cacheReadTokens / promptTokens * 100 : void 0;
|
|
251
|
+
}
|
|
252
|
+
function formatTokenCount(value) {
|
|
253
|
+
if (Math.abs(value) >= 1e6) return `${formatAbbreviatedTokenCount(value, 1e6)}m`;
|
|
254
|
+
if (Math.abs(value) >= 1e3) return `${formatAbbreviatedTokenCount(value, 1e3)}k`;
|
|
255
|
+
return String(Math.round(value));
|
|
256
|
+
}
|
|
257
|
+
function formatPercent(value) {
|
|
258
|
+
return `${(Math.round(value * 100) / 100).toFixed(2)}%`;
|
|
259
|
+
}
|
|
260
|
+
function formatAbbreviatedTokenCount(value, divisor) {
|
|
261
|
+
return (Math.round(value / divisor * 10) / 10).toFixed(1);
|
|
262
|
+
}
|
|
263
|
+
|
|
1
264
|
// src/capture.ts
|
|
2
265
|
import { mkdir, appendFile, readFile, writeFile } from "fs/promises";
|
|
3
266
|
import { existsSync, readFileSync } from "fs";
|
|
@@ -62,6 +325,37 @@ function resolveCapturePath(options = {}) {
|
|
|
62
325
|
const dataDir = typeof options.dataDir === "string" && options.dataDir.length > 0 ? options.dataDir : defaultDataDir();
|
|
63
326
|
return join(dataDir, "insights.sqlite");
|
|
64
327
|
}
|
|
328
|
+
function resolveInsightsConfigPath(options = {}) {
|
|
329
|
+
return join(dirname(resolveCapturePath(options)), "config.json");
|
|
330
|
+
}
|
|
331
|
+
async function readInsightsConfig(options = {}) {
|
|
332
|
+
const path = resolveInsightsConfigPath(options);
|
|
333
|
+
if (!existsSync(path)) {
|
|
334
|
+
try {
|
|
335
|
+
await mkdir(dirname(path), { recursive: true });
|
|
336
|
+
await writeFile(path, `${JSON.stringify(defaultInsightsConfig(), null, 2)}
|
|
337
|
+
`, "utf8");
|
|
338
|
+
} catch {
|
|
339
|
+
return defaultInsightsConfig();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
return insightsConfigFrom(JSON.parse(await readFile(path, "utf8")));
|
|
344
|
+
} catch {
|
|
345
|
+
return defaultInsightsConfig();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function defaultInsightsConfig() {
|
|
349
|
+
return { promptRightMetrics: [...DEFAULT_PROMPT_RIGHT_METRICS] };
|
|
350
|
+
}
|
|
351
|
+
function insightsConfigFrom(value) {
|
|
352
|
+
if (!isRecord(value) || !Array.isArray(value.promptRightMetrics)) return defaultInsightsConfig();
|
|
353
|
+
const metrics = value.promptRightMetrics.filter(isPromptRightMetric);
|
|
354
|
+
return metrics.length ? { promptRightMetrics: metrics } : defaultInsightsConfig();
|
|
355
|
+
}
|
|
356
|
+
function isPromptRightMetric(value) {
|
|
357
|
+
return value === "tps" || value === "avg" || value === "ttft" || value === "used" || value === "cache" || value === "input" || value === "output" || value === "reasoning";
|
|
358
|
+
}
|
|
65
359
|
function normalizeChatMessageCapture(input, output, timestamp = Date.now()) {
|
|
66
360
|
const inputRecord = isRecord(input) ? input : {};
|
|
67
361
|
const model = isRecord(inputRecord.model) ? inputRecord.model : void 0;
|
|
@@ -384,8 +678,21 @@ function retentionCutoff(now, retentionMs) {
|
|
|
384
678
|
}
|
|
385
679
|
|
|
386
680
|
export {
|
|
681
|
+
DEFAULT_PROMPT_RIGHT_METRICS,
|
|
682
|
+
createMetricsState,
|
|
683
|
+
estimateStreamTokens,
|
|
684
|
+
recordAssistantMessage,
|
|
685
|
+
recordAssistantDelta,
|
|
686
|
+
recordToolActivity,
|
|
687
|
+
renderMetricsText,
|
|
688
|
+
renderResponseMetricsText,
|
|
689
|
+
renderPromptRightMetricsText,
|
|
690
|
+
getSessionTokenUsage,
|
|
691
|
+
renderSessionTokenUsage,
|
|
387
692
|
defaultDataDir,
|
|
388
693
|
resolveCapturePath,
|
|
694
|
+
resolveInsightsConfigPath,
|
|
695
|
+
readInsightsConfig,
|
|
389
696
|
normalizeChatMessageCapture,
|
|
390
697
|
normalizeChatParamsCapture,
|
|
391
698
|
normalizeChatHeadersCapture,
|
|
@@ -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,
|
package/dist/cli.d.ts
CHANGED