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.
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/package.json +64 -6
- package/src/compression/active-summary-cache.ts +50 -0
- package/src/compression/adopt.ts +57 -0
- package/src/compression/auto.ts +372 -0
- package/src/compression/babel.ts +85 -0
- package/src/compression/block.ts +25 -0
- package/src/compression/cache-history.ts +75 -0
- package/src/compression/compaction.ts +189 -0
- package/src/compression/decompress-tool.ts +74 -0
- package/src/compression/decompress.ts +292 -0
- package/src/compression/deleted-sessions.ts +24 -0
- package/src/compression/distill.ts +220 -0
- package/src/compression/events.ts +275 -0
- package/src/compression/markdown.ts +53 -0
- package/src/compression/normalize.ts +114 -0
- package/src/compression/render.ts +70 -0
- package/src/compression/schema.ts +84 -0
- package/src/compression/select.ts +123 -0
- package/src/compression/session-message.ts +112 -0
- package/src/compression/settings-rpc.ts +111 -0
- package/src/compression/settings.ts +175 -0
- package/src/compression/state.ts +248 -0
- package/src/compression/summarize.ts +595 -0
- package/src/compression/summary.ts +73 -0
- package/src/compression/tool.ts +142 -0
- package/src/compression/transcript-store.ts +258 -0
- package/src/compression/transcript.ts +63 -0
- package/src/compression/validate.ts +81 -0
- package/src/i18n/en.ts +93 -0
- package/src/i18n/index.ts +28 -0
- package/src/i18n/types.ts +89 -0
- package/src/i18n/zh.ts +93 -0
- package/src/index.ts +108 -0
- package/src/shared/blocks.ts +17 -0
- package/src/shared/cache.ts +199 -0
- package/src/shared/format.ts +23 -0
- package/src/shared/media.ts +184 -0
- package/src/shared/parse.ts +25 -0
- package/src/shared/parts.ts +72 -0
- package/src/shared/rpc.ts +145 -0
- package/src/shared/scan.ts +32 -0
- package/src/shared/settings.ts +471 -0
- package/src/shared/stats.ts +142 -0
- package/src/shared/tokens.ts +259 -0
- package/src/tui/chart/cache-chart.ts +237 -0
- package/src/tui/chart/color.ts +58 -0
- package/src/tui/index.tsx +111 -0
- package/src/tui/panel/cache.tsx +203 -0
- package/src/tui/panel/data.ts +105 -0
- package/src/tui/panel/index.tsx +176 -0
- package/src/tui/panel/summaries.tsx +192 -0
- package/src/tui/settings/dialogs.tsx +215 -0
- package/src/tui/settings/options.ts +238 -0
- package/src/tui/settings.tsx +418 -0
- 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
|
-
#
|
|
1
|
+
# compressed-context
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|