compressed-context 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.
Files changed (55) hide show
  1. package/package.json +64 -64
  2. package/src/compression/active-summary-cache.ts +50 -50
  3. package/src/compression/adopt.ts +57 -57
  4. package/src/compression/auto.ts +372 -372
  5. package/src/compression/babel.ts +85 -85
  6. package/src/compression/block.ts +25 -25
  7. package/src/compression/cache-history.ts +75 -75
  8. package/src/compression/compaction.ts +189 -189
  9. package/src/compression/decompress-tool.ts +74 -74
  10. package/src/compression/decompress.ts +292 -292
  11. package/src/compression/deleted-sessions.ts +24 -24
  12. package/src/compression/distill.ts +220 -220
  13. package/src/compression/events.ts +275 -275
  14. package/src/compression/markdown.ts +53 -53
  15. package/src/compression/normalize.ts +114 -114
  16. package/src/compression/render.ts +70 -70
  17. package/src/compression/schema.ts +84 -84
  18. package/src/compression/select.ts +123 -123
  19. package/src/compression/session-message.ts +112 -112
  20. package/src/compression/settings-rpc.ts +111 -111
  21. package/src/compression/settings.ts +175 -175
  22. package/src/compression/state.ts +248 -248
  23. package/src/compression/summarize.ts +595 -595
  24. package/src/compression/summary.ts +73 -73
  25. package/src/compression/tool.ts +142 -142
  26. package/src/compression/transcript-store.ts +258 -258
  27. package/src/compression/transcript.ts +63 -63
  28. package/src/compression/validate.ts +81 -81
  29. package/src/i18n/en.ts +93 -93
  30. package/src/i18n/index.ts +28 -28
  31. package/src/i18n/types.ts +89 -89
  32. package/src/i18n/zh.ts +93 -93
  33. package/src/index.ts +108 -108
  34. package/src/shared/blocks.ts +17 -17
  35. package/src/shared/cache.ts +199 -199
  36. package/src/shared/format.ts +23 -23
  37. package/src/shared/media.ts +184 -184
  38. package/src/shared/parse.ts +25 -25
  39. package/src/shared/parts.ts +72 -72
  40. package/src/shared/rpc.ts +145 -145
  41. package/src/shared/scan.ts +32 -32
  42. package/src/shared/settings.ts +471 -471
  43. package/src/shared/stats.ts +142 -142
  44. package/src/shared/tokens.ts +259 -259
  45. package/src/tui/chart/cache-chart.ts +237 -237
  46. package/src/tui/chart/color.ts +58 -58
  47. package/src/tui/index.tsx +111 -111
  48. package/src/tui/panel/cache.tsx +203 -203
  49. package/src/tui/panel/data.ts +105 -105
  50. package/src/tui/panel/index.tsx +176 -176
  51. package/src/tui/panel/summaries.tsx +192 -192
  52. package/src/tui/settings/dialogs.tsx +215 -215
  53. package/src/tui/settings/options.ts +238 -238
  54. package/src/tui/settings.tsx +418 -418
  55. package/src/tui/types.ts +4 -4
