dsh-code 1.0.5 → 1.0.7
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.en.md +338 -286
- package/README.md +68 -16
- package/bin/deepseek.mjs +204 -4
- package/cordis.patch.yml +105 -7
- package/lib/index.mjs +2148 -439
- package/lib/session-query.mjs +149 -0
- package/lib/types/app.d.ts +34 -8
- package/lib/types/attachments.d.ts +36 -4
- package/lib/types/index.d.ts +38 -2
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/provider-settings.d.ts +6 -11
- package/lib/types/render/animations.d.ts +74 -7
- package/lib/types/render/editor.d.ts +4 -3
- package/lib/types/render/export.d.ts +0 -6
- package/lib/types/render/fuzzy.d.ts +21 -0
- package/lib/types/render/ime-cursor.d.ts +60 -0
- package/lib/types/render/projection.d.ts +80 -4
- package/lib/types/render/status.d.ts +1 -1
- package/lib/types/session-directory.d.ts +48 -13
- package/lib/types/session-query.d.ts +92 -0
- package/lib/types/store.d.ts +3 -0
- package/lib/types/terminal-title.d.ts +58 -0
- package/lib/types/update-panel.d.ts +49 -0
- package/lib/types/update.d.ts +66 -0
- package/package.json +307 -162
- package/src/app.ts +730 -266
- package/src/attachments.ts +110 -11
- package/src/commands.ts +35 -5
- package/src/index.ts +1986 -1779
- package/src/internals.ts +66 -40
- package/src/kernel-panels.ts +89 -3
- package/src/provider-settings.ts +12 -12
- package/src/render/animations.ts +606 -403
- package/src/render/editor.ts +5 -4
- package/src/render/export.ts +13 -3
- package/src/render/fuzzy.ts +83 -0
- package/src/render/ime-cursor.ts +147 -0
- package/src/render/projection.ts +1974 -1621
- package/src/render/status.ts +18 -4
- package/src/session-directory.ts +94 -16
- package/src/session-query.ts +235 -0
- package/src/skills.ts +23 -9
- package/src/store.ts +39 -1
- package/src/subagents.ts +26 -3
- package/src/terminal-title.ts +173 -0
- package/src/update-panel.ts +246 -0
- package/src/update.ts +110 -0
package/src/render/projection.ts
CHANGED
|
@@ -1,1621 +1,1974 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure session-event-to-view projection for the TUI transcript: one reducer
|
|
3
|
-
* over {@link SessionEvent}s producing the ordered entries the renderer draws.
|
|
4
|
-
* Rendering never reads the session directly — this module owns the view
|
|
5
|
-
* model, so tests drive it with plain event arrays.
|
|
6
|
-
*
|
|
7
|
-
* @module @deepseek-ai/dsh-tui/render/projection
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { boundContextSummary, type ContentBlock, type ImageBlock, type MessageId } from '@deepseek-ai/dsh-llm'
|
|
11
|
-
import type {
|
|
12
|
-
import type {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
import type {} from '@deepseek-ai/dsh-
|
|
20
|
-
import type {} from '@deepseek-ai/dsh-
|
|
21
|
-
import type {} from '@deepseek-ai/dsh-
|
|
22
|
-
import type {} from '@deepseek-ai/dsh-
|
|
23
|
-
import type {} from '@deepseek-ai/dsh-
|
|
24
|
-
import type {} from '@deepseek-ai/dsh-
|
|
25
|
-
import type {} from '@deepseek-ai/dsh-
|
|
26
|
-
import type {} from '@deepseek-ai/dsh-
|
|
27
|
-
import {
|
|
28
|
-
import
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
*/
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
},
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
if (
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
:
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
view
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
//
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
:
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
return {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
case '
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
const
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
const
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
acc.
|
|
1472
|
-
acc.stats = {
|
|
1473
|
-
...acc.stats,
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Pure session-event-to-view projection for the TUI transcript: one reducer
|
|
3
|
+
* over {@link SessionEvent}s producing the ordered entries the renderer draws.
|
|
4
|
+
* Rendering never reads the session directly — this module owns the view
|
|
5
|
+
* model, so tests drive it with plain event arrays.
|
|
6
|
+
*
|
|
7
|
+
* @module @deepseek-ai/dsh-tui/render/projection
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { assistantStreamFirstTokenTime, boundContextSummary, isTokenDelta, type ContentBlock, type FileBlock, type ImageBlock, type MessageId, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
11
|
+
import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
12
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
13
|
+
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'
|
|
14
|
+
import { graphemeWidth, splitGraphemes } from './width.ts'
|
|
15
|
+
// Type-only imports merge the plugin-owned SessionEventMap variants
|
|
16
|
+
// (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
|
|
17
|
+
// plan/mode, permission/preset, sandbox/mode, session/title) into the union
|
|
18
|
+
// this reducer switches on.
|
|
19
|
+
import type {} from '@deepseek-ai/dsh-agent'
|
|
20
|
+
import type {} from '@deepseek-ai/dsh-commands'
|
|
21
|
+
import type {} from '@deepseek-ai/dsh-compaction'
|
|
22
|
+
import type {} from '@deepseek-ai/dsh-goal'
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-llm-retry'
|
|
24
|
+
import type {} from '@deepseek-ai/dsh-plan-mode'
|
|
25
|
+
import type {} from '@deepseek-ai/dsh-permission-presets'
|
|
26
|
+
import type {} from '@deepseek-ai/dsh-schedule'
|
|
27
|
+
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
|
28
|
+
import type {} from '@deepseek-ai/dsh-session-title'
|
|
29
|
+
// The subagent package's durable catalog event joins the union the same way
|
|
30
|
+
// (parent-owned facts; the fold itself lives with the live feed).
|
|
31
|
+
import type {} from '@deepseek-ai/dsh-subagent'
|
|
32
|
+
import { toolArgumentsPreview, toolPromptPreview } from './tool-preview.ts'
|
|
33
|
+
import { toolResultDetail, type ToolDetail } from './tool-detail.ts'
|
|
34
|
+
|
|
35
|
+
/** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
|
|
36
|
+
const MAX_STREAMING_CHARS = 65_536
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Upper bound on remembered `compaction/summary` shadow prices waiting for a
|
|
40
|
+
* matching `compaction/end`. Compactions are sequential and rare, so a few
|
|
41
|
+
* slots suffice; an aborted compaction (summary without end) otherwise leaves
|
|
42
|
+
* an unbounded residue in `anchors.compactionTokens`. An evicted price
|
|
43
|
+
* degrades to the documented `lastPruneTokens` fallback, exactly like a
|
|
44
|
+
* missing summary.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_COMPACTION_SUMMARY_RESIDUE = 16
|
|
47
|
+
|
|
48
|
+
/** Append one delta without retaining an unbounded duplicate of the live reply. */
|
|
49
|
+
function appendStreamingTail(current: string, delta: string): string {
|
|
50
|
+
const next = current + delta
|
|
51
|
+
return next.length <= MAX_STREAMING_CHARS ? next : next.slice(-MAX_STREAMING_CHARS)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One user prompt line. */
|
|
55
|
+
export interface UserEntry {
|
|
56
|
+
kind: 'user'
|
|
57
|
+
/** Joined text blocks of the user message. */
|
|
58
|
+
text: string
|
|
59
|
+
/** True for collapsed injected context (plugin/continuation notices), which
|
|
60
|
+
* the renderer marks with a dim ↳ instead of the user ❯ prompt. */
|
|
61
|
+
notice: boolean
|
|
62
|
+
/** Durable image references carried by this prompt. */
|
|
63
|
+
images?: readonly ImageBlock['attachment'][]
|
|
64
|
+
/** Durable file references carried by this prompt (0.1.5 file blocks). */
|
|
65
|
+
files?: readonly FileAttachmentRef[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One user message waiting in the agent inbox (the web's queued-message row). */
|
|
69
|
+
export interface PendingEntry {
|
|
70
|
+
kind: 'pending'
|
|
71
|
+
/** Stable message identity shared with the durable `user/message` that retires it. */
|
|
72
|
+
messageId: MessageId
|
|
73
|
+
/** Which inbox list holds the message: steering is consumed at the next step boundary. */
|
|
74
|
+
target: 'next-turn' | 'next-step'
|
|
75
|
+
/** Full message text — Codex PendingSteer renders queued prompts exactly like user rows. */
|
|
76
|
+
text: string
|
|
77
|
+
/** Durable image references queued with this prompt. */
|
|
78
|
+
images?: readonly ImageBlock['attachment'][]
|
|
79
|
+
/** Durable file references queued with this prompt (0.1.5 file blocks). */
|
|
80
|
+
files?: readonly FileAttachmentRef[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One authoritative assembled assistant reply. */
|
|
84
|
+
export interface AssistantEntry {
|
|
85
|
+
kind: 'assistant'
|
|
86
|
+
/** Joined text blocks of the assistant message. */
|
|
87
|
+
text: string
|
|
88
|
+
/** Joined reasoning blocks from the same assembled message. */
|
|
89
|
+
reasoning: string
|
|
90
|
+
/** True when a cancelled stream's delivered prefix was finalized as this
|
|
91
|
+
* entry (rc.8 `assistant/message.interrupted`) — rendered with a marker. */
|
|
92
|
+
interrupted?: true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One model-requested tool invocation and its settled state. */
|
|
96
|
+
export interface ToolEntry {
|
|
97
|
+
kind: 'tool'
|
|
98
|
+
/** Correlation id shared with the matching `tool/result`. */
|
|
99
|
+
callId: string
|
|
100
|
+
/**
|
|
101
|
+
* Global tool-call ordinal across the whole transcript (1, 2, 3…, never
|
|
102
|
+
* reset between turns). The tool-card badge and every error line that
|
|
103
|
+
* references the failed call share this number, so "call N" in an error
|
|
104
|
+
* always names the exact card the badge shows.
|
|
105
|
+
*/
|
|
106
|
+
ordinal: number
|
|
107
|
+
/** Tool name as the model addressed it. */
|
|
108
|
+
name: string
|
|
109
|
+
/** Raw arguments JSON string exactly as the model produced it. */
|
|
110
|
+
arguments: string
|
|
111
|
+
/** Bounded human-meaningful arguments preview for the tool card. */
|
|
112
|
+
preview: string
|
|
113
|
+
/** Bounded delegation prompt (subagent cards' second row), '' when none. */
|
|
114
|
+
prompt: string
|
|
115
|
+
/** Execution state; `running` until the paired result lands. */
|
|
116
|
+
state: 'running' | 'done' | 'error'
|
|
117
|
+
/** Bounded first text block of the result, empty until it lands. */
|
|
118
|
+
summary: string
|
|
119
|
+
/**
|
|
120
|
+
* Bounded expansion payload for the verbose transcript (Ctrl+O), derived
|
|
121
|
+
* from the tool's persisted presentation metadata; undefined until the
|
|
122
|
+
* result lands and only when something renderable exists.
|
|
123
|
+
*/
|
|
124
|
+
detail: ToolDetail | undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One slash-command execution dispatched through `ctx.commands`. */
|
|
128
|
+
export interface CommandEntry {
|
|
129
|
+
kind: 'command'
|
|
130
|
+
/** Pairing id shared with the matching `command/done`. */
|
|
131
|
+
commandId: string
|
|
132
|
+
/** Lowercase command name without the leading slash. */
|
|
133
|
+
name: string
|
|
134
|
+
/** Verbatim text following the command name. */
|
|
135
|
+
args: string
|
|
136
|
+
/** Execution state; `running` until the paired lifecycle event lands. */
|
|
137
|
+
state: 'running' | 'done' | 'error'
|
|
138
|
+
/** Handler outcome text, empty until it lands. */
|
|
139
|
+
summary: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One turn-level failure surfaced from `turn/end`. */
|
|
143
|
+
export interface ErrorEntry {
|
|
144
|
+
kind: 'error'
|
|
145
|
+
/** `code: message` of the failure. */
|
|
146
|
+
text: string
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One non-error turn outcome surfaced from `turn/end`. */
|
|
150
|
+
export interface TurnMarkerEntry {
|
|
151
|
+
kind: 'turn-marker'
|
|
152
|
+
/** Human-readable outcome line, dim-rendered. */
|
|
153
|
+
text: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One completed compaction lifecycle surfaced from `compaction/end`. */
|
|
157
|
+
export interface CompactionEntry {
|
|
158
|
+
kind: 'compaction'
|
|
159
|
+
/** True when the compaction completed, false when it failed. */
|
|
160
|
+
ok: boolean
|
|
161
|
+
/** Heuristic tokens shadowed by the compaction (summary or prune price). */
|
|
162
|
+
tokens: number
|
|
163
|
+
/** Failure text when `ok` is false, empty otherwise. */
|
|
164
|
+
error: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** One provider-routed model-request retry (the `llm/retry` pair). */
|
|
168
|
+
export interface RetryEntry {
|
|
169
|
+
kind: 'retry'
|
|
170
|
+
/** Correlation id shared with the matching `llm/retry-started`. */
|
|
171
|
+
retryId: string
|
|
172
|
+
/** Retry policy mode from the event: `always` has no attempt cap. */
|
|
173
|
+
mode: 'normal' | 'always'
|
|
174
|
+
/** Attempt ordinal and its cap. */
|
|
175
|
+
attempt: number
|
|
176
|
+
max: number
|
|
177
|
+
/** Failure code that triggered the retry. */
|
|
178
|
+
code: string
|
|
179
|
+
/** Backoff wait before the next attempt, in ms. */
|
|
180
|
+
delayMs: number
|
|
181
|
+
/**
|
|
182
|
+
* `running` while the backoff waits, `done` once the attempt started — or
|
|
183
|
+
* when the turn ended first (the turn-end sweep finalizes orphans so they
|
|
184
|
+
* never pin the settled boundary).
|
|
185
|
+
*/
|
|
186
|
+
state: 'running' | 'done'
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Turn-tail deliverables: files mutated by the turn's diff-bearing tools. */
|
|
190
|
+
export interface FilesEntry {
|
|
191
|
+
kind: 'files'
|
|
192
|
+
/** Unique mutated paths in call order, bounded. */
|
|
193
|
+
paths: readonly string[]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Ordered transcript items the renderer draws. */
|
|
197
|
+
export type TranscriptEntry = UserEntry | PendingEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry
|
|
198
|
+
|
|
199
|
+
/** The live goal the status line badges, folded from `goal/change`. */
|
|
200
|
+
export interface GoalFold {
|
|
201
|
+
/** Human-requested completion objective. */
|
|
202
|
+
objective: string
|
|
203
|
+
/** Durable lifecycle phase. */
|
|
204
|
+
phase: 'active' | 'paused' | 'blocked' | 'complete'
|
|
205
|
+
/** Highest admitted continuation round and its cap. */
|
|
206
|
+
rounds: number
|
|
207
|
+
max: number
|
|
208
|
+
/** Blocked explanation, empty outside the blocked phase. */
|
|
209
|
+
blocked: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Cumulative token accounting folded from `assistant/message` usage reports. */
|
|
213
|
+
export interface UsageTotals {
|
|
214
|
+
/** Prompt-side billed tokens: `inputTokens` plus both cache buckets. */
|
|
215
|
+
inputTokens: number
|
|
216
|
+
/** Completion-side tokens over the whole log. */
|
|
217
|
+
outputTokens: number
|
|
218
|
+
/** Cache-read tokens over the whole log (0 when the adapter reports none). */
|
|
219
|
+
cacheReadTokens: number
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Estimated used tokens per context content type, folded from transcript
|
|
224
|
+
* events via {@link estimateTokens}. The segmented context bar's composition
|
|
225
|
+
* source: proportions across types are meaningful, absolute values are not
|
|
226
|
+
* (they never touch billing or the reported `lastPromptTokens`).
|
|
227
|
+
*/
|
|
228
|
+
export interface ContextSegments {
|
|
229
|
+
/** Rendered system-prompt text (latest `request/header`) plus injected-context notices. */
|
|
230
|
+
system: number
|
|
231
|
+
/** Direct human prompts (durable `user/message` rows). */
|
|
232
|
+
prompt: number
|
|
233
|
+
/** Assistant text blocks (visible replies). */
|
|
234
|
+
assistant: number
|
|
235
|
+
/** Assistant reasoning blocks (hidden thinking). */
|
|
236
|
+
thinking: number
|
|
237
|
+
/** Tool call arguments plus result text. */
|
|
238
|
+
tools: number
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Window-scoped figures the status line shows; timing uses event timestamps. */
|
|
242
|
+
export interface TranscriptStats {
|
|
243
|
+
/** Durable turns opened (`turn/start` events). */
|
|
244
|
+
turns: number
|
|
245
|
+
/** Model requests made (`step/start` events). */
|
|
246
|
+
steps: number
|
|
247
|
+
/** Summed model wall time: `step/start` → `assistant/message`, in ms. */
|
|
248
|
+
llmMs: number
|
|
249
|
+
/** Summed tool wall time: `tool/call` → `tool/result`, in ms. */
|
|
250
|
+
toolMs: number
|
|
251
|
+
/** Cumulative token accounting; input stays 0 until a report lands. */
|
|
252
|
+
usage: UsageTotals
|
|
253
|
+
/** Prompt-side size of the most recent reported request (context pressure). */
|
|
254
|
+
lastPromptTokens: number
|
|
255
|
+
/** Newest advertised route capacity, 0 when no adapter ever advertised one. */
|
|
256
|
+
contextWindow: number
|
|
257
|
+
/** Estimated used tokens per content type (the segmented bar's composition). */
|
|
258
|
+
contextSegments: ContextSegments
|
|
259
|
+
/** Summed first-token waits: `step/start` → first non-empty chunk, in ms. */
|
|
260
|
+
ttftMs: number
|
|
261
|
+
/** Steps that produced a first chunk (the TTFT average's denominator). */
|
|
262
|
+
ttftSteps: number
|
|
263
|
+
/** Summed decode spans: first chunk → `assistant/message`, in ms. */
|
|
264
|
+
decodeMs: number
|
|
265
|
+
/** Completion tokens over timed decode spans (the tok/s numerator). */
|
|
266
|
+
decodeTokens: number
|
|
267
|
+
/**
|
|
268
|
+
* Adapter-owned reasoning effort of the latest `request/header` config —
|
|
269
|
+
* the EFFECTIVE effort the session actually uses (a materialized model
|
|
270
|
+
* default is included, exactly as the adapter resolved it). Empty when the
|
|
271
|
+
* header carried none (provider-default behavior). The status line appends
|
|
272
|
+
* it to the model segment as `provider/model@effort`.
|
|
273
|
+
*/
|
|
274
|
+
reasoningEffort: string
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One active reminder folded from durable `schedule/change` events. */
|
|
278
|
+
export interface ScheduleRow {
|
|
279
|
+
readonly id: string
|
|
280
|
+
readonly kind: 'after' | 'at' | 'every'
|
|
281
|
+
readonly prompt: string
|
|
282
|
+
/** Next due time (epoch ms); the /schedule panel derives overdue/relative labels. */
|
|
283
|
+
readonly targetAt: number
|
|
284
|
+
/** Recurrence seconds for 'every' rows, undefined otherwise. */
|
|
285
|
+
readonly everySeconds?: number
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The durable `schedule/change` payload shape this fold consumes. Upstream
|
|
290
|
+
* strict-decodes the whole transition stream before appending, so unknown
|
|
291
|
+
* ids here are corrupt-input edges that degrade to a no-op.
|
|
292
|
+
*/
|
|
293
|
+
export interface ScheduleChangeLike {
|
|
294
|
+
readonly operation: 'create' | 'delete' | 'dispatch'
|
|
295
|
+
readonly schedule?: {
|
|
296
|
+
readonly id: string
|
|
297
|
+
readonly kind: 'after' | 'at' | 'every'
|
|
298
|
+
readonly prompt: string
|
|
299
|
+
readonly afterSeconds?: number
|
|
300
|
+
readonly everySeconds?: number
|
|
301
|
+
readonly scheduledAt: string
|
|
302
|
+
}
|
|
303
|
+
readonly id?: string
|
|
304
|
+
readonly acceptedAt?: string
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/*
|
|
308
|
+
* Upstream record semantics (dsh-schedule types): `scheduledAt` is ALREADY
|
|
309
|
+
* the due instant — AfterScheduleRecord carries the RFC 3339 UTC target
|
|
310
|
+
* (delay included), EveryScheduleRecord carries the earliest anchor-aligned
|
|
311
|
+
* occurrence not yet dispatched. No kind ever adds its own interval on top.
|
|
312
|
+
*/
|
|
313
|
+
|
|
314
|
+
/** Fold one `schedule/change` into the active-reminder list (create/delete/dispatch). */
|
|
315
|
+
export function applyScheduleChange(rows: readonly ScheduleRow[], data: ScheduleChangeLike): readonly ScheduleRow[] {
|
|
316
|
+
if (data.operation === 'create' && data.schedule !== undefined) {
|
|
317
|
+
const schedule = data.schedule
|
|
318
|
+
const row: ScheduleRow = {
|
|
319
|
+
id: schedule.id,
|
|
320
|
+
kind: schedule.kind,
|
|
321
|
+
prompt: schedule.prompt,
|
|
322
|
+
targetAt: Date.parse(schedule.scheduledAt),
|
|
323
|
+
...(schedule.kind === 'every' ? { everySeconds: schedule.everySeconds ?? 0 } : {}),
|
|
324
|
+
}
|
|
325
|
+
return [...rows.filter(existing => existing.id !== schedule.id), row]
|
|
326
|
+
}
|
|
327
|
+
if (data.operation === 'delete' && data.id !== undefined) {
|
|
328
|
+
return rows.filter(existing => existing.id !== data.id)
|
|
329
|
+
}
|
|
330
|
+
if (data.operation === 'dispatch' && data.id !== undefined) {
|
|
331
|
+
// A dispatched one-shot reminder is finished. An 'every' reminder
|
|
332
|
+
// advances PAST every missed occurrence in one step: the next target is
|
|
333
|
+
// the first anchor-aligned instant strictly after acceptedAt, stepping
|
|
334
|
+
// from the previous aligned target (upstream advances the same way).
|
|
335
|
+
if (data.acceptedAt === undefined) return rows.filter(existing => existing.id !== data.id)
|
|
336
|
+
const accepted = Date.parse(data.acceptedAt)
|
|
337
|
+
return rows.map(existing => existing.id === data.id
|
|
338
|
+
? { ...existing, targetAt: nextEveryTarget(existing.targetAt, accepted, existing.everySeconds ?? 0) }
|
|
339
|
+
: existing)
|
|
340
|
+
}
|
|
341
|
+
return rows
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** First anchor-aligned target after `acceptedAt`, stepping from the previous aligned target. */
|
|
345
|
+
export function nextEveryTarget(previousTarget: number, acceptedAt: number, everySeconds: number): number {
|
|
346
|
+
const interval = Math.max(1, everySeconds) * 1000
|
|
347
|
+
if (acceptedAt <= previousTarget) return previousTarget + interval
|
|
348
|
+
const missed = Math.ceil((acceptedAt - previousTarget + 1) / interval)
|
|
349
|
+
return previousTarget + missed * interval
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Plugin snapshot sources folded into token stats but never rendered as rows. */
|
|
353
|
+
const HIDDEN_SNAPSHOT_PLUGINS = new Set(['time-context', 'tmux-context'])
|
|
354
|
+
/** Plugin prompt sources rendered as full user rows (they ARE the conversation). */
|
|
355
|
+
const REMINDER_PLUGINS = new Set(['schedule'])
|
|
356
|
+
|
|
357
|
+
/** The complete TUI transcript view for one session. */
|
|
358
|
+
export interface TranscriptView {
|
|
359
|
+
/** Settled entries in log order. */
|
|
360
|
+
entries: readonly TranscriptEntry[]
|
|
361
|
+
/** Bounded text tail accumulated from live stream frames since the last settlement. */
|
|
362
|
+
streaming: string
|
|
363
|
+
/** Bounded thinking tail accumulated from reasoning deltas since the last flush. */
|
|
364
|
+
streamingReasoning: string
|
|
365
|
+
/** Latest whole-list todo snapshot from `todo/write`, empty when none. */
|
|
366
|
+
todos: readonly TodoItem[]
|
|
367
|
+
/**
|
|
368
|
+
* Global tool-call ordinal counter: the number the NEXT `tool/call` lands
|
|
369
|
+
* with (1-based). Never reset, so the counter and the badges/error lines
|
|
370
|
+
* stay consistent across turns and resumed sessions.
|
|
371
|
+
*/
|
|
372
|
+
toolCallOrdinal: number
|
|
373
|
+
/** True while a durable turn is open (`turn/start` … `turn/end`). */
|
|
374
|
+
busy: boolean
|
|
375
|
+
/** `turn/start` time of the open turn (0 while idle) — the web TurnStatus clock anchor. */
|
|
376
|
+
busySince: number
|
|
377
|
+
/** Figures the status line renders. */
|
|
378
|
+
stats: TranscriptStats
|
|
379
|
+
/**
|
|
380
|
+
* The `provider/model` pair of the last `request/header` snapshot — the
|
|
381
|
+
* session's own model record, which a resumed TUI prefers over the
|
|
382
|
+
* deployment default (mirrors the web host's resume selection order).
|
|
383
|
+
* Empty before the session's first request.
|
|
384
|
+
*/
|
|
385
|
+
model: string
|
|
386
|
+
/** Plan mode state folded from the last `plan/mode` event. */
|
|
387
|
+
plan: boolean
|
|
388
|
+
/** Active permission preset folded from the last `permission/preset` event, empty before one. */
|
|
389
|
+
permission: string
|
|
390
|
+
/** Latest session title folded from the last `session/title` event, empty before one. */
|
|
391
|
+
title: string
|
|
392
|
+
/**
|
|
393
|
+
* Effective system prompt assembled from `system/message` surface nodes
|
|
394
|
+
* (v3): the head node's text joined with every later non-empty node, blank
|
|
395
|
+
* lines between. Empty before the first system node or when every node is
|
|
396
|
+
* empty ("no system prompt").
|
|
397
|
+
*/
|
|
398
|
+
systemPrompt: string
|
|
399
|
+
/** Sandbox-mode override folded from the last `sandbox/mode` event, empty when never switched. */
|
|
400
|
+
sandbox: string
|
|
401
|
+
/** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
|
|
402
|
+
goal: GoalFold | undefined
|
|
403
|
+
/** Active reminders folded from `schedule/change` events, oldest target first at render. */
|
|
404
|
+
schedules: readonly ScheduleRow[]
|
|
405
|
+
/**
|
|
406
|
+
* Ordered live message ids per inbox target, mirrored from
|
|
407
|
+
* `agent/inbox/spliced` exactly like the upstream Inbox projection — the
|
|
408
|
+
* coordinates later removals resolve against.
|
|
409
|
+
*/
|
|
410
|
+
pending: { 'next-turn': readonly string[]; 'next-step': readonly string[] }
|
|
411
|
+
/**
|
|
412
|
+
* Fold-internal timing anchors, never rendered: open step and tool-call
|
|
413
|
+
* start timestamps the next `assistant/message` / `tool/result` resolves
|
|
414
|
+
* against. Keyed `turn:step` and by call id. `turnSteps`/`turnTools`
|
|
415
|
+
* track which step/tool anchors still belong to the open turn so
|
|
416
|
+
* `turn/end` (and a superseding `step/start`) can sweep anchors an
|
|
417
|
+
* interruption left behind; `turnFiles` keys mutated paths by turn.
|
|
418
|
+
*/
|
|
419
|
+
readonly anchors: {
|
|
420
|
+
stepStart: Map<string, number>
|
|
421
|
+
toolStart: Map<string, number>
|
|
422
|
+
firstChunkAt: Map<string, number>
|
|
423
|
+
compactionTokens: Map<string, number>
|
|
424
|
+
lastPruneTokens: number
|
|
425
|
+
turnFiles: Map<number, Set<string>>
|
|
426
|
+
turnSteps: Map<number, string>
|
|
427
|
+
turnTools: Map<number, Set<string>>
|
|
428
|
+
/** Live `system/message` surface nodes by event seq (empty string = an empty node). */
|
|
429
|
+
systemNodes: Map<number, string>
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Assemble the effective system prompt from surface nodes: head text plus every later non-empty node. */
|
|
434
|
+
function assembleSystemPrompt(nodes: ReadonlyMap<number, string>): string {
|
|
435
|
+
if (nodes.size === 0) return ''
|
|
436
|
+
const ordered = [...nodes.entries()].sort((left, right) => left[0] - right[0])
|
|
437
|
+
const texts = ordered
|
|
438
|
+
.map(([, text]) => text)
|
|
439
|
+
.filter(text => text !== '')
|
|
440
|
+
return texts.join('\n\n')
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Apply one surface event's replace to the live system nodes. Any surface
|
|
445
|
+
* event may shadow system nodes — the kernel's compaction summary lands as a
|
|
446
|
+
* `user/message` replace whose range can cover later system nodes (only node
|
|
447
|
+
* 0 is compaction-protected upstream) — so every surface fold retires covered
|
|
448
|
+
* nodes, not just `system/message` itself.
|
|
449
|
+
* @param nodes - the live system-node map (mutated when the event replaces).
|
|
450
|
+
* @param surfaceOp - the surface operation the event carries, when it is a
|
|
451
|
+
* surface event (log-only events have none and change nothing).
|
|
452
|
+
* @returns the reassembled prompt when nodes were retired, `changed: false`
|
|
453
|
+
* when the event shadows nothing.
|
|
454
|
+
*/
|
|
455
|
+
function retireShadowedSystemNodes(
|
|
456
|
+
nodes: Map<number, string>,
|
|
457
|
+
surfaceOp: 'append' | { readonly op: 'replace'; readonly startSeq: number; readonly endSeq: number } | undefined,
|
|
458
|
+
): { readonly prompt: string; readonly changed: boolean } {
|
|
459
|
+
if (surfaceOp === undefined || surfaceOp === 'append') return { prompt: '', changed: false }
|
|
460
|
+
let changed = false
|
|
461
|
+
for (const seq of nodes.keys()) {
|
|
462
|
+
if (seq >= surfaceOp.startSeq && seq <= surfaceOp.endSeq) {
|
|
463
|
+
nodes.delete(seq)
|
|
464
|
+
changed = true
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return changed ? { prompt: assembleSystemPrompt(nodes), changed } : { prompt: '', changed: false }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Join the text blocks of a content list; non-text blocks contribute nothing. */
|
|
471
|
+
function textOf(content: readonly ContentBlock[]): string {
|
|
472
|
+
return content.filter(block => block.type === 'text').map(block => block.text).join('')
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Snapshot-isolate one anchors block (Maps and their nested Sets): a view
|
|
477
|
+
* already handed to the renderer must never observe a later fold through a
|
|
478
|
+
* shared container. The collections are small and turn-bounded, so cloning
|
|
479
|
+
* per event is cheap next to the entries copy the reducer already makes.
|
|
480
|
+
*/
|
|
481
|
+
function cloneViewAnchors(anchors: TranscriptView['anchors']): TranscriptView['anchors'] {
|
|
482
|
+
return {
|
|
483
|
+
stepStart: new Map(anchors.stepStart),
|
|
484
|
+
toolStart: new Map(anchors.toolStart),
|
|
485
|
+
firstChunkAt: new Map(anchors.firstChunkAt),
|
|
486
|
+
compactionTokens: new Map(anchors.compactionTokens),
|
|
487
|
+
lastPruneTokens: anchors.lastPruneTokens,
|
|
488
|
+
turnFiles: new Map([...anchors.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
|
|
489
|
+
turnSteps: new Map(anchors.turnSteps),
|
|
490
|
+
turnTools: new Map([...anchors.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
|
|
491
|
+
systemNodes: new Map(anchors.systemNodes),
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Durable image references in their model-visible order. */
|
|
496
|
+
function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attachment'][] {
|
|
497
|
+
return content.filter((block): block is ImageBlock => block.type === 'image').map(block => block.attachment)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Durable file references in their model-visible order. */
|
|
501
|
+
function filesOf(content: readonly ContentBlock[]): readonly FileAttachmentRef[] {
|
|
502
|
+
return content.filter((block): block is FileBlock => block.type === 'file').map(block => block.attachment)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Human-readable bounded image labels for transcript, inspector, and export surfaces. */
|
|
506
|
+
export function imageLabels(images: readonly ImageBlock['attachment'][] | undefined): string {
|
|
507
|
+
if (images === undefined || images.length === 0) return ''
|
|
508
|
+
return images.map((image, index) => {
|
|
509
|
+
const rawName = image.name?.trim() || `image ${index + 1}`
|
|
510
|
+
const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
|
|
511
|
+
const original = image.originalDimensions
|
|
512
|
+
const dimensions = original === undefined
|
|
513
|
+
? `${image.width}×${image.height}`
|
|
514
|
+
: `${image.width}×${image.height} · original ${original.width}×${original.height}`
|
|
515
|
+
return `[image: ${name} · ${dimensions} · ${image.bytes} B]`
|
|
516
|
+
}).join('\n')
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Human-readable bounded file labels for the same surfaces (0.1.5 file blocks). */
|
|
520
|
+
export function fileLabels(files: readonly FileAttachmentRef[] | undefined): string {
|
|
521
|
+
if (files === undefined || files.length === 0) return ''
|
|
522
|
+
return files.map((file, index) => {
|
|
523
|
+
const rawName = file.name?.trim() || `file ${index + 1}`
|
|
524
|
+
const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
|
|
525
|
+
return `[file: ${name} · ${file.bytes} B]`
|
|
526
|
+
}).join('\n')
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Prompt text with its durable image and file labels, without exposing local paths or bytes. */
|
|
530
|
+
export function promptDisplayText(entry: Pick<UserEntry | PendingEntry, 'text' | 'images' | 'files'>): string {
|
|
531
|
+
const imageText = imageLabels(entry.images)
|
|
532
|
+
const fileText = fileLabels(entry.files)
|
|
533
|
+
const labels = imageText === '' ? fileText : fileText === '' ? imageText : `${imageText}\n${fileText}`
|
|
534
|
+
return entry.text === '' ? labels : labels === '' ? entry.text : `${entry.text}\n${labels}`
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
|
|
538
|
+
function reasoningOf(content: readonly ContentBlock[]): string {
|
|
539
|
+
return content.filter(block => block.type === 'reasoning').map(block => block.text).join('')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Rough token estimate for the segmented context bar (pi-nano-context's ~4
|
|
544
|
+
* chars/token heuristic, CJK-aware so a Chinese prompt is not quartered):
|
|
545
|
+
* CJK/wide chars cost ~1 token each, ASCII ~4 chars per token. Estimates
|
|
546
|
+
* drive bar PROPORTIONS, never billing, so precision is not required.
|
|
547
|
+
* @param text - the text to estimate.
|
|
548
|
+
* @returns an integer token estimate, 0 for empty text.
|
|
549
|
+
*/
|
|
550
|
+
function estimateTokens(text: string): number {
|
|
551
|
+
let wide = 0
|
|
552
|
+
let narrow = 0
|
|
553
|
+
for (const cluster of splitGraphemes(text)) {
|
|
554
|
+
if (graphemeWidth(cluster) > 1) wide += 1
|
|
555
|
+
else narrow += 1
|
|
556
|
+
}
|
|
557
|
+
return wide + Math.ceil(narrow / 4)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** A fresh, empty transcript view. */
|
|
561
|
+
export function createTranscriptView(): TranscriptView {
|
|
562
|
+
return {
|
|
563
|
+
entries: [],
|
|
564
|
+
streaming: '',
|
|
565
|
+
streamingReasoning: '',
|
|
566
|
+
todos: [],
|
|
567
|
+
toolCallOrdinal: 0,
|
|
568
|
+
busy: false,
|
|
569
|
+
busySince: 0,
|
|
570
|
+
model: '',
|
|
571
|
+
plan: false,
|
|
572
|
+
permission: '',
|
|
573
|
+
title: '',
|
|
574
|
+
systemPrompt: '',
|
|
575
|
+
sandbox: '',
|
|
576
|
+
goal: undefined,
|
|
577
|
+
schedules: [],
|
|
578
|
+
pending: { 'next-turn': [], 'next-step': [] },
|
|
579
|
+
stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
|
|
580
|
+
anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map(), turnSteps: new Map(), turnTools: new Map(), systemNodes: new Map() },
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Full prompt text of a queued message (identical to the durable user row it retires into). */
|
|
585
|
+
function pendingText(content: readonly ContentBlock[]): string {
|
|
586
|
+
return textOf(content)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Fold one session event into an updated view (copy-on-write).
|
|
591
|
+
* @param view - the view before the event.
|
|
592
|
+
* @param event - one durable session event from `session/event` or the log.
|
|
593
|
+
* @returns the view after the event; the input view is never mutated.
|
|
594
|
+
*/
|
|
595
|
+
export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
|
|
596
|
+
// Fold against a private anchors block so the documented contract holds —
|
|
597
|
+
// "the input view is never mutated" — even for the in-place anchor sweeps
|
|
598
|
+
// below; without this, every handed-out view shared live Maps.
|
|
599
|
+
view = { ...view, anchors: cloneViewAnchors(view.anchors) }
|
|
600
|
+
// Any surface event's replace may shadow system nodes (a compaction
|
|
601
|
+
// summary lands as a user/message replace whose range can cover later
|
|
602
|
+
// system nodes); the switch below folds against the shadowed view.
|
|
603
|
+
const shadow = retireShadowedSystemNodes(view.anchors.systemNodes, event.surfaceOp)
|
|
604
|
+
if (shadow.changed) {
|
|
605
|
+
view = {
|
|
606
|
+
...view,
|
|
607
|
+
systemPrompt: shadow.prompt,
|
|
608
|
+
stats: { ...view.stats, contextSegments: { ...view.stats.contextSegments, system: estimateTokens(shadow.prompt) } },
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
switch (event.type) {
|
|
612
|
+
case 'user/message': {
|
|
613
|
+
// A queued row retires when its durable user message lands (the agent
|
|
614
|
+
// claims the inbox and logs the same message identity) — the transient
|
|
615
|
+
// steering/queued preview yields to the real transcript entry.
|
|
616
|
+
const message = event.data
|
|
617
|
+
let entries = view.entries
|
|
618
|
+
let pending = view.pending
|
|
619
|
+
for (const target of ['next-turn', 'next-step'] as const) {
|
|
620
|
+
const index = pending[target].indexOf(message.id)
|
|
621
|
+
if (index < 0) continue
|
|
622
|
+
pending = { ...pending, [target]: pending[target].filter((_, i) => i !== index) }
|
|
623
|
+
entries = entries.filter(entry => !(entry.kind === 'pending' && entry.messageId === message.id))
|
|
624
|
+
}
|
|
625
|
+
// Injected context (plugin/model-continuation sources) stays collapsed
|
|
626
|
+
// to a bounded notice row, exactly like collapsed transcript context
|
|
627
|
+
// elsewhere in the product; only direct human prompts render in full.
|
|
628
|
+
const text = textOf(message.content)
|
|
629
|
+
const images = imagesOf(message.content)
|
|
630
|
+
const files = filesOf(message.content)
|
|
631
|
+
if (message.source.kind === 'user') {
|
|
632
|
+
return {
|
|
633
|
+
...view,
|
|
634
|
+
pending,
|
|
635
|
+
entries: [...entries, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
|
|
636
|
+
stats: {
|
|
637
|
+
...view.stats,
|
|
638
|
+
contextSegments: {
|
|
639
|
+
...view.stats.contextSegments,
|
|
640
|
+
prompt: view.stats.contextSegments.prompt + estimateTokens(text),
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Snapshot injections (time/tmux context) still spend model context but
|
|
646
|
+
// render nothing; the schedule reminder is a real prompt and renders in
|
|
647
|
+
// full — the model acts on it, so the transcript must show it.
|
|
648
|
+
if (message.source.kind === 'plugin' && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
|
|
649
|
+
return {
|
|
650
|
+
...view,
|
|
651
|
+
pending,
|
|
652
|
+
entries,
|
|
653
|
+
stats: {
|
|
654
|
+
...view.stats,
|
|
655
|
+
contextSegments: {
|
|
656
|
+
...view.stats.contextSegments,
|
|
657
|
+
system: view.stats.contextSegments.system + estimateTokens(text),
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin)) {
|
|
663
|
+
return {
|
|
664
|
+
...view,
|
|
665
|
+
pending,
|
|
666
|
+
entries: [...entries, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
|
|
667
|
+
stats: {
|
|
668
|
+
...view.stats,
|
|
669
|
+
contextSegments: {
|
|
670
|
+
...view.stats.contextSegments,
|
|
671
|
+
prompt: view.stats.contextSegments.prompt + estimateTokens(text),
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
|
|
677
|
+
? message.source.summary
|
|
678
|
+
: message.source.kind === 'plugin'
|
|
679
|
+
? message.source.plugin
|
|
680
|
+
: message.source.kind
|
|
681
|
+
const summary = boundContextSummary(notice)
|
|
682
|
+
return {
|
|
683
|
+
...view,
|
|
684
|
+
pending,
|
|
685
|
+
entries: [...entries, { kind: 'user', text: summary, notice: true }],
|
|
686
|
+
stats: {
|
|
687
|
+
...view.stats,
|
|
688
|
+
contextSegments: {
|
|
689
|
+
...view.stats.contextSegments,
|
|
690
|
+
system: view.stats.contextSegments.system + estimateTokens(summary),
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
case 'agent/inbox/spliced': {
|
|
696
|
+
// The durable inbox mutation (web queue-mirror contract, event-sourced):
|
|
697
|
+
// removals drop the projected rows at their inbox coordinates, inserted
|
|
698
|
+
// messages gain a pending row at their log position.
|
|
699
|
+
const { target, start, removedCount = 0, inserted } = event.data
|
|
700
|
+
const ids = view.pending[target]
|
|
701
|
+
const removed = ids.slice(start, start + removedCount)
|
|
702
|
+
// In-place upstream semantics: the kernel's authoritative fold is
|
|
703
|
+
// `inbox.splice(start, removedCount, ...inserted)` — inserted ids land
|
|
704
|
+
// AT the splice position (prepend/replace shapes), never at the tail.
|
|
705
|
+
// A tail append diverged the id order, so later coordinate-based events
|
|
706
|
+
// (next-turn head claims, positioned remove/replace) tombstoned the
|
|
707
|
+
// wrong pending row.
|
|
708
|
+
const nextIds = [
|
|
709
|
+
...ids.slice(0, start),
|
|
710
|
+
...inserted.map(message => message.id),
|
|
711
|
+
...ids.slice(start + removedCount),
|
|
712
|
+
]
|
|
713
|
+
let entries = view.entries
|
|
714
|
+
if (removed.length > 0) {
|
|
715
|
+
const removedSet = new Set(removed)
|
|
716
|
+
entries = entries.filter(entry =>
|
|
717
|
+
!(entry.kind === 'pending' && entry.target === target && removedSet.has(entry.messageId)))
|
|
718
|
+
}
|
|
719
|
+
for (const message of inserted) {
|
|
720
|
+
entries = [...entries, {
|
|
721
|
+
kind: 'pending',
|
|
722
|
+
messageId: message.id,
|
|
723
|
+
target,
|
|
724
|
+
text: pendingText(message.content),
|
|
725
|
+
...imagesOf(message.content).length === 0 ? {} : { images: imagesOf(message.content) },
|
|
726
|
+
...filesOf(message.content).length === 0 ? {} : { files: filesOf(message.content) },
|
|
727
|
+
}]
|
|
728
|
+
}
|
|
729
|
+
return { ...view, entries, pending: { ...view.pending, [target]: nextIds } }
|
|
730
|
+
}
|
|
731
|
+
case 'system/message': {
|
|
732
|
+
// Session-log v3 carries the system prompt as surface nodes (the
|
|
733
|
+
// `request/header.system` field is gone): append adds one node;
|
|
734
|
+
// replace(startSeq, endSeq) retires the covered nodes and this event's
|
|
735
|
+
// node takes their place. The effective prompt is the head text joined
|
|
736
|
+
// with every later non-empty node (kernel in-history assembly), and
|
|
737
|
+
// the context estimate prices that assembly — a tail-clearing
|
|
738
|
+
// replacement never zeroes a surviving head.
|
|
739
|
+
const text = textOf(event.data.message.content)
|
|
740
|
+
const nodes = view.anchors.systemNodes
|
|
741
|
+
retireShadowedSystemNodes(nodes, event.surfaceOp)
|
|
742
|
+
nodes.set(event.seq, text)
|
|
743
|
+
const systemPrompt = assembleSystemPrompt(nodes)
|
|
744
|
+
return {
|
|
745
|
+
...view,
|
|
746
|
+
systemPrompt,
|
|
747
|
+
stats: {
|
|
748
|
+
...view.stats,
|
|
749
|
+
contextSegments: {
|
|
750
|
+
...view.stats.contextSegments,
|
|
751
|
+
system: estimateTokens(systemPrompt),
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
case 'assistant/attempt': {
|
|
757
|
+
// A failed, retried, cancelled, or stream-error attempt that produced no
|
|
758
|
+
// surface message (session-log v2+ folds its chunk stream in here). The
|
|
759
|
+
// step is NOT closed — the kernel's session-stats keeps one step start
|
|
760
|
+
// across in-step retries, so llmMs spans them; keep the anchors so a
|
|
761
|
+
// retrying attempt and its final settlement time the step from one
|
|
762
|
+
// start. An attempt whose first token streamed only live (the frames
|
|
763
|
+
// died before the fold) restores its first-token anchor from the
|
|
764
|
+
// embedded stream exactly like a replayed one.
|
|
765
|
+
const key = `${event.data.turn}:${event.data.step}`
|
|
766
|
+
let stats = view.stats
|
|
767
|
+
if (!view.anchors.firstChunkAt.has(key)) {
|
|
768
|
+
const first = assistantStreamFirstTokenTime(event.data.stream ?? [])
|
|
769
|
+
if (first !== undefined) {
|
|
770
|
+
view.anchors.firstChunkAt.set(key, first)
|
|
771
|
+
const started = view.anchors.stepStart.get(key)
|
|
772
|
+
if (started !== undefined) {
|
|
773
|
+
stats = {
|
|
774
|
+
...stats,
|
|
775
|
+
ttftMs: stats.ttftMs + Math.max(0, first - started),
|
|
776
|
+
ttftSteps: stats.ttftSteps + 1,
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (view.streaming === '' && view.streamingReasoning === '' && stats === view.stats) return view
|
|
782
|
+
return { ...view, streaming: '', streamingReasoning: '', stats }
|
|
783
|
+
}
|
|
784
|
+
case 'assistant/message': {
|
|
785
|
+
// The assembled message is authoritative; drop the streamed buffers.
|
|
786
|
+
const key = `${event.data.turn}:${event.data.step}`
|
|
787
|
+
const started = view.anchors.stepStart.get(key)
|
|
788
|
+
view.anchors.stepStart.delete(key)
|
|
789
|
+
// Live streaming anchored the first token through the process-local
|
|
790
|
+
// assistant-stream frames; a replayed settlement has no live frames, so
|
|
791
|
+
// the embedded stream's own first-token time restores the same anchor
|
|
792
|
+
// AND the TTFT figures live frames accumulate on the live path.
|
|
793
|
+
let firstChunk = view.anchors.firstChunkAt.get(key)
|
|
794
|
+
let stats = view.stats
|
|
795
|
+
if (firstChunk === undefined) {
|
|
796
|
+
firstChunk = assistantStreamFirstTokenTime(event.data.stream ?? [])
|
|
797
|
+
if (firstChunk !== undefined && started !== undefined) {
|
|
798
|
+
stats = {
|
|
799
|
+
...stats,
|
|
800
|
+
ttftMs: stats.ttftMs + Math.max(0, firstChunk - started),
|
|
801
|
+
ttftSteps: stats.ttftSteps + 1,
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
view.anchors.firstChunkAt.delete(key)
|
|
806
|
+
// The assembled message consumes the turn's current step anchor; a
|
|
807
|
+
// later `turn/end` sweep then has nothing left to clean for this step.
|
|
808
|
+
if (view.anchors.turnSteps.get(event.data.turn) === key) view.anchors.turnSteps.delete(event.data.turn)
|
|
809
|
+
const usage = event.data.usage
|
|
810
|
+
const totals = stats.usage
|
|
811
|
+
const text = textOf(event.data.message.content)
|
|
812
|
+
const reasoning = reasoningOf(event.data.message.content)
|
|
813
|
+
return {
|
|
814
|
+
...view,
|
|
815
|
+
streaming: '',
|
|
816
|
+
streamingReasoning: '',
|
|
817
|
+
entries: [...view.entries, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined }],
|
|
818
|
+
stats: {
|
|
819
|
+
...stats,
|
|
820
|
+
llmMs: stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
|
|
821
|
+
usage: usage === undefined ? totals : {
|
|
822
|
+
inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
|
|
823
|
+
outputTokens: totals.outputTokens + usage.outputTokens,
|
|
824
|
+
cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
|
|
825
|
+
},
|
|
826
|
+
lastPromptTokens: usage === undefined ? stats.lastPromptTokens
|
|
827
|
+
: usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
|
|
828
|
+
// Decode span and its tokens pair up: an un-timed step (no first
|
|
829
|
+
// chunk landed) contributes neither, so the rate stays honest.
|
|
830
|
+
decodeMs: stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
|
|
831
|
+
decodeTokens: stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
|
|
832
|
+
contextSegments: {
|
|
833
|
+
...stats.contextSegments,
|
|
834
|
+
thinking: stats.contextSegments.thinking + estimateTokens(reasoning),
|
|
835
|
+
assistant: stats.contextSegments.assistant + estimateTokens(text),
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
case 'tool/call': {
|
|
841
|
+
const data = event.data
|
|
842
|
+
view.anchors.toolStart.set(data.callId, event.time)
|
|
843
|
+
// Remember the call's turn so `turn/end` can sweep a start that never
|
|
844
|
+
// pairs with a result (an interrupted tool otherwise leaks its anchor).
|
|
845
|
+
const turnTools = view.anchors.turnTools.get(data.turn) ?? new Set<string>()
|
|
846
|
+
turnTools.add(data.callId)
|
|
847
|
+
view.anchors.turnTools.set(data.turn, turnTools)
|
|
848
|
+
const ordinal = view.toolCallOrdinal + 1
|
|
849
|
+
return {
|
|
850
|
+
...view,
|
|
851
|
+
toolCallOrdinal: ordinal,
|
|
852
|
+
entries: [
|
|
853
|
+
...view.entries,
|
|
854
|
+
{
|
|
855
|
+
kind: 'tool',
|
|
856
|
+
callId: data.callId,
|
|
857
|
+
ordinal,
|
|
858
|
+
name: data.name,
|
|
859
|
+
arguments: data.arguments,
|
|
860
|
+
preview: toolArgumentsPreview(data.arguments, data.name),
|
|
861
|
+
prompt: toolPromptPreview(data.name, data.arguments),
|
|
862
|
+
state: 'running',
|
|
863
|
+
summary: '',
|
|
864
|
+
detail: undefined,
|
|
865
|
+
}],
|
|
866
|
+
stats: {
|
|
867
|
+
...view.stats,
|
|
868
|
+
contextSegments: {
|
|
869
|
+
...view.stats.contextSegments,
|
|
870
|
+
tools: view.stats.contextSegments.tools
|
|
871
|
+
+ (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
|
|
872
|
+
},
|
|
873
|
+
},
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
case 'tool/result': {
|
|
877
|
+
const block = event.data.message.content[0]
|
|
878
|
+
const started = view.anchors.toolStart.get(block.toolCallId)
|
|
879
|
+
view.anchors.toolStart.delete(block.toolCallId)
|
|
880
|
+
// Deregister the call from its turn's registry so `turn/end` does not
|
|
881
|
+
// sweep a start that already paired with a result.
|
|
882
|
+
const turnTools = view.anchors.turnTools.get(event.data.turn)
|
|
883
|
+
if (turnTools !== undefined) {
|
|
884
|
+
turnTools.delete(block.toolCallId)
|
|
885
|
+
if (turnTools.size === 0) view.anchors.turnTools.delete(event.data.turn)
|
|
886
|
+
}
|
|
887
|
+
const rawText = textOf(block.content)
|
|
888
|
+
const summary = boundContextSummary(rawText)
|
|
889
|
+
// The verbose expansion self-serves from the persisted presentation
|
|
890
|
+
// metadata (diffs, read windows, web sources) with the bounded raw text
|
|
891
|
+
// as the universal fallback — the capable-UI degradation ladder.
|
|
892
|
+
const detail = toolResultDetail(event.data.meta, rawText)
|
|
893
|
+
// Turn-tail deliverables: a diff-bearing mutation records its paths.
|
|
894
|
+
if (detail?.kind === 'diff') {
|
|
895
|
+
const set = view.anchors.turnFiles.get(event.data.turn) ?? new Set<string>()
|
|
896
|
+
for (const diff of detail.diffs) set.add(diff.path)
|
|
897
|
+
view.anchors.turnFiles.set(event.data.turn, set)
|
|
898
|
+
}
|
|
899
|
+
const entries = view.entries.map((entry) => {
|
|
900
|
+
if (entry.kind !== 'tool' || entry.callId !== block.toolCallId) return entry
|
|
901
|
+
return { ...entry, state: block.isError === true ? 'error' as const : 'done' as const, summary, detail }
|
|
902
|
+
})
|
|
903
|
+
return {
|
|
904
|
+
...view,
|
|
905
|
+
entries,
|
|
906
|
+
stats: {
|
|
907
|
+
...view.stats,
|
|
908
|
+
toolMs: view.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
|
|
909
|
+
contextSegments: {
|
|
910
|
+
...view.stats.contextSegments,
|
|
911
|
+
tools: view.stats.contextSegments.tools + estimateTokens(rawText),
|
|
912
|
+
},
|
|
913
|
+
},
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
case 'todo/write':
|
|
917
|
+
return { ...view, todos: event.data.todos }
|
|
918
|
+
case 'turn/start':
|
|
919
|
+
// The web todo projection clears on turn/start: a fresh turn's first
|
|
920
|
+
// write is the authoritative list, and a stale snapshot must not linger
|
|
921
|
+
// through a turn that has not written one yet.
|
|
922
|
+
return {
|
|
923
|
+
...view,
|
|
924
|
+
busy: true,
|
|
925
|
+
busySince: view.busy ? view.busySince : event.time,
|
|
926
|
+
todos: [],
|
|
927
|
+
stats: { ...view.stats, turns: view.stats.turns + 1 },
|
|
928
|
+
}
|
|
929
|
+
case 'step/start': {
|
|
930
|
+
// A step supersedes the turn's previous step: if that step never
|
|
931
|
+
// assembled a message (interrupted), its timing anchors are stale the
|
|
932
|
+
// moment the next step opens and are swept here instead of leaking.
|
|
933
|
+
const key = `${event.data.turn}:${event.data.step}`
|
|
934
|
+
const previous = view.anchors.turnSteps.get(event.data.turn)
|
|
935
|
+
if (previous !== undefined && previous !== key) {
|
|
936
|
+
view.anchors.stepStart.delete(previous)
|
|
937
|
+
view.anchors.firstChunkAt.delete(previous)
|
|
938
|
+
}
|
|
939
|
+
view.anchors.turnSteps.set(event.data.turn, key)
|
|
940
|
+
view.anchors.stepStart.set(key, event.time)
|
|
941
|
+
return {
|
|
942
|
+
...view,
|
|
943
|
+
streaming: '',
|
|
944
|
+
streamingReasoning: '',
|
|
945
|
+
stats: { ...view.stats, steps: view.stats.steps + 1 },
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
case 'turn/end': {
|
|
949
|
+
const reason = event.data.reason
|
|
950
|
+
const appended: TranscriptEntry[] = []
|
|
951
|
+
if (reason.kind === 'error') {
|
|
952
|
+
const recovery = reason.error.code === 'MISSING_CREDENTIAL'
|
|
953
|
+
? ' · open /model to add an API key'
|
|
954
|
+
: ''
|
|
955
|
+
appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
|
|
956
|
+
} else {
|
|
957
|
+
// Non-error outcomes deserve their own durable row (the web renders
|
|
958
|
+
// distinct max-tokens / abort / interruption nodes); `completed` stays
|
|
959
|
+
// silent so an ordinary turn never grows a marker.
|
|
960
|
+
const marker = reason.kind === 'aborted'
|
|
961
|
+
? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
|
|
962
|
+
: reason.kind === 'max-tokens'
|
|
963
|
+
? 'turn hit the output-token ceiling (max-tokens)'
|
|
964
|
+
: reason.kind === 'blocked'
|
|
965
|
+
? 'turn ended blocked'
|
|
966
|
+
: reason.kind === 'interrupted'
|
|
967
|
+
? 'turn was interrupted by a restart'
|
|
968
|
+
: undefined
|
|
969
|
+
if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
|
|
970
|
+
}
|
|
971
|
+
// Deliverables ride the turn tail (the web's turnTail chips): the
|
|
972
|
+
// turn's mutated files flush as one bounded row, then the set resets.
|
|
973
|
+
const files = view.anchors.turnFiles.get(event.data.turn)
|
|
974
|
+
view.anchors.turnFiles.delete(event.data.turn)
|
|
975
|
+
if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
|
|
976
|
+
// Derivable boundary sweep: the turn is over, so any step/tool anchors
|
|
977
|
+
// it left behind (interruptions that never produced their message or
|
|
978
|
+
// result) can never be resolved and are reclaimed now.
|
|
979
|
+
const stepKey = view.anchors.turnSteps.get(event.data.turn)
|
|
980
|
+
if (stepKey !== undefined) {
|
|
981
|
+
view.anchors.stepStart.delete(stepKey)
|
|
982
|
+
view.anchors.firstChunkAt.delete(stepKey)
|
|
983
|
+
view.anchors.turnSteps.delete(event.data.turn)
|
|
984
|
+
}
|
|
985
|
+
const turnToolSet = view.anchors.turnTools.get(event.data.turn)
|
|
986
|
+
if (turnToolSet !== undefined) {
|
|
987
|
+
for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
|
|
988
|
+
view.anchors.turnTools.delete(event.data.turn)
|
|
989
|
+
}
|
|
990
|
+
// Orphaned retry/command rows can never be resolved after the turn
|
|
991
|
+
// ends: an aborted retry backoff returns upstream without its
|
|
992
|
+
// `llm/retry-started`, and crash repair synthesizes only tool/step/
|
|
993
|
+
// turn closers. Left `running` they pin the settled boundary forever,
|
|
994
|
+
// so the turn end finalizes them exactly like the anchor sweep above.
|
|
995
|
+
let orphans = false
|
|
996
|
+
const swept = view.entries.map((entry) => {
|
|
997
|
+
if (entry.kind === 'retry' && entry.state === 'running') {
|
|
998
|
+
orphans = true
|
|
999
|
+
return { ...entry, state: 'done' as const }
|
|
1000
|
+
}
|
|
1001
|
+
if (entry.kind === 'command' && entry.state === 'running') {
|
|
1002
|
+
orphans = true
|
|
1003
|
+
return { ...entry, state: 'error' as const, summary: 'interrupted before the turn ended' }
|
|
1004
|
+
}
|
|
1005
|
+
return entry
|
|
1006
|
+
})
|
|
1007
|
+
const entries = orphans ? swept : view.entries
|
|
1008
|
+
if (appended.length === 0) {
|
|
1009
|
+
return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '', entries }
|
|
1010
|
+
}
|
|
1011
|
+
return {
|
|
1012
|
+
...view,
|
|
1013
|
+
busy: false,
|
|
1014
|
+
busySince: 0,
|
|
1015
|
+
streaming: '',
|
|
1016
|
+
streamingReasoning: '',
|
|
1017
|
+
entries: [...entries, ...appended],
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
case 'llm/retry': {
|
|
1021
|
+
const data = event.data
|
|
1022
|
+
return {
|
|
1023
|
+
...view,
|
|
1024
|
+
streaming: '',
|
|
1025
|
+
streamingReasoning: '',
|
|
1026
|
+
entries: [...view.entries, {
|
|
1027
|
+
kind: 'retry',
|
|
1028
|
+
retryId: data.retryId,
|
|
1029
|
+
mode: data.mode,
|
|
1030
|
+
attempt: data.retry,
|
|
1031
|
+
max: 'maxRetries' in data ? data.maxRetries : data.retry,
|
|
1032
|
+
code: data.failure.code,
|
|
1033
|
+
delayMs: data.delayMs,
|
|
1034
|
+
state: 'running',
|
|
1035
|
+
}],
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
case 'llm/retry-started': {
|
|
1039
|
+
const data = event.data
|
|
1040
|
+
const entries = view.entries.map((entry) => {
|
|
1041
|
+
if (entry.kind !== 'retry' || entry.retryId !== data.retryId) return entry
|
|
1042
|
+
return { ...entry, state: 'done' as const }
|
|
1043
|
+
})
|
|
1044
|
+
return { ...view, entries }
|
|
1045
|
+
}
|
|
1046
|
+
case 'sandbox/mode':
|
|
1047
|
+
// Log-only override switch; last write wins for the status badge.
|
|
1048
|
+
return { ...view, sandbox: event.data.mode }
|
|
1049
|
+
case 'goal/change': {
|
|
1050
|
+
const data = event.data
|
|
1051
|
+
const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
|
|
1052
|
+
if (data.operation === 'clear') {
|
|
1053
|
+
return {
|
|
1054
|
+
...view,
|
|
1055
|
+
goal: undefined,
|
|
1056
|
+
entries: [...view.entries, { kind: 'turn-marker', text: '◎ goal cleared' }],
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const goal: GoalFold = {
|
|
1060
|
+
objective: data.goal.objective,
|
|
1061
|
+
phase: data.goal.phase,
|
|
1062
|
+
rounds: data.roundsStarted,
|
|
1063
|
+
max: data.goal.maxGoalRounds,
|
|
1064
|
+
blocked: data.goal.blockedReason?.message ?? '',
|
|
1065
|
+
}
|
|
1066
|
+
const line = data.operation === 'create'
|
|
1067
|
+
? `◎ goal: ${clip(data.goal.objective)}`
|
|
1068
|
+
: data.operation === 'complete'
|
|
1069
|
+
? '◎ goal complete'
|
|
1070
|
+
: data.operation === 'pause'
|
|
1071
|
+
? '◎ goal paused'
|
|
1072
|
+
: data.operation === 'resume'
|
|
1073
|
+
? '◎ goal resumed'
|
|
1074
|
+
: data.operation === 'block'
|
|
1075
|
+
? `◎ goal blocked: ${clip(goal.blocked)}`
|
|
1076
|
+
: undefined
|
|
1077
|
+
return {
|
|
1078
|
+
...view,
|
|
1079
|
+
goal,
|
|
1080
|
+
entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
case 'schedule/change':
|
|
1084
|
+
// Non-conversational catalog state: the /schedule panel renders the
|
|
1085
|
+
// active list, the transcript shows only the reminder prompts
|
|
1086
|
+
// (handled at user/message above).
|
|
1087
|
+
return { ...view, schedules: applyScheduleChange(view.schedules, event.data) }
|
|
1088
|
+
case 'session/title':
|
|
1089
|
+
// Latest-wins title snapshot, log-only; the status line prefers it.
|
|
1090
|
+
return { ...view, title: event.data.title }
|
|
1091
|
+
case 'compaction/summary':
|
|
1092
|
+
// Remember the shadow price so the matching `compaction/end` row can
|
|
1093
|
+
// state what the compaction reclaimed. The map is capped so an aborted
|
|
1094
|
+
// compaction (summary without end) cannot leave an unbounded residue.
|
|
1095
|
+
if (view.anchors.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
|
|
1096
|
+
const oldest = view.anchors.compactionTokens.keys().next().value
|
|
1097
|
+
if (oldest !== undefined) view.anchors.compactionTokens.delete(oldest)
|
|
1098
|
+
}
|
|
1099
|
+
view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
|
|
1100
|
+
return view
|
|
1101
|
+
case 'compaction/prune':
|
|
1102
|
+
// A model-free prune carries no compaction id; its price serves the next
|
|
1103
|
+
// `compaction/end` that cannot find a summary price.
|
|
1104
|
+
return { ...view, anchors: { ...view.anchors, lastPruneTokens: event.data.shadowedTokenCount } }
|
|
1105
|
+
case 'compaction/end': {
|
|
1106
|
+
const ok = event.data.error === undefined
|
|
1107
|
+
const tokens = view.anchors.compactionTokens.get(event.data.compactionId) ?? view.anchors.lastPruneTokens
|
|
1108
|
+
view.anchors.compactionTokens.delete(event.data.compactionId)
|
|
1109
|
+
return {
|
|
1110
|
+
...view,
|
|
1111
|
+
entries: [...view.entries, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' }],
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
case 'request/context':
|
|
1115
|
+
// Route capacity, logged only when it changes; last one wins.
|
|
1116
|
+
return {
|
|
1117
|
+
...view,
|
|
1118
|
+
stats: { ...view.stats, contextWindow: event.data.contextWindow ?? view.stats.contextWindow },
|
|
1119
|
+
}
|
|
1120
|
+
case 'request/header': {
|
|
1121
|
+
// The session's own model record: the latest snapshot's provider/model
|
|
1122
|
+
// pair, exactly what a resumed TUI restores as the selection, plus the
|
|
1123
|
+
// effective reasoning effort that snapshot carried (the adapter may
|
|
1124
|
+
// materialize the model default, which is what the status line shows).
|
|
1125
|
+
// The system-prompt estimate lives with `system/message` events since
|
|
1126
|
+
// session-log v3 removed the header's `system` field.
|
|
1127
|
+
const config = event.data.header.config
|
|
1128
|
+
return {
|
|
1129
|
+
...view,
|
|
1130
|
+
model: `${config.provider}/${config.model}`,
|
|
1131
|
+
stats: {
|
|
1132
|
+
...view.stats,
|
|
1133
|
+
reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
|
|
1134
|
+
},
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
case 'plan/mode':
|
|
1138
|
+
// Whole-value replace; the last one wins (upstream fold semantics).
|
|
1139
|
+
return { ...view, plan: event.data.active }
|
|
1140
|
+
case 'permission/preset':
|
|
1141
|
+
return { ...view, permission: event.data.preset }
|
|
1142
|
+
case 'command/run': {
|
|
1143
|
+
const data = event.data
|
|
1144
|
+
return {
|
|
1145
|
+
...view,
|
|
1146
|
+
entries: [...view.entries, {
|
|
1147
|
+
kind: 'command',
|
|
1148
|
+
commandId: data.commandId,
|
|
1149
|
+
name: data.name,
|
|
1150
|
+
args: data.args ?? '',
|
|
1151
|
+
state: 'running',
|
|
1152
|
+
summary: '',
|
|
1153
|
+
}],
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
case 'command/done': {
|
|
1157
|
+
const data = event.data
|
|
1158
|
+
const entries = view.entries.map((entry) => {
|
|
1159
|
+
if (entry.kind !== 'command' || entry.commandId !== data.commandId) return entry
|
|
1160
|
+
return {
|
|
1161
|
+
...entry,
|
|
1162
|
+
state: data.kind === 'success' ? 'done' as const : 'error' as const,
|
|
1163
|
+
summary: boundContextSummary(data.text ?? ''),
|
|
1164
|
+
}
|
|
1165
|
+
})
|
|
1166
|
+
return { ...view, entries }
|
|
1167
|
+
}
|
|
1168
|
+
default:
|
|
1169
|
+
return view
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Mutable replay accumulator: folds a persisted log into the identical view
|
|
1175
|
+
* `projectEvent` would produce, but in near-linear time. Where `projectEvent`
|
|
1176
|
+
* is copy-on-write — every append/scan rebuilds the whole `entries` array, so
|
|
1177
|
+
* folding a full log costs O(N²) — the accumulator appends by push, resolves
|
|
1178
|
+
* id-keyed updates (tool/result, command/done, retry-started) through index
|
|
1179
|
+
* maps, and tombstones retired pending rows, so the whole log folds in O(N)
|
|
1180
|
+
* plus one compaction pass when tombstones exist.
|
|
1181
|
+
*
|
|
1182
|
+
* Index maps never delete: every appended row registers its index, so an id
|
|
1183
|
+
* lookup miss provably means no matching row exists and the update is an O(1)
|
|
1184
|
+
* no-op (a malicious/orphan-heavy log cannot force per-orphan full-array
|
|
1185
|
+
* scans). Each id maps to ALL of its indices, so a duplicate id updates every
|
|
1186
|
+
* matching row exactly like the copy-on-write reducer.
|
|
1187
|
+
*
|
|
1188
|
+
* @internal Exported only so tests can (a) prove replay ≡ sequential
|
|
1189
|
+
* `projectEvent` folds and (b) assert the linear complexity deterministically
|
|
1190
|
+
* via {@link ReplayAccumulator.ops}, which counts entry-level container work
|
|
1191
|
+
* instead of relying on wall-clock thresholds. No public consumer.
|
|
1192
|
+
*/
|
|
1193
|
+
export interface ReplayAccumulator {
|
|
1194
|
+
/** Working entry list; `undefined` marks a retired pending row (tombstone). */
|
|
1195
|
+
entries: (TranscriptEntry | undefined)[]
|
|
1196
|
+
/** callId → every index into `entries` holding a `tool` row with that id. */
|
|
1197
|
+
toolIndex: Map<string, number[]>
|
|
1198
|
+
/** commandId → every index into `entries` holding a `command` row with that id. */
|
|
1199
|
+
commandIndex: Map<string, number[]>
|
|
1200
|
+
/** retryId → every index into `entries` holding a `retry` row with that id. */
|
|
1201
|
+
retryIndex: Map<string, number[]>
|
|
1202
|
+
/** messageId → every index into `entries` holding a `pending` row with that id. */
|
|
1203
|
+
pendingIndex: Map<string, number[]>
|
|
1204
|
+
/** Tombstone count; zero means `entries` is already the final array. */
|
|
1205
|
+
removedCount: number
|
|
1206
|
+
/** Mutable inbox id lists, mirroring `view.pending` order per target. */
|
|
1207
|
+
pendingTurn: string[]
|
|
1208
|
+
pendingStep: string[]
|
|
1209
|
+
streaming: string
|
|
1210
|
+
streamingReasoning: string
|
|
1211
|
+
todos: readonly TodoItem[]
|
|
1212
|
+
/** Global tool-call ordinal counter (see `TranscriptView.toolCallOrdinal`). */
|
|
1213
|
+
toolCallOrdinal: number
|
|
1214
|
+
busy: boolean
|
|
1215
|
+
busySince: number
|
|
1216
|
+
model: string
|
|
1217
|
+
plan: boolean
|
|
1218
|
+
permission: string
|
|
1219
|
+
title: string
|
|
1220
|
+
systemPrompt: string
|
|
1221
|
+
sandbox: string
|
|
1222
|
+
goal: GoalFold | undefined
|
|
1223
|
+
schedules: readonly ScheduleRow[]
|
|
1224
|
+
stats: TranscriptStats
|
|
1225
|
+
stepStart: Map<string, number>
|
|
1226
|
+
toolStart: Map<string, number>
|
|
1227
|
+
firstChunkAt: Map<string, number>
|
|
1228
|
+
compactionTokens: Map<string, number>
|
|
1229
|
+
lastPruneTokens: number
|
|
1230
|
+
turnFiles: Map<number, Set<string>>
|
|
1231
|
+
turnSteps: Map<number, string>
|
|
1232
|
+
turnTools: Map<number, Set<string>>
|
|
1233
|
+
/** Live `system/message` surface nodes by event seq (empty string = an empty node). */
|
|
1234
|
+
systemNodes: Map<number, string>
|
|
1235
|
+
/** Entry-level container operations performed so far (test instrumentation). */
|
|
1236
|
+
ops: number
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** @internal A fresh replay accumulator whose state mirrors `createTranscriptView()`. */
|
|
1240
|
+
export function createReplayAccumulator(): ReplayAccumulator {
|
|
1241
|
+
return {
|
|
1242
|
+
entries: [],
|
|
1243
|
+
toolIndex: new Map(),
|
|
1244
|
+
commandIndex: new Map(),
|
|
1245
|
+
retryIndex: new Map(),
|
|
1246
|
+
pendingIndex: new Map(),
|
|
1247
|
+
removedCount: 0,
|
|
1248
|
+
pendingTurn: [],
|
|
1249
|
+
pendingStep: [],
|
|
1250
|
+
streaming: '',
|
|
1251
|
+
streamingReasoning: '',
|
|
1252
|
+
todos: [],
|
|
1253
|
+
toolCallOrdinal: 0,
|
|
1254
|
+
busy: false,
|
|
1255
|
+
busySince: 0,
|
|
1256
|
+
model: '',
|
|
1257
|
+
plan: false,
|
|
1258
|
+
permission: '',
|
|
1259
|
+
title: '',
|
|
1260
|
+
systemPrompt: '',
|
|
1261
|
+
sandbox: '',
|
|
1262
|
+
goal: undefined,
|
|
1263
|
+
schedules: [],
|
|
1264
|
+
stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
|
|
1265
|
+
stepStart: new Map(),
|
|
1266
|
+
toolStart: new Map(),
|
|
1267
|
+
firstChunkAt: new Map(),
|
|
1268
|
+
compactionTokens: new Map(),
|
|
1269
|
+
lastPruneTokens: 0,
|
|
1270
|
+
turnFiles: new Map(),
|
|
1271
|
+
turnSteps: new Map(),
|
|
1272
|
+
turnTools: new Map(),
|
|
1273
|
+
systemNodes: new Map(),
|
|
1274
|
+
ops: 0,
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/** Append one entry (O(1)) and account the push. */
|
|
1279
|
+
function appendReplayEntry(acc: ReplayAccumulator, entry: TranscriptEntry): void {
|
|
1280
|
+
acc.entries.push(entry)
|
|
1281
|
+
acc.ops += 1
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Get (or create) the index list an id owns. Lists are never removed: every
|
|
1286
|
+
* appended row registers its index, so a lookup miss later proves no matching
|
|
1287
|
+
* row exists and the caller can no-op in O(1).
|
|
1288
|
+
*/
|
|
1289
|
+
function indexList(map: Map<string, number[]>, id: string): number[] {
|
|
1290
|
+
let list = map.get(id)
|
|
1291
|
+
if (list === undefined) {
|
|
1292
|
+
list = []
|
|
1293
|
+
map.set(id, list)
|
|
1294
|
+
}
|
|
1295
|
+
return list
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Finalize replay rows the ended turn left `running`, mirroring the reducer's
|
|
1300
|
+
* turn-end orphan sweep: an orphaned retry settles `done`, an orphaned command
|
|
1301
|
+
* settles `error` with an interruption note. Only the id-indexed rows are
|
|
1302
|
+
* visited, so the sweep stays O(retries+commands of the log), never a scan.
|
|
1303
|
+
*/
|
|
1304
|
+
function finalizeReplayOrphans(acc: ReplayAccumulator): void {
|
|
1305
|
+
for (const list of acc.retryIndex.values()) {
|
|
1306
|
+
for (const index of list) {
|
|
1307
|
+
const entry = acc.entries[index]
|
|
1308
|
+
if (entry !== undefined && entry.kind === 'retry' && entry.state === 'running') {
|
|
1309
|
+
acc.entries[index] = { ...entry, state: 'done' }
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
for (const list of acc.commandIndex.values()) {
|
|
1314
|
+
for (const index of list) {
|
|
1315
|
+
const entry = acc.entries[index]
|
|
1316
|
+
if (entry !== undefined && entry.kind === 'command' && entry.state === 'running') {
|
|
1317
|
+
acc.entries[index] = { ...entry, state: 'error', summary: 'interrupted before the turn ended' }
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Apply an id-keyed update to every row that registered the id, mirroring the
|
|
1325
|
+
* copy-on-write reducer's full-array map semantics (all matching rows update,
|
|
1326
|
+
* in order). Each registered index is O(1), so a duplicate id costs
|
|
1327
|
+
* O(#duplicates) — never a full-array scan. The kind+id re-check is defensive:
|
|
1328
|
+
* registered indices are valid by construction, because tool/command/retry
|
|
1329
|
+
* rows are never removed and tombstones never shift indices.
|
|
1330
|
+
*/
|
|
1331
|
+
function updateReplayById<T extends TranscriptEntry>(
|
|
1332
|
+
acc: ReplayAccumulator,
|
|
1333
|
+
map: Map<string, number[]>,
|
|
1334
|
+
id: string,
|
|
1335
|
+
isMatch: (entry: T) => boolean,
|
|
1336
|
+
update: (entry: T) => T,
|
|
1337
|
+
): void {
|
|
1338
|
+
const list = map.get(id)
|
|
1339
|
+
if (list === undefined) return // miss provably means no matching row
|
|
1340
|
+
for (const index of list) {
|
|
1341
|
+
const entry = acc.entries[index]
|
|
1342
|
+
if (entry === undefined || !isMatch(entry as T)) continue
|
|
1343
|
+
acc.entries[index] = update(entry as T)
|
|
1344
|
+
acc.ops += 1
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** Tombstone a retired pending row, keeping every other index stable. */
|
|
1349
|
+
function retireReplayEntry(acc: ReplayAccumulator, index: number): void {
|
|
1350
|
+
if (acc.entries[index] !== undefined) {
|
|
1351
|
+
acc.entries[index] = undefined
|
|
1352
|
+
acc.removedCount += 1
|
|
1353
|
+
acc.ops += 1
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Fold one session event into a replay accumulator. This mirrors
|
|
1359
|
+
* {@link projectEvent} case for case — same stats arithmetic, same anchor
|
|
1360
|
+
* set/delete behavior, same entry shapes — so the finished view is identical
|
|
1361
|
+
* to a sequential fold; only the `entries` container operations are mutable.
|
|
1362
|
+
*
|
|
1363
|
+
* @internal Test-instrumentation path; `projectEvents` is the public entry.
|
|
1364
|
+
* @returns whether the event changed the accumulated state — the live store
|
|
1365
|
+
* stays silent and keeps its snapshot identity for ignored events, exactly
|
|
1366
|
+
* like the copy-on-write reducer returning its input view unchanged.
|
|
1367
|
+
*/
|
|
1368
|
+
export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): boolean {
|
|
1369
|
+
// Mirror of the reducer's entry shadow: any surface replace retires the
|
|
1370
|
+
// system nodes it covers before the per-event fold runs.
|
|
1371
|
+
const shadow = retireShadowedSystemNodes(acc.systemNodes, event.surfaceOp)
|
|
1372
|
+
if (shadow.changed) {
|
|
1373
|
+
acc.systemPrompt = shadow.prompt
|
|
1374
|
+
acc.stats = { ...acc.stats, contextSegments: { ...acc.stats.contextSegments, system: estimateTokens(shadow.prompt) } }
|
|
1375
|
+
}
|
|
1376
|
+
switch (event.type) {
|
|
1377
|
+
case 'user/message': {
|
|
1378
|
+
const message = event.data
|
|
1379
|
+
for (const target of ['next-turn', 'next-step'] as const) {
|
|
1380
|
+
const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
|
|
1381
|
+
const index = ids.indexOf(message.id)
|
|
1382
|
+
acc.ops += index < 0 ? ids.length : index + 1
|
|
1383
|
+
if (index < 0) continue
|
|
1384
|
+
ids.splice(index, 1)
|
|
1385
|
+
acc.ops += 1
|
|
1386
|
+
// Retire every pending row carrying this message id (duplicate ids
|
|
1387
|
+
// included), exactly like the reducer's full-array filter.
|
|
1388
|
+
const list = acc.pendingIndex.get(message.id)
|
|
1389
|
+
if (list !== undefined) {
|
|
1390
|
+
for (const entryIndex of list) retireReplayEntry(acc, entryIndex)
|
|
1391
|
+
acc.ops += 1
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
const text = textOf(message.content)
|
|
1395
|
+
const images = imagesOf(message.content)
|
|
1396
|
+
const files = filesOf(message.content)
|
|
1397
|
+
if (message.source.kind === 'user' || (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin))) {
|
|
1398
|
+
appendReplayEntry(acc, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
|
|
1399
|
+
acc.stats = {
|
|
1400
|
+
...acc.stats,
|
|
1401
|
+
contextSegments: {
|
|
1402
|
+
...acc.stats.contextSegments,
|
|
1403
|
+
prompt: acc.stats.contextSegments.prompt + estimateTokens(text),
|
|
1404
|
+
},
|
|
1405
|
+
}
|
|
1406
|
+
return true
|
|
1407
|
+
}
|
|
1408
|
+
if (message.source.kind === 'plugin' && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
|
|
1409
|
+
acc.stats = {
|
|
1410
|
+
...acc.stats,
|
|
1411
|
+
contextSegments: {
|
|
1412
|
+
...acc.stats.contextSegments,
|
|
1413
|
+
system: acc.stats.contextSegments.system + estimateTokens(text),
|
|
1414
|
+
},
|
|
1415
|
+
}
|
|
1416
|
+
return true
|
|
1417
|
+
}
|
|
1418
|
+
const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
|
|
1419
|
+
? message.source.summary
|
|
1420
|
+
: message.source.kind === 'plugin'
|
|
1421
|
+
? message.source.plugin
|
|
1422
|
+
: message.source.kind
|
|
1423
|
+
const summary = boundContextSummary(notice)
|
|
1424
|
+
appendReplayEntry(acc, { kind: 'user', text: summary, notice: true })
|
|
1425
|
+
acc.stats = {
|
|
1426
|
+
...acc.stats,
|
|
1427
|
+
contextSegments: {
|
|
1428
|
+
...acc.stats.contextSegments,
|
|
1429
|
+
system: acc.stats.contextSegments.system + estimateTokens(summary),
|
|
1430
|
+
},
|
|
1431
|
+
}
|
|
1432
|
+
return true
|
|
1433
|
+
}
|
|
1434
|
+
case 'agent/inbox/spliced': {
|
|
1435
|
+
const { target, start, removedCount = 0, inserted } = event.data
|
|
1436
|
+
const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
|
|
1437
|
+
const removed = ids.slice(start, start + removedCount)
|
|
1438
|
+
acc.ops += removed.length
|
|
1439
|
+
ids.splice(start, removedCount)
|
|
1440
|
+
acc.ops += removed.length
|
|
1441
|
+
for (const id of removed) {
|
|
1442
|
+
const list = acc.pendingIndex.get(id)
|
|
1443
|
+
if (list === undefined) continue
|
|
1444
|
+
for (const entryIndex of list) {
|
|
1445
|
+
const entry = acc.entries[entryIndex]
|
|
1446
|
+
if (entry !== undefined && entry.kind === 'pending' && entry.target === target) {
|
|
1447
|
+
retireReplayEntry(acc, entryIndex)
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
// Mirror the reducer's in-place order: inserted ids join at the splice
|
|
1452
|
+
// position (upstream `splice(start, removedCount, ...inserted)`), never
|
|
1453
|
+
// at the tail — the id list must stay coordinate-compatible with every
|
|
1454
|
+
// later inbox event.
|
|
1455
|
+
ids.splice(start, 0, ...inserted.map(message => message.id))
|
|
1456
|
+
for (const message of inserted) {
|
|
1457
|
+
const images = imagesOf(message.content)
|
|
1458
|
+
const files = filesOf(message.content)
|
|
1459
|
+
appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
|
|
1460
|
+
indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
|
|
1461
|
+
acc.ops += 1
|
|
1462
|
+
}
|
|
1463
|
+
return true
|
|
1464
|
+
}
|
|
1465
|
+
case 'system/message': {
|
|
1466
|
+
// Mirrors the reducer's per-node fold: append adds, replace retires the
|
|
1467
|
+
// covered seq range, and the estimate prices the assembled prompt.
|
|
1468
|
+
const text = textOf(event.data.message.content)
|
|
1469
|
+
retireShadowedSystemNodes(acc.systemNodes, event.surfaceOp)
|
|
1470
|
+
acc.systemNodes.set(event.seq, text)
|
|
1471
|
+
acc.systemPrompt = assembleSystemPrompt(acc.systemNodes)
|
|
1472
|
+
acc.stats = {
|
|
1473
|
+
...acc.stats,
|
|
1474
|
+
contextSegments: {
|
|
1475
|
+
...acc.stats.contextSegments,
|
|
1476
|
+
system: estimateTokens(acc.systemPrompt),
|
|
1477
|
+
},
|
|
1478
|
+
}
|
|
1479
|
+
return true
|
|
1480
|
+
}
|
|
1481
|
+
case 'assistant/attempt': {
|
|
1482
|
+
// Mirrors the reducer: the step stays open across in-step retries; a
|
|
1483
|
+
// missing first-token anchor is restored (and accrued) from the
|
|
1484
|
+
// attempt's embedded stream, and only the live tails are dropped.
|
|
1485
|
+
const key = `${event.data.turn}:${event.data.step}`
|
|
1486
|
+
let changed = false
|
|
1487
|
+
if (!acc.firstChunkAt.has(key)) {
|
|
1488
|
+
const first = assistantStreamFirstTokenTime(event.data.stream ?? [])
|
|
1489
|
+
if (first !== undefined) {
|
|
1490
|
+
acc.firstChunkAt.set(key, first)
|
|
1491
|
+
const started = acc.stepStart.get(key)
|
|
1492
|
+
if (started !== undefined) {
|
|
1493
|
+
acc.stats = {
|
|
1494
|
+
...acc.stats,
|
|
1495
|
+
ttftMs: acc.stats.ttftMs + Math.max(0, first - started),
|
|
1496
|
+
ttftSteps: acc.stats.ttftSteps + 1,
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
changed = true
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
const streamed = acc.streaming !== '' || acc.streamingReasoning !== ''
|
|
1503
|
+
acc.streaming = ''
|
|
1504
|
+
acc.streamingReasoning = ''
|
|
1505
|
+
return changed || streamed
|
|
1506
|
+
}
|
|
1507
|
+
case 'assistant/message': {
|
|
1508
|
+
const key = `${event.data.turn}:${event.data.step}`
|
|
1509
|
+
const started = acc.stepStart.get(key)
|
|
1510
|
+
acc.stepStart.delete(key)
|
|
1511
|
+
let firstChunk = acc.firstChunkAt.get(key)
|
|
1512
|
+
if (firstChunk === undefined) {
|
|
1513
|
+
// A replayed settlement has no live frames; the embedded stream's
|
|
1514
|
+
// first-token time restores both the anchor and the TTFT figures the
|
|
1515
|
+
// live path accumulates in applyAssistantStreamChunk.
|
|
1516
|
+
firstChunk = assistantStreamFirstTokenTime(event.data.stream ?? [])
|
|
1517
|
+
if (firstChunk !== undefined && started !== undefined) {
|
|
1518
|
+
acc.stats = {
|
|
1519
|
+
...acc.stats,
|
|
1520
|
+
ttftMs: acc.stats.ttftMs + Math.max(0, firstChunk - started),
|
|
1521
|
+
ttftSteps: acc.stats.ttftSteps + 1,
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
acc.firstChunkAt.delete(key)
|
|
1526
|
+
if (acc.turnSteps.get(event.data.turn) === key) acc.turnSteps.delete(event.data.turn)
|
|
1527
|
+
const usage = event.data.usage
|
|
1528
|
+
const totals = acc.stats.usage
|
|
1529
|
+
const text = textOf(event.data.message.content)
|
|
1530
|
+
const reasoning = reasoningOf(event.data.message.content)
|
|
1531
|
+
acc.streaming = ''
|
|
1532
|
+
acc.streamingReasoning = ''
|
|
1533
|
+
appendReplayEntry(acc, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined })
|
|
1534
|
+
acc.stats = {
|
|
1535
|
+
...acc.stats,
|
|
1536
|
+
llmMs: acc.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
|
|
1537
|
+
usage: usage === undefined ? totals : {
|
|
1538
|
+
inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
|
|
1539
|
+
outputTokens: totals.outputTokens + usage.outputTokens,
|
|
1540
|
+
cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
|
|
1541
|
+
},
|
|
1542
|
+
lastPromptTokens: usage === undefined ? acc.stats.lastPromptTokens
|
|
1543
|
+
: usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
|
|
1544
|
+
decodeMs: acc.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
|
|
1545
|
+
decodeTokens: acc.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
|
|
1546
|
+
contextSegments: {
|
|
1547
|
+
...acc.stats.contextSegments,
|
|
1548
|
+
thinking: acc.stats.contextSegments.thinking + estimateTokens(reasoning),
|
|
1549
|
+
assistant: acc.stats.contextSegments.assistant + estimateTokens(text),
|
|
1550
|
+
},
|
|
1551
|
+
}
|
|
1552
|
+
return true
|
|
1553
|
+
}
|
|
1554
|
+
case 'tool/call': {
|
|
1555
|
+
const data = event.data
|
|
1556
|
+
acc.toolStart.set(data.callId, event.time)
|
|
1557
|
+
const turnTools = acc.turnTools.get(data.turn) ?? new Set<string>()
|
|
1558
|
+
turnTools.add(data.callId)
|
|
1559
|
+
acc.turnTools.set(data.turn, turnTools)
|
|
1560
|
+
acc.toolCallOrdinal += 1
|
|
1561
|
+
appendReplayEntry(acc, {
|
|
1562
|
+
kind: 'tool',
|
|
1563
|
+
callId: data.callId,
|
|
1564
|
+
ordinal: acc.toolCallOrdinal,
|
|
1565
|
+
name: data.name,
|
|
1566
|
+
arguments: data.arguments,
|
|
1567
|
+
preview: toolArgumentsPreview(data.arguments, data.name),
|
|
1568
|
+
prompt: toolPromptPreview(data.name, data.arguments),
|
|
1569
|
+
state: 'running',
|
|
1570
|
+
summary: '',
|
|
1571
|
+
detail: undefined,
|
|
1572
|
+
})
|
|
1573
|
+
indexList(acc.toolIndex, data.callId).push(acc.entries.length - 1)
|
|
1574
|
+
acc.stats = {
|
|
1575
|
+
...acc.stats,
|
|
1576
|
+
contextSegments: {
|
|
1577
|
+
...acc.stats.contextSegments,
|
|
1578
|
+
tools: acc.stats.contextSegments.tools
|
|
1579
|
+
+ (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
|
|
1580
|
+
},
|
|
1581
|
+
}
|
|
1582
|
+
return true
|
|
1583
|
+
}
|
|
1584
|
+
case 'tool/result': {
|
|
1585
|
+
const block = event.data.message.content[0]
|
|
1586
|
+
const started = acc.toolStart.get(block.toolCallId)
|
|
1587
|
+
acc.toolStart.delete(block.toolCallId)
|
|
1588
|
+
const turnTools = acc.turnTools.get(event.data.turn)
|
|
1589
|
+
if (turnTools !== undefined) {
|
|
1590
|
+
turnTools.delete(block.toolCallId)
|
|
1591
|
+
if (turnTools.size === 0) acc.turnTools.delete(event.data.turn)
|
|
1592
|
+
}
|
|
1593
|
+
const rawText = textOf(block.content)
|
|
1594
|
+
const summary = boundContextSummary(rawText)
|
|
1595
|
+
const detail = toolResultDetail(event.data.meta, rawText)
|
|
1596
|
+
if (detail?.kind === 'diff') {
|
|
1597
|
+
const set = acc.turnFiles.get(event.data.turn) ?? new Set<string>()
|
|
1598
|
+
for (const diff of detail.diffs) set.add(diff.path)
|
|
1599
|
+
acc.turnFiles.set(event.data.turn, set)
|
|
1600
|
+
}
|
|
1601
|
+
const update = (entry: ToolEntry): ToolEntry => ({
|
|
1602
|
+
...entry,
|
|
1603
|
+
state: block.isError === true ? 'error' as const : 'done' as const,
|
|
1604
|
+
summary,
|
|
1605
|
+
detail,
|
|
1606
|
+
})
|
|
1607
|
+
// Every matching row updates (duplicate callIds included); an id with no
|
|
1608
|
+
// registered index is a provable no-op — no full-array fallback scan.
|
|
1609
|
+
updateReplayById<ToolEntry>(acc, acc.toolIndex, block.toolCallId, entry => entry.callId === block.toolCallId, update)
|
|
1610
|
+
acc.stats = {
|
|
1611
|
+
...acc.stats,
|
|
1612
|
+
toolMs: acc.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
|
|
1613
|
+
contextSegments: {
|
|
1614
|
+
...acc.stats.contextSegments,
|
|
1615
|
+
tools: acc.stats.contextSegments.tools + estimateTokens(rawText),
|
|
1616
|
+
},
|
|
1617
|
+
}
|
|
1618
|
+
return true
|
|
1619
|
+
}
|
|
1620
|
+
case 'todo/write':
|
|
1621
|
+
acc.todos = event.data.todos
|
|
1622
|
+
return true
|
|
1623
|
+
case 'turn/start': {
|
|
1624
|
+
const wasBusy = acc.busy
|
|
1625
|
+
acc.busy = true
|
|
1626
|
+
acc.busySince = wasBusy ? acc.busySince : event.time
|
|
1627
|
+
acc.todos = []
|
|
1628
|
+
acc.stats = { ...acc.stats, turns: acc.stats.turns + 1 }
|
|
1629
|
+
return true
|
|
1630
|
+
}
|
|
1631
|
+
case 'step/start': {
|
|
1632
|
+
const key = `${event.data.turn}:${event.data.step}`
|
|
1633
|
+
const previous = acc.turnSteps.get(event.data.turn)
|
|
1634
|
+
if (previous !== undefined && previous !== key) {
|
|
1635
|
+
acc.stepStart.delete(previous)
|
|
1636
|
+
acc.firstChunkAt.delete(previous)
|
|
1637
|
+
}
|
|
1638
|
+
acc.turnSteps.set(event.data.turn, key)
|
|
1639
|
+
acc.stepStart.set(key, event.time)
|
|
1640
|
+
acc.streaming = ''
|
|
1641
|
+
acc.streamingReasoning = ''
|
|
1642
|
+
acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 }
|
|
1643
|
+
return true
|
|
1644
|
+
}
|
|
1645
|
+
case 'turn/end': {
|
|
1646
|
+
const reason = event.data.reason
|
|
1647
|
+
const appended: TranscriptEntry[] = []
|
|
1648
|
+
acc.streamingReasoning = ''
|
|
1649
|
+
acc.streaming = ''
|
|
1650
|
+
if (reason.kind === 'error') {
|
|
1651
|
+
const recovery = reason.error.code === 'MISSING_CREDENTIAL'
|
|
1652
|
+
? ' · open /model to add an API key'
|
|
1653
|
+
: ''
|
|
1654
|
+
appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
|
|
1655
|
+
} else {
|
|
1656
|
+
const marker = reason.kind === 'aborted'
|
|
1657
|
+
? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
|
|
1658
|
+
: reason.kind === 'max-tokens'
|
|
1659
|
+
? 'turn hit the output-token ceiling (max-tokens)'
|
|
1660
|
+
: reason.kind === 'blocked'
|
|
1661
|
+
? 'turn ended blocked'
|
|
1662
|
+
: reason.kind === 'interrupted'
|
|
1663
|
+
? 'turn was interrupted by a restart'
|
|
1664
|
+
: undefined
|
|
1665
|
+
if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
|
|
1666
|
+
}
|
|
1667
|
+
const files = acc.turnFiles.get(event.data.turn)
|
|
1668
|
+
acc.turnFiles.delete(event.data.turn)
|
|
1669
|
+
if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
|
|
1670
|
+
const stepKey = acc.turnSteps.get(event.data.turn)
|
|
1671
|
+
if (stepKey !== undefined) {
|
|
1672
|
+
acc.stepStart.delete(stepKey)
|
|
1673
|
+
acc.firstChunkAt.delete(stepKey)
|
|
1674
|
+
acc.turnSteps.delete(event.data.turn)
|
|
1675
|
+
}
|
|
1676
|
+
const turnToolSet = acc.turnTools.get(event.data.turn)
|
|
1677
|
+
if (turnToolSet !== undefined) {
|
|
1678
|
+
for (const callId of turnToolSet) acc.toolStart.delete(callId)
|
|
1679
|
+
acc.turnTools.delete(event.data.turn)
|
|
1680
|
+
}
|
|
1681
|
+
finalizeReplayOrphans(acc)
|
|
1682
|
+
acc.busy = false
|
|
1683
|
+
acc.busySince = 0
|
|
1684
|
+
for (const entry of appended) appendReplayEntry(acc, entry)
|
|
1685
|
+
return true
|
|
1686
|
+
}
|
|
1687
|
+
case 'llm/retry': {
|
|
1688
|
+
const data = event.data
|
|
1689
|
+
acc.streaming = ''
|
|
1690
|
+
acc.streamingReasoning = ''
|
|
1691
|
+
appendReplayEntry(acc, {
|
|
1692
|
+
kind: 'retry',
|
|
1693
|
+
retryId: data.retryId,
|
|
1694
|
+
mode: data.mode,
|
|
1695
|
+
attempt: data.retry,
|
|
1696
|
+
max: 'maxRetries' in data ? data.maxRetries : data.retry,
|
|
1697
|
+
code: data.failure.code,
|
|
1698
|
+
delayMs: data.delayMs,
|
|
1699
|
+
state: 'running',
|
|
1700
|
+
})
|
|
1701
|
+
indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1)
|
|
1702
|
+
return true
|
|
1703
|
+
}
|
|
1704
|
+
case 'llm/retry-started': {
|
|
1705
|
+
const data = event.data
|
|
1706
|
+
updateReplayById<RetryEntry>(acc, acc.retryIndex, data.retryId, entry => entry.retryId === data.retryId, entry => ({ ...entry, state: 'done' as const }))
|
|
1707
|
+
return true
|
|
1708
|
+
}
|
|
1709
|
+
case 'sandbox/mode':
|
|
1710
|
+
acc.sandbox = event.data.mode
|
|
1711
|
+
return true
|
|
1712
|
+
case 'goal/change': {
|
|
1713
|
+
const data = event.data
|
|
1714
|
+
const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
|
|
1715
|
+
if (data.operation === 'clear') {
|
|
1716
|
+
acc.goal = undefined
|
|
1717
|
+
appendReplayEntry(acc, { kind: 'turn-marker', text: '◎ goal cleared' })
|
|
1718
|
+
return true
|
|
1719
|
+
}
|
|
1720
|
+
const goal: GoalFold = {
|
|
1721
|
+
objective: data.goal.objective,
|
|
1722
|
+
phase: data.goal.phase,
|
|
1723
|
+
rounds: data.roundsStarted,
|
|
1724
|
+
max: data.goal.maxGoalRounds,
|
|
1725
|
+
blocked: data.goal.blockedReason?.message ?? '',
|
|
1726
|
+
}
|
|
1727
|
+
const line = data.operation === 'create'
|
|
1728
|
+
? `◎ goal: ${clip(data.goal.objective)}`
|
|
1729
|
+
: data.operation === 'complete'
|
|
1730
|
+
? '◎ goal complete'
|
|
1731
|
+
: data.operation === 'pause'
|
|
1732
|
+
? '◎ goal paused'
|
|
1733
|
+
: data.operation === 'resume'
|
|
1734
|
+
? '◎ goal resumed'
|
|
1735
|
+
: data.operation === 'block'
|
|
1736
|
+
? `◎ goal blocked: ${clip(goal.blocked)}`
|
|
1737
|
+
: undefined
|
|
1738
|
+
acc.goal = goal
|
|
1739
|
+
if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
|
|
1740
|
+
return true
|
|
1741
|
+
}
|
|
1742
|
+
case 'schedule/change':
|
|
1743
|
+
acc.schedules = applyScheduleChange(acc.schedules, event.data)
|
|
1744
|
+
acc.ops += 1
|
|
1745
|
+
return true
|
|
1746
|
+
case 'session/title':
|
|
1747
|
+
acc.title = event.data.title
|
|
1748
|
+
return true
|
|
1749
|
+
case 'compaction/summary':
|
|
1750
|
+
if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
|
|
1751
|
+
const oldest = acc.compactionTokens.keys().next().value
|
|
1752
|
+
if (oldest !== undefined) acc.compactionTokens.delete(oldest)
|
|
1753
|
+
}
|
|
1754
|
+
acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
|
|
1755
|
+
return true
|
|
1756
|
+
case 'compaction/prune':
|
|
1757
|
+
acc.lastPruneTokens = event.data.shadowedTokenCount
|
|
1758
|
+
return true
|
|
1759
|
+
case 'compaction/end': {
|
|
1760
|
+
const ok = event.data.error === undefined
|
|
1761
|
+
const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens
|
|
1762
|
+
acc.compactionTokens.delete(event.data.compactionId)
|
|
1763
|
+
appendReplayEntry(acc, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' })
|
|
1764
|
+
return true
|
|
1765
|
+
}
|
|
1766
|
+
case 'request/context':
|
|
1767
|
+
acc.stats = { ...acc.stats, contextWindow: event.data.contextWindow ?? acc.stats.contextWindow }
|
|
1768
|
+
return true
|
|
1769
|
+
case 'request/header': {
|
|
1770
|
+
const config = event.data.header.config
|
|
1771
|
+
acc.model = `${config.provider}/${config.model}`
|
|
1772
|
+
acc.stats = {
|
|
1773
|
+
...acc.stats,
|
|
1774
|
+
reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
|
|
1775
|
+
}
|
|
1776
|
+
return true
|
|
1777
|
+
}
|
|
1778
|
+
case 'plan/mode':
|
|
1779
|
+
acc.plan = event.data.active
|
|
1780
|
+
return true
|
|
1781
|
+
case 'permission/preset':
|
|
1782
|
+
acc.permission = event.data.preset
|
|
1783
|
+
return true
|
|
1784
|
+
case 'command/run': {
|
|
1785
|
+
const data = event.data
|
|
1786
|
+
appendReplayEntry(acc, {
|
|
1787
|
+
kind: 'command',
|
|
1788
|
+
commandId: data.commandId,
|
|
1789
|
+
name: data.name,
|
|
1790
|
+
args: data.args ?? '',
|
|
1791
|
+
state: 'running',
|
|
1792
|
+
summary: '',
|
|
1793
|
+
})
|
|
1794
|
+
indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1)
|
|
1795
|
+
return true
|
|
1796
|
+
}
|
|
1797
|
+
case 'command/done': {
|
|
1798
|
+
const data = event.data
|
|
1799
|
+
const update = (candidate: CommandEntry): CommandEntry => ({
|
|
1800
|
+
...candidate,
|
|
1801
|
+
state: data.kind === 'success' ? 'done' as const : 'error' as const,
|
|
1802
|
+
summary: boundContextSummary(data.text ?? ''),
|
|
1803
|
+
})
|
|
1804
|
+
updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
|
|
1805
|
+
return true
|
|
1806
|
+
}
|
|
1807
|
+
default:
|
|
1808
|
+
return false
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/**
|
|
1813
|
+
* Materialize the accumulated fold as a `TranscriptView`, compacting any
|
|
1814
|
+
* retired tombstones. The anchors maps are handed through as-is (their
|
|
1815
|
+
* content is identical to a sequential fold's).
|
|
1816
|
+
*
|
|
1817
|
+
* @internal Test-instrumentation path; `projectEvents` is the public entry.
|
|
1818
|
+
*/
|
|
1819
|
+
export function finishReplay(acc: ReplayAccumulator): TranscriptView {
|
|
1820
|
+
return materializeReplayView(acc, false)
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* Materialize the accumulated fold as a fresh immutable snapshot for the
|
|
1825
|
+
* live store. Unlike {@link finishReplay} — the one-shot replay entry, which
|
|
1826
|
+
* hands the accumulator's own arrays through because the accumulator is
|
|
1827
|
+
* discarded — every array a renderer can hold is copied here, so later
|
|
1828
|
+
* folds never mutate a snapshot already handed out. Same fields, same
|
|
1829
|
+
* tombstone compaction.
|
|
1830
|
+
*
|
|
1831
|
+
* @internal Live-store path; `projectEvents` is the public entry.
|
|
1832
|
+
*/
|
|
1833
|
+
export function snapshotReplayView(acc: ReplayAccumulator): TranscriptView {
|
|
1834
|
+
return materializeReplayView(acc, true)
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/** Field-for-field materialization; `copy` selects snapshot array isolation. */
|
|
1838
|
+
function materializeReplayView(acc: ReplayAccumulator, copy: boolean): TranscriptView {
|
|
1839
|
+
const entries: readonly TranscriptEntry[] = acc.removedCount === 0
|
|
1840
|
+
? (copy ? [...acc.entries] : acc.entries) as TranscriptEntry[]
|
|
1841
|
+
: acc.entries.filter((entry): entry is TranscriptEntry => entry !== undefined)
|
|
1842
|
+
if (acc.removedCount > 0) acc.ops += acc.entries.length
|
|
1843
|
+
return {
|
|
1844
|
+
entries,
|
|
1845
|
+
streaming: acc.streaming,
|
|
1846
|
+
streamingReasoning: acc.streamingReasoning,
|
|
1847
|
+
todos: acc.todos,
|
|
1848
|
+
toolCallOrdinal: acc.toolCallOrdinal,
|
|
1849
|
+
busy: acc.busy,
|
|
1850
|
+
busySince: acc.busySince,
|
|
1851
|
+
model: acc.model,
|
|
1852
|
+
plan: acc.plan,
|
|
1853
|
+
permission: acc.permission,
|
|
1854
|
+
title: acc.title,
|
|
1855
|
+
systemPrompt: acc.systemPrompt,
|
|
1856
|
+
sandbox: acc.sandbox,
|
|
1857
|
+
goal: acc.goal,
|
|
1858
|
+
schedules: acc.schedules,
|
|
1859
|
+
pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
|
|
1860
|
+
stats: acc.stats,
|
|
1861
|
+
// Handed-out views get their own anchors snapshot: the accumulator keeps
|
|
1862
|
+
// folding its live containers, and no consumer may observe that.
|
|
1863
|
+
anchors: {
|
|
1864
|
+
stepStart: new Map(acc.stepStart),
|
|
1865
|
+
toolStart: new Map(acc.toolStart),
|
|
1866
|
+
firstChunkAt: new Map(acc.firstChunkAt),
|
|
1867
|
+
compactionTokens: new Map(acc.compactionTokens),
|
|
1868
|
+
lastPruneTokens: acc.lastPruneTokens,
|
|
1869
|
+
turnFiles: new Map([...acc.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
|
|
1870
|
+
turnSteps: new Map(acc.turnSteps),
|
|
1871
|
+
turnTools: new Map([...acc.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
|
|
1872
|
+
systemNodes: new Map(acc.systemNodes),
|
|
1873
|
+
},
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Fold a replayed event history into one view.
|
|
1879
|
+
*
|
|
1880
|
+
* Folding is near-linear in the log size: the mutable replay accumulator
|
|
1881
|
+
* appends in place and resolves id-keyed updates through index maps, so a
|
|
1882
|
+
* long persisted session replays without the O(N²) copy-on-write rebuilds a
|
|
1883
|
+
* naive sequential fold would incur. The result is identical to folding
|
|
1884
|
+
* {@link projectEvent} per event in order.
|
|
1885
|
+
* @param events - events in `seq` order.
|
|
1886
|
+
* @returns the folded view.
|
|
1887
|
+
*/
|
|
1888
|
+
export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
|
|
1889
|
+
const acc = createReplayAccumulator()
|
|
1890
|
+
for (const event of events) replayProjectEvent(acc, event)
|
|
1891
|
+
return finishReplay(acc)
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* Fold one process-local assistant-stream chunk frame (session-log v2+ keeps
|
|
1896
|
+
* durable logs settlement-only; live typing rides the `agent/assistant-stream`
|
|
1897
|
+
* agent event). Same first-token anchoring the durable `assistant/chunk` event
|
|
1898
|
+
* used to carry: the first non-empty delta anchors the TTFT and empty
|
|
1899
|
+
* keep-alive deltas do not count. The caller maps the frame's attempt to the
|
|
1900
|
+
* `turn:step` key (the start frame owns turn/step; chunk frames do not).
|
|
1901
|
+
* @param acc - the live replay accumulator.
|
|
1902
|
+
* @param key - the `turn:step` key the attempt's start frame declared.
|
|
1903
|
+
* @param time - the frame's safe-integer timestamp.
|
|
1904
|
+
* @param chunk - the model chunk the frame carries.
|
|
1905
|
+
* @returns whether the accumulator changed (the store stays silent otherwise).
|
|
1906
|
+
*/
|
|
1907
|
+
export function applyAssistantStreamChunk(acc: ReplayAccumulator, key: string, time: number, chunk: StreamChunk): boolean {
|
|
1908
|
+
// First-token latency uses the kernel's isTokenDelta rule (a non-empty
|
|
1909
|
+
// text, reasoning, or tool-call fragment counts; block/usage/finish chunks
|
|
1910
|
+
// do not), so live frames and replayed embedded streams time the same
|
|
1911
|
+
// token.
|
|
1912
|
+
if (isTokenDelta(chunk) && !acc.firstChunkAt.has(key)) {
|
|
1913
|
+
acc.firstChunkAt.set(key, time)
|
|
1914
|
+
const started = acc.stepStart.get(key)
|
|
1915
|
+
if (started !== undefined) {
|
|
1916
|
+
acc.stats = {
|
|
1917
|
+
...acc.stats,
|
|
1918
|
+
ttftMs: acc.stats.ttftMs + Math.max(0, time - started),
|
|
1919
|
+
ttftSteps: acc.stats.ttftSteps + 1,
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
if (chunk.type === 'text-delta') {
|
|
1924
|
+
acc.streaming = appendStreamingTail(acc.streaming, chunk.text)
|
|
1925
|
+
return true
|
|
1926
|
+
}
|
|
1927
|
+
if (chunk.type === 'reasoning-delta') {
|
|
1928
|
+
acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text)
|
|
1929
|
+
return true
|
|
1930
|
+
}
|
|
1931
|
+
return false
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/**
|
|
1935
|
+
* Drop the live streaming tails without a settlement (an `agent/assistant-stream`
|
|
1936
|
+
* end frame with an `abandoned` outcome, or a session switch). The next start
|
|
1937
|
+
* frame rebuilds from scratch.
|
|
1938
|
+
* @param acc - the live replay accumulator.
|
|
1939
|
+
* @returns whether any tail text was discarded.
|
|
1940
|
+
*/
|
|
1941
|
+
export function clearAssistantStream(acc: ReplayAccumulator): boolean {
|
|
1942
|
+
const streamed = acc.streaming !== '' || acc.streamingReasoning !== ''
|
|
1943
|
+
acc.streaming = ''
|
|
1944
|
+
acc.streamingReasoning = ''
|
|
1945
|
+
return streamed
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
/**
|
|
1949
|
+
* The append-only flush boundary for a transcript view: the count of entries
|
|
1950
|
+
* no later event can remove. Entries at or beyond this index are mutable and
|
|
1951
|
+
* must stay in the live tree.
|
|
1952
|
+
*
|
|
1953
|
+
* `pending` rows are excluded even though they are not a running tool/retry:
|
|
1954
|
+
* the inbox claims or cancels them durably (`agent/inbox/spliced` removals,
|
|
1955
|
+
* `user/message` retirement), and an append-only `<Static>` flush cannot
|
|
1956
|
+
* erase a row that vanishes from the view — the retired row would ghost on
|
|
1957
|
+
* screen until the next source-backed replay. Running commands join the
|
|
1958
|
+
* mutable boundary for the same reason in reverse: `command/done` mutates the
|
|
1959
|
+
* row's state/summary, so a flushed row would keep its stale running mark
|
|
1960
|
+
* until a resize-triggered replay. Everything else (including a completed
|
|
1961
|
+
* tail) is final: later events only APPEND new rows.
|
|
1962
|
+
* @param entries - the view's transcript entries in order.
|
|
1963
|
+
* @returns the count of entries safe to flush (0 for an empty transcript).
|
|
1964
|
+
*/
|
|
1965
|
+
export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
|
|
1966
|
+
for (let index = 0; index < entries.length; index++) {
|
|
1967
|
+
const entry = entries[index]
|
|
1968
|
+
if (entry.kind === 'pending') return index
|
|
1969
|
+
if (entry.kind === 'tool' && entry.state === 'running') return index
|
|
1970
|
+
if (entry.kind === 'retry' && entry.state === 'running') return index
|
|
1971
|
+
if (entry.kind === 'command' && entry.state === 'running') return index
|
|
1972
|
+
}
|
|
1973
|
+
return entries.length
|
|
1974
|
+
}
|