@celestea/runtime 2.7.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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/agent-config.d.ts +18 -0
  4. package/dist/agent-config.js +31 -0
  5. package/dist/autowake.d.ts +141 -0
  6. package/dist/autowake.js +262 -0
  7. package/dist/compact/index.d.ts +13 -0
  8. package/dist/compact/index.js +13 -0
  9. package/dist/compact/plan.d.ts +51 -0
  10. package/dist/compact/plan.js +98 -0
  11. package/dist/compact/rewrite.d.ts +23 -0
  12. package/dist/compact/rewrite.js +79 -0
  13. package/dist/compact/run.d.ts +44 -0
  14. package/dist/compact/run.js +59 -0
  15. package/dist/compact/summarize.d.ts +30 -0
  16. package/dist/compact/summarize.js +70 -0
  17. package/dist/compact/transcript.d.ts +35 -0
  18. package/dist/compact/transcript.js +88 -0
  19. package/dist/compose.d.ts +117 -0
  20. package/dist/compose.js +191 -0
  21. package/dist/errors.d.ts +25 -0
  22. package/dist/errors.js +34 -0
  23. package/dist/frames.d.ts +46 -0
  24. package/dist/frames.js +62 -0
  25. package/dist/gen.d.ts +86 -0
  26. package/dist/gen.js +129 -0
  27. package/dist/host/engine-session.d.ts +117 -0
  28. package/dist/host/engine-session.js +109 -0
  29. package/dist/host/index.d.ts +39 -0
  30. package/dist/host/index.js +39 -0
  31. package/dist/host/provider-target.d.ts +113 -0
  32. package/dist/host/provider-target.js +116 -0
  33. package/dist/inbox-checkpoint.d.ts +18 -0
  34. package/dist/inbox-checkpoint.js +37 -0
  35. package/dist/inbox.d.ts +94 -0
  36. package/dist/inbox.js +139 -0
  37. package/dist/index.d.ts +71 -0
  38. package/dist/index.js +71 -0
  39. package/dist/ledger-io.d.ts +27 -0
  40. package/dist/ledger-io.js +74 -0
  41. package/dist/ledger-llm.d.ts +48 -0
  42. package/dist/ledger-llm.js +115 -0
  43. package/dist/ledger-query.d.ts +91 -0
  44. package/dist/ledger-query.js +153 -0
  45. package/dist/ledger.d.ts +271 -0
  46. package/dist/ledger.js +444 -0
  47. package/dist/pricing.d.ts +100 -0
  48. package/dist/pricing.js +167 -0
  49. package/dist/profile.d.ts +26 -0
  50. package/dist/profile.js +39 -0
  51. package/dist/recovery.d.ts +56 -0
  52. package/dist/recovery.js +91 -0
  53. package/dist/retention.d.ts +49 -0
  54. package/dist/retention.js +119 -0
  55. package/dist/runtime.d.ts +197 -0
  56. package/dist/runtime.js +347 -0
  57. package/dist/sanitize.d.ts +35 -0
  58. package/dist/sanitize.js +36 -0
  59. package/dist/session-binding.d.ts +36 -0
  60. package/dist/session-binding.js +33 -0
  61. package/dist/session-registry.d.ts +238 -0
  62. package/dist/session-registry.js +388 -0
  63. package/dist/status.d.ts +279 -0
  64. package/dist/status.js +411 -0
  65. package/dist/tokens.d.ts +25 -0
  66. package/dist/tokens.js +25 -0
  67. package/dist/turn-runner.d.ts +169 -0
  68. package/dist/turn-runner.js +242 -0
  69. package/dist/usage.d.ts +64 -0
  70. package/dist/usage.js +88 -0
  71. package/dist/watchdog-mount.d.ts +79 -0
  72. package/dist/watchdog-mount.js +120 -0
  73. package/dist/worker-wiring.d.ts +74 -0
  74. package/dist/worker-wiring.js +107 -0
  75. package/package.json +31 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Statusline tracker — port of `celestea_studio/src/main.rs:253-330` (W218/W263).
