compressed-context 0.1.0 → 0.2.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/README.md +59 -51
  2. package/package.json +64 -64
  3. package/src/compression/active-summary-cache.ts +50 -50
  4. package/src/compression/adopt.ts +57 -57
  5. package/src/compression/auto.ts +386 -372
  6. package/src/compression/babel.ts +85 -85
  7. package/src/compression/block.ts +25 -25
  8. package/src/compression/cache-history.ts +75 -75
  9. package/src/compression/compaction.ts +189 -189
  10. package/src/compression/decompress-tool.ts +74 -74
  11. package/src/compression/decompress.ts +292 -292
  12. package/src/compression/deleted-sessions.ts +24 -24
  13. package/src/compression/distill.ts +220 -220
  14. package/src/compression/events.ts +275 -275
  15. package/src/compression/markdown.ts +53 -53
  16. package/src/compression/normalize.ts +114 -114
  17. package/src/compression/render.ts +70 -70
  18. package/src/compression/schema.ts +131 -84
  19. package/src/compression/select.ts +123 -123
  20. package/src/compression/session-message.ts +112 -112
  21. package/src/compression/settings-rpc.ts +123 -111
  22. package/src/compression/settings.ts +175 -175
  23. package/src/compression/shape.ts +75 -0
  24. package/src/compression/state.ts +248 -248
  25. package/src/compression/summarize.ts +534 -595
  26. package/src/compression/summary.ts +73 -73
  27. package/src/compression/tool.ts +184 -142
  28. package/src/compression/transcript-store.ts +258 -258
  29. package/src/compression/transcript.ts +63 -63
  30. package/src/compression/validate.ts +81 -81
  31. package/src/i18n/en.ts +93 -93
  32. package/src/i18n/index.ts +28 -28
  33. package/src/i18n/types.ts +89 -89
  34. package/src/i18n/zh.ts +93 -93
  35. package/src/index.ts +116 -108
  36. package/src/shared/blocks.ts +17 -17
  37. package/src/shared/cache.ts +199 -199
  38. package/src/shared/format.ts +23 -23
  39. package/src/shared/media.ts +184 -184
  40. package/src/shared/parse.ts +25 -25
  41. package/src/shared/parts.ts +72 -72
  42. package/src/shared/rpc.ts +145 -145
  43. package/src/shared/scan.ts +32 -32
  44. package/src/shared/settings.ts +466 -471
  45. package/src/shared/stats.ts +142 -142
  46. package/src/shared/tokens.ts +259 -259
  47. package/src/tui/chart/cache-chart.ts +237 -237
  48. package/src/tui/chart/color.ts +58 -58
  49. package/src/tui/index.tsx +109 -111
  50. package/src/tui/panel/cache.tsx +203 -203
  51. package/src/tui/panel/data.ts +105 -105
  52. package/src/tui/panel/index.tsx +176 -176
  53. package/src/tui/panel/summaries.tsx +203 -192
  54. package/src/tui/settings/dialogs.tsx +215 -215
  55. package/src/tui/settings/options.ts +238 -238
  56. package/src/tui/settings.tsx +418 -418
  57. package/src/tui/types.ts +4 -4
package/README.md CHANGED
@@ -1,89 +1,97 @@
1
1
  # compressed-context
2
2
 
3
- OpenCode 的上下文压缩插件:把变长的旧历史折叠成自包含摘要块,让长会话在有限窗口里继续跑下去。
3
+ OpenCode 的上下文压缩插件。它会将较早的对话历史折叠成独立的摘要块,让长会话在有限的上下文窗口里继续跑下去。
4
4
 
5
- ## 功能
5
+ ![compressed-context TUI 面板](docs/panel.png)
6
6
 
7
- - **自动压缩**:上下文占用达到阈值时自动入队,工具循环里的后续 step 也能触发
8
- - **手动压缩**:模型可主动调用 `compress` 工具,在任意时刻打检查点
9
- - **分层摘要**:T1 压缩当前对话流;T2 / T3 合并相邻摘要,层级单调递减
10
- - **解压**:`decompress` 还原某个块覆盖的原始消息,可只还原下一层或展开到原文
11
- - **缓存友好**:压缩请求保持前缀不变,尽量减少缓存未命中
12
- - **摘要格式**:Markdown(默认)或 Babel(首行 `T=标题|G=目标`,正文自由高密度)
13
- - **主动提醒**:上下文增长超过设定值时,在请求尾部追加一次提醒,让模型自己决定何时压缩
14
- - **TUI 面板**:查看摘要块、缓存命中率、上下文构成,设置项在插件设置里直接改
7
+ ## 架构与工作原理
15
8
 
