compressed-context 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -51
- package/package.json +64 -64
- package/src/compression/active-summary-cache.ts +50 -50
- package/src/compression/adopt.ts +57 -57
- package/src/compression/auto.ts +386 -372
- package/src/compression/babel.ts +85 -85
- package/src/compression/block.ts +25 -25
- package/src/compression/cache-history.ts +75 -75
- package/src/compression/compaction.ts +189 -189
- package/src/compression/decompress-tool.ts +74 -74
- package/src/compression/decompress.ts +292 -292
- package/src/compression/deleted-sessions.ts +24 -24
- package/src/compression/distill.ts +220 -220
- package/src/compression/events.ts +275 -275
- package/src/compression/markdown.ts +53 -53
- package/src/compression/normalize.ts +114 -114
- package/src/compression/render.ts +70 -70
- package/src/compression/schema.ts +131 -84
- package/src/compression/select.ts +123 -123
- package/src/compression/session-message.ts +112 -112
- package/src/compression/settings-rpc.ts +123 -111
- package/src/compression/settings.ts +175 -175
- package/src/compression/shape.ts +75 -0
- package/src/compression/state.ts +248 -248
- package/src/compression/summarize.ts +534 -595
- package/src/compression/summary.ts +73 -73
- package/src/compression/tool.ts +184 -142
- package/src/compression/transcript-store.ts +258 -258
- package/src/compression/transcript.ts +63 -63
- package/src/compression/validate.ts +81 -81
- package/src/i18n/en.ts +93 -93
- package/src/i18n/index.ts +28 -28
- package/src/i18n/types.ts +89 -89
- package/src/i18n/zh.ts +93 -93
- package/src/index.ts +116 -108
- package/src/shared/blocks.ts +17 -17
- package/src/shared/cache.ts +199 -199
- package/src/shared/format.ts +23 -23
- package/src/shared/media.ts +184 -184
- package/src/shared/parse.ts +25 -25
- package/src/shared/parts.ts +72 -72
- package/src/shared/rpc.ts +145 -145
- package/src/shared/scan.ts +32 -32
- package/src/shared/settings.ts +466 -471
- package/src/shared/stats.ts +142 -142
- package/src/shared/tokens.ts +259 -259
- package/src/tui/chart/cache-chart.ts +237 -237
- package/src/tui/chart/color.ts +58 -58
- package/src/tui/index.tsx +109 -111
- package/src/tui/panel/cache.tsx +203 -203
- package/src/tui/panel/data.ts +105 -105
- package/src/tui/panel/index.tsx +176 -176
- package/src/tui/panel/summaries.tsx +203 -192
- package/src/tui/settings/dialogs.tsx +215 -215
- package/src/tui/settings/options.ts +238 -238
- package/src/tui/settings.tsx +418 -418
- package/src/tui/types.ts +4 -4
|
@@ -1,595 +1,534 @@
|
|
|
1
|
-
import { jsonrepair } from "jsonrepair"
|
|
2
|
-
|
|
3
|
-
import type { SummaryFormat, SummaryLanguage } from "../shared/settings.ts"
|
|
4
|
-
import { splitBabelCandidate, toBabelSummary } from "./babel.ts"
|
|
5
|
-
import { toSummary } from "./normalize.ts"
|
|
6
|
-
import type { Summary } from "./schema.ts"
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
readonly
|
|
17
|
-
readonly
|
|
18
|
-
readonly
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (language === "
|
|
25
|
-
return "Write
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
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
|
-
if (format === "
|
|
69
|
-
return "
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
readonly
|
|
76
|
-
readonly
|
|
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
|
-
options?.format
|
|
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
|
-
readonly
|
|
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
|
-
? "reply was not valid markdown with a valid candidate"
|
|
536
|
-
: "reply was not valid JSON with a valid candidate and summary"
|
|
537
|
-
prompt =
|
|
538
|
-
format === "babel"
|
|
539
|
-
? `${base}\n\nYour previous reply was not valid Babel. Return the corrected Babel only. First line must be \`c=N\` with N from 1 to ${candidates.length.toString()}; second line must be \`T=<title>|G=<goal>\`.`
|
|
540
|
-
: format === "markdown"
|
|
541
|
-
? `${base}\n\nYour previous reply was not valid markdown. Return the corrected markdown only. First line must be \`candidate: N\` with N from 1 to ${candidates.length.toString()}.`
|
|
542
|
-
: `${base}\n\nYour previous reply was not valid JSON with a valid candidate and summary. Return the corrected JSON only. The top-level "candidate" must be an integer from 1 to ${candidates.length.toString()}; "goal" must be non-empty.`
|
|
543
|
-
}
|
|
544
|
-
onFailure?.(reason)
|
|
545
|
-
throw new Error(reason)
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* 输入:整条 active 链、当前任务、合法候选、generate、模型。
|
|
550
|
-
* 输出:模型选择的候选 + 同一次回复里的摘要;失败返回 undefined。
|
|
551
|
-
* 预期:一次调用同时完成“选哪对”和“写合并摘要”,多一个 candidate 字段。
|
|
552
|
-
*/
|
|
553
|
-
export async function summarizeWithChoice(input: {
|
|
554
|
-
readonly context: string
|
|
555
|
-
readonly task: string
|
|
556
|
-
readonly candidates: readonly SummaryChoiceCandidate[]
|
|
557
|
-
readonly generate: GenerateLike
|
|
558
|
-
readonly model?: ModelRefLike
|
|
559
|
-
readonly maxChars?: number
|
|
560
|
-
readonly totalChars?: number
|
|
561
|
-
readonly language?: SummaryLanguage
|
|
562
|
-
readonly format?: SummaryFormat
|
|
563
|
-
readonly onFailure?: (reason: string) => void
|
|
564
|
-
}): Promise<{ readonly summary: Summary; readonly candidate: SummaryChoiceCandidate } | undefined> {
|
|
565
|
-
return runChoicePrompt(
|
|
566
|
-
buildChoicePrompt(input.context, input.task, input.candidates, input),
|
|
567
|
-
input.candidates,
|
|
568
|
-
input.generate,
|
|
569
|
-
input.model,
|
|
570
|
-
input.maxChars,
|
|
571
|
-
input.format ?? "json",
|
|
572
|
-
input.onFailure,
|
|
573
|
-
)
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/** 输入:已经拼好的合并指令 + 候选;输出:选择 + 摘要;失败返回 undefined。 */
|
|
577
|
-
export async function summarizeChoiceInstruction(input: {
|
|
578
|
-
readonly prompt: string
|
|
579
|
-
readonly candidates: readonly SummaryChoiceCandidate[]
|
|
580
|
-
readonly generate: GenerateLike
|
|
581
|
-
readonly model?: ModelRefLike
|
|
582
|
-
readonly maxChars?: number
|
|
583
|
-
readonly format?: SummaryFormat
|
|
584
|
-
readonly onFailure?: (reason: string) => void
|
|
585
|
-
}): Promise<{ readonly summary: Summary; readonly candidate: SummaryChoiceCandidate } | undefined> {
|
|
586
|
-
return runChoicePrompt(
|
|
587
|
-
input.prompt,
|
|
588
|
-
input.candidates,
|
|
589
|
-
input.generate,
|
|
590
|
-
input.model,
|
|
591
|
-
input.maxChars,
|
|
592
|
-
input.format ?? "json",
|
|
593
|
-
input.onFailure,
|
|
594
|
-
)
|
|
595
|
-
}
|
|
1
|
+
import { jsonrepair } from "jsonrepair"
|
|
2
|
+
|
|
3
|
+
import type { SummaryFormat, SummaryLanguage } from "../shared/settings.ts"
|
|
4
|
+
import { splitBabelCandidate, toBabelSummary } from "./babel.ts"
|
|
5
|
+
import { toSummary } from "./normalize.ts"
|
|
6
|
+
import type { Summary } from "./schema.ts"
|
|
7
|
+
import { shapeLines } from "./shape.ts"
|
|
8
|
+
import type { Tier } from "./state.ts"
|
|
9
|
+
import { MAX_SUMMARY_CHARS, MIN_SUMMARY_CHARS, minSummaryChars, validateSummary } from "./validate.ts"
|
|
10
|
+
|
|
11
|
+
/** 摘要/合并调用遇到坏格式时的尝试次数;真实模型偶尔会漏 candidate 或 goal。 */
|
|
12
|
+
const SUMMARY_ATTEMPTS = 3
|
|
13
|
+
|
|
14
|
+
/** 摘要提示词的可调部分;只改尾部指令,不改会话前缀。 */
|
|
15
|
+
export interface SummaryPromptOptions {
|
|
16
|
+
readonly maxChars?: number
|
|
17
|
+
readonly totalChars?: number
|
|
18
|
+
readonly language?: SummaryLanguage
|
|
19
|
+
readonly format?: SummaryFormat
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 输入:摘要语言;输出:只用于提示词的语言约束。 */
|
|
23
|
+
function languageLine(language: SummaryLanguage | undefined): string {
|
|
24
|
+
if (language === "zh") return "Write the summary in Chinese."
|
|
25
|
+
if (language === "en") return "Write the summary in English."
|
|
26
|
+
return "Write in the source conversation's primary language."
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 输入:选项;输出:长度、语言和格式规则提示行。 */
|
|
30
|
+
function promptRules(options: SummaryPromptOptions | undefined): string[] {
|
|
31
|
+
const maxChars = options?.maxChars ?? MAX_SUMMARY_CHARS
|
|
32
|
+
const lines =
|
|
33
|
+
options?.format === "babel"
|
|
34
|
+
? [
|
|
35
|
+
"- Receiver is another equally capable language model, not a human. Drop human readability and grammar; freely mix languages, emoji, math/logic symbols; replace connectives, modifiers, and long clauses with symbols.",
|
|
36
|
+
"- Only `T=<short title>|G=<one-line goal>` is fixed. Do not use a fixed key schema or long labels (`Meta:`, `Entity:`, `Decisions:`, `Progress:`). Invent the shortest one-character anchors or delimiters that fit this source and use them consistently.",
|
|
37
|
+
"- Body is a compact latent index, not prose: no headings, no paragraphs, no sentences, no JSON, no code fences.",
|
|
38
|
+
"- Preserve exactly in latent space: entity/relation graph; exact numbers, versions, config keys, thresholds, formulas; conditions, causality, exceptions; comparisons; original placeholders, paths, commands, signatures, error text. Never invent or round.",
|
|
39
|
+
"- Keep the latest objective, constraints, decisions with reasons, outcomes, open items, and work the user still expects. Drop narrative, process, logs, repeated reads, and duplicates.",
|
|
40
|
+
"- Target 400-800 tokens for T1; drop explanations before exact facts.",
|
|
41
|
+
`- Keep this summary under ${maxChars.toString()} characters.`,
|
|
42
|
+
]
|
|
43
|
+
: options?.format === "markdown"
|
|
44
|
+
? [
|
|
45
|
+
"- Return ONLY markdown, no JSON, no code fences.",
|
|
46
|
+
"- Start with a short `# Title` line.",
|
|
47
|
+
"- Only include sections with real content; never invent entries to fill a section.",
|
|
48
|
+
"- Use real file paths in `## Files`; put config keys and function names in `## Facts`.",
|
|
49
|
+
"- Put work in `### Completed` only when it was actually finished; plans go in `### Active` or `## Open Items`.",
|
|
50
|
+
`- Keep this summary under ${maxChars.toString()} characters.`,
|
|
51
|
+
]
|
|
52
|
+
: [
|
|
53
|
+
"- Only \"goal\" is required. Omit any optional field that has no real content; never invent entries to fill it.",
|
|
54
|
+
"- Use \"files\" only for real file paths; put config keys and function names in \"facts\".",
|
|
55
|
+
"- Put work in \"progress.completed\" only when the source shows it was finished; plans go in \"progress.active\" or \"open_items\".",
|
|
56
|
+
`- Keep this summary under ${maxChars.toString()} characters.`,
|
|
57
|
+
]
|
|
58
|
+
if (options?.totalChars !== undefined) {
|
|
59
|
+
lines.push(`- The whole active checkpoint chain must stay under ${options.totalChars.toString()} characters.`)
|
|
60
|
+
}
|
|
61
|
+
// Babel 的卖点就是跨语言/符号密度,不能再强制单一语言。
|
|
62
|
+
if (options?.format !== "babel") lines.push(`- ${languageLine(options?.language)}`)
|
|
63
|
+
return lines
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 输入:摘要格式;输出:提示词和错误里的格式名。 */
|
|
67
|
+
function formatName(format: SummaryFormat): string {
|
|
68
|
+
if (format === "babel") return "Babel"
|
|
69
|
+
if (format === "markdown") return "Markdown"
|
|
70
|
+
return "JSON"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 模型引用的最小结构。 */
|
|
74
|
+
export interface ModelRefLike {
|
|
75
|
+
readonly providerID: string
|
|
76
|
+
readonly id: string
|
|
77
|
+
readonly variant?: string | undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** ctx.generate 的最小结构,便于测试替身。 */
|
|
81
|
+
export interface GenerateLike {
|
|
82
|
+
text(input: { prompt: string; model?: ModelRefLike }): Promise<{ text: string }>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** ctx.session.generate 的最小结构:历史由 session 前缀提供,只追加 prompt。 */
|
|
86
|
+
export interface SessionGenerateLike {
|
|
87
|
+
generate(input: { sessionID: string; prompt: string }): Promise<{ text: string }>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 输入:session 生成入口 + sessionID;输出:只追加指令、复用会话前缀的 GenerateLike。 */
|
|
91
|
+
export function sessionGenerator(session: SessionGenerateLike, sessionID: string): GenerateLike {
|
|
92
|
+
return {
|
|
93
|
+
text: async ({ prompt }) => {
|
|
94
|
+
const response = await session.generate({ sessionID, prompt })
|
|
95
|
+
if (typeof response.text !== "string") {
|
|
96
|
+
throw new Error("session.generate returned no text")
|
|
97
|
+
}
|
|
98
|
+
return { text: response.text }
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const BABEL_T1_RULES = `Guidance:
|
|
104
|
+
- This checkpoint replaces the messages above; write a compact latent index, not a story.
|
|
105
|
+
- Only T/G is fixed. Dynamically invent the shortest anchors/delimiters for this source; do not reuse a fixed field schema or long labels.
|
|
106
|
+
- Keep only what future work needs: latest objective, hard constraints, decisions with one-line reasons, outcomes, open items, and exact technical facts.
|
|
107
|
+
- Preserve exact paths, line numbers, signatures, commands, error text, versions, config keys, thresholds, and measured values.
|
|
108
|
+
- Drop process, logs, repeated reads, full diffs, abandoned details, and explanation prose. If space is tight, keep exact facts before narrative.`
|
|
109
|
+
|
|
110
|
+
const TIER1_RULES = `Guidance:
|
|
111
|
+
- This checkpoint replaces the messages above; the next reader sees it instead of them, so it must stand on its own.
|
|
112
|
+
- Describe what already happened as history. Do not turn a recorded plan or request into an instruction to act now.
|
|
113
|
+
- Copy exact identifiers instead of describing them: full paths with line numbers, function and type signatures, the line of code that carries the logic, error text, versions, config keys, thresholds, and measured numbers.
|
|
114
|
+
- For each decision, say why it was made. Keep discovered constraints, the user's objective and any change of direction, the reason behind important actions, unanswered questions, and work the user still expects.
|
|
115
|
+
- Quote the user only when the exact wording matters; otherwise paraphrase and never invent a quote.
|
|
116
|
+
- Omit build/test logs, repeated reads, exploration that produced nothing, abandoned attempts (keep one line on why they failed), repeated status checks, and discussion that has already reached a conclusion.
|
|
117
|
+
- If space is tight, keep in this order: objective and constraints; decisions and reasons; exact technical facts; conclusions; lessons from failures.
|
|
118
|
+
- Prefer short factual entries over prose. Group related entries under the field that fits them.`
|
|
119
|
+
|
|
120
|
+
const TIER2_RULES = `Guidance for merging existing checkpoints:
|
|
121
|
+
- The sources are already compressed summaries, not raw conversation. Merge them into one checkpoint that covers all of them; do not re-tell the process.
|
|
122
|
+
- Keep only what future work needs: the latest objective and hard constraints; decisions with their reasons; final outcomes; open items and blocked work; the user's still-open expectations.
|
|
123
|
+
- Keep exact paths, signatures, errors, versions, config keys, and measured values only when they still matter. Drop line numbers, full diffs, code listings, logs, repeated reads, and intermediate debugging.
|
|
124
|
+
- Merge duplicate facts across sources and keep the latest status. Mark superseded or obsolete content with one short marker; do not keep its details.
|
|
125
|
+
- A failed attempt may stay as one line saying what failed and why, because that prevents repeating it.
|
|
126
|
+
- Target roughly 50-150 tokens per source block. Prefer dense factual entries over narrative.`
|
|
127
|
+
|
|
128
|
+
const TIER3_RULES = `Guidance for ultra-condensing existing checkpoints:
|
|
129
|
+
- Reduce the sources to a lookup index. Keep only shipped outcomes, open work, architectural decisions, and critical constraints.
|
|
130
|
+
- For each source block, keep one to three single-line facts: subject plus outcome.
|
|
131
|
+
- Drop explanations, rationale details, process, logs, and anything already marked obsolete or superseded.
|
|
132
|
+
- Open user-requested objectives survive until a later source marks them completed or superseded.
|
|
133
|
+
- Target roughly 30-60 tokens per source block.`
|
|
134
|
+
|
|
135
|
+
function rulesFor(tier: Tier, format?: SummaryFormat): string {
|
|
136
|
+
if (format === "babel") return tier === 1 ? BABEL_T1_RULES : tier === 2 ? TIER2_RULES : TIER3_RULES
|
|
137
|
+
if (tier === 2) return TIER2_RULES
|
|
138
|
+
if (tier === 3) return TIER3_RULES
|
|
139
|
+
return TIER1_RULES
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function openingFor(tier: Tier): string {
|
|
143
|
+
if (tier === 1) {
|
|
144
|
+
return "You are writing a checkpoint that will replace an older part of a conversation."
|
|
145
|
+
}
|
|
146
|
+
return "You are merging existing checkpoint blocks into one denser checkpoint."
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildPrompt(transcript: string, tier: Tier, options?: SummaryPromptOptions): string {
|
|
150
|
+
return [
|
|
151
|
+
openingFor(tier),
|
|
152
|
+
"The checkpoint and the messages after it are all the next reader will see; make it self-contained.",
|
|
153
|
+
"",
|
|
154
|
+
...shapeLines(options?.format, false),
|
|
155
|
+
"",
|
|
156
|
+
rulesFor(tier, options?.format),
|
|
157
|
+
...promptRules(options),
|
|
158
|
+
"",
|
|
159
|
+
"Sources:",
|
|
160
|
+
transcript,
|
|
161
|
+
].join("\n")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** T1 的会话内指令:历史已经在 session 前缀里,只追加这段。 */
|
|
165
|
+
export function buildT1Instruction(options?: SummaryPromptOptions): string {
|
|
166
|
+
return [
|
|
167
|
+
"You are writing a checkpoint for the older part of the conversation above.",
|
|
168
|
+
"Summarize only the messages before the final user message; ignore the final user message and anything after it.",
|
|
169
|
+
"The checkpoint replaces those older messages; make it self-contained.",
|
|
170
|
+
"",
|
|
171
|
+
...shapeLines(options?.format, false),
|
|
172
|
+
"",
|
|
173
|
+
rulesFor(1, options?.format),
|
|
174
|
+
...promptRules(options),
|
|
175
|
+
].join("\n")
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** T2/T3 的会话内指令:checkpoint 已经在 session 前缀里,只追加候选和规则。 */
|
|
179
|
+
export function buildMergeInstruction(
|
|
180
|
+
candidates: readonly SummaryChoiceCandidate[],
|
|
181
|
+
options?: SummaryPromptOptions,
|
|
182
|
+
): string {
|
|
183
|
+
const lines = candidates.map((candidate) => `${candidate.index.toString()}. ${candidate.description}`)
|
|
184
|
+
return [
|
|
185
|
+
"The checkpoint at the top of the conversation contains summary blocks.",
|
|
186
|
+
"Merge ONLY the chosen pair from the candidate list below; ignore every message after the checkpoint.",
|
|
187
|
+
"The messages after the checkpoint describe the current task; use them only to judge what is least needed, never include them in the merged summary.",
|
|
188
|
+
"",
|
|
189
|
+
...shapeLines(options?.format, true),
|
|
190
|
+
"",
|
|
191
|
+
options?.format === "babel"
|
|
192
|
+
? BABEL_CHOICE_RULES
|
|
193
|
+
: options?.format === "markdown"
|
|
194
|
+
? MARKDOWN_CHOICE_RULES
|
|
195
|
+
: CHOICE_RULES,
|
|
196
|
+
...promptRules(options),
|
|
197
|
+
"",
|
|
198
|
+
"Candidates (oldest first):",
|
|
199
|
+
...lines,
|
|
200
|
+
].join("\n")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 多候选合并时,模型要额外返回的候选信息。 */
|
|
204
|
+
export interface SummaryChoiceCandidate {
|
|
205
|
+
/** 1 开始的选择编号。 */
|
|
206
|
+
readonly index: number
|
|
207
|
+
/** 候选描述,例如 `b1 T2 "title" — goal + b2 ... → T3`。 */
|
|
208
|
+
readonly description: string
|
|
209
|
+
/** 这对块的完整 transcript 字符数,用于校验摘要确实更小。 */
|
|
210
|
+
readonly sourceChars: number
|
|
211
|
+
readonly tier: Tier
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const CHOICE_RULES = `Guidance for choosing and merging:
|
|
215
|
+
- Exactly one adjacent pair may be merged. Pick one candidate from the list below and merge ONLY that pair; every other block is context for the decision, not part of the merge.
|
|
216
|
+
- Add a top-level "candidate": N field to the JSON object, where N is the chosen candidate number. The rest of the object must follow the exact shape above.
|
|
217
|
+
- Keep the latest objective and hard constraints, decisions with their reasons, final outcomes, open items and blocked work, and exact facts that still matter.
|
|
218
|
+
- Merge duplicate facts across the chosen pair, keep the latest status, and drop process, logs, repeated reads, full diffs, and non-load-bearing line numbers.
|
|
219
|
+
- If the chosen candidate's output tier is 2, target roughly 50-150 tokens per source block. If it is tier 3, be ultra-condensed: one to three single-line facts per source block.
|
|
220
|
+
- Do not invent facts. Write in the source conversation's primary language.`
|
|
221
|
+
|
|
222
|
+
const MARKDOWN_CHOICE_RULES = `Guidance for choosing and merging:
|
|
223
|
+
- Exactly one adjacent pair may be merged. Pick one candidate from the list below and merge ONLY that pair; every other block is context for the decision, not part of the merge.
|
|
224
|
+
- First line must be \`candidate: N\`, where N is the chosen candidate number.
|
|
225
|
+
- After that line, write a self-contained markdown checkpoint starting with a short \`# Title\`.
|
|
226
|
+
- Keep the latest objective and hard constraints, decisions with their reasons, final outcomes, open items and blocked work, and exact facts that still matter.
|
|
227
|
+
- Merge duplicate facts across the chosen pair, keep the latest status, and drop process, logs, repeated reads, full diffs, and non-load-bearing line numbers.
|
|
228
|
+
- If the chosen candidate's output tier is 2, target roughly 50-150 tokens per source block. If it is tier 3, be ultra-condensed: one to three single-line facts per source block.
|
|
229
|
+
- Do not invent facts. Write in the source conversation's primary language.`
|
|
230
|
+
|
|
231
|
+
const BABEL_CHOICE_RULES = `Guidance for choosing and merging:
|
|
232
|
+
- Exactly one adjacent pair may be merged. Pick one candidate from the list below and merge ONLY that pair; every other block is context for the decision, not part of the merge.
|
|
233
|
+
- First line must be \`c=N\`, where N is the chosen candidate number.
|
|
234
|
+
- Second line must be \`T=<short title>|G=<one-line goal>\`. After that, write adaptive Babel: only T/G is fixed; dynamically invent the shortest anchors/delimiters and use them consistently. Do not use a fixed field schema or long labels.
|
|
235
|
+
- Mix languages, emoji, and logic symbols freely for density; no Markdown headings, no JSON, no prose/sentences, no code fences.
|
|
236
|
+
- Target 150-300 tokens for T2 and 60-120 tokens for T3; drop explanations before exact facts.
|
|
237
|
+
- Keep the latest objective and hard constraints, decisions with reasons, final outcomes, open items, blocked work, and exact facts that still matter.
|
|
238
|
+
- Merge duplicate facts across the chosen pair, keep the latest status, and drop process, logs, repeated reads, full diffs, and non-load-bearing line numbers.
|
|
239
|
+
- Copy identifiers, paths, commands, errors, versions, and numbers verbatim. Do not invent facts.`
|
|
240
|
+
|
|
241
|
+
function buildChoicePrompt(
|
|
242
|
+
context: string,
|
|
243
|
+
task: string,
|
|
244
|
+
candidates: readonly SummaryChoiceCandidate[],
|
|
245
|
+
options?: SummaryPromptOptions,
|
|
246
|
+
): string {
|
|
247
|
+
const lines = candidates.map((candidate) => `${candidate.index.toString()}. ${candidate.description}`)
|
|
248
|
+
return [
|
|
249
|
+
"You are merging existing checkpoint blocks into one denser checkpoint.",
|
|
250
|
+
"The checkpoint chain is over its token budget. The algorithm has listed every legal adjacent merge below.",
|
|
251
|
+
"",
|
|
252
|
+
...shapeLines(options?.format, true),
|
|
253
|
+
"",
|
|
254
|
+
options?.format === "babel"
|
|
255
|
+
? BABEL_CHOICE_RULES
|
|
256
|
+
: options?.format === "markdown"
|
|
257
|
+
? MARKDOWN_CHOICE_RULES
|
|
258
|
+
: CHOICE_RULES,
|
|
259
|
+
...promptRules(options),
|
|
260
|
+
"",
|
|
261
|
+
"Current task:",
|
|
262
|
+
task,
|
|
263
|
+
"",
|
|
264
|
+
"Candidates (oldest first):",
|
|
265
|
+
...lines,
|
|
266
|
+
"",
|
|
267
|
+
"Sources (the whole active chain; use it to judge the task, but merge only the chosen pair):",
|
|
268
|
+
context,
|
|
269
|
+
].join("\n")
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseJson(text: string): unknown {
|
|
273
|
+
const cleaned = text
|
|
274
|
+
.trim()
|
|
275
|
+
.replace(/^```(?:json)?\s*/i, "")
|
|
276
|
+
.replace(/\s*```$/, "")
|
|
277
|
+
// 真实模型常会在 JSON 前后加一句说明;只取第一个对象范围再修复。
|
|
278
|
+
const start = cleaned.indexOf("{")
|
|
279
|
+
const end = cleaned.lastIndexOf("}")
|
|
280
|
+
const candidate = start >= 0 && end > start ? cleaned.slice(start, end + 1) : cleaned
|
|
281
|
+
const parsed: unknown = JSON.parse(jsonrepair(candidate))
|
|
282
|
+
// 有些模型会把 JSON 再编码成字符串;解一层。
|
|
283
|
+
return typeof parsed === "string" ? JSON.parse(jsonrepair(parsed)) : parsed
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseJsonSafe(text: string): unknown {
|
|
287
|
+
try {
|
|
288
|
+
return parseJson(text)
|
|
289
|
+
} catch {
|
|
290
|
+
return undefined
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 输入:模型返回的 Markdown 文本。
|
|
296
|
+
* 输出:优先按 JSON 解析,失败再按 Markdown 原文解析。
|
|
297
|
+
* 预期:模型无视格式要求直接回 JSON 时,不把原始 JSON 当成摘要正文。
|
|
298
|
+
*/
|
|
299
|
+
function decodeMarkdownSummary(text: string): Summary | undefined {
|
|
300
|
+
const parsed = parseJsonSafe(text)
|
|
301
|
+
if (parsed !== undefined) {
|
|
302
|
+
const fromJson = toSummary(parsed, "markdown")
|
|
303
|
+
if (fromJson !== undefined) return fromJson
|
|
304
|
+
}
|
|
305
|
+
return toSummary(text, "markdown")
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function runSummaryPrompt(
|
|
309
|
+
base: string,
|
|
310
|
+
sourceChars: number,
|
|
311
|
+
generate: GenerateLike,
|
|
312
|
+
model: ModelRefLike | undefined,
|
|
313
|
+
maxChars: number | undefined,
|
|
314
|
+
format: SummaryFormat,
|
|
315
|
+
onFailure?: (reason: string) => void,
|
|
316
|
+
): Promise<Summary | undefined> {
|
|
317
|
+
let prompt = base
|
|
318
|
+
let reason = "summary rejected"
|
|
319
|
+
for (let attempt = 0; attempt < SUMMARY_ATTEMPTS; attempt += 1) {
|
|
320
|
+
let text: string
|
|
321
|
+
try {
|
|
322
|
+
const response = await generate.text(model ? { prompt, model } : { prompt })
|
|
323
|
+
text = response.text
|
|
324
|
+
} catch (error) {
|
|
325
|
+
onFailure?.(`generate failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
326
|
+
throw error
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const decoded =
|
|
330
|
+
format === "babel"
|
|
331
|
+
? toBabelSummary(text)
|
|
332
|
+
: format === "markdown"
|
|
333
|
+
? decodeMarkdownSummary(text)
|
|
334
|
+
: toSummary(parseJsonSafe(text), "json")
|
|
335
|
+
if (decoded) {
|
|
336
|
+
const result = validateSummary(decoded, sourceChars, MIN_SUMMARY_CHARS, maxChars)
|
|
337
|
+
if (result.ok) return decoded
|
|
338
|
+
reason = result.message
|
|
339
|
+
prompt = `${base}\n\nYour previous reply was rejected: ${result.message}. Return the corrected ${formatName(format)} only.`
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
reason =
|
|
343
|
+
format === "babel"
|
|
344
|
+
? "reply was not valid Babel with a T/G header"
|
|
345
|
+
: format === "markdown"
|
|
346
|
+
? "reply was not valid markdown"
|
|
347
|
+
: "reply was not valid JSON matching the shape"
|
|
348
|
+
prompt = `${base}\n\nYour previous reply was not valid ${formatName(format)}. Return the corrected ${formatName(format)} only.`
|
|
349
|
+
}
|
|
350
|
+
onFailure?.(reason)
|
|
351
|
+
throw new Error(reason)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 输入:transcript、原文长度、generate、模型、目标层级。
|
|
356
|
+
* 输出:校验通过的摘要;失败返回 undefined。
|
|
357
|
+
* 预期:JSON 修复 + schema 解码 + 语义校验;失败重试一次。
|
|
358
|
+
*/
|
|
359
|
+
export async function summarize(input: {
|
|
360
|
+
readonly transcript: string
|
|
361
|
+
readonly sourceChars: number
|
|
362
|
+
readonly generate: GenerateLike
|
|
363
|
+
readonly model?: ModelRefLike
|
|
364
|
+
readonly tier?: Tier
|
|
365
|
+
readonly maxChars?: number
|
|
366
|
+
readonly totalChars?: number
|
|
367
|
+
readonly language?: SummaryLanguage
|
|
368
|
+
readonly format?: SummaryFormat
|
|
369
|
+
}): Promise<Summary | undefined> {
|
|
370
|
+
const tier = input.tier ?? 1
|
|
371
|
+
const format = input.format ?? "json"
|
|
372
|
+
return runSummaryPrompt(
|
|
373
|
+
buildPrompt(input.transcript, tier, input),
|
|
374
|
+
input.sourceChars,
|
|
375
|
+
input.generate,
|
|
376
|
+
input.model,
|
|
377
|
+
input.maxChars,
|
|
378
|
+
format,
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** 输入:已经拼好的会话内指令;输出:摘要;失败返回 undefined。 */
|
|
383
|
+
export async function summarizeInstruction(input: {
|
|
384
|
+
readonly prompt: string
|
|
385
|
+
readonly sourceChars: number
|
|
386
|
+
readonly generate: GenerateLike
|
|
387
|
+
readonly model?: ModelRefLike
|
|
388
|
+
readonly maxChars?: number
|
|
389
|
+
readonly format?: SummaryFormat
|
|
390
|
+
readonly onFailure?: (reason: string) => void
|
|
391
|
+
}): Promise<Summary | undefined> {
|
|
392
|
+
return runSummaryPrompt(
|
|
393
|
+
input.prompt,
|
|
394
|
+
input.sourceChars,
|
|
395
|
+
input.generate,
|
|
396
|
+
input.model,
|
|
397
|
+
input.maxChars,
|
|
398
|
+
input.format ?? "json",
|
|
399
|
+
input.onFailure,
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** 输入:解析后的 JSON;输出:去掉 candidate 后的摘要值 + 原始 candidate。 */
|
|
404
|
+
function splitCandidate(value: unknown): { readonly candidate: unknown; readonly summary: unknown } | undefined {
|
|
405
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
|
|
406
|
+
const summary: Record<string, unknown> = { ...(value as Record<string, unknown>) }
|
|
407
|
+
const candidate = summary["candidate"]
|
|
408
|
+
delete summary["candidate"]
|
|
409
|
+
return { candidate, summary }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 输入:Markdown 回复;输出:candidate + 剩余正文。
|
|
414
|
+
* 预期:接受 `candidate: N`(可带标题/加粗/中文冒号),也接受模型直接返回 JSON;
|
|
415
|
+
* 两种形状都解析失败才返回 undefined。
|
|
416
|
+
*/
|
|
417
|
+
function splitMarkdownCandidate(text: string): { readonly candidate: unknown; readonly summary: unknown } | undefined {
|
|
418
|
+
const match = /^\s*(?:#{1,6}\s*)?(?:\*\*)?candidate(?:\*\*)?\s*[::]\s*(\d+)\s*$/im.exec(text)
|
|
419
|
+
if (match !== null) {
|
|
420
|
+
return { candidate: Number(match[1]), summary: text.replace(match[0], "").trim() }
|
|
421
|
+
}
|
|
422
|
+
return splitCandidate(parseJsonSafe(text))
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function runChoicePrompt(
|
|
426
|
+
base: string,
|
|
427
|
+
candidates: readonly SummaryChoiceCandidate[],
|
|
428
|
+
generate: GenerateLike,
|
|
429
|
+
model: ModelRefLike | undefined,
|
|
430
|
+
maxChars: number | undefined,
|
|
431
|
+
format: SummaryFormat,
|
|
432
|
+
onFailure?: (reason: string) => void,
|
|
433
|
+
): Promise<{ readonly summary: Summary; readonly candidate: SummaryChoiceCandidate } | undefined> {
|
|
434
|
+
let prompt = base
|
|
435
|
+
let reason = "choice rejected"
|
|
436
|
+
for (let attempt = 0; attempt < SUMMARY_ATTEMPTS; attempt += 1) {
|
|
437
|
+
let text: string
|
|
438
|
+
try {
|
|
439
|
+
const response = await generate.text(model ? { prompt, model } : { prompt })
|
|
440
|
+
text = response.text
|
|
441
|
+
} catch (error) {
|
|
442
|
+
onFailure?.(`generate failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
443
|
+
throw error
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const split =
|
|
447
|
+
format === "babel"
|
|
448
|
+
? splitBabelCandidate(text)
|
|
449
|
+
: format === "markdown"
|
|
450
|
+
? splitMarkdownCandidate(text)
|
|
451
|
+
: splitCandidate(parseJsonSafe(text))
|
|
452
|
+
if (split !== undefined && typeof split.candidate === "number" && Number.isInteger(split.candidate)) {
|
|
453
|
+
const candidate = candidates[split.candidate - 1]
|
|
454
|
+
if (candidate !== undefined) {
|
|
455
|
+
const decoded = toSummary(split.summary, format)
|
|
456
|
+
if (decoded) {
|
|
457
|
+
const result = validateSummary(
|
|
458
|
+
decoded,
|
|
459
|
+
candidate.sourceChars,
|
|
460
|
+
minSummaryChars(candidate.tier),
|
|
461
|
+
maxChars,
|
|
462
|
+
)
|
|
463
|
+
if (result.ok) return { summary: decoded, candidate }
|
|
464
|
+
reason = result.message
|
|
465
|
+
prompt = `${base}\n\nYour previous reply was rejected: ${result.message}. Return the corrected ${formatName(format)} only.`
|
|
466
|
+
continue
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
reason =
|
|
471
|
+
format === "babel"
|
|
472
|
+
? "reply was not valid Babel with a valid candidate"
|
|
473
|
+
: format === "markdown"
|
|
474
|
+
? "reply was not valid markdown with a valid candidate"
|
|
475
|
+
: "reply was not valid JSON with a valid candidate and summary"
|
|
476
|
+
prompt =
|
|
477
|
+
format === "babel"
|
|
478
|
+
? `${base}\n\nYour previous reply was not valid Babel. Return the corrected Babel only. First line must be \`c=N\` with N from 1 to ${candidates.length.toString()}; second line must be \`T=<title>|G=<goal>\`.`
|
|
479
|
+
: format === "markdown"
|
|
480
|
+
? `${base}\n\nYour previous reply was not valid markdown. Return the corrected markdown only. First line must be \`candidate: N\` with N from 1 to ${candidates.length.toString()}.`
|
|
481
|
+
: `${base}\n\nYour previous reply was not valid JSON with a valid candidate and summary. Return the corrected JSON only. The top-level "candidate" must be an integer from 1 to ${candidates.length.toString()}; "goal" must be non-empty.`
|
|
482
|
+
}
|
|
483
|
+
onFailure?.(reason)
|
|
484
|
+
throw new Error(reason)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 输入:整条 active 链、当前任务、合法候选、generate、模型。
|
|
489
|
+
* 输出:模型选择的候选 + 同一次回复里的摘要;失败返回 undefined。
|
|
490
|
+
* 预期:一次调用同时完成“选哪对”和“写合并摘要”,多一个 candidate 字段。
|
|
491
|
+
*/
|
|
492
|
+
export async function summarizeWithChoice(input: {
|
|
493
|
+
readonly context: string
|
|
494
|
+
readonly task: string
|
|
495
|
+
readonly candidates: readonly SummaryChoiceCandidate[]
|
|
496
|
+
readonly generate: GenerateLike
|
|
497
|
+
readonly model?: ModelRefLike
|
|
498
|
+
readonly maxChars?: number
|
|
499
|
+
readonly totalChars?: number
|
|
500
|
+
readonly language?: SummaryLanguage
|
|
501
|
+
readonly format?: SummaryFormat
|
|
502
|
+
readonly onFailure?: (reason: string) => void
|
|
503
|
+
}): Promise<{ readonly summary: Summary; readonly candidate: SummaryChoiceCandidate } | undefined> {
|
|
504
|
+
return runChoicePrompt(
|
|
505
|
+
buildChoicePrompt(input.context, input.task, input.candidates, input),
|
|
506
|
+
input.candidates,
|
|
507
|
+
input.generate,
|
|
508
|
+
input.model,
|
|
509
|
+
input.maxChars,
|
|
510
|
+
input.format ?? "json",
|
|
511
|
+
input.onFailure,
|
|
512
|
+
)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** 输入:已经拼好的合并指令 + 候选;输出:选择 + 摘要;失败返回 undefined。 */
|
|
516
|
+
export async function summarizeChoiceInstruction(input: {
|
|
517
|
+
readonly prompt: string
|
|
518
|
+
readonly candidates: readonly SummaryChoiceCandidate[]
|
|
519
|
+
readonly generate: GenerateLike
|
|
520
|
+
readonly model?: ModelRefLike
|
|
521
|
+
readonly maxChars?: number
|
|
522
|
+
readonly format?: SummaryFormat
|
|
523
|
+
readonly onFailure?: (reason: string) => void
|
|
524
|
+
}): Promise<{ readonly summary: Summary; readonly candidate: SummaryChoiceCandidate } | undefined> {
|
|
525
|
+
return runChoicePrompt(
|
|
526
|
+
input.prompt,
|
|
527
|
+
input.candidates,
|
|
528
|
+
input.generate,
|
|
529
|
+
input.model,
|
|
530
|
+
input.maxChars,
|
|
531
|
+
input.format ?? "json",
|
|
532
|
+
input.onFailure,
|
|
533
|
+
)
|
|
534
|
+
}
|