compressed-context 0.0.0-stage → 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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -2
  3. package/package.json +62 -4
  4. package/src/compression/active-summary-cache.ts +50 -0
  5. package/src/compression/adopt.ts +57 -0
  6. package/src/compression/auto.ts +372 -0
  7. package/src/compression/babel.ts +85 -0
  8. package/src/compression/block.ts +25 -0
  9. package/src/compression/cache-history.ts +75 -0
  10. package/src/compression/compaction.ts +189 -0
  11. package/src/compression/decompress-tool.ts +74 -0
  12. package/src/compression/decompress.ts +292 -0
  13. package/src/compression/deleted-sessions.ts +24 -0
  14. package/src/compression/distill.ts +220 -0
  15. package/src/compression/events.ts +275 -0
  16. package/src/compression/markdown.ts +53 -0
  17. package/src/compression/normalize.ts +114 -0
  18. package/src/compression/render.ts +70 -0
  19. package/src/compression/schema.ts +84 -0
  20. package/src/compression/select.ts +123 -0
  21. package/src/compression/session-message.ts +112 -0
  22. package/src/compression/settings-rpc.ts +111 -0
  23. package/src/compression/settings.ts +175 -0
  24. package/src/compression/state.ts +248 -0
  25. package/src/compression/summarize.ts +595 -0
  26. package/src/compression/summary.ts +73 -0
  27. package/src/compression/tool.ts +142 -0
  28. package/src/compression/transcript-store.ts +258 -0
  29. package/src/compression/transcript.ts +63 -0
  30. package/src/compression/validate.ts +81 -0
  31. package/src/i18n/en.ts +93 -0
  32. package/src/i18n/index.ts +28 -0
  33. package/src/i18n/types.ts +89 -0
  34. package/src/i18n/zh.ts +93 -0
  35. package/src/index.ts +108 -0
  36. package/src/shared/blocks.ts +17 -0
  37. package/src/shared/cache.ts +199 -0
  38. package/src/shared/format.ts +23 -0
  39. package/src/shared/media.ts +184 -0
  40. package/src/shared/parse.ts +25 -0
  41. package/src/shared/parts.ts +72 -0
  42. package/src/shared/rpc.ts +145 -0
  43. package/src/shared/scan.ts +32 -0
  44. package/src/shared/settings.ts +471 -0
  45. package/src/shared/stats.ts +142 -0
  46. package/src/shared/tokens.ts +259 -0
  47. package/src/tui/chart/cache-chart.ts +237 -0
  48. package/src/tui/chart/color.ts +58 -0
  49. package/src/tui/index.tsx +111 -0
  50. package/src/tui/panel/cache.tsx +203 -0
  51. package/src/tui/panel/data.ts +105 -0
  52. package/src/tui/panel/index.tsx +176 -0
  53. package/src/tui/panel/summaries.tsx +192 -0
  54. package/src/tui/settings/dialogs.tsx +215 -0
  55. package/src/tui/settings/options.ts +238 -0
  56. package/src/tui/settings.tsx +418 -0
  57. package/src/tui/types.ts +4 -0