16
- ## 安装
9
+ 插件接管发给模型的上下文:把较早的对话折叠成摘要块,让长会话继续跑下去:
17
10
 
18
- 插件通过 npm 分发,OpenCode 会在启动时自行安装。
11
+ ```text
12
+ 原始上下文:
13
+ [ 系统提示词 ][ 早期历史对话 ............................. ][ 最近活跃对话 ]
14
+ ↓ 触发压缩 (T1)
15
+ 一级压缩后:
16
+ [ 系统提示词 ][ T1 摘要块 #1 ][ T1 摘要块 #2 ] ............ [ 最近活跃对话 ]
17
+ ↓ 摘要堆积达到阈值 (T2/T3)
18
+ 分层合并后:
19
+ [ 系统提示词 ][ T2 合并摘要 (块#1+#2) ][ T1 摘要块 #3 ] .... [ 最近活跃对话 ]
20
+ ```
19
21
 
20
- ### 方式一:npm 包名(推荐)
22
+ - **递归分层折叠**:
23
+ - **T1 级**:将溢出的原始对话流折叠为自包含检查点(原始消息移入持久化存储,释放窗口)。
24
+ - **T2 / T3 级**:当摘要块自身占用接近预算上限时,自动在背景对相邻摘要进行高阶合并(`T1 + T1 → T2`,`T2 + T2 → T3`)。
25
+ - **无损按需回溯**:模型可在需要时通过 `decompress` 按块还原历史,支持只解压下一层级摘要或直接恢复原始对话流。
21
26
 
22
- 在 OpenCode 配置里加入 `plugin` 数组(全局配置 `~/.config/opencode/opencode.json`,或项目内 `opencode.json`):
27
+ ## 核心特性
23
28
 
24
- ```json
25
- {
26
- "plugin": ["compressed-context"]
27
- }
28
- ```
29
+ - **自动压缩**:上下文达到阈值时自动触发(工具调用循环中的子步骤同样生效)。
30
+ - **主动压缩**:向模型暴露 `compress` 工具,允许模型在阶段性任务完成后主动打检查点。
31
+ - **多级合并**:T1 负责压缩原始对话;当摘要本身积攒过多时,会自动向上合并为 T2、T3 高阶摘要。
32
+ - **按需解压**:模型可通过 `decompress` 工具还原指定摘要块(支持逐层展开或直接恢复为原始对话)。
33
+ - **缓存友好**:压缩请求尽量保持前缀不变,避免破坏 Prompt Cache。
34
+ - **双摘要格式**:支持标准 `markdown`(默认)和极致压缩的 `babel` 格式(首行 `T=标题|G=目标`,正文为高密度非自然语言)。
35
+ - **超长提醒**:上下文增长超过设定阈值时,在请求尾部追加轻量提醒,引导模型自主决定压缩时机。
36
+ - **TUI 面板**:直观查看摘要块、缓存命中率与上下文构成,支持在面板内直接修改配置。
37
+
38
+ ## 安装与配置
29
39
 
30
- ### 方式二:本地路径
40
+ 插件通过 npm 分发,OpenCode 启动时会自动安装。
31
41
 
32
- 从源码使用或自己改代码时,直接指向仓库目录:
42
+ 由于本插件会接管上下文管理,**建议同时关闭 OpenCode 原生的自动压缩**,避免两套策略打架。在全局配置 `~/.config/opencode/opencode.json`(或项目级 `opencode.json`)中加入:
33
43
 
34
44
  ```json
35
45
  {
36
- "plugin": ["/absolute/path/to/compressed-context"]
46
+ "plugin": ["compressed-context"],
47
+ "compaction": { "auto": false }
37
48
  }
38
49
  ```
39
50
 
40
- ### 配置建议
41
-
42
- 插件会接管上下文压缩,建议关掉 OpenCode 原生自动压缩,避免两套策略互相干扰:
51
+ **本地开发调试**:如果你拉取了源码自行修改,直接填入本地仓库的绝对路径即可:
43
52
 
