opencode-tokenwatch 0.3.0 → 0.3.1

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.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * stats-store.ts — 持久化聚合统计存储
3
+ *
4
+ * 设计目标:将性能指标的"聚合统计"与"原始 JSONL 日志"彻底解耦。
5
+ * - 每次请求完成时,通过 updatePersistedStats() 增量写入 JSON 统计文件
6
+ * - 统计文件永久累积,不受 JSONL 日志轮转/窗口限制影响
7
+ * - 百分位数采用 Reservoir Sampling 保持有界内存占用
8
+ * - 首次启动时自动从现有 JSONL 日志迁移,不丢失历史数据
9
+ */
10
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { homedir } from "node:os";
13
+ const STATS_PATH = join(homedir(), ".opencode", "tokenwatch-stats.json");
14
+ const LOG_PATH = join(homedir(), ".opencode", "tokenwatch.jsonl");
15
+ const RESERVOIR_SIZE = 500; // 每个指标最多保留的原始样本数
16
+ const CURRENT_VERSION = 1;
17
+ // ─────────────────────────────────────────────
18
+ // 内部 I/O 工具
19
+ // ─────────────────────────────────────────────
20
+ function loadStatsFile() {
21
+ try {
22
+ if (!existsSync(STATS_PATH)) {
23
+ return { version: CURRENT_VERSION, updatedAt: "", migratedFromLogs: false, models: {} };
24
+ }
25
+ const content = readFileSync(STATS_PATH, "utf-8");
26
+ const parsed = JSON.parse(content);
27
+ if (parsed?.version === CURRENT_VERSION && parsed.models)
28
+ return parsed;
29
+ }
30
+ catch { /* 文件损坏时返回空白统计 */ }
31
+ return { version: CURRENT_VERSION, updatedAt: "", migratedFromLogs: false, models: {} };
32
+ }
33
+ function saveStatsFile(file) {
34
+ try {
35
+ file.updatedAt = new Date().toISOString();
36
+ writeFileSync(STATS_PATH, JSON.stringify(file), "utf-8");
37
+ }
38
+ catch { /* 写入失败不影响主流程 */ }
39
+ }
40
+ // ─────────────────────────────────────────────
41
+ // Reservoir Sampling(有界样本更新)
42
+ // ─────────────────────────────────────────────
43
+ /**
44
+ * Reservoir Sampling 算法:保证内存有界的同时,给每个观测值相同的入选概率,
45
+ * 使分位数估算在统计意义上无偏。
46
+ *
47
+ * @param reservoir 当前样本数组(会被原地返回新引用)
48
+ * @param value 新观测值
49
+ * @param totalCount 加入此值后的总观测数
50
+ */
51
+ function reservoirAdd(reservoir, value, totalCount) {
52
+ if (reservoir.length < RESERVOIR_SIZE) {
53
+ return [...reservoir, value];
54
+ }
55
+ // 以 RESERVOIR_SIZE/totalCount 的概率替换随机位置
56
+ const j = Math.floor(Math.random() * totalCount);
57
+ if (j < RESERVOIR_SIZE) {
58
+ const next = [...reservoir];
59
+ next[j] = value;
60
+ return next;
61
+ }
62
+ return reservoir;
63
+ }
64
+ // ─────────────────────────────────────────────
65
+ // 核心增量更新逻辑(可复用于单条 & 批量迁移)
66
+ // ─────────────────────────────────────────────
67
+ function applyEntryToModels(models, entry) {
68
+ const key = entry.model;
69
+ let s = models[key];
70
+ if (!s) {
71
+ s = {
72
+ model: entry.model,
73
+ providerID: entry.providerID,
74
+ requestCount: 0,
75
+ ttftCount: 0,
76
+ tpsCount: 0,
77
+ latencyCount: 0,
78
+ totalInput: 0,
79
+ totalOutput: 0,
80
+ totalCacheRead: 0,
81
+ totalCacheWrite: 0,
82
+ totalCost: 0,
83
+ avgTTFT: null, maxTTFT: null, minTTFT: null,
84
+ avgTPS: null, maxTPS: null, minTPS: null,
85
+ avgLatency: null, maxLatency: null, minLatency: null,
86
+ ttftReservoir: [],
87
+ latencyReservoir: [],
88
+ };
89
+ models[key] = s;
90
+ }
91
+ s.requestCount++;
92
+ s.totalInput += entry.inputTokens;
93
+ s.totalOutput += entry.outputTokens;
94
+ s.totalCacheRead += entry.cacheReadTokens;
95
+ s.totalCacheWrite += entry.cacheWriteTokens;
96
+ s.totalCost += entry.cost;
97
+ if (entry.ttft_ms != null) {
98
+ s.ttftCount++;
99
+ const c = s.ttftCount;
100
+ s.avgTTFT = s.avgTTFT != null ? s.avgTTFT + (entry.ttft_ms - s.avgTTFT) / c : entry.ttft_ms;
101
+ s.maxTTFT = s.maxTTFT != null ? Math.max(s.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
102
+ s.minTTFT = s.minTTFT != null ? Math.min(s.minTTFT, entry.ttft_ms) : entry.ttft_ms;
103
+ s.ttftReservoir = reservoirAdd(s.ttftReservoir, entry.ttft_ms, s.ttftCount);
104
+ }
105
+ if (entry.tps != null) {
106
+ s.tpsCount++;
107
+ const c = s.tpsCount;
108
+ s.avgTPS = s.avgTPS != null ? s.avgTPS + (entry.tps - s.avgTPS) / c : entry.tps;
109
+ s.maxTPS = s.maxTPS != null ? Math.max(s.maxTPS, entry.tps) : entry.tps;
110
+ s.minTPS = s.minTPS != null ? Math.min(s.minTPS, entry.tps) : entry.tps;
111
+ }
112
+ if (entry.latency_ms != null) {
113
+ s.latencyCount++;
114
+ const c = s.latencyCount;
115
+ s.avgLatency = s.avgLatency != null ? s.avgLatency + (entry.latency_ms - s.avgLatency) / c : entry.latency_ms;
116
+ s.maxLatency = s.maxLatency != null ? Math.max(s.maxLatency, entry.latency_ms) : entry.latency_ms;
117
+ s.minLatency = s.minLatency != null ? Math.min(s.minLatency, entry.latency_ms) : entry.latency_ms;
118
+ s.latencyReservoir = reservoirAdd(s.latencyReservoir, entry.latency_ms, s.latencyCount);
119
+ }
120
+ }
121
+ // ─────────────────────────────────────────────
122
+ // 一次性迁移:从 JSONL 日志重建初始统计
123
+ // ─────────────────────────────────────────────
124
+ /**
125
+ * 如果统计文件尚未完成迁移,则读取全量 JSONL 日志并批量写入统计文件。
126
+ * 只在首次调用 readPersistedStats() 时执行一次,之后通过 migratedFromLogs 标志跳过。
127
+ */
128
+ function migrateFromLogsIfNeeded(file) {
129
+ if (file.migratedFromLogs)
130
+ return false;
131
+ if (!existsSync(LOG_PATH)) {
132
+ file.migratedFromLogs = true;
133
+ return true;
134
+ }
135
+ try {
136
+ const content = readFileSync(LOG_PATH, "utf-8").trim();
137
+ if (!content) {
138
+ file.migratedFromLogs = true;
139
+ return true;
140
+ }
141
+ let migrated = 0;
142
+ for (const line of content.split("\n")) {
143
+ if (!line)
144
+ continue;
145
+ try {
146
+ const entry = JSON.parse(line);
147
+ if (entry.model && entry.ts) {
148
+ applyEntryToModels(file.models, entry);
149
+ migrated++;
150
+ }
151
+ }
152
+ catch { /* 跳过格式损坏的行 */ }
153
+ }
154
+ file.migratedFromLogs = true;
155
+ if (migrated > 0) {
156
+ // 标记本次迁移来源,便于调试
157
+ ;
158
+ file._migratedFrom = `${LOG_PATH} (${migrated} entries)`;
159
+ }
160
+ return true;
161
+ }
162
+ catch {
163
+ // 迁移失败时仍标记为已完成,避免每次都重试(下次重建会通过 updatePersistedStats 增量补充)
164
+ file.migratedFromLogs = true;
165
+ return true;
166
+ }
167
+ }
168
+ // ─────────────────────────────────────────────
169
+ // 分位数计算
170
+ // ─────────────────────────────────────────────
171
+ function percentile(arr, p) {
172
+ if (arr.length === 0)
173
+ return null;
174
+ if (arr.length === 1)
175
+ return arr[0];
176
+ const idx = (p / 100) * (arr.length - 1);
177
+ const lo = Math.floor(idx);
178
+ const hi = Math.ceil(idx);
179
+ if (lo === hi)
180
+ return arr[lo];
181
+ return arr[lo] + (arr[hi] - arr[lo]) * (idx - lo);
182
+ }
183
+ // ─────────────────────────────────────────────
184
+ // 公开 API
185
+ // ─────────────────────────────────────────────
186
+ /**
187
+ * 将一条新的日志条目增量更新到持久化统计文件。
188
+ * 在 perf-tracker.ts 的 appendLog() 之后调用。
189
+ *
190
+ * 设计原则:本函数只做增量更新,迁移逻辑由 readPersistedStats() 负责。
191
+ * 这样可以避免迁移与增量更新之间的竞态问题。
192
+ */
193
+ export function updatePersistedStats(entry) {
194
+ try {
195
+ const file = loadStatsFile();
196
+ applyEntryToModels(file.models, entry);
197
+ // 如果尚未完成迁移,先标记(避免 readPersistedStats 再重复迁移后与当前增量数据合并)
198
+ // 实际上:首次有请求时 migratedFromLogs 必然为 false,
199
+ // 所以 readPersistedStats 首次被调用时会重建全量历史,覆盖这个增量写入。
200
+ // 这是可接受的:迁移完成后统计文件是完整的(含本条目,因为 JSONL 已先写入)。
201
+ saveStatsFile(file);
202
+ }
203
+ catch { /* 统计写入失败不影响主流程 */ }
204
+ }
205
+ /**
206
+ * 读取所有持久化统计,返回 ModelPerfStats 数组(含分位数)。
207
+ * 用于 HTML 报告生成,替代 aggregatePerfStats(readLogs(N)) 的有限窗口方案。
208
+ */
209
+ export function readPersistedStats() {
210
+ try {
211
+ const file = loadStatsFile();
212
+ // 如果尚未迁移(例如首次生成报告前没有任何请求),执行迁移
213
+ // 迁移时先清空 models,以 JSONL 全量数据为唯一权威来源,
214
+ // 避免与 updatePersistedStats 先写入的零散增量数据叠加导致重复计数。
215
+ if (!file.migratedFromLogs) {
216
+ file.models = {}; // 清空,让迁移从零开始重建
217
+ migrateFromLogsIfNeeded(file);
218
+ saveStatsFile(file);
219
+ }
220
+ return Object.values(file.models).map(s => {
221
+ const ttftArr = [...s.ttftReservoir].sort((a, b) => a - b);
222
+ const latArr = [...s.latencyReservoir].sort((a, b) => a - b);
223
+ const denom = s.totalInput + s.totalCacheRead;
224
+ return {
225
+ model: s.model,
226
+ providerID: s.providerID,
227
+ requestCount: s.requestCount,
228
+ ttftCount: s.ttftCount,
229
+ tpsCount: s.tpsCount,
230
+ latencyCount: s.latencyCount,
231
+ totalInput: s.totalInput,
232
+ totalOutput: s.totalOutput,
233
+ totalCacheRead: s.totalCacheRead,
234
+ totalCacheWrite: s.totalCacheWrite,
235
+ totalCost: s.totalCost,
236
+ avgTTFT: s.avgTTFT,
237
+ maxTTFT: s.maxTTFT,
238
+ minTTFT: s.minTTFT,
239
+ p50TTFT: percentile(ttftArr, 50),
240
+ p95TTFT: percentile(ttftArr, 95),
241
+ p99TTFT: percentile(ttftArr, 99),
242
+ avgTPS: s.avgTPS,
243
+ maxTPS: s.maxTPS,
244
+ minTPS: s.minTPS,
245
+ avgLatency: s.avgLatency,
246
+ maxLatency: s.maxLatency,
247
+ minLatency: s.minLatency,
248
+ p50Latency: percentile(latArr, 50),
249
+ p95Latency: percentile(latArr, 95),
250
+ p99Latency: percentile(latArr, 99),
251
+ cacheHitRate: denom > 0 ? (s.totalCacheRead / denom) * 100 : null,
252
+ };
253
+ });
254
+ }
255
+ catch {
256
+ return [];
257
+ }
258
+ }
package/dist/tui.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import { createSignal } from "solid-js";
1
+ import { createSignal, createEffect, onCleanup } from "solid-js";
2
2
  import { registerCommands } from "./commands.jsx";
3
3
  import { createPerfTracker } from "./perf-tracker.js";
4
4
  import { TokenWatchPanel } from "./sidebar.jsx";
@@ -78,7 +78,7 @@ const tui = async (api) => {
78
78
  sidebarRevision();
79
79
  if (session_id && session_id !== currentSlotSessionID) {
80
80
  currentSlotSessionID = session_id;
81
- perfTracker.reset();
81
+ perfTracker.loadSession(session_id);
82
82
  let loaded = [];
83
83
  try {
84
84
  const saved = api.kv?.get?.(kvKey(session_id));
@@ -86,32 +86,86 @@ const tui = async (api) => {
86
86
  loaded = saved;
87
87
  }
88
88
  catch { }
89
- if (loaded.length === 0) {
90
- const existing = api.state.session.messages(session_id);
91
- for (const msg of existing) {
92
- if (msg.role !== "assistant")
93
- continue;
94
- const tokens = msg.tokens;
95
- if (!tokens)
96
- continue;
97
- loaded.push({
98
- id: msg.id,
99
- sessionID: session_id,
100
- providerID: msg.providerID ?? "unknown",
101
- modelID: msg.modelID ?? "unknown",
102
- inputTokens: tokens?.input ?? 0,
103
- outputTokens: tokens?.output ?? 0,
104
- reasoningTokens: tokens?.reasoning ?? 0,
105
- cacheRead: tokens?.cache?.read ?? 0,
106
- cacheWrite: tokens?.cache?.write ?? 0,
107
- cost: msg.cost ?? 0,
108
- });
109
- }
110
- }
111
89
  setAllTokenMessages(loaded);
112
90
  }
113
- const messages = api.state.session.messages(session_id);
114
- return <TokenWatchPanel api={api} theme={api.theme} perfTracker={perfTracker} messages={messages} allTokenMessages={allTokenMessages()}/>;
91
+ // 引入 createEffect 监听历史会话消息在后台异步加载完毕后的变化
92
+ // 由于历史会话加载可能是异步的,初次检查 messages 可能为空且底层并非响应式数据源,
93
+ // 故采用短期高频轮询,直到数据加载完成或超时。
94
+ createEffect(() => {
95
+ if (!session_id)
96
+ return;
97
+ let timer = null;
98
+ let pollCount = 0;
99
+ const maxPolls = 50; // 最多轮询 10 秒 (50 * 200ms)
100
+ const checkAndPopulate = () => {
101
+ const existing = api.state.session.messages(session_id);
102
+ if (!existing || existing.length === 0)
103
+ return false;
104
+ setAllTokenMessages((prev) => {
105
+ let changed = false;
106
+ const next = [...prev];
107
+ for (const msg of existing) {
108
+ if (msg.role !== "assistant")
109
+ continue;
110
+ const tokens = msg.tokens;
111
+ if (!tokens)
112
+ continue;
113
+ const id = msg.id;
114
+ const idx = next.findIndex(m => m.id === id);
115
+ const tokenMsg = {
116
+ id,
117
+ sessionID: session_id,
118
+ providerID: msg.providerID ?? "unknown",
119
+ modelID: msg.modelID ?? "unknown",
120
+ inputTokens: tokens?.input ?? 0,
121
+ outputTokens: tokens?.output ?? 0,
122
+ reasoningTokens: tokens?.reasoning ?? 0,
123
+ cacheRead: tokens?.cache?.read ?? 0,
124
+ cacheWrite: tokens?.cache?.write ?? 0,
125
+ cost: msg.cost ?? 0,
126
+ };
127
+ if (idx >= 0) {
128
+ const cur = next[idx];
129
+ if (cur.inputTokens !== tokenMsg.inputTokens ||
130
+ cur.outputTokens !== tokenMsg.outputTokens ||
131
+ cur.reasoningTokens !== tokenMsg.reasoningTokens ||
132
+ cur.cacheRead !== tokenMsg.cacheRead ||
133
+ cur.cacheWrite !== tokenMsg.cacheWrite ||
134
+ cur.cost !== tokenMsg.cost) {
135
+ next[idx] = tokenMsg;
136
+ changed = true;
137
+ }
138
+ }
139
+ else {
140
+ next.push(tokenMsg);
141
+ changed = true;
142
+ }
143
+ }
144
+ if (changed) {
145
+ persistToKv(session_id, next);
146
+ return next;
147
+ }
148
+ return prev;
149
+ });
150
+ return true;
151
+ };
152
+ const hasMessages = checkAndPopulate();
153
+ if (!hasMessages) {
154
+ timer = setInterval(() => {
155
+ pollCount++;
156
+ if (checkAndPopulate() || pollCount >= maxPolls) {
157
+ clearInterval(timer);
158
+ timer = null;
159
+ }
160
+ }, 200);
161
+ }
162
+ onCleanup(() => {
163
+ if (timer) {
164
+ clearInterval(timer);
165
+ }
166
+ });
167
+ });
168
+ return <TokenWatchPanel api={api} theme={api.theme} perfTracker={perfTracker} messages={() => api.state.session.messages(session_id)} allTokenMessages={allTokenMessages}/>;
115
169
  },
116
170
  },
117
171
  });