@@ -0,0 +1,292 @@
1
+ /**
2
+ * 解压:把已压缩块的内容还给模型。
3
+ *
4
+ * - `full:false`(默认)展开一层:合并块返回直接子块的摘要;T1 块返回磁盘原文。
5
+ * - `full:true` 递归到 T1,原文从插件自己的 transcript storage 读取。
6
+ * - 媒体 bytes/base64 写临时文件;url/ref/缺失内容输出占位符。
7
+ * - 返回分级:≤ INLINE_MAX 字符内联进 tool result;超出边读边写 `os.tmpdir()` 文件并回路径,
8
+ * 模型用 read 工具打开。临时文件有 reaper(上限 TEMP_CAP,删最老),防无限堆积。
9
+ * - copy-paste 语义:不修改任何块状态;块保持折叠,内容只是 tool result 文本。
10
+ */
11
+
12
+ import { appendFileSync, unlinkSync, writeFileSync } from "node:fs"
13
+ import { tmpdir } from "node:os"
14
+ import { join } from "node:path"
15
+
16
+ import { countText } from "../shared/tokens.ts"
17
+ import type { Block, StorageLike } from "./state.ts"
18
+ import { loadState } from "./state.ts"
19
+ import { renderBlock } from "./render.ts"
20
+ import {
21
+ loadTranscriptManifest,
22
+ loadTranscriptMessage,
23
+ type StoredMessage,
24
+ type StoredPart,
25
+ } from "./transcript-store.ts"
26
+
27
+ /** 内联返回的默认字符上限;运行时由设置覆盖。 */
28
+ export const INLINE_MAX = 10_000
29
+ /** 临时文件数量默认上限;运行时由设置覆盖。 */
30
+ export const TEMP_CAP = 50
31
+
32
+ /** decompress 的运行时限制。 */
33
+ export interface DecompressLimits {
34
+ readonly inlineMax: number
35
+ readonly tempCap: number
36
+ readonly showTokens: boolean
37
+ }
38
+
39
+ const DEFAULT_LIMITS: DecompressLimits = {
40
+ inlineMax: INLINE_MAX,
41
+ tempCap: TEMP_CAP,
42
+ showTokens: true,
43
+ }
44
+
45
+ /** 解压工具结果;ok 时 inline 或 path 至少一项存在。 */
46
+ export type DecompressResult =
47
+ | { readonly status: "ok"; readonly inline: string; readonly path?: string; readonly tokens?: number }
48
+ | { readonly status: "error"; readonly message: string }
49
+
50
+ const tempFiles: { path: string; mtimeMs: number; sessionID: string }[] = []
51
+
52
+ /** 输入:上限;输出:清理过期临时文件。预期:超上限删最老,绝不抛错。 */
53
+ function reapTempFiles(cap: number): void {
54
+ while (tempFiles.length > cap) {
55
+ tempFiles.sort((a, b) => a.mtimeMs - b.mtimeMs)
56
+ const oldest = tempFiles.shift()
57
+ if (oldest === undefined) break
58
+ try {
59
+ unlinkSync(oldest.path)
60
+ } catch {
61
+ // 已被外部清理或权限问题:跳过,不阻塞解压
62
+ }
63
+ }
64
+ }
65
+
66
+ /** 输入:会话 + 块 id + 正文 + 上限;输出:临时文件路径。 */
67
+ function writeTempFile(sessionID: string, blockId: string, body: string, cap: number): string {
68
+ const safeId = blockId.replace(/[^a-zA-Z0-9_-]/g, "-")
69
+ const path = join(tmpdir(), `compressed-context-decompress-${safeId}-${String(Date.now())}.txt`)
70
+ writeFileSync(path, body, { encoding: "utf8", mode: 0o600 })
71
+ tempFiles.push({ path, mtimeMs: Date.now(), sessionID })
72
+ reapTempFiles(cap)
73
+ return path
74
+ }
75
+
76
+ /** 输入:会话;输出:无;预期:删除该会话全部解压临时文件。 */
77
+ export function deleteSessionTempFiles(sessionID: string): void {
78
+ for (let index = tempFiles.length - 1; index >= 0; index -= 1) {
79
+ const entry = tempFiles[index]
80
+ if (entry?.sessionID !== sessionID) continue
81
+ try {
82
+ unlinkSync(entry.path)
83
+ } catch {
84
+ // best effort
85
+ }
86
+ tempFiles.splice(index, 1)
87
+ }
88
+ }
89
+
90
+ /** 输入:会话 + 块 + 序号 + base64 + mime + 文件名;输出:媒体临时文件路径。 */
91
+ function writeMediaTempFile(
92
+ sessionID: string,
93
+ blockID: string,
94
+ index: number,
95
+ data: string,
96
+ filename: string | undefined,
97
+ cap: number,
98
+ ): string {
99
+ const safeId = `${blockID}-media-${index.toString()}`.replace(/[^a-zA-Z0-9_-]/g, "-")
100
+ const safeName = (filename ?? "").replace(/[^a-zA-Z0-9._-]/g, "-")
101
+ const suffix = safeName.length > 0 ? `-${safeName}` : ""
102
+ const path = join(tmpdir(), `compressed-context-${safeId}${suffix}-${String(Date.now())}.bin`)
103
+ writeFileSync(path, Buffer.from(data, "base64"), { mode: 0o600 })
104
+ tempFiles.push({ path, mtimeMs: Date.now(), sessionID })
105
+ reapTempFiles(cap)
106
+ return path
107
+ }
108
+
109
+ /** 输入:任意值;输出:可读 JSON 文本;不可序列化时返回空串。 */
110
+ function stringifyStored(value: unknown): string {
111
+ if (typeof value === "string") return value
112
+ if (value === undefined) return ""
113
+ try {
114
+ const encoded: unknown = JSON.stringify(value)
115
+ return typeof encoded === "string" ? encoded : ""
116
+ } catch {
117
+ return ""
118
+ }
119
+ }
120
+
121
+ /** 输入:落盘 part + 上下文;输出:可读文本;不可还原的媒体输出占位符。 */
122
+ function renderStoredPart(
123
+ part: StoredPart,
124
+ sessionID: string,
125
+ blockID: string,
126
+ index: number,
127
+ cap: number,
128
+ ): string {
129
+ if (part.type === "text") return part.text ?? ""
130
+ if (part.type === "tool-call") {
131
+ return `[Tool call ${part.name ?? "?"}] ${stringifyStored(part.input)}`
132
+ }
133
+ if (part.type === "tool-result") {
134
+ return `[Tool result ${part.name ?? "?"}] ${stringifyStored(part.result)}`
135
+ }
136
+ const source = part.media?.source
137
+ const filename = part.filename === undefined ? "" : ` "${part.filename}"`
138
+ const mime = source?.mediaType ?? "unknown"
139
+ if (source?.type === "base64" && source.data !== undefined && source.data.length > 0) {
140
+ const path = writeMediaTempFile(sessionID, blockID, index, source.data, part.filename, cap)
141
+ return `[media ${mime}${filename} saved to: ${path}]`
142
+ }
143
+ const kind = source?.type ?? "unknown"
144
+ return `[unavailable media: ${mime}${filename} (${kind} not stored)]`
145
+ }
146
+
147
+ /** 输入:落盘消息 + 上下文;输出:带角色前缀的文本。 */
148
+ function renderStoredMessage(
149
+ message: StoredMessage,
150
+ sessionID: string,
151
+ blockID: string,
152
+ messageIndex: number,
153
+ cap: number,
154
+ ): string {
155
+ const lines: string[] = []
156
+ for (const [partIndex, part] of message.content.entries()) {
157
+ const text = renderStoredPart(
158
+ part,
159
+ sessionID,
160
+ blockID,
161
+ messageIndex * 1000 + partIndex,
162
+ cap,
163
+ )
164
+ if (text.length === 0) continue
165
+ lines.push(message.role === "user" ? `[User]\n${text}` : text)
166
+ }
167
+ return lines.join("\n")
168
+ }
169
+
170
+ /**
171
+ * 输入:storage + 会话 + 块 + 写入口。
172
+ * 输出:无。
173
+ * 预期:逐条读取并立即追加,缺 manifest/消息时写占位符,不把整段原文拼成字符串。
174
+ */
175
+ async function appendStoredTranscript(
176
+ storage: StorageLike,
177
+ sessionID: string,
178
+ block: Block,
179
+ cap: number,
180
+ append: (text: string) => void,
181
+ ): Promise<void> {
182
+ const manifest = await loadTranscriptManifest(storage, sessionID, block.id)
183
+ if (manifest === undefined) {
184
+ append(`[original transcript for ${block.id} is unavailable]`)
185
+ return
186
+ }
187
+ for (let index = 0; index < manifest.count; index += 1) {
188
+ const message = await loadTranscriptMessage(storage, sessionID, block.id, index)
189
+ if (message === undefined) {
190
+ append(`[missing transcript message ${index.toString()} for ${block.id}]`)
191
+ continue
192
+ }
193
+ const text = renderStoredMessage(message, sessionID, block.id, index, cap)
194
+ if (text.length > 0) append(text)
195
+ }
196
+ }
197
+
198
+ /**
199
+ * 输入:工具参数(block 必填,full 可选)+ 会话上下文。
200
+ * 输出:分级结果(内联文本或临时文件路径)。
201
+ * 预期:从磁盘 transcript 按需读取;缺原文/媒体时输出占位符,不报错。
202
+ * 超过内联上限时边读边写临时文件,避免把全文常驻内存。
203
+ */
204
+ export async function resolveDecompress(
205
+ args: Record<string, unknown>,
206
+ ctx: { readonly storage: StorageLike },
207
+ sessionID: string,
208
+ limits: DecompressLimits = DEFAULT_LIMITS,
209
+ ): Promise<DecompressResult> {
210
+ const raw = args["block"]
211
+ if (typeof raw !== "string" || raw.trim().length === 0) {
212
+ return { status: "error", message: "block is required (e.g. b3)" }
213
+ }
214
+ const blockId = raw.trim()
215
+ const full = args["full"] === true
216
+
217
+ const state = await loadState(ctx.storage, sessionID)
218
+ if (state.blocks.length === 0) {
219
+ return { status: "error", message: `block ${blockId} not found (session has no compressed blocks)` }
220
+ }
221
+
222
+ const byId = new Map(state.blocks.map((block) => [block.id, block]))
223
+ const target = byId.get(blockId)
224
+ if (target === undefined) {
225
+ return { status: "error", message: `block ${blockId} not found` }
226
+ }
227
+
228
+ const chunks: string[] = []
229
+ let filePath: string | undefined
230
+ let chars = 0
231
+ let tokens = 0
232
+ let first = true
233
+
234
+ /** 追加一段文本;超过内联上限后转为临时文件,避免全文常驻内存。 */
235
+ const append = (text: string): void => {
236
+ if (text.length === 0) return
237
+ const separator = first ? "" : "\n\n"
238
+ first = false
239
+ const payload = separator + text
240
+ chars += payload.length
241
+ tokens += countText(payload)
242
+ if (filePath !== undefined) {
243
+ appendFileSync(filePath, payload)
244
+ return
245
+ }
246
+ chunks.push(payload)
247
+ if (chars > limits.inlineMax) {
248
+ filePath = writeTempFile(sessionID, blockId, chunks.join(""), limits.tempCap)
249
+ chunks.length = 0
250
+ }
251
+ }
252
+
253
+ const visited = new Set<string>()
254
+ const walk = async (block: Block, depth: number): Promise<void> => {
255
+ if (visited.has(block.id)) return
256
+ visited.add(block.id)
257
+ const sources = block.sourceBlockIds ?? []
258
+ if (sources.length === 0) {
259
+ await appendStoredTranscript(ctx.storage, sessionID, block, limits.tempCap, append)
260
+ return
261
+ }
262
+ if (!full && depth > 0) {
263
+ append(renderBlock(block))
264
+ return
265
+ }
266
+ for (const sourceId of sources) {
267
+ const source = byId.get(sourceId)
268
+ if (source === undefined) continue
269
+ if (!full) {
270
+ append(renderBlock(source))
271
+ continue
272
+ }
273
+ await walk(source, depth + 1)
274
+ }
275
+ }
276
+
277
+ await walk(target, 0)
278
+ if (chars === 0) append("(no restorable content for this block)")
279
+
280
+ const tokenLabel = limits.showTokens ? ` · ≈${tokens.toString()} tok` : ""
281
+ const header = `[Block ${blockId} content${full ? ", full" : ""}${tokenLabel}]`
282
+
283
+ if (filePath !== undefined) {
284
+ return {
285
+ status: "ok",
286
+ inline: `${header}\nContent (${chars.toString()} chars) written to: ${filePath}\nUse the read tool to access it.`,
287
+ path: filePath,
288
+ tokens,
289
+ }
290
+ }
291
+ return { status: "ok", inline: `${header}\n${chunks.join("")}`, tokens }
292
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 已删除会话的内存墓碑。
3
+ *
4
+ * 输入:会话删除事件。
5
+ * 输出:无。
6
+ * 预期:删除后到达的 saveState / saveTranscript 直接跳过,避免把状态复活;
7
+ * 进程重启后墓碑消失,由启动 GC 清理孤儿数据。
8
+ */
9
+ const deleted = new Set<string>()
10
+
11
+ /** 输入:sessionID;输出:无;预期:标记会话已删除。 */
12
+ export function markSessionDeleted(sessionID: string): void {
13
+ deleted.add(sessionID)
14
+ }
15
+
16
+ /** 输入:sessionID;输出:是否已删除。 */
17
+ export function isSessionDeleted(sessionID: string): boolean {
18
+ return deleted.has(sessionID)
19
+ }
20
+
21
+ /** 输入:无;输出:无;预期:测试或多租户重置时清空墓碑。 */
22
+ export function clearDeletedSessions(): void {
23
+ deleted.clear()
24
+ }
@@ -0,0 +1,220 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+
3
+ import type { SummaryFormat, SummaryLanguage } from "../shared/settings.ts"
4
+ import { countText } from "../shared/tokens.ts"
5
+ import { activeBlocks, renderAll, renderBlock } from "./render.ts"
6
+ import { nextBlockId, type Block, type CompactionPhase, type State, type Tier } from "./state.ts"
7
+ import { summaryChars, summaryGoal, summaryTitle } from "./summary.ts"
8
+ import {
9
+ buildMergeInstruction,
10
+ sessionGenerator,
11
+ summarizeChoiceInstruction,
12
+ type SummaryChoiceCandidate,
13
+ } from "./summarize.ts"
14
+ import { minSummaryChars, validateSummary } from "./validate.ts"
15
+
16
+ /** 一个合法的相邻合并候选。 */
17
+ export interface MergeCandidate {
18
+ readonly left: Block
19
+ readonly right: Block
20
+ readonly tier: Tier
21
+ }
22
+
23
+ export interface DistillOnceInput {
24
+ readonly ctx: Plugin.Context
25
+ readonly sessionID: string
26
+ readonly state: State
27
+ /** 上一层还没提交的块;T2/T3 钩子里可能来自 pending。 */
28
+ readonly pending: Block | undefined
29
+ readonly budgetTokens: number
30
+ readonly summaryBlockChars?: number
31
+ readonly summaryTotalChars?: number
32
+ readonly summaryLanguage?: SummaryLanguage
33
+ readonly summaryFormat?: SummaryFormat
34
+ readonly phase: Exclude<CompactionPhase, undefined>
35
+ readonly showTimeline?: boolean
36
+ readonly showBlockTokens?: boolean
37
+ }
38
+
39
+ export interface DistillOnceResult {
40
+ readonly blocks: readonly Block[]
41
+ readonly pending: Block | undefined
42
+ readonly summary: string
43
+ readonly merged: boolean
44
+ readonly nextPhase?: CompactionPhase
45
+ readonly error?: string
46
+ }
47
+
48
+ /** 输入:active 链;输出:所有保持 tier 单调不增的相邻合并候选。 */
49
+ export function mergeCandidates(blocks: readonly Block[]): readonly MergeCandidate[] {
50
+ const candidates: MergeCandidate[] = []
51
+ for (let index = 0; index < blocks.length - 1; index += 1) {
52
+ const left = blocks[index]
53
+ const right = blocks[index + 1]
54
+ if (left === undefined || right === undefined) continue
55
+ const tier = Math.min(3, Math.max(left.tier, right.tier) + 1) as Tier
56
+ const previous = blocks[index - 1]
57
+ const next = blocks[index + 2]
58
+ if (previous !== undefined && previous.tier < tier) continue
59
+ if (next !== undefined && tier < next.tier) continue
60
+ candidates.push({ left, right, tier })
61
+ }
62
+ return candidates
63
+ }
64
+
65
+ /** 输入:T1 后的摘要链;输出:是否要自动排 T2。 */
66
+ export function planT1Phase(
67
+ summary: string,
68
+ active: readonly Block[],
69
+ budgetTokens: number,
70
+ totalChars: number = Number.POSITIVE_INFINITY,
71
+ ): CompactionPhase | undefined {
72
+ return (countText(summary) > budgetTokens || summary.length > totalChars) &&
73
+ mergeCandidates(active).length > 0
74
+ ? "t2"
75
+ : undefined
76
+ }
77
+
78
+ /**
79
+ * 输入:一次蒸馏结果 + active 链 + 预算。
80
+ * 输出:是否已经无法继续压缩(仍超预算,且没有任何合法合并候选)。
81
+ * 预期:与层级无关——全 T3 只要还有两块可合并,或链本身没超预算,都不算终止。
82
+ */
83
+ export function isTerminal(
84
+ result: DistillOnceResult,
85
+ active: readonly Block[],
86
+ budgetTokens: number,
87
+ totalChars: number,
88
+ ): boolean {
89
+ if (result.merged || result.error !== undefined) return false
90
+ if (countText(result.summary) <= budgetTokens && result.summary.length <= totalChars) return false
91
+ return mergeCandidates(active).length === 0
92
+ }
93
+
94
+ /** 输入:块;输出:候选列表里的简短描述。 */
95
+ function describe(block: Block): string {
96
+ const goal = summaryGoal(block.summary).replace(/\s+/g, " ").slice(0, 160)
97
+ return `${block.id} T${block.tier.toString()} "${summaryTitle(block.summary)}" — ${goal}`
98
+ }
99
+
100
+ /** 输入:候选;输出:这两块的完整 transcript(只用于本地校验,不发给模型)。 */
101
+ function pairTranscript(candidate: MergeCandidate): string {
102
+ return `${renderBlock(candidate.left)}\n\n${renderBlock(candidate.right)}`
103
+ }
104
+
105
+ /**
106
+ * 输入:当前状态 + 上一层 pending + 预算 + 本次是 T2 还是 T3。
107
+ * 输出:只合并一对后的 blocks / pending / summary。
108
+ * 预期:每层只合一次;模型在会话前缀后只看到追加的候选指令。
109
+ */
110
+ export async function distillOnce(input: DistillOnceInput): Promise<DistillOnceResult> {
111
+ const renderOptions = {
112
+ showTimeline: input.showTimeline === true,
113
+ showBlockTokens: input.showBlockTokens === true,
114
+ }
115
+ const active = activeBlocks(input.state.blocks, input.pending)
116
+ const currentSummary = renderAll(input.state.blocks, input.pending, renderOptions)
117
+ const totalChars = input.summaryTotalChars ?? Number.POSITIVE_INFINITY
118
+ if (
119
+ active.length < 2 ||
120
+ (countText(currentSummary) <= input.budgetTokens && currentSummary.length <= totalChars)
121
+ ) {
122
+ return { blocks: input.state.blocks, pending: input.pending, summary: currentSummary, merged: false }
123
+ }
124
+
125
+ const candidates = mergeCandidates(active)
126
+ if (candidates.length === 0) {
127
+ return { blocks: input.state.blocks, pending: input.pending, summary: currentSummary, merged: false }
128
+ }
129
+
130
+ const choices: SummaryChoiceCandidate[] = candidates.map((candidate, index) => ({
131
+ index: index + 1,
132
+ description: `${describe(candidate.left)} + ${describe(candidate.right)} → T${candidate.tier.toString()}`,
133
+ sourceChars: pairTranscript(candidate).length,
134
+ tier: candidate.tier,
135
+ }))
136
+
137
+ const generator = sessionGenerator(input.ctx.session, input.sessionID)
138
+ let failure = "distill summary failed"
139
+ const promptOptions = {
140
+ ...(input.summaryBlockChars === undefined ? {} : { maxChars: input.summaryBlockChars }),
141
+ ...(input.summaryTotalChars === undefined ? {} : { totalChars: input.summaryTotalChars }),
142
+ ...(input.summaryLanguage === undefined ? {} : { language: input.summaryLanguage }),
143
+ format: input.summaryFormat ?? "json",
144
+ }
145
+ const picked = await summarizeChoiceInstruction({
146
+ prompt: buildMergeInstruction(choices, promptOptions),
147
+ candidates: choices,
148
+ generate: generator,
149
+ ...(input.summaryBlockChars === undefined ? {} : { maxChars: input.summaryBlockChars }),
150
+ format: input.summaryFormat ?? "json",
151
+ onFailure: (reason) => {
152
+ failure = `distill summary failed: ${reason}`
153
+ },
154
+ })
155
+ if (picked === undefined) {
156
+ return {
157
+ blocks: input.state.blocks,
158
+ pending: input.pending,
159
+ summary: currentSummary,
160
+ merged: false,
161
+ error: failure,
162
+ }
163
+ }
164
+
165
+ const chosen = candidates[picked.candidate.index - 1]
166
+ if (chosen === undefined) {
167
+ return {
168
+ blocks: input.state.blocks,
169
+ pending: input.pending,
170
+ summary: currentSummary,
171
+ merged: false,
172
+ error: "model picked an unknown candidate",
173
+ }
174
+ }
175
+
176
+ const validation = validateSummary(
177
+ picked.summary,
178
+ pairTranscript(chosen).length,
179
+ minSummaryChars(chosen.tier),
180
+ input.summaryBlockChars,
181
+ )
182
+ if (!validation.ok) {
183
+ return {
184
+ blocks: input.state.blocks,
185
+ pending: input.pending,
186
+ summary: currentSummary,
187
+ merged: false,
188
+ error: validation.message,
189
+ }
190
+ }
191
+
192
+ // 上一层 pending 先转成已落盘块,新合并块保持 pending,交给 compaction 事件转正。
193
+ const working = input.pending === undefined ? [...input.state.blocks] : [...input.state.blocks, input.pending]
194
+ const merged: Block = {
195
+ id: nextBlockId(working),
196
+ tier: chosen.tier,
197
+ summary: picked.summary,
198
+ fromId: chosen.left.fromId,
199
+ toId: chosen.right.toId,
200
+ tokensBefore: chosen.left.tokensAfter + chosen.right.tokensAfter,
201
+ tokensAfter: Math.ceil(summaryChars(picked.summary) / 4),
202
+ at: Math.max(chosen.left.at, chosen.right.at),
203
+ order: chosen.left.order,
204
+ sourceBlockIds: [chosen.left.id, chosen.right.id],
205
+ }
206
+ const summary = renderAll(working, merged, renderOptions)
207
+ const stillOver =
208
+ countText(summary) > input.budgetTokens || summary.length > totalChars
209
+ // 只要还超预算且还有合法候选就继续合并;T3+T3 产出仍是 T3,直到没有候选或压进预算。
210
+ const nextPhase: CompactionPhase | undefined =
211
+ stillOver && mergeCandidates(activeBlocks(working, merged)).length > 0 ? "t3" : undefined
212
+
213
+ return {
214
+ blocks: working,
215
+ pending: merged,
216
+ summary,
217
+ merged: true,
218
+ ...(nextPhase === undefined ? {} : { nextPhase }),
219
+ }
220
+ }