44
53
  ```json
45
54
  {
46
- "plugin": ["compressed-context"],
55
+ "plugin": ["/absolute/path/to/compressed-context"],
47
56
  "compaction": { "auto": false }
48
57
  }
49
58
  ```
50
59
 
51
60
  ## 配置项
52
61
 
53
- 都能在 TUI 的插件设置里改,也可以通过设置接口写入:
62
+ 所有配置均可直接在 TUI 的插件设置面板中修改,或通过配置文件写入:
54
63
 
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` 工具 |
64
+ | 配置项 | 默认值 | 说明 |
65
+ | ---------------------- | ------------------ | -------------------------------------------------------------- |
66
+ | `contextLimit` | `"auto"` | 上下文窗口上限。填具体数字时,实际取该值与模型原生窗口的较小值 |
67
+ | `triggerPercent` | `80` | 上下文占用达到百分之多少时触发自动压缩 |
68
+ | `autoCompress` | `true` | 是否开启自动压缩 |
69
+ | `summaryBlockPercent` | `10` | 单个摘要块的 Token 预算(占窗口的百分比) |
70
+ | `summaryTotalPercent` | `50` | 所有摘要的总 Token 预算占比,超出后触发 T2 / T3 合并 |
71
+ | `summaryFormat` | `"markdown"` | 摘要格式:`markdown` 或 `babel` |
72
+ | `summaryLanguage` | `"auto"` | 摘要语言(`babel` 格式下忽略此项) |
73
+ | `nudgeGrowthTokens` | `50000` | 触发“主动压缩提醒”的 Token 增长阈值,设为 `0` 则关闭 |
74
+ | `enableCompressTool` | `true` | 是否允许模型调用 `compress` 工具 |
75
+ | `enableDecompressTool` | `true` | 是否允许模型调用 `decompress` 工具 |
67
76
 
68
- ## 开发
77
+ ## 本地开发
69
78
 
70
79
  ```bash
71
80
  pnpm install
72
- pnpm check # typecheck + lint + 单测 + TUI 测试
73
- bun scripts/e2e-nudge.mjs # 隔离环境 + 假 provider 的提醒 e2e
74
- bun scripts/e2e-tool-loop.mjs # 工具循环里的自动压缩 e2e
81
+ pnpm check # 运行 typecheck + lint + 单测 + TUI 测试
82
+ bun scripts/e2e-nudge.mjs # E2E 测试:主动提醒(隔离环境 + Mock Provider)
83
+ bun scripts/e2e-tool-loop.mjs # E2E 测试:工具循环内的自动压缩
75
84
  ```
76
85
 
77
- ## 基准
86
+ ## 效果参考
78
87
 
79
- 在 Terminal-Bench 的 `schemelike-metacircular-eval` 上做过一次粗略对比:100k 窗口下插件把单步上下文
80
- 压在 93,560 并让该次运行通过验证。**只跑了一次,波动很大,只能当参考**,详见
81
- [docs/benchmark.md](docs/benchmark.md)。
88
+ 在 Terminal-Bench 的 `schemelike-metacircular-eval` 任务上做过初步实测:在 100k 窗口限制下,插件成功将单步上下文峰值压在 93,560 tokens 并顺利通过验证。由于单次测试存在随机波动,数据仅供参考,详细记录见 [docs/benchmark.md](docs/benchmark.md)。
82
89
 
83
- ## 协议
90
+ ## 致谢与灵感
84
91
 
85
- [MIT](LICENSE) © 1cyberlangke1
92
+ - [billion-context](https://github.com/ranxianglei/billion-context)
93
+ - [Large Language Models Do Not Always Need Readable Language](https://arxiv.org/abs/2606.19857) —— Babel 摘要格式(BabelTele)的灵感来源
86
94
 
87
- ## 灵感
95
+ ## 协议
88
96
 
89
- - [billion-context](https://github.com/ranxianglei/billion-context)
97
+ [MIT](LICENSE) © 1cyberlangke1
package/package.json CHANGED
@@ -1,64 +1,64 @@
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
- }
1
+ {
2
+ "name": "compressed-context",
3
+ "version": "0.2.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.1.2",
50
+ "@opencode/theme": "2.1.2",
51
+ "@opentui/core": "0.6.6",
52
+ "@opentui/solid": "0.6.6",
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
+ }
@@ -1,50 +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
- }
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
+ }
@@ -1,57 +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
- }
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
+ }