@logictan/dsh-verbatim-compaction 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/cordis.patch.yml +13 -0
- package/lib/index.js +445 -0
- package/lib/types/index.d.ts +165 -0
- package/package.json +66 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Logic Tan
|
|
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,101 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @logictan/dsh-verbatim-compaction
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把上下文压缩的**摘要**从「让模型重写一遍」换成「确定性地把正文抄进 checkpoint」:用户与助手的正文逐字保留,工具结果压成一行指针,推理块从不读取,全程零模型调用。
|
|
4
|
+
|
|
5
|
+
## 它解决什么
|
|
6
|
+
|
|
7
|
+
`@deepseek-ai/dsh-compaction-basic` 每次压缩都发起一次 `ctx.llm.stream()`,要求模型按九节模板(Primary Request / Key Technical Concepts / Errors and Fixes …)重写被阴影的区间。在 8 个真实会话上实测(403 MiB、145 次压缩、24.5M 被丢弃 token):
|
|
8
|
+
|
|
9
|
+
- 模板只保住被丢弃内容的 **2.38%**;
|
|
10
|
+
- 被丢弃内容里真正的「人写的正文」只占 **0.67%** —— 其余是推理 63.8%、工具结果 25.2%、工具调用参数 10.4%;
|
|
11
|
+
- 侥幸保住的正文,下一次压缩会被**再摘要一遍**:`selectCompactableRange` 总是从 `surfaceNodes[firstIdx]` 开始,而上一份 checkpoint 正好坐在 index 1。
|
|
12
|
+
|
|
13
|
+
本插件只覆盖基类的 `summarize()`(基类文档里点名的唯一子类钩子),重放、区间选择、durable 事务与 shrink 守卫全部沿用基类,因此不可能与宿主的事务语义分叉。
|
|
14
|
+
|
|
15
|
+
## 摘要长什么样
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
## user (turn 37)
|
|
19
|
+
(用户原话,逐字)
|
|
20
|
+
|
|
21
|
+
### assistant (turn 37)
|
|
22
|
+
(助手正文,逐字)
|
|
23
|
+
tools: bash×280 ssh_exec×44 web_search×6 (408830 bytes)
|
|
24
|
+
errors: 1: Error: 命令超过 600 秒未结束,已断开连接
|
|
25
|
+
wrote: /Volumes/LogicExt/Git/BookReader/src/video.js
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- 正文逐字,不重写、不改写。
|
|
29
|
+
- 工具结果只留「工具名 × 次数 + 字节数」,内容丢弃。
|
|
30
|
+
- 错误只留首行(100 字符),足以知道「这里失败过、失败原因是什么」。
|
|
31
|
+
- `wrote` 记录本轮写过的路径,重建「改了哪些文件」。
|
|
32
|
+
- 推理块从不进入摘要,也不读取。
|
|
33
|
+
|
|
34
|
+
## 摘要源是日志,不是被阴影的区间
|
|
35
|
+
|
|
36
|
+
上一份 checkpoint 每次都被一起阴影掉,所以「只摘要被阴影区间」会把上一份摘要承载的全部历史再丢一次。本插件扫描会话日志里 `surfaceOp === 'append'` 的事件,减去本次压缩后仍留在 surface 上的尾部,重建整段历史——不需要任何累积状态,摘要源集合逐次单调增长(122 次压缩实测 0 次非单调)。
|
|
37
|
+
|
|
38
|
+
`turn/start` 不在 surface 上,但它是「这条 user 消息属于第几轮」的唯一记录(user 消息自身不带 turn),所以一并保留。
|
|
39
|
+
|
|
40
|
+
## 为什么不会触发 shrink 守卫
|
|
41
|
+
|
|
42
|
+
基类的守卫比较「framed 摘要」与「被替换内容的 route 价」,摘要不小于原内容就整次压缩失败。本插件把预算直接算成 `min(196752, shadowedPrice − 86 − 512)`:上界取守卫自己的那个数,因此这条失败路径按构造不可能发生,而不是靠实测侥幸。实测 8 个会话 0 次触发,最小余量 174387。
|
|
43
|
+
|
|
44
|
+
## 安装
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pnpm add @logictan/dsh-verbatim-compaction
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 激活:必须改 profile patch,本包的行不生效
|
|
51
|
+
|
|
52
|
+
**这是本插件唯一不直观的地方。** 真正在用的 `compaction` 组嵌在 4 个 agent preset(`preset-standard` / `preset-ptc` / `preset-minimal` / `preset-cordis`)的 `config.plugins` 里,带 `isolate: {compaction: true}`,每个 preset 各持一个 `compaction` 服务实例,根上下文看不到它们。因此:
|
|
53
|
+
|
|
54
|
+
- 顶层新增一行 `compaction` 服务**不会被任何 agent 用到**;
|
|
55
|
+
- 往 preset 的 `config.plugins` 里 `insert` 会报 `patch insert: entry "compaction" not found`(`applyEntryPatches` 的 `entryMap` 只收录顶层条目与 `group: true` 条目);
|
|
56
|
+
- 新行也不能与 `compaction-basic` 并存在同一组:`provide('compaction')` 重复注册会抛 `service "compaction" has been registered at <...>`。
|
|
57
|
+
|
|
58
|
+
本包自带的 `cordis.patch.yml` 因此只插入一行 `disabled: true` 的占位(让聚合包能带上它),真正激活靠**按 Loader row id override 整份 preset `config`**——宿主指南 `dsh-agent-preset/skills/editing-cordis-compositions/SKILL.md`「Change a shipped preset」的官方路径。在 `~/.dsh/cordis.patch.yml` 里给每个要改的 preset 写一份完整 config,只把 `compaction` 组里的 `compaction-basic` 换成 `verbatim-compaction`:
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
- id: preset-standard
|
|
62
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
63
|
+
config:
|
|
64
|
+
id: standard
|
|
65
|
+
order: 1
|
|
66
|
+
plugins:
|
|
67
|
+
# 其余插件原样重述,只改 compaction 组
|
|
68
|
+
- id: compaction
|
|
69
|
+
name: cordis:group
|
|
70
|
+
group: true
|
|
71
|
+
isolate:
|
|
72
|
+
compaction: true
|
|
73
|
+
toolResultPruner: true
|
|
74
|
+
config:
|
|
75
|
+
- id: verbatim-compaction
|
|
76
|
+
name: '@logictan/dsh-verbatim-compaction'
|
|
77
|
+
- id: command-compact
|
|
78
|
+
name: '@deepseek-ai/dsh-command-compact'
|
|
79
|
+
- id: tool-result-pruner
|
|
80
|
+
name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
|
|
81
|
+
config: { … }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
代价是每个要改的 preset 都要重述一遍 `plugins`;换来的是确定性——不依赖任何监听器顺序。
|
|
85
|
+
|
|
86
|
+
`cordis.patch.yml` 里那行占位必须保留,且 `id` 与本包宿主半边的 `export const name`(`verbatim-compaction`)一致,否则 `check:host` 会报 patch 行 id 不一致。
|
|
87
|
+
|
|
88
|
+
## 配置
|
|
89
|
+
|
|
90
|
+
无需配置。预算、注入折叠、错误首行长度都是内部常量;唯一的可调项是基类自己的 `thresholdRatio` / `retainRatio`,它们照旧从 `compaction-basic` 的配置读。
|
|
91
|
+
|
|
92
|
+
## 已知边界
|
|
93
|
+
|
|
94
|
+
- **正文超预算会被截断。** 预算是硬的,两级依次触发:**先** user 正文超过预算一半时从**最新**往前算、最旧的先丢(用户刚说的话是下一轮唯一依赖的东西,丢它等于丢任务);**仍不够**才把助手正文按轮均分截断,每轮下限 800 字符。8 个实测会话里两级都没跑过(最大摘要 44542 token,预算 196752),所以这是守卫而不是常态。
|
|
95
|
+
- **`turn/start` 缺失时轮次编号退化为 `turn ?`。** 不会丢正文,只是编号不可读。
|
|
96
|
+
- **注入内容不进摘要。** 判据是 `source.kind !== 'user'`——注入上下文在一个真实会话里占 user 消息字符的 97%,放进摘要会把人的原话淹掉。
|
|
97
|
+
- **不改善首轮之外的工具参数细节。** `bash` 命令本身(占工具参数 84.2%)不保留;需要「当时跑了什么命令」的场景不在本插件的覆盖范围内。
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# dsh-verbatim-compaction bundle patch layer.
|
|
2
|
+
#
|
|
3
|
+
# This row is NOT inserted into the top-level composition: the compaction
|
|
4
|
+
# backend lives inside each agent preset's `compaction` group, behind
|
|
5
|
+
# `isolate: {compaction: true}`, so a top-level row could not serve it.
|
|
6
|
+
# The profile patch overrides each preset row instead — see the README.
|
|
7
|
+
#
|
|
8
|
+
# The id must stay equal to the host half's `export const name`, because
|
|
9
|
+
# cordis-plugin-loader keys mounted rows by id.
|
|
10
|
+
- insert:
|
|
11
|
+
- id: verbatim-compaction
|
|
12
|
+
name: '@logictan/dsh-verbatim-compaction'
|
|
13
|
+
disabled: true
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, verbatim-history compaction backend.
|
|
3
|
+
*
|
|
4
|
+
* `@deepseek-ai/dsh-compaction-basic` condenses a shadowed span through one
|
|
5
|
+
* `ctx.llm.stream()` call whose instruction asks for a nine-section template
|
|
6
|
+
* ("Primary Request / Key Technical Concepts / Errors and Fixes ..."). Measured
|
|
7
|
+
* over eight real sessions (403 MiB, 145 compactions, 24.5M discarded tokens)
|
|
8
|
+
* that template retains 2.38% of what it discards, and of everything discarded
|
|
9
|
+
* only 0.67% is prose a human wrote -- the rest is reasoning (63.8%), tool
|
|
10
|
+
* results (25.2%) and tool-call arguments (10.4%). The prose that does survive
|
|
11
|
+
* is then summarized again on the next compaction, because
|
|
12
|
+
* `selectCompactableRange` always starts at `surfaceNodes[firstIdx]` and the
|
|
13
|
+
* previous checkpoint sits at index 1.
|
|
14
|
+
*
|
|
15
|
+
* This backend keeps the base class's replay, range selection, durable
|
|
16
|
+
* transaction and shrink guard untouched and overrides only `summarize()`, the
|
|
17
|
+
* one hook the base class documents for exactly this purpose. The replacement
|
|
18
|
+
* summary is built deterministically, with no model call:
|
|
19
|
+
*
|
|
20
|
+
* - user and assistant prose verbatim, per turn;
|
|
21
|
+
* - tool results reduced to one line (tool names, call counts, bytes);
|
|
22
|
+
* - reasoning never read;
|
|
23
|
+
* - repeated injections recorded once.
|
|
24
|
+
*
|
|
25
|
+
* The digest source is the session log, not the shadowed span: the previous
|
|
26
|
+
* checkpoint is itself shadowed on every compaction, so a span-only digest
|
|
27
|
+
* would drop everything the previous digest carried. Reading the log's
|
|
28
|
+
* append-origin surface events minus the retained tail reconstructs the whole
|
|
29
|
+
* history without any accumulated state.
|
|
30
|
+
*
|
|
31
|
+
* @module @logictan/dsh-verbatim-compaction
|
|
32
|
+
*/
|
|
33
|
+
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic';
|
|
34
|
+
/** Cordis companion plugin name. Must equal this plugin's `cordis.patch.yml` row id. */
|
|
35
|
+
export const name = 'verbatim-compaction';
|
|
36
|
+
/**
|
|
37
|
+
* Ceiling for one digest, in the meter's fixed 4-chars-per-token heuristic.
|
|
38
|
+
*
|
|
39
|
+
* The base class's own summary budget is `thresholdTokens - retainTokens -
|
|
40
|
+
* 8192`, which is 196752 for the route this was measured on. The digest is also
|
|
41
|
+
* bounded by the shrink guard itself, which is tighter in practice; see
|
|
42
|
+
* `summarize`.
|
|
43
|
+
*/
|
|
44
|
+
const DIGEST_BUDGET_TOKENS = 196752;
|
|
45
|
+
/** Framing overhead `frameSummary` adds: preamble plus both tags, 341 chars = 86 tokens. */
|
|
46
|
+
const FRAME_TOKENS = 86;
|
|
47
|
+
/** Safety margin below the shrink guard, in tokens. */
|
|
48
|
+
const SHRINK_MARGIN_TOKENS = 512;
|
|
49
|
+
/** Per-turn floor when assistant prose has to be truncated, in characters. */
|
|
50
|
+
const MIN_TURN_CHARS = 800;
|
|
51
|
+
/** Share of the budget user prose may occupy before oldest-first truncation. */
|
|
52
|
+
const USER_BUDGET_SHARE = 0.5;
|
|
53
|
+
/** Tool names whose call arguments name a written path. */
|
|
54
|
+
const WRITE_TOOLS = new Set(['write', 'edit', 'multiedit', 'apply_patch']);
|
|
55
|
+
/** The message an event derives to, or null when it derives to none. */
|
|
56
|
+
function messageOf(event) {
|
|
57
|
+
switch (event.type) {
|
|
58
|
+
case 'user/message':
|
|
59
|
+
return event.data;
|
|
60
|
+
case 'assistant/message':
|
|
61
|
+
case 'tool/result':
|
|
62
|
+
case 'system/message':
|
|
63
|
+
case 'developer/message':
|
|
64
|
+
return event.data.message ?? null;
|
|
65
|
+
default:
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Source kind of a user message, or undefined when it carries none. */
|
|
70
|
+
function sourceKind(event) {
|
|
71
|
+
if (event.type !== 'user/message')
|
|
72
|
+
return undefined;
|
|
73
|
+
const source = event.data.source;
|
|
74
|
+
return source?.kind;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether this user message was injected by the host rather than typed.
|
|
78
|
+
*
|
|
79
|
+
* The rule is `kind !== 'user'` rather than a list of known producers: the
|
|
80
|
+
* source union is merge-extensible, so a new plugin's kind must not silently
|
|
81
|
+
* become "user prose the human wrote". Measured on one real session, injected
|
|
82
|
+
* context is 97% of all user-message characters (161993 of 375 for
|
|
83
|
+
* `agent-instructions` against the human's own 375), so a leaked kind would
|
|
84
|
+
* swamp the digest.
|
|
85
|
+
*/
|
|
86
|
+
function isInjection(event) {
|
|
87
|
+
const kind = sourceKind(event);
|
|
88
|
+
return kind !== undefined && kind !== 'user';
|
|
89
|
+
}
|
|
90
|
+
/** Whether this user message is a compaction checkpoint written by a backend. */
|
|
91
|
+
function isCheckpoint(event) {
|
|
92
|
+
return sourceKind(event) === 'compact-checkpoint';
|
|
93
|
+
}
|
|
94
|
+
/** The visible text of a message, with reasoning excluded. */
|
|
95
|
+
function textOf(event) {
|
|
96
|
+
const message = messageOf(event);
|
|
97
|
+
if (message === null)
|
|
98
|
+
return '';
|
|
99
|
+
const parts = [];
|
|
100
|
+
for (const block of message.content) {
|
|
101
|
+
if (block.type === 'text')
|
|
102
|
+
parts.push(block.text);
|
|
103
|
+
}
|
|
104
|
+
return parts.join('\n');
|
|
105
|
+
}
|
|
106
|
+
/** Every tool-call block of one message, in order. */
|
|
107
|
+
function toolCallsOf(event) {
|
|
108
|
+
const message = messageOf(event);
|
|
109
|
+
if (message === null)
|
|
110
|
+
return [];
|
|
111
|
+
const calls = [];
|
|
112
|
+
for (const block of message.content) {
|
|
113
|
+
if (block.type === 'tool-call')
|
|
114
|
+
calls.push({ name: block.name, arguments: block.arguments });
|
|
115
|
+
}
|
|
116
|
+
return calls;
|
|
117
|
+
}
|
|
118
|
+
/** The `path` argument of one tool call, when the call carries one. */
|
|
119
|
+
function pathArgumentOf(argumentsJson) {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(argumentsJson);
|
|
122
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
123
|
+
return undefined;
|
|
124
|
+
const path = parsed.path;
|
|
125
|
+
return typeof path === 'string' ? path : undefined;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** The turn number an event belongs to, when the log records one. */
|
|
132
|
+
function turnOf(event) {
|
|
133
|
+
const turn = event.data.turn;
|
|
134
|
+
return typeof turn === 'number' && Number.isInteger(turn) ? turn : undefined;
|
|
135
|
+
}
|
|
136
|
+
/** A fresh, empty turn accumulator. */
|
|
137
|
+
function emptyTurn(turn) {
|
|
138
|
+
return { turn, user: [], assistant: [], tools: new Map(), resultBytes: 0, errors: [], writes: [] };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Group append-origin surface events into per-turn digest material.
|
|
142
|
+
*
|
|
143
|
+
* A section starts at each non-injected user message; assistant-only runs
|
|
144
|
+
* before the first user message join the first section. Turn numbers come from
|
|
145
|
+
* the log: `turn/start` carries `{turn}` and `assistant/message` carries
|
|
146
|
+
* `data.turn`. A user message carries no turn of its own, so it inherits the
|
|
147
|
+
* last one seen.
|
|
148
|
+
*
|
|
149
|
+
* @param events - the digest source in log order.
|
|
150
|
+
* @returns one section per non-injected user message.
|
|
151
|
+
*/
|
|
152
|
+
export function buildSections(events) {
|
|
153
|
+
const sections = [];
|
|
154
|
+
let current;
|
|
155
|
+
let currentTurn;
|
|
156
|
+
let lastTurn;
|
|
157
|
+
const openTurn = (turn) => {
|
|
158
|
+
if (current === undefined) {
|
|
159
|
+
current = { turns: [] };
|
|
160
|
+
sections.push(current);
|
|
161
|
+
}
|
|
162
|
+
const next = emptyTurn(turn);
|
|
163
|
+
current.turns.push(next);
|
|
164
|
+
if (turn !== undefined)
|
|
165
|
+
lastTurn = turn;
|
|
166
|
+
return next;
|
|
167
|
+
};
|
|
168
|
+
for (const event of events) {
|
|
169
|
+
if (event.type === 'turn/start') {
|
|
170
|
+
const turn = turnOf(event);
|
|
171
|
+
if (turn !== undefined)
|
|
172
|
+
lastTurn = turn;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (event.type === 'tool/result') {
|
|
176
|
+
if (currentTurn === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
const text = textOf(event);
|
|
179
|
+
currentTurn.resultBytes += text.length;
|
|
180
|
+
const message = messageOf(event);
|
|
181
|
+
if (message?.isError === true) {
|
|
182
|
+
const firstLine = text.split('\n')[0] ?? '';
|
|
183
|
+
currentTurn.errors.push(firstLine.slice(0, 100));
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (event.type !== 'user/message' && event.type !== 'assistant/message')
|
|
188
|
+
continue;
|
|
189
|
+
if (isInjection(event) || isCheckpoint(event))
|
|
190
|
+
continue;
|
|
191
|
+
const eventTurn = turnOf(event) ?? lastTurn;
|
|
192
|
+
if (event.type === 'user/message') {
|
|
193
|
+
// A user message opens a new section, not merely a new turn: sections are
|
|
194
|
+
// what the digest headings delimit, and one section per user request is
|
|
195
|
+
// what makes the digest readable after a hundred turns.
|
|
196
|
+
current = { turns: [] };
|
|
197
|
+
sections.push(current);
|
|
198
|
+
currentTurn = openTurn(eventTurn);
|
|
199
|
+
const text = textOf(event);
|
|
200
|
+
if (text.length > 0)
|
|
201
|
+
currentTurn.user.push(text);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (currentTurn === undefined || (eventTurn !== undefined && currentTurn.turn !== eventTurn)) {
|
|
205
|
+
currentTurn = openTurn(eventTurn);
|
|
206
|
+
}
|
|
207
|
+
const text = textOf(event);
|
|
208
|
+
if (text.length > 0)
|
|
209
|
+
currentTurn.assistant.push(text);
|
|
210
|
+
for (const call of toolCallsOf(event)) {
|
|
211
|
+
currentTurn.tools.set(call.name, (currentTurn.tools.get(call.name) ?? 0) + 1);
|
|
212
|
+
if (!WRITE_TOOLS.has(call.name))
|
|
213
|
+
continue;
|
|
214
|
+
const path = pathArgumentOf(call.arguments);
|
|
215
|
+
if (path !== undefined && !currentTurn.writes.includes(path))
|
|
216
|
+
currentTurn.writes.push(path);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return sections;
|
|
220
|
+
}
|
|
221
|
+
/** Render one section to digest text, truncating assistant prose to `capChars`. */
|
|
222
|
+
function renderSection(section, capChars) {
|
|
223
|
+
const lines = [];
|
|
224
|
+
for (const turn of section.turns) {
|
|
225
|
+
const label = turn.turn === undefined ? '?' : String(turn.turn);
|
|
226
|
+
if (turn.user.length > 0)
|
|
227
|
+
lines.push(`\n## user (turn ${label})\n${turn.user.join('\n')}`);
|
|
228
|
+
let body = turn.assistant.join('\n');
|
|
229
|
+
if (capChars !== undefined && body.length > capChars) {
|
|
230
|
+
body = `${body.slice(0, capChars)}\n…(+${body.length - capChars} chars)`;
|
|
231
|
+
}
|
|
232
|
+
if (body.length > 0)
|
|
233
|
+
lines.push(`### assistant (turn ${label})\n${body}`);
|
|
234
|
+
if (turn.tools.size > 0) {
|
|
235
|
+
const names = [...turn.tools.entries()]
|
|
236
|
+
.sort((left, right) => right[1] - left[1])
|
|
237
|
+
.map(([tool, count]) => `${tool}×${count}`)
|
|
238
|
+
.join(' ');
|
|
239
|
+
lines.push(` tools: ${names} (${turn.resultBytes} bytes)`);
|
|
240
|
+
}
|
|
241
|
+
if (turn.errors.length > 0) {
|
|
242
|
+
lines.push(` errors: ${turn.errors.length}: ${turn.errors.slice(0, 3).join(' | ')}`);
|
|
243
|
+
}
|
|
244
|
+
if (turn.writes.length > 0)
|
|
245
|
+
lines.push(` wrote: ${turn.writes.join(', ')}`);
|
|
246
|
+
}
|
|
247
|
+
return lines.join('\n');
|
|
248
|
+
}
|
|
249
|
+
/** Total characters of assistant prose across every section. */
|
|
250
|
+
function assistantChars(sections) {
|
|
251
|
+
let total = 0;
|
|
252
|
+
for (const section of sections)
|
|
253
|
+
for (const turn of section.turns)
|
|
254
|
+
total += turn.assistant.join('\n').length;
|
|
255
|
+
return total;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Render the digest under a hard character budget.
|
|
259
|
+
*
|
|
260
|
+
* The two truncation tiers are the plan's T1 and T1', in that order:
|
|
261
|
+
*
|
|
262
|
+
* - **T1** user prose is verbatim unless it alone exceeds `USER_BUDGET_SHARE` of
|
|
263
|
+
* the budget, then the oldest turns are cut first.
|
|
264
|
+
* - **T1'** assistant prose is verbatim unless the whole digest overflows, then
|
|
265
|
+
* it is cut evenly per turn down to `MIN_TURN_CHARS`.
|
|
266
|
+
*
|
|
267
|
+
* T1' runs *after* T1 because a user's own words are the least reconstructible
|
|
268
|
+
* thing in the log: an assistant's reply can be inferred from its tool calls
|
|
269
|
+
* and the surrounding turns, a user's request cannot. The 800-character floor
|
|
270
|
+
* exists so a tight budget thins every turn instead of erasing the last few.
|
|
271
|
+
*
|
|
272
|
+
* Neither tier runs in practice: over the eight measured sessions the largest
|
|
273
|
+
* digest was 44542 tokens against a 196752 budget, and user prose totalled 2591
|
|
274
|
+
* tokens against a 98376 cap. They are a guard against a pasted file, not the
|
|
275
|
+
* normal path.
|
|
276
|
+
*
|
|
277
|
+
* @param events - the digest source in log order.
|
|
278
|
+
* @param budgetTokens - hard ceiling in the meter's fixed 4-chars-per-token heuristic.
|
|
279
|
+
* @returns the digest text, never longer than `budgetTokens * 4` characters.
|
|
280
|
+
*/
|
|
281
|
+
export function renderDigest(events, budgetTokens) {
|
|
282
|
+
const sections = buildSections(events);
|
|
283
|
+
const budgetChars = Math.max(0, budgetTokens * 4);
|
|
284
|
+
const render = (capChars, trimmed) => (trimmed ?? sections).map((section) => renderSection(section, capChars)).join('\n');
|
|
285
|
+
const verbatim = render(undefined);
|
|
286
|
+
if (verbatim.length <= budgetChars)
|
|
287
|
+
return verbatim;
|
|
288
|
+
// T1: user prose over its share, oldest first. Only the user side changes, so
|
|
289
|
+
// assistant prose stays verbatim for now. The walk is backwards because the
|
|
290
|
+
// newest request is the one the next turn depends on: cutting forwards would
|
|
291
|
+
// keep the opening question and drop what the user just asked for.
|
|
292
|
+
const userCap = Math.floor(budgetChars * USER_BUDGET_SHARE);
|
|
293
|
+
let seen = 0;
|
|
294
|
+
const t1 = sections.map((section) => ({ turns: section.turns.map((turn) => ({ ...turn, user: [...turn.user] })) }));
|
|
295
|
+
for (let section = t1.length - 1; section >= 0; section -= 1) {
|
|
296
|
+
const turns = t1[section]?.turns ?? [];
|
|
297
|
+
for (let turn = turns.length - 1; turn >= 0; turn -= 1) {
|
|
298
|
+
const texts = turns[turn]?.user ?? [];
|
|
299
|
+
for (let index = texts.length - 1; index >= 0; index -= 1) {
|
|
300
|
+
const text = texts[index] ?? '';
|
|
301
|
+
const room = userCap - seen;
|
|
302
|
+
if (text.length <= room) {
|
|
303
|
+
seen += text.length;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
texts[index] = room <= 0
|
|
307
|
+
? `…(+${text.length} chars, older user prose truncated)`
|
|
308
|
+
: `${text.slice(0, room)}\n…(+${text.length - room} chars)`;
|
|
309
|
+
seen = userCap;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const afterT1 = render(undefined, t1);
|
|
314
|
+
if (afterT1.length <= budgetChars)
|
|
315
|
+
return afterT1;
|
|
316
|
+
// T1': assistant prose, evenly per turn. Length is monotone non-decreasing in
|
|
317
|
+
// the cap, so bisection finds the largest cap that fits exactly, in about
|
|
318
|
+
// eight renders. The floor is honoured only while it fits; below that the cap
|
|
319
|
+
// follows the budget down and the final slice guarantees the ceiling.
|
|
320
|
+
let low = 0;
|
|
321
|
+
let high = assistantChars(t1);
|
|
322
|
+
while (low < high) {
|
|
323
|
+
const mid = Math.ceil((low + high) / 2);
|
|
324
|
+
if (render(mid, t1).length <= budgetChars)
|
|
325
|
+
low = mid;
|
|
326
|
+
else
|
|
327
|
+
high = mid - 1;
|
|
328
|
+
}
|
|
329
|
+
const capped = render(low, t1);
|
|
330
|
+
if (capped.length <= budgetChars)
|
|
331
|
+
return capped;
|
|
332
|
+
// Even cap 0 overflows, so the headings and pointers alone exceed the budget.
|
|
333
|
+
// Drop the oldest sections instead of slicing the head: a truncated tail would
|
|
334
|
+
// cut the newest turns, which are exactly the ones the next request needs.
|
|
335
|
+
let first = 0;
|
|
336
|
+
let dropped = 0;
|
|
337
|
+
while (first < t1.length - 1) {
|
|
338
|
+
dropped += 1;
|
|
339
|
+
first += 1;
|
|
340
|
+
const rest = render(0, t1.slice(first));
|
|
341
|
+
const text = `…(${dropped} earlier turn(s) dropped: over budget)\n${rest}`;
|
|
342
|
+
if (text.length <= budgetChars)
|
|
343
|
+
return text;
|
|
344
|
+
}
|
|
345
|
+
return render(0, t1.slice(-1)).slice(0, budgetChars);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The surface seqs this compaction shadows, recovered from the summarization
|
|
349
|
+
* input's message count.
|
|
350
|
+
*
|
|
351
|
+
* `SummarizationInput` carries no seqs, but the base class always builds it as
|
|
352
|
+
* `[system head?] + shadowed region in surface order`, so the count recovers the
|
|
353
|
+
* span exactly. Verified against 120 real compactions: the recovered span
|
|
354
|
+
* matched the shadowed span seq-for-seq every time.
|
|
355
|
+
*
|
|
356
|
+
* @param session - session whose surface the input was built from.
|
|
357
|
+
* @param input - the replayed prefix handed to `summarize`.
|
|
358
|
+
* @returns the shadowed surface seqs in order.
|
|
359
|
+
*/
|
|
360
|
+
export function shadowedSeqs(session, input) {
|
|
361
|
+
const nodes = session.surface.nodes;
|
|
362
|
+
const head = nodes[0] !== undefined && session.eventAt(nodes[0])?.type === 'system/message' ? 1 : 0;
|
|
363
|
+
const take = Math.max(0, input.messages.length - head);
|
|
364
|
+
return nodes.slice(head, head + take);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Pick the log events the digest is built from: every append-origin surface
|
|
368
|
+
* event, minus the retained tail.
|
|
369
|
+
*
|
|
370
|
+
* The retained tail is what stays on the surface after this compaction, so
|
|
371
|
+
* excluding it keeps the digest and the surviving surface disjoint. Everything
|
|
372
|
+
* else has either already been shadowed by an earlier checkpoint or is being
|
|
373
|
+
* shadowed now, and must appear in the digest or it is lost.
|
|
374
|
+
*
|
|
375
|
+
* Events a `{op: 'replace'}` copy superseded still qualify, because their own
|
|
376
|
+
* marker is `'append'`. That is deliberate: tool results become a one-line
|
|
377
|
+
* pointer either way, so including the pre-replacement original changes the
|
|
378
|
+
* digest by 19-109 tokens across the measured sessions, and never dropping
|
|
379
|
+
* content is worth more than that.
|
|
380
|
+
*
|
|
381
|
+
* @param session - session whose log supplies the events.
|
|
382
|
+
* @param retainedSeqs - seqs that remain on the surface after this compaction.
|
|
383
|
+
* @returns the events the digest is built from, in log order.
|
|
384
|
+
*/
|
|
385
|
+
export function digestSource(session, retainedSeqs) {
|
|
386
|
+
return session.snapshotEvents().filter((event) => {
|
|
387
|
+
// `turn/start` is not a surface event, but it is the only record of which
|
|
388
|
+
// turn a user message belongs to: user messages carry no turn of their own.
|
|
389
|
+
// Keeping the markers costs a few tokens and makes every heading say
|
|
390
|
+
// "turn 37" instead of "turn ?".
|
|
391
|
+
if (event.type === 'turn/start')
|
|
392
|
+
return true;
|
|
393
|
+
if (event.surfaceOp !== 'append')
|
|
394
|
+
return false;
|
|
395
|
+
if (isCheckpoint(event))
|
|
396
|
+
return false;
|
|
397
|
+
return !retainedSeqs.has(event.seq);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Deterministic compaction backend: verbatim prose, pointerized tools, no model
|
|
402
|
+
* call.
|
|
403
|
+
*
|
|
404
|
+
* Everything except `summarize()` is inherited. The base class prices pressure,
|
|
405
|
+
* selects the span, writes the durable `compaction/start` ... `compaction/end`
|
|
406
|
+
* pair, applies the shrink guard and records `compaction/summary`, so this
|
|
407
|
+
* subclass cannot diverge from the host's transaction semantics.
|
|
408
|
+
*/
|
|
409
|
+
export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
410
|
+
/**
|
|
411
|
+
* Build the replacement checkpoint content from the session log.
|
|
412
|
+
*
|
|
413
|
+
* The budget is the shrink guard's own bound rather than only the fixed
|
|
414
|
+
* ceiling: the guard compares the framed digest against the route price of
|
|
415
|
+
* the span being replaced, so a digest accumulated over a long history
|
|
416
|
+
* against a small span is the one case that can fail. Deriving the budget
|
|
417
|
+
* from that same number makes the failure impossible by construction instead
|
|
418
|
+
* of relying on measurement.
|
|
419
|
+
*
|
|
420
|
+
* @param input - replayed conversation prefix the base class is shadowing.
|
|
421
|
+
* @param agent - owner of the session being compacted.
|
|
422
|
+
* @param _signal - unused: the digest is synchronous and cannot be cancelled midway.
|
|
423
|
+
* @returns text summary blocks, unmarked by any LLM call.
|
|
424
|
+
*/
|
|
425
|
+
async summarize(input, agent, _signal) {
|
|
426
|
+
const session = agent.session;
|
|
427
|
+
const shadowed = shadowedSeqs(session, input);
|
|
428
|
+
const shadowedSet = new Set(shadowed);
|
|
429
|
+
const retained = new Set(session.surface.nodes.filter((seq) => !shadowedSet.has(seq)));
|
|
430
|
+
const source = digestSource(session, retained);
|
|
431
|
+
let shadowedPrice = 0;
|
|
432
|
+
for (const node of this.ctx.tokenMeter.measure(session).nodes) {
|
|
433
|
+
if (shadowedSet.has(node.seq))
|
|
434
|
+
shadowedPrice += node.tokens;
|
|
435
|
+
}
|
|
436
|
+
const budget = Math.max(0, Math.min(DIGEST_BUDGET_TOKENS, shadowedPrice - FRAME_TOKENS - SHRINK_MARGIN_TOKENS));
|
|
437
|
+
const text = renderDigest(source, budget);
|
|
438
|
+
const route = session.requestHeader()?.config;
|
|
439
|
+
return {
|
|
440
|
+
summary: [{ type: 'text', text }],
|
|
441
|
+
provider: route?.provider ?? agent.options.provider ?? 'verbatim',
|
|
442
|
+
model: route?.model ?? agent.options.model ?? 'digest',
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, verbatim-history compaction backend.
|
|
3
|
+
*
|
|
4
|
+
* `@deepseek-ai/dsh-compaction-basic` condenses a shadowed span through one
|
|
5
|
+
* `ctx.llm.stream()` call whose instruction asks for a nine-section template
|
|
6
|
+
* ("Primary Request / Key Technical Concepts / Errors and Fixes ..."). Measured
|
|
7
|
+
* over eight real sessions (403 MiB, 145 compactions, 24.5M discarded tokens)
|
|
8
|
+
* that template retains 2.38% of what it discards, and of everything discarded
|
|
9
|
+
* only 0.67% is prose a human wrote -- the rest is reasoning (63.8%), tool
|
|
10
|
+
* results (25.2%) and tool-call arguments (10.4%). The prose that does survive
|
|
11
|
+
* is then summarized again on the next compaction, because
|
|
12
|
+
* `selectCompactableRange` always starts at `surfaceNodes[firstIdx]` and the
|
|
13
|
+
* previous checkpoint sits at index 1.
|
|
14
|
+
*
|
|
15
|
+
* This backend keeps the base class's replay, range selection, durable
|
|
16
|
+
* transaction and shrink guard untouched and overrides only `summarize()`, the
|
|
17
|
+
* one hook the base class documents for exactly this purpose. The replacement
|
|
18
|
+
* summary is built deterministically, with no model call:
|
|
19
|
+
*
|
|
20
|
+
* - user and assistant prose verbatim, per turn;
|
|
21
|
+
* - tool results reduced to one line (tool names, call counts, bytes);
|
|
22
|
+
* - reasoning never read;
|
|
23
|
+
* - repeated injections recorded once.
|
|
24
|
+
*
|
|
25
|
+
* The digest source is the session log, not the shadowed span: the previous
|
|
26
|
+
* checkpoint is itself shadowed on every compaction, so a span-only digest
|
|
27
|
+
* would drop everything the previous digest carried. Reading the log's
|
|
28
|
+
* append-origin surface events minus the retained tail reconstructs the whole
|
|
29
|
+
* history without any accumulated state.
|
|
30
|
+
*
|
|
31
|
+
* @module @logictan/dsh-verbatim-compaction
|
|
32
|
+
*/
|
|
33
|
+
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic';
|
|
34
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
35
|
+
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm';
|
|
36
|
+
import type { Session, SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session';
|
|
37
|
+
/** Cordis companion plugin name. Must equal this plugin's `cordis.patch.yml` row id. */
|
|
38
|
+
export declare const name = "verbatim-compaction";
|
|
39
|
+
/** The replayed prefix the base class hands to `summarize`. */
|
|
40
|
+
export interface SummarizationInput {
|
|
41
|
+
/** Tool schemas reused for prefix-cache alignment; absent when the request carried none. */
|
|
42
|
+
readonly tools?: readonly unknown[];
|
|
43
|
+
/** The derived system head, when present, followed by the shadowed region in surface order. */
|
|
44
|
+
readonly messages: readonly Message[];
|
|
45
|
+
}
|
|
46
|
+
/** Safe summary content plus the envelope the base class records with it. */
|
|
47
|
+
export interface SummaryResult {
|
|
48
|
+
summary: ContentBlock[];
|
|
49
|
+
provider: string;
|
|
50
|
+
model: string;
|
|
51
|
+
}
|
|
52
|
+
/** One turn's accumulated digest material. */
|
|
53
|
+
interface Turn {
|
|
54
|
+
turn: number | undefined;
|
|
55
|
+
user: string[];
|
|
56
|
+
assistant: string[];
|
|
57
|
+
tools: Map<string, number>;
|
|
58
|
+
resultBytes: number;
|
|
59
|
+
errors: string[];
|
|
60
|
+
writes: string[];
|
|
61
|
+
}
|
|
62
|
+
/** One digest section: everything from one non-injected user message to the next. */
|
|
63
|
+
interface Section {
|
|
64
|
+
turns: Turn[];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Group append-origin surface events into per-turn digest material.
|
|
68
|
+
*
|
|
69
|
+
* A section starts at each non-injected user message; assistant-only runs
|
|
70
|
+
* before the first user message join the first section. Turn numbers come from
|
|
71
|
+
* the log: `turn/start` carries `{turn}` and `assistant/message` carries
|
|
72
|
+
* `data.turn`. A user message carries no turn of its own, so it inherits the
|
|
73
|
+
* last one seen.
|
|
74
|
+
*
|
|
75
|
+
* @param events - the digest source in log order.
|
|
76
|
+
* @returns one section per non-injected user message.
|
|
77
|
+
*/
|
|
78
|
+
export declare function buildSections(events: readonly SessionEvent[]): Section[];
|
|
79
|
+
/**
|
|
80
|
+
* Render the digest under a hard character budget.
|
|
81
|
+
*
|
|
82
|
+
* The two truncation tiers are the plan's T1 and T1', in that order:
|
|
83
|
+
*
|
|
84
|
+
* - **T1** user prose is verbatim unless it alone exceeds `USER_BUDGET_SHARE` of
|
|
85
|
+
* the budget, then the oldest turns are cut first.
|
|
86
|
+
* - **T1'** assistant prose is verbatim unless the whole digest overflows, then
|
|
87
|
+
* it is cut evenly per turn down to `MIN_TURN_CHARS`.
|
|
88
|
+
*
|
|
89
|
+
* T1' runs *after* T1 because a user's own words are the least reconstructible
|
|
90
|
+
* thing in the log: an assistant's reply can be inferred from its tool calls
|
|
91
|
+
* and the surrounding turns, a user's request cannot. The 800-character floor
|
|
92
|
+
* exists so a tight budget thins every turn instead of erasing the last few.
|
|
93
|
+
*
|
|
94
|
+
* Neither tier runs in practice: over the eight measured sessions the largest
|
|
95
|
+
* digest was 44542 tokens against a 196752 budget, and user prose totalled 2591
|
|
96
|
+
* tokens against a 98376 cap. They are a guard against a pasted file, not the
|
|
97
|
+
* normal path.
|
|
98
|
+
*
|
|
99
|
+
* @param events - the digest source in log order.
|
|
100
|
+
* @param budgetTokens - hard ceiling in the meter's fixed 4-chars-per-token heuristic.
|
|
101
|
+
* @returns the digest text, never longer than `budgetTokens * 4` characters.
|
|
102
|
+
*/
|
|
103
|
+
export declare function renderDigest(events: readonly SessionEvent[], budgetTokens: number): string;
|
|
104
|
+
/**
|
|
105
|
+
* The surface seqs this compaction shadows, recovered from the summarization
|
|
106
|
+
* input's message count.
|
|
107
|
+
*
|
|
108
|
+
* `SummarizationInput` carries no seqs, but the base class always builds it as
|
|
109
|
+
* `[system head?] + shadowed region in surface order`, so the count recovers the
|
|
110
|
+
* span exactly. Verified against 120 real compactions: the recovered span
|
|
111
|
+
* matched the shadowed span seq-for-seq every time.
|
|
112
|
+
*
|
|
113
|
+
* @param session - session whose surface the input was built from.
|
|
114
|
+
* @param input - the replayed prefix handed to `summarize`.
|
|
115
|
+
* @returns the shadowed surface seqs in order.
|
|
116
|
+
*/
|
|
117
|
+
export declare function shadowedSeqs(session: Session, input: SummarizationInput): SessionSeq[];
|
|
118
|
+
/**
|
|
119
|
+
* Pick the log events the digest is built from: every append-origin surface
|
|
120
|
+
* event, minus the retained tail.
|
|
121
|
+
*
|
|
122
|
+
* The retained tail is what stays on the surface after this compaction, so
|
|
123
|
+
* excluding it keeps the digest and the surviving surface disjoint. Everything
|
|
124
|
+
* else has either already been shadowed by an earlier checkpoint or is being
|
|
125
|
+
* shadowed now, and must appear in the digest or it is lost.
|
|
126
|
+
*
|
|
127
|
+
* Events a `{op: 'replace'}` copy superseded still qualify, because their own
|
|
128
|
+
* marker is `'append'`. That is deliberate: tool results become a one-line
|
|
129
|
+
* pointer either way, so including the pre-replacement original changes the
|
|
130
|
+
* digest by 19-109 tokens across the measured sessions, and never dropping
|
|
131
|
+
* content is worth more than that.
|
|
132
|
+
*
|
|
133
|
+
* @param session - session whose log supplies the events.
|
|
134
|
+
* @param retainedSeqs - seqs that remain on the surface after this compaction.
|
|
135
|
+
* @returns the events the digest is built from, in log order.
|
|
136
|
+
*/
|
|
137
|
+
export declare function digestSource(session: Session, retainedSeqs: ReadonlySet<SessionSeq>): readonly SessionEvent[];
|
|
138
|
+
/**
|
|
139
|
+
* Deterministic compaction backend: verbatim prose, pointerized tools, no model
|
|
140
|
+
* call.
|
|
141
|
+
*
|
|
142
|
+
* Everything except `summarize()` is inherited. The base class prices pressure,
|
|
143
|
+
* selects the span, writes the durable `compaction/start` ... `compaction/end`
|
|
144
|
+
* pair, applies the shrink guard and records `compaction/summary`, so this
|
|
145
|
+
* subclass cannot diverge from the host's transaction semantics.
|
|
146
|
+
*/
|
|
147
|
+
export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
148
|
+
/**
|
|
149
|
+
* Build the replacement checkpoint content from the session log.
|
|
150
|
+
*
|
|
151
|
+
* The budget is the shrink guard's own bound rather than only the fixed
|
|
152
|
+
* ceiling: the guard compares the framed digest against the route price of
|
|
153
|
+
* the span being replaced, so a digest accumulated over a long history
|
|
154
|
+
* against a small span is the one case that can fail. Deriving the budget
|
|
155
|
+
* from that same number makes the failure impossible by construction instead
|
|
156
|
+
* of relying on measurement.
|
|
157
|
+
*
|
|
158
|
+
* @param input - replayed conversation prefix the base class is shadowing.
|
|
159
|
+
* @param agent - owner of the session being compacted.
|
|
160
|
+
* @param _signal - unused: the digest is synchronous and cannot be cancelled midway.
|
|
161
|
+
* @returns text summary blocks, unmarked by any LLM call.
|
|
162
|
+
*/
|
|
163
|
+
protected summarize(input: SummarizationInput, agent: Agent, _signal?: AbortSignal): Promise<SummaryResult>;
|
|
164
|
+
}
|
|
165
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logictan/dsh-verbatim-compaction",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deterministic compaction backend that keeps conversation prose verbatim in the checkpoint instead of re-summarizing it: user and assistant text survive word for word, tool results collapse to one-line pointers, reasoning is never read, and no model call is made.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/types/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dsh": {
|
|
16
|
+
"bundle": {
|
|
17
|
+
"patch": "./cordis.patch.yml"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"lib",
|
|
22
|
+
"cordis.patch.yml",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "node build.mjs",
|
|
28
|
+
"prepare": "node build.mjs",
|
|
29
|
+
"prepack": "node build.mjs",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "node build.mjs && node --test \"test/*.spec.mjs\""
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"dsh",
|
|
35
|
+
"deepseek-harness",
|
|
36
|
+
"plugin",
|
|
37
|
+
"compaction",
|
|
38
|
+
"context",
|
|
39
|
+
"checkpoint",
|
|
40
|
+
"summarization"
|
|
41
|
+
],
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/dale0525/dsh-plugins.git",
|
|
46
|
+
"directory": "packages/dsh-verbatim-compaction"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
50
|
+
"@deepseek-ai/dsh-agent": ">=0.1.6-alpha.1 <0.2.0 || ^0.2.1-rc.2",
|
|
51
|
+
"@deepseek-ai/dsh-compaction": ">=0.1.6-alpha.1 <0.2.0 || ^0.2.1-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-compaction-basic": ">=0.1.6-alpha.1 <0.2.0 || ^0.2.1-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-llm": ">=0.1.6-alpha.1 <0.2.0 || ^0.2.1-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-session": ">=0.1.6-alpha.1 <0.2.0 || ^0.2.1-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-token-meter": ">=0.1.6-alpha.1 <0.2.0 || ^0.2.1-rc.2"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@deepseek-ai/cordis": "^4.0.5",
|
|
59
|
+
"@deepseek-ai/dsh-agent": "0.2.1-rc.2",
|
|
60
|
+
"@deepseek-ai/dsh-compaction": "0.2.1-rc.2",
|
|
61
|
+
"@deepseek-ai/dsh-compaction-basic": "0.2.1-rc.2",
|
|
62
|
+
"@deepseek-ai/dsh-llm": "0.2.1-rc.2",
|
|
63
|
+
"@deepseek-ai/dsh-session": "0.2.1-rc.2",
|
|
64
|
+
"@deepseek-ai/dsh-token-meter": "0.2.1-rc.2",
|
|
65
|
+
"typescript": "^5.9.0",
|
|
66
|
+
"@types/node": "^22.20.0"
|
|
67
|
+
}
|
|
68
|
+
}
|