dsh-plan-completion 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -46
- package/README.md +271 -263
- package/client.js +2040 -1994
- package/cordis.patch.yml +13 -12
- package/docs/delivery-notes.md +104 -102
- package/docs/phase-2-plan.md +72 -68
- package/docs/publish-assessment.md +100 -98
- package/docs/security.md +57 -0
- package/host/security.js +66 -0
- package/index.js +1281 -1325
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +58 -58
package/index.js
CHANGED
|
@@ -1,1326 +1,1282 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host half of dsh-plan-completion.
|
|
3
|
-
*
|
|
4
|
-
* Responsibilities (design doc v1.2 §3.5):
|
|
5
|
-
* - watch committed Session events for `turn/end` and run one plan job per session;
|
|
6
|
-
* - call the model through the injected `llm` service (never through the agent loop);
|
|
7
|
-
* - keep the implicit plan and the next-message suggestion per session in memory;
|
|
8
|
-
* - expose the state to the Client half over a local HTTP route registered on `webServer`;
|
|
9
|
-
* - register the `/roadmap` and `/replan` human commands.
|
|
10
|
-
*
|
|
11
|
-
* The Client half only talks to this half through `/plan-completion/*`
|
|
12
|
-
* (verified contract: same-origin fetch from the Electron page works and the body is readable).
|
|
13
|
-
*/
|
|
14
|
-
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
15
|
-
|
|
16
|
-
import { DEFAULTS } from './host/config.js'
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
resolve(
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
body =
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
'
|
|
80
|
-
'
|
|
81
|
-
'
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
res.
|
|
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
|
-
if (
|
|
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
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
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
|
-
const
|
|
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
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
return
|
|
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
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
const
|
|
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
|
-
}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
})
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
})
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
if (method === '
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
const
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
const
|
|
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
|
-
const
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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
|
-
disposers.push(commands.register({
|
|
1283
|
-
name: 'replan',
|
|
1284
|
-
description: '强制重写当前会话的隐式计划',
|
|
1285
|
-
input: { hint: '可选:一句话说明新的方向' },
|
|
1286
|
-
handler: invocation => {
|
|
1287
|
-
const sessionId = invocation?.agent?.id ?? ''
|
|
1288
|
-
const session = sessions.get(sessionId)
|
|
1289
|
-
if (!session) return { kind: 'error', text: '还没有观察到这个会话,请先在会话里发一条消息。' }
|
|
1290
|
-
const turn = Number(states.get(sessionId)?.plan?.watermark?.turn ?? 0)
|
|
1291
|
-
void runGeneration(
|
|
1292
|
-
session,
|
|
1293
|
-
{ seq: Number(debug.lastTurnEnd?.seq ?? 0), data: { turn } },
|
|
1294
|
-
{ force: true, instruction: String(invocation?.rawInput ?? '') },
|
|
1295
|
-
)
|
|
1296
|
-
return { kind: 'success', text: '计划重写已开始…' }
|
|
1297
|
-
},
|
|
1298
|
-
}))
|
|
1299
|
-
child.effect(() => () => {
|
|
1300
|
-
for (const dispose of disposers) {
|
|
1301
|
-
try {
|
|
1302
|
-
dispose()
|
|
1303
|
-
} catch {
|
|
1304
|
-
/* ignore */
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
// Drop matching entries so the next apply() does not dispose them twice.
|
|
1308
|
-
for (const dispose of disposers) {
|
|
1309
|
-
const index = shared.disposers.indexOf(dispose)
|
|
1310
|
-
if (index >= 0) shared.disposers.splice(index, 1)
|
|
1311
|
-
}
|
|
1312
|
-
})
|
|
1313
|
-
} catch (error) {
|
|
1314
|
-
debugLog({ phase: 'command-register-error', error: String(error) })
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
})
|
|
1318
|
-
|
|
1319
|
-
debugLog({
|
|
1320
|
-
phase: 'host-ready',
|
|
1321
|
-
settings,
|
|
1322
|
-
config: rawConfig,
|
|
1323
|
-
services: Object.fromEntries(Object.entries(services).map(([key, value]) => [key, Boolean(value)])),
|
|
1324
|
-
})
|
|
1325
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Host half of dsh-plan-completion.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities (design doc v1.2 §3.5):
|
|
5
|
+
* - watch committed Session events for `turn/end` and run one plan job per session;
|
|
6
|
+
* - call the model through the injected `llm` service (never through the agent loop);
|
|
7
|
+
* - keep the implicit plan and the next-message suggestion per session in memory;
|
|
8
|
+
* - expose the state to the Client half over a local HTTP route registered on `webServer`;
|
|
9
|
+
* - register the `/roadmap` and `/replan` human commands.
|
|
10
|
+
*
|
|
11
|
+
* The Client half only talks to this half through `/plan-completion/*`
|
|
12
|
+
* (verified contract: same-origin fetch from the Electron page works and the body is readable).
|
|
13
|
+
*/
|
|
14
|
+
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
15
|
+
|
|
16
|
+
import { DEFAULTS } from './host/config.js'
|
|
17
|
+
import { requestRejection, safeDebugRecord, safeDiagnostics } from './host/security.js'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { normalizePlan, patchPlan, rewritePlan, statusFor, applyUserEdit } from './host/plan.js'
|
|
20
|
+
import { buildPatchPrompt, buildRewritePrompt, buildRepairPrompt, parseModelOutput } from './host/prompt.js'
|
|
21
|
+
import { heuristicSuggestion, fallbackPlan } from './host/heuristics.js'
|
|
22
|
+
import { collectFromEvents } from './host/evidence.js'
|
|
23
|
+
import { createMemoryStore } from './host/store.js'
|
|
24
|
+
import { runExperiment } from './host/experiment.js'
|
|
25
|
+
import { sliceEvidence } from './host/experiment.js'
|
|
26
|
+
|
|
27
|
+
const ROUTE_PREFIX = '/plan-completion/'
|
|
28
|
+
|
|
29
|
+
// Debug log location is configurable (`config.debugDir` or DSH_PLAN_COMPLETION_DEBUG_DIR).
|
|
30
|
+
// Empty means "do not write files", so a published copy of this bundle carries no local paths.
|
|
31
|
+
let debugDir = ''
|
|
32
|
+
function debugLogPath() {
|
|
33
|
+
return debugDir ? join(debugDir, 'plan-completion-runtime.jsonl') : ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function debugLog(record) {
|
|
37
|
+
try {
|
|
38
|
+
if (!debugDir) return
|
|
39
|
+
mkdirSync(debugDir, { recursive: true })
|
|
40
|
+
appendFileSync(debugLogPath(), `${JSON.stringify({ time: new Date().toISOString(), ...safeDebugRecord(record) })}\n`, 'utf8')
|
|
41
|
+
} catch {
|
|
42
|
+
/* ignore */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readJsonBody(req) {
|
|
47
|
+
return new Promise(resolve => {
|
|
48
|
+
let size = 0
|
|
49
|
+
const chunks = []
|
|
50
|
+
req.on('data', chunk => {
|
|
51
|
+
size += chunk.length
|
|
52
|
+
if (size > 512 * 1024) {
|
|
53
|
+
resolve(null)
|
|
54
|
+
req.destroy()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
chunks.push(chunk)
|
|
58
|
+
})
|
|
59
|
+
req.on('end', () => {
|
|
60
|
+
try {
|
|
61
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
62
|
+
} catch {
|
|
63
|
+
resolve(null)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
req.on('error', () => resolve(null))
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function jsonResponse(res, status, value) {
|
|
71
|
+
let body = ''
|
|
72
|
+
try {
|
|
73
|
+
body = JSON.stringify(value)
|
|
74
|
+
} catch {
|
|
75
|
+
body = '{"ok":false}'
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const headers = {
|
|
79
|
+
'content-type': 'application/json; charset=utf-8',
|
|
80
|
+
'cache-control': 'no-store',
|
|
81
|
+
'x-content-type-options': 'nosniff',
|
|
82
|
+
}
|
|
83
|
+
if (typeof res.writeHead === 'function') {
|
|
84
|
+
res.writeHead(status, headers)
|
|
85
|
+
res.end(body)
|
|
86
|
+
} else {
|
|
87
|
+
res.end(body)
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
/* ignore */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createGate(limit) {
|
|
95
|
+
let active = 0
|
|
96
|
+
const waiters = []
|
|
97
|
+
return {
|
|
98
|
+
async acquire() {
|
|
99
|
+
if (active < limit) {
|
|
100
|
+
active += 1
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
await new Promise(resolve => waiters.push(resolve))
|
|
104
|
+
active += 1
|
|
105
|
+
},
|
|
106
|
+
release() {
|
|
107
|
+
active = Math.max(0, active - 1)
|
|
108
|
+
const next = waiters.shift()
|
|
109
|
+
if (next) next()
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Convert one request message to the block form the `llm` service accepts. */
|
|
115
|
+
function toRequestMessage(message) {
|
|
116
|
+
if (!message || typeof message !== 'object') return null
|
|
117
|
+
const role = message.role === 'assistant' ? 'assistant' : message.role === 'user' ? 'user' : null
|
|
118
|
+
if (!role) return null
|
|
119
|
+
const content = message.content
|
|
120
|
+
if (Array.isArray(content)) return { role, content }
|
|
121
|
+
if (typeof content === 'string') {
|
|
122
|
+
return role === 'assistant'
|
|
123
|
+
? { role, content: [{ type: 'text', text: content }] }
|
|
124
|
+
: { role, content: [{ type: 'text', text: content }] }
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizePromptMessages(prompt) {
|
|
130
|
+
const messages = Array.isArray(prompt?.messages) ? prompt.messages : []
|
|
131
|
+
return messages.map(toRequestMessage).filter(Boolean)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Use the user's current default route only. Never send session evidence to
|
|
136
|
+
* another configured provider/model just because it may respond faster.
|
|
137
|
+
*/
|
|
138
|
+
async function pickModel(services) {
|
|
139
|
+
const llm = services.llm
|
|
140
|
+
const selection = services.agentDefaultModel?.currentSelection?.()
|
|
141
|
+
if (!llm || !selection?.provider || !selection?.model) return null
|
|
142
|
+
const route = { provider: selection.provider, model: selection.model, source: 'session-default' }
|
|
143
|
+
try {
|
|
144
|
+
const info = await llm.resolveModelInfo?.(route.provider, route.model)
|
|
145
|
+
const efforts = info?.reasoning?.efforts
|
|
146
|
+
if (Array.isArray(efforts) && efforts.length) route.reasoningEffort = efforts[0]?.id
|
|
147
|
+
} catch {
|
|
148
|
+
// Resolution failure must not silently choose a different data destination.
|
|
149
|
+
}
|
|
150
|
+
return route
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Experiments also stay on the explicitly selected route (not an independent judge). */
|
|
154
|
+
async function pickJudgeRoute(_services, mainRoute) {
|
|
155
|
+
return mainRoute
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function callModel(services, route, prompt, signal) {
|
|
159
|
+
if (!services.llm || !route) throw new Error('no-llm-route')
|
|
160
|
+
const messages = normalizePromptMessages(prompt)
|
|
161
|
+
if (!messages.length) throw new Error('empty-messages')
|
|
162
|
+
let text = ''
|
|
163
|
+
let usage = null
|
|
164
|
+
const request = {
|
|
165
|
+
provider: route.provider,
|
|
166
|
+
model: route.model,
|
|
167
|
+
system: prompt.system,
|
|
168
|
+
messages,
|
|
169
|
+
maxTokens: prompt.maxTokens,
|
|
170
|
+
temperature: prompt.temperature,
|
|
171
|
+
signal,
|
|
172
|
+
}
|
|
173
|
+
if (route.reasoningEffort) request.reasoningEffort = route.reasoningEffort
|
|
174
|
+
const stream = services.llm.stream(request)
|
|
175
|
+
for await (const chunk of stream) {
|
|
176
|
+
if (!chunk || typeof chunk !== 'object') continue
|
|
177
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
178
|
+
else if (chunk.type === 'usage' && chunk.usage) usage = chunk.usage
|
|
179
|
+
else if (chunk.type === 'finish') {
|
|
180
|
+
const kind = chunk.reason?.kind
|
|
181
|
+
if (kind === 'error' || kind === 'aborted') {
|
|
182
|
+
throw new Error(`model-${kind}:${chunk.reason?.failure?.message ?? ''}`)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { text, usage }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const HOST_GLOBAL_KEY = '__dsh_plan_completion_host__'
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Process-wide host state. DSH may re-apply this plugin (toggle, hot reload) while the old
|
|
193
|
+
* module instance is still alive: keeping state and route disposers in `globalThis` makes the
|
|
194
|
+
* new instance reuse the same maps and dispose the previous route handlers, so we never end
|
|
195
|
+
* up with two live handler sets answering different state.
|
|
196
|
+
*/
|
|
197
|
+
function hostGlobal() {
|
|
198
|
+
const fallback = { states: new Map(), sessions: new Map(), handledTurns: new Map(), warmAttempts: new Map(), disposers: [] }
|
|
199
|
+
if (typeof globalThis === 'undefined') return { ...fallback, store: createMemoryStore() }
|
|
200
|
+
if (!globalThis[HOST_GLOBAL_KEY] || typeof globalThis[HOST_GLOBAL_KEY] !== 'object') {
|
|
201
|
+
globalThis[HOST_GLOBAL_KEY] = fallback
|
|
202
|
+
}
|
|
203
|
+
const shared = globalThis[HOST_GLOBAL_KEY]
|
|
204
|
+
if (!(shared.states instanceof Map)) shared.states = new Map()
|
|
205
|
+
if (!(shared.sessions instanceof Map)) shared.sessions = new Map()
|
|
206
|
+
if (!(shared.handledTurns instanceof Map)) shared.handledTurns = new Map()
|
|
207
|
+
if (!(shared.warmAttempts instanceof Map)) shared.warmAttempts = new Map()
|
|
208
|
+
if (!Array.isArray(shared.disposers)) shared.disposers = []
|
|
209
|
+
if (!shared.store || typeof shared.store.get !== 'function' || typeof shared.store.put !== 'function') {
|
|
210
|
+
shared.store = createMemoryStore()
|
|
211
|
+
// Adopt plans retained by older plugin versions during an in-process upgrade.
|
|
212
|
+
for (const state of shared.states.values()) {
|
|
213
|
+
if (state?.plan) shared.store.put(state.plan, { cas: null })
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return shared
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function apply(ctx, config = {}) {
|
|
220
|
+
// Production settings, frozen by the round-1/round-2 isolation tests:
|
|
221
|
+
// evidenceChars 1500 — compact evidence slice for the patch path
|
|
222
|
+
// maxTokensPatch 400 — patch output only needs ~65-120 tokens
|
|
223
|
+
// maxTokensRewrite 1200 — rewrite must emit the full step table
|
|
224
|
+
const readInt = (value, fallback, min, max) => {
|
|
225
|
+
const number = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : fallback
|
|
226
|
+
return Math.min(max, Math.max(min, number))
|
|
227
|
+
}
|
|
228
|
+
const rawConfig = config && typeof config === 'object' ? config : {}
|
|
229
|
+
const settings = {
|
|
230
|
+
evidenceChars: readInt(rawConfig.evidenceChars, 1500, 400, 8000),
|
|
231
|
+
maxTokensPatch: readInt(rawConfig.maxTokensPatch, 400, 120, 4000),
|
|
232
|
+
maxTokensRewrite: readInt(rawConfig.maxTokensRewrite, 1200, 400, 8000),
|
|
233
|
+
warmBackfill: rawConfig.warmBackfill !== false,
|
|
234
|
+
modelCalls: rawConfig.modelCalls !== false,
|
|
235
|
+
enableDebugRoutes: rawConfig.enableDebugRoutes === true,
|
|
236
|
+
debugDir: typeof rawConfig.debugDir === 'string' && rawConfig.debugDir.trim() !== ''
|
|
237
|
+
? rawConfig.debugDir.trim()
|
|
238
|
+
: (typeof process !== 'undefined' && process.env && typeof process.env.DSH_PLAN_COMPLETION_DEBUG_DIR === 'string'
|
|
239
|
+
? process.env.DSH_PLAN_COMPLETION_DEBUG_DIR.trim()
|
|
240
|
+
: ''),
|
|
241
|
+
}
|
|
242
|
+
debugDir = settings.debugDir
|
|
243
|
+
|
|
244
|
+
// Services may come up after this plugin on a cold boot, so resolve them lazily on every
|
|
245
|
+
// access instead of snapshotting them during apply(). Registration that needs a service is
|
|
246
|
+
// wrapped in ctx.inject([...]) below, which re-runs once the service becomes available.
|
|
247
|
+
const services = {
|
|
248
|
+
get llm() {
|
|
249
|
+
return settings.modelCalls ? ctx.get('llm') : null
|
|
250
|
+
},
|
|
251
|
+
get webServer() {
|
|
252
|
+
return ctx.get('webServer')
|
|
253
|
+
},
|
|
254
|
+
get connection() {
|
|
255
|
+
return ctx.get('connection')
|
|
256
|
+
},
|
|
257
|
+
get commands() {
|
|
258
|
+
return ctx.get('commands')
|
|
259
|
+
},
|
|
260
|
+
get agentDefaultModel() {
|
|
261
|
+
return ctx.get('agentDefaultModel')
|
|
262
|
+
},
|
|
263
|
+
get agents() {
|
|
264
|
+
return ctx.get('agents')
|
|
265
|
+
},
|
|
266
|
+
get sessionPersistence() {
|
|
267
|
+
return ctx.get('sessionPersistence')
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const shared = hostGlobal()
|
|
272
|
+
// A new apply can run before the old context has finished disposing.
|
|
273
|
+
shared.stopGeneration?.()
|
|
274
|
+
const states = shared.states // sessionId -> { plan, suggestion, stale }
|
|
275
|
+
const sessions = shared.sessions // sessionId -> Session
|
|
276
|
+
const warmAttempts = shared.warmAttempts // sessionId -> { at, seq }
|
|
277
|
+
const inflight = new Map() // sessionId -> AbortController
|
|
278
|
+
const gate = createGate(DEFAULTS.globalConcurrency)
|
|
279
|
+
const store = shared.store
|
|
280
|
+
let disposed = false
|
|
281
|
+
const stopGeneration = () => {
|
|
282
|
+
disposed = true
|
|
283
|
+
for (const controller of inflight.values()) controller.abort(new Error('plugin-disposed'))
|
|
284
|
+
inflight.clear()
|
|
285
|
+
if (shared.stopGeneration === stopGeneration) delete shared.stopGeneration
|
|
286
|
+
}
|
|
287
|
+
shared.stopGeneration = stopGeneration
|
|
288
|
+
ctx.effect(() => stopGeneration)
|
|
289
|
+
const debug = { lastTurnEnd: null, lastJob: null, model: null }
|
|
290
|
+
let debugTheme = null
|
|
291
|
+
const experimentState = { running: false, result: null, error: null }
|
|
292
|
+
|
|
293
|
+
const publicView = sessionId => {
|
|
294
|
+
const state = states.get(sessionId)
|
|
295
|
+
const plan = state?.plan ?? null
|
|
296
|
+
return {
|
|
297
|
+
sessionId,
|
|
298
|
+
plan,
|
|
299
|
+
version: plan?.version ?? null,
|
|
300
|
+
status: plan?.status ?? null,
|
|
301
|
+
intent: plan?.intent ?? null,
|
|
302
|
+
steps: Array.isArray(plan?.steps) ? plan.steps : [],
|
|
303
|
+
updatedAt: plan?.updatedAt ?? state?.updatedAt ?? null,
|
|
304
|
+
reason: plan?.reason ?? null,
|
|
305
|
+
suggestion: state?.suggestion ?? null,
|
|
306
|
+
stale: state ? Boolean(state.stale) : true,
|
|
307
|
+
debugTheme,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function publishState(sessionId, patch) {
|
|
312
|
+
const previous = states.get(sessionId) ?? { plan: null, suggestion: null, stale: false }
|
|
313
|
+
states.set(sessionId, { ...previous, ...patch })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function seedPlan(sessionId, evidence) {
|
|
317
|
+
const intent = evidence?.intent || evidence?.firstUserMessage || '推进当前会话目标'
|
|
318
|
+
const fallback = (() => {
|
|
319
|
+
try {
|
|
320
|
+
return fallbackPlan(intent)
|
|
321
|
+
} catch {
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
324
|
+
})()
|
|
325
|
+
const steps = fallback?.ok && Array.isArray(fallback.steps) ? fallback.steps : [
|
|
326
|
+
{ id: 's1', title: '确认目标与约束', status: 'done', source: 'model' },
|
|
327
|
+
{ id: 's2', title: '完成核心改动', status: 'doing', source: 'model' },
|
|
328
|
+
{ id: 's3', title: '验证并收尾', status: 'todo', source: 'model' },
|
|
329
|
+
]
|
|
330
|
+
const candidate = {
|
|
331
|
+
sessionId,
|
|
332
|
+
version: { gen: 0, rev: 0 },
|
|
333
|
+
status: 'active',
|
|
334
|
+
intent: fallback?.intent || intent,
|
|
335
|
+
steps,
|
|
336
|
+
updatedAt: Date.now(),
|
|
337
|
+
reason: '启发式初始化计划',
|
|
338
|
+
watermark: evidence?.watermark ?? { turn: 0, seq: 0 },
|
|
339
|
+
editedByUser: false,
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const normalized = normalizePlan(candidate)
|
|
343
|
+
if (normalized?.ok && normalized.plan) return normalized.plan
|
|
344
|
+
} catch {
|
|
345
|
+
/* ignore */
|
|
346
|
+
}
|
|
347
|
+
return null
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function heuristicText(evidence) {
|
|
351
|
+
try {
|
|
352
|
+
const result = heuristicSuggestion(evidence)
|
|
353
|
+
if (result?.ok && typeof result.text === 'string' && result.text.trim()) return result.text.trim()
|
|
354
|
+
} catch {
|
|
355
|
+
/* ignore */
|
|
356
|
+
}
|
|
357
|
+
return ''
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Warm backfill: build an implicit plan for a session the plugin has never observed ──
|
|
361
|
+
// Live sessions come from `sessions.get()`; older sessions are read through the durable
|
|
362
|
+
// session store so opening them also produces a plan + suggestion.
|
|
363
|
+
async function loadStoredEvents(sessionId, limit = 240) {
|
|
364
|
+
const persistence = services.sessionPersistence
|
|
365
|
+
if (!persistence || typeof persistence.open !== 'function') return null
|
|
366
|
+
let handle = null
|
|
367
|
+
try {
|
|
368
|
+
handle = await persistence.open(sessionId, 'read')
|
|
369
|
+
let total = 0
|
|
370
|
+
let page = null
|
|
371
|
+
try {
|
|
372
|
+
const stat = await persistence.stat?.(sessionId)
|
|
373
|
+
total = Number(stat?.eventCount ?? 0)
|
|
374
|
+
page = stat
|
|
375
|
+
} catch {
|
|
376
|
+
total = 0
|
|
377
|
+
}
|
|
378
|
+
const from = Math.max(0, total - limit)
|
|
379
|
+
let read = await handle.read(from, limit)
|
|
380
|
+
let events = Array.isArray(read?.events) ? read.events.slice() : []
|
|
381
|
+
// Fallback: some backends report no eventCount — page from the beginning until the end.
|
|
382
|
+
if (!events.length && !page) {
|
|
383
|
+
let offset = 0
|
|
384
|
+
for (let guard = 0; guard < 40; guard += 1) {
|
|
385
|
+
const chunk = await handle.read(offset, 400)
|
|
386
|
+
const batch = Array.isArray(chunk?.events) ? chunk.events : []
|
|
387
|
+
if (!batch.length) break
|
|
388
|
+
events = events.concat(batch)
|
|
389
|
+
offset += batch.length
|
|
390
|
+
}
|
|
391
|
+
events = events.slice(-limit)
|
|
392
|
+
}
|
|
393
|
+
return events
|
|
394
|
+
} catch {
|
|
395
|
+
return null
|
|
396
|
+
} finally {
|
|
397
|
+
try {
|
|
398
|
+
await handle?.close?.()
|
|
399
|
+
} catch {
|
|
400
|
+
/* ignore */
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function warmSessionShim(sessionId, events) {
|
|
406
|
+
const last = events[events.length - 1] ?? null
|
|
407
|
+
let lastTurn = 0
|
|
408
|
+
let maxTurn = 0
|
|
409
|
+
let hasConversation = false
|
|
410
|
+
for (const event of events) {
|
|
411
|
+
const type = String(event?.type ?? '')
|
|
412
|
+
if (type === 'turn/end') lastTurn = Math.max(lastTurn, Number(event.data?.turn ?? 0))
|
|
413
|
+
if (type === 'turn/start') maxTurn = Math.max(maxTurn, Number(event.data?.turn ?? 0))
|
|
414
|
+
if (type === 'user/message' || type === 'assistant/message') hasConversation = true
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
warm: true,
|
|
418
|
+
id: sessionId,
|
|
419
|
+
seq: Number(last?.seq ?? 0),
|
|
420
|
+
lastTurn: lastTurn || maxTurn,
|
|
421
|
+
hasConversation,
|
|
422
|
+
snapshotEvents(from) {
|
|
423
|
+
const start = Number(from ?? 0)
|
|
424
|
+
return events.filter(event => Number(event?.seq ?? 0) >= start)
|
|
425
|
+
},
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function runWarmBackfill(sessionId, options = {}) {
|
|
430
|
+
if (!settings.warmBackfill) return { ok: false, reason: 'disabled' }
|
|
431
|
+
if (!sessionId) return { ok: false, reason: 'no-session-id' }
|
|
432
|
+
if (states.get(sessionId)?.plan) return { ok: true, already: true }
|
|
433
|
+
const now = Date.now()
|
|
434
|
+
const attempt = warmAttempts.get(sessionId)
|
|
435
|
+
if (!options.force && attempt && now - Number(attempt.at ?? 0) < 10 * 60 * 1000) {
|
|
436
|
+
return { ok: true, skipped: 'recent', at: attempt.at }
|
|
437
|
+
}
|
|
438
|
+
warmAttempts.set(sessionId, { at: now, seq: attempt?.seq ?? 0 })
|
|
439
|
+
|
|
440
|
+
const live = sessions.get(sessionId) ?? null
|
|
441
|
+
if (live) {
|
|
442
|
+
const turn = Number(debug.lastTurnEnd?.sessionId === sessionId ? debug.lastTurnEnd.turn : 0)
|
|
443
|
+
void runGeneration(live, { seq: Number(live.seq ?? 0), data: { turn } }, { force: true })
|
|
444
|
+
return { ok: true, started: true, source: 'live' }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const events = await loadStoredEvents(sessionId, 1500)
|
|
448
|
+
if (!events || events.length === 0) {
|
|
449
|
+
debugLog({ phase: 'warm-no-history', sessionId })
|
|
450
|
+
return { ok: false, reason: 'no-history' }
|
|
451
|
+
}
|
|
452
|
+
const shim = warmSessionShim(sessionId, events)
|
|
453
|
+
if (!shim.hasConversation) {
|
|
454
|
+
return {
|
|
455
|
+
ok: false,
|
|
456
|
+
reason: 'no-conversation',
|
|
457
|
+
detail: { events: events.length, types: events.slice(-8).map(event => event?.type) },
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
warmAttempts.set(sessionId, { at: now, seq: shim.seq })
|
|
461
|
+
debugLog({ phase: 'warm-start', sessionId, events: events.length, turn: shim.lastTurn })
|
|
462
|
+
try {
|
|
463
|
+
await runGeneration(shim, { seq: shim.seq, data: { turn: shim.lastTurn } }, { force: true })
|
|
464
|
+
} catch (error) {
|
|
465
|
+
debugLog({ phase: 'warm-failed', sessionId, error: String(error) })
|
|
466
|
+
return { ok: false, reason: 'generation-failed', detail: String(error) }
|
|
467
|
+
}
|
|
468
|
+
const view = publicView(sessionId)
|
|
469
|
+
debugLog({ phase: 'warm-done', sessionId, version: view.version, hasSuggestion: Boolean(view.suggestion) })
|
|
470
|
+
return { ok: true, warmed: true, version: view.version, suggestion: view.suggestion, plan: view.plan }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function runGeneration(session, turnEndEvent, options = {}) {
|
|
474
|
+
const sessionId = session?.id
|
|
475
|
+
if (!sessionId || disposed) return
|
|
476
|
+
const seq = Number(turnEndEvent?.seq ?? 0)
|
|
477
|
+
const turn = Number(turnEndEvent?.data?.turn ?? 0)
|
|
478
|
+
const force = Boolean(options.force)
|
|
479
|
+
const instruction = typeof options.instruction === 'string' ? options.instruction.trim() : ''
|
|
480
|
+
const budget = force ? DEFAULTS.forceRewriteDeadlineMs : DEFAULTS.planDeadlineMs
|
|
481
|
+
const startedAt = Date.now()
|
|
482
|
+
debugLog({ phase: 'job-start', sessionId, turn, force })
|
|
483
|
+
|
|
484
|
+
const previous = inflight.get(sessionId)
|
|
485
|
+
if (previous) previous.abort(new Error('superseded'))
|
|
486
|
+
const controller = new AbortController()
|
|
487
|
+
inflight.set(sessionId, controller)
|
|
488
|
+
const timer = setTimeout(() => controller.abort(new Error('plan-deadline')), budget)
|
|
489
|
+
const isCurrent = () => !disposed && !controller.signal.aborted && inflight.get(sessionId) === controller
|
|
490
|
+
|
|
491
|
+
await gate.acquire()
|
|
492
|
+
try {
|
|
493
|
+
if (!isCurrent()) return
|
|
494
|
+
let events = []
|
|
495
|
+
try {
|
|
496
|
+
events = Array.from(session.snapshotEvents(Math.max(0, seq - 300)) ?? [])
|
|
497
|
+
} catch {
|
|
498
|
+
events = []
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const basePlan = states.get(sessionId)?.plan ?? null
|
|
502
|
+
const storedVersion = store.get(sessionId)?.version ?? null
|
|
503
|
+
const evidence = (() => {
|
|
504
|
+
try {
|
|
505
|
+
const collected = collectFromEvents({ events, plan: basePlan, turn, sessionId })
|
|
506
|
+
if (collected && collected.ok !== false) return collected
|
|
507
|
+
} catch (error) {
|
|
508
|
+
debugLog({ phase: 'evidence-error', sessionId, error: String(error) })
|
|
509
|
+
}
|
|
510
|
+
return { ok: false, turn, watermark: { turn, seq }, plan: basePlan, intent: '', lastAssistantText: '' }
|
|
511
|
+
})()
|
|
512
|
+
if (instruction) evidence.instruction = instruction
|
|
513
|
+
|
|
514
|
+
// Round-2 isolation test: the patch path is most accurate with a compact evidence
|
|
515
|
+
// slice (800-2000 chars); feeding the full history caused false-completion claims
|
|
516
|
+
// and dropped the blind-judge score. Rewrite keeps the full evidence payload.
|
|
517
|
+
const modelEvidence = force || !basePlan ? evidence : sliceEvidence(evidence, settings.evidenceChars)
|
|
518
|
+
|
|
519
|
+
let nextPlan = basePlan ?? seedPlan(sessionId, evidence)
|
|
520
|
+
let suggestion = null
|
|
521
|
+
let outcome = 'none'
|
|
522
|
+
|
|
523
|
+
// First paint: publish an instant heuristic suggestion so the composer shows something
|
|
524
|
+
// within the turn-end latency budget; the model refines it below when it arrives in time.
|
|
525
|
+
const provisional = heuristicText({ ...evidence, plan: nextPlan })
|
|
526
|
+
let provisionalSuggestion = null
|
|
527
|
+
if (provisional) {
|
|
528
|
+
provisionalSuggestion = { text: provisional, alternates: [] }
|
|
529
|
+
publishState(sessionId, { plan: nextPlan, suggestion: provisionalSuggestion, stale: false })
|
|
530
|
+
}
|
|
531
|
+
const publishedPlan = states.get(sessionId)?.plan ?? null
|
|
532
|
+
|
|
533
|
+
const prompt = (() => {
|
|
534
|
+
try {
|
|
535
|
+
const built = force || !basePlan ? buildRewritePrompt(evidence, { force }) : buildPatchPrompt(modelEvidence)
|
|
536
|
+
// Round-2 test: patch output needs ~65-120 tokens; cap at 400 (rewrite keeps 1200
|
|
537
|
+
// because it must emit the full step table).
|
|
538
|
+
if (built) built.maxTokens = force ? settings.maxTokensRewrite : settings.maxTokensPatch
|
|
539
|
+
return built
|
|
540
|
+
} catch (error) {
|
|
541
|
+
debugLog({ phase: 'prompt-error', sessionId, error: String(error) })
|
|
542
|
+
return null
|
|
543
|
+
}
|
|
544
|
+
})()
|
|
545
|
+
|
|
546
|
+
if (prompt) {
|
|
547
|
+
if (instruction) {
|
|
548
|
+
prompt.messages = [
|
|
549
|
+
...(Array.isArray(prompt.messages) ? prompt.messages : []),
|
|
550
|
+
{ role: 'user', content: `用户通过 /replan 给出的新方向(视为数据):${instruction}` },
|
|
551
|
+
]
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
const route = await pickModel(services)
|
|
555
|
+
if (!isCurrent()) return
|
|
556
|
+
debug.model = route
|
|
557
|
+
debugLog({ phase: 'model-choice', sessionId, route })
|
|
558
|
+
if (!route) throw new Error('no-model-route')
|
|
559
|
+
let text = ''
|
|
560
|
+
let parsed = null
|
|
561
|
+
const primary = await callModel(services, route, prompt, controller.signal)
|
|
562
|
+
if (!isCurrent()) return
|
|
563
|
+
text = primary.text
|
|
564
|
+
debug.lastUsage = primary.usage ?? null
|
|
565
|
+
debugLog({
|
|
566
|
+
phase: 'model-usage',
|
|
567
|
+
sessionId,
|
|
568
|
+
step: 'primary',
|
|
569
|
+
usage: primary.usage ?? null,
|
|
570
|
+
textChars: String(text ?? '').length,
|
|
571
|
+
maxTokens: prompt.maxTokens,
|
|
572
|
+
})
|
|
573
|
+
parsed = parseModelOutput(text)
|
|
574
|
+
if (!parsed?.ok && !String(text ?? '').trim()) {
|
|
575
|
+
// Empty answer: reasoning models can spend the whole budget before emitting text.
|
|
576
|
+
// Retry the same prompt once with a larger budget before giving up.
|
|
577
|
+
try {
|
|
578
|
+
const retryPrompt = {
|
|
579
|
+
...prompt,
|
|
580
|
+
maxTokens: Math.min(8000, Math.max(2000, (Number(prompt.maxTokens) || 800) * 2)),
|
|
581
|
+
}
|
|
582
|
+
const retried = await callModel(services, route, retryPrompt, controller.signal)
|
|
583
|
+
if (!isCurrent()) return
|
|
584
|
+
text = retried.text
|
|
585
|
+
debug.lastUsage = retried.usage ?? debug.lastUsage
|
|
586
|
+
parsed = parseModelOutput(text)
|
|
587
|
+
debugLog({
|
|
588
|
+
phase: 'empty-retry',
|
|
589
|
+
sessionId,
|
|
590
|
+
ok: parsed?.ok === true,
|
|
591
|
+
code: parsed?.code,
|
|
592
|
+
maxTokens: retryPrompt.maxTokens,
|
|
593
|
+
usage: retried.usage ?? null,
|
|
594
|
+
textHead: String(text ?? '').slice(0, 120),
|
|
595
|
+
})
|
|
596
|
+
} catch (error) {
|
|
597
|
+
debugLog({ phase: 'empty-retry-failed', sessionId, error: String(error) })
|
|
598
|
+
}
|
|
599
|
+
} else if (!parsed?.ok) {
|
|
600
|
+
// Non-empty but not valid JSON (truncated / stray text): one repair retry.
|
|
601
|
+
debugLog({ phase: 'parse-retry', sessionId, code: parsed?.code, error: parsed?.error })
|
|
602
|
+
try {
|
|
603
|
+
const repairPrompt = buildRepairPrompt(prompt, text, parsed?.error ?? '')
|
|
604
|
+
if (repairPrompt) repairPrompt.maxTokens = Math.max(Number(repairPrompt.maxTokens) || 0, 1600)
|
|
605
|
+
const repairedRes = await callModel(services, route, repairPrompt, controller.signal)
|
|
606
|
+
if (!isCurrent()) return
|
|
607
|
+
debug.lastUsage = repairedRes.usage ?? debug.lastUsage
|
|
608
|
+
const reparsed = parseModelOutput(repairedRes.text)
|
|
609
|
+
if (reparsed?.ok) parsed = reparsed
|
|
610
|
+
} catch (error) {
|
|
611
|
+
debugLog({ phase: 'repair-failed', sessionId, error: String(error) })
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (parsed?.ok && parsed.output) {
|
|
615
|
+
const output = parsed.output
|
|
616
|
+
const meta = {
|
|
617
|
+
reason: output.reason || '模型更新',
|
|
618
|
+
watermark: { turn, seq },
|
|
619
|
+
now: Date.now(),
|
|
620
|
+
sessionId,
|
|
621
|
+
force,
|
|
622
|
+
}
|
|
623
|
+
let applied = null
|
|
624
|
+
if (output.op === 'patch' && nextPlan) {
|
|
625
|
+
applied = patchPlan(nextPlan, output.changes, meta)
|
|
626
|
+
} else {
|
|
627
|
+
const steps = output.op === 'rewrite' ? output.steps : null
|
|
628
|
+
if (steps && steps.length) {
|
|
629
|
+
applied = rewritePlan(nextPlan, {
|
|
630
|
+
steps,
|
|
631
|
+
intent: output.intent || undefined,
|
|
632
|
+
reason: output.reason,
|
|
633
|
+
}, meta)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (applied?.ok && applied.plan) {
|
|
637
|
+
nextPlan = applied.plan
|
|
638
|
+
nextPlan.status = statusFor(nextPlan.steps)
|
|
639
|
+
outcome = applied.changed === false ? 'unchanged' : 'committed'
|
|
640
|
+
} else if (applied && !applied.ok) {
|
|
641
|
+
debugLog({ phase: 'plan-apply-rejected', sessionId, code: applied.code, error: applied.error })
|
|
642
|
+
}
|
|
643
|
+
if (typeof output.suggestion === 'string' && output.suggestion.trim()) {
|
|
644
|
+
suggestion = {
|
|
645
|
+
text: output.suggestion.trim(),
|
|
646
|
+
alternates: Array.isArray(output.alternates) ? output.alternates.slice(0, DEFAULTS.alternatesMax) : [],
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
} else {
|
|
650
|
+
debugLog({ phase: 'parse-failed', sessionId, code: parsed?.code, error: parsed?.error, textHead: String(text).slice(0, 240) })
|
|
651
|
+
}
|
|
652
|
+
} catch (error) {
|
|
653
|
+
debugLog({ phase: 'model-error', sessionId, error: String(error) })
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (!isCurrent()) return
|
|
658
|
+
// A user edit or reset can replace the visible plan while the model is waiting.
|
|
659
|
+
// Its newer plan and suggestion belong to that writer, not to this job.
|
|
660
|
+
if ((states.get(sessionId)?.plan ?? null) !== publishedPlan) {
|
|
661
|
+
debug.lastJob = { sessionId, turn, elapsed: Date.now() - startedAt, outcome: 'conflict', suggestion: false }
|
|
662
|
+
debugLog({ phase: 'state-conflict', sessionId, turn })
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (!suggestion) {
|
|
667
|
+
const fallbackText = heuristicText({ ...evidence, plan: nextPlan })
|
|
668
|
+
if (fallbackText) suggestion = { text: fallbackText, alternates: [] }
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (nextPlan) {
|
|
672
|
+
try {
|
|
673
|
+
// Check CAS even for an unchanged plan; an old snapshot is never an
|
|
674
|
+
// unconditional write. null also protects the first commit from races.
|
|
675
|
+
const committed = store.put(nextPlan, { cas: storedVersion })
|
|
676
|
+
if (!committed) {
|
|
677
|
+
debugLog({ phase: 'store-cas-miss', sessionId })
|
|
678
|
+
const latest = store.get(sessionId)
|
|
679
|
+
publishState(sessionId, { plan: latest, suggestion: null, stale: true })
|
|
680
|
+
debug.lastJob = { sessionId, turn, elapsed: Date.now() - startedAt, outcome: 'conflict', suggestion: false }
|
|
681
|
+
return
|
|
682
|
+
}
|
|
683
|
+
} catch (error) {
|
|
684
|
+
debugLog({ phase: 'store-error', sessionId, error: String(error) })
|
|
685
|
+
return
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const elapsed = Date.now() - startedAt
|
|
690
|
+
const suggestionDeadline = force ? Number.POSITIVE_INFINITY : Math.max(DEFAULTS.suggestionDeadlineMs, 6000)
|
|
691
|
+
const withinSuggestionDeadline = elapsed <= suggestionDeadline
|
|
692
|
+
const finalSuggestion = withinSuggestionDeadline ? suggestion ?? provisionalSuggestion : provisionalSuggestion
|
|
693
|
+
publishState(sessionId, {
|
|
694
|
+
plan: nextPlan,
|
|
695
|
+
suggestion: finalSuggestion,
|
|
696
|
+
stale: false,
|
|
697
|
+
})
|
|
698
|
+
debug.lastJob = { sessionId, turn, elapsed, outcome, suggestion: Boolean(suggestion), withinSuggestionDeadline }
|
|
699
|
+
debugLog({ phase: 'job-done', sessionId, turn, elapsed, outcome, withinSuggestionDeadline })
|
|
700
|
+
} catch (error) {
|
|
701
|
+
if (!isCurrent()) return
|
|
702
|
+
debugLog({ phase: 'job-failed', sessionId, turn, error: String(error) })
|
|
703
|
+
const existing = states.get(sessionId)
|
|
704
|
+
publishState(sessionId, { plan: existing?.plan ?? null, suggestion: existing?.suggestion ?? null, stale: Boolean(existing?.plan) })
|
|
705
|
+
} finally {
|
|
706
|
+
clearTimeout(timer)
|
|
707
|
+
if (inflight.get(sessionId) === controller) inflight.delete(sessionId)
|
|
708
|
+
gate.release()
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── Session event subscription: one plan job per completed turn ──────────
|
|
713
|
+
const handledTurns = shared.handledTurns // sessionId -> last handled turn number
|
|
714
|
+
|
|
715
|
+
function triggerTurnEnd(session, seq, turn, source) {
|
|
716
|
+
if (!session?.id || disposed) return
|
|
717
|
+
sessions.set(session.id, session)
|
|
718
|
+
const turnNumber = Number(turn) || 0
|
|
719
|
+
if (handledTurns.get(session.id) === turnNumber) return
|
|
720
|
+
handledTurns.set(session.id, turnNumber)
|
|
721
|
+
debug.lastTurnEnd = { sessionId: session.id, seq: Number(seq) || 0, turn: turnNumber, source }
|
|
722
|
+
debugLog({ phase: 'turn-end-trigger', sessionId: session.id, turn: turnNumber, source })
|
|
723
|
+
void runGeneration(session, { seq: Number(seq) || 0, data: { turn: turnNumber } }, { force: false })
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Deterministic idle fallback: after the session log goes quiet for a moment, read the last
|
|
727
|
+
// committed `turn/end` straight from the session log. This does not depend on the turn-boundary
|
|
728
|
+
// event reaching the plugin context (it may be missed across a plugin reload window).
|
|
729
|
+
const idleTimers = new Map()
|
|
730
|
+
const IDLE_QUIET_MS = 2000
|
|
731
|
+
ctx.effect(() => () => {
|
|
732
|
+
for (const timer of idleTimers.values()) clearTimeout(timer)
|
|
733
|
+
idleTimers.clear()
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
function checkIdleTurn(session) {
|
|
737
|
+
if (!session?.id || disposed) return
|
|
738
|
+
let events = []
|
|
739
|
+
try {
|
|
740
|
+
const from = Math.max(0, Number(session.seq ?? 0) - 160)
|
|
741
|
+
events = Array.from(session.snapshotEvents(from) ?? [])
|
|
742
|
+
} catch {
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
746
|
+
const event = events[i]
|
|
747
|
+
if (event?.type !== 'turn/end') continue
|
|
748
|
+
if (event.data?.reason?.kind !== 'completed') return
|
|
749
|
+
const eventTime = Number(event.time ?? 0)
|
|
750
|
+
// Only act on a turn that just ended (plugin reloads must not replay old turns).
|
|
751
|
+
if (eventTime > 0 && Date.now() - eventTime > 5 * 60 * 1000) return
|
|
752
|
+
const turn = Number(event.data?.turn ?? 0)
|
|
753
|
+
if (handledTurns.get(session.id) === turn) return
|
|
754
|
+
debugLog({ phase: 'idle-turn-end', sessionId: session.id, turn })
|
|
755
|
+
triggerTurnEnd(session, event.seq, turn, 'idle-log-scan')
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function scheduleIdleCheck(session) {
|
|
761
|
+
if (!session?.id || disposed) return
|
|
762
|
+
const previous = idleTimers.get(session.id)
|
|
763
|
+
if (previous) clearTimeout(previous)
|
|
764
|
+
const timer = setTimeout(() => {
|
|
765
|
+
idleTimers.delete(session.id)
|
|
766
|
+
try {
|
|
767
|
+
checkIdleTurn(sessions.get(session.id) ?? session)
|
|
768
|
+
} catch (error) {
|
|
769
|
+
debugLog({ phase: 'idle-check-error', sessionId: session.id, error: String(error) })
|
|
770
|
+
}
|
|
771
|
+
}, IDLE_QUIET_MS)
|
|
772
|
+
idleTimers.set(session.id, timer)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
ctx.on('session/event', (session, event) => {
|
|
776
|
+
try {
|
|
777
|
+
if (disposed) return
|
|
778
|
+
debug.eventCount = (debug.eventCount ?? 0) + 1
|
|
779
|
+
debug.eventTypes = debug.eventTypes ?? {}
|
|
780
|
+
const type = String(event?.type ?? 'unknown')
|
|
781
|
+
debug.eventTypes[type] = (debug.eventTypes[type] ?? 0) + 1
|
|
782
|
+
if (debug.eventCount <= 12) debugLog({ phase: 'session-event', type, sessionId: session?.id })
|
|
783
|
+
if (!session?.id) return
|
|
784
|
+
sessions.set(session.id, session)
|
|
785
|
+
scheduleIdleCheck(session)
|
|
786
|
+
if (event?.type !== 'turn/end') return
|
|
787
|
+
debugLog({ phase: 'turn-end-raw', sessionId: session.id, turn: event.data?.turn, reason: event.data?.reason?.kind, seq: event.seq })
|
|
788
|
+
if (event.data?.reason?.kind !== 'completed') return
|
|
789
|
+
triggerTurnEnd(session, event.seq, event.data?.turn, 'session/event')
|
|
790
|
+
} catch (error) {
|
|
791
|
+
debugLog({ phase: 'listener-error', error: String(error) })
|
|
792
|
+
}
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
// Fallback trigger: if a `turn/end` event is not observed through `session/event` for any reason,
|
|
796
|
+
// the transition to `idle` right after the turn still lets us read the last committed turn from
|
|
797
|
+
// the session log and run the same job exactly once per turn.
|
|
798
|
+
ctx.on('agent/status', payload => {
|
|
799
|
+
try {
|
|
800
|
+
if (disposed) return
|
|
801
|
+
if (payload?.status !== 'idle') return
|
|
802
|
+
const sessionId = payload?.agent?.id
|
|
803
|
+
if (!sessionId) return
|
|
804
|
+
const session = sessions.get(sessionId)
|
|
805
|
+
if (!session) {
|
|
806
|
+
debugLog({ phase: 'agent-idle-no-session', sessionId })
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
let events = []
|
|
810
|
+
try {
|
|
811
|
+
events = Array.from(session.snapshotEvents(Math.max(0, Number(session.seq ?? 0) - 120)) ?? [])
|
|
812
|
+
} catch {
|
|
813
|
+
events = []
|
|
814
|
+
}
|
|
815
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
816
|
+
const event = events[i]
|
|
817
|
+
if (event?.type !== 'turn/end') continue
|
|
818
|
+
debugLog({ phase: 'agent-idle-turn-end', sessionId, turn: event.data?.turn, reason: event.data?.reason?.kind })
|
|
819
|
+
if (event.data?.reason?.kind === 'completed') triggerTurnEnd(session, event.seq, event.data?.turn, 'agent/status-idle')
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
debugLog({ phase: 'agent-idle-no-turn-end', sessionId })
|
|
823
|
+
} catch (error) {
|
|
824
|
+
debugLog({ phase: 'agent-status-error', error: String(error) })
|
|
825
|
+
}
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
ctx.on('agent/turn-stopping', payload => {
|
|
829
|
+
try {
|
|
830
|
+
debugLog({ phase: 'turn-stopping', sessionId: payload?.agent?.id, turn: payload?.turn })
|
|
831
|
+
} catch {
|
|
832
|
+
/* ignore */
|
|
833
|
+
}
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
// ── HTTP routes for the Client half ──────────────────────────────────────
|
|
837
|
+
const handleRouteUnsafe = async (req, res) => {
|
|
838
|
+
let connection
|
|
839
|
+
try { connection = services.connection } catch { /* unavailable */ }
|
|
840
|
+
const rejected = requestRejection(connection, req)
|
|
841
|
+
if (rejected !== undefined) {
|
|
842
|
+
jsonResponse(res, rejected, { ok: false, error: rejected === 503 ? 'auth-unavailable' : 'request-rejected' })
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
let url = null
|
|
846
|
+
try {
|
|
847
|
+
url = new URL(req.url, 'http://127.0.0.1')
|
|
848
|
+
} catch {
|
|
849
|
+
url = null
|
|
850
|
+
}
|
|
851
|
+
const pathname = url?.pathname ?? String(req.url ?? '')
|
|
852
|
+
const method = String(req.method ?? 'GET').toUpperCase()
|
|
853
|
+
debug.requestCount = (debug.requestCount ?? 0) + 1
|
|
854
|
+
if (debug.requestCount <= 200) {
|
|
855
|
+
debugLog({
|
|
856
|
+
phase: 'http',
|
|
857
|
+
method,
|
|
858
|
+
pathname,
|
|
859
|
+
query: url?.search ?? '',
|
|
860
|
+
ua: String(req.headers?.['user-agent'] ?? '').slice(0, 48),
|
|
861
|
+
cookie: Boolean(req.headers?.cookie),
|
|
862
|
+
})
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (method === 'OPTIONS') {
|
|
866
|
+
jsonResponse(res, 204, {})
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
if (pathname === `${ROUTE_PREFIX}warm` && method === 'POST') {
|
|
870
|
+
const body = (await readJsonBody(req)) ?? {}
|
|
871
|
+
const sessionId = String(body.sessionId ?? '')
|
|
872
|
+
if (!sessionId) {
|
|
873
|
+
jsonResponse(res, 200, { ok: false, reason: 'no-session-id' })
|
|
874
|
+
return
|
|
875
|
+
}
|
|
876
|
+
const outcome = await runWarmBackfill(sessionId, { force: body.force === true })
|
|
877
|
+
jsonResponse(res, 200, outcome)
|
|
878
|
+
return
|
|
879
|
+
}
|
|
880
|
+
if (pathname === `${ROUTE_PREFIX}state` && method === 'GET') {
|
|
881
|
+
jsonResponse(res, 200, publicView(url?.searchParams?.get('sessionId') ?? ''))
|
|
882
|
+
return
|
|
883
|
+
}
|
|
884
|
+
if (pathname === `${ROUTE_PREFIX}debug` && method === 'GET') {
|
|
885
|
+
const probeSessionId = url?.searchParams?.get('sessionId') ?? ''
|
|
886
|
+
const logTail = (() => {
|
|
887
|
+
if (!probeSessionId) return null
|
|
888
|
+
const session = sessions.get(probeSessionId)
|
|
889
|
+
if (!session) return null
|
|
890
|
+
try {
|
|
891
|
+
const from = Math.max(0, Number(session.seq ?? 0) - 60)
|
|
892
|
+
return Array.from(session.snapshotEvents(from) ?? []).map(event => `${event.seq}:${event.type}`)
|
|
893
|
+
} catch (error) {
|
|
894
|
+
return [`error:${String(error)}`]
|
|
895
|
+
}
|
|
896
|
+
})()
|
|
897
|
+
jsonResponse(res, 200, {
|
|
898
|
+
...debug,
|
|
899
|
+
sessions: Array.from(sessions.keys()),
|
|
900
|
+
states: Array.from(states.keys()),
|
|
901
|
+
probeSessionId,
|
|
902
|
+
logTail,
|
|
903
|
+
})
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
if (pathname === `${ROUTE_PREFIX}replan` && method === 'POST') {
|
|
907
|
+
const body = (await readJsonBody(req)) ?? {}
|
|
908
|
+
const sessionId = String(body.sessionId ?? '')
|
|
909
|
+
const session = sessions.get(sessionId)
|
|
910
|
+
if (!session) {
|
|
911
|
+
jsonResponse(res, 200, { ok: false, error: 'session-not-observed' })
|
|
912
|
+
return
|
|
913
|
+
}
|
|
914
|
+
const turn = Number(states.get(sessionId)?.plan?.watermark?.turn ?? 0)
|
|
915
|
+
void runGeneration(
|
|
916
|
+
session,
|
|
917
|
+
{ seq: Number(debug.lastTurnEnd?.seq ?? 0), data: { turn } },
|
|
918
|
+
{ force: true, instruction: typeof body.instruction === 'string' ? body.instruction : '' },
|
|
919
|
+
)
|
|
920
|
+
jsonResponse(res, 200, { ok: true, queued: true })
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
if (pathname === `${ROUTE_PREFIX}diag` && method === 'GET') {
|
|
924
|
+
jsonResponse(res, 200, { ok: true, lastDiag: debug.lastDiag ?? null })
|
|
925
|
+
return
|
|
926
|
+
}
|
|
927
|
+
if (pathname === `${ROUTE_PREFIX}diag` && method === 'POST') {
|
|
928
|
+
const body = (await readJsonBody(req)) ?? {}
|
|
929
|
+
debug.lastDiag = { at: Date.now(), body: safeDiagnostics(body) }
|
|
930
|
+
debugLog({ phase: 'client-diag' })
|
|
931
|
+
jsonResponse(res, 200, { ok: true })
|
|
932
|
+
return
|
|
933
|
+
}
|
|
934
|
+
if (pathname === `${ROUTE_PREFIX}autojob` && method === 'POST') {
|
|
935
|
+
const body = (await readJsonBody(req)) ?? {}
|
|
936
|
+
const sessionId = String(body.sessionId ?? '')
|
|
937
|
+
const session = sessions.get(sessionId)
|
|
938
|
+
if (!session) {
|
|
939
|
+
jsonResponse(res, 200, { ok: false, error: 'session-not-observed' })
|
|
940
|
+
return
|
|
941
|
+
}
|
|
942
|
+
if (!states.get(sessionId)?.plan) {
|
|
943
|
+
jsonResponse(res, 200, { ok: false, error: 'no-plan' })
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
const baseTurn = Number(states.get(sessionId)?.plan?.watermark?.turn ?? 0)
|
|
947
|
+
const startedAt = Date.now()
|
|
948
|
+
try {
|
|
949
|
+
await runGeneration(
|
|
950
|
+
session,
|
|
951
|
+
{ seq: Number(session.seq ?? 0), data: { turn: baseTurn + 1 } },
|
|
952
|
+
{ force: false },
|
|
953
|
+
)
|
|
954
|
+
} catch (error) {
|
|
955
|
+
debugLog({ phase: 'autojob-error', sessionId, error: String(error) })
|
|
956
|
+
}
|
|
957
|
+
const view = publicView(sessionId)
|
|
958
|
+
jsonResponse(res, 200, {
|
|
959
|
+
ok: true,
|
|
960
|
+
elapsedMs: Date.now() - startedAt,
|
|
961
|
+
usage: debug.lastUsage ?? null,
|
|
962
|
+
version: view.version,
|
|
963
|
+
suggestion: view.suggestion,
|
|
964
|
+
stepCount: Array.isArray(view.steps) ? view.steps.length : 0,
|
|
965
|
+
reason: view.reason,
|
|
966
|
+
lastJob: debug.lastJob ?? null,
|
|
967
|
+
})
|
|
968
|
+
return
|
|
969
|
+
}
|
|
970
|
+
if (pathname === `${ROUTE_PREFIX}reset` && method === 'POST') {
|
|
971
|
+
const body = (await readJsonBody(req)) ?? {}
|
|
972
|
+
const sessionId = String(body.sessionId ?? '')
|
|
973
|
+
const existed = states.has(sessionId)
|
|
974
|
+
inflight.get(sessionId)?.abort(new Error('state-reset'))
|
|
975
|
+
store.clear(sessionId)
|
|
976
|
+
states.delete(sessionId)
|
|
977
|
+
warmAttempts.delete(sessionId)
|
|
978
|
+
handledTurns.delete(sessionId)
|
|
979
|
+
debugLog({ phase: 'state-reset', sessionId, existed })
|
|
980
|
+
jsonResponse(res, 200, { ok: true, sessionId, existed })
|
|
981
|
+
return
|
|
982
|
+
}
|
|
983
|
+
if (pathname === `${ROUTE_PREFIX}steps` && method === 'POST') {
|
|
984
|
+
const body = (await readJsonBody(req)) ?? {}
|
|
985
|
+
const sessionId = String(body.sessionId ?? '')
|
|
986
|
+
const plan = states.get(sessionId)?.plan ?? null
|
|
987
|
+
if (!plan) {
|
|
988
|
+
jsonResponse(res, 200, { ok: false, error: 'no-plan' })
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
const allowed = ['todo', 'doing', 'done', 'dropped']
|
|
992
|
+
let nextSteps = null
|
|
993
|
+
let editReason = ''
|
|
994
|
+
if (Array.isArray(body.steps) && body.steps.length) {
|
|
995
|
+
// Whole-plan user edit (used by /replan-free "整理并更新计划" requests).
|
|
996
|
+
nextSteps = body.steps
|
|
997
|
+
.map((step, index) => ({
|
|
998
|
+
id: String(step?.id ?? `s${index + 1}`),
|
|
999
|
+
title: String(step?.title ?? '').slice(0, 160),
|
|
1000
|
+
status: allowed.includes(String(step?.status)) ? String(step.status) : 'todo',
|
|
1001
|
+
source: 'user',
|
|
1002
|
+
}))
|
|
1003
|
+
.filter(step => step.title)
|
|
1004
|
+
editReason = typeof body.reason === 'string' && body.reason.trim()
|
|
1005
|
+
? body.reason.trim()
|
|
1006
|
+
: '用户整理并更新计划'
|
|
1007
|
+
} else {
|
|
1008
|
+
const stepId = String(body.stepId ?? '')
|
|
1009
|
+
const status = String(body.status ?? 'done')
|
|
1010
|
+
if (!allowed.includes(status)) {
|
|
1011
|
+
jsonResponse(res, 200, { ok: false, error: 'bad-status' })
|
|
1012
|
+
return
|
|
1013
|
+
}
|
|
1014
|
+
nextSteps = (plan.steps || []).map(step => (
|
|
1015
|
+
step.id === stepId ? { ...step, status, source: 'user' } : step
|
|
1016
|
+
))
|
|
1017
|
+
if (!nextSteps.some(step => step.id === stepId)) {
|
|
1018
|
+
jsonResponse(res, 200, { ok: false, error: 'unknown-step', stepId })
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
1021
|
+
editReason = typeof body.reason === 'string' && body.reason.trim()
|
|
1022
|
+
? body.reason.trim()
|
|
1023
|
+
: `用户手动标记 ${stepId} 为 ${status}`
|
|
1024
|
+
}
|
|
1025
|
+
const edit = { steps: nextSteps }
|
|
1026
|
+
if (typeof body.intent === 'string' && body.intent.trim()) edit.intent = body.intent.trim().slice(0, 200)
|
|
1027
|
+
const applied = applyUserEdit(plan, edit, {
|
|
1028
|
+
reason: editReason,
|
|
1029
|
+
watermark: plan.watermark,
|
|
1030
|
+
now: Date.now(),
|
|
1031
|
+
})
|
|
1032
|
+
if (!applied?.ok) {
|
|
1033
|
+
jsonResponse(res, 200, { ok: false, error: 'apply-failed', detail: applied })
|
|
1034
|
+
return
|
|
1035
|
+
}
|
|
1036
|
+
try {
|
|
1037
|
+
store.put(applied.plan, { cas: plan.version })
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
debugLog({ phase: 'user-edit-store-error', sessionId, error: String(error) })
|
|
1040
|
+
}
|
|
1041
|
+
const previous = states.get(sessionId)
|
|
1042
|
+
publishState(sessionId, {
|
|
1043
|
+
plan: applied.plan,
|
|
1044
|
+
suggestion: previous?.suggestion ?? null,
|
|
1045
|
+
stale: false,
|
|
1046
|
+
})
|
|
1047
|
+
debugLog({ phase: 'user-edit', sessionId, stepCount: nextSteps.length, version: applied.plan.version })
|
|
1048
|
+
jsonResponse(res, 200, {
|
|
1049
|
+
ok: true,
|
|
1050
|
+
changed: applied.changed === true,
|
|
1051
|
+
version: applied.plan.version,
|
|
1052
|
+
editedByUser: applied.plan.editedByUser === true,
|
|
1053
|
+
steps: applied.plan.steps,
|
|
1054
|
+
})
|
|
1055
|
+
return
|
|
1056
|
+
}
|
|
1057
|
+
if (pathname === `${ROUTE_PREFIX}experiment` && method === 'GET') {
|
|
1058
|
+
jsonResponse(res, 200, {
|
|
1059
|
+
running: experimentState.running,
|
|
1060
|
+
error: experimentState.error,
|
|
1061
|
+
result: experimentState.result,
|
|
1062
|
+
})
|
|
1063
|
+
return
|
|
1064
|
+
}
|
|
1065
|
+
if (pathname === `${ROUTE_PREFIX}experiment` && method === 'POST') {
|
|
1066
|
+
const body = (await readJsonBody(req)) ?? {}
|
|
1067
|
+
if (experimentState.running) {
|
|
1068
|
+
jsonResponse(res, 200, { ok: true, running: true })
|
|
1069
|
+
return
|
|
1070
|
+
}
|
|
1071
|
+
const sessionId = String(body.sessionId ?? '')
|
|
1072
|
+
const session = sessions.get(sessionId)
|
|
1073
|
+
if (!session) {
|
|
1074
|
+
jsonResponse(res, 200, { ok: false, error: 'session-not-observed' })
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
const basePlan = states.get(sessionId)?.plan ?? null
|
|
1078
|
+
if (!basePlan) {
|
|
1079
|
+
jsonResponse(res, 200, { ok: false, error: 'no-plan' })
|
|
1080
|
+
return
|
|
1081
|
+
}
|
|
1082
|
+
experimentState.running = true
|
|
1083
|
+
experimentState.result = null
|
|
1084
|
+
experimentState.error = null
|
|
1085
|
+
void (async () => {
|
|
1086
|
+
try {
|
|
1087
|
+
const route = await pickModel(services)
|
|
1088
|
+
if (!route) throw new Error('no-model-route')
|
|
1089
|
+
const judgeRoute = await pickJudgeRoute(services, route)
|
|
1090
|
+
debugLog({ phase: 'experiment-start', sessionId, route, judgeRoute })
|
|
1091
|
+
const result = await runExperiment({
|
|
1092
|
+
services,
|
|
1093
|
+
route,
|
|
1094
|
+
judgeRoute,
|
|
1095
|
+
session,
|
|
1096
|
+
sessionId,
|
|
1097
|
+
basePlan,
|
|
1098
|
+
collectFromEvents,
|
|
1099
|
+
buildPatchPrompt,
|
|
1100
|
+
parseModelOutput,
|
|
1101
|
+
callModel,
|
|
1102
|
+
debugLog,
|
|
1103
|
+
evidenceChars: body.evidenceChars,
|
|
1104
|
+
maxTokens: body.maxTokens,
|
|
1105
|
+
reps: body.reps,
|
|
1106
|
+
onProgress: (index, total, variant) => debugLog({
|
|
1107
|
+
phase: 'experiment-progress',
|
|
1108
|
+
index,
|
|
1109
|
+
total,
|
|
1110
|
+
windowSize: variant?.windowSize,
|
|
1111
|
+
maxTokens: variant?.maxTokens,
|
|
1112
|
+
}),
|
|
1113
|
+
})
|
|
1114
|
+
experimentState.result = result
|
|
1115
|
+
try {
|
|
1116
|
+
if (debugDir) {
|
|
1117
|
+
mkdirSync(debugDir, { recursive: true })
|
|
1118
|
+
writeFileSync(`${debugDir}\\plan-completion-experiment.json`, JSON.stringify(result, null, 2), 'utf8')
|
|
1119
|
+
}
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
/* ignore */
|
|
1122
|
+
}
|
|
1123
|
+
debugLog({ phase: 'experiment-done', total: result.total })
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
experimentState.error = String(error)
|
|
1126
|
+
debugLog({ phase: 'experiment-failed', error: String(error) })
|
|
1127
|
+
} finally {
|
|
1128
|
+
experimentState.running = false
|
|
1129
|
+
}
|
|
1130
|
+
})()
|
|
1131
|
+
jsonResponse(res, 200, { ok: true, started: true, planSteps: Array.isArray(basePlan.steps) ? basePlan.steps.length : 0 })
|
|
1132
|
+
return
|
|
1133
|
+
}
|
|
1134
|
+
if (pathname === `${ROUTE_PREFIX}theme` && method === 'POST') {
|
|
1135
|
+
const body = (await readJsonBody(req)) ?? {}
|
|
1136
|
+
const mode = typeof body.mode === 'string' ? body.mode : null
|
|
1137
|
+
debugTheme = mode === 'dark' || mode === 'light' || mode === 'system' ? mode : null
|
|
1138
|
+
debugLog({ phase: 'theme-request', mode: debugTheme })
|
|
1139
|
+
jsonResponse(res, 200, { ok: true, theme: debugTheme })
|
|
1140
|
+
return
|
|
1141
|
+
}
|
|
1142
|
+
jsonResponse(res, 404, { ok: false, error: 'unknown-route', pathname })
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Defense in depth: an unexpected throw must still close the HTTP response instead
|
|
1146
|
+
// of leaving the client fetch hanging.
|
|
1147
|
+
const handleRoute = async (req, res) => {
|
|
1148
|
+
try {
|
|
1149
|
+
await handleRouteUnsafe(req, res)
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
try {
|
|
1152
|
+
jsonResponse(res, 500, { ok: false, error: 'internal-error' })
|
|
1153
|
+
} catch {
|
|
1154
|
+
/* ignore */
|
|
1155
|
+
}
|
|
1156
|
+
try {
|
|
1157
|
+
debugLog({ phase: 'route-error', method: String(req?.method ?? ''), error: true })
|
|
1158
|
+
} catch {
|
|
1159
|
+
/* ignore */
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// The shipped web server matches only routes already registered for the request path;
|
|
1165
|
+
// register one exact route per endpoint (verified working with the runtime probe).
|
|
1166
|
+
// `ctx.inject` defers this until the webServer service exists (cold boot ordering).
|
|
1167
|
+
ctx.inject(['webServer'], child => {
|
|
1168
|
+
const web = child.webServer
|
|
1169
|
+
if (!web?.register) {
|
|
1170
|
+
debugLog({ phase: 'route-register-skipped', reason: 'webServer unavailable' })
|
|
1171
|
+
return
|
|
1172
|
+
}
|
|
1173
|
+
// Production endpoints are always registered; development/debug endpoints require
|
|
1174
|
+
// `config.enableDebugRoutes: true` so a published bundle exposes a minimal surface.
|
|
1175
|
+
const paths = [
|
|
1176
|
+
`${ROUTE_PREFIX}state`,
|
|
1177
|
+
`${ROUTE_PREFIX}replan`,
|
|
1178
|
+
`${ROUTE_PREFIX}warm`,
|
|
1179
|
+
]
|
|
1180
|
+
if (settings.enableDebugRoutes) {
|
|
1181
|
+
paths.push(
|
|
1182
|
+
`${ROUTE_PREFIX}debug`,
|
|
1183
|
+
`${ROUTE_PREFIX}diag`,
|
|
1184
|
+
`${ROUTE_PREFIX}theme`,
|
|
1185
|
+
`${ROUTE_PREFIX}experiment`,
|
|
1186
|
+
`${ROUTE_PREFIX}autojob`,
|
|
1187
|
+
`${ROUTE_PREFIX}steps`,
|
|
1188
|
+
`${ROUTE_PREFIX}reset`,
|
|
1189
|
+
)
|
|
1190
|
+
}
|
|
1191
|
+
const handles = []
|
|
1192
|
+
// Dispose route handlers registered by a previous instance of this plugin (toggle/reload).
|
|
1193
|
+
for (const dispose of shared.disposers.splice(0, shared.disposers.length)) {
|
|
1194
|
+
try {
|
|
1195
|
+
dispose()
|
|
1196
|
+
} catch {
|
|
1197
|
+
/* ignore */
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
try {
|
|
1201
|
+
for (const path of paths) {
|
|
1202
|
+
handles.push(web.register({ kind: 'exact', path, handler: handleRoute }))
|
|
1203
|
+
}
|
|
1204
|
+
for (const dispose of handles) shared.disposers.push(dispose)
|
|
1205
|
+
debugLog({ phase: 'routes-registered', paths })
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
debugLog({ phase: 'route-register-error', error: String(error) })
|
|
1208
|
+
}
|
|
1209
|
+
child.effect(() => () => {
|
|
1210
|
+
for (const dispose of handles) {
|
|
1211
|
+
try {
|
|
1212
|
+
dispose()
|
|
1213
|
+
} catch {
|
|
1214
|
+
/* ignore */
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
})
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
// ── Human commands ────────────────────────────────────────────────────────
|
|
1221
|
+
ctx.inject(['commands'], child => {
|
|
1222
|
+
const commands = child.commands
|
|
1223
|
+
if (commands?.register) {
|
|
1224
|
+
const disposers = []
|
|
1225
|
+
try {
|
|
1226
|
+
disposers.push(commands.register({
|
|
1227
|
+
name: 'roadmap',
|
|
1228
|
+
description: '查看当前会话的隐式计划(只读)',
|
|
1229
|
+
handler: invocation => {
|
|
1230
|
+
const view = publicView(invocation?.agent?.id ?? '')
|
|
1231
|
+
if (!view.plan) return { kind: 'success', text: '当前会话还没有计划。下一次回合结束后会自动生成。' }
|
|
1232
|
+
const lines = [`会话计划 v${view.version?.gen ?? 0}.${view.version?.rev ?? 0} · ${view.status ?? ''}`]
|
|
1233
|
+
if (view.intent) lines.push(`目标:${view.intent}`)
|
|
1234
|
+
for (const step of view.steps) {
|
|
1235
|
+
const mark = step.status === 'done' ? '✓' : step.status === 'doing' ? '●' : step.status === 'dropped' ? '×' : '○'
|
|
1236
|
+
lines.push(`${mark} ${step.title}`)
|
|
1237
|
+
}
|
|
1238
|
+
if (view.reason) lines.push(`更新原因:${view.reason}`)
|
|
1239
|
+
if (view.suggestion?.text) lines.push(`下一步建议:${view.suggestion.text}`)
|
|
1240
|
+
return { kind: 'success', text: lines.join('\n') }
|
|
1241
|
+
},
|
|
1242
|
+
}))
|
|
1243
|
+
disposers.push(commands.register({
|
|
1244
|
+
name: 'replan',
|
|
1245
|
+
description: '强制重写当前会话的隐式计划',
|
|
1246
|
+
input: { hint: '可选:一句话说明新的方向' },
|
|
1247
|
+
handler: invocation => {
|
|
1248
|
+
const sessionId = invocation?.agent?.id ?? ''
|
|
1249
|
+
const session = sessions.get(sessionId)
|
|
1250
|
+
if (!session) return { kind: 'error', text: '还没有观察到这个会话,请先在会话里发一条消息。' }
|
|
1251
|
+
const turn = Number(states.get(sessionId)?.plan?.watermark?.turn ?? 0)
|
|
1252
|
+
void runGeneration(
|
|
1253
|
+
session,
|
|
1254
|
+
{ seq: Number(debug.lastTurnEnd?.seq ?? 0), data: { turn } },
|
|
1255
|
+
{ force: true, instruction: String(invocation?.rawInput ?? '') },
|
|
1256
|
+
)
|
|
1257
|
+
return { kind: 'success', text: '计划重写已开始…' }
|
|
1258
|
+
},
|
|
1259
|
+
}))
|
|
1260
|
+
child.effect(() => () => {
|
|
1261
|
+
for (const dispose of disposers) {
|
|
1262
|
+
try {
|
|
1263
|
+
dispose()
|
|
1264
|
+
} catch {
|
|
1265
|
+
/* ignore */
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
})
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
debugLog({ phase: 'command-register-error', error: String(error) })
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
})
|
|
1274
|
+
|
|
1275
|
+
debugLog({
|
|
1276
|
+
phase: 'host-ready',
|
|
1277
|
+
settings,
|
|
1278
|
+
config: rawConfig,
|
|
1279
|
+
services: Object.fromEntries(Object.entries(services).map(([key, value]) => [key, Boolean(value)])),
|
|
1280
|
+
})
|
|
1281
|
+
}
|
|
1326
1282
|
|