3
+ *
4
+ * Two live counters, fed by the turn sink:
5
+ * - `steps` one per tool CALL (its `tool_result` closes that step, so a
6
+ * result never doubles the count — W263 semantics, equal to the
7
+ * frontend's per-turn tool counter);
8
+ * - `rate` the `tokens_per_sec` estimate over text/thinking deltas: the
9
+ * 5s sliding-window rate (W754) while the window still carries
10
+ * output, else the mean over the turn's ACTIVE intervals (W763).
11
+ *
12
+ * W754 rate semantics (the window half): the rate averages over ACTIVE intervals
13
+ * only. Two deltas farther apart than `GAP_MS` (1s) bound a no-flow break (long
14
+ * tool call, rate limit stall, idle tail of a finished turn), and that break is
15
+ * NOT part of the denominator — a wall-clock span over the whole window would
16
+ * otherwise dilute the rate towards zero. The denominator is the sum of the
17
+ * per-interval spans, each floored at `MIN_ACTIVE_MS` (1s) so a lone burst
18
+ * cannot report a silly rate.
19
+ *
20
+ * W763 turn semantics (the fallback half): the window answer is the responsive
21
+ * one (an observed stream read 570 -> 1003 -> 760 tok/s), but an EMPTY window —
22
+ * a stall longer than 5s, or the plain end of a turn — used to report 0 and stay
23
+ * there, which the operator rejected. So the window rate is reported only while
24
+ * it is positive; otherwise the rate is the mean over the whole TURN's active
25
+ * intervals ([turnRate]), a stable positive number until the next `beginTurn()`.
26
+ * 0 therefore means exactly one thing: this turn has produced no delta yet (TTFT).
27
+ *
28
+ * Unit note: what is computed here is CHARACTERS per second, not tokens. The
29
+ * field has always been named `tokens_per_sec` (frozen contract) and the
30
+ * frontend renders it as an approximate ~1:1 token rate, so the unit semantics
31
+ * are deliberately unchanged by W754 — only the denominator changed.
32
+ *
33
+ * W755 context-usage口径 (aligned with the DSH host's `contextPressure`):
34
+ * 1. a provider usage frame has been seen -> `used` = that request's REAL
35
+ * `prompt_tokens` (input + cache, never output) PLUS the model-visible
36
+ * growth measured since the sample, so the number answers for the NEXT
37
+ * request instead of the last one (`projected:true`);
38
+ * 2. no frame yet, but the loop hands back its own assembly -> `used` = the
39
+ * token estimate of THAT request (system + trimmed history + tool
40
+ * schemas), never of the raw session log (`assembled_estimate`);
41
+ * 3. neither -> `used` = 0 / `method:"none"`: the UI shows "unknown" rather
42
+ * than a made-up ratio.
43
+ * The session log's CHARACTER count is never reported as a token count again
44
+ * ([estimatedContextChars] survives as an internal/debug helper only), and a
45
+ * missing context window is reported as `window:0, ratio:0` — the 1,000,000
46
+ * display fallback never enters a ratio (W755, Fix A/B/C).
47
+ *
48
+ * W766: branch 2 reads a MEMOIZED estimate ([AssembledContext]): the runtime
49
+ * derives it once per log state, so an idle session's 2s status tick is a lookup
50
+ * instead of an O(bytes) re-estimate. No口径 value changes — the same function
51
+ * produces the same number, just not on every tick.
52
+ *
53
+ * `now()` is injectable so the rate window is testable without sleeping.
54
+ */
55
+ import type { ModelRequest, SessionEvent, Statusline, TurnOutcome } from "@celestea/core";
56
+ import { type UsageAccounting } from "./usage.js";
57
+ /** W218: sliding-window length for `tokens_per_sec`. */
58
+ export declare const RATE_WINDOW_MS = 5000;
59
+ /** W754: deltas farther apart than this bound a no-flow break — excluded from the rate denominator. */
60
+ export declare const GAP_MS = 1000;
61
+ /** W754: minimum duration credited to one activity interval (the old "1s floor" spirit, without counting stalls). */
62
+ export declare const MIN_ACTIVE_MS = 1000;
63
+ /** W218: cadence of the SSE status "progress" events during a turn. */
64
+ export declare const STATUS_TICK_MS = 2000;
65
+ /** One output delta: when it landed and how many chars it carried. */
66
+ export interface RateSample {
67
+ at: number;
68
+ chars: number;
69
+ }
70
+ export declare class StatusTracker {
71
+ private steps;
72
+ private samples;
73
+ /** W763: the turn's COMPRESSED activity intervals (grows with stoppages, never with deltas). */
74
+ private segments;
75
+ private readonly now;
76
+ constructor(now?: () => number);
77
+ /** New-turn baseline: clear the step counter, the rate window and the turn's intervals (W763). */
78
+ beginTurn(): void;
79
+ /** Record one step (one tool call). */
80
+ addStep(): void;
81
+ /** Record one output delta (text/thinking) into the rate window and the turn's intervals (W763). */
82
+ addChars(chars: number): void;
83
+ /**
84
+ * W763: activity intervals held for the current turn. Diagnostic/test hook —
85
+ * an uninterrupted stream is ONE segment no matter how many deltas it carries.
86
+ */
87
+ get turnSegmentCount(): number;
88
+ /** Steps recorded in the current turn. */
89
+ get stepCount(): number;
90
+ /**
91
+ * Chars-per-second estimate (W754 + W763): the responsive window rate while
92
+ * the 5s window still carries output, otherwise the turn's active-interval
93
+ * mean. 0 only while this turn has not produced a single delta (TTFT).
94
+ */
95
+ rate(): number;
96
+ private trim;
97
+ }
98
+ /**
99
+ * W754: total ACTIVE milliseconds covered by the samples (the rate denominator).
100
+ *
101
+ * Samples are walked in time order; every adjacent pair closer than `GAP_MS`
102
+ * stays in the same activity interval, a wider step closes the interval and
103
+ * opens a new one (the break itself contributes nothing). The final interval is
104
+ * extended to `now` only while the stream still looks alive (`now` within
105
+ * `GAP_MS` of the last delta), so a stall does not keep inflating the
106
+ * denominator. Each interval is credited at least `MIN_ACTIVE_MS`.
107
+ */
108
+ export declare function activeSpanMs(samples: readonly RateSample[], now: number): number;
109
+ /**
110
+ * W763: one COMPRESSED activity interval of the current turn. Consecutive deltas
111
+ * closer than `GAP_MS` collapse into a single record, so this list grows with
112
+ * the number of stoppages — never with the number of deltas (10k deltas without
113
+ * a pause is ONE segment), which is what keeps the whole turn in O(pauses)
114
+ * memory instead of O(deltas).
115
+ */
116
+ export interface TurnSegment {
117
+ /** Wall clock of the interval's first delta (ms). */
118
+ start: number;
119
+ /** Wall clock of the interval's last delta (ms) — the segment's live edge. */
120
+ at: number;
121
+ /** Characters carried by every delta of this interval. */
122
+ chars: number;
123
+ }
124
+ /** W763: fold one delta into the turn's segments (O(1); allocates only when a pause opens a new interval). */
125
+ export declare function pushTurnDelta(segments: TurnSegment[], at: number, chars: number): void;
126
+ /**
127
+ * W763: Σ ACTIVE ms over the turn's segments — the same flooring rule as
128
+ * [activeSpanMs] (every interval ≥ `MIN_ACTIVE_MS`) and the same guard that only
129
+ * extends the OPEN interval while the stream is still alive, so neither a stall
130
+ * nor a finished turn can inflate the denominator.
131
+ */
132
+ export declare function turnSpanMs(segments: readonly TurnSegment[], now: number): number;
133
+ /**
134
+ * W763: chars/s averaged over the turn's ACTIVE intervals — the "有流时段均值" the
135
+ * operator asked for. 0 iff the turn has produced no delta at all yet.
136
+ */
137
+ export declare function turnRate(segments: readonly TurnSegment[], now: number): number;
138
+ /** Factory form (ARCHITECTURE.md §6.1). */
139
+ export declare function createStatusTracker(now?: () => number): StatusTracker;
140
+ /**
141
+ * W755: which source backs `context_usage.window`.
142
+ * - `profile` the session's profile declared a real capacity -> `ratio` is real;
143
+ * - `fallback` the profile declares none (0 = trimming off), so the only number
144
+ * left is the contract's DISPLAY default (`CONTEXT_WINDOW_FALLBACK`)
145
+ * owned by the frontend — `window` stays 0 and no ratio is drawn;
146
+ * - `unknown` the configured value is not a usable number at all.
147
+ */
148
+ export type ContextWindowSource = "profile" | "fallback" | "unknown";
149
+ /**
150
+ * W755 (Fix B): the forward-looking correction of the provider's prompt sample.
151
+ *
152
+ * DSH computes `projectedTokens = pressureTokens + surfaceTokens - sampledSurfaceTokens`
153
+ * so that occupancy answers for the NEXT request rather than the last one
154
+ * (`dsh-token-meter/lib/types/usage-projection.js:178-187`). Same shape here:
155
+ * remember the model-visible estimate observed when a NEW `prompt_tokens` value
156
+ * arrived, and add whatever the visible surface has grown by since.
157
+ *
158
+ * Only growth counts: a shrinking surface (a trim/compaction) never pulls the
159
+ * number BELOW the provider's own measurement — that anchor is a fact.
160
+ *
161
+ * The state is PER SESSION (one instance lives on the session's [StatusView]),
162
+ * never a module singleton and never a timer. A fresh instance per call just
163
+ * degrades to "no projection", which is correct, only lagging.
164
+ */
165
+ export declare class ContextPressure {
166
+ private sampledPrompt;
167
+ private assembledAtSample;
168
+ /**
169
+ * Note the (real prompt, visible estimate) pair of this observation. A prompt
170
+ * value already sampled keeps its original anchor, which is exactly what makes
171
+ * the surface growth measurable; a new value re-anchors.
172
+ */
173
+ observe(promptTokens: number, assembledTokens: number | null): void;
174
+ /**
175
+ * `used` for this observation: the real prompt plus the visible growth since
176
+ * it was sampled. Without either half of the anchor it is just the prompt
177
+ * (`projected:false` — nothing forward-looking was measurable).
178
+ */
179
+ project(promptTokens: number, assembledTokens: number | null): {
180
+ used: number;
181
+ projected: boolean;
182
+ };
183
+ }
184
+ /** Everything the statusline needs, so a turn task can snapshot it without a request path. */
185
+ export interface StatusView {
186
+ model: string;
187
+ reasoning_effort: string | null;
188
+ status: StatusTracker;
189
+ usage: UsageAccounting;
190
+ /** Live profile context window (0 = trimming off -> contract display default). */
191
+ context_window: number;
192
+ /** The current session log's events (a rebind swaps the producer, not the view). */
193
+ events: () => readonly SessionEvent[];
194
+ /**
195
+ * W755 (Fix A): the model-visible request the NEXT step would build — the
196
+ * loop's OWN assembly (system + trimmed history + tool schemas) — or null when
197
+ * the mounted loop cannot snapshot (test doubles). This — not the raw log —
198
+ * is what the context usage falls back to.
199
+ *
200
+ * W766: the estimate travels WITH the request ([AssembledContext]), because the
201
+ * runtime memoizes both on the log state that produced them. A tick that
202
+ * re-reads an unchanged log therefore costs one lookup, not another O(bytes)
203
+ * walk of the messages.
204
+ */
205
+ assembled: () => AssembledContext | null;
206
+ /**
207
+ * W755 (Fix B): the per-session projection state. MUST be the same instance on
208
+ * every call for one session (a fresh one per call silently disables the
209
+ * forward-looking correction).
210
+ */
211
+ pressure: ContextPressure;
212
+ }
213
+ /**
214
+ * W766: a model-visible request TOGETHER with its token estimate.
215
+ *
216
+ * The two are one value on purpose: [estimatedContextTokens] is O(total bytes),
217
+ * the runtime memoizes the assembly on the session log's state, and a statusline
218
+ * tick must not re-walk a request it was already handed. Bundling the estimate
219
+ * with the request gives it the SAME lifetime and the SAME invalidation key as
220
+ * what it describes — there is no second cache to keep in sync
221
+ * (`Runtime.assembledContext()`).
222
+ */
223
+ export interface AssembledContext {
224
+ request: ModelRequest;
225
+ /** [estimatedContextTokens] of `request`, computed once per assembly. */
226
+ tokens: number;
227
+ }
228
+ /**
229
+ * W755 (Fix A): token estimate of a MODEL-VISIBLE request — the very estimator
230
+ * the loop's trim budget already uses (`packages/agent-loop/src/context-trim.ts`),
231
+ * reused rather than re-derived, so the two can never disagree: system text +
232
+ * every message (content + structural overhead) + the tool schemas.
233
+ *
234
+ * W766: callers on a hot path should obtain the value through a memoized
235
+ * [AssembledContext] instead of calling this again on an unchanged request.
236
+ */
237
+ export declare function estimatedContextTokens(request: ModelRequest): number;
238
+ /**
239
+ * W766: pair a request with its estimate — for callers that build a view by hand
240
+ * (tests, the bench) and therefore have no runtime cache to read it from.
241
+ */
242
+ export declare function assembledContextOf(request: ModelRequest): AssembledContext;
243
+ /** W263: used/window ratio, clamped to [0,1], rounded to 4 decimals. */
244
+ export declare function ratio4(used: number, window: number): number;
245
+ /**
246
+ * W218 character volume of the session log (user/assistant text + tool call
247
+ * id/name/args + tool result value/error). Thinking deltas and turn markers
248
+ * carry no model-visible history and are free.
249
+ *
250
+ * W755: THIS IS A CHARACTER COUNT, NOT A TOKEN COUNT, and it is no longer part
251
+ * of any reported口径 — `contextUsage` never reports it (the old
252
+ * `session_event_chars` method divided it by a TOKEN window, which over-reported
253
+ * by ~4x for latin text and inflated further for CJK). Kept only as an internal
254
+ * / debug scale reference (and as the regression anchor pinning the fix).
255
+ */
256
+ export declare function estimatedContextChars(events: readonly SessionEvent[]): number;
257
+ /**
258
+ * W755: the context usage, in the DSH口径 — real provider prompt first, else the
259
+ * model-visible assembly's token estimate, else an honest "unknown" (never the
260
+ * session log's character count; see [estimatedContextChars]).
261
+ */
262
+ export declare function contextUsage(view: StatusView): Statusline["context_usage"];
263
+ /**
264
+ * W755: the statusline of a session with NO live generation yet (the studio
265
+ * adapter's "empty one"). Nothing is measurable there — no provider frame, no
266
+ * engine assembly, no events — so it reports the honest `method:"none"` branch
267
+ * (`used:0`) instead of a made-up ratio. A FRESH [ContextPressure] is correct
268
+ * here: with no usage frame no prompt sample is ever observed through it.
269
+ */
270
+ export declare function coldStatusline(input: {
271
+ model: string;
272
+ reasoning_effort: string | null;
273
+ context_window: number;
274
+ now?: () => number;
275
+ }): Statusline;
276
+ /** `statusline_of` — the frozen `/api/status` payload. */
277
+ export declare function statuslineOf(view: StatusView): Statusline;
278
+ /** Terminal outcome -> SSE status phase (the frozen vocabulary). */
279
+ export declare function outcomePhaseOf(outcome: TurnOutcome): string;
package/dist/status.js ADDED
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Statusline tracker — port of `celestea_studio/src/main.rs:253-330` (W218/W263).
3
+ *
4
+ * Two live counters, fed by the turn sink:
5
+ * - `steps` one per tool CALL (its `tool_result` closes that step, so a
6
+ * result never doubles the count — W263 semantics, equal to the
7
+ * frontend's per-turn tool counter);
8
+ * - `rate` the `tokens_per_sec` estimate over text/thinking deltas: the
9
+ * 5s sliding-window rate (W754) while the window still carries
10
+ * output, else the mean over the turn's ACTIVE intervals (W763).
11
+ *
12
+ * W754 rate semantics (the window half): the rate averages over ACTIVE intervals
13
+ * only. Two deltas farther apart than `GAP_MS` (1s) bound a no-flow break (long
14
+ * tool call, rate limit stall, idle tail of a finished turn), and that break is
15
+ * NOT part of the denominator — a wall-clock span over the whole window would
16
+ * otherwise dilute the rate towards zero. The denominator is the sum of the
17
+ * per-interval spans, each floored at `MIN_ACTIVE_MS` (1s) so a lone burst
18
+ * cannot report a silly rate.
19
+ *
20
+ * W763 turn semantics (the fallback half): the window answer is the responsive
21
+ * one (an observed stream read 570 -> 1003 -> 760 tok/s), but an EMPTY window —
22
+ * a stall longer than 5s, or the plain end of a turn — used to report 0 and stay
23
+ * there, which the operator rejected. So the window rate is reported only while
24
+ * it is positive; otherwise the rate is the mean over the whole TURN's active
25
+ * intervals ([turnRate]), a stable positive number until the next `beginTurn()`.
26
+ * 0 therefore means exactly one thing: this turn has produced no delta yet (TTFT).
27
+ *
28
+ * Unit note: what is computed here is CHARACTERS per second, not tokens. The
29
+ * field has always been named `tokens_per_sec` (frozen contract) and the
30
+ * frontend renders it as an approximate ~1:1 token rate, so the unit semantics
31
+ * are deliberately unchanged by W754 — only the denominator changed.
32
+ *
33
+ * W755 context-usage口径 (aligned with the DSH host's `contextPressure`):
34
+ * 1. a provider usage frame has been seen -> `used` = that request's REAL
35
+ * `prompt_tokens` (input + cache, never output) PLUS the model-visible
36
+ * growth measured since the sample, so the number answers for the NEXT
37
+ * request instead of the last one (`projected:true`);
38
+ * 2. no frame yet, but the loop hands back its own assembly -> `used` = the
39
+ * token estimate of THAT request (system + trimmed history + tool
40
+ * schemas), never of the raw session log (`assembled_estimate`);
41
+ * 3. neither -> `used` = 0 / `method:"none"`: the UI shows "unknown" rather
42
+ * than a made-up ratio.
43
+ * The session log's CHARACTER count is never reported as a token count again
44
+ * ([estimatedContextChars] survives as an internal/debug helper only), and a
45
+ * missing context window is reported as `window:0, ratio:0` — the 1,000,000
46
+ * display fallback never enters a ratio (W755, Fix A/B/C).
47
+ *
48
+ * W766: branch 2 reads a MEMOIZED estimate ([AssembledContext]): the runtime
49
+ * derives it once per log state, so an idle session's 2s status tick is a lookup
50
+ * instead of an O(bytes) re-estimate. No口径 value changes — the same function
51
+ * produces the same number, just not on every tick.
52
+ *
53
+ * `now()` is injectable so the rate window is testable without sleeping.
54
+ */
55
+ import { estimateMessagesTokens, estimateTokens } from "@celestea/agent-loop";
56
+ import { createUsageTracker, usageStatus } from "./usage.js";
57
+ /** W218: sliding-window length for `tokens_per_sec`. */
58
+ export const RATE_WINDOW_MS = 5_000;
59
+ /** W754: deltas farther apart than this bound a no-flow break — excluded from the rate denominator. */
60
+ export const GAP_MS = 1_000;
61
+ /** W754: minimum duration credited to one activity interval (the old "1s floor" spirit, without counting stalls). */
62
+ export const MIN_ACTIVE_MS = 1_000;
63
+ /** W218: cadence of the SSE status "progress" events during a turn. */
64
+ export const STATUS_TICK_MS = 2_000;
65
+ export class StatusTracker {
66
+ steps = 0;
67
+ samples = [];
68
+ /** W763: the turn's COMPRESSED activity intervals (grows with stoppages, never with deltas). */
69
+ segments = [];
70
+ now;
71
+ constructor(now = Date.now) {
72
+ this.now = now;
73
+ }
74
+ /** New-turn baseline: clear the step counter, the rate window and the turn's intervals (W763). */
75
+ beginTurn() {
76
+ this.steps = 0;
77
+ this.samples = [];
78
+ this.segments = [];
79
+ }
80
+ /** Record one step (one tool call). */
81
+ addStep() {
82
+ this.steps += 1;
83
+ }
84
+ /** Record one output delta (text/thinking) into the rate window and the turn's intervals (W763). */
85
+ addChars(chars) {
86
+ const at = this.now();
87
+ const n = Math.max(0, Math.trunc(chars));
88
+ this.samples.push({ at, chars: n });
89
+ this.trim(at);
90
+ pushTurnDelta(this.segments, at, n);
91
+ }
92
+ /**
93
+ * W763: activity intervals held for the current turn. Diagnostic/test hook —
94
+ * an uninterrupted stream is ONE segment no matter how many deltas it carries.
95
+ */
96
+ get turnSegmentCount() {
97
+ return this.segments.length;
98
+ }
99
+ /** Steps recorded in the current turn. */
100
+ get stepCount() {
101
+ return this.steps;
102
+ }
103
+ /**
104
+ * Chars-per-second estimate (W754 + W763): the responsive window rate while
105
+ * the 5s window still carries output, otherwise the turn's active-interval
106
+ * mean. 0 only while this turn has not produced a single delta (TTFT).
107
+ */
108
+ rate() {
109
+ const now = this.now();
110
+ this.trim(now);
111
+ const window = windowRateOf(this.samples, now);
112
+ return window > 0 ? window : turnRate(this.segments, now);
113
+ }
114
+ trim(now) {
115
+ while (this.samples.length > 0 && now - (this.samples[0]?.at ?? now) > RATE_WINDOW_MS) {
116
+ this.samples.shift();
117
+ }
118
+ }
119
+ }
120
+ /**
121
+ * W754: total ACTIVE milliseconds covered by the samples (the rate denominator).
122
+ *
123
+ * Samples are walked in time order; every adjacent pair closer than `GAP_MS`
124
+ * stays in the same activity interval, a wider step closes the interval and
125
+ * opens a new one (the break itself contributes nothing). The final interval is
126
+ * extended to `now` only while the stream still looks alive (`now` within
127
+ * `GAP_MS` of the last delta), so a stall does not keep inflating the
128
+ * denominator. Each interval is credited at least `MIN_ACTIVE_MS`.
129
+ */
130
+ export function activeSpanMs(samples, now) {
131
+ const first = samples[0];
132
+ if (first === undefined)
133
+ return 0;
134
+ let span = 0;
135
+ let segStart = first.at;
136
+ let prev = first.at;
137
+ for (let i = 1; i < samples.length; i += 1) {
138
+ const at = samples[i]?.at ?? prev;
139
+ if (at - prev > GAP_MS) {
140
+ span += Math.max(prev - segStart, MIN_ACTIVE_MS);
141
+ segStart = at;
142
+ }
143
+ prev = at;
144
+ }
145
+ const tail = now - prev;
146
+ const end = tail >= 0 && tail <= GAP_MS ? now : prev;
147
+ return span + Math.max(end - segStart, MIN_ACTIVE_MS);
148
+ }
149
+ /** W763: fold one delta into the turn's segments (O(1); allocates only when a pause opens a new interval). */
150
+ export function pushTurnDelta(segments, at, chars) {
151
+ const last = segments[segments.length - 1];
152
+ if (last === undefined || at - last.at > GAP_MS) {
153
+ segments.push({ start: at, at, chars });
154
+ return;
155
+ }
156
+ last.at = at;
157
+ last.chars += chars;
158
+ }
159
+ /**
160
+ * W763: Σ ACTIVE ms over the turn's segments — the same flooring rule as
161
+ * [activeSpanMs] (every interval ≥ `MIN_ACTIVE_MS`) and the same guard that only
162
+ * extends the OPEN interval while the stream is still alive, so neither a stall
163
+ * nor a finished turn can inflate the denominator.
164
+ */
165
+ export function turnSpanMs(segments, now) {
166
+ let ms = 0;
167
+ for (let i = 0; i < segments.length; i += 1) {
168
+ const seg = segments[i];
169
+ if (seg === undefined)
170
+ continue;
171
+ const tail = now - seg.at;
172
+ const open = i === segments.length - 1 && tail >= 0 && tail <= GAP_MS;
173
+ ms += Math.max((open ? now : seg.at) - seg.start, MIN_ACTIVE_MS);
174
+ }
175
+ return ms;
176
+ }
177
+ /**
178
+ * W763: chars/s averaged over the turn's ACTIVE intervals — the "有流时段均值" the
179
+ * operator asked for. 0 iff the turn has produced no delta at all yet.
180
+ */
181
+ export function turnRate(segments, now) {
182
+ if (segments.length === 0)
183
+ return 0;
184
+ let chars = 0;
185
+ for (const seg of segments)
186
+ chars += seg.chars;
187
+ return (chars / turnSpanMs(segments, now)) * 1_000;
188
+ }
189
+ /** W754 window half: the 5s-window rate, 0 when the window holds no char-carrying sample. */
190
+ function windowRateOf(samples, now) {
191
+ let chars = 0;
192
+ for (const s of samples)
193
+ chars += s.chars;
194
+ if (chars === 0)
195
+ return 0;
196
+ return (chars / activeSpanMs(samples, now)) * 1_000;
197
+ }
198
+ /** Factory form (ARCHITECTURE.md §6.1). */
199
+ export function createStatusTracker(now = Date.now) {
200
+ return new StatusTracker(now);
201
+ }
202
+ /**
203
+ * W755 (Fix B): the forward-looking correction of the provider's prompt sample.
204
+ *
205
+ * DSH computes `projectedTokens = pressureTokens + surfaceTokens - sampledSurfaceTokens`
206
+ * so that occupancy answers for the NEXT request rather than the last one
207
+ * (`dsh-token-meter/lib/types/usage-projection.js:178-187`). Same shape here:
208
+ * remember the model-visible estimate observed when a NEW `prompt_tokens` value
209
+ * arrived, and add whatever the visible surface has grown by since.
210
+ *
211
+ * Only growth counts: a shrinking surface (a trim/compaction) never pulls the
212
+ * number BELOW the provider's own measurement — that anchor is a fact.
213
+ *
214
+ * The state is PER SESSION (one instance lives on the session's [StatusView]),
215
+ * never a module singleton and never a timer. A fresh instance per call just
216
+ * degrades to "no projection", which is correct, only lagging.
217
+ */
218
+ export class ContextPressure {
219
+ sampledPrompt = 0;
220
+ assembledAtSample = null;
221
+ /**
222
+ * Note the (real prompt, visible estimate) pair of this observation. A prompt
223
+ * value already sampled keeps its original anchor, which is exactly what makes
224
+ * the surface growth measurable; a new value re-anchors.
225
+ */
226
+ observe(promptTokens, assembledTokens) {
227
+ if (!(promptTokens > 0))
228
+ return;
229
+ if (promptTokens === this.sampledPrompt && this.assembledAtSample !== null)
230
+ return;
231
+ this.sampledPrompt = promptTokens;
232
+ this.assembledAtSample = assembledTokens;
233
+ }
234
+ /**
235
+ * `used` for this observation: the real prompt plus the visible growth since
236
+ * it was sampled. Without either half of the anchor it is just the prompt
237
+ * (`projected:false` — nothing forward-looking was measurable).
238
+ */
239
+ project(promptTokens, assembledTokens) {
240
+ if (!(promptTokens > 0))
241
+ return { used: 0, projected: false };
242
+ if (assembledTokens === null || this.assembledAtSample === null) {
243
+ return { used: promptTokens, projected: false };
244
+ }
245
+ const growth = Math.max(0, assembledTokens - this.assembledAtSample);
246
+ return { used: promptTokens + growth, projected: growth > 0 };
247
+ }
248
+ }
249
+ /**
250
+ * W755 (Fix A): token estimate of a MODEL-VISIBLE request — the very estimator
251
+ * the loop's trim budget already uses (`packages/agent-loop/src/context-trim.ts`),
252
+ * reused rather than re-derived, so the two can never disagree: system text +
253
+ * every message (content + structural overhead) + the tool schemas.
254
+ *
255
+ * W766: callers on a hot path should obtain the value through a memoized
256
+ * [AssembledContext] instead of calling this again on an unchanged request.
257
+ */
258
+ export function estimatedContextTokens(request) {
259
+ const system = request.system === null ? 0 : estimateTokens(request.system);
260
+ return system + estimateMessagesTokens(request.messages) + estimateTokens(JSON.stringify(request.tools));
261
+ }
262
+ /**
263
+ * W766: pair a request with its estimate — for callers that build a view by hand
264
+ * (tests, the bench) and therefore have no runtime cache to read it from.
265
+ */
266
+ export function assembledContextOf(request) {
267
+ return { request, tokens: estimatedContextTokens(request) };
268
+ }
269
+ /** W263: used/window ratio, clamped to [0,1], rounded to 4 decimals. */
270
+ export function ratio4(used, window) {
271
+ if (window <= 0)
272
+ return 0;
273
+ const r = used / window;
274
+ return Math.round(Math.min(1, Math.max(0, r)) * 10_000) / 10_000;
275
+ }
276
+ /**
277
+ * W218 character volume of the session log (user/assistant text + tool call
278
+ * id/name/args + tool result value/error). Thinking deltas and turn markers
279
+ * carry no model-visible history and are free.
280
+ *
281
+ * W755: THIS IS A CHARACTER COUNT, NOT A TOKEN COUNT, and it is no longer part
282
+ * of any reported口径 — `contextUsage` never reports it (the old
283
+ * `session_event_chars` method divided it by a TOKEN window, which over-reported
284
+ * by ~4x for latin text and inflated further for CJK). Kept only as an internal
285
+ * / debug scale reference (and as the regression anchor pinning the fix).
286
+ */
287
+ export function estimatedContextChars(events) {
288
+ let total = 0;
289
+ for (const ev of events) {
290
+ switch (ev.type) {
291
+ case "user_message":
292
+ case "assistant_message":
293
+ total += ev.text.length;
294
+ break;
295
+ case "tool_call":
296
+ total += ev.id.length + ev.name.length + jsonLen(ev.args);
297
+ break;
298
+ case "tool_result":
299
+ total += ev.id.length + jsonLen(ev.value) + (ev.error?.length ?? 0);
300
+ break;
301
+ default:
302
+ break;
303
+ }
304
+ }
305
+ return total;
306
+ }
307
+ /**
308
+ * W755: the context usage, in the DSH口径 — real provider prompt first, else the
309
+ * model-visible assembly's token estimate, else an honest "unknown" (never the
310
+ * session log's character count; see [estimatedContextChars]).
311
+ */
312
+ export function contextUsage(view) {
313
+ const { window, source } = contextWindowOf(view.context_window);
314
+ const assembled = assembledTokensOf(view);
315
+ const prompt = view.usage.latest().prompt_tokens;
316
+ if (prompt > 0) {
317
+ view.pressure.observe(prompt, assembled);
318
+ const { used, projected } = view.pressure.project(prompt, assembled);
319
+ return {
320
+ used,
321
+ window,
322
+ ratio: ratio4(used, window),
323
+ estimated: false,
324
+ method: "usage_prompt_tokens",
325
+ projected,
326
+ window_source: source,
327
+ };
328
+ }
329
+ if (assembled !== null) {
330
+ return {
331
+ used: assembled,
332
+ window,
333
+ ratio: ratio4(assembled, window),
334
+ estimated: true,
335
+ method: "assembled_estimate",
336
+ projected: false,
337
+ window_source: source,
338
+ };
339
+ }
340
+ return { used: 0, window, ratio: 0, estimated: true, method: "none", projected: false, window_source: source };
341
+ }
342
+ /**
343
+ * W755 (Fix C): the honest window. A missing capacity is NOT a 1,000,000-token
344
+ * window — the contract's display default is for the UI's text only and never
345
+ * enters a ratio (DSH refuses to render the ring at all without a capacity).
346
+ */
347
+ function contextWindowOf(configured) {
348
+ if (Number.isFinite(configured) && configured > 0)
349
+ return { window: Math.trunc(configured), source: "profile" };
350
+ if (configured === 0)
351
+ return { window: 0, source: "fallback" };
352
+ return { window: 0, source: "unknown" };
353
+ }
354
+ /** The token estimate of the loop's assembly, or null when it cannot snapshot. */
355
+ function assembledTokensOf(view) {
356
+ let assembled = null;
357
+ try {
358
+ assembled = view.assembled();
359
+ }
360
+ catch {
361
+ // A statusline read must never fail the endpoint: no snapshot is a valid
362
+ // answer (method "none"), an exception is not.
363
+ assembled = null;
364
+ }
365
+ // W766: read the estimate the assembly carries; never re-estimate here.
366
+ return assembled === null ? null : assembled.tokens;
367
+ }
368
+ /**
369
+ * W755: the statusline of a session with NO live generation yet (the studio
370
+ * adapter's "empty one"). Nothing is measurable there — no provider frame, no
371
+ * engine assembly, no events — so it reports the honest `method:"none"` branch
372
+ * (`used:0`) instead of a made-up ratio. A FRESH [ContextPressure] is correct
373
+ * here: with no usage frame no prompt sample is ever observed through it.
374
+ */
375
+ export function coldStatusline(input) {
376
+ return statuslineOf({
377
+ model: input.model,
378
+ reasoning_effort: input.reasoning_effort,
379
+ status: createStatusTracker(input.now ?? Date.now),
380
+ usage: createUsageTracker(),
381
+ context_window: input.context_window,
382
+ events: () => [],
383
+ assembled: () => null,
384
+ pressure: new ContextPressure(),
385
+ });
386
+ }
387
+ /** `statusline_of` — the frozen `/api/status` payload. */
388
+ export function statuslineOf(view) {
389
+ return {
390
+ model: view.model,
391
+ reasoning_effort: view.reasoning_effort,
392
+ steps: view.status.stepCount,
393
+ tokens_per_sec: Math.round(view.status.rate() * 100) / 100,
394
+ context_usage: contextUsage(view),
395
+ usage: usageStatus(view.usage),
396
+ };
397
+ }
398
+ function jsonLen(value) {
399
+ if (value === null || value === undefined)
400
+ return 0;
401
+ try {
402
+ return JSON.stringify(value)?.length ?? 0;
403
+ }
404
+ catch {
405
+ return 0;
406
+ }
407
+ }
408
+ /** Terminal outcome -> SSE status phase (the frozen vocabulary). */
409
+ export function outcomePhaseOf(outcome) {
410
+ return typeof outcome === "string" ? outcome : "error";
411
+ }