compressed-context 0.0.0-stage → 0.1.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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -2
  3. package/package.json +64 -6
  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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 1cyberlangke1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,89 @@
1
- # Temporary Holding Version
1
+ # compressed-context
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ OpenCode 的上下文压缩插件:把变长的旧历史折叠成自包含摘要块,让长会话在有限窗口里继续跑下去。
4
+
5
+ ## 功能
6
+
7
+ - **自动压缩**:上下文占用达到阈值时自动入队,工具循环里的后续 step 也能触发
8
+ - **手动压缩**:模型可主动调用 `compress` 工具,在任意时刻打检查点
9
+ - **分层摘要**:T1 压缩当前对话流;T2 / T3 合并相邻摘要,层级单调递减
10
+ - **解压**:`decompress` 还原某个块覆盖的原始消息,可只还原下一层或展开到原文
11
+ - **缓存友好**:压缩请求保持前缀不变,尽量减少缓存未命中
12
+ - **摘要格式**:Markdown(默认)或 Babel(首行 `T=标题|G=目标`,正文自由高密度)
13
+ - **主动提醒**:上下文增长超过设定值时,在请求尾部追加一次提醒,让模型自己决定何时压缩
14
+ - **TUI 面板**:查看摘要块、缓存命中率、上下文构成,设置项在插件设置里直接改
15
+
16
+ ## 安装
17
+
18
+ 插件通过 npm 分发,OpenCode 会在启动时自行安装。
19
+
20
+ ### 方式一:npm 包名(推荐)
21
+
22
+ 在 OpenCode 配置里加入 `plugin` 数组(全局配置 `~/.config/opencode/opencode.json`,或项目内 `opencode.json`):
23
+
24
+ ```json
25
+ {
26
+ "plugin": ["compressed-context"]
27
+ }
28
+ ```
29
+
30
+ ### 方式二:本地路径
31
+
32
+ 从源码使用或自己改代码时,直接指向仓库目录:
33
+
34
+ ```json
35
+ {
36
+ "plugin": ["/absolute/path/to/compressed-context"]
37
+ }
38
+ ```
39
+
40
+ ### 配置建议
41
+
42
+ 插件会接管上下文压缩,建议关掉 OpenCode 原生自动压缩,避免两套策略互相干扰:
43
+
44
+ ```json
45
+ {
46
+ "plugin": ["compressed-context"],
47
+ "compaction": { "auto": false }
48
+ }
49
+ ```
50
+
51
+ ## 配置项
52
+
53
+ 都能在 TUI 的插件设置里改,也可以通过设置接口写入:
54
+
55
+ | 键 | 默认 | 说明 |
56
+ |---|---|---|
57
+ | `contextLimit` | `"auto"` | 上下文窗口上限;数字表示上限,实际取它与模型原生窗口的较小值 |
58
+ | `triggerPercent` | `80` | 占用达到窗口的百分之多少时自动压缩 |
59
+ | `autoCompress` | 开 | 是否允许自动压缩 |
60
+ | `summaryBlockPercent` | 单条摘要占窗口的百分比 | 单个摘要块的 token 预算 |
61
+ | `summaryTotalPercent` | 全部摘要占窗口的百分比 | 摘要总量预算,超过后触发 T2 / T3 |
62
+ | `summaryFormat` | `markdown` | `markdown` 或 `babel` |
63
+ | `summaryLanguage` | `auto` | 摘要语言;`babel` 格式下忽略 |
64
+ | `nudgeGrowthTokens` | `50000` | 主动提醒的增长阈值,`0` 关闭 |
65
+ | `enableCompressTool` | 开 | 是否暴露 `compress` 工具 |
66
+ | `enableDecompressTool` | 开 | 是否暴露 `decompress` 工具 |
67
+
68
+ ## 开发
69
+
70
+ ```bash
71
+ pnpm install
72
+ pnpm check # typecheck + lint + 单测 + TUI 测试
73
+ bun scripts/e2e-nudge.mjs # 隔离环境 + 假 provider 的提醒 e2e
74
+ bun scripts/e2e-tool-loop.mjs # 工具循环里的自动压缩 e2e
75
+ ```
76
+
77
+ ## 基准
78
+
79
+ 在 Terminal-Bench 的 `schemelike-metacircular-eval` 上做过一次粗略对比:100k 窗口下插件把单步上下文
80
+ 压在 93,560 并让该次运行通过验证。**只跑了一次,波动很大,只能当参考**,详见
81
+ [docs/benchmark.md](docs/benchmark.md)。
82
+
83
+ ## 协议
84
+
85
+ [MIT](LICENSE) © 1cyberlangke1
86
+
87
+ ## 灵感
88
+
89
+ - [billion-context](https://github.com/ranxianglei/billion-context)
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
- {
2
- "name": "compressed-context",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "compressed-context",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode 上下文管理插件:按需压缩、自包含摘要",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "1cyberlangke1",
8
+ "homepage": "https://github.com/1cyberlangke1/compressed-context#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/1cyberlangke1/compressed-context.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/1cyberlangke1/compressed-context/issues"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "plugin",
19
+ "context",
20
+ "compression",
21
+ "summarization",
22
+ "llm"
23
+ ],
24
+ "exports": {
25
+ ".": "./src/index.ts",
26
+ "./tui": "./src/tui/index.tsx"
27
+ },
28
+ "files": [
29
+ "src"
30
+ ],
31
+ "peerDependencies": {
32
+ "@opencode/plugin": "^2.0.32",
33
+ "@opentui/core": ">=0.5.17",
34
+ "@opentui/solid": ">=0.5.17",
35
+ "solid-js": ">=1.9.0"
36
+ },
37
+ "scripts": {
38
+ "typecheck": "tsc --noEmit",
39
+ "lint": "eslint .",
40
+ "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm test:tui",
41
+ "test": "vitest run",
42
+ "test:tui": "cd tui-test && bun test",
43
+ "test:e2e:nudge": "bun scripts/e2e-nudge.mjs",
44
+ "test:e2e:tool-loop": "bun scripts/e2e-tool-loop.mjs",
45
+ "test:watch": "vitest"
46
+ },
47
+ "devDependencies": {
48
+ "@eslint/js": "^10.0.1",
49
+ "@opencode/plugin": "^2.0.32",
50
+ "@opencode/theme": "2.0.32",
51
+ "@opentui/core": "0.5.17",
52
+ "@opentui/solid": "0.5.17",
53
+ "@types/node": "^26.6.5",
54
+ "eslint": "^10.13.0",
55
+ "solid-js": "1.9.15",
56
+ "typescript": "^6.0.3",
57
+ "typescript-eslint": "^8.71.2",
58
+ "vitest": "^5.0.5"
59
+ },
60
+ "dependencies": {
61
+ "effect": "4.0.1",
62
+ "jsonrepair": "^3.15.0"
63
+ }
64
+ }
@@ -0,0 +1,50 @@
1
+ import { renderAll, type RenderOptions } from "./render.ts"
2
+ import type { State } from "./state.ts"
3
+
4
+ interface Entry {
5
+ readonly revision: string
6
+ readonly text: string
7
+ }
8
+
9
+ const LIMIT = 32
10
+ const cache = new Map<string, Entry>()
11
+
12
+ /** 输入:状态 + 渲染选项;输出:只由块身份决定的 revision。 */
13
+ function revisionOf(state: State, options: RenderOptions): string {
14
+ return JSON.stringify({
15
+ blocks: state.blocks.map((block) => [
16
+ block.id,
17
+ block.tier,
18
+ block.order,
19
+ block.at,
20
+ block.sourceBlockIds ?? [],
21
+ ]),
22
+ pending: state.pending?.id ?? null,
23
+ options,
24
+ })
25
+ }
26
+
27
+ /** 输入:sessionID + 状态 + 渲染选项;输出:活跃摘要文本。 */
28
+ export function renderActiveSummary(
29
+ sessionID: string,
30
+ state: State,
31
+ options: RenderOptions,
32
+ ): string {
33
+ const revision = revisionOf(state, options)
34
+ const hit = cache.get(sessionID)
35
+ if (hit?.revision === revision) return hit.text
36
+ const text = renderAll(state.blocks, state.pending, options)
37
+ cache.delete(sessionID)
38
+ cache.set(sessionID, { revision, text })
39
+ while (cache.size > LIMIT) {
40
+ const oldest = cache.keys().next().value
41
+ if (oldest === undefined) break
42
+ cache.delete(oldest)
43
+ }
44
+ return text
45
+ }
46
+
47
+ /** 输入:sessionID;输出:无;预期:会话删除时丢掉活跃摘要缓存。 */
48
+ export function clearActiveSummary(sessionID: string): void {
49
+ cache.delete(sessionID)
50
+ }
@@ -0,0 +1,57 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+
3
+ import { createT1Block } from "./block.ts"
4
+ import { toMarkdownSummary } from "./markdown.ts"
5
+ import { isCheckpoint, messageText, type MessageLike } from "./select.ts"
6
+ import { loadState, saveState } from "./state.ts"
7
+
8
+ /** 原生 checkpoint 里抽出的摘要与 recent 上下文。 */
9
+ export interface NativeCheckpoint {
10
+ readonly summary: string
11
+ readonly recent?: string
12
+ }
13
+
14
+ /**
15
+ * 输入:OpenCode checkpoint 文本。
16
+ * 输出:`<summary>` / `<recent-context>` 内容;不是 checkpoint 时 undefined。
17
+ * 预期:只做只读解析,不改原文。
18
+ */
19
+ export function extractCheckpoint(text: string): NativeCheckpoint | undefined {
20
+ if (!text.trimStart().startsWith("<conversation-checkpoint>")) return undefined
21
+ const summary = /<summary>\n?([\s\S]*?)\n?<\/summary>/.exec(text)?.[1]?.trim()
22
+ if (summary === undefined || summary.length === 0) return undefined
23
+ const recent = /<recent-context>\n?([\s\S]*?)\n?<\/recent-context>/.exec(text)?.[1]?.trim()
24
+ return recent === undefined || recent.length === 0 ? { summary } : { summary, recent }
25
+ }
26
+
27
+ /**
28
+ * 输入:插件上下文 + sessionID + 当前请求消息。
29
+ * 输出:是否收养了原生摘要。
30
+ * 预期:仅当插件还没有任何块时,把 OpenCode 自己的 checkpoint 摘要当作 b1:T1;
31
+ * 它没有原始 transcript,fromId/toId 指向 checkpoint 消息本身。
32
+ */
33
+ export async function adoptNativeCheckpoint(
34
+ ctx: Plugin.Context,
35
+ sessionID: string,
36
+ messages: readonly MessageLike[],
37
+ ): Promise<boolean> {
38
+ const state = await loadState(ctx.storage, sessionID)
39
+ if (state.blocks.length > 0 || state.pending !== undefined || state.terminal !== undefined) return false
40
+
41
+ const checkpoint = messages.findLast((message) => isCheckpoint(message))
42
+ if (checkpoint === undefined) return false
43
+ const parsed = extractCheckpoint(messageText(checkpoint))
44
+ if (parsed === undefined) return false
45
+
46
+ const summary = toMarkdownSummary(parsed.summary)
47
+ const block = createT1Block({
48
+ blocks: [],
49
+ summary,
50
+ // 原生摘要没有原始 transcript;空范围让 decompress full 返回 unavailable,而不是伪造原文。
51
+ fromId: "",
52
+ toId: "",
53
+ sourceChars: parsed.summary.length,
54
+ })
55
+ await saveState(ctx.storage, sessionID, { blocks: [block], attempts: 0 })
56
+ return true
57
+ }
@@ -0,0 +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
+ }