@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.
@@ -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
- /** Raw live throughput from one step's recent observable deltas. */
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 <= 0)
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
- bytes += sample.bytes;
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
- export function smoothLiveTps(previous, raw) {
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
- return previous * (1 - LIVE_EWMA_ALPHA) + raw * LIVE_EWMA_ALPHA;
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
- if (this.disposed || !Number.isFinite(this.retention) || this.retention < 0)
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, next - this.now()));
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.samples.push({ time: event.created, bytes: sampleBytes });
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 !== undefined)
268
- step.liveTps = smoothLiveTps(step.liveTps, raw);
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.samples.length < 2)
371
+ if (step.liveTps === undefined || step.observedAt === undefined
372
+ || monotonic - step.observedAt >= LIVE_WINDOW_MS)
317
373
  continue;
318
- const first = step.samples[0];
319
- const last = step.samples[step.samples.length - 1];
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
- milliseconds += Math.max(0, completed - created);
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
- return unavailable || !Number.isFinite(milliseconds)
71
- ? { status: "unavailable" } : { status: "ready", milliseconds };
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
  }