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.
- 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 +372 -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 +84 -84
- package/src/compression/select.ts +123 -123
- package/src/compression/session-message.ts +112 -112
- package/src/compression/settings-rpc.ts +111 -111
- package/src/compression/settings.ts +175 -175
- package/src/compression/state.ts +248 -248
- package/src/compression/summarize.ts +595 -595
- package/src/compression/summary.ts +73 -73
- package/src/compression/tool.ts +142 -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 +108 -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 +471 -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 +111 -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 +192 -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
package/src/compression/auto.ts
CHANGED
|
@@ -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
|
+
}
|