@chenlongapps/opencode-token-usage 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/ROADMAP.md +15 -0
- package/dist/controller.d.ts +5 -1
- package/dist/controller.js +25 -15
- package/dist/performance.d.ts +20 -8
- package/dist/performance.js +114 -32
- package/dist/tui.js +8 -1
- package/dist/usage.d.ts +1 -0
- package/dist/usage.js +10 -2
- package/docs/verification.md +8 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ For remote sessions, add the package name to `plugins` in your local `~/.config/
|
|
|
49
49
|
| `Cache Rate` | `Cache Read ÷ (Input + Cache Read + Cache Write)` |
|
|
50
50
|
| `Total` | Sum of all five token categories |
|
|
51
51
|
| `Context` | Latest context usage after the most recent completed compaction in the viewed session; not aggregated across the subtree |
|
|
52
|
+
| `Steps` | Assistant message count across the session tree, including subagents; follows OpenCode's own stats definition, so compaction and user messages are not steps |
|
|
52
53
|
| `Cost` | Estimated cost for the entire tree, recalculated using the viewed session's active model; not a provider bill |
|
|
53
54
|
| `TPS` | Generation throughput for `Output + Reasoning` across the tree; live estimates are marked with `~` |
|
|
54
55
|
| `TTFT` | Average time to first token across measurable assistant steps in the tree |
|
|
@@ -58,6 +59,7 @@ For remote sessions, add the package name to `plugins` in your local `~/.config/
|
|
|
58
59
|
- Usage includes server-reported assistant and compaction messages throughout the session tree, including unopened subagents. Token counts are never inferred from text length.
|
|
59
60
|
- A fork is a separate session tree. Inherited message copies are attributed only to their original source to prevent double counting.
|
|
60
61
|
- `Context` only searches messages after the most recent compaction with `status === "completed"` and is hidden when reliable usage or a model context limit is unavailable.
|
|
62
|
+
- `Steps` counts every assistant message in the tree, whether or not it reported usage, and reuses the fork-copy de-duplication so inherited history is never counted twice.
|
|
61
63
|
- `Cost` applies the viewed session's active model to the whole tree. Missing applicable prices fall back to 0, matching OpenCode's behavior.
|
|
62
64
|
- Initial read failures display `Unavailable`. Later failures retain the last complete snapshot, display `Not updated`, and retry automatically.
|
|
63
65
|
|
package/README.zh-CN.md
CHANGED
|
@@ -49,6 +49,7 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
49
49
|
| `Cache Rate` | `Cache Read ÷ (Input + Cache Read + Cache Write)` |
|
|
50
50
|
| `Total` | 五类 token 之和 |
|
|
51
51
|
| `Context` | 当前会话最后一次已完成压缩后的最新上下文用量,不随子树累计 |
|
|
52
|
+
| `Steps` | 全树(含子代理)的 assistant 消息数,口径与 OpenCode 自带统计一致,compaction 与用户消息不计为 step |
|
|
52
53
|
| `Cost` | 按当前会话活动模型重新估算的全树费用,不代表提供商账单 |
|
|
53
54
|
| `TPS` | 全树 `Output + Reasoning` 的生成速度;生成中的估算值带 `~` 标记 |
|
|
54
55
|
| `TTFT` | 全树中可测 assistant step 的平均首 token 时间 |
|
|
@@ -58,6 +59,7 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
58
59
|
- 用量覆盖会话树中服务端上报的 assistant 与 compaction 消息,包括未打开的子代理;不按文本长度估算 token。
|
|
59
60
|
- 分叉会话是独立会话树。继承消息副本只归原始来源,避免重复计费。
|
|
60
61
|
- `Context` 仅搜索最后一次 `status === "completed"` 的 compaction 之后;没有可靠用量或模型上限时隐藏。
|
|
62
|
+
- `Steps` 统计树中全部 assistant 消息,无论是否已上报用量;复用分叉副本去重,继承历史不会重复计数。
|
|
61
63
|
- `Cost` 使用当前查看会话的活动模型重算整棵树;缺失适用价格时按 OpenCode 行为回退为 0。
|
|
62
64
|
- 首次读取失败显示 `Unavailable`;后续失败保留上次完整快照、标注 `Not updated` 并自动重试。
|
|
63
65
|
|
package/ROADMAP.md
CHANGED
|
@@ -54,11 +54,26 @@ TPS 48.7 tok/s
|
|
|
54
54
|
TTFT 2.7s
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
## v0.2.5 — Steps 计数
|
|
58
|
+
|
|
59
|
+
目标:让整棵树的模型调用次数可见。
|
|
60
|
+
|
|
61
|
+
* [x] 显示全树(含子代理)的 `Steps` 计数
|
|
62
|
+
* [x] 口径与 OpenCode 官方统计一致:每条 assistant 消息为一个 step,compaction 与用户消息不计
|
|
63
|
+
* [x] 分叉继承副本不重复计数
|
|
64
|
+
* [x] 行位置为 Context 之后、Input 之前;无 Context 时位于面板第一行
|
|
65
|
+
* [x] 实际打包产物在 OpenCode 2.0.11 中验证显示与刷新
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
Steps 1,234
|
|
69
|
+
```
|
|
70
|
+
|
|
57
71
|
## v0.3.0 — 公开价格目录
|
|
58
72
|
|
|
59
73
|
目标:提供可查询的价格目录,便于用户了解不同模型的计费档位。
|
|
60
74
|
|
|
61
75
|
* [ ] 构建公开价格目录,包含模型、价格档位及阈值
|
|
76
|
+
* [ ] 只收录半年内发布的模型
|
|
62
77
|
<!-- TODO -->
|
|
63
78
|
|
|
64
79
|
## v0.4.0 — 用量命令
|
package/dist/controller.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { UsageSource } from "./source.js";
|
|
2
|
+
import { PerformanceMonitor } from "./performance.js";
|
|
2
3
|
import type { PerformanceSummary } from "./performance.js";
|
|
3
4
|
import type { ContextUsage, Summary } from "./usage.js";
|
|
4
5
|
export interface UsageState {
|
|
@@ -30,11 +31,14 @@ export declare class UsageController {
|
|
|
30
31
|
private timer;
|
|
31
32
|
private retry;
|
|
32
33
|
private performanceTimer;
|
|
34
|
+
private sessions;
|
|
33
35
|
private pending;
|
|
34
36
|
private disposed;
|
|
35
37
|
private unsubscribe;
|
|
38
|
+
private readonly unsubscribePerformance;
|
|
36
39
|
private readonly performance;
|
|
37
|
-
|
|
40
|
+
private readonly ownsPerformance;
|
|
41
|
+
constructor(source: UsageSource, subscribe: Subscribe, publish: (state: UsageState) => void, delay?: number, retryDelay?: number, performanceDelay?: number, performance?: PerformanceMonitor);
|
|
38
42
|
select(sessionID: string): void;
|
|
39
43
|
refresh(): void;
|
|
40
44
|
dispose(): void;
|
package/dist/controller.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { loadSnapshot, uniqueMessages, viewedMessages } from "./source.js";
|
|
2
|
-
import {
|
|
2
|
+
import { PerformanceMonitor } from "./performance.js";
|
|
3
3
|
import { contextUsage, summarize } from "./usage.js";
|
|
4
|
-
const performanceEvents = new Set([
|
|
5
|
-
"session.step.started", "session.step.streamed", "session.step.ended", "session.step.failed",
|
|
6
|
-
"session.text.delta", "session.reasoning.delta", "session.tool.input.delta",
|
|
7
|
-
]);
|
|
8
4
|
const sessionEvents = new Set([
|
|
9
5
|
"session.created", "session.deleted", "session.forked", "session.moved",
|
|
10
6
|
"session.model.selected", "session.agent.selected", "session.step.ended", "session.step.failed",
|
|
@@ -31,22 +27,32 @@ export class UsageController {
|
|
|
31
27
|
timer;
|
|
32
28
|
retry;
|
|
33
29
|
performanceTimer;
|
|
30
|
+
sessions = new Set();
|
|
34
31
|
pending = false;
|
|
35
32
|
disposed = false;
|
|
36
33
|
unsubscribe;
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
unsubscribePerformance;
|
|
35
|
+
performance;
|
|
36
|
+
ownsPerformance;
|
|
37
|
+
constructor(source, subscribe, publish, delay = 80, retryDelay = 3_000, performanceDelay = 100, performance) {
|
|
39
38
|
this.source = source;
|
|
40
39
|
this.publish = publish;
|
|
41
40
|
this.delay = delay;
|
|
42
41
|
this.retryDelay = retryDelay;
|
|
43
42
|
this.performanceDelay = performanceDelay;
|
|
43
|
+
this.ownsPerformance = performance === undefined;
|
|
44
|
+
this.performance = performance ?? new PerformanceMonitor(subscribe);
|
|
45
|
+
this.unsubscribePerformance = this.performance.listen(sessionID => {
|
|
46
|
+
if (this.sessions.has(sessionID))
|
|
47
|
+
this.refreshPerformance();
|
|
48
|
+
});
|
|
44
49
|
this.unsubscribe = subscribe(event => {
|
|
45
50
|
const data = (event.data && typeof event.data === "object" ? event.data : {});
|
|
46
|
-
if (event.type === "session.created")
|
|
47
|
-
this.
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
if (event.type === "session.created" && data.sessionID && data.parentID && this.sessions.has(data.parentID)) {
|
|
52
|
+
this.sessions.add(data.sessionID);
|
|
53
|
+
}
|
|
54
|
+
if (event.type === "session.deleted" && data.sessionID)
|
|
55
|
+
this.sessions.delete(data.sessionID);
|
|
50
56
|
if (globalEvents.has(event.type))
|
|
51
57
|
return this.refresh();
|
|
52
58
|
if (!sessionEvents.has(event.type))
|
|
@@ -69,6 +75,7 @@ export class UsageController {
|
|
|
69
75
|
this.performanceTimer = undefined;
|
|
70
76
|
this.pending = false;
|
|
71
77
|
this.snapshot = undefined;
|
|
78
|
+
this.sessions.clear();
|
|
72
79
|
this.sessionID = sessionID;
|
|
73
80
|
this.update({ status: "loading" });
|
|
74
81
|
void this.run();
|
|
@@ -95,7 +102,10 @@ export class UsageController {
|
|
|
95
102
|
clearTimeout(this.timer);
|
|
96
103
|
clearTimeout(this.retry);
|
|
97
104
|
clearTimeout(this.performanceTimer);
|
|
105
|
+
this.unsubscribePerformance();
|
|
98
106
|
this.unsubscribe();
|
|
107
|
+
if (this.ownsPerformance)
|
|
108
|
+
this.performance.dispose();
|
|
99
109
|
}
|
|
100
110
|
update(state) { this.state = state; this.publish(state); }
|
|
101
111
|
refreshPerformance() {
|
|
@@ -105,7 +115,7 @@ export class UsageController {
|
|
|
105
115
|
this.performanceTimer = undefined;
|
|
106
116
|
if (this.disposed || !this.snapshot || !this.state.summary)
|
|
107
117
|
return;
|
|
108
|
-
this.update({ ...this.state, performance: this.performance.summary(uniqueMessages(this.snapshot)) });
|
|
118
|
+
this.update({ ...this.state, performance: this.performance.summary(uniqueMessages(this.snapshot), this.sessions) });
|
|
109
119
|
}, this.performanceDelay);
|
|
110
120
|
}
|
|
111
121
|
async run() {
|
|
@@ -121,15 +131,15 @@ export class UsageController {
|
|
|
121
131
|
return;
|
|
122
132
|
this.snapshot = snapshot;
|
|
123
133
|
const messages = [...uniqueMessages(snapshot)];
|
|
124
|
-
this.
|
|
125
|
-
this.performance.reconcile(messages);
|
|
134
|
+
this.sessions = new Set(snapshot.sessions.keys());
|
|
135
|
+
this.performance.reconcile(messages, this.sessions);
|
|
126
136
|
clearTimeout(this.performanceTimer);
|
|
127
137
|
this.performanceTimer = undefined;
|
|
128
138
|
this.update({
|
|
129
139
|
status: "ready",
|
|
130
140
|
summary: summarize(messages, snapshot.model.prices),
|
|
131
141
|
context: contextUsage(viewedMessages(snapshot), snapshot.model.context),
|
|
132
|
-
performance: this.performance.summary(messages),
|
|
142
|
+
performance: this.performance.summary(messages, this.sessions),
|
|
133
143
|
model: snapshot.model.label,
|
|
134
144
|
});
|
|
135
145
|
}
|
package/dist/performance.d.ts
CHANGED
|
@@ -17,17 +17,29 @@ export interface PerformanceEvent {
|
|
|
17
17
|
id?: string;
|
|
18
18
|
created?: number;
|
|
19
19
|
}
|
|
20
|
+
export type PerformanceSubscribe = (listener: (event: PerformanceEvent) => void) => () => void;
|
|
21
|
+
export type PerformanceListener = (sessionID: string) => void;
|
|
20
22
|
/** Exact completed-run TPS and the average of only measurable TTFT samples. */
|
|
21
23
|
export declare function historicalPerformance(messages: Iterable<UsageMessage>, observations?: Iterable<FirstTokenObservation>): PerformanceSummary;
|
|
22
|
-
/**
|
|
23
|
-
export declare class
|
|
24
|
-
private
|
|
25
|
-
private
|
|
24
|
+
/** Captures streaming performance for every session for one plugin lifetime. */
|
|
25
|
+
export declare class PerformanceMonitor {
|
|
26
|
+
private readonly retention;
|
|
27
|
+
private readonly now;
|
|
26
28
|
private active;
|
|
27
29
|
private observed;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
private listeners;
|
|
31
|
+
private cleanupTimer;
|
|
32
|
+
private disposed;
|
|
33
|
+
private readonly unsubscribe;
|
|
34
|
+
constructor(subscribe?: PerformanceSubscribe, retention?: number, now?: () => number);
|
|
35
|
+
listen(listener: PerformanceListener): () => void;
|
|
36
|
+
dispose(): void;
|
|
37
|
+
private key;
|
|
38
|
+
private publish;
|
|
39
|
+
private deleteSession;
|
|
40
|
+
private prune;
|
|
41
|
+
private scheduleCleanup;
|
|
30
42
|
handle(event: PerformanceEvent): boolean;
|
|
31
|
-
reconcile(messages: Iterable<UsageMessage>): void;
|
|
32
|
-
summary(messages: Iterable<UsageMessage>): PerformanceSummary;
|
|
43
|
+
reconcile(messages: Iterable<UsageMessage>, sessionIDs: Iterable<string>): void;
|
|
44
|
+
summary(messages: Iterable<UsageMessage>, sessionIDs: Iterable<string>): PerformanceSummary;
|
|
33
45
|
}
|
package/dist/performance.js
CHANGED
|
@@ -68,56 +68,134 @@ export function historicalPerformance(messages, observations = []) {
|
|
|
68
68
|
result.ttft = ttft / ttftSamples;
|
|
69
69
|
return result;
|
|
70
70
|
}
|
|
71
|
-
/**
|
|
72
|
-
export class
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
/** Captures streaming performance for every session for one plugin lifetime. */
|
|
72
|
+
export class PerformanceMonitor {
|
|
73
|
+
retention;
|
|
74
|
+
now;
|
|
75
75
|
active = new Map();
|
|
76
76
|
observed = new Map();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
this.
|
|
84
|
-
|
|
85
|
-
if (!this.sessions.has(step.sessionID))
|
|
86
|
-
this.active.delete(id);
|
|
87
|
-
for (const [id, sample] of this.observed)
|
|
88
|
-
if (!this.sessions.has(sample.sessionID))
|
|
89
|
-
this.observed.delete(id);
|
|
77
|
+
listeners = new Set();
|
|
78
|
+
cleanupTimer;
|
|
79
|
+
disposed = false;
|
|
80
|
+
unsubscribe;
|
|
81
|
+
constructor(subscribe, retention = 60_000, now = Date.now) {
|
|
82
|
+
this.retention = retention;
|
|
83
|
+
this.now = now;
|
|
84
|
+
this.unsubscribe = subscribe?.(event => this.handle(event));
|
|
90
85
|
}
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
86
|
+
listen(listener) {
|
|
87
|
+
if (this.disposed)
|
|
88
|
+
return () => { };
|
|
89
|
+
this.listeners.add(listener);
|
|
90
|
+
return () => { this.listeners.delete(listener); };
|
|
91
|
+
}
|
|
92
|
+
dispose() {
|
|
93
|
+
if (this.disposed)
|
|
94
|
+
return;
|
|
95
|
+
this.disposed = true;
|
|
96
|
+
this.unsubscribe?.();
|
|
97
|
+
clearTimeout(this.cleanupTimer);
|
|
98
|
+
this.cleanupTimer = undefined;
|
|
99
|
+
this.listeners.clear();
|
|
100
|
+
this.active.clear();
|
|
101
|
+
this.observed.clear();
|
|
102
|
+
}
|
|
103
|
+
key(sessionID, messageID) { return `${sessionID}\0${messageID}`; }
|
|
104
|
+
publish(sessionID) {
|
|
105
|
+
for (const listener of this.listeners)
|
|
106
|
+
listener(sessionID);
|
|
107
|
+
}
|
|
108
|
+
deleteSession(sessionID) {
|
|
109
|
+
for (const [key, step] of this.active)
|
|
110
|
+
if (step.sessionID === sessionID)
|
|
111
|
+
this.active.delete(key);
|
|
112
|
+
for (const [key, sample] of this.observed)
|
|
113
|
+
if (sample.sessionID === sessionID)
|
|
114
|
+
this.observed.delete(key);
|
|
115
|
+
}
|
|
116
|
+
prune() {
|
|
117
|
+
const now = this.now();
|
|
118
|
+
const changed = new Set();
|
|
119
|
+
for (const [key, step] of this.active) {
|
|
120
|
+
if (step.finishedAt !== undefined && step.finishedAt + this.retention <= now) {
|
|
121
|
+
this.active.delete(key);
|
|
122
|
+
changed.add(step.sessionID);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const [key, sample] of this.observed) {
|
|
126
|
+
if (sample.expiresAt !== undefined && sample.expiresAt <= now) {
|
|
127
|
+
this.observed.delete(key);
|
|
128
|
+
changed.add(sample.sessionID);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return changed;
|
|
132
|
+
}
|
|
133
|
+
scheduleCleanup() {
|
|
134
|
+
clearTimeout(this.cleanupTimer);
|
|
135
|
+
if (this.disposed || !Number.isFinite(this.retention) || this.retention < 0)
|
|
136
|
+
return;
|
|
137
|
+
let next = Infinity;
|
|
138
|
+
for (const step of this.active.values()) {
|
|
139
|
+
if (step.finishedAt !== undefined)
|
|
140
|
+
next = Math.min(next, step.finishedAt + this.retention);
|
|
141
|
+
}
|
|
142
|
+
for (const sample of this.observed.values()) {
|
|
143
|
+
if (sample.expiresAt !== undefined)
|
|
144
|
+
next = Math.min(next, sample.expiresAt);
|
|
145
|
+
}
|
|
146
|
+
if (!Number.isFinite(next))
|
|
147
|
+
return;
|
|
148
|
+
this.cleanupTimer = setTimeout(() => {
|
|
149
|
+
this.cleanupTimer = undefined;
|
|
150
|
+
for (const sessionID of this.prune())
|
|
151
|
+
this.publish(sessionID);
|
|
152
|
+
this.scheduleCleanup();
|
|
153
|
+
}, Math.max(0, next - this.now()));
|
|
154
|
+
this.cleanupTimer.unref?.();
|
|
94
155
|
}
|
|
95
156
|
handle(event) {
|
|
157
|
+
if (this.disposed)
|
|
158
|
+
return false;
|
|
159
|
+
for (const sessionID of this.prune())
|
|
160
|
+
this.publish(sessionID);
|
|
96
161
|
const data = (event.data && typeof event.data === "object" ? event.data : {});
|
|
97
162
|
const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined;
|
|
163
|
+
if (event.type === "session.deleted") {
|
|
164
|
+
if (sessionID)
|
|
165
|
+
this.deleteSession(sessionID);
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
98
168
|
const messageID = typeof data.assistantMessageID === "string" ? data.assistantMessageID : undefined;
|
|
99
|
-
if (!sessionID || !messageID
|
|
169
|
+
if (!sessionID || !messageID)
|
|
100
170
|
return false;
|
|
171
|
+
const key = this.key(sessionID, messageID);
|
|
101
172
|
if (event.type === "session.step.started") {
|
|
102
173
|
if (!finite(data.started))
|
|
103
174
|
return false;
|
|
104
|
-
if (this.active.has(
|
|
175
|
+
if (this.active.has(key))
|
|
105
176
|
return false;
|
|
106
|
-
this.active.set(
|
|
177
|
+
this.active.set(key, {
|
|
107
178
|
messageID, sessionID, started: data.started, bytes: 0, events: new Set(),
|
|
108
179
|
});
|
|
109
180
|
return false;
|
|
110
181
|
}
|
|
111
|
-
const step = this.active.get(
|
|
182
|
+
const step = this.active.get(key);
|
|
112
183
|
if (!step)
|
|
113
184
|
return false;
|
|
114
185
|
if (event.type === "session.step.streamed") {
|
|
115
186
|
if (!finite(event.created) || event.created < step.started)
|
|
116
187
|
return false;
|
|
117
188
|
step.streamed = event.created;
|
|
189
|
+
if (step.bytes > 0)
|
|
190
|
+
this.publish(sessionID);
|
|
118
191
|
return step.bytes > 0;
|
|
119
192
|
}
|
|
120
193
|
if (event.type === "session.step.ended" || event.type === "session.step.failed") {
|
|
194
|
+
step.finishedAt = this.now();
|
|
195
|
+
const sample = this.observed.get(key);
|
|
196
|
+
if (sample)
|
|
197
|
+
sample.expiresAt = step.finishedAt + this.retention;
|
|
198
|
+
this.scheduleCleanup();
|
|
121
199
|
return false;
|
|
122
200
|
}
|
|
123
201
|
if (event.type !== "session.text.delta" && event.type !== "session.reasoning.delta"
|
|
@@ -133,29 +211,33 @@ export class PerformanceTracker {
|
|
|
133
211
|
step.last = Math.max(step.last ?? event.created, event.created);
|
|
134
212
|
if (step.first === undefined) {
|
|
135
213
|
step.first = event.created;
|
|
136
|
-
this.observed.set(
|
|
214
|
+
this.observed.set(key, { messageID, sessionID, started: step.started, first: event.created });
|
|
137
215
|
}
|
|
216
|
+
this.publish(sessionID);
|
|
138
217
|
return true;
|
|
139
218
|
}
|
|
140
|
-
reconcile(messages) {
|
|
219
|
+
reconcile(messages, sessionIDs) {
|
|
220
|
+
const sessions = new Set(sessionIDs);
|
|
141
221
|
const represented = new Set();
|
|
142
222
|
for (const message of messages) {
|
|
143
223
|
if (message.type === "assistant" && (message.tokens || message.time?.streamed !== undefined
|
|
144
224
|
|| message.time?.completed !== undefined))
|
|
145
225
|
represented.add(message.id);
|
|
146
226
|
}
|
|
147
|
-
for (const [
|
|
148
|
-
if (represented.has(
|
|
149
|
-
|
|
227
|
+
for (const [key, step] of this.active) {
|
|
228
|
+
if (sessions.has(step.sessionID) && represented.has(step.messageID)
|
|
229
|
+
&& (step.finishedAt !== undefined || step.streamed !== undefined))
|
|
230
|
+
this.active.delete(key);
|
|
150
231
|
}
|
|
151
232
|
}
|
|
152
|
-
summary(messages) {
|
|
153
|
-
const
|
|
233
|
+
summary(messages, sessionIDs) {
|
|
234
|
+
const sessions = new Set(sessionIDs);
|
|
235
|
+
const samples = [...this.observed.values()].filter(sample => sessions.has(sample.sessionID));
|
|
154
236
|
const result = historicalPerformance(messages, samples);
|
|
155
237
|
let bytes = 0;
|
|
156
238
|
let duration = 0;
|
|
157
239
|
for (const step of this.active.values()) {
|
|
158
|
-
if (!
|
|
240
|
+
if (!sessions.has(step.sessionID) || step.bytes <= 0)
|
|
159
241
|
continue;
|
|
160
242
|
const last = step.streamed ?? step.last;
|
|
161
243
|
if (!finite(last) || last <= step.started)
|
package/dist/tui.js
CHANGED
|
@@ -8,6 +8,7 @@ import { createElement as _$createElement } from "@opentui/solid";
|
|
|
8
8
|
import { Plugin } from "@opencode/plugin/tui";
|
|
9
9
|
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
|
|
10
10
|
import { UsageController } from "./controller.js";
|
|
11
|
+
import { PerformanceMonitor } from "./performance.js";
|
|
11
12
|
import { createSource } from "./source.js";
|
|
12
13
|
import { usageRows } from "./usage.js";
|
|
13
14
|
function UsagePanel(props) {
|
|
@@ -16,7 +17,7 @@ function UsagePanel(props) {
|
|
|
16
17
|
});
|
|
17
18
|
const controller = new UsageController(createSource(props.context.client), listener => props.context.data.listen(({
|
|
18
19
|
details
|
|
19
|
-
}) => listener(details)), setState);
|
|
20
|
+
}) => listener(details)), setState, 80, 3_000, 100, props.performance);
|
|
20
21
|
const unregister = props.register(controller);
|
|
21
22
|
createEffect(() => controller.select(props.sessionID));
|
|
22
23
|
onCleanup(() => {
|
|
@@ -89,6 +90,9 @@ function UsagePanel(props) {
|
|
|
89
90
|
export default Plugin.define({
|
|
90
91
|
id: "opencode-token-usage.tui",
|
|
91
92
|
setup(context) {
|
|
93
|
+
const performance = new PerformanceMonitor(listener => context.data.listen(({
|
|
94
|
+
details
|
|
95
|
+
}) => listener(details)));
|
|
92
96
|
const controllers = new Set();
|
|
93
97
|
const register = controller => {
|
|
94
98
|
controllers.add(controller);
|
|
@@ -103,6 +107,7 @@ export default Plugin.define({
|
|
|
103
107
|
get sessionID() {
|
|
104
108
|
return props.sessionID;
|
|
105
109
|
},
|
|
110
|
+
performance: performance,
|
|
106
111
|
register: register
|
|
107
112
|
})
|
|
108
113
|
});
|
|
@@ -130,6 +135,7 @@ export default Plugin.define({
|
|
|
130
135
|
get sessionID() {
|
|
131
136
|
return props.sessionID;
|
|
132
137
|
},
|
|
138
|
+
performance: performance,
|
|
133
139
|
register: register
|
|
134
140
|
}));
|
|
135
141
|
return _el$9;
|
|
@@ -141,6 +147,7 @@ export default Plugin.define({
|
|
|
141
147
|
controllers.clear();
|
|
142
148
|
remove();
|
|
143
149
|
removeChild();
|
|
150
|
+
performance.dispose();
|
|
144
151
|
};
|
|
145
152
|
}
|
|
146
153
|
});
|
package/dist/usage.d.ts
CHANGED
package/dist/usage.js
CHANGED
|
@@ -22,8 +22,13 @@ export function estimate(tokens, prices = []) {
|
|
|
22
22
|
export function summarize(messages, prices = []) {
|
|
23
23
|
const tokens = normalize();
|
|
24
24
|
let cost = 0;
|
|
25
|
+
let steps = 0;
|
|
25
26
|
let defaultPrice = prices.length === 0;
|
|
26
27
|
for (const message of messages) {
|
|
28
|
+
// Matches OpenCode SessionStats: every assistant message is one step,
|
|
29
|
+
// with or without reported usage; compaction and user messages are not.
|
|
30
|
+
if (message.type === "assistant")
|
|
31
|
+
steps++;
|
|
27
32
|
if ((message.type !== "assistant" && message.type !== "compaction") || !message.tokens)
|
|
28
33
|
continue;
|
|
29
34
|
const t = normalize(message.tokens);
|
|
@@ -36,7 +41,10 @@ export function summarize(messages, prices = []) {
|
|
|
36
41
|
cost += value.cost;
|
|
37
42
|
defaultPrice ||= value.defaultPrice;
|
|
38
43
|
}
|
|
39
|
-
return {
|
|
44
|
+
return {
|
|
45
|
+
tokens, total: total(tokens), cacheRate: incoming(tokens) ? tokens.cache.read / incoming(tokens) : 0,
|
|
46
|
+
steps, cost, defaultPrice,
|
|
47
|
+
};
|
|
40
48
|
}
|
|
41
49
|
const usableLimit = (value) => value !== undefined && Number.isFinite(value) && value > 0;
|
|
42
50
|
// Matches the OpenCode 2.0.10 sidebar: the last assistant message with tokens that follows
|
|
@@ -73,7 +81,7 @@ export function usageRows(summary, context, performance) {
|
|
|
73
81
|
const rows = [];
|
|
74
82
|
if (context)
|
|
75
83
|
rows.push(["Context", `${formatTokens(context.used)} / ${formatTokens(context.limit)} (${context.percent.toFixed(1)}%)`]);
|
|
76
|
-
rows.push(["Input", number(t?.input)], ["Output", number(t?.output)], ["Reasoning", number(t?.reasoning)], ["Cache Read", number(t?.cache.read)]);
|
|
84
|
+
rows.push(["Steps", number(summary?.steps)], ["Input", number(t?.input)], ["Output", number(t?.output)], ["Reasoning", number(t?.reasoning)], ["Cache Read", number(t?.cache.read)]);
|
|
77
85
|
if (t && t.cache.write > 0)
|
|
78
86
|
rows.push(["Cache Write", number(t.cache.write)]);
|
|
79
87
|
rows.push(["Cache Rate", summary ? `${(summary.cacheRate * 100).toFixed(1)}%` : "—"], ["Total", number(summary?.total)]);
|
package/docs/verification.md
CHANGED
|
@@ -2,26 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## 未发布(SDK 2.0.11)
|
|
4
4
|
|
|
5
|
-
验证日期:2026-09-
|
|
5
|
+
验证日期:2026-09-22。环境:macOS、Node.js v22.23.2、npm 10.9.8、OpenCode v2.0.11;`@opencode/plugin`、`@opencode/client`、`@opencode/schema` 和 `@opencode/theme` 均精确锁定为 2.0.11。
|
|
6
6
|
|
|
7
7
|
### 自动化检查
|
|
8
8
|
|
|
9
9
|
| 检查 | 结果 |
|
|
10
10
|
| --- | --- |
|
|
11
11
|
| `npm run typecheck` | 通过 |
|
|
12
|
-
| `npm test` |
|
|
12
|
+
| `npm test` | 32 项通过 |
|
|
13
13
|
| `npm run build` | 通过,输出 ESM 与类型声明 |
|
|
14
14
|
| `npm run test:smoke` | 通过,使用实际 `.tgz` 安装产物和真实 OpenCode 2.0.11 |
|
|
15
15
|
|
|
16
16
|
SDK 从 2.0.9 升级到 2.0.11 后,四个顶层 OpenCode 包及锁文件中的传递依赖均已统一到 2.0.11。`@opencode/plugin` 两版的导出结构一致;包内容差异除依赖版本外,仅为 `ToastOptions` 新增可选的 `sessionID` 类型字段。
|
|
17
17
|
|
|
18
|
+
新增共享性能监视器测试覆盖:查看会话 A 时捕获独立会话 B 的流,切换到 B 后立即恢复实时 TPS/TTFT;生成中切走、继续接收 delta 再切回;控制器销毁重建后恢复共享流;不同会话树隔离、新子代理即时纳入,以及删除会话清理运行期状态。既有重复事件去重与完成后收敛为精确 TPS 的断言继续通过。
|
|
19
|
+
|
|
20
|
+
新增 Steps 计数覆盖:`summarize` 对带与不带 `tokens` 的 assistant 消息各计一个 step、compaction 与 user 消息不计、无 summary 时显示 `—`;`uniqueMessages` 链路上分叉继承副本不重复计数(快照 2 个 step、独立分叉树 1 个 step)、compaction 不产生 step、孙会话视图与根视图同为 6 个 step;行序断言锁定 Context → Steps → Input,无 Context 时 Steps 位于面板第一行。基准行数由 6 行调整为 7 行。
|
|
21
|
+
|
|
18
22
|
### 真实 OpenCode 集成
|
|
19
23
|
|
|
20
|
-
隔离 smoke
|
|
24
|
+
隔离 smoke 测试确认打包插件可加载;空会话隐藏无数据行并显示 `Steps 0`;在独立会话已经开始慢速流后,同一 TUI 切换过去会立即显示 `TPS ~…` 与 TTFT,完成后变为无 `~` 的精确 TPS;既有 token、上下文与费用刷新、真实子代理全树累计、会话局部上下文及模型切换验证均继续通过。Steps 在真实集成中断言为:首条 assistant 完成后 `Steps 1` 且行序位于 Context 与 Input 之间;真实子代理场景下根视图与子代理视图均为 `Steps 4`(父 3 条 + 子 1 条),而被查看会话自身仍为 1,确认 Steps 随整棵树累计、上下文保持会话局部。测试产物保存在 `/var/folders/rw/bmx6c8hd737brl55m0_ff_b80000gn/T/token-usage-smoke-423PQP`。
|
|
21
25
|
|
|
22
26
|
### 验证边界
|
|
23
27
|
|
|
24
|
-
当前 SDK 2.0.11 与宿主 2.0.11
|
|
28
|
+
当前 SDK 2.0.11 与宿主 2.0.11 的组合已完整验证。验证时机器默认宿主已是 2.0.12,因此 smoke 使用校验完整性的官方 `@opencode/cli-darwin-arm64@2.0.11` 隔离二进制,没有放宽宿主版本断言。2.0.9 和 2.0.10 宿主的历史验证记录保留在下方,但升级 SDK 后未重新执行这两个旧宿主的打包矩阵。后续升级 SDK 或宿主时,仍需重新核对分叉副本、compaction 排序及 step/delta 事件语义。
|
|
25
29
|
|
|
26
30
|
## v0.2.1(TPS 与 TTFT)
|
|
27
31
|
|