@qlzt/dsh-spill-policy 0.0.0-stage → 0.2.1-alpha.3

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 DeepSeek
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.
@@ -0,0 +1,82 @@
1
+ # Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
2
+ # section, a hash of its English and Chinese blocks outside code blocks and generated regions.
3
+ # After editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/spill/spill-policy/README.md
5
+ /:
6
+ en: 07748986630e8da3
7
+ zh: 4ff004a12b883fdf
8
+ /deepseek-ai-dsh-spill-policy:
9
+ en: e3d75c128bb2f4b9
10
+ zh: 15daa9f8f3efaabd
11
+ /deepseek-ai-dsh-spill-policy/summary:
12
+ en: 225c013f0f230c37
13
+ zh: 30634801496c205f
14
+ /deepseek-ai-dsh-spill-policy/table-of-contents:
15
+ en: d152484eb41ac6b4
16
+ zh: 09388d293f9be9cb
17
+ /deepseek-ai-dsh-spill-policy/use-this-package:
18
+ en: 52e3b36ee5b98e7f
19
+ zh: 98380821aa7cde9d
20
+ /deepseek-ai-dsh-spill-policy/use-this-package/minimal-configuration:
21
+ en: a5d85ae7f7962b9e
22
+ zh: 73c44a9413c3c8cf
23
+ /deepseek-ai-dsh-spill-policy/use-this-package/what-the-model-sees:
24
+ en: 7cb5bba705975712
25
+ zh: 26adbdee1252d2bb
26
+ /deepseek-ai-dsh-spill-policy/use-this-package/which-results-are-affected:
27
+ en: c419117e6b5f84f8
28
+ zh: 1a43afb623346603
29
+ /deepseek-ai-dsh-spill-policy/use-this-package/best-effort-failure-behavior:
30
+ en: b4d94d99a8a9e52f
31
+ zh: d5cec6295cbf5ef6
32
+ /deepseek-ai-dsh-spill-policy/use-this-package/the-durable-log-copy:
33
+ en: b72e6e88ada5a957
34
+ zh: 5c6167651618738a
35
+ /deepseek-ai-dsh-spill-policy/understand-the-implementation:
36
+ en: bb7cedc768113c0e
37
+ zh: ca05828dfe63b233
38
+ /deepseek-ai-dsh-spill-policy/understand-the-implementation/design-philosophy:
39
+ en: a8e0d6d0651401c0
40
+ zh: 0e159fc19caf9300
41
+ /deepseek-ai-dsh-spill-policy/understand-the-implementation/the-two-arms:
42
+ en: a9233181feb1afb4
43
+ zh: e54f1498a0dea843
44
+ /deepseek-ai-dsh-spill-policy/understand-the-implementation/shared-notice-ownership:
45
+ en: 916aa902ea376c42
46
+ zh: 51fbf25c1f1560d7
47
+ /deepseek-ai-dsh-spill-policy/understand-the-implementation/source-map:
48
+ en: 355ca3b01de5233e
49
+ zh: 19a7c28a57171573
50
+ /deepseek-ai-dsh-spill-policy/understand-the-implementation/failure-modes:
51
+ en: 5640d59fdf0e064f
52
+ zh: 7f92c52a1bdf5bff
53
+ /deepseek-ai-dsh-spill-policy/further-exploration:
54
+ en: 2b5860d2cc9a6523
55
+ zh: 006157ad7bc92891
56
+ /deepseek-ai-dsh-spill-policy/model-experience:
57
+ en: 215e7ba838619b7b
58
+ zh: a311da3843709f90
59
+ /deepseek-ai-dsh-spill-policy/model-experience/oversized-text-and-image-results:
60
+ en: 99c96010474af9cf
61
+ zh: 9a25fa709f21a494
62
+ /deepseek-ai-dsh-spill-policy/model-experience/oversized-text-and-image-results/what-the-model-sees:
63
+ en: a701c098db84aff5
64
+ zh: 2b04abc792def1a2
65
+ /deepseek-ai-dsh-spill-policy/model-experience/oversized-text-and-image-results/token-effect:
66
+ en: 02ec4218006ecb65
67
+ zh: 62f1d481ebf0c665
68
+ /deepseek-ai-dsh-spill-policy/model-experience/oversized-text-and-image-results/kv-cache-effect:
69
+ en: f5d5f97a153f7c70
70
+ zh: bee82b41983a66a0
71
+ /deepseek-ai-dsh-spill-policy/known-limitations-and-deferred-work:
72
+ en: bd38d2abae4cb4e6
73
+ zh: 29acb530988ee2bd
74
+ /deepseek-ai-dsh-spill-policy/known-limitations-and-deferred-work/dev-note:
75
+ en: a63e51f03bdb279f
76
+ zh: 191a48d3bfc4a792
77
+ /deepseek-ai-dsh-spill-policy/known-limitations-and-deferred-work/dev-note/future-per-tool-configuration:
78
+ en: 7e6c4300e87f3f07
79
+ zh: 7b97be5218905ecc
80
+ /deepseek-ai-dsh-spill-policy/known-limitations-and-deferred-work/dev-note/future-earlier-spill:
81
+ en: dbb9f9fe93996893
82
+ zh: d9d3c1a46ea67595
package/README.md CHANGED
@@ -1,3 +1,166 @@
1
- # Temporary Holding Version
1
+ ---
2
+ description: "Tool-result retention with a shared text/image token budget and readable recovery files."
3
+ kind: "package-reference"
4
+ ---
2
5
 
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.
6
+ # @deepseek-ai/dsh-spill-policy
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ Keep oversized text and image results within a shared estimated token budget. The model receives ordered head/tail content and a path to the complete result. Images remain in attachment storage; the result file records their readable paths. Omitting `maxInlineTokens` disables retention, and recovery failures leave the original content visible.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Mount the policy alongside a spill backend. Text and images share the configured budget after post-execute policy accepts the result.
29
+
30
+ ### Minimal configuration
31
+
32
+ Load a spill backend and set `maxInlineTokens` in estimated tokens:
33
+
34
+ ```yaml
35
+ - name: '@deepseek-ai/dsh-spill-local'
36
+ - name: '@deepseek-ai/dsh-spill-policy'
37
+ config:
38
+ maxInlineTokens: 12500
39
+ ```
40
+
41
+ | Field | Default | Meaning |
42
+ |---|---|---|
43
+ | `maxInlineTokens` | omitted | Estimated token cap for retained text, images, image descriptions, and notices; omission disables retention |
44
+
45
+ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-spill-policy) is the exhaustive source for every accepted field. A negative or fractional cap fails plugin load rather than corrupting per-call behavior.
46
+
47
+ ### What the model sees
48
+
49
+ Oversized results keep their original order. Each end receives half the budget remaining after omission notices; text may be split, while each image is retained or omitted whole. Images inside the omitted interval are omitted too. A successful replacement stays within the configured token estimate:
50
+
51
+ ```text
52
+ <retained head/tail preview>
53
+
54
+ (Omitted N bytes. Full formatted result stored at: /…/session-…/…-web_fetch.txt. Use read with offset/limit, or grep this path to search within it.)
55
+ ```
56
+
57
+ The notice also reports omitted image counts. A notice-only result is allowed when no preview fits; if the notice itself exceeds the cap, the original content stays visible. The full result file keeps all accepted text and an attachment path at each image position, so the model can use `read` and then `read_image`. Attachment bytes are not copied into this file. Local attachment objects persist independently of spill cleanup.
58
+
59
+ ### Which results are affected
60
+
61
+ The policy accepts text/image sequences. Results within budget, `read`, blocked decisions, value replacements, and other block types pass through. Text-only nested results are bounded only in their log copies. Provider or tool limits applied before this policy cannot be recovered here.
62
+
63
+ ### Best-effort failure behavior
64
+
65
+ A missing owner or spill backend, failed storage, missing route image pricing, or unavailable execution-world image path logs a warning and keeps the original content. The policy never substitutes an unreadable path for an image.
66
+
67
+ ### The durable log copy
68
+
69
+ PTC programs receive complete canonical values. Image-bearing sub-results are bounded before forwarding to the model; when every image is omitted, the model still receives the retained text and recovery notice. The dispatch log uses the same retained content. Text-only sub-call logs, including `read`, are bounded asynchronously without delaying program values.
70
+
71
+ -----
72
+
73
+ <a id="understand-the-implementation"></a>
74
+ ## Understand the implementation
75
+
76
+ <details>
77
+ <summary>Implementation internals — click to expand</summary>
78
+
79
+ This section explains the design decisions behind the policy; the observable behavior is fully covered in [Use this package](#use-this-package).
80
+
81
+ ### Design philosophy
82
+
83
+ The pure retention function selects ordered content by cost; the plugin owns policy, recovery text, and storage calls. Text uses the existing token-meter estimate. Images use the active route's `imageRequestPricing`, including descriptor text. DeepSeek routes reuse the provider's image-dimension calculator. The budget is an estimate, not an exact tokenizer guarantee.
84
+
85
+ ### The two arms
86
+
87
+ The prepended `tools/post-execute` listener delegates before bounding accepted content. `tools/ptc-dispatch-log` shares the same helper. MCP's `projectContent` installs real image blocks before these policies; later content replacement, value replacement, or blocking remains authoritative.
88
+
89
+ <a id="shared-notice-ownership"></a>
90
+ ### Shared notice ownership
91
+
92
+ The browser-safe `./notice` entry owns `formatSpillNotice(omitted, ref, images)` and `hasSpillNotice(text)`. It recognizes both historical byte-only notices and notices with whole-image counts without changing recorded text.
93
+
94
+ ### Source map
95
+
96
+ | File | Role |
97
+ |---|---|
98
+ | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` validation, the two waterfall listeners, the shared replacement helper |
99
+ | [`src/notice.ts`](src/notice.ts) | Browser-safe notice formatting and recognition, published as `./notice` |
100
+ | [`src/retention.ts`](src/retention.ts) | Pure ordered text/image head-tail retention |
101
+
102
+ ### Failure modes
103
+
104
+ Recovery or pricing failures preserve the input and log the reason. Negative, fractional, or unsafe-integer budgets fail at plugin load. Results containing unsupported block types remain unchanged.
105
+
106
+ </details>
107
+
108
+ -----
109
+
110
+ <a id="further-exploration"></a>
111
+ ## Further Exploration
112
+
113
+ Read these pages when the package-level contract is not enough.
114
+
115
+ - [Spill storage service](../spill/README.md) — the `saveText` contract behind the policy's replacement.
116
+ - [dsh-spill-local](../spill-local/README.md) — the local backend that stores the spilled text.
117
+ - [Token meter](../../llm/token-meter/README.md) — shared text estimates and route image accounting.
118
+ - [Tool output spill decision](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) — the capability boundary and design rationale.
119
+
120
+ -----
121
+
122
+ <a id="model-experience"></a>
123
+ ## Model Experience
124
+
125
+ ### Oversized text and image results
126
+
127
+ #### What the model sees
128
+
129
+ The retained prefix and suffix keep image order, with `[...]` at the omitted interval. The final notice names omitted text bytes, optional whole-image counts, and the complete-result path. Reading that file reveals the omitted text and image addresses.
130
+
131
+ #### Token effect
132
+
133
+ A successful replacement fits `maxInlineTokens` under the shared text estimate and active route's image calculator, including notices and image descriptor text. Provider-reported usage remains authoritative.
134
+
135
+ #### KV Cache effect
136
+
137
+ Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
138
+
139
+ ## Known Limitations and Deferred Work
140
+
141
+ <a id="known-limitations-and-deferred-work"></a>
142
+
143
+
144
+ These limits define when the policy cannot help. They are current package constraints.
145
+
146
+ - **Text recognition cannot authenticate output** — a tool can print the same notice text; `hasSpillNotice` identifies a text convention, not proof that the policy saved a result.
147
+ - **Unavailable recovery or pricing** — images require a route calculator and execution-readable attachment paths; otherwise the original content stays visible. Unsupported blocks, blocked feedback, and `read` also pass through.
148
+ - **A notice that cannot fit disables replacement for that call** — a tiny cap or long locator leaves the oversized original inline after the backend has already saved an unreferenced spill.
149
+
150
+ <a id="dev-note"></a>
151
+ ### Dev Note
152
+
153
+ <details>
154
+ <summary>Working context for maintainers — click to expand</summary>
155
+
156
+ This Dev Note is working context for maintainers: open directions. It is explicitly non-authoritative.
157
+
158
+ #### Future: per-tool configuration
159
+
160
+ Per-tool opt-out or per-tool policy declarations remain deferred; the built-in `read` skip covers the known loop, and a second real tool need would justify configuration.
161
+
162
+ #### Future: earlier spill
163
+
164
+ The policy only sees final accepted content. Earlier provider truncation and tool-owned output limits remain outside its scope.
165
+
166
+ </details>
package/README.zh.md ADDED
@@ -0,0 +1,166 @@
1
+ ---
2
+ description: "工具结果保留:文字和图片共享 token 预算,并通过完整结果文件恢复省略内容。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-spill-policy
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ 将过大的文字和图片结果限制在共享的估算 token 预算内。模型收到按原顺序保留的首尾内容,以及完整结果文件的路径。图片保存在附件存储中,结果文件记录其可读取路径。省略 `maxInlineTokens` 会禁用策略,无法保存可恢复内容时保留原结果。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 将策略与 spill 后端一起挂载。执行后策略接受结果之后,文字和图片共享配置的预算。
29
+
30
+ ### 最小配置
31
+
32
+ 挂载 spill 后端,并以估算 token 数设置 `maxInlineTokens`:
33
+
34
+ ```yaml
35
+ - name: '@deepseek-ai/dsh-spill-local'
36
+ - name: '@deepseek-ai/dsh-spill-policy'
37
+ config:
38
+ maxInlineTokens: 12500
39
+ ```
40
+
41
+ | 字段 | 默认值 | 含义 |
42
+ |---|---|---|
43
+ | `maxInlineTokens` | 省略 | 保留的文字、图片、图片说明和提示的估算 token 上限;省略时禁用策略 |
44
+
45
+ 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-spill-policy)是每个受支持字段的穷尽式真源。负数或小数上限会让插件加载失败,而不是破坏每次调用的行为。
46
+
47
+ ### 模型看到什么
48
+
49
+ 过大的结果保持原始顺序。扣除省略提示后,两端各使用剩余预算的一半;文字可以切分,图片整张保留或省略。省略区间中的图片也会被省略。成功替换的内容不超过配置的 token 估算预算:
50
+
51
+ ```text
52
+ <retained head/tail preview>
53
+
54
+ (Omitted N bytes. Full formatted result stored at: /…/session-…/…-web_fetch.txt. Use read with offset/limit, or grep this path to search within it.)
55
+ ```
56
+
57
+ 提示也会报告省略的图片数量。预算容不下预览时允许只返回提示;连提示也超出上限时保留原内容。完整结果文件保存全部已接受文字,并在每张图片的位置记录附件路径,模型可先用 `read` 读取,再用 `read_image` 查看。图片字节不复制到这个文件中。本地附件对象的持久保存独立于 spill 文件清理。
58
+
59
+ ### 哪些结果会受影响
60
+
61
+ 策略接受文字和图片序列。预算内结果、`read`、被阻止的决策、值替换以及其他内容块类型会原样通过。纯文本嵌套结果只限制日志副本。提供方或工具在此前应用的限制无法在这里恢复。
62
+
63
+ ### 尽力而为的故障行为
64
+
65
+ 缺少归属或 spill 后端、存储失败、缺少模型图片计量或图片路径无法在执行环境读取时,策略记录警告并保留原内容。策略不会用无法读取的路径替换图片。
66
+
67
+ ### 持久日志副本
68
+
69
+ PTC 程序收到完整的规范值。含图片的子结果在转发给模型前设定上限;全部图片被省略时,模型仍会收到保留的文字和读取提示。分发日志使用相同的保留内容。纯文本子调用的日志,包括 `read`,异步设定上限,不延迟程序获取返回值。
70
+
71
+ -----
72
+
73
+ <a id="understand-the-implementation"></a>
74
+ ## 理解实现
75
+
76
+ <details>
77
+ <summary>实现细节——点击展开</summary>
78
+
79
+ 本节解释该策略背后的设计决策;可观察行为已在[使用本包](#use-this-package)中完整说明。
80
+
81
+ ### 设计理念
82
+
83
+ 纯保留函数按成本选择有序内容,插件负责策略、读取提示和存储调用。文字使用现有 token-meter 估算。图片使用当前模型的 `imageRequestPricing`,并计入说明文字。DeepSeek 模型复用提供方的图片尺寸计算器。预算是估算值,不保证与实际分词结果完全一致。
84
+
85
+ ### 两条分支
86
+
87
+ 以 prepend 注册的 `tools/post-execute` 监听器先委托,再限制已接受的内容。`tools/ptc-dispatch-log` 共用同一辅助函数。MCP 的 `projectContent` 在这些策略之前安装真实图片块;后续内容替换、值替换或阻止仍然生效。
88
+
89
+ <a id="shared-notice-ownership"></a>
90
+ ### 共享通知的所有权
91
+
92
+ 浏览器安全入口 `./notice` 负责 `formatSpillNotice(omitted, ref, images)` 和 `hasSpillNotice(text)`。它识别历史的仅字节提示和包含整张图片数量的提示,不改写已有记录。
93
+
94
+ ### 源码地图
95
+
96
+ | 文件 | 职责 |
97
+ |---|---|
98
+ | [`src/index.ts`](src/index.ts) | 插件入口:`Config` 校验、两个 waterfall 监听器、共享替换辅助函数 |
99
+ | [`src/notice.ts`](src/notice.ts) | 浏览器安全的通知格式化与识别,以 `./notice` 发布 |
100
+ | [`src/retention.ts`](src/retention.ts) | 有序图文首尾保留的纯函数 |
101
+
102
+ ### 故障模式
103
+
104
+ 无法恢复内容或计量失败时保留输入,并记录原因。负数、小数或非安全整数预算会使插件加载失败。包含不支持的内容块类型的结果保持原样。
105
+
106
+ </details>
107
+
108
+ -----
109
+
110
+ <a id="further-exploration"></a>
111
+ ## 进一步探索
112
+
113
+ 当包级约定不够用时阅读以下页面。
114
+
115
+ - [spill 存储服务](../spill/README.zh.md)——策略替换背后的 `saveText` 约定。
116
+ - [dsh-spill-local](../spill-local/README.zh.md)——保存 spill 文本的本地后端。
117
+ - [Token meter](../../llm/token-meter/README.zh.md) — 共享文字估算和模型图片计量。
118
+ - [工具输出 spill 决策](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md)——能力边界与设计依据。
119
+
120
+ -----
121
+
122
+ <a id="model-experience"></a>
123
+ ## 模型体验
124
+
125
+ ### 过大的文字和图片结果
126
+
127
+ #### 模型看到什么
128
+
129
+ 保留的前缀和后缀维持图片顺序,并在省略区间显示 `[...]`。末尾提示说明省略的文字字节数、整张图片数量及完整结果路径。读取该文件可以找到省略的文字和图片地址。
130
+
131
+ #### Token 影响
132
+
133
+ 成功替换的结果在共享文字估算和当前模型图片计算器下不超过 `maxInlineTokens`,包括提示和图片说明文字。实际用量以提供方报告为准。
134
+
135
+ #### KV Cache 影响
136
+
137
+ 仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV Cache 条目失效。
138
+
139
+ ## 已知限制与延期工作
140
+
141
+ <a id="known-limitations-and-deferred-work"></a>
142
+
143
+
144
+ 这些限制说明策略在哪些情况下无法提供帮助。它们是当前的包约束。
145
+
146
+ - **文本识别无法认证输出来源**——工具也能打印相同的通知文本;`hasSpillNotice` 识别的是文本约定,不能证明策略保存过结果。
147
+ - **无法恢复或计量**:图片要求模型计算器和执行环境可读取的附件路径,否则保留原内容。不支持的内容块、被阻止的反馈和 `read` 也会原样通过。
148
+ - **通知无法容纳时会禁用该次调用的替换**——上限极小或定位信息很长时,后端已经保存了无引用的 spill,但过大的原始结果仍留在内联位置。
149
+
150
+ <a id="dev-note"></a>
151
+ ### 开发备注
152
+
153
+ <details>
154
+ <summary>维护者的工作上下文——点击展开</summary>
155
+
156
+ 本开发备注是维护者的工作上下文:开放方向。它明确不具权威性。
157
+
158
+ #### 未来:逐工具配置
159
+
160
+ 逐工具选择退出或逐工具策略声明仍然延期;内置的 `read` 跳过已覆盖已知循环,第二个真实工具需求才能证明配置的合理性。
161
+
162
+ #### 未来:更早的 spill
163
+
164
+ 策略只处理最终已接受的内容。此前的提供方截断和工具自身的输出限制仍由各自负责。
165
+
166
+ </details>
package/lib/index.js ADDED
@@ -0,0 +1,262 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { createUserMessage, resolveImageAttachmentAccess } from "@qlzt/dsh-llm";
3
+ import { estimateContent } from "@qlzt/dsh-token-meter/estimate";
4
+ import { describeOmitted } from "@qlzt/dsh-output-retention";
5
+ //#region lib/types/notice.js
6
+ /** Browser-safe formatting and recognition of persisted spill-policy notices. */
7
+ const OPEN = "(";
8
+ const CLOSE = ")";
9
+ const LOCATION = " Full formatted result stored at: ";
10
+ const GUIDANCE_SEPARATOR = ". ";
11
+ const EXACT_OMISSION = describeOmitted({
12
+ kind: "exact",
13
+ count: 0
14
+ }, "bytes");
15
+ const COUNT_OFFSET = EXACT_OMISSION.indexOf("0");
16
+ EXACT_OMISSION.slice(COUNT_OFFSET + 1);
17
+ /**
18
+ * Format the notice appended to a retained preview, preserving its persisted spelling.
19
+ * @param omitted - bytes omitted by the retention policy.
20
+ * @param ref - saved text locator and retrieval guidance.
21
+ * @param images - number of whole images omitted alongside text.
22
+ * @returns the complete notice without a leading preview separator.
23
+ */
24
+ function formatSpillNotice(omitted, ref, images = 0) {
25
+ const imageNotice = images > 0 ? ` Omitted ${images} images.` : "";
26
+ return `${OPEN}${describeOmitted(omitted, "bytes")}${imageNotice}${LOCATION}${ref.locator}${GUIDANCE_SEPARATOR}${ref.retrievalHint}${CLOSE}`;
27
+ }
28
+ //#endregion
29
+ //#region lib/types/retention.js
30
+ /** Preserve a UTF-16 code point when a text budget cuts between its surrogates. */
31
+ function textSlice(text, length, tail) {
32
+ let cut = tail ? text.length - length : length;
33
+ const previous = text.charCodeAt(cut - 1);
34
+ const current = text.charCodeAt(cut);
35
+ if (previous >= 55296 && previous <= 56319 && current >= 56320 && current <= 57343) cut += tail ? 1 : -1;
36
+ return tail ? text.slice(cut) : text.slice(0, cut);
37
+ }
38
+ /** Largest contiguous text end fitting a monotonic token estimate. */
39
+ function fitText(text, budget, tail, price) {
40
+ let low = 0;
41
+ let high = text.length;
42
+ while (low < high) {
43
+ const middle = Math.ceil((low + high) / 2);
44
+ const candidate = textSlice(text, middle, tail);
45
+ if (candidate.length === 0 || price({
46
+ type: "text",
47
+ text: candidate
48
+ }) <= budget) low = middle;
49
+ else high = middle - 1;
50
+ }
51
+ return textSlice(text, low, tail);
52
+ }
53
+ /**
54
+ * Retain contiguous ends without moving or partially retaining an image.
55
+ * Each end receives half the content budget; unused space at an indivisible
56
+ * image stays unused. Text pricing must be monotonic in retained length.
57
+ * @param content - ordered text and image blocks whose total price exceeds budget.
58
+ * @param budget - non-negative integer token budget after reserving notices.
59
+ * @param price - model-route cost of one retained block, including its framing.
60
+ * @returns retained ends and omitted UTF-8 text bytes and whole images.
61
+ */
62
+ function retainContent(content, budget, price) {
63
+ const head = [];
64
+ const tail = [];
65
+ let first = 0;
66
+ let last = content.length - 1;
67
+ let headCharacters = 0;
68
+ let remaining = Math.ceil(budget / 2);
69
+ while (first <= last) {
70
+ const block = content[first];
71
+ const cost = price(block);
72
+ if (cost <= remaining) {
73
+ head.push(block);
74
+ remaining -= cost;
75
+ first++;
76
+ continue;
77
+ }
78
+ if (block.type === "text") {
79
+ const text = fitText(block.text, remaining, false, price);
80
+ headCharacters = text.length;
81
+ if (text.length > 0) head.push({
82
+ type: "text",
83
+ text
84
+ });
85
+ }
86
+ break;
87
+ }
88
+ remaining = Math.floor(budget / 2);
89
+ while (last >= first) {
90
+ const original = content[last];
91
+ const block = last === first && original.type === "text" ? {
92
+ type: "text",
93
+ text: original.text.slice(headCharacters)
94
+ } : original;
95
+ const cost = price(block);
96
+ if (cost <= remaining) {
97
+ tail.push(block);
98
+ remaining -= cost;
99
+ last--;
100
+ continue;
101
+ }
102
+ if (block.type === "text") {
103
+ const text = fitText(block.text, remaining, true, price);
104
+ if (text.length > 0) tail.push({
105
+ type: "text",
106
+ text
107
+ });
108
+ }
109
+ break;
110
+ }
111
+ tail.reverse();
112
+ const bytes = (blocks) => blocks.reduce((total, block) => total + (block.type === "text" ? Buffer.byteLength(block.text, "utf8") : 0), 0);
113
+ const images = (blocks) => blocks.filter((block) => block.type === "image").length;
114
+ return {
115
+ head,
116
+ tail,
117
+ omittedBytes: bytes(content) - bytes(head) - bytes(tail),
118
+ omittedImages: images(content) - images(head) - images(tail)
119
+ };
120
+ }
121
+ //#endregion
122
+ //#region lib/types/index.js
123
+ /** Cordis plugin identity. */
124
+ const name = "spill-policy";
125
+ /** Tools own both model-facing results and PTC dispatch logs. */
126
+ const inject = ["tools"];
127
+ const Config = z.object({ maxInlineTokens: z.number() });
128
+ /** Narrow the model's ordered content without rewriting unsupported blocks. */
129
+ function retainable(content) {
130
+ return content.every((block) => block.type === "text" || block.type === "image");
131
+ }
132
+ const GAP = {
133
+ type: "text",
134
+ text: "\n\n[...]\n\n"
135
+ };
136
+ /**
137
+ * Mount token retention for accepted tool results and PTC log copies.
138
+ * @param ctx - tool registry and optional pricing, filesystem, attachment, and spill services.
139
+ * @param config - maximum estimated result tokens; omission disables the plugin.
140
+ */
141
+ function apply(ctx, config) {
142
+ const cap = config.maxInlineTokens;
143
+ if (cap === void 0) return;
144
+ if (!Number.isSafeInteger(cap) || cap < 0) throw new Error(`spill-policy: maxInlineTokens must be a non-negative integer (got ${cap})`);
145
+ const maxTokens = cap;
146
+ /** Price actual request images and their accompanying descriptor text. */
147
+ function pricing(exec, images) {
148
+ const costs = /* @__PURE__ */ new Map();
149
+ if (images.length > 0) {
150
+ const routed = exec.agent?.session.requestHeader()?.config;
151
+ const provider = routed?.provider ?? exec.agent?.options.provider;
152
+ const model = routed?.model ?? exec.agent?.options.model;
153
+ const calculator = provider === void 0 || model === void 0 ? void 0 : ctx.get("llm")?.imageRequestPricing(provider, model);
154
+ if (calculator === void 0) throw new Error("the current model has no image token calculator");
155
+ const prices = calculator.priceImages(images);
156
+ if (prices.length !== images.length) throw new Error("image token calculator returned an inconsistent occurrence count");
157
+ for (const [index, image] of images.entries()) {
158
+ const cost = prices[index];
159
+ costs.set(image, cost.visualTokens + estimateContent([{
160
+ type: "text",
161
+ text: cost.text
162
+ }]));
163
+ }
164
+ }
165
+ return (block) => block.type === "image" ? costs.get(block) : estimateContent([block]);
166
+ }
167
+ /** Full ordered text with an execution-readable attachment path at each image position. */
168
+ function fullText(content) {
169
+ return content.map((block) => {
170
+ if (block.type === "text") return block.text;
171
+ const attachments = ctx.get("attachments");
172
+ const fs = ctx.get("fs");
173
+ const access = attachments === void 0 || fs === void 0 ? void 0 : resolveImageAttachmentAccess(attachments, (path) => fs.processPathFromHostPath(path), block.attachment);
174
+ if (access === void 0) throw new Error(`image ${block.attachment.attachmentId} has no readable attachment path`);
175
+ return `\n[Image: ${JSON.stringify(access.readonlyPath)}; ${block.attachment.mediaType}; ${block.attachment.width}x${block.attachment.height}. Use read_image to view it.]\n`;
176
+ }).join("");
177
+ }
178
+ /** Recoverably bound one display copy; failures leave successful tool content visible. */
179
+ async function bound(exec, content, toolName, callId, label) {
180
+ if (!retainable(content)) return void 0;
181
+ try {
182
+ const images = content.filter((block) => block.type === "image");
183
+ const price = pricing(exec, images);
184
+ if (content.reduce((total, block) => total + price(block), 0) <= maxTokens) return void 0;
185
+ const owner = exec.agent?.session.header.id;
186
+ if (owner === void 0) throw new Error(`no session owner for ${toolName} ${label}`);
187
+ const spillStore = ctx.get("spillStore");
188
+ if (spillStore === void 0) throw new Error("no ctx.spillStore backend loaded");
189
+ const ref = await spillStore.saveText({
190
+ owner: { sessionId: owner },
191
+ source: {
192
+ kind: "tool",
193
+ toolName,
194
+ callId,
195
+ label
196
+ },
197
+ suggestedName: `${toolName}.txt`,
198
+ content: fullText(content)
199
+ });
200
+ const totalBytes = content.reduce((total, block) => total + (block.type === "text" ? Buffer.byteLength(block.text, "utf8") : 0), 0);
201
+ const notice = (bytes, count) => ({
202
+ type: "text",
203
+ text: formatSpillNotice({
204
+ kind: "exact",
205
+ count: bytes
206
+ }, ref, count)
207
+ });
208
+ const worstNotice = notice(totalBytes, images.length);
209
+ const reserved = price(GAP) + price({
210
+ type: "text",
211
+ text: `\n\n${worstNotice.text}`
212
+ });
213
+ if (price(worstNotice) > maxTokens) throw new Error(`spill notice for ${toolName} exceeds maxInlineTokens`);
214
+ const retained = retainContent(content, Math.max(0, maxTokens - reserved), price);
215
+ const footer = notice(retained.omittedBytes, retained.omittedImages);
216
+ const result = retained.head.length + retained.tail.length === 0 ? [footer] : [
217
+ ...retained.head,
218
+ GAP,
219
+ ...retained.tail,
220
+ {
221
+ type: "text",
222
+ text: `\n\n${footer.text}`
223
+ }
224
+ ];
225
+ const merged = [];
226
+ for (const block of result) {
227
+ const previous = merged.at(-1);
228
+ if (block.type === "text" && previous?.type === "text") previous.text += block.text;
229
+ else merged.push(block.type === "text" ? { ...block } : block);
230
+ }
231
+ return merged;
232
+ } catch (error) {
233
+ ctx.logger.warn(`spill-policy: ${String(error)}; keeping the inline content`);
234
+ return;
235
+ }
236
+ }
237
+ ctx.on("tools/post-execute", async (exec, result, next) => {
238
+ const decision = await next();
239
+ if (decision.kind !== "accept" || Object.hasOwn(decision, "value") || exec.name === "read") return decision;
240
+ const content = decision.content ?? result.content;
241
+ const hasImages = content.some((block) => block.type === "image");
242
+ if (exec.parent !== void 0 && !hasImages) return decision;
243
+ const retained = await bound(exec, content, exec.name, exec.callId, exec.parent === void 0 ? "result" : "dispatch");
244
+ if (retained === void 0) return decision;
245
+ const additionalContexts = [...decision.additionalContexts ?? []];
246
+ if (exec.parent !== void 0 && !result.isError && hasImages && !retained.some((block) => block.type === "image")) additionalContexts.push(createUserMessage({
247
+ content: retained,
248
+ source: { kind: "ptc-mode" }
249
+ }));
250
+ return {
251
+ kind: "accept",
252
+ content: retained,
253
+ ...additionalContexts.length > 0 ? { additionalContexts } : {}
254
+ };
255
+ }, { prepend: true });
256
+ ctx.on("tools/ptc-dispatch-log", async (dispatch, next) => {
257
+ const content = await next();
258
+ return await bound(dispatch.exec, content, dispatch.name, dispatch.subCallId, "dispatch") ?? content;
259
+ }, { prepend: true });
260
+ }
261
+ //#endregion
262
+ export { Config, apply, inject, name };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Token-budgeted tool content with recoverable text and image addresses.
3
+ * Post-execute policies settle before retention; canonical program values
4
+ * remain intact. Missing recovery storage or image pricing keeps the original
5
+ * content and reports the reason through the logger.
6
+ * @module @qlzt/dsh-spill-policy
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ import z from '@deepseek-ai/schemastery';
10
+ export type { SpillPolicyExec } from './types.ts';
11
+ /** Optional result-retention budget. */
12
+ export interface Config {
13
+ /** Maximum estimated tokens in a retained result, including image descriptors and omission notices. Omitted disables retention. */
14
+ maxInlineTokens?: number;
15
+ }
16
+ /** Cordis plugin identity. */
17
+ export declare const name = "spill-policy";
18
+ /** Tools own both model-facing results and PTC dispatch logs. */
19
+ export declare const inject: string[];
20
+ export declare const Config: z<Config>;
21
+ /**
22
+ * Mount token retention for accepted tool results and PTC log copies.
23
+ * @param ctx - tool registry and optional pricing, filesystem, attachment, and spill services.
24
+ * @param config - maximum estimated result tokens; omission disables the plugin.
25
+ */
26
+ export declare function apply(ctx: Context, config: Config): void;
27
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,138 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import { createUserMessage, resolveImageAttachmentAccess } from '@qlzt/dsh-llm';
3
+ import { estimateContent } from '@qlzt/dsh-token-meter/estimate';
4
+ import { formatSpillNotice } from "./notice.js";
5
+ import { retainContent } from "./retention.js";
6
+ /** Cordis plugin identity. */
7
+ export const name = 'spill-policy';
8
+ /** Tools own both model-facing results and PTC dispatch logs. */
9
+ export const inject = ['tools'];
10
+ export const Config = z.object({ maxInlineTokens: z.number() });
11
+ /** Narrow the model's ordered content without rewriting unsupported blocks. */
12
+ function retainable(content) {
13
+ return content.every(block => block.type === 'text' || block.type === 'image');
14
+ }
15
+ const GAP = { type: 'text', text: '\n\n[...]\n\n' };
16
+ /**
17
+ * Mount token retention for accepted tool results and PTC log copies.
18
+ * @param ctx - tool registry and optional pricing, filesystem, attachment, and spill services.
19
+ * @param config - maximum estimated result tokens; omission disables the plugin.
20
+ */
21
+ export function apply(ctx, config) {
22
+ const cap = config.maxInlineTokens;
23
+ if (cap === undefined)
24
+ return;
25
+ if (!Number.isSafeInteger(cap) || cap < 0) {
26
+ throw new Error(`spill-policy: maxInlineTokens must be a non-negative integer (got ${cap})`);
27
+ }
28
+ const maxTokens = cap;
29
+ /** Price actual request images and their accompanying descriptor text. */
30
+ function pricing(exec, images) {
31
+ const costs = new Map();
32
+ if (images.length > 0) {
33
+ const routed = exec.agent?.session.requestHeader()?.config;
34
+ const provider = routed?.provider ?? exec.agent?.options.provider;
35
+ const model = routed?.model ?? exec.agent?.options.model;
36
+ const calculator = provider === undefined || model === undefined
37
+ ? undefined
38
+ : ctx.get('llm')?.imageRequestPricing(provider, model);
39
+ if (calculator === undefined)
40
+ throw new Error('the current model has no image token calculator');
41
+ const prices = calculator.priceImages(images);
42
+ if (prices.length !== images.length)
43
+ throw new Error('image token calculator returned an inconsistent occurrence count');
44
+ for (const [index, image] of images.entries()) {
45
+ const cost = prices[index];
46
+ costs.set(image, cost.visualTokens + estimateContent([{ type: 'text', text: cost.text }]));
47
+ }
48
+ }
49
+ return block => block.type === 'image' ? costs.get(block) : estimateContent([block]);
50
+ }
51
+ /** Full ordered text with an execution-readable attachment path at each image position. */
52
+ function fullText(content) {
53
+ return content.map((block) => {
54
+ if (block.type === 'text')
55
+ return block.text;
56
+ const attachments = ctx.get('attachments');
57
+ const fs = ctx.get('fs');
58
+ const access = attachments === undefined || fs === undefined ? undefined : resolveImageAttachmentAccess(attachments, path => fs.processPathFromHostPath(path), block.attachment);
59
+ if (access === undefined)
60
+ throw new Error(`image ${block.attachment.attachmentId} has no readable attachment path`);
61
+ return `\n[Image: ${JSON.stringify(access.readonlyPath)}; ${block.attachment.mediaType}; ${block.attachment.width}x${block.attachment.height}. Use read_image to view it.]\n`;
62
+ }).join('');
63
+ }
64
+ /** Recoverably bound one display copy; failures leave successful tool content visible. */
65
+ async function bound(exec, content, toolName, callId, label) {
66
+ if (!retainable(content))
67
+ return undefined;
68
+ try {
69
+ const images = content.filter((block) => block.type === 'image');
70
+ const price = pricing(exec, images);
71
+ if (content.reduce((total, block) => total + price(block), 0) <= maxTokens)
72
+ return undefined;
73
+ const owner = exec.agent?.session.header.id;
74
+ if (owner === undefined)
75
+ throw new Error(`no session owner for ${toolName} ${label}`);
76
+ const spillStore = ctx.get('spillStore');
77
+ if (spillStore === undefined)
78
+ throw new Error('no ctx.spillStore backend loaded');
79
+ const ref = await spillStore.saveText({
80
+ owner: { sessionId: owner }, source: { kind: 'tool', toolName, callId, label },
81
+ suggestedName: `${toolName}.txt`, content: fullText(content),
82
+ });
83
+ const totalBytes = content.reduce((total, block) => total + (block.type === 'text' ? Buffer.byteLength(block.text, 'utf8') : 0), 0);
84
+ const notice = (bytes, count) => ({
85
+ type: 'text', text: formatSpillNotice({ kind: 'exact', count: bytes }, ref, count),
86
+ });
87
+ const worstNotice = notice(totalBytes, images.length);
88
+ const reserved = price(GAP) + price({ type: 'text', text: `\n\n${worstNotice.text}` });
89
+ if (price(worstNotice) > maxTokens)
90
+ throw new Error(`spill notice for ${toolName} exceeds maxInlineTokens`);
91
+ const retained = retainContent(content, Math.max(0, maxTokens - reserved), price);
92
+ const footer = notice(retained.omittedBytes, retained.omittedImages);
93
+ const result = retained.head.length + retained.tail.length === 0
94
+ ? [footer]
95
+ : [...retained.head, GAP, ...retained.tail, { type: 'text', text: `\n\n${footer.text}` }];
96
+ // Adjacent text has one framing cost on the wire and in the spill preview.
97
+ const merged = [];
98
+ for (const block of result) {
99
+ const previous = merged.at(-1);
100
+ if (block.type === 'text' && previous?.type === 'text')
101
+ previous.text += block.text;
102
+ else
103
+ merged.push(block.type === 'text' ? { ...block } : block);
104
+ }
105
+ return merged;
106
+ }
107
+ catch (error) {
108
+ ctx.logger.warn(`spill-policy: ${String(error)}; keeping the inline content`);
109
+ return undefined;
110
+ }
111
+ }
112
+ ctx.on('tools/post-execute', async (exec, result, next) => {
113
+ const decision = await next();
114
+ if (decision.kind !== 'accept' || Object.hasOwn(decision, 'value') || exec.name === 'read')
115
+ return decision;
116
+ const content = decision.content ?? result.content;
117
+ const hasImages = content.some(block => block.type === 'image');
118
+ // Text-only PTC bindings retain their asynchronous log-only spill path.
119
+ if (exec.parent !== undefined && !hasImages)
120
+ return decision;
121
+ const retained = await bound(exec, content, exec.name, exec.callId, exec.parent === undefined ? 'result' : 'dispatch');
122
+ if (retained === undefined)
123
+ return decision;
124
+ const additionalContexts = [...decision.additionalContexts ?? []];
125
+ if (exec.parent !== undefined && !result.isError && hasImages && !retained.some(block => block.type === 'image')) {
126
+ additionalContexts.push(createUserMessage({ content: retained, source: { kind: 'ptc-mode' } }));
127
+ }
128
+ return {
129
+ kind: 'accept', content: retained,
130
+ ...additionalContexts.length > 0 ? { additionalContexts } : {},
131
+ };
132
+ }, { prepend: true });
133
+ ctx.on('tools/ptc-dispatch-log', async (dispatch, next) => {
134
+ const content = await next();
135
+ return await bound(dispatch.exec, content, dispatch.name, dispatch.subCallId, 'dispatch') ?? content;
136
+ }, { prepend: true });
137
+ }
138
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,19 @@
1
+ /** Browser-safe formatting and recognition of persisted spill-policy notices. */
2
+ import { type Omitted } from '@qlzt/dsh-output-retention';
3
+ import type { SpillRef } from '@qlzt/dsh-spill';
4
+ /**
5
+ * Format the notice appended to a retained preview, preserving its persisted spelling.
6
+ * @param omitted - bytes omitted by the retention policy.
7
+ * @param ref - saved text locator and retrieval guidance.
8
+ * @param images - number of whole images omitted alongside text.
9
+ * @returns the complete notice without a leading preview separator.
10
+ */
11
+ export declare function formatSpillNotice(omitted: Omitted, ref: Pick<SpillRef, 'locator' | 'retrievalHint'>, images?: number): string;
12
+ /**
13
+ * Recognize a final spill-policy notice in persisted text, including notice-only output.
14
+ * This identifies the text convention, not authenticated tool-output origin.
15
+ * @param text - complete recorded text result.
16
+ * @returns whether a complete notice occupies the end of the result.
17
+ */
18
+ export declare function hasSpillNotice(text: string): boolean;
19
+ //# sourceMappingURL=notice.d.ts.map
@@ -0,0 +1,59 @@
1
+ /** Browser-safe formatting and recognition of persisted spill-policy notices. */
2
+ import { describeOmitted } from '@qlzt/dsh-output-retention';
3
+ const OPEN = '(';
4
+ const CLOSE = ')';
5
+ const LOCATION = ' Full formatted result stored at: ';
6
+ const GUIDANCE_SEPARATOR = '. ';
7
+ const SEPARATOR = '\n\n';
8
+ const EXACT_OMISSION = describeOmitted({ kind: 'exact', count: 0 }, 'bytes');
9
+ const COUNT_OFFSET = EXACT_OMISSION.indexOf('0');
10
+ const COUNT_SUFFIX = EXACT_OMISSION.slice(COUNT_OFFSET + 1);
11
+ /**
12
+ * Format the notice appended to a retained preview, preserving its persisted spelling.
13
+ * @param omitted - bytes omitted by the retention policy.
14
+ * @param ref - saved text locator and retrieval guidance.
15
+ * @param images - number of whole images omitted alongside text.
16
+ * @returns the complete notice without a leading preview separator.
17
+ */
18
+ export function formatSpillNotice(omitted, ref, images = 0) {
19
+ const imageNotice = images > 0 ? ` Omitted ${images} images.` : '';
20
+ return `${OPEN}${describeOmitted(omitted, 'bytes')}${imageNotice}${LOCATION}${ref.locator}${GUIDANCE_SEPARATOR}${ref.retrievalHint}${CLOSE}`;
21
+ }
22
+ function isOmission(text) {
23
+ const imageNotice = / Omitted ([1-9][0-9]*) images\.$/.exec(text);
24
+ if (imageNotice !== null) {
25
+ if (!Number.isSafeInteger(Number(imageNotice[1])))
26
+ return false;
27
+ text = text.slice(0, imageNotice.index);
28
+ }
29
+ if (text === describeOmitted({ kind: 'none' }, 'bytes')
30
+ || text === describeOmitted({ kind: 'unknown' }, 'bytes'))
31
+ return true;
32
+ const count = Number(text.slice(COUNT_OFFSET, text.length - COUNT_SUFFIX.length));
33
+ return Number.isSafeInteger(count) && count >= 0
34
+ && text === describeOmitted({ kind: 'exact', count }, 'bytes');
35
+ }
36
+ /**
37
+ * Recognize a final spill-policy notice in persisted text, including notice-only output.
38
+ * This identifies the text convention, not authenticated tool-output origin.
39
+ * @param text - complete recorded text result.
40
+ * @returns whether a complete notice occupies the end of the result.
41
+ */
42
+ export function hasSpillNotice(text) {
43
+ if (!text.endsWith(CLOSE))
44
+ return false;
45
+ let start = 0;
46
+ while (true) {
47
+ const next = text.indexOf(`${SEPARATOR}${OPEN}`, start);
48
+ const candidate = text.slice(start, next < 0 ? -CLOSE.length : next);
49
+ const location = candidate.indexOf(LOCATION, OPEN.length);
50
+ if (candidate.startsWith(OPEN) && location >= 0
51
+ && isOmission(candidate.slice(OPEN.length, location))) {
52
+ return text.indexOf(GUIDANCE_SEPARATOR, start + location + LOCATION.length) >= 0;
53
+ }
54
+ if (next < 0)
55
+ return false;
56
+ start = next + SEPARATOR.length;
57
+ }
58
+ }
59
+ //# sourceMappingURL=notice.js.map
@@ -0,0 +1,24 @@
1
+ /** Ordered head/tail retention of text and indivisible images. @module @qlzt/dsh-spill-policy/retention */
2
+ import type { ContentBlock } from '@qlzt/dsh-llm';
3
+ /** Content whose text can be split and whose images must remain whole. */
4
+ export type RetainableBlock = Extract<ContentBlock, {
5
+ type: 'text' | 'image';
6
+ }>;
7
+ /** Retained ends and exact quantities excluded from their original sequence. */
8
+ export interface RetainedContent {
9
+ head: RetainableBlock[];
10
+ tail: RetainableBlock[];
11
+ omittedBytes: number;
12
+ omittedImages: number;
13
+ }
14
+ /**
15
+ * Retain contiguous ends without moving or partially retaining an image.
16
+ * Each end receives half the content budget; unused space at an indivisible
17
+ * image stays unused. Text pricing must be monotonic in retained length.
18
+ * @param content - ordered text and image blocks whose total price exceeds budget.
19
+ * @param budget - non-negative integer token budget after reserving notices.
20
+ * @param price - model-route cost of one retained block, including its framing.
21
+ * @returns retained ends and omitted UTF-8 text bytes and whole images.
22
+ */
23
+ export declare function retainContent(content: readonly RetainableBlock[], budget: number, price: (block: RetainableBlock) => number): RetainedContent;
24
+ //# sourceMappingURL=retention.d.ts.map
@@ -0,0 +1,88 @@
1
+ /** Preserve a UTF-16 code point when a text budget cuts between its surrogates. */
2
+ function textSlice(text, length, tail) {
3
+ let cut = tail ? text.length - length : length;
4
+ const previous = text.charCodeAt(cut - 1);
5
+ const current = text.charCodeAt(cut);
6
+ if (previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff) {
7
+ cut += tail ? 1 : -1;
8
+ }
9
+ return tail ? text.slice(cut) : text.slice(0, cut);
10
+ }
11
+ /** Largest contiguous text end fitting a monotonic token estimate. */
12
+ function fitText(text, budget, tail, price) {
13
+ let low = 0;
14
+ let high = text.length;
15
+ while (low < high) {
16
+ const middle = Math.ceil((low + high) / 2);
17
+ const candidate = textSlice(text, middle, tail);
18
+ if (candidate.length === 0 || price({ type: 'text', text: candidate }) <= budget)
19
+ low = middle;
20
+ else
21
+ high = middle - 1;
22
+ }
23
+ return textSlice(text, low, tail);
24
+ }
25
+ /**
26
+ * Retain contiguous ends without moving or partially retaining an image.
27
+ * Each end receives half the content budget; unused space at an indivisible
28
+ * image stays unused. Text pricing must be monotonic in retained length.
29
+ * @param content - ordered text and image blocks whose total price exceeds budget.
30
+ * @param budget - non-negative integer token budget after reserving notices.
31
+ * @param price - model-route cost of one retained block, including its framing.
32
+ * @returns retained ends and omitted UTF-8 text bytes and whole images.
33
+ */
34
+ export function retainContent(content, budget, price) {
35
+ const head = [];
36
+ const tail = [];
37
+ let first = 0;
38
+ let last = content.length - 1;
39
+ let headCharacters = 0;
40
+ let remaining = Math.ceil(budget / 2);
41
+ while (first <= last) {
42
+ const block = content[first];
43
+ const cost = price(block);
44
+ if (cost <= remaining) {
45
+ head.push(block);
46
+ remaining -= cost;
47
+ first++;
48
+ continue;
49
+ }
50
+ if (block.type === 'text') {
51
+ const text = fitText(block.text, remaining, false, price);
52
+ headCharacters = text.length;
53
+ if (text.length > 0)
54
+ head.push({ type: 'text', text });
55
+ }
56
+ break;
57
+ }
58
+ remaining = Math.floor(budget / 2);
59
+ while (last >= first) {
60
+ const original = content[last];
61
+ const block = last === first && original.type === 'text'
62
+ ? { type: 'text', text: original.text.slice(headCharacters) }
63
+ : original;
64
+ const cost = price(block);
65
+ if (cost <= remaining) {
66
+ tail.push(block);
67
+ remaining -= cost;
68
+ last--;
69
+ continue;
70
+ }
71
+ if (block.type === 'text') {
72
+ const text = fitText(block.text, remaining, true, price);
73
+ if (text.length > 0)
74
+ tail.push({ type: 'text', text });
75
+ }
76
+ break;
77
+ }
78
+ tail.reverse();
79
+ const bytes = (blocks) => blocks.reduce((total, block) => total + (block.type === 'text' ? Buffer.byteLength(block.text, 'utf8') : 0), 0);
80
+ const images = (blocks) => blocks.filter(block => block.type === 'image').length;
81
+ return {
82
+ head,
83
+ tail,
84
+ omittedBytes: bytes(content) - bytes(head) - bytes(tail),
85
+ omittedImages: images(content) - images(head) - images(tail),
86
+ };
87
+ }
88
+ //# sourceMappingURL=retention.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Vocabulary for the spill-policy plugin: the minimal structural view of a tool
3
+ * execution the policy needs to derive the owning session for a spill artifact.
4
+ *
5
+ * `@qlzt/dsh-tools`' `ToolExecution` satisfies this shape, so the policy
6
+ * reads `exec` straight through without importing `dsh-tools` or `dsh-agent`.
7
+ * Only the session HEADER id is read — the same identity every other subsystem
8
+ * keys off (see `dsh-tool-bash`'s owner derivation).
9
+ *
10
+ * @module @qlzt/dsh-spill-policy/types
11
+ */
12
+ import type { SessionId } from '@qlzt/dsh-session';
13
+ /** Minimal structural view of a tool execution: the owning session's header id, when present. */
14
+ export interface SpillPolicyExec {
15
+ /** The agent on whose behalf the call runs, when there is one. */
16
+ agent?: {
17
+ session: {
18
+ header: {
19
+ /** The canonical session identity — the spill owner. */
20
+ id: SessionId;
21
+ };
22
+ };
23
+ };
24
+ }
25
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Vocabulary for the spill-policy plugin: the minimal structural view of a tool
3
+ * execution the policy needs to derive the owning session for a spill artifact.
4
+ *
5
+ * `@qlzt/dsh-tools`' `ToolExecution` satisfies this shape, so the policy
6
+ * reads `exec` straight through without importing `dsh-tools` or `dsh-agent`.
7
+ * Only the session HEADER id is read — the same identity every other subsystem
8
+ * keys off (see `dsh-tool-bash`'s owner derivation).
9
+ *
10
+ * @module @qlzt/dsh-spill-policy/types
11
+ */
12
+ export {};
13
+ //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "@qlzt/dsh-spill-policy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "description": "Token-budgeted tool-result retention with recoverable text and image paths",
4
+ "version": "0.2.1-alpha.3",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://gitee.com/zsping1989/deepseek-harness.git",
11
+ "directory": "packages/spill/spill-policy"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./notice": {
22
+ "types": "./lib/types/notice.d.ts",
23
+ "default": "./lib/types/notice.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib/index.js",
30
+ "lib/types/**/*.js",
31
+ "lib/types/**/*.d.ts"
32
+ ],
33
+ "license": "MIT",
34
+ "peerDependencies": {
35
+ "@qlzt/dsh-llm": "0.2.1-alpha.3",
36
+ "@qlzt/dsh-output-retention": "0.2.1-alpha.3",
37
+ "@qlzt/dsh-session": "0.2.1-alpha.3",
38
+ "@qlzt/dsh-spill": "0.2.1-alpha.3",
39
+ "@qlzt/dsh-tools": "0.2.1-alpha.3",
40
+ "@deepseek-ai/cordis": "~4.0.5-alpha.1",
41
+ "@qlzt/dsh-attachment": "0.2.1-alpha.3",
42
+ "@qlzt/dsh-fs": "0.2.1-alpha.3",
43
+ "@qlzt/dsh-token-meter": "0.2.1-alpha.3"
44
+ },
45
+ "dependencies": {
46
+ "@deepseek-ai/schemastery": "~3.18.5-alpha.1"
47
+ },
48
+ "devDependencies": {
49
+ "@deepseek-ai/dsh-agent": "0.2.1-alpha.3",
50
+ "@deepseek-ai/dsh-ptc-runtime-node": "0.2.1-alpha.3",
51
+ "@deepseek-ai/dsh-llm": "0.2.1-alpha.3",
52
+ "@deepseek-ai/dsh-output-retention": "0.2.1-alpha.3",
53
+ "@deepseek-ai/dsh-session": "0.2.1-alpha.3",
54
+ "@deepseek-ai/dsh-spill": "0.2.1-alpha.3",
55
+ "@deepseek-ai/dsh-tools": "0.2.1-alpha.3",
56
+ "@deepseek-ai/cordis": "~4.0.5-alpha.1",
57
+ "@deepseek-ai/dsh-fs-local": "0.2.1-alpha.3",
58
+ "@deepseek-ai/dsh-subprocess-local": "0.2.1-alpha.3",
59
+ "@deepseek-ai/dsh-sandbox-local": "0.2.1-alpha.3",
60
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.1-alpha.3",
61
+ "@deepseek-ai/dsh-session-projection": "0.2.1-alpha.3",
62
+ "@deepseek-ai/dsh-attachment": "0.2.1-alpha.3",
63
+ "@deepseek-ai/dsh-fs": "0.2.1-alpha.3",
64
+ "@deepseek-ai/dsh-token-meter": "0.2.1-alpha.3",
65
+ "@deepseek-ai/dsh-mcp-client": "0.2.1-alpha.3",
66
+ "@deepseek-ai/dsh-spill-local": "0.2.1-alpha.3",
67
+ "@deepseek-ai/dsh-llm-deepseek": "0.2.1-alpha.3",
68
+ "@deepseek-ai/dsh-ptc-runtime": "0.2.1-alpha.3",
69
+ "@deepseek-ai/dsh-attachment-local": "0.2.1-alpha.3",
70
+ "@deepseek-ai/dsh-tool-fs": "0.2.1-alpha.3",
71
+ "sharp": "^0.35.5",
72
+ "@deepseek-ai/dsh-agent-loop-testkit": "0.2.1-alpha.3"
73
+ },
74
+ "dsh": {}
75
+ }