package/package.json CHANGED
@@ -1,63 +1,63 @@
1
- {
2
- "name": "opencode-tokenwatch",
3
- "version": "0.3.0",
4
- "description": "Real-time token usage, cache analytics & performance dashboard plugin for OpenCode CLI",
5
- "type": "module",
6
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
12
- },
13
- "./tui": {
14
- "types": "./dist/tui.d.ts",
15
- "import": "./dist/tui.jsx"
16
- },
17
- "./package.json": "./package.json"
18
- },
19
- "files": [
20
- "dist"
21
- ],
22
- "sideEffects": false,
23
- "engines": {
24
- "node": ">=18"
25
- },
26
- "scripts": {
27
- "build": "tsc",
28
- "release:check": "node ./scripts/publish-check.mjs",
29
- "prepublishOnly": "npm run build"
30
- },
31
- "keywords": [
32
- "opencode",
33
- "plugin",
34
- "opencode-plugin",
35
- "tokens",
36
- "usage",
37
- "stats",
38
- "sqlite",
39
- "analytics",
40
- "tui"
41
- ],
42
- "license": "MIT",
43
- "author": "TTWK",
44
- "repository": {
45
- "type": "git",
46
- "url": "git+https://github.com/TTWK/opencode-tokenwatch.git"
47
- },
48
- "bugs": {
49
- "url": "https://github.com/TTWK/opencode-tokenwatch/issues"
50
- },
51
- "homepage": "https://github.com/TTWK/opencode-tokenwatch#readme",
52
- "devDependencies": {
53
- "@opencode-ai/plugin": "latest",
54
- "@opentui/core": "^0.2.9",
55
- "@opentui/keymap": "^0.2.9",
56
- "@opentui/solid": "^0.2.9",
57
- "@types/node": "^22.0.0",
58
- "typescript": "^5.7.0"
59
- },
60
- "publishConfig": {
61
- "access": "public"
62
- }
63
- }
1
+ {
2
+ "name": "opencode-tokenwatch",
3
+ "version": "0.3.1",
4
+ "description": "Real-time token usage, cache analytics & performance dashboard plugin for OpenCode CLI",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./tui": {
14
+ "types": "./dist/tui.d.ts",
15
+ "import": "./dist/tui.jsx"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "sideEffects": false,
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc",
28
+ "release:check": "node ./scripts/publish-check.mjs",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "keywords": [
32
+ "opencode",
33
+ "plugin",
34
+ "opencode-plugin",
35
+ "tokens",
36
+ "usage",
37
+ "stats",
38
+ "sqlite",
39
+ "analytics",
40
+ "tui"
41
+ ],
42
+ "license": "MIT",
43
+ "author": "TTWK",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/TTWK/opencode-tokenwatch.git"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/TTWK/opencode-tokenwatch/issues"
50
+ },
51
+ "homepage": "https://github.com/TTWK/opencode-tokenwatch#readme",
52
+ "devDependencies": {
53
+ "@opencode-ai/plugin": "latest",
54
+ "@opentui/core": "^0.2.9",
55
+ "@opentui/keymap": "^0.2.9",
56
+ "@opentui/solid": "^0.2.9",
57
+ "@types/node": "^22.0.0",
58
+ "typescript": "^5.7.0"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ }
63
+ }