dsh-context-fold 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 niechao136
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,250 @@
1
- # Temporary Holding Version
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.
1
+ # dsh-context-fold
2
+
3
+ > **Not an official DeepSeek product.** 第三方 DSH 插件,与 DeepSeek 官方无关。
4
+
5
+ 阶段边界的 tool 结果折叠:**结论留在窗口里,正文落到 spill 文件,一跳可达。**
6
+ 第二阶段再把历史 reasoning 折叠掉(见下文「第二阶段」)。
7
+
8
+ 长会话里最贵的东西不是历史,是「已经用完的正文还在每一步重发」。这个插件在阶段边界
9
+ (回合结束,或单回合里结果已经够旧)把已完成的 tool 结果换成 `结论 + spill 路径 +
10
+ 同批清单`,通过 DSH 自带的替换协议落盘,因此可回放、可审计、不会破坏 tool-call 配对。
11
+
12
+ - **规则驱动,不调模型。** 同一份日志 + 同一份配置必然得到同一个结论。
13
+ - **结论内联,指针次要。** 永远不是一个裸指针:结论写在一行里,路径只是细节入口。
14
+ - **错误结果永不折叠。** 错误文本是窗口里价值最高的内容。
15
+ - **不新增召回工具。** `read` / `grep` 就是召回原语,spill 存储本来就返回检索提示。
16
+ - **先量后信。** `dryRun` 只报告候选与阈值,不改任何东西;`/context-fold` 从日志里
17
+ 重新算出折叠量、省下多少字符、以及被重新读了几次。
18
+
19
+ ## 它折叠什么
20
+
21
+ 候选是一个**当前 surface 上的 `tool/result` 节点**,必须同时满足:
22
+
23
+ | 条件 | 意图 |
24
+ | --- | --- |
25
+ | `node.turn < currentTurn` 或 `currentStep - node.step >= staleSteps` | 已经「完成」:回合结束,或单回合里够旧 |
26
+ | `node.step > currentStep - keepRecentSteps` 不成立 | 最近几步还在用,不动 |
27
+ | 不是错误结果 | 错误永不折叠 |
28
+ | 未被更晚的事件引用(`callId` 出现在后续 tool 参数或消息文本里) | 还在被引用就不动 |
29
+ | `chars >= minFoldChars`,且结论确实比原文短 | 折叠必须有净收益 |
30
+ | 文本节点尚未带折叠标记 | 幂等:折过的不会再折 |
31
+
32
+ 候选按 `turn` 分批;一个回合超过 `batchSpanSteps` 步就按连续 step 区间切成多批。
33
+ 只有当一批候选的原始字符数达到 `minBatchChars` 才真正动手 —— 一次前缀缓存失效,
34
+ 换一整批的收益。
35
+
36
+ ## 折叠后的样子
37
+
38
+ 批头节点带整批清单,其余节点只带一行回指(总开销 O(n) 而不是 O(n²)):
39
+
40
+ ```
41
+ [context-fold] [tool: ops_grep] 73,447 chars folded at turn 1, step 12. (batch head)
42
+ Conclusion: pattern "surfaceOp" — 47 matches across 12 files; first "src/a.ts:12"
43
+ Full result: /home/me/.dsh/spill/session-abc/9f8e7d6c5b4a-ops_grep.txt
44
+ Same batch (1 more):
45
+ - ops_bash (callId a1b2): /home/me/.dsh/spill/session-abc/1c2d3e4f5a6b-ops_bash.txt
46
+ ```
47
+
48
+ ```
49
+ [context-fold] [tool: ops_bash] 6,266 chars folded at turn 1, step 12.
50
+ Conclusion: exit 0; first "=== deps ==="; 41 lines, 6,266 chars
51
+ Full result: /home/me/.dsh/spill/session-abc/1c2d3e4f5a6b-ops_bash.txt
52
+ Batch head: a0f9 (callId) — lists 1 more result from this batch.
53
+ ```
54
+
55
+ 结论由工具族决定(`src/rules.ts`):
56
+
57
+ | 工具族 | 结论里保留 |
58
+ | --- | --- |
59
+ | `ops_bash`、`pwsh`、`bash`、`wsl` | exit code、stdout 首行、stderr 末行、行数与字符数 |
60
+ | `read`、`ops_read_files`、`wsl-read`… | 路径、行范围、首个内容行 |
61
+ | `grep`、`glob`、`ops_grep`、`ops_glob`… | pattern、命中行数、匹配到的文件 |
62
+ | `cordis_inspect_*` | 小节标题,丢掉声明正文 |
63
+ | `web_fetch`、`web_search`… | URL、首段 |
64
+ | 其他 | 首行 + 行数字符数 |
65
+
66
+ ## 安装
67
+
68
+ ```yaml
69
+ # profile 的 cordis.patch.yml
70
+ - id: context-fold
71
+ name: dsh-context-fold
72
+ config:
73
+ minFoldChars: 1000 # 小于这个字符数的结果不折叠(下限 300,低于它没有净收益)
74
+ minBatchChars: 8000 # 一批候选合计达到这个量才动手
75
+ keepRecentSteps: 6 # 最近 N 步永不折叠
76
+ staleSteps: 12 # 单回合内的年龄阈值
77
+ batchSpanSteps: 20 # 超长回合按连续 step 区间切批
78
+ foldReasoning: true # 折叠历史 tool-call 回合的 reasoning(第二阶段)
79
+ foldPlainTurnReasoning: false # 额外折叠普通回合;router 路由应设为 true
80
+ enabled: true
81
+ dryRun: false # 只算不改,候选与阈值写到日志
82
+ ```
83
+
84
+ 插件需要 `ctx.spillStore` 与 `ctx.tokenMeter`;缺 `spillStore` 时**整体不动**并打一条
85
+ warning(折叠后原文不可达就是有损,那违反这个插件的承诺)。
86
+
87
+ ## 报告
88
+
89
+ ```
90
+ /context-fold
91
+ ```
92
+
93
+ 从日志重算,不依赖插件内部状态:
94
+
95
+ ```
96
+ [context-fold] 2 folded node(s) on the surface.
97
+ Characters removed: 12,345
98
+ Re-read signals: 1 across 1 folded node(s)
99
+
100
+ | seq | turn.step | tool | before | after | re-reads |
101
+ | --- | --- | --- | ---: | ---: | ---: |
102
+ | 12 | 1.4 | ops_grep | 73447 | 412 | 1 |
103
+ | 18 | 1.9 | ops_bash | 6266 | 380 | 0 |
104
+ ```
105
+
106
+ 「被重新读了几次」是反目标(折叠反而让模型重读)的直接度量,也是这个插件值不值得继续
107
+ 加码的唯一证据。
108
+
109
+ ## 协议与安全
110
+
111
+ 替换严格复用 `dsh-compaction-tool-result-pruner` 的形状:先 `compaction/prune` 记下被遮蔽
112
+ 节点的影子价格,再追加一条只改 `content` 的 `tool/result` 替换,`sourceEventSeqs` 指回原
113
+ 节点。宿主 `assertToolResultRewrite` 强制「只能改 content」,所以 `toolCallId`、`isError`、
114
+ `turn`、`step`、`error`、`meta` 全部逐字节保留 —— tool-call 配对不可能被破坏。
115
+
116
+ - 原文永远在日志里(只追加),`sourceEventSeqs` 让回放拿得回精确输入。
117
+ - 折叠标记写在文本里而不是消息字段里,因为替换不允许改字段。
118
+ - 单节点替换失败只影响该节点,同批已经落地的替换保持有效,不重试整批。
119
+ - spill 失败则该节点不折叠,保持原样内联。
120
+
121
+ ### 已知限制
122
+
123
+ - 只按 `callId` / 工具名做引用检测。模型若用完全相同的参数**重跑原工具**来重读,
124
+ 这里检测不到(设计文档 §6.2 记录的已知缺口);re-read 指标专门用来暴露它。
125
+ - 图片与文件块不折叠:占位文本不是内容本身,phase 1 不拿附件换一句话。
126
+ - 不折叠 assistant 文本、user 消息、system prompt。
127
+ - reasoning 折叠走**另一套机制**(消息投影,见下节),原文只留在日志里,不落 spill 文件。
128
+ - UI 是设计文档的 phase 3,本包不含。
129
+
130
+ ## 第二阶段:reasoning 折叠
131
+
132
+ 助手思考过程是窗口里第二贵的东西。以下事实都是实测/读代码确认的,不是推测。
133
+
134
+ ### 为什么不用 phase 1 的替换协议
135
+
136
+ **`assistant/message` 根本不能做 surface 替换**:宿主强制替换必须引用所有被遮蔽节点,
137
+ 但 assistant 消息「embeds its source stream」不允许带 `sourceEventSeqs`。
138
+ 不是难,是结构上不可能。
139
+
140
+ ### 替代机制:消息投影 + plugin record
141
+
142
+ 宿主官方给的是**消息投影**(`ctx.sessions.registerMessageProjection`):追加一条持久
143
+ 决策记录,在 `deriveMessages()`(也就是组请求的消息来源)时把 reasoning 换成
144
+ `240 字原文开头 + 省略说明`。日志一个字节不改。
145
+
146
+ **一个必须说清的关键修正**:持久记录必须用 `appendPluginRecord`,**不能用** `session.append`。
147
+
148
+ `dsh-compaction-image-offload` 不是「自己发明事件类型」的先例——`image/offload` 是
149
+ harness **核心**事件(在 `KNOWN_SESSION_EVENT_TYPES` 里),下游插件无法加入那个集合,
150
+ `SessionEventMap` 增强只影响 TypeScript。
151
+
152
+ 而 `session.append` 写出的信封只有 `{type, seq, time, data}`,没有 `ignorable`;持久化
153
+ 读取路径会因此拒绝整条日志:
154
+
155
+ > "contains event type … unknown to this harness and not marked ignorable; refusing to interpret the log"
156
+
157
+ 也就是说,**用户只要折过一次 reasoning,会话就再也打不开**——正常使用就会丢光对话。
158
+ 这个缺陷在第一版实现里真实存在过。`appendPluginRecord` 会打上 `ignorable: true`,
159
+ 老版本读取器会保留并跳过该记录,而 `reasoningProjection` 依然能从它驱动
160
+ `deriveMessages()`(已端到端验证)。
161
+
162
+ ### 规则
163
+
164
+ - 规则驱动,零模型调用;带标记的块永不二次折叠。
165
+ - `MIN_REASONING_CHARS = 1200`(低于它标记本身比正文还长),保留原文开头 240 字
166
+ ——是真前缀,不发明任何内容。
167
+ - 沿用 phase 1 的年龄与「最近 N 步不动」规则。
168
+ - 决策记录**自描述**:存的是内容索引,不是「折几个」的计数,所以回放不依赖位置重推。
169
+
170
+ ### 按路由决定折哪些回合
171
+
172
+ 哪些回合的 reasoning 会真的重发,取决于序列化器:
173
+
174
+ | 路由 | 普通回合的 reasoning | 默认 |
175
+ | --- | --- | --- |
176
+ | `dsh-llm-deepseek` | 丢弃(只在有 tool_calls 时发) | 折了没有收益 |
177
+ | `dsh-router-core`(本 profile 实际在跑) | **无条件发送** | 折了是真省钱 |
178
+
179
+ 所以:`foldReasoning`(默认开)折 tool-call 回合;`foldPlainTurnReasoning`(默认关)
180
+ 额外折普通回合——在 router 路由上应当打开。
181
+
182
+ ### 失败策略
183
+
184
+ 投影对**过期目标宽容**:compaction 可能遮蔽决策点名的节点,此时抛错会让之后每一次
185
+ `deriveMessages()` 都失败。所以过期目标跳过而非报错;记录本身形状非法也只是一条无效
186
+ 记录(不影响派生),因为**已经落盘的日志不能因为新版本校验变严就变得读不出来**。
187
+
188
+ 投影**即使 `foldReasoning: false` 也注册**:开关只该管**新**决策,日志里已有决策的
189
+ 会话应当继续享受它已经被计费的收益。实测也确认:没有投影时不报错,`deriveMessages()`
190
+ 退回未折叠的原文(4000 字 vs 332 字)。宿主同一事件类型只允许注册一次,重复注册会被
191
+ 捕获并告警,不会让 `apply()` 抛错。
192
+
193
+ ### 已知缺口
194
+
195
+ **与 `compaction-basic` 的交互**:compaction 遮蔽 assistant 节点会让 reasoning 目标 seq
196
+ 过期并被跳过(这是设计上的宽容,但还没有用真实 `/compact` 验证过)。
197
+
198
+ ### 召回路径(已补齐)
199
+
200
+ reasoning 折叠时会同步把**原文**写进 spill,并把路径内联进标记:
201
+
202
+ ```
203
+ [context-fold] [reasoning] 9,000 chars folded.
204
+ <240 字原文开头>
205
+ [… 8,760 chars of earlier reasoning omitted] Full text: /spill/…/reasoning-12-0.txt — use read (offset/limit) or grep on that path.
206
+ ```
207
+
208
+ 所以它和 tool 结果折叠共用同一条召回链,回放后路径依然在。spill 失败不阻塞折叠
209
+ (标记仍准确,只是没有提示),这与 phase 1 对 tool 结果的处理一致。
210
+
211
+ ## 会话内标签页
212
+
213
+ 会话里多一个 `conversation.view` 标签页(和 `dsh-context` 用同一个注册点,order 40 排在
214
+ 它后面)。它显示:
215
+
216
+ - **被折叠的内容**:每条折叠的类型、turn.step、字符数变化、是否可恢复;展开时**懒加载**
217
+ 原文(折叠的意义就是这段文本很贵,不能预先加载)。
218
+ - **token 对比(估算)**:折叠后占用 / 如果不折会占用 / 每步省的 token / 前缀失效代价 /
219
+ 净收益,并明确标注这是估算。
220
+
221
+ ### 为什么是估算,以及它修正了什么
222
+
223
+ 实测四份真实会话:**972 个 `assistant/message` 一个 usage 字段都没有**。token 与缓存数据
224
+ 来自 `dsh-token-meter` 的投影,不在日志的消息事件上。
225
+
226
+ 而「启用前」永远不会发生——历史会话不是在关闭插件时跑的,也不会重跑。所以这是**重放
227
+ token 算术**,不是测量差值。它修正了两处朴素估算会算错的地方:
228
+
229
+ 1. **省下的 token 本该按“缓存读”计价**(约 0.1×),不是按全价。一个热会话缓存命中率
230
+ 约 95%,所以真实收益比 `token × 步数` 低一个数量级。
231
+ 2. **一次折叠会让前缀从被遮蔽处失效**,之后整段 prompt 重新按全价计一次。所以折叠有
232
+ 一次性成本,**折得太晚就是净亏**——面板会直接显示负数。
233
+
234
+ 面板还会说明这个会话到底有没有报告过真实 usage,因为用启发式算出来的数字不该看起来
235
+ 像账单。
236
+
237
+ ## 开发
238
+
239
+ ```bash
240
+ pnpm typecheck # tsc --noEmit
241
+ pnpm test # vitest run
242
+ pnpm build # tsdown -> lib/
243
+ pnpm check # 三者依次
244
+ ```
245
+
246
+ 设计文档:[`docs/specs/2026-11-12-context-fold-design.md`](docs/specs/2026-11-12-context-fold-design.md)。
247
+
248
+ ## 许可
249
+
250
+ MIT。
@@ -0,0 +1,6 @@
1
+ # dsh-context-fold bundle layer: the plugin needs no configuration to start, so this patch only
2
+ # inserts the row. Per-session tuning lives in the profile's own cordis.patch.yml, under this entry's
3
+ # `config` (see README §Configuration).
4
+ - insert:
5
+ - id: context-fold
6
+ name: dsh-context-fold
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
2
+ <path d="M4 6h16" />
3
+ <path d="M4 12h16" />
4
+ <path d="M4 18h7" />
5
+ <path d="M15 16l2 2 4-4" />
6
+ </svg>
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `conversation.view` tab: what this plugin folded, what it saved, and the full
3
+ * text behind each fold.
4
+ *
5
+ * ## Why the page reads from the server route, not the session log
6
+ *
7
+ * The browser client cannot walk a session log: the fold decisions are durable
8
+ * plugin records, and the full original reasoning lives in the log's assistant
9
+ * events. Both are server-side. The tab therefore renders exactly what
10
+ * `/api/context-fold/report` returns and does no derivation of its own.
11
+ *
12
+ * ## What it shows, and how honestly
13
+ *
14
+ * The token comparison is a **counterfactual estimate**, not a measured A/B.
15
+ * The "before" case never ran. The panel says so in the UI, lists the
16
+ * assumptions the number depends on, and marks whether the session reported real
17
+ * provider usage at all — because a number built on heuristics must not look
18
+ * like a bill.
19
+ *
20
+ * The original text of every fold is shown on demand, fetched by its recovery
21
+ * path, so the tab doubles as the answer to "what exactly did it remove".
22
+ *
23
+ * @module dsh-context-fold/client
24
+ */
25
+ /** One fold row as the server reports it. */
26
+ interface FoldRow {
27
+ /** Surface seq. */
28
+ seq: number;
29
+ /** `tool-result` or `reasoning`. */
30
+ kind: 'tool-result' | 'reasoning';
31
+ /** Turn it belonged to. */
32
+ turn: number;
33
+ /** Step it belonged to. */
34
+ step: number;
35
+ /** Tool name, for tool folds. */
36
+ toolName?: string;
37
+ /** Characters before folding. */
38
+ charsBefore: number;
39
+ /** Characters after folding. */
40
+ charsAfter: number;
41
+ /** Estimated tokens before. */
42
+ tokensBefore: number;
43
+ /** Estimated tokens after. */
44
+ tokensAfter: number;
45
+ /** Spill path holding the full original, when one was written. */
46
+ locator?: string;
47
+ /** The conclusion currently inline. */
48
+ inline: string;
49
+ /** Whether the full original is still retrievable. */
50
+ recoverable: boolean;
51
+ }
52
+ /** The server's report payload. */
53
+ interface FoldReportPayload {
54
+ /** Every fold on the current surface. */
55
+ folds: FoldRow[];
56
+ /** The counterfactual comparison. */
57
+ counterfactual: {
58
+ tokensNow: number;
59
+ tokensIfUnfolded: number;
60
+ tokensSavedPerStep: number;
61
+ remainingSteps: number;
62
+ grossSaving: number;
63
+ invalidationCost: number;
64
+ netSaving: number;
65
+ actualPromptCost: number;
66
+ counterfactualPromptCost: number;
67
+ hasRealUsage: boolean;
68
+ cacheReadShare?: number;
69
+ assumptions: string[];
70
+ };
71
+ /** Re-read signals observed after each fold. */
72
+ reReads: number;
73
+ }
74
+ /** Minimal cordis client shape this plugin touches. */
75
+ interface ClientContext {
76
+ slots: {
77
+ inject(name: string, register: () => unknown): unknown;
78
+ register(descriptor: Record<string, unknown>, render: (props: unknown) => unknown): unknown;
79
+ };
80
+ locale: {
81
+ get(key: string): string;
82
+ };
83
+ }
84
+ /** Client plugin name. */
85
+ declare const name = "context-fold-client";
86
+ /** The tab needs the slot registry and the locale service. */
87
+ declare const inject: string[];
88
+ /**
89
+ * Mount the conversation tab.
90
+ *
91
+ * @param ctx - client plugin context.
92
+ */
93
+ declare function apply(ctx: ClientContext): void;
94
+ export { FoldReportPayload, FoldRow, apply, inject, name };
package/lib/client.js ADDED
@@ -0,0 +1,192 @@
1
+ // src/client.ts
2
+ /** Fetch the report for one session. */
3
+ async function fetchReport(sessionId) {
4
+ const response = await fetch("/api/context-fold/report", {
5
+ method: "POST",
6
+ headers: { "content-type": "application/json" },
7
+ body: JSON.stringify({ sessionId })
8
+ });
9
+ if (!response.ok) return void 0;
10
+ const body = await response.json();
11
+ return body.ok === true ? body.value : void 0;
12
+ }
13
+ /** Fetch the full text behind one fold. */
14
+ async function fetchOriginal(sessionId, locator) {
15
+ const response = await fetch("/api/context-fold/original", {
16
+ method: "POST",
17
+ headers: { "content-type": "application/json" },
18
+ body: JSON.stringify({
19
+ sessionId,
20
+ locator
21
+ })
22
+ });
23
+ if (!response.ok) return void 0;
24
+ const body = await response.json();
25
+ return body.ok === true ? body.value?.text : void 0;
26
+ }
27
+ /** Format a number for display. */
28
+ function num(value) {
29
+ return value.toLocaleString("en-US", { maximumFractionDigits: 1 });
30
+ }
31
+ /** A signed figure with an explicit sign, so a loss never looks like a gain. */
32
+ function signed(value) {
33
+ const rounded = Math.round(value * 10) / 10;
34
+ return `${rounded > 0 ? "+" : ""}${num(rounded)}`;
35
+ }
36
+ /**
37
+ * Render the tab body.
38
+ *
39
+ * Deliberately plain DOM (no framework import): the plugin's render prop may be
40
+ * called from any client runtime, and depending on a specific React bundle would
41
+ * couple this package to one shell version.
42
+ *
43
+ * @param props - slot props carrying the active session id.
44
+ * @returns an element or a node tree.
45
+ */
46
+ function renderTab(props) {
47
+ const sessionId = props?.sessionId;
48
+ const root = document.createElement("div");
49
+ root.className = "context-fold-tab";
50
+ root.style.cssText = "padding:12px;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;overflow:auto;height:100%";
51
+ const title = document.createElement("div");
52
+ title.textContent = "Context fold";
53
+ title.style.cssText = "font-weight:600;margin-bottom:8px";
54
+ root.append(title);
55
+ const status = document.createElement("div");
56
+ status.style.cssText = "color:#888";
57
+ status.textContent = sessionId === void 0 ? "No active session." : "Loading…";
58
+ root.append(status);
59
+ if (sessionId !== void 0) fetchReport(sessionId).then((report) => {
60
+ root.replaceChildren(title);
61
+ if (report === void 0) {
62
+ status.textContent = "No report available for this session.";
63
+ root.append(status);
64
+ return;
65
+ }
66
+ root.append(renderSummary(report), renderFolds(sessionId, report));
67
+ });
68
+ return root;
69
+ }
70
+ /** The counterfactual summary block. */
71
+ function renderSummary(report) {
72
+ const cf = report.counterfactual;
73
+ const box = document.createElement("section");
74
+ box.style.cssText = "margin-bottom:12px;padding:8px;border:1px solid #333;border-radius:6px";
75
+ const heading = document.createElement("div");
76
+ heading.textContent = "Token comparison (estimate)";
77
+ heading.style.cssText = "font-weight:600;margin-bottom:6px";
78
+ const rows = [
79
+ ["Folded content, now", `${num(cf.tokensNow)} tokens`],
80
+ ["If it had stayed unfolded", `${num(cf.tokensIfUnfolded)} tokens`],
81
+ ["Prompt reduction per step", `${num(cf.tokensSavedPerStep)} tokens`],
82
+ ["Steps still paying the folded prompt", num(cf.remainingSteps)],
83
+ ["Gross saving", signed(cf.grossSaving)],
84
+ ["Prefix invalidation cost", signed(-cf.invalidationCost)],
85
+ ["Net", signed(cf.netSaving)]
86
+ ];
87
+ const table = document.createElement("table");
88
+ table.style.cssText = "border-collapse:collapse";
89
+ for (const [label, value] of rows) {
90
+ const tr = document.createElement("tr");
91
+ const th = document.createElement("td");
92
+ th.textContent = label;
93
+ th.style.cssText = "padding:1px 12px 1px 0;color:#aaa";
94
+ const td = document.createElement("td");
95
+ td.textContent = value;
96
+ if (label === "Net") td.style.cssText = `font-weight:600;color:${cf.netSaving >= 0 ? "#4ade80" : "#f87171"}`;
97
+ tr.append(th, td);
98
+ table.append(tr);
99
+ }
100
+ const freshness = document.createElement("div");
101
+ freshness.style.cssText = "margin-top:6px;color:#facc15";
102
+ freshness.textContent = cf.hasRealUsage ? `Priced against this session's real usage${cf.cacheReadShare === void 0 ? "" : ` (cache-read share ${(cf.cacheReadShare * 100).toFixed(1)}%)`}.` : "This session reported NO provider usage — every figure here is heuristic token counts, not a bill.";
103
+ const assumptions = document.createElement("details");
104
+ const summary = document.createElement("summary");
105
+ summary.textContent = "Assumptions";
106
+ summary.style.cssText = "cursor:pointer;color:#888;margin-top:6px";
107
+ assumptions.append(summary);
108
+ const list = document.createElement("ul");
109
+ list.style.cssText = "margin:4px 0 0 16px;padding:0;color:#999";
110
+ for (const assumption of cf.assumptions) {
111
+ const item = document.createElement("li");
112
+ item.textContent = assumption;
113
+ list.append(item);
114
+ }
115
+ assumptions.append(list);
116
+ box.append(heading, table, freshness, assumptions);
117
+ if (report.reReads > 0) {
118
+ const reread = document.createElement("div");
119
+ reread.style.cssText = "margin-top:6px;color:#f87171";
120
+ reread.textContent = `Re-read signals: ${String(report.reReads)} — folded content was fetched again after being folded.`;
121
+ box.append(reread);
122
+ }
123
+ return box;
124
+ }
125
+ /** The per-fold list, with the original text expandable. */
126
+ function renderFolds(sessionId, report) {
127
+ const section = document.createElement("section");
128
+ const heading = document.createElement("div");
129
+ heading.textContent = `Folded content (${String(report.folds.length)})`;
130
+ heading.style.cssText = "font-weight:600;margin-bottom:6px";
131
+ section.append(heading);
132
+ if (report.folds.length === 0) {
133
+ const empty = document.createElement("div");
134
+ empty.style.cssText = "color:#888";
135
+ empty.textContent = "Nothing has been folded in this session yet.";
136
+ section.append(empty);
137
+ return section;
138
+ }
139
+ for (const fold of report.folds) {
140
+ const item = document.createElement("details");
141
+ item.style.cssText = "margin-bottom:6px;padding:6px;border:1px solid #2a2a2a;border-radius:6px";
142
+ const summary = document.createElement("summary");
143
+ summary.style.cssText = "cursor:pointer";
144
+ summary.textContent = `${fold.kind === "reasoning" ? "reasoning" : fold.toolName ?? "tool"} · turn ${String(fold.turn)}.${String(fold.step)} · ${num(fold.charsBefore)} → ${num(fold.charsAfter)} chars · ${fold.recoverable ? "recoverable" : "NOT recoverable"}`;
145
+ item.append(summary);
146
+ const inline = document.createElement("pre");
147
+ inline.textContent = fold.inline;
148
+ inline.style.cssText = "white-space:pre-wrap;margin:6px 0;color:#bbb;max-height:12em;overflow:auto";
149
+ item.append(inline);
150
+ if (fold.locator !== void 0) {
151
+ const path = document.createElement("div");
152
+ path.textContent = fold.locator;
153
+ path.style.cssText = "color:#666;font-size:11px;word-break:break-all";
154
+ item.append(path);
155
+ item.addEventListener("toggle", () => {
156
+ if (!item.open || item.dataset["loaded"] === "1") return;
157
+ item.dataset["loaded"] = "1";
158
+ fetchOriginal(sessionId, fold.locator).then((text) => {
159
+ const pre = document.createElement("pre");
160
+ pre.textContent = text ?? "(could not read the stored original)";
161
+ pre.style.cssText = "white-space:pre-wrap;margin:6px 0;color:#ddd;max-height:24em;overflow:auto;border-top:1px dashed #333;padding-top:6px";
162
+ item.append(pre);
163
+ });
164
+ });
165
+ } else if (!fold.recoverable) {
166
+ const warning = document.createElement("div");
167
+ warning.textContent = "No recovery path: the original is in the session log but no tool exposes it.";
168
+ warning.style.cssText = "color:#f87171;font-size:11px";
169
+ item.append(warning);
170
+ }
171
+ section.append(item);
172
+ }
173
+ return section;
174
+ }
175
+ /** Client plugin name. */
176
+ const name = "context-fold-client";
177
+ /** The tab needs the slot registry and the locale service. */
178
+ const inject = ["slots", "locale"];
179
+ /**
180
+ * Mount the conversation tab.
181
+ *
182
+ * @param ctx - client plugin context.
183
+ */
184
+ function apply(ctx) {
185
+ ctx.slots.inject("conversation.view", () => ctx.slots.register({
186
+ name: "conversation.view",
187
+ id: "context-fold",
188
+ order: 40,
189
+ label: () => ctx.locale.get("tab.contextFold")
190
+ }, renderTab));
191
+ }
192
+ export { apply, inject, name };