@chenlongapps/opencode-token-usage 0.4.8 → 0.4.9
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 +13 -9
- package/README.zh-CN.md +13 -9
- package/ROADMAP.md +6 -2
- package/dist/click.d.ts +9 -0
- package/dist/click.js +20 -0
- package/dist/context-rpc.d.ts +11 -0
- package/dist/context-rpc.js +6 -0
- package/dist/controller.d.ts +14 -1
- package/dist/controller.js +74 -6
- package/dist/index.js +1 -0
- package/dist/performance.d.ts +8 -3
- package/dist/performance.js +77 -23
- package/dist/runtime-monitor.d.ts +34 -0
- package/dist/runtime-monitor.js +155 -0
- package/dist/runtime.d.ts +15 -0
- package/dist/runtime.js +34 -3
- package/dist/tui.js +250 -203
- package/dist/usage.d.ts +3 -2
- package/dist/usage.js +16 -7
- package/docs/verification.md +112 -0
- package/package.json +1 -1
package/dist/performance.js
CHANGED
|
@@ -1,30 +1,39 @@
|
|
|
1
1
|
import { normalize } from "./usage.js";
|
|
2
2
|
export const LIVE_WINDOW_MS = 2_000;
|
|
3
|
+
export const LIVE_MIN_SPAN_MS = 500;
|
|
4
|
+
export const LIVE_SAMPLE_MS = 100;
|
|
3
5
|
export const LIVE_EWMA_ALPHA = 0.35;
|
|
4
6
|
export const LIVE_BYTES_PER_TOKEN = 4;
|
|
5
|
-
/**
|
|
7
|
+
/** The first batch is a counter baseline, not bytes generated after its timestamp. */
|
|
6
8
|
export function estimatedLiveTps(samples) {
|
|
7
9
|
if (samples.length < 2)
|
|
8
10
|
return undefined;
|
|
9
11
|
const first = samples[0];
|
|
10
12
|
const last = samples[samples.length - 1];
|
|
11
13
|
const duration = last.time - first.time;
|
|
12
|
-
if (!Number.isFinite(duration) || duration
|
|
14
|
+
if (!Number.isFinite(duration) || duration < LIVE_MIN_SPAN_MS)
|
|
13
15
|
return undefined;
|
|
14
16
|
let bytes = 0;
|
|
17
|
+
let time = first.time;
|
|
15
18
|
for (const sample of samples) {
|
|
16
|
-
if (!Number.isFinite(sample.time) || !Number.isFinite(sample.bytes) || sample.bytes < 0)
|
|
19
|
+
if (!Number.isFinite(sample.time) || sample.time < time || !Number.isFinite(sample.bytes) || sample.bytes < 0)
|
|
17
20
|
return undefined;
|
|
18
|
-
|
|
21
|
+
if (sample.time > first.time)
|
|
22
|
+
bytes += sample.bytes;
|
|
23
|
+
time = sample.time;
|
|
19
24
|
}
|
|
20
|
-
if (bytes <= 0)
|
|
25
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
21
26
|
return undefined;
|
|
22
27
|
return (bytes / LIVE_BYTES_PER_TOKEN) / (duration / 1_000);
|
|
23
28
|
}
|
|
24
|
-
|
|
29
|
+
/** Time-based EWMA: elapsed time determines the weight, never delta count. */
|
|
30
|
+
export function smoothLiveTps(previous, raw, elapsed = LIVE_MIN_SPAN_MS) {
|
|
25
31
|
if (previous === undefined || !Number.isFinite(previous))
|
|
26
32
|
return raw;
|
|
27
|
-
|
|
33
|
+
if (!Number.isFinite(elapsed) || elapsed <= 0)
|
|
34
|
+
return previous;
|
|
35
|
+
const alpha = 1 - (1 - LIVE_EWMA_ALPHA) ** (elapsed / LIVE_MIN_SPAN_MS);
|
|
36
|
+
return previous * (1 - alpha) + raw * alpha;
|
|
28
37
|
}
|
|
29
38
|
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
30
39
|
function historicalFirst(message) {
|
|
@@ -109,15 +118,17 @@ function performanceWithObservations(prepared, observations) {
|
|
|
109
118
|
export class PerformanceMonitor {
|
|
110
119
|
retention;
|
|
111
120
|
now;
|
|
121
|
+
monotonic;
|
|
112
122
|
active = new Map();
|
|
113
123
|
observed = new Map();
|
|
114
124
|
listeners = new Set();
|
|
115
125
|
cleanupTimer;
|
|
116
126
|
disposed = false;
|
|
117
127
|
unsubscribe;
|
|
118
|
-
constructor(subscribe, retention = 60_000, now = Date.now) {
|
|
128
|
+
constructor(subscribe, retention = 60_000, now = Date.now, monotonic = () => performance.now()) {
|
|
119
129
|
this.retention = retention;
|
|
120
130
|
this.now = now;
|
|
131
|
+
this.monotonic = monotonic;
|
|
121
132
|
this.unsubscribe = subscribe?.(event => this.handle(event));
|
|
122
133
|
}
|
|
123
134
|
listen(listener) {
|
|
@@ -149,9 +160,19 @@ export class PerformanceMonitor {
|
|
|
149
160
|
for (const [key, sample] of this.observed)
|
|
150
161
|
if (sample.sessionID === sessionID)
|
|
151
162
|
this.observed.delete(key);
|
|
163
|
+
this.scheduleCleanup();
|
|
164
|
+
}
|
|
165
|
+
resetLive(step) {
|
|
166
|
+
step.samples = [];
|
|
167
|
+
delete step.bucketStarted;
|
|
168
|
+
delete step.observedAt;
|
|
169
|
+
delete step.liveTps;
|
|
170
|
+
delete step.liveDuration;
|
|
171
|
+
delete step.smoothedAt;
|
|
152
172
|
}
|
|
153
173
|
prune() {
|
|
154
174
|
const now = this.now();
|
|
175
|
+
const monotonic = this.monotonic();
|
|
155
176
|
const changed = new Set();
|
|
156
177
|
for (const [key, step] of this.active) {
|
|
157
178
|
if ((step.finishedAt ?? step.reconciledAt) !== undefined
|
|
@@ -159,6 +180,10 @@ export class PerformanceMonitor {
|
|
|
159
180
|
this.active.delete(key);
|
|
160
181
|
changed.add(step.sessionID);
|
|
161
182
|
}
|
|
183
|
+
else if (step.observedAt !== undefined && monotonic - step.observedAt >= LIVE_WINDOW_MS) {
|
|
184
|
+
this.resetLive(step);
|
|
185
|
+
changed.add(step.sessionID);
|
|
186
|
+
}
|
|
162
187
|
}
|
|
163
188
|
for (const [key, sample] of this.observed) {
|
|
164
189
|
if (sample.expiresAt !== undefined && sample.expiresAt <= now) {
|
|
@@ -170,17 +195,24 @@ export class PerformanceMonitor {
|
|
|
170
195
|
}
|
|
171
196
|
scheduleCleanup() {
|
|
172
197
|
clearTimeout(this.cleanupTimer);
|
|
173
|
-
|
|
198
|
+
this.cleanupTimer = undefined;
|
|
199
|
+
if (this.disposed)
|
|
174
200
|
return;
|
|
175
201
|
let next = Infinity;
|
|
202
|
+
const now = this.now();
|
|
203
|
+
const monotonic = this.monotonic();
|
|
204
|
+
const retain = Number.isFinite(this.retention) && this.retention >= 0;
|
|
176
205
|
for (const step of this.active.values()) {
|
|
177
206
|
const settled = step.finishedAt ?? step.reconciledAt;
|
|
178
|
-
if (settled !== undefined)
|
|
179
|
-
next = Math.min(next, settled + this.retention);
|
|
207
|
+
if (retain && settled !== undefined)
|
|
208
|
+
next = Math.min(next, settled + this.retention - now);
|
|
209
|
+
// Local monotonic age, never client epoch minus a remote event timestamp.
|
|
210
|
+
if (step.observedAt !== undefined)
|
|
211
|
+
next = Math.min(next, step.observedAt + LIVE_WINDOW_MS - monotonic);
|
|
180
212
|
}
|
|
181
213
|
for (const sample of this.observed.values()) {
|
|
182
|
-
if (sample.expiresAt !== undefined)
|
|
183
|
-
next = Math.min(next, sample.expiresAt);
|
|
214
|
+
if (retain && sample.expiresAt !== undefined)
|
|
215
|
+
next = Math.min(next, sample.expiresAt - now);
|
|
184
216
|
}
|
|
185
217
|
if (!Number.isFinite(next))
|
|
186
218
|
return;
|
|
@@ -189,7 +221,7 @@ export class PerformanceMonitor {
|
|
|
189
221
|
for (const sessionID of this.prune())
|
|
190
222
|
this.publish(sessionID);
|
|
191
223
|
this.scheduleCleanup();
|
|
192
|
-
}, Math.max(0,
|
|
224
|
+
}, Math.max(0, Math.ceil(next)));
|
|
193
225
|
this.cleanupTimer.unref?.();
|
|
194
226
|
}
|
|
195
227
|
handle(event) {
|
|
@@ -244,6 +276,8 @@ export class PerformanceMonitor {
|
|
|
244
276
|
return false;
|
|
245
277
|
if (typeof data.delta !== "string" || data.delta.length === 0 || !finite(event.created) || event.created < step.started)
|
|
246
278
|
return false;
|
|
279
|
+
if (step.last !== undefined && event.created < step.last)
|
|
280
|
+
return false;
|
|
247
281
|
if (event.id && step.events.has(event.id))
|
|
248
282
|
return false;
|
|
249
283
|
if (event.id)
|
|
@@ -258,19 +292,39 @@ export class PerformanceMonitor {
|
|
|
258
292
|
}
|
|
259
293
|
const sampleBytes = new TextEncoder().encode(data.delta).byteLength;
|
|
260
294
|
step.bytes += sampleBytes;
|
|
261
|
-
step.
|
|
295
|
+
if (step.last !== undefined && event.created - step.last >= LIVE_WINDOW_MS)
|
|
296
|
+
this.resetLive(step);
|
|
297
|
+
const last = step.samples.at(-1);
|
|
298
|
+
if (last && step.bucketStarted !== undefined && event.created - step.bucketStarted < LIVE_SAMPLE_MS) {
|
|
299
|
+
// Include a whole delivery burst in one batch, including the initial baseline.
|
|
300
|
+
last.time = event.created;
|
|
301
|
+
last.bytes += sampleBytes;
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
step.bucketStarted = event.created;
|
|
305
|
+
step.samples.push({ time: event.created, bytes: sampleBytes });
|
|
306
|
+
}
|
|
262
307
|
const cutoff = event.created - LIVE_WINDOW_MS;
|
|
263
308
|
while (step.samples.length > 0 && step.samples[0].time < cutoff)
|
|
264
309
|
step.samples.shift();
|
|
265
|
-
// Advance smoothing once per accepted delta, independently of summary readers.
|
|
266
310
|
const raw = estimatedLiveTps(step.samples);
|
|
267
|
-
if (raw
|
|
268
|
-
|
|
311
|
+
if (raw === undefined) {
|
|
312
|
+
delete step.liveTps;
|
|
313
|
+
delete step.liveDuration;
|
|
314
|
+
delete step.smoothedAt;
|
|
315
|
+
}
|
|
316
|
+
else if (step.smoothedAt === undefined || event.created - step.smoothedAt >= LIVE_SAMPLE_MS) {
|
|
317
|
+
step.liveTps = smoothLiveTps(step.liveTps, raw, event.created - (step.smoothedAt ?? event.created));
|
|
318
|
+
step.liveDuration = event.created - step.samples[0].time;
|
|
319
|
+
step.smoothedAt = event.created;
|
|
320
|
+
}
|
|
321
|
+
step.observedAt = this.monotonic();
|
|
269
322
|
step.last = Math.max(step.last ?? event.created, event.created);
|
|
270
323
|
if (step.first === undefined) {
|
|
271
324
|
step.first = event.created;
|
|
272
325
|
this.observed.set(key, { messageID, sessionID, started: step.started, first: event.created });
|
|
273
326
|
}
|
|
327
|
+
this.scheduleCleanup();
|
|
274
328
|
this.publish(sessionID);
|
|
275
329
|
return true;
|
|
276
330
|
}
|
|
@@ -310,15 +364,15 @@ export class PerformanceMonitor {
|
|
|
310
364
|
// completed provider usage still replaces this estimate via reconcile().
|
|
311
365
|
let weighted = 0;
|
|
312
366
|
let durationTotal = 0;
|
|
367
|
+
const monotonic = this.monotonic();
|
|
313
368
|
for (const step of this.active.values()) {
|
|
314
369
|
if (!sessions.has(step.sessionID) || step.reconciledAt !== undefined)
|
|
315
370
|
continue;
|
|
316
|
-
if (step.liveTps === undefined || step.
|
|
371
|
+
if (step.liveTps === undefined || step.observedAt === undefined
|
|
372
|
+
|| monotonic - step.observedAt >= LIVE_WINDOW_MS)
|
|
317
373
|
continue;
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
const duration = last.time - first.time;
|
|
321
|
-
if (!finite(duration) || duration <= 0)
|
|
374
|
+
const duration = step.liveDuration;
|
|
375
|
+
if (!finite(duration) || duration < LIVE_MIN_SPAN_MS)
|
|
322
376
|
continue;
|
|
323
377
|
weighted += step.liveTps * duration;
|
|
324
378
|
durationTotal += duration;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface RuntimeEvent {
|
|
2
|
+
type: string;
|
|
3
|
+
data: unknown;
|
|
4
|
+
created?: number;
|
|
5
|
+
}
|
|
6
|
+
type Subscribe = (listener: (event: RuntimeEvent) => void) => () => void;
|
|
7
|
+
type Listener = (sessionID?: string) => void;
|
|
8
|
+
type Tick = (now: number | undefined) => void;
|
|
9
|
+
/** Shared server-time anchor and display clock; no wall clock, execution log or periodic reads. */
|
|
10
|
+
export declare class RuntimeMonitor {
|
|
11
|
+
private readonly monotonic;
|
|
12
|
+
private anchor;
|
|
13
|
+
private latest;
|
|
14
|
+
private stopped;
|
|
15
|
+
private listeners;
|
|
16
|
+
private ticks;
|
|
17
|
+
private timer;
|
|
18
|
+
private synchronization;
|
|
19
|
+
private disposed;
|
|
20
|
+
private readonly unsubscribe;
|
|
21
|
+
constructor(subscribe?: Subscribe, monotonic?: () => number);
|
|
22
|
+
now(): number | undefined;
|
|
23
|
+
/** One optional read-only RPC supplies an anchor even when opening a quiet active tool. */
|
|
24
|
+
synchronize(read: (signal: AbortSignal) => Promise<unknown>): Promise<void>;
|
|
25
|
+
listen(listener: Listener): () => void;
|
|
26
|
+
/** Only views actually projecting a live turn keep this single 500ms ticker alive. */
|
|
27
|
+
tick(listener: Tick): () => void;
|
|
28
|
+
canEstimate(sessionID: string, messageID: string): boolean;
|
|
29
|
+
handle(event: RuntimeEvent): void;
|
|
30
|
+
dispose(): void;
|
|
31
|
+
private observe;
|
|
32
|
+
private publish;
|
|
33
|
+
}
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const timestamp = (value) => typeof value === "number" && value >= 0
|
|
2
|
+
&& Number.isFinite(new Date(value).getTime());
|
|
3
|
+
/** Shared server-time anchor and display clock; no wall clock, execution log or periodic reads. */
|
|
4
|
+
export class RuntimeMonitor {
|
|
5
|
+
monotonic;
|
|
6
|
+
anchor;
|
|
7
|
+
latest = new Map();
|
|
8
|
+
stopped = new Set();
|
|
9
|
+
listeners = new Set();
|
|
10
|
+
ticks = new Set();
|
|
11
|
+
timer;
|
|
12
|
+
synchronization;
|
|
13
|
+
disposed = false;
|
|
14
|
+
unsubscribe;
|
|
15
|
+
constructor(subscribe, monotonic = () => performance.now()) {
|
|
16
|
+
this.monotonic = monotonic;
|
|
17
|
+
this.unsubscribe = subscribe?.(event => this.handle(event));
|
|
18
|
+
}
|
|
19
|
+
now() {
|
|
20
|
+
if (!this.anchor)
|
|
21
|
+
return undefined;
|
|
22
|
+
const now = this.anchor.server + Math.max(0, this.monotonic() - this.anchor.local);
|
|
23
|
+
return timestamp(now) ? now : undefined;
|
|
24
|
+
}
|
|
25
|
+
/** One optional read-only RPC supplies an anchor even when opening a quiet active tool. */
|
|
26
|
+
async synchronize(read) {
|
|
27
|
+
if (this.disposed)
|
|
28
|
+
return;
|
|
29
|
+
this.synchronization?.abort();
|
|
30
|
+
const request = new AbortController();
|
|
31
|
+
this.synchronization = request;
|
|
32
|
+
const start = this.monotonic();
|
|
33
|
+
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(5_000)]);
|
|
34
|
+
try {
|
|
35
|
+
const server = await read(signal);
|
|
36
|
+
if (this.disposed || signal.aborted || !timestamp(server))
|
|
37
|
+
return;
|
|
38
|
+
// Midpoint correction bounds the initial network-delay estimate without
|
|
39
|
+
// ever assuming the client and a remote server share an epoch clock.
|
|
40
|
+
this.observe(server + Math.max(0, this.monotonic() - start) / 2);
|
|
41
|
+
this.publish();
|
|
42
|
+
}
|
|
43
|
+
catch { /* Without a trusted anchor, retain native totals until a live event arrives. */ }
|
|
44
|
+
finally {
|
|
45
|
+
request.abort();
|
|
46
|
+
if (this.synchronization === request)
|
|
47
|
+
this.synchronization = undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
listen(listener) {
|
|
51
|
+
if (this.disposed)
|
|
52
|
+
return () => { };
|
|
53
|
+
this.listeners.add(listener);
|
|
54
|
+
return () => { this.listeners.delete(listener); };
|
|
55
|
+
}
|
|
56
|
+
/** Only views actually projecting a live turn keep this single 500ms ticker alive. */
|
|
57
|
+
tick(listener) {
|
|
58
|
+
if (this.disposed)
|
|
59
|
+
return () => { };
|
|
60
|
+
this.ticks.add(listener);
|
|
61
|
+
this.timer ??= setInterval(() => {
|
|
62
|
+
const now = this.now();
|
|
63
|
+
for (const tick of this.ticks)
|
|
64
|
+
tick(now);
|
|
65
|
+
}, 500);
|
|
66
|
+
this.timer.unref?.();
|
|
67
|
+
return () => {
|
|
68
|
+
this.ticks.delete(listener);
|
|
69
|
+
if (!this.ticks.size) {
|
|
70
|
+
clearInterval(this.timer);
|
|
71
|
+
this.timer = undefined;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
canEstimate(sessionID, messageID) {
|
|
76
|
+
const latest = this.latest.get(sessionID);
|
|
77
|
+
return !this.disposed && !this.stopped.has(sessionID)
|
|
78
|
+
&& (!latest || (latest.messageID === messageID && !latest.settled));
|
|
79
|
+
}
|
|
80
|
+
handle(event) {
|
|
81
|
+
if (this.disposed)
|
|
82
|
+
return;
|
|
83
|
+
if (event.type === "server.connected") {
|
|
84
|
+
this.anchor = undefined;
|
|
85
|
+
this.latest.clear();
|
|
86
|
+
this.stopped.clear();
|
|
87
|
+
}
|
|
88
|
+
const firstAnchor = !this.anchor && timestamp(event.created);
|
|
89
|
+
if (timestamp(event.created))
|
|
90
|
+
this.observe(event.created);
|
|
91
|
+
const data = (event.data && typeof event.data === "object" ? event.data : {});
|
|
92
|
+
const sessionID = data.sessionID;
|
|
93
|
+
if (event.type === "global.disposed" || event.type === "location.shutdown") {
|
|
94
|
+
this.anchor = undefined;
|
|
95
|
+
this.publish();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (sessionID) {
|
|
99
|
+
const latest = this.latest.get(sessionID);
|
|
100
|
+
// Late/replayed endings cannot stop a newer step.
|
|
101
|
+
const older = timestamp(event.created) && timestamp(latest?.created) && event.created < latest.created;
|
|
102
|
+
if (event.type === "session.deleted") {
|
|
103
|
+
this.latest.delete(sessionID);
|
|
104
|
+
this.stopped.add(sessionID);
|
|
105
|
+
this.publish(sessionID);
|
|
106
|
+
}
|
|
107
|
+
else if (!older && event.type === "session.step.started" && typeof data.assistantMessageID === "string") {
|
|
108
|
+
this.latest.set(sessionID, { messageID: data.assistantMessageID, settled: false,
|
|
109
|
+
...(timestamp(event.created) ? { created: event.created } : {}) });
|
|
110
|
+
this.stopped.delete(sessionID);
|
|
111
|
+
this.publish(sessionID);
|
|
112
|
+
}
|
|
113
|
+
else if (!older && (event.type === "session.step.failed"
|
|
114
|
+
|| (event.type === "session.step.ended" && data.finish && !["tool-calls", "unknown"].includes(data.finish)))) {
|
|
115
|
+
if (typeof data.assistantMessageID === "string" && (!latest || latest.messageID === data.assistantMessageID)) {
|
|
116
|
+
this.latest.set(sessionID, { messageID: data.assistantMessageID, settled: true,
|
|
117
|
+
...(timestamp(event.created) ? { created: event.created } : {}) });
|
|
118
|
+
this.publish(sessionID);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else if (!older && event.type === "session.retry.scheduled" && latest && latest.messageID === data.assistantMessageID) {
|
|
122
|
+
latest.settled = false;
|
|
123
|
+
this.publish(sessionID);
|
|
124
|
+
}
|
|
125
|
+
else if (!older && ["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted", "session.idle"].includes(event.type)) {
|
|
126
|
+
this.stopped.add(sessionID);
|
|
127
|
+
this.publish(sessionID);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (firstAnchor || event.type === "server.connected")
|
|
131
|
+
this.publish();
|
|
132
|
+
}
|
|
133
|
+
dispose() {
|
|
134
|
+
if (this.disposed)
|
|
135
|
+
return;
|
|
136
|
+
this.disposed = true;
|
|
137
|
+
this.synchronization?.abort();
|
|
138
|
+
this.unsubscribe?.();
|
|
139
|
+
clearInterval(this.timer);
|
|
140
|
+
this.timer = undefined;
|
|
141
|
+
this.anchor = undefined;
|
|
142
|
+
this.listeners.clear();
|
|
143
|
+
this.ticks.clear();
|
|
144
|
+
this.latest.clear();
|
|
145
|
+
this.stopped.clear();
|
|
146
|
+
}
|
|
147
|
+
observe(server) {
|
|
148
|
+
// Do not move the live estimate backwards for old/duplicated stream events.
|
|
149
|
+
if (!timestamp(server))
|
|
150
|
+
return;
|
|
151
|
+
this.anchor = { server: Math.max(this.now() ?? server, server), local: this.monotonic() };
|
|
152
|
+
}
|
|
153
|
+
publish(sessionID) { for (const listener of this.listeners)
|
|
154
|
+
listener(sessionID); }
|
|
155
|
+
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -3,6 +3,17 @@ export interface RuntimeSummary {
|
|
|
3
3
|
status: "loading" | "ready" | "stale" | "unavailable";
|
|
4
4
|
/** Sum of one native response duration per turn in the viewed session's retained history. */
|
|
5
5
|
milliseconds?: number | undefined;
|
|
6
|
+
/** Display-only projection; never becomes part of the native historical subtotal. */
|
|
7
|
+
estimatedMilliseconds?: number | undefined;
|
|
8
|
+
}
|
|
9
|
+
export interface PreparedRuntime {
|
|
10
|
+
summary: RuntimeSummary;
|
|
11
|
+
/** Only a response/continuation at the open tail can be projected while the host is running. */
|
|
12
|
+
turn?: {
|
|
13
|
+
messageID: string;
|
|
14
|
+
started: number;
|
|
15
|
+
milliseconds: number;
|
|
16
|
+
};
|
|
6
17
|
}
|
|
7
18
|
/**
|
|
8
19
|
* OpenCode 2.0.26's TUI turnDuration/inputIndex: completed assistant time minus
|
|
@@ -13,3 +24,7 @@ export interface RuntimeSummary {
|
|
|
13
24
|
* No execution-log API, client clock, child history, or additional reads.
|
|
14
25
|
*/
|
|
15
26
|
export declare function summarizeRuntime(messages: readonly UsageMessage[], fork?: boolean): RuntimeSummary;
|
|
27
|
+
/** Scan history once per snapshot, keeping the current turn's overlapping subtotal separate. */
|
|
28
|
+
export declare function prepareRuntime(messages: readonly UsageMessage[], fork?: boolean): PreparedRuntime;
|
|
29
|
+
/** Replace, rather than add to, an already-counted footer in the same steered turn. */
|
|
30
|
+
export declare function projectRuntime(prepared: PreparedRuntime, now: number | undefined): RuntimeSummary;
|
package/dist/runtime.js
CHANGED
|
@@ -9,6 +9,10 @@ const timestamp = (value) => typeof value === "number" && value >= 0
|
|
|
9
9
|
* No execution-log API, client clock, child history, or additional reads.
|
|
10
10
|
*/
|
|
11
11
|
export function summarizeRuntime(messages, fork = false) {
|
|
12
|
+
return prepareRuntime(messages, fork).summary;
|
|
13
|
+
}
|
|
14
|
+
/** Scan history once per snapshot, keeping the current turn's overlapping subtotal separate. */
|
|
15
|
+
export function prepareRuntime(messages, fork = false) {
|
|
12
16
|
// Even a copied marker identifies the host's turn semantics for an open fork.
|
|
13
17
|
const legacy = !messages.some(message => message.type === "idle");
|
|
14
18
|
// Fork copies belong to the original session, including copied turn boundaries.
|
|
@@ -16,6 +20,8 @@ export function summarizeRuntime(messages, fork = false) {
|
|
|
16
20
|
let milliseconds = 0, unavailable = false;
|
|
17
21
|
let input;
|
|
18
22
|
let response, completedResponse;
|
|
23
|
+
let lastAssistant;
|
|
24
|
+
let turnMilliseconds = 0;
|
|
19
25
|
const finish = (closed) => {
|
|
20
26
|
if (!response)
|
|
21
27
|
return;
|
|
@@ -40,23 +46,29 @@ export function summarizeRuntime(messages, fork = false) {
|
|
|
40
46
|
unavailable = true;
|
|
41
47
|
return;
|
|
42
48
|
}
|
|
43
|
-
|
|
49
|
+
turnMilliseconds = Math.max(0, completed - created);
|
|
50
|
+
milliseconds += turnMilliseconds;
|
|
44
51
|
};
|
|
45
52
|
for (const message of history) {
|
|
46
53
|
if (message.type === "idle") {
|
|
47
54
|
finish(true);
|
|
48
55
|
input = undefined;
|
|
49
56
|
response = completedResponse = undefined;
|
|
57
|
+
lastAssistant = undefined;
|
|
58
|
+
turnMilliseconds = 0;
|
|
50
59
|
}
|
|
51
60
|
else if (message.type === "user" || message.type === "synthetic") {
|
|
52
61
|
if (legacy) {
|
|
53
62
|
finish(true);
|
|
54
63
|
input = undefined;
|
|
55
64
|
response = completedResponse = undefined;
|
|
65
|
+
lastAssistant = undefined;
|
|
66
|
+
turnMilliseconds = 0;
|
|
56
67
|
}
|
|
57
68
|
input ??= message;
|
|
58
69
|
}
|
|
59
70
|
else if (message.type === "assistant") {
|
|
71
|
+
lastAssistant = message;
|
|
60
72
|
// Matches native footer eligibility. Older imported messages can lack
|
|
61
73
|
// finish metadata; their completed timestamp still permits a duration.
|
|
62
74
|
if (!message.error && !message.retry && (message.finish === "tool-calls" || message.finish === "unknown"))
|
|
@@ -67,6 +79,25 @@ export function summarizeRuntime(messages, fork = false) {
|
|
|
67
79
|
}
|
|
68
80
|
}
|
|
69
81
|
finish(false);
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
if (unavailable || !Number.isFinite(milliseconds))
|
|
83
|
+
return { summary: { status: "unavailable" } };
|
|
84
|
+
const summary = { status: "ready", milliseconds };
|
|
85
|
+
const started = input ? input.time?.created : lastAssistant?.time?.created;
|
|
86
|
+
// Tool steps and scheduled retries can be completed without ending the turn.
|
|
87
|
+
// A terminal response must not keep growing during the later idle/cleanup transition.
|
|
88
|
+
const continuing = lastAssistant && (lastAssistant.retry || (!lastAssistant.error
|
|
89
|
+
&& (lastAssistant.finish === "tool-calls" || lastAssistant.finish === "unknown"
|
|
90
|
+
|| (!lastAssistant.finish && lastAssistant.time?.completed === undefined))));
|
|
91
|
+
if (!lastAssistant || !continuing || !timestamp(started) || !timestamp(lastAssistant.time?.created))
|
|
92
|
+
return { summary };
|
|
93
|
+
return { summary, turn: { messageID: lastAssistant.id, started, milliseconds: turnMilliseconds } };
|
|
94
|
+
}
|
|
95
|
+
/** Replace, rather than add to, an already-counted footer in the same steered turn. */
|
|
96
|
+
export function projectRuntime(prepared, now) {
|
|
97
|
+
const { summary, turn } = prepared;
|
|
98
|
+
if (summary.status !== "ready" || summary.milliseconds === undefined || !turn || !timestamp(now))
|
|
99
|
+
return summary;
|
|
100
|
+
const elapsed = Math.max(turn.milliseconds, now - turn.started, 0);
|
|
101
|
+
const estimatedMilliseconds = Math.round(summary.milliseconds - turn.milliseconds + elapsed);
|
|
102
|
+
return Number.isFinite(estimatedMilliseconds) ? { ...summary, estimatedMilliseconds } : summary;
|
|
72
103
|
}
|