@@ -1,372 +1,372 @@
1
- import type { Plugin } from "@opencode/plugin"
2
-
3
- import { formatTokens } from "../shared/format.ts"
4
- import { MIN_SUMMARY_BLOCK_CHARS, SUMMARY_WINDOW_RESERVE } from "../shared/settings.ts"
5
- import { estimateCategories, totalCategories } from "../shared/tokens.ts"
6
- import { eligibleMessages, messageText, type MessageLike } from "./select.ts"
7
- import { EFFECTIVE_WINDOW_KEY, RESOLVED_WINDOW_KEY, windowKey, loadServerSettings } from "./settings.ts"
8
- import { MAX_COMPRESSION_ATTEMPTS, loadState, saveState, type State } from "./state.ts"
9
- import type { ModelRefLike } from "./summarize.ts"
10
- import { buildTranscript } from "./transcript.ts"
11
- import { MIN_SOURCE_CHARS } from "./validate.ts"
12
-
13
- /** 模型窗口缓存时长。 */
14
- const LIMIT_TTL = 60_000
15
-
16
- const limitCache = new Map<string, { at: number; limit: number }>()
17
-
18
- /**
19
- * 正在执行 compaction 钩子的 session。
20
- * 压缩钩子内部会调用 session.generate 生成摘要;那个请求同样会经过 context 钩子,
21
- * 如果不拦住,就会在压缩过程中再次入队压缩,形成递归并让本次摘要失败。
22
- */
23
- const compactionInFlight = new Set<string>()
24
-
25
- /** 输入:sessionID + 是否正在压缩;输出:无;预期:只更新进程内标记。 */
26
- export function markCompactionInFlight(sessionID: string, active: boolean): void {
27
- if (active) compactionInFlight.add(sessionID)
28
- else compactionInFlight.delete(sessionID)
29
- }
30
-
31
- /** 输入:sessionID;输出:是否正在压缩。 */
32
- export function isCompactionInFlight(sessionID: string): boolean {
33
- return compactionInFlight.has(sessionID)
34
- }
35
-
36
- /** 入队后等待压缩完成的最大时长;OpenCode 原生压缩在 step 循环里同步等,这里对齐该语义。 */
37
- export const COMPACTION_WAIT_MS = 5 * 60_000
38
- /** 等待轮询间隔。 */
39
- const COMPACTION_POLL_MS = 200
40
-
41
- /**
42
- * 输入:服务端上下文 + sessionID + 入队前的 state 快照。
43
- * 输出:无。
44
- * 预期:阻塞直到该次 compaction 落地——pending 出现(钩子已生成块)、blocks 增多
45
- * (事件循环转正)、lastError 出现或变化(失败)、或等待超时。
46
- * 超时不抛错,放行请求。
47
- */
48
- export async function waitForCompaction(
49
- ctx: Plugin.Context,
50
- sessionID: string,
51
- before: { blocks: number; lastError: string | undefined },
52
- ): Promise<void> {
53
- const deadline = Date.now() + COMPACTION_WAIT_MS
54
- while (Date.now() < deadline) {
55
- const state = await loadState(ctx.storage, sessionID)
56
- // pending 出现即钩子已生成块;blocks 增多即事件循环已转正;两者任一都算落地。
57
- const settled =
58
- state.pending !== undefined || state.blocks.length > before.blocks
59
- const failed = state.lastError !== undefined && state.lastError !== before.lastError
60
- if (settled || failed) return
61
- await new Promise((resolve) => setTimeout(resolve, COMPACTION_POLL_MS))
62
- }
63
- }
64
-
65
- /**
66
- * 输入:storage;输出:最近一次解析到的「生效窗口」。
67
- * 预期:`resolveLimit` 在缓存里保存的是原生窗口,settings 里的数字上限
68
- * `contextLimit=100000` 只有在生效窗口里才体现;缺失时 undefined。
69
- */
70
- async function effectiveLimit(storage: Plugin.Context["storage"]): Promise<number | undefined> {
71
- const stored = await storage.get(EFFECTIVE_WINDOW_KEY)
72
- return typeof stored === "number" && stored > 0 ? stored : undefined
73
- }
74
-
75
- /**
76
- * 输入:会话状态。
77
- * 输出:本轮自动压缩是否必须跳过。
78
- * 预期:有 pending、已达尝试上限、或已进入终止态时都不再入队。
79
- */
80
- function blockedByState(state: State): boolean {
81
- return (
82
- state.pending !== undefined ||
83
- state.attempts >= MAX_COMPRESSION_ATTEMPTS ||
84
- state.terminal !== undefined
85
- )
86
- }
87
-
88
- /**
89
- * 输入:服务端上下文 + sessionID + 捕获到的错误。
90
- * 输出:无。
91
- * 预期:累加 attempts 并记录 lastError;写状态失败时忽略,避免掩盖原始错误。
92
- */
93
- async function recordCompressionFailure(
94
- ctx: Plugin.Context,
95
- sessionID: string,
96
- error: unknown,
97
- ): Promise<void> {
98
- try {
99
- const current = await loadState(ctx.storage, sessionID)
100
- await saveState(ctx.storage, sessionID, {
101
- ...current,
102
- attempts: current.attempts + 1,
103
- lastError: String(error),
104
- })
105
- } catch {
106
- // best effort
107
- }
108
- }
109
-
110
- /**
111
- * 输入:`session.step.ended` 事件带的 usage + 服务端上下文 + sessionID。
112
- * 输出:无。
113
- * 预期:tool loop 的后续 step 不触发 context hook,这里是唯一能在同 turn 内
114
- * 看到 usage 增长的入口;超过阈值直接入队 compaction。
115
- */
116
- export async function maybeAutoCompressStep(input: {
117
- readonly ctx: Plugin.Context
118
- readonly sessionID: string
119
- readonly tokens: {
120
- readonly input: number
121
- readonly cache: { readonly read: number; readonly write: number }
122
- }
123
- }): Promise<void> {
124
- try {
125
- if (isCompactionInFlight(input.sessionID)) return
126
- const state = await loadState(input.ctx.storage, input.sessionID)
127
- if (blockedByState(state)) return
128
-
129
- const settings = await loadServerSettings(input.ctx.storage)
130
- if (!settings.autoCompress) return
131
- // 事件里没有 model 引用;用生效窗口缓存(min(settings, native))折算阈值。
132
- // 原生窗口单独是 1M 时阈值会变成 800k,压缩永远不会触发。
133
- const effective = await effectiveLimit(input.ctx.storage)
134
- if (effective === undefined || effective <= 0) return
135
- if (effective - SUMMARY_WINDOW_RESERVE < MIN_SUMMARY_BLOCK_CHARS) return
136
-
137
- if (state.phase !== undefined) {
138
- await input.ctx.session.compact({ sessionID: input.sessionID })
139
- return
140
- }
141
-
142
- if (promptTokens(input.tokens) < effective * (settings.triggerPercent / 100)) return
143
-
144
- // 入队后同步等压缩落地,对齐 OpenCode 原生压缩的语义:
145
- // 下一个 step 的模型请求一定从压缩后的上下文开始,而不是继续放大。
146
- const before = { blocks: state.blocks.length, lastError: state.lastError }
147
- await input.ctx.session.compact({ sessionID: input.sessionID })
148
- await waitForCompaction(input.ctx, input.sessionID, before)
149
- } catch (error) {
150
- await recordCompressionFailure(input.ctx, input.sessionID, error)
151
- }
152
- }
153
-
154
- /**
155
- * 输入:当前请求消息。
156
- * 输出:最后一条 user 是否是插件自己的压缩指令。
157
- * 预期:压缩摘要请求不能再触发自动压缩。
158
- */
159
- function isCompressionInstruction(messages: readonly MessageLike[]): boolean {
160
- const lastUser = [...messages].reverse().find((message) => message.role === "user")
161
- if (lastUser === undefined) return false
162
- const text = messageText(lastUser).trimStart()
163
- return (
164
- text.startsWith("You are writing a checkpoint") ||
165
- text.startsWith("The checkpoint at the top of the conversation")
166
- )
167
- }
168
-
169
- export interface AutoInput {
170
- readonly ctx: Plugin.Context
171
- readonly sessionID: string
172
- readonly messages: readonly MessageLike[]
173
- readonly system: readonly { readonly type: string; readonly text: string }[]
174
- readonly tools: Readonly<Record<string, { readonly description?: string; readonly input?: unknown }>>
175
- readonly model: ModelRefLike
176
- }
177
-
178
- /** 输入:插件上下文 + 模型标识 + 窗口;输出:无;预期:写进 storage 供 TUI 读取,失败不影响压缩。 */
179
- async function rememberWindow(
180
- ctx: Plugin.Context,
181
- providerID: string,
182
- modelID: string,
183
- limit: number,
184
- ): Promise<void> {
185
- try {
186
- await ctx.storage.set(windowKey(providerID, modelID), limit)
187
- await ctx.storage.set(RESOLVED_WINDOW_KEY, limit)
188
- } catch {
189
- // best effort:写不进去只影响 TUI 显示,不影响压缩
190
- }
191
- }
192
-
193
- /**
194
- * 输入:设置 + 模型;输出:实际使用的窗口大小。
195
- * 预期:数字设置是上限,先解析模型原生窗口再取 min;auto 直接用原生值。
196
- */
197
- export async function resolveLimit(
198
- ctx: Plugin.Context,
199
- model: ModelRefLike,
200
- setting: "auto" | number,
201
- ): Promise<number | undefined> {
202
- const key = `${model.providerID}/${model.id}`
203
- const cached = limitCache.get(key)
204
- let native = cached !== undefined && Date.now() - cached.at < LIMIT_TTL ? cached.limit : undefined
205
- if (native === undefined) {
206
- try {
207
- const result = await ctx.model.list()
208
- const info = result.data.find((item) => item.providerID === model.providerID && item.id === model.id)
209
- const limit = info?.limit.context
210
- if (typeof limit === "number" && limit > 0) {
211
- native = limit
212
- limitCache.set(key, { at: Date.now(), limit })
213
- await rememberWindow(ctx, model.providerID, model.id, limit)
214
- }
215
- } catch {
216
- // 原生窗口未知:数字设置按原值使用,auto 则关闭自动压缩
217
- }
218
- }
219
- const effective = setting === "auto" ? native : native === undefined ? setting : Math.min(setting, native)
220
- if (typeof effective === "number" && effective > 0) {
221
- try {
222
- await ctx.storage.set(EFFECTIVE_WINDOW_KEY, effective)
223
- } catch {
224
- // best effort:写不进去只影响 step.ended 兜底
225
- }
226
- }
227
- return effective
228
- }
229
-
230
- /** 输入:system / tools / messages;输出:估算总 token。 */
231
- function estimateTotal(input: AutoInput): number {
232
- return totalCategories(
233
- estimateCategories({
234
- system: input.system,
235
- tools: input.tools,
236
- messages: input.messages,
237
- }),
238
- )
239
- }
240
-
241
- /** 单个 assistant 的真实 prompt 大小;output/reasoning 不属于本轮输入。 */
242
- function promptTokens(tokens: {
243
- readonly input: number
244
- readonly cache: { readonly read: number; readonly write: number }
245
- }): number {
246
- return tokens.input + tokens.cache.read + tokens.cache.write
247
- }
248
-
249
- /**
250
- * 输入:服务端上下文 + sessionID。
251
- * 输出:最后一个 assistant 的真实 prompt token;没有 usage 时 undefined。
252
- * 预期:SessionHistory 按 seq 升序返回,从末尾取最新 assistant。
253
- */
254
- async function measuredPromptTokens(
255
- ctx: Plugin.Context,
256
- sessionID: string,
257
- ): Promise<number | undefined> {
258
- try {
259
- const messages = await ctx.session.context({ sessionID })
260
- for (let index = messages.length - 1; index >= 0; index -= 1) {
261
- const message = messages[index]
262
- if (message?.type !== "assistant" || message.tokens === undefined) continue
263
- const total = promptTokens(message.tokens)
264
- if (total > 0) return total
265
- }
266
- } catch {
267
- // 没有 session.context 或 provider 没返回 usage:调用方回退到估算
268
- }
269
- return undefined
270
- }
271
-
272
- /**
273
- * 输入:服务端上下文 + 当前请求。
274
- * 输出:无。
275
- * 预期:占用超过 triggerPercent 时只入队 compaction;真正摘要在 compaction 钩子里做。
276
- */
277
- export async function maybeAutoCompress(input: AutoInput): Promise<void> {
278
- try {
279
- if (isCompactionInFlight(input.sessionID)) return
280
- if (isCompressionInstruction(input.messages)) return
281
-
282
- const state = await loadState(input.ctx.storage, input.sessionID)
283
- if (blockedByState(state)) return
284
-
285
- const settings = await loadServerSettings(input.ctx.storage)
286
- if (!settings.autoCompress) return
287
- const limit = await resolveLimit(input.ctx, input.model, settings.contextLimit)
288
- if (limit === undefined || limit <= 0) return
289
- // 窗口小到连最小摘要都放不下:压缩必然失败,别反复入队空压缩。
290
- if (limit - SUMMARY_WINDOW_RESERVE < MIN_SUMMARY_BLOCK_CHARS) return
291
-
292
- // 已排下一层蒸馏:下一次模型请求就入队 compaction,由 compaction 钩子合并。
293
- // 不能在 T1 刚结束时入队——那时历史里只剩 checkpoint,OpenCode 会拒绝压缩。
294
- if (state.phase !== undefined) {
295
- await input.ctx.session.compact({ sessionID: input.sessionID })
296
- return
297
- }
298
-
299
- const total = (await measuredPromptTokens(input.ctx, input.sessionID)) ?? estimateTotal(input)
300
- if (total < limit * (settings.triggerPercent / 100)) return
301
- const eligible = eligibleMessages(input.messages)
302
- const sourceChars = buildTranscript(eligible).length
303
- if (eligible.length === 0 || sourceChars < MIN_SOURCE_CHARS) return
304
-
305
- await input.ctx.session.compact({ sessionID: input.sessionID })
306
- } catch (error) {
307
- await recordCompressionFailure(input.ctx, input.sessionID, error)
308
- }
309
- }
310
-
311
- /** 输入:本次增长量 + 是否已有压缩块 + 自动压缩开关;输出:追加到请求尾部的提醒文本。 */
312
- export function buildNudgeText(growthTokens: number, hasCompressed: boolean, autoCompress: boolean): string {
313
- const scope = hasCompressed ? "since the last compressed block" : "since the session started"
314
- const fallback = autoCompress
315
- ? "If you are busy, ignore this; automatic compression will run later."
316
- : "If you are busy, ignore this; call compress when convenient."
317
- return (
318
- `[context-reminder] Context has grown by about ${formatTokens(growthTokens)} tokens ${scope}. ` +
319
- `You may call compress to checkpoint the entire current conversation flow ${scope}. ` +
320
- fallback
321
- )
322
- }
323
-
324
- /** 主动压缩提醒的输入;messages 必须可追加,和只读的自动压缩输入区分开。 */
325
- export type NudgeInput = Omit<AutoInput, "messages"> & {
326
- readonly messages: MessageLike[]
327
- }
328
-
329
- /**
330
- * 输入:服务端上下文 + 可追加消息。
331
- * 输出:无。
332
- * 预期:按 nudgeGrowthTokens 增长量在请求尾部追加一次提醒;压缩中、终止、失败上限或
333
- * 没有可压缩内容时跳过。
334
- */
335
- export async function maybeNudge(input: NudgeInput): Promise<void> {
336
- try {
337
- if (isCompactionInFlight(input.sessionID)) return
338
- if (isCompressionInstruction(input.messages)) return
339
-
340
- const state = await loadState(input.ctx.storage, input.sessionID)
341
- if (blockedByState(state) || state.phase !== undefined) return
342
-
343
- const settings = await loadServerSettings(input.ctx.storage)
344
- if (settings.nudgeGrowthTokens <= 0) return
345
-
346
- const total = (await measuredPromptTokens(input.ctx, input.sessionID)) ?? estimateTotal(input)
347
- if (state.nudgeBaselineTokens === undefined || state.nudgeBaselineTokens > total) {
348
- await saveState(input.ctx.storage, input.sessionID, { ...state, nudgeBaselineTokens: total })
349
- return
350
- }
351
-
352
- const growth = total - state.nudgeBaselineTokens
353
- if (growth < settings.nudgeGrowthTokens) return
354
-
355
- const eligible = eligibleMessages(input.messages)
356
- const sourceChars = buildTranscript(eligible).length
357
- if (eligible.length === 0 || sourceChars < MIN_SOURCE_CHARS) return
358
-
359
- const hasCompressed = state.blocks.length > 0
360
- input.messages.push({
361
- role: "user",
362
- content: [{ type: "text", text: buildNudgeText(growth, hasCompressed, settings.autoCompress) }],
363
- })
364
- await saveState(input.ctx.storage, input.sessionID, {
365
- ...state,
366
- nudgeBaselineTokens: total,
367
- nudgeCount: (state.nudgeCount ?? 0) + 1,
368
- })
369
- } catch {
370
- // 提醒是 best effort,不能影响模型请求。
371
- }
372
- }
1
+ import type { Plugin } from "@opencode/plugin"
2
+
3
+ import { formatTokens } from "../shared/format.ts"
4
+ import { MIN_SUMMARY_BLOCK_CHARS, SUMMARY_WINDOW_RESERVE } from "../shared/settings.ts"
5
+ import { estimateCategories, totalCategories } from "../shared/tokens.ts"
6
+ import { eligibleMessages, messageText, type MessageLike } from "./select.ts"
7
+ import { EFFECTIVE_WINDOW_KEY, RESOLVED_WINDOW_KEY, windowKey, loadServerSettings } from "./settings.ts"
8
+ import { MAX_COMPRESSION_ATTEMPTS, loadState, saveState, type State } from "./state.ts"
9
+ import type { ModelRefLike } from "./summarize.ts"
10
+ import { buildTranscript } from "./transcript.ts"
11
+ import { MIN_SOURCE_CHARS } from "./validate.ts"
12
+
13
+ /** 模型窗口缓存时长。 */
14
+ const LIMIT_TTL = 60_000
15
+
16
+ const limitCache = new Map<string, { at: number; limit: number }>()
17
+
18
+ /**
19
+ * 正在执行 compaction 钩子的 session。
20
+ * 压缩钩子内部会调用 session.generate 生成摘要;那个请求同样会经过 context 钩子,
21
+ * 如果不拦住,就会在压缩过程中再次入队压缩,形成递归并让本次摘要失败。
22
+ */
23
+ const compactionInFlight = new Set<string>()
24
+
25
+ /** 输入:sessionID + 是否正在压缩;输出:无;预期:只更新进程内标记。 */
26
+ export function markCompactionInFlight(sessionID: string, active: boolean): void {
27
+ if (active) compactionInFlight.add(sessionID)
28
+ else compactionInFlight.delete(sessionID)
29
+ }
30
+
31
+ /** 输入:sessionID;输出:是否正在压缩。 */
32
+ export function isCompactionInFlight(sessionID: string): boolean {
33
+ return compactionInFlight.has(sessionID)
34
+ }
35
+
36
+ /** 入队后等待压缩完成的最大时长;OpenCode 原生压缩在 step 循环里同步等,这里对齐该语义。 */
37
+ export const COMPACTION_WAIT_MS = 5 * 60_000
38
+ /** 等待轮询间隔。 */
39
+ const COMPACTION_POLL_MS = 200
40
+
41
+ /**
42
+ * 输入:服务端上下文 + sessionID + 入队前的 state 快照。
43
+ * 输出:无。
44
+ * 预期:阻塞直到该次 compaction 落地——pending 出现(钩子已生成块)、blocks 增多
45
+ * (事件循环转正)、lastError 出现或变化(失败)、或等待超时。
46
+ * 超时不抛错,放行请求。
47
+ */
48
+ export async function waitForCompaction(
49
+ ctx: Plugin.Context,
50
+ sessionID: string,
51
+ before: { blocks: number; lastError: string | undefined },
52
+ ): Promise<void> {
53
+ const deadline = Date.now() + COMPACTION_WAIT_MS
54
+ while (Date.now() < deadline) {
55
+ const state = await loadState(ctx.storage, sessionID)
56
+ // pending 出现即钩子已生成块;blocks 增多即事件循环已转正;两者任一都算落地。
57
+ const settled =
58
+ state.pending !== undefined || state.blocks.length > before.blocks
59
+ const failed = state.lastError !== undefined && state.lastError !== before.lastError
60
+ if (settled || failed) return
61
+ await new Promise((resolve) => setTimeout(resolve, COMPACTION_POLL_MS))
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 输入:storage;输出:最近一次解析到的「生效窗口」。
67
+ * 预期:`resolveLimit` 在缓存里保存的是原生窗口,settings 里的数字上限
68
+ * `contextLimit=100000` 只有在生效窗口里才体现;缺失时 undefined。
69
+ */
70
+ async function effectiveLimit(storage: Plugin.Context["storage"]): Promise<number | undefined> {
71
+ const stored = await storage.get(EFFECTIVE_WINDOW_KEY)
72
+ return typeof stored === "number" && stored > 0 ? stored : undefined
73
+ }
74
+
75
+ /**
76
+ * 输入:会话状态。
77
+ * 输出:本轮自动压缩是否必须跳过。
78
+ * 预期:有 pending、已达尝试上限、或已进入终止态时都不再入队。
79
+ */
80
+ function blockedByState(state: State): boolean {
81
+ return (
82
+ state.pending !== undefined ||
83
+ state.attempts >= MAX_COMPRESSION_ATTEMPTS ||
84
+ state.terminal !== undefined
85
+ )
86
+ }
87
+
88
+ /**
89
+ * 输入:服务端上下文 + sessionID + 捕获到的错误。
90
+ * 输出:无。
91
+ * 预期:累加 attempts 并记录 lastError;写状态失败时忽略,避免掩盖原始错误。
92
+ */
93
+ async function recordCompressionFailure(
94
+ ctx: Plugin.Context,
95
+ sessionID: string,
96
+ error: unknown,
97
+ ): Promise<void> {
98
+ try {
99
+ const current = await loadState(ctx.storage, sessionID)
100
+ await saveState(ctx.storage, sessionID, {
101
+ ...current,
102
+ attempts: current.attempts + 1,
103
+ lastError: String(error),
104
+ })
105
+ } catch {
106
+ // best effort
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 输入:`session.step.ended` 事件带的 usage + 服务端上下文 + sessionID。
112
+ * 输出:无。
113
+ * 预期:tool loop 的后续 step 不触发 context hook,这里是唯一能在同 turn 内
114
+ * 看到 usage 增长的入口;超过阈值直接入队 compaction。
115
+ */
116
+ export async function maybeAutoCompressStep(input: {
117
+ readonly ctx: Plugin.Context
118
+ readonly sessionID: string
119
+ readonly tokens: {
120
+ readonly input: number
121
+ readonly cache: { readonly read: number; readonly write: number }
122
+ }
123
+ }): Promise<void> {
124
+ try {
125
+ if (isCompactionInFlight(input.sessionID)) return
126
+ const state = await loadState(input.ctx.storage, input.sessionID)
127
+ if (blockedByState(state)) return
128
+
129
+ const settings = await loadServerSettings(input.ctx.storage)
130
+ if (!settings.autoCompress) return
131
+ // 事件里没有 model 引用;用生效窗口缓存(min(settings, native))折算阈值。
132
+ // 原生窗口单独是 1M 时阈值会变成 800k,压缩永远不会触发。
133
+ const effective = await effectiveLimit(input.ctx.storage)
134
+ if (effective === undefined || effective <= 0) return
135
+ if (effective - SUMMARY_WINDOW_RESERVE < MIN_SUMMARY_BLOCK_CHARS) return
136
+
137
+ if (state.phase !== undefined) {
138
+ await input.ctx.session.compact({ sessionID: input.sessionID })
139
+ return
140
+ }
141
+
142
+ if (promptTokens(input.tokens) < effective * (settings.triggerPercent / 100)) return
143
+
144
+ // 入队后同步等压缩落地,对齐 OpenCode 原生压缩的语义:
145
+ // 下一个 step 的模型请求一定从压缩后的上下文开始,而不是继续放大。
146
+ const before = { blocks: state.blocks.length, lastError: state.lastError }
147
+ await input.ctx.session.compact({ sessionID: input.sessionID })
148
+ await waitForCompaction(input.ctx, input.sessionID, before)
149
+ } catch (error) {
150
+ await recordCompressionFailure(input.ctx, input.sessionID, error)
151
+ }
152
+ }
153
+
154
+ /**
155
+ * 输入:当前请求消息。
156
+ * 输出:最后一条 user 是否是插件自己的压缩指令。
157
+ * 预期:压缩摘要请求不能再触发自动压缩。
158
+ */
159
+ function isCompressionInstruction(messages: readonly MessageLike[]): boolean {
160
+ const lastUser = [...messages].reverse().find((message) => message.role === "user")
161
+ if (lastUser === undefined) return false
162
+ const text = messageText(lastUser).trimStart()
163
+ return (
164
+ text.startsWith("You are writing a checkpoint") ||
165
+ text.startsWith("The checkpoint at the top of the conversation")
166
+ )
167
+ }
168
+
169
+ export interface AutoInput {
170
+ readonly ctx: Plugin.Context
171
+ readonly sessionID: string
172
+ readonly messages: readonly MessageLike[]
173
+ readonly system: readonly { readonly type: string; readonly text: string }[]
174
+ readonly tools: Readonly<Record<string, { readonly description?: string; readonly input?: unknown }>>
175
+ readonly model: ModelRefLike
176
+ }
177
+
178
+ /** 输入:插件上下文 + 模型标识 + 窗口;输出:无;预期:写进 storage 供 TUI 读取,失败不影响压缩。 */
179
+ async function rememberWindow(
180
+ ctx: Plugin.Context,
181
+ providerID: string,
182
+ modelID: string,
183
+ limit: number,
184
+ ): Promise<void> {
185
+ try {
186
+ await ctx.storage.set(windowKey(providerID, modelID), limit)
187
+ await ctx.storage.set(RESOLVED_WINDOW_KEY, limit)
188
+ } catch {
189
+ // best effort:写不进去只影响 TUI 显示,不影响压缩
190
+ }
191
+ }
192
+
193
+ /**
194
+ * 输入:设置 + 模型;输出:实际使用的窗口大小。
195
+ * 预期:数字设置是上限,先解析模型原生窗口再取 min;auto 直接用原生值。
196
+ */
197
+ export async function resolveLimit(
198
+ ctx: Plugin.Context,
199
+ model: ModelRefLike,
200
+ setting: "auto" | number,
201
+ ): Promise<number | undefined> {
202
+ const key = `${model.providerID}/${model.id}`
203
+ const cached = limitCache.get(key)
204
+ let native = cached !== undefined && Date.now() - cached.at < LIMIT_TTL ? cached.limit : undefined
205
+ if (native === undefined) {
206
+ try {
207
+ const result = await ctx.model.list()
208
+ const info = result.data.find((item) => item.providerID === model.providerID && item.id === model.id)
209
+ const limit = info?.limit.context
210
+ if (typeof limit === "number" && limit > 0) {
211
+ native = limit
212
+ limitCache.set(key, { at: Date.now(), limit })
213
+ await rememberWindow(ctx, model.providerID, model.id, limit)
214
+ }
215
+ } catch {
216
+ // 原生窗口未知:数字设置按原值使用,auto 则关闭自动压缩
217
+ }
218
+ }
219
+ const effective = setting === "auto" ? native : native === undefined ? setting : Math.min(setting, native)
220
+ if (typeof effective === "number" && effective > 0) {
221
+ try {
222
+ await ctx.storage.set(EFFECTIVE_WINDOW_KEY, effective)
223
+ } catch {
224
+ // best effort:写不进去只影响 step.ended 兜底
225
+ }
226
+ }
227
+ return effective
228
+ }
229
+
230
+ /** 输入:system / tools / messages;输出:估算总 token。 */
231
+ function estimateTotal(input: AutoInput): number {
232
+ return totalCategories(
233
+ estimateCategories({
234
+ system: input.system,
235
+ tools: input.tools,
236
+ messages: input.messages,
237
+ }),
238
+ )
239
+ }
240
+
241
+ /** 单个 assistant 的真实 prompt 大小;output/reasoning 不属于本轮输入。 */
242
+ function promptTokens(tokens: {
243
+ readonly input: number
244
+ readonly cache: { readonly read: number; readonly write: number }
245
+ }): number {
246
+ return tokens.input + tokens.cache.read + tokens.cache.write
247
+ }
248
+
249
+ /**
250
+ * 输入:服务端上下文 + sessionID。
251
+ * 输出:最后一个 assistant 的真实 prompt token;没有 usage 时 undefined。
252
+ * 预期:SessionHistory 按 seq 升序返回,从末尾取最新 assistant。
253
+ */
254
+ async function measuredPromptTokens(
255
+ ctx: Plugin.Context,
256
+ sessionID: string,
257
+ ): Promise<number | undefined> {
258
+ try {
259
+ const messages = await ctx.session.context({ sessionID })
260
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
261
+ const message = messages[index]
262
+ if (message?.type !== "assistant" || message.tokens === undefined) continue
263
+ const total = promptTokens(message.tokens)
264
+ if (total > 0) return total
265
+ }
266
+ } catch {
267
+ // 没有 session.context 或 provider 没返回 usage:调用方回退到估算
268
+ }
269
+ return undefined
270
+ }
271
+
272
+ /**
273
+ * 输入:服务端上下文 + 当前请求。
274
+ * 输出:无。
275
+ * 预期:占用超过 triggerPercent 时只入队 compaction;真正摘要在 compaction 钩子里做。
276
+ */
277
+ export async function maybeAutoCompress(input: AutoInput): Promise<void> {
278
+ try {
279
+ if (isCompactionInFlight(input.sessionID)) return
280
+ if (isCompressionInstruction(input.messages)) return
281
+
282
+ const state = await loadState(input.ctx.storage, input.sessionID)
283
+ if (blockedByState(state)) return
284
+
285
+ const settings = await loadServerSettings(input.ctx.storage)
286
+ if (!settings.autoCompress) return
287
+ const limit = await resolveLimit(input.ctx, input.model, settings.contextLimit)
288
+ if (limit === undefined || limit <= 0) return
289
+ // 窗口小到连最小摘要都放不下:压缩必然失败,别反复入队空压缩。
290
+ if (limit - SUMMARY_WINDOW_RESERVE < MIN_SUMMARY_BLOCK_CHARS) return
291
+
292
+ // 已排下一层蒸馏:下一次模型请求就入队 compaction,由 compaction 钩子合并。
293
+ // 不能在 T1 刚结束时入队——那时历史里只剩 checkpoint,OpenCode 会拒绝压缩。
294
+ if (state.phase !== undefined) {
295
+ await input.ctx.session.compact({ sessionID: input.sessionID })
296
+ return
297
+ }
298
+
299
+ const total = (await measuredPromptTokens(input.ctx, input.sessionID)) ?? estimateTotal(input)
300
+ if (total < limit * (settings.triggerPercent / 100)) return
301
+ const eligible = eligibleMessages(input.messages)
302
+ const sourceChars = buildTranscript(eligible).length
303
+ if (eligible.length === 0 || sourceChars < MIN_SOURCE_CHARS) return
304
+
305
+ await input.ctx.session.compact({ sessionID: input.sessionID })
306
+ } catch (error) {
307
+ await recordCompressionFailure(input.ctx, input.sessionID, error)
308
+ }
309
+ }
310
+
311
+ /** 输入:本次增长量 + 是否已有压缩块 + 自动压缩开关;输出:追加到请求尾部的提醒文本。 */
312
+ export function buildNudgeText(growthTokens: number, hasCompressed: boolean, autoCompress: boolean): string {
313
+ const scope = hasCompressed ? "since the last compressed block" : "since the session started"
314
+ const fallback = autoCompress
315
+ ? "If you are busy, ignore this; automatic compression will run later."
316
+ : "If you are busy, ignore this; call compress when convenient."
317
+ return (
318
+ `[context-reminder] Context has grown by about ${formatTokens(growthTokens)} tokens ${scope}. ` +
319
+ `You may call compress to checkpoint the entire current conversation flow ${scope}. ` +
320
+ fallback
321
+ )
322
+ }
323
+
324
+ /** 主动压缩提醒的输入;messages 必须可追加,和只读的自动压缩输入区分开。 */
325
+ export type NudgeInput = Omit<AutoInput, "messages"> & {
326
+ readonly messages: MessageLike[]
327
+ }
328
+
329
+ /**
330
+ * 输入:服务端上下文 + 可追加消息。
331
+ * 输出:无。
332
+ * 预期:按 nudgeGrowthTokens 增长量在请求尾部追加一次提醒;压缩中、终止、失败上限或
333
+ * 没有可压缩内容时跳过。
334
+ */
335
+ export async function maybeNudge(input: NudgeInput): Promise<void> {
336
+ try {
337
+ if (isCompactionInFlight(input.sessionID)) return
338
+ if (isCompressionInstruction(input.messages)) return
339
+
340
+ const state = await loadState(input.ctx.storage, input.sessionID)
341
+ if (blockedByState(state) || state.phase !== undefined) return
342
+
343
+ const settings = await loadServerSettings(input.ctx.storage)
344
+ if (settings.nudgeGrowthTokens <= 0) return
345
+
346
+ const total = (await measuredPromptTokens(input.ctx, input.sessionID)) ?? estimateTotal(input)
347
+ if (state.nudgeBaselineTokens === undefined || state.nudgeBaselineTokens > total) {
348
+ await saveState(input.ctx.storage, input.sessionID, { ...state, nudgeBaselineTokens: total })
349
+ return
350
+ }
351
+
352
+ const growth = total - state.nudgeBaselineTokens
353
+ if (growth < settings.nudgeGrowthTokens) return
354
+
355
+ const eligible = eligibleMessages(input.messages)
356
+ const sourceChars = buildTranscript(eligible).length
357
+ if (eligible.length === 0 || sourceChars < MIN_SOURCE_CHARS) return
358
+
359
+ const hasCompressed = state.blocks.length > 0
360
+ input.messages.push({
361
+ role: "user",
362
+ content: [{ type: "text", text: buildNudgeText(growth, hasCompressed, settings.autoCompress) }],
363
+ })
364
+ await saveState(input.ctx.storage, input.sessionID, {
365
+ ...state,
366
+ nudgeBaselineTokens: total,
367
+ nudgeCount: (state.nudgeCount ?? 0) + 1,
368
+ })
369
+ } catch {
370
+ // 提醒是 best effort,不能影响模型请求。
371
+ }
372
+ }