@aiwayds/dsh-tui-pi 0.8.2 → 0.9.0
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 +44 -3
- package/README.zh.md +36 -3
- package/assets/fonts/LICENSE-Hack.txt +45 -0
- package/assets/fonts/LICENSE-NerdFonts.txt +126 -0
- package/assets/fonts/LICENSE-NotoSansSymbols.txt +93 -0
- package/assets/fonts/LICENSE-NotoSansSymbols2.txt +93 -0
- package/assets/fonts/dsh-tui-pi-nerd.ttf +0 -0
- package/assets/fonts-gen.mjs +219 -0
- package/assets/whale-gen.mjs +169 -0
- package/assets/whale-source.png +0 -0
- package/lib/agents.js +1 -1
- package/lib/agents.js.map +1 -1
- package/lib/canvas-terminal.d.ts +17 -11
- package/lib/canvas-terminal.js +62 -24
- package/lib/canvas-terminal.js.map +1 -1
- package/lib/dsh-events.d.ts +32 -3
- package/lib/dsh-events.js +14 -0
- package/lib/dsh-events.js.map +1 -1
- package/lib/font-detect.d.ts +66 -0
- package/lib/font-detect.js +124 -0
- package/lib/font-detect.js.map +1 -0
- package/lib/footer.d.ts +3 -2
- package/lib/footer.js +16 -8
- package/lib/footer.js.map +1 -1
- package/lib/icons.d.ts +53 -0
- package/lib/icons.js +78 -0
- package/lib/icons.js.map +1 -0
- package/lib/index.js +44 -13
- package/lib/index.js.map +1 -1
- package/lib/live-widgets.d.ts +16 -5
- package/lib/live-widgets.js +29 -8
- package/lib/live-widgets.js.map +1 -1
- package/lib/messages.js +2 -1
- package/lib/messages.js.map +1 -1
- package/lib/session.d.ts +99 -16
- package/lib/session.js +322 -64
- package/lib/session.js.map +1 -1
- package/lib/sessions.d.ts +15 -0
- package/lib/sessions.js +18 -3
- package/lib/sessions.js.map +1 -1
- package/lib/settings.js +2 -2
- package/lib/skills.d.ts +19 -3
- package/lib/skills.js +43 -10
- package/lib/skills.js.map +1 -1
- package/lib/subagent-policy.d.ts +11 -11
- package/lib/subagent-policy.js +17 -15
- package/lib/subagent-policy.js.map +1 -1
- package/lib/subagent-viewer.d.ts +20 -5
- package/lib/subagent-viewer.js +29 -15
- package/lib/subagent-viewer.js.map +1 -1
- package/lib/theme-settings.d.ts +25 -10
- package/lib/theme-settings.js +40 -14
- package/lib/theme-settings.js.map +1 -1
- package/lib/tokens.d.ts +38 -0
- package/lib/tokens.js +68 -0
- package/lib/tokens.js.map +1 -0
- package/lib/tui.js +15 -6
- package/lib/tui.js.map +1 -1
- package/package.json +2 -1
- package/scripts/install-font.mjs +329 -0
package/lib/session.js
CHANGED
|
@@ -14,8 +14,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
|
14
14
|
import { settingsNamespace, SettingsConflictError } from '@deepseek-ai/dsh-settings';
|
|
15
15
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
16
16
|
import { readAppendSystem } from "./append-system.js";
|
|
17
|
-
import { isAgentEnd, isAgentStart, isLlmRetry, isSubagentDescriptor } from "./dsh-events.js";
|
|
17
|
+
import { isAgentEnd, isAgentStart, isDcpCompactionNotice, isLlmRetry, isSubagentDescriptor } from "./dsh-events.js";
|
|
18
18
|
import { installSpawnToolFence } from "./subagent-policy.js";
|
|
19
|
+
import { estimateContentTokens, estimateTextTokens } from "./tokens.js";
|
|
19
20
|
/**
|
|
20
21
|
* Register the APPEND_SYSTEM.md section on ONE agent's scoped context, so the
|
|
21
22
|
* user's orchestrator identity applies to the TUI's main agent ONLY. A section
|
|
@@ -45,18 +46,45 @@ function installAppendSystem(agentCtx) {
|
|
|
45
46
|
*/
|
|
46
47
|
const CHILD_LOG_CAP = 2000;
|
|
47
48
|
/**
|
|
48
|
-
* Cadence of the per-child
|
|
49
|
-
* assume child `session/event` streams bubble here, but in some
|
|
50
|
-
* a subagent's own events never reach this plugin — the child
|
|
51
|
-
* through the parent's `tool-workflow/agent-start`, so
|
|
52
|
-
* the `maxRounds` policy never fires. The reconcile
|
|
53
|
-
*
|
|
54
|
-
* fallback; it only ever moves the count UP past what
|
|
55
|
-
* recorded, so the two sources never double count.
|
|
49
|
+
* Cadence of the per-child round-count reconcile. The TUI's discovery and
|
|
50
|
+
* fold assume child `session/event` streams bubble here, but in some
|
|
51
|
+
* deployments a subagent's own events never reach this plugin — the child
|
|
52
|
+
* shows up only through the parent's `tool-workflow/agent-start`, so
|
|
53
|
+
* "rounds" stays 0 and the `maxRounds` policy never fires. The reconcile
|
|
54
|
+
* re-derives each child's round count from its authoritative session log
|
|
55
|
+
* (`ctx.sessions`) as a fallback; it only ever moves the count UP past what
|
|
56
|
+
* streamed events already recorded, so the two sources never double count.
|
|
57
|
+
* Rounds are counted on `assistant/message` events (one per LLM round-trip) —
|
|
58
|
+
* a one-shot child never advances `turn/end` while it works.
|
|
56
59
|
*/
|
|
57
|
-
const
|
|
60
|
+
const ROUND_RECONCILE_MS = 600;
|
|
58
61
|
/** Tail marker for `llm/retry` events (no text content — the row shows it is re-working). */
|
|
59
62
|
const RETRY_MARKER = '↻ retry';
|
|
63
|
+
/**
|
|
64
|
+
* Estimated tokens of a `tool/result` event's text payload (the tool output
|
|
65
|
+
* that enters the next request). Defensive: reads through an `unknown` shape
|
|
66
|
+
* and never throws. The result body lives in `data.message.content[*].content`
|
|
67
|
+
* as `{ type: 'text', text }` blocks (plus any inline `isError` marker).
|
|
68
|
+
*/
|
|
69
|
+
function estimateToolResultTokens(data) {
|
|
70
|
+
const content = data.message?.content;
|
|
71
|
+
if (!Array.isArray(content))
|
|
72
|
+
return 0;
|
|
73
|
+
const parts = [];
|
|
74
|
+
for (const block of content) {
|
|
75
|
+
if (block === null || typeof block !== 'object')
|
|
76
|
+
continue;
|
|
77
|
+
const inner = block.content;
|
|
78
|
+
if (!Array.isArray(inner))
|
|
79
|
+
continue;
|
|
80
|
+
for (const item of inner) {
|
|
81
|
+
const b = item;
|
|
82
|
+
if (b.type === 'text' && typeof b.text === 'string')
|
|
83
|
+
parts.push(b.text);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return estimateTextTokens(parts.join('\n'));
|
|
87
|
+
}
|
|
60
88
|
/**
|
|
61
89
|
* Bound of a child's live streaming buffer (text + reasoning deltas): enough
|
|
62
90
|
* to extract the last content line of any real stream, bounded so a runaway
|
|
@@ -149,7 +177,23 @@ export class DshSessionBridge {
|
|
|
149
177
|
cacheWriteTokens: 0,
|
|
150
178
|
msgCount: 0,
|
|
151
179
|
toolCallCount: 0,
|
|
180
|
+
contextTokens: 0,
|
|
152
181
|
};
|
|
182
|
+
/**
|
|
183
|
+
* Usage snapshot of the LATEST assistant/message — the exact billed context
|
|
184
|
+
* of the last request (input = uncached, cache read/write billed on top;
|
|
185
|
+
* output = that request's own output). The footer's context segment prices
|
|
186
|
+
* this plus `pendingTokens` (see BridgeStats.contextTokens). `undefined`
|
|
187
|
+
* until the first assistant/message arrives.
|
|
188
|
+
*/
|
|
189
|
+
lastUsage;
|
|
190
|
+
/**
|
|
191
|
+
* CJK-estimated tokens of every message appended AFTER the latest
|
|
192
|
+
* assistant/message (user prompts, tool results, streamed text deltas) —
|
|
193
|
+
* they enter the next request. Reset to 0 by the next assistant/message,
|
|
194
|
+
* whose usage snapshot then covers them exactly.
|
|
195
|
+
*/
|
|
196
|
+
pendingTokens = 0;
|
|
153
197
|
/** Live subagent rows keyed by child session id. */
|
|
154
198
|
agentViews = new Map();
|
|
155
199
|
/** Child session ids whose own events we fold into the agent views. */
|
|
@@ -168,14 +212,49 @@ export class DshSessionBridge {
|
|
|
168
212
|
* line and deletes the entry.
|
|
169
213
|
*/
|
|
170
214
|
childStreams = new Map();
|
|
171
|
-
/** Per-child completed turn count — the "rounds" the policy caps. */
|
|
172
|
-
turnCounts = new Map();
|
|
173
215
|
/**
|
|
174
|
-
* Per-child
|
|
175
|
-
*
|
|
216
|
+
* Per-child assistant-message count — the "rounds" the policy caps. One
|
|
217
|
+
* increment per `assistant/message` (an LLM round-trip), counted on the
|
|
218
|
+
* child's OWN events. Settled children keep counting (a continuable child
|
|
219
|
+
* resumes later), so the count stays authoritative for the viewer and the
|
|
220
|
+
* wrap-up policy.
|
|
221
|
+
*/
|
|
222
|
+
roundCounts = new Map();
|
|
223
|
+
/**
|
|
224
|
+
* The EVENT path's own absolute per-child assistant-message count — its
|
|
225
|
+
* ledger of streamed `assistant/message` events it has actually received.
|
|
226
|
+
* The reconcile derives its own count from the session log; the displayed
|
|
227
|
+
* "rounds" is `max(streamed, reconciled)` (see foldTracked). Keeping the
|
|
228
|
+
* two ledgers separate is what makes a message the reconcile already
|
|
229
|
+
* counted immune to a second count when its streamed event arrives late.
|
|
230
|
+
*/
|
|
231
|
+
streamedRoundCounts = new Map();
|
|
232
|
+
/**
|
|
233
|
+
* Per-child usage snapshot of the latest assistant/message (see
|
|
234
|
+
* `lastUsage` for the main session) — the exact billed context of the
|
|
235
|
+
* child's last request, the `contextTokens` baseline.
|
|
236
|
+
*/
|
|
237
|
+
childUsage = new Map();
|
|
238
|
+
/**
|
|
239
|
+
* Per-child CJK-estimated tokens of messages appended after the latest
|
|
240
|
+
* assistant/message (see `pendingTokens` for the main session) — the
|
|
241
|
+
* `contextTokens` increment that makes the compact `X/Y` follow live.
|
|
242
|
+
*/
|
|
243
|
+
childPending = new Map();
|
|
244
|
+
/**
|
|
245
|
+
* Per-child dsh-dcp compaction count — one per `user/message` notice whose
|
|
246
|
+
* source is `{ kind: 'plugin', plugin: 'dsh-dcp', form: 'notice' }`. The
|
|
247
|
+
* viewer's picker rows read this via `getChildCompactionCount` so the user
|
|
248
|
+
* sees at a glance whether DCP actually compacted inside a child.
|
|
249
|
+
*/
|
|
250
|
+
childCompactionCounts = new Map();
|
|
251
|
+
/**
|
|
252
|
+
* Per-child session-log length already reconciled (see
|
|
253
|
+
* reconcileChildRounds). Incremental high-water, so the reconcile scans
|
|
254
|
+
* only newly-appended events.
|
|
176
255
|
*/
|
|
177
256
|
reconciledLen = new Map();
|
|
178
|
-
/** Per-child
|
|
257
|
+
/** Per-child round count derived from the session log by the reconcile. */
|
|
179
258
|
reconciledCount = new Map();
|
|
180
259
|
/**
|
|
181
260
|
* Session ids that can own tracked children (this session first, then every
|
|
@@ -203,20 +282,40 @@ export class DshSessionBridge {
|
|
|
203
282
|
const sessionKey = String(session.id);
|
|
204
283
|
// Discover subagent children by session header — the deployment may
|
|
205
284
|
// never emit tool-workflow events (the firehose is not scope-filtered,
|
|
206
|
-
// so child sessions arrive here too).
|
|
207
|
-
// tracked session is a child; the descriptor
|
|
208
|
-
// label/provider.
|
|
285
|
+
// so child sessions arrive here too). A session whose parent is a
|
|
286
|
+
// tracked session AND is marked as delegated is a child; the descriptor
|
|
287
|
+
// event later refines the label/provider.
|
|
288
|
+
//
|
|
289
|
+
// Guard: a child is `origin: 'subagent'` OR a delegation budget
|
|
290
|
+
// `delegationDepth > 0`. Both markers are written together by dsh's
|
|
291
|
+
// childSessionMeta (spawn AND in-process fork children alike), so in
|
|
292
|
+
// practice the origin alone carries the decision; the budget clause
|
|
293
|
+
// only future-proofs the gate against a child that carries the budget
|
|
294
|
+
// without the origin. The budget test MUST be a VALUE test, not a
|
|
295
|
+
// field-presence test: the jsonl persistence backend materialises
|
|
296
|
+
// `delegationDepth: 0` on every restored header (write `?? 0`, read
|
|
297
|
+
// unconditionally), so a presence test would pull user-facing
|
|
298
|
+
// `Session.fork` conversations and other non-child restored sessions
|
|
299
|
+
// onto the live board / Ctrl+G.
|
|
209
300
|
const header = session.header;
|
|
210
|
-
if (header?.
|
|
301
|
+
if (header?.parentSession !== undefined
|
|
302
|
+
&& (header.origin === 'subagent' || (header.delegationDepth ?? 0) > 0)
|
|
211
303
|
&& this.trackedSessions.has(String(header.parentSession))
|
|
212
304
|
&& !this.agentViews.has(sessionKey)) {
|
|
213
305
|
this.agentViews.set(sessionKey, {
|
|
214
306
|
childId: sessionKey,
|
|
215
307
|
parentSession: String(header.parentSession),
|
|
216
|
-
|
|
308
|
+
// Current dsh children always carry the origin, so in practice the
|
|
309
|
+
// label is `subagent <id8>`; the `fork` label only fires for the
|
|
310
|
+
// defensive budget-without-origin shape above.
|
|
311
|
+
label: header.origin === undefined
|
|
312
|
+
? `fork ${sessionKey.slice(0, 8)}`
|
|
313
|
+
: `subagent ${sessionKey.slice(0, 8)}`,
|
|
217
314
|
startedAt: event.time,
|
|
218
315
|
tokens: 0,
|
|
316
|
+
rounds: 0,
|
|
219
317
|
retries: 0,
|
|
318
|
+
contextTokens: 0,
|
|
220
319
|
});
|
|
221
320
|
this.childSessions.add(sessionKey);
|
|
222
321
|
this.trackedSessions.add(sessionKey);
|
|
@@ -243,33 +342,36 @@ export class DshSessionBridge {
|
|
|
243
342
|
this.running = status === 'running';
|
|
244
343
|
this.callbacks.onStatus(status);
|
|
245
344
|
}));
|
|
246
|
-
//
|
|
247
|
-
// even when a child's own events never bubble to this plugin (see
|
|
248
|
-
//
|
|
345
|
+
// Round-count reconcile fallback: keep "rounds" and the maxRounds policy
|
|
346
|
+
// live even when a child's own events never bubble to this plugin (see
|
|
347
|
+
// ROUND_RECONCILE_MS). Runs regardless of whether the viewer is open — the
|
|
249
348
|
// cap must still fire while the child works in the background.
|
|
250
349
|
const reconcile = setInterval(() => {
|
|
251
350
|
try {
|
|
252
|
-
this.
|
|
351
|
+
this.reconcileChildRounds();
|
|
253
352
|
}
|
|
254
353
|
catch {
|
|
255
354
|
// A throwing reconcile must never take the process down.
|
|
256
355
|
}
|
|
257
|
-
},
|
|
356
|
+
}, ROUND_RECONCILE_MS);
|
|
258
357
|
reconcile.unref?.();
|
|
259
358
|
this.disposers.push(() => clearInterval(reconcile));
|
|
260
359
|
}
|
|
261
360
|
/**
|
|
262
|
-
* Re-derive each tracked child's
|
|
361
|
+
* Re-derive each tracked child's round count from its own session log
|
|
263
362
|
* (`ctx.sessions`), the authoritative append-only facts. The firehose
|
|
264
363
|
* assumption behind discovery/fold is unreliable in some deployments — a
|
|
265
364
|
* child is listed from the parent's `tool-workflow/agent-start` but its own
|
|
266
|
-
* `
|
|
267
|
-
* `maxRounds` policy dead at 0. This corrects the count UP
|
|
268
|
-
* streamed events already recorded (never down, never
|
|
269
|
-
* counted), and fires `
|
|
270
|
-
* events were never delivered.
|
|
365
|
+
* `assistant/message` events never arrive, which leaves both the rounds
|
|
366
|
+
* display and the `maxRounds` policy dead at 0. This corrects the count UP
|
|
367
|
+
* past whatever the streamed events already recorded (never down, never
|
|
368
|
+
* re-adding what events counted), and fires `onRoundCount` so the cap still
|
|
369
|
+
* guards a child whose events were never delivered. Rounds are counted on
|
|
370
|
+
* `assistant/message` (one per LLM round-trip — `turn/end` is structurally
|
|
371
|
+
* inert for a one-shot child that never leaves its single turn). Scans only
|
|
372
|
+
* newly-appended events per child.
|
|
271
373
|
*/
|
|
272
|
-
|
|
374
|
+
reconcileChildRounds() {
|
|
273
375
|
const sessions = this.ctx.sessions;
|
|
274
376
|
if (sessions === undefined)
|
|
275
377
|
return;
|
|
@@ -290,16 +392,16 @@ export class DshSessionBridge {
|
|
|
290
392
|
continue;
|
|
291
393
|
let added = 0;
|
|
292
394
|
for (let i = from; i < len; i++) {
|
|
293
|
-
if (events[i].type === '
|
|
395
|
+
if (events[i].type === 'assistant/message')
|
|
294
396
|
added += 1;
|
|
295
397
|
}
|
|
296
398
|
const absolute = (this.reconciledCount.get(childId) ?? 0) + added;
|
|
297
399
|
this.reconciledLen.set(childId, len);
|
|
298
400
|
this.reconciledCount.set(childId, absolute);
|
|
299
|
-
const current = this.
|
|
401
|
+
const current = this.roundCounts.get(childId) ?? 0;
|
|
300
402
|
if (absolute > current) {
|
|
301
|
-
this.
|
|
302
|
-
this.callbacks.
|
|
403
|
+
this.roundCounts.set(childId, absolute);
|
|
404
|
+
this.callbacks.onRoundCount?.(childId, absolute);
|
|
303
405
|
}
|
|
304
406
|
}
|
|
305
407
|
}
|
|
@@ -323,9 +425,13 @@ export class DshSessionBridge {
|
|
|
323
425
|
getChildLog(childId) {
|
|
324
426
|
return this.childLogs.get(childId) ?? [];
|
|
325
427
|
}
|
|
326
|
-
/**
|
|
327
|
-
|
|
328
|
-
return this.
|
|
428
|
+
/** Assistant-message count of one child ("rounds" — the policy cap unit). */
|
|
429
|
+
getRoundCount(childId) {
|
|
430
|
+
return this.roundCounts.get(childId) ?? 0;
|
|
431
|
+
}
|
|
432
|
+
/** dsh-dcp compaction count of one child (viewer picker rows). */
|
|
433
|
+
getChildCompactionCount(childId) {
|
|
434
|
+
return this.childCompactionCounts.get(childId) ?? 0;
|
|
329
435
|
}
|
|
330
436
|
/** Whether one child settled (a continuable child may resume later). */
|
|
331
437
|
isChildSettled(childId) {
|
|
@@ -399,7 +505,11 @@ export class DshSessionBridge {
|
|
|
399
505
|
this.childSessions.clear();
|
|
400
506
|
this.childLogs.clear();
|
|
401
507
|
this.childStreams.clear();
|
|
402
|
-
this.
|
|
508
|
+
this.roundCounts.clear();
|
|
509
|
+
this.streamedRoundCounts.clear();
|
|
510
|
+
this.childUsage.clear();
|
|
511
|
+
this.childPending.clear();
|
|
512
|
+
this.childCompactionCounts.clear();
|
|
403
513
|
this.reconciledLen.clear();
|
|
404
514
|
this.reconciledCount.clear();
|
|
405
515
|
this.trackedSessions.clear();
|
|
@@ -428,11 +538,18 @@ export class DshSessionBridge {
|
|
|
428
538
|
this.stats.msgCount = 0;
|
|
429
539
|
this.stats.toolCallCount = 0;
|
|
430
540
|
this.stats.cacheHitRate = undefined;
|
|
541
|
+
this.stats.contextTokens = 0;
|
|
542
|
+
this.lastUsage = undefined;
|
|
543
|
+
this.pendingTokens = 0;
|
|
431
544
|
this.agentViews.clear();
|
|
432
545
|
this.childSessions.clear();
|
|
433
546
|
this.childLogs.clear();
|
|
434
547
|
this.childStreams.clear();
|
|
435
|
-
this.
|
|
548
|
+
this.roundCounts.clear();
|
|
549
|
+
this.streamedRoundCounts.clear();
|
|
550
|
+
this.childUsage.clear();
|
|
551
|
+
this.childPending.clear();
|
|
552
|
+
this.childCompactionCounts.clear();
|
|
436
553
|
this.reconciledLen.clear();
|
|
437
554
|
this.reconciledCount.clear();
|
|
438
555
|
this.trackedSessions.clear();
|
|
@@ -514,6 +631,9 @@ export class DshSessionBridge {
|
|
|
514
631
|
this.stats.msgCount = 0;
|
|
515
632
|
this.stats.toolCallCount = 0;
|
|
516
633
|
this.stats.cacheHitRate = undefined;
|
|
634
|
+
this.stats.contextTokens = 0;
|
|
635
|
+
this.lastUsage = undefined;
|
|
636
|
+
this.pendingTokens = 0;
|
|
517
637
|
const sessionId = this.sessionId;
|
|
518
638
|
for (const event of events) {
|
|
519
639
|
if (event.type === 'assistant/chunk')
|
|
@@ -533,6 +653,9 @@ export class DshSessionBridge {
|
|
|
533
653
|
switch (event.type) {
|
|
534
654
|
case 'user/message':
|
|
535
655
|
this.stats.msgCount += 1;
|
|
656
|
+
// The prompt enters the NEXT request — priced into the occupancy now.
|
|
657
|
+
this.pendingTokens += estimateContentTokens(event.data.content);
|
|
658
|
+
this.stats.contextTokens = this.contextTokens();
|
|
536
659
|
break;
|
|
537
660
|
case 'assistant/message': {
|
|
538
661
|
this.stats.msgCount += 1;
|
|
@@ -541,22 +664,83 @@ export class DshSessionBridge {
|
|
|
541
664
|
this.stats.toolCallCount += 1;
|
|
542
665
|
}
|
|
543
666
|
const usage = event.data.usage;
|
|
544
|
-
if (usage
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
667
|
+
if (usage !== undefined) {
|
|
668
|
+
// The assembled message finalizes the request: its usage snapshot is
|
|
669
|
+
// now the exact billed context (input + cache + output) and the
|
|
670
|
+
// pending estimate restarts from what follows it. A usage-less
|
|
671
|
+
// message (adapter reported none) leaves the baseline untouched —
|
|
672
|
+
// pending already holds the streamed chunk estimate accumulated for
|
|
673
|
+
// this message, a reasonable stand-in until the next billed message.
|
|
674
|
+
this.lastUsage = usage;
|
|
675
|
+
this.pendingTokens = 0;
|
|
676
|
+
this.stats.inputTokens += usage.inputTokens;
|
|
677
|
+
this.stats.outputTokens += usage.outputTokens;
|
|
678
|
+
this.stats.cacheReadTokens += usage.cacheReadTokens ?? 0;
|
|
679
|
+
this.stats.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
|
|
680
|
+
const billedInput = this.stats.inputTokens + this.stats.cacheReadTokens + this.stats.cacheWriteTokens;
|
|
681
|
+
this.stats.cacheHitRate = billedInput > 0
|
|
682
|
+
? (this.stats.cacheReadTokens / billedInput) * 100
|
|
683
|
+
: undefined;
|
|
684
|
+
}
|
|
685
|
+
this.stats.contextTokens = this.contextTokens();
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
case 'assistant/chunk': {
|
|
689
|
+
// Streamed output of the in-flight request — the next request's
|
|
690
|
+
// context grows with it, so the occupancy follows the stream live.
|
|
691
|
+
// Both text and reasoning deltas are priced: usage.outputTokens
|
|
692
|
+
// includes reasoning tokens at snapshot time, so the live estimate
|
|
693
|
+
// must count reasoning too to match that accounting (the next billed
|
|
694
|
+
// assistant/message corrects the final value anyway).
|
|
695
|
+
const chunk = event.data.chunk;
|
|
696
|
+
if ((chunk?.type === 'text-delta' || chunk?.type === 'reasoning-delta') && chunk.text !== '') {
|
|
697
|
+
this.pendingTokens += estimateTextTokens(chunk.text ?? '');
|
|
698
|
+
this.stats.contextTokens = this.contextTokens();
|
|
699
|
+
}
|
|
554
700
|
break;
|
|
555
701
|
}
|
|
702
|
+
case 'tool/result':
|
|
703
|
+
// Tool results enter the next request — priced into the occupancy.
|
|
704
|
+
this.pendingTokens += estimateToolResultTokens(event.data);
|
|
705
|
+
this.stats.contextTokens = this.contextTokens();
|
|
706
|
+
break;
|
|
556
707
|
default:
|
|
557
708
|
break;
|
|
558
709
|
}
|
|
559
710
|
}
|
|
711
|
+
/**
|
|
712
|
+
* The current context-occupancy estimate: the latest assistant/message's
|
|
713
|
+
* billed input (uncached + cache read + cache write) and output, plus the
|
|
714
|
+
* CJK estimate of every message appended after it. Before the first
|
|
715
|
+
* assistant/message the pending estimate alone stands in for the whole
|
|
716
|
+
* current context.
|
|
717
|
+
*/
|
|
718
|
+
contextTokens() {
|
|
719
|
+
if (this.lastUsage === undefined)
|
|
720
|
+
return this.pendingTokens;
|
|
721
|
+
return this.lastUsage.inputTokens
|
|
722
|
+
+ (this.lastUsage.cacheReadTokens ?? 0)
|
|
723
|
+
+ (this.lastUsage.cacheWriteTokens ?? 0)
|
|
724
|
+
+ this.lastUsage.outputTokens
|
|
725
|
+
+ this.pendingTokens;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* The current context-occupancy estimate of one child (the `X` in the
|
|
729
|
+
* compact line's `X/Y`): the child's latest assistant/message billed input
|
|
730
|
+
* + output plus the CJK estimate of messages appended after it. Before the
|
|
731
|
+
* child's first assistant/message the pending estimate alone stands in.
|
|
732
|
+
*/
|
|
733
|
+
childContextTokens(childId) {
|
|
734
|
+
const usage = this.childUsage.get(childId);
|
|
735
|
+
const pending = this.childPending.get(childId) ?? 0;
|
|
736
|
+
if (usage === undefined)
|
|
737
|
+
return pending;
|
|
738
|
+
return usage.inputTokens
|
|
739
|
+
+ (usage.cacheReadTokens ?? 0)
|
|
740
|
+
+ (usage.cacheWriteTokens ?? 0)
|
|
741
|
+
+ usage.outputTokens
|
|
742
|
+
+ pending;
|
|
743
|
+
}
|
|
560
744
|
/**
|
|
561
745
|
* Fold one event of a tracked session into the agent views (workflow events
|
|
562
746
|
* of the parent log + the child's own log). O(1) per event — Maps/Sets only,
|
|
@@ -573,7 +757,9 @@ export class DshSessionBridge {
|
|
|
573
757
|
label: event.data.label,
|
|
574
758
|
startedAt: event.time,
|
|
575
759
|
tokens: 0,
|
|
760
|
+
rounds: 0,
|
|
576
761
|
retries: 0,
|
|
762
|
+
contextTokens: 0,
|
|
577
763
|
});
|
|
578
764
|
this.runSeqToChild.set(`${event.data.runId}:${event.data.seq}`, event.data.childId);
|
|
579
765
|
this.childSessions.add(event.data.childId);
|
|
@@ -594,13 +780,6 @@ export class DshSessionBridge {
|
|
|
594
780
|
}
|
|
595
781
|
}
|
|
596
782
|
if (this.childSessions.has(sessionId)) {
|
|
597
|
-
// Round counting is view-independent: every completed turn of the child
|
|
598
|
-
// is one round, even once the view settled (continuable children resume).
|
|
599
|
-
if (event.type === 'turn/end') {
|
|
600
|
-
const count = (this.turnCounts.get(sessionId) ?? 0) + 1;
|
|
601
|
-
this.turnCounts.set(sessionId, count);
|
|
602
|
-
this.callbacks.onTurnCount?.(sessionId, count);
|
|
603
|
-
}
|
|
604
783
|
const view = this.agentViews.get(sessionId);
|
|
605
784
|
if (view !== undefined) {
|
|
606
785
|
if (isSubagentDescriptor(event)) {
|
|
@@ -617,22 +796,60 @@ export class DshSessionBridge {
|
|
|
617
796
|
}
|
|
618
797
|
}
|
|
619
798
|
else if (event.type === 'assistant/message') {
|
|
799
|
+
// Round counting is view-independent: every assistant message of the
|
|
800
|
+
// child is one round (one LLM round-trip), counted even once the
|
|
801
|
+
// view settled — continuable children resume. `turn/end` is NOT the
|
|
802
|
+
// round unit: a one-shot child never leaves its single turn, so its
|
|
803
|
+
// rounds would stay frozen at 0 while it works.
|
|
804
|
+
// The event path keeps its OWN absolute streamed count and merges
|
|
805
|
+
// it with the reconcile's log-derived count by max() — the two
|
|
806
|
+
// ledgers never double count. A message the reconcile already
|
|
807
|
+
// counted (its `assistant/message` reached the session log first)
|
|
808
|
+
// must not be counted AGAIN when that same event arrives late here:
|
|
809
|
+
// the reconcile only moves the count up, so a naive `current + 1`
|
|
810
|
+
// would inflate it permanently. Caveat: "absolute" holds only while
|
|
811
|
+
// the firehose delivers each event at most once — there is no seq
|
|
812
|
+
// de-dup, so a redelivered `assistant/message` inflates the
|
|
813
|
+
// streamed ledger permanently (the previous `current + 1` counter
|
|
814
|
+
// had the same exposure).
|
|
815
|
+
const streamed = (this.streamedRoundCounts.get(sessionId) ?? 0) + 1;
|
|
816
|
+
this.streamedRoundCounts.set(sessionId, streamed);
|
|
817
|
+
const count = Math.max(streamed, this.roundCounts.get(sessionId) ?? 0);
|
|
818
|
+
if (count !== (this.roundCounts.get(sessionId) ?? 0)) {
|
|
819
|
+
this.roundCounts.set(sessionId, count);
|
|
820
|
+
this.callbacks.onRoundCount?.(sessionId, count);
|
|
821
|
+
}
|
|
620
822
|
const usage = event.data.usage;
|
|
621
823
|
const delta = usage === undefined
|
|
622
824
|
? 0
|
|
623
825
|
: usage.inputTokens + usage.outputTokens
|
|
624
826
|
+ (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
|
|
827
|
+
// The assembled message finalizes the child's request: its usage is
|
|
828
|
+
// now the exact billed context baseline and the pending estimate
|
|
829
|
+
// restarts from what follows it. A usage-less message leaves both
|
|
830
|
+
// untouched — pending still holds the streamed chunk estimate
|
|
831
|
+
// accumulated for this message, a reasonable approximation until the
|
|
832
|
+
// next billed message replaces it.
|
|
833
|
+
if (usage !== undefined) {
|
|
834
|
+
this.childUsage.set(sessionId, usage);
|
|
835
|
+
this.childPending.set(sessionId, 0);
|
|
836
|
+
}
|
|
625
837
|
const lastLine = lastTextLine(event.data);
|
|
626
838
|
const tokensChanged = delta > 0;
|
|
627
839
|
const lineChanged = lastLine !== undefined && lastLine !== view.lastLine;
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
840
|
+
const roundsChanged = count !== view.rounds;
|
|
841
|
+
const contextTokens = this.childContextTokens(sessionId);
|
|
842
|
+
const contextChanged = contextTokens !== view.contextTokens;
|
|
843
|
+
if (tokensChanged || lineChanged || roundsChanged || contextChanged) {
|
|
844
|
+
// One set: neither delta nor lastLine nor rounds nor contextTokens
|
|
845
|
+
// may clobber the others. The assembled message is authoritative
|
|
846
|
+
// for the content tail — its streaming buffer is spent.
|
|
632
847
|
this.agentViews.set(sessionId, {
|
|
633
848
|
...view,
|
|
634
849
|
...(tokensChanged ? { tokens: view.tokens + delta } : {}),
|
|
635
850
|
...(lineChanged ? { lastLine } : {}),
|
|
851
|
+
...(roundsChanged ? { rounds: count } : {}),
|
|
852
|
+
...(contextChanged ? { contextTokens } : {}),
|
|
636
853
|
});
|
|
637
854
|
changed = true;
|
|
638
855
|
}
|
|
@@ -651,13 +868,54 @@ export class DshSessionBridge {
|
|
|
651
868
|
const buffer = bumpChildStream(this.childStreams.get(sessionId) ?? '', delta);
|
|
652
869
|
this.childStreams.set(sessionId, buffer);
|
|
653
870
|
const lastLine = lastBufferLine(buffer);
|
|
654
|
-
|
|
655
|
-
|
|
871
|
+
// Streamed output (text AND reasoning) grows the child's current
|
|
872
|
+
// context too — usage.outputTokens includes reasoning tokens at
|
|
873
|
+
// snapshot, so the live estimate prices both; the compact X/Y
|
|
874
|
+
// follows live.
|
|
875
|
+
const wasPending = this.childPending.get(sessionId) ?? 0;
|
|
876
|
+
const lineChanged = lastLine !== undefined && lastLine !== view.lastLine;
|
|
877
|
+
// The outer guard already narrowed to text/reasoning deltas, so
|
|
878
|
+
// this branch is unconditional.
|
|
879
|
+
this.childPending.set(sessionId, wasPending + estimateTextTokens(delta));
|
|
880
|
+
const contextTokens = this.childContextTokens(sessionId);
|
|
881
|
+
const contextChanged = contextTokens !== view.contextTokens;
|
|
882
|
+
if (lineChanged || contextChanged) {
|
|
883
|
+
this.agentViews.set(sessionId, {
|
|
884
|
+
...view,
|
|
885
|
+
...(lineChanged ? { lastLine } : {}),
|
|
886
|
+
...(contextChanged ? { contextTokens } : {}),
|
|
887
|
+
});
|
|
656
888
|
changed = true;
|
|
657
889
|
}
|
|
658
890
|
}
|
|
659
891
|
}
|
|
660
892
|
}
|
|
893
|
+
else if (event.type === 'user/message') {
|
|
894
|
+
// The child's own prompts/injects enter its next request — the
|
|
895
|
+
// occupancy follows (the content tail is never touched here). A
|
|
896
|
+
// dsh-dcp compaction notice (plugin `notice` form) also tallies the
|
|
897
|
+
// per-child compaction count the viewer's picker rows read.
|
|
898
|
+
if (isDcpCompactionNotice(event)) {
|
|
899
|
+
this.childCompactionCounts.set(sessionId, (this.childCompactionCounts.get(sessionId) ?? 0) + 1);
|
|
900
|
+
}
|
|
901
|
+
this.childPending.set(sessionId, (this.childPending.get(sessionId) ?? 0)
|
|
902
|
+
+ estimateContentTokens(event.data.content));
|
|
903
|
+
const contextTokens = this.childContextTokens(sessionId);
|
|
904
|
+
if (contextTokens !== view.contextTokens) {
|
|
905
|
+
this.agentViews.set(sessionId, { ...view, contextTokens });
|
|
906
|
+
changed = true;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
else if (event.type === 'tool/result') {
|
|
910
|
+
// Tool results enter the child's next request — the occupancy
|
|
911
|
+
// follows (the content tail shows the child's own output only).
|
|
912
|
+
this.childPending.set(sessionId, (this.childPending.get(sessionId) ?? 0) + estimateToolResultTokens(event.data));
|
|
913
|
+
const contextTokens = this.childContextTokens(sessionId);
|
|
914
|
+
if (contextTokens !== view.contextTokens) {
|
|
915
|
+
this.agentViews.set(sessionId, { ...view, contextTokens });
|
|
916
|
+
changed = true;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
661
919
|
else if (isLlmRetry(event)) {
|
|
662
920
|
// No text content — surface a fixed marker so the row shows the
|
|
663
921
|
// child is (re)working. Merged into one set so the retry counters
|