dsh-compaction-tower 0.1.39 → 0.1.42
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/README.md +14 -2
- package/lib/engine.js +8 -0
- package/lib/tower.js +170 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ DSH 的分块自动压缩工具:**无需主动压缩** —— 旧历史提前
|
|
|
8
8
|
|
|
9
9
|
## 它做什么
|
|
10
10
|
|
|
11
|
-
- **压缩由它自己决定,不用模型操心**:观察者在后台把早期历史蒸成「块」写进日志 —— 只进日志不进上下文,模型看不见。每次请求前和每个回合结束前各判定一次,按「每层库存」决定蒸几块,并发发起(默认 10 个)。**每层库存 =(触发线 − 目标线)÷
|
|
11
|
+
- **压缩由它自己决定,不用模型操心**:观察者在后台把早期历史蒸成「块」写进日志 —— 只进日志不进上下文,模型看不见。每次请求前和每个回合结束前各判定一次,按「每层库存」决定蒸几块,并发发起(默认 10 个)。**每层库存 =(触发线 − 目标线)÷ 一块能回收的量,再留 1 块当安全垫**;一块顶掉的是「覆盖内容 − 自己的摘要」,按 80% 估,默认 45% / 15% / 5% 时是 **9 块**。
|
|
12
12
|
- **越线那一刻不等模型**:块是提前蒸好的,提交就是一次查表,再交给宿主的压缩引擎换进表层 —— 不卡顿。
|
|
13
13
|
- **压过的内容还能取回来**:`decompress` 看回被压缩的历史,`search_context` 在含已压缩内容的全部历史里搜。
|
|
14
14
|
|
|
@@ -183,9 +183,21 @@ DSH 的分块自动压缩工具:**无需主动压缩** —— 旧历史提前
|
|
|
183
183
|
> 为什么不用宿主的 settings 服务:`settings.describe()` 只列出 **profile 文档里**的条目,
|
|
184
184
|
> 由 bundle patch 插入的行不在其中——我们这条 `compaction-tower` 正是被插进去的,拿不到表单。
|
|
185
185
|
|
|
186
|
+
## 接管了宿主的哪些入口
|
|
187
|
+
|
|
188
|
+
| 入口 | 原来的行为 | 装了以后 |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| 到阈值自动压(`compactIfNeeded`) | 宿主自己挑一段、现调模型摘要 | 换成塔的判定与提交 |
|
|
191
|
+
| `/compact` 命令(`compactNow`) | 绕过前者,直接 `compactSurfaceRegion` 压一段 | **提交塔里已经蒸好的块**;没块时可压内容为零,什么都不做 |
|
|
192
|
+
| `summarize` | 宿主现场摘要 | 有现成块就直接返回,零 LLM 调用 |
|
|
193
|
+
|
|
194
|
+
`/compact` 这一条必须接管:它原来走的是另一条路径,压出来的 checkpoint 没有块号,账本解释不了它,被它盖住的块会悄悄失效。
|
|
195
|
+
|
|
196
|
+
**装之前已经被宿主压过的会话**:插件会把那些无主 checkpoint 认领成「只读块」——可见、不进库存、永远不会被再次提交,但 `decompress` 仍然能把它们背后被盖掉的原文取回来(`covers` 取自宿主写下的 `sourceEventSeqs`)。
|
|
197
|
+
|
|
186
198
|
## 已知限制
|
|
187
199
|
|
|
188
|
-
- **原型补丁依赖方法名**:`compactIfNeeded` / `summarize` 是官方扩展点,但宿主大版本升级后需要复查。缺失时插件会记 warning 并降级为「只提供工具」。
|
|
200
|
+
- **原型补丁依赖方法名**:`compactIfNeeded` / `summarize` / `compactNow` 是官方扩展点,但宿主大版本升级后需要复查。缺失时插件会记 warning 并降级为「只提供工具」。
|
|
189
201
|
- **块记录每次现读**:`blocksOf()` 遍历会话全量事件。请求前的排程有 5 秒节流、回合尾才无条件跑一次,所以长会话下是「偶尔 O(事件数)」,不是每个 step。
|
|
190
202
|
- **默认比例未经长会话标定**:`triggerRatio 0.45` 按 1M 窗口推;`distillMinRunRatio 0.05` 是单块分量(≈52K @ 1M),实际频率与块大小待多轮观察。
|
|
191
203
|
|
package/lib/engine.js
CHANGED
|
@@ -54,6 +54,14 @@ export function installEnginePatches(tower) {
|
|
|
54
54
|
current?.rememberEngine?.(agent, this);
|
|
55
55
|
return current.serveSummary(this, originalSummarize, input, agent, signal);
|
|
56
56
|
};
|
|
57
|
+
// `/compact` 走的是 compactNow,它直接调 compactSurfaceRegion、绕过 compactIfNeeded。
|
|
58
|
+
// 不接管的话,用户敲一次命令就会压出一个没有块号的 checkpoint,把账本打出洞。
|
|
59
|
+
if (typeof proto.compactNow === 'function') {
|
|
60
|
+
proto.compactNow = function patchedCompactNow(agent, signal, commandId) {
|
|
61
|
+
current?.rememberEngine?.(agent, this);
|
|
62
|
+
return current.manualCompact(this, agent, signal, commandId);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
57
65
|
|
|
58
66
|
Object.defineProperty(proto, PATCHED, { value: true, configurable: true });
|
|
59
67
|
current = tower;
|
package/lib/tower.js
CHANGED
|
@@ -43,6 +43,14 @@ function captureService(ctx, name) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* 一块大约能回收它覆盖内容的多少。
|
|
48
|
+
*
|
|
49
|
+
* 提交一块换掉的是 covered(原文 / 下层摘要),换进来的是 summary(自己的摘要),
|
|
50
|
+
* 所以真实回收 = covered − summary。实测约 92%,这里取 0.8 留余量。
|
|
51
|
+
*/
|
|
52
|
+
const RECLAIM_RATE = 0.8;
|
|
53
|
+
|
|
46
54
|
/** 把块记录变成模型可见的消息块。头部 + 正文。 */
|
|
47
55
|
export function summaryBlocks(block, childBlockIds = []) {
|
|
48
56
|
const header = blockHeader({
|
|
@@ -101,6 +109,109 @@ export class Tower {
|
|
|
101
109
|
this.commitRequests = new WeakSet();
|
|
102
110
|
/** 事实修正系数:WeakMap<Session, {scale, samples}>(见 calibration) */
|
|
103
111
|
this.fits = new WeakMap();
|
|
112
|
+
/** 已经认领过宿主遗留 checkpoint 的会话(每次进程一次就够)。 */
|
|
113
|
+
this.imported = new WeakSet();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 认领「宿主压过、账本没记」的段。
|
|
118
|
+
*
|
|
119
|
+
* 装插件之前宿主压缩留下的 checkpoint 没有块号:对账本来说它是一个洞 ——
|
|
120
|
+
* 面板解释不了它,被它盖住的块悄悄失效却还挂着。这里把它登记成一个**只读块**:
|
|
121
|
+
*
|
|
122
|
+
* - `covers` 从事件的 `surfaceOp` 还原(宿主写的是 replace 区间);
|
|
123
|
+
* - 写一条普通的 block 记录 + commit 记录,于是身份、状态、面板全都自动对上;
|
|
124
|
+
* - 但它的 covers 已经不在表层,`usable()` 判 false → **永远不会被再次提交**;
|
|
125
|
+
* - `decompress` 直接读日志,所以这些块的原文仍然取得回来。
|
|
126
|
+
*
|
|
127
|
+
* 幂等:某条 checkpoint 已经有块号(不管是我们提交的还是上次认领的)就跳过。
|
|
128
|
+
* @param {object} agent
|
|
129
|
+
*/
|
|
130
|
+
async ensureImported(agent) {
|
|
131
|
+
const session = agent?.session;
|
|
132
|
+
if (session === undefined || session === null) return;
|
|
133
|
+
if (this.imported.has(session)) return;
|
|
134
|
+
try {
|
|
135
|
+
const blocks = readBlocks(session);
|
|
136
|
+
const owner = checkpointOwner(session);
|
|
137
|
+
let added = 0;
|
|
138
|
+
for (const seq of session.surface.nodes) {
|
|
139
|
+
if (owner.has(seq)) continue;
|
|
140
|
+
const event = session.eventAt(seq);
|
|
141
|
+
if (event?.type !== 'user/message') continue;
|
|
142
|
+
const source = event.data?.source;
|
|
143
|
+
if (source === undefined || !isCompactCheckpointSource(source)) continue;
|
|
144
|
+
const op = event.surfaceOp;
|
|
145
|
+
if (op?.op !== 'replace') continue;
|
|
146
|
+
// 宿主写 replace 时同时记了 sourceEventSeqs(被换掉的节点清单),比区间精确;
|
|
147
|
+
// 老格式没有这个字段就退回区间遍历。
|
|
148
|
+
const shadowed = Array.isArray(event.sourceEventSeqs) && event.sourceEventSeqs.length > 0
|
|
149
|
+
? event.sourceEventSeqs
|
|
150
|
+
: (() => {
|
|
151
|
+
const list = [];
|
|
152
|
+
for (let cursor = op.startSeq; cursor <= op.endSeq; cursor += 1) list.push(cursor);
|
|
153
|
+
return list;
|
|
154
|
+
})();
|
|
155
|
+
const covers = [];
|
|
156
|
+
const children = new Set();
|
|
157
|
+
for (const cursor of shadowed) {
|
|
158
|
+
const covered = session.eventAt(cursor);
|
|
159
|
+
if (covered === undefined) continue;
|
|
160
|
+
if (session.deriveEventMessage(covered) === null) continue;
|
|
161
|
+
covers.push(cursor);
|
|
162
|
+
const child = owner.get(cursor);
|
|
163
|
+
if (child !== undefined) children.add(child);
|
|
164
|
+
}
|
|
165
|
+
if (covers.length === 0) continue;
|
|
166
|
+
|
|
167
|
+
const text = (event.data?.content ?? [])
|
|
168
|
+
.filter((part) => part.type === 'text')
|
|
169
|
+
.map((part) => part.text)
|
|
170
|
+
.join('\n');
|
|
171
|
+
const summaryTokens = Math.max(1, this.estimateText(text));
|
|
172
|
+
let coveredTokens = 0;
|
|
173
|
+
for (const cursor of covers) {
|
|
174
|
+
try {
|
|
175
|
+
coveredTokens += this.ctx.tokenMeter.estimateMessage(session.deriveEventMessage(session.eventAt(cursor)));
|
|
176
|
+
} catch {
|
|
177
|
+
// 估不出来就少算一点:这个数只影响合并配额,不影响正确性。
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const tier = children.size === 0
|
|
181
|
+
? 1
|
|
182
|
+
: Math.max(...[...children].map((id) => Number(blocks.find((b) => b.blockId === id)?.tier ?? 1))) + 1;
|
|
183
|
+
const blockId = this.allocateBlockId(session, blocks);
|
|
184
|
+
const record = {
|
|
185
|
+
blockId,
|
|
186
|
+
tier,
|
|
187
|
+
kind: 'import',
|
|
188
|
+
covers,
|
|
189
|
+
coveredTokens: Math.max(coveredTokens, summaryTokens),
|
|
190
|
+
summaryTokens,
|
|
191
|
+
childBlockIds: [...children],
|
|
192
|
+
summary: [{ type: 'text', text }],
|
|
193
|
+
origin: { kind: 'foreign', checkpointSeq: seq, compactionId: source?.compactionId ?? null },
|
|
194
|
+
tokensEstimated: true,
|
|
195
|
+
createdAt: new Date().toISOString(),
|
|
196
|
+
};
|
|
197
|
+
appendBlock(session, record);
|
|
198
|
+
appendCommit(session, {
|
|
199
|
+
blockId,
|
|
200
|
+
checkpointSeq: seq,
|
|
201
|
+
...(typeof source?.compactionId === 'string' ? { compactionId: source.compactionId } : {}),
|
|
202
|
+
});
|
|
203
|
+
blocks.push(record);
|
|
204
|
+
owner.set(seq, blockId);
|
|
205
|
+
added += 1;
|
|
206
|
+
}
|
|
207
|
+
this.imported.add(session);
|
|
208
|
+
if (added > 0) {
|
|
209
|
+
this.debug(`认领了 ${added} 段宿主压缩留下的历史(只读块)`);
|
|
210
|
+
await this.flushSession(agent, session);
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
this.debug(`认领宿主 checkpoint 失败:${error?.message ?? error}`);
|
|
214
|
+
}
|
|
104
215
|
}
|
|
105
216
|
|
|
106
217
|
/**
|
|
@@ -130,18 +241,23 @@ export class Tower {
|
|
|
130
241
|
/**
|
|
131
242
|
* 每层该备多少份库存:把「触发线 → 目标线」这段缺口填满,再留 1 份安全垫。
|
|
132
243
|
*
|
|
244
|
+
* 一块顶掉的不是它覆盖的全部原文,而是 covered − summary(摘要本身还占着)。
|
|
245
|
+
* 实测回收率约 92%,这里按 80% 算 —— 模糊的正确:把份数放大一点,
|
|
246
|
+
* 宁可多备一块,也别在越线那一刻差一点点。
|
|
247
|
+
*
|
|
133
248
|
* 不做成配置项 —— 它完全由 triggerRatio / targetRatio / 一个单位 推出来,
|
|
134
249
|
* 手填只会和这三个数打架。
|
|
135
250
|
* @param {number} usable 可用预算(窗口 − 固定开销)
|
|
136
|
-
* @returns {number
|
|
251
|
+
* @returns {{unit:number, reclaim:number, blocks:number, tokens:number}}
|
|
137
252
|
*/
|
|
138
|
-
|
|
253
|
+
reserveOf(usable) {
|
|
139
254
|
const unit = Math.max(1, this.config.distillMinRunRatio * usable);
|
|
255
|
+
const reclaim = Math.max(1, Math.round(unit * RECLAIM_RATE));
|
|
140
256
|
const gap = Math.max(0, this.config.triggerRatio - this.config.targetRatio) * usable;
|
|
141
257
|
// 减一个极小量再 ceil:0.45−0.15 在浮点里是 0.30000000000000004,
|
|
142
258
|
// 直接 ceil(6.000000000000001) 会多算一份。
|
|
143
|
-
const
|
|
144
|
-
return
|
|
259
|
+
const blocks = Math.ceil(gap / reclaim - 1e-9) + 1;
|
|
260
|
+
return { unit, reclaim, blocks, tokens: blocks * reclaim };
|
|
145
261
|
}
|
|
146
262
|
|
|
147
263
|
/**
|
|
@@ -580,6 +696,9 @@ export class Tower {
|
|
|
580
696
|
async compactIfNeeded(engine, agent, trigger, signal) {
|
|
581
697
|
this.stats.pressureCalls = (this.stats.pressureCalls ?? 0) + 1;
|
|
582
698
|
this.debug(`compactIfNeeded 被调用:trigger=${trigger}`);
|
|
699
|
+
// 先把「宿主压过、账本没记」的段认领成块:否则它们是账本上的洞,
|
|
700
|
+
// 库存与提交计划都会被这段无名历史带偏。
|
|
701
|
+
await this.ensureImported(agent);
|
|
583
702
|
// 手动提交没法在空闲时做:宿主的 compactRegion 要求事件落在一个「打开的回合」里。
|
|
584
703
|
// 所以按钮只排队,等下一回合开始、引擎来问的时候按目标 0 一次提交完。
|
|
585
704
|
const manual = this.commitRequests.has(agent.session);
|
|
@@ -601,6 +720,7 @@ export class Tower {
|
|
|
601
720
|
|
|
602
721
|
// 一次请求可以连续提交多块:每块自己一对 compaction 事件,区间互不重叠。
|
|
603
722
|
let first = null;
|
|
723
|
+
const results = [];
|
|
604
724
|
let succeeded = 0;
|
|
605
725
|
let lastError = null;
|
|
606
726
|
const surface = new Set(agent.session.surface.nodes);
|
|
@@ -619,6 +739,7 @@ export class Tower {
|
|
|
619
739
|
if (result !== null) {
|
|
620
740
|
this.stats.committed += 1;
|
|
621
741
|
succeeded += 1;
|
|
742
|
+
results.push(result);
|
|
622
743
|
this.recordCheckpoint(agent.session, block.blockId, result);
|
|
623
744
|
first ??= result;
|
|
624
745
|
}
|
|
@@ -638,9 +759,48 @@ export class Tower {
|
|
|
638
759
|
...previous,
|
|
639
760
|
outcome: { planned: plan.blocks.length, succeeded, error: lastError },
|
|
640
761
|
});
|
|
762
|
+
// 手动压缩(/compact)一次可能提交多块,把总量合起来回报给命令层,
|
|
763
|
+
// 否则它只会说「压了 1 段」。
|
|
764
|
+
if (manual === true && results.length > 1) {
|
|
765
|
+
return {
|
|
766
|
+
...results[results.length - 1],
|
|
767
|
+
shadowedSeqs: results.flatMap((item) => item.shadowedSeqs ?? []),
|
|
768
|
+
shadowedTokenCount: results.reduce((sum, item) => sum + (item.shadowedTokenCount ?? 0), 0),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
641
771
|
return first;
|
|
642
772
|
}
|
|
643
773
|
|
|
774
|
+
/**
|
|
775
|
+
* `/compact` 的落点:把塔里已经蒸好的块提交进上下文。
|
|
776
|
+
*
|
|
777
|
+
* 装了本插件之后,「压缩」这个动作的语义就是提交块 —— 宿主原来那套
|
|
778
|
+
* 「现场挑一段、现调模型摘要」已经不该再跑了(它压出来的东西没有块号,
|
|
779
|
+
* 账本无法解释)。所以这里不复用原实现,而是走自己的提交路径。
|
|
780
|
+
*
|
|
781
|
+
* 宿主要求手动压缩独占 agent,所以外面套一层 `runMaintenance`。
|
|
782
|
+
* @param {object} engine
|
|
783
|
+
* @param {object} agent
|
|
784
|
+
* @param {AbortSignal} [signal]
|
|
785
|
+
* @returns {Promise<object|null>} 宿主 compactRegion 的结果;没有可提交的块时为 null
|
|
786
|
+
*/
|
|
787
|
+
async manualCompact(engine, agent, signal) {
|
|
788
|
+
const withMaintenance = typeof agent?.runMaintenance === 'function'
|
|
789
|
+
? (task) => agent.runMaintenance(task)
|
|
790
|
+
: (task) => task(signal);
|
|
791
|
+
return withMaintenance(async (agentSignal) => {
|
|
792
|
+
const operation = agentSignal === undefined || signal === undefined
|
|
793
|
+
? (agentSignal ?? signal)
|
|
794
|
+
: AbortSignal.any([agentSignal, signal]);
|
|
795
|
+
this.commitRequests.add(agent.session);
|
|
796
|
+
try {
|
|
797
|
+
return await this.compactIfNeeded(engine, agent, 'manual', operation);
|
|
798
|
+
} finally {
|
|
799
|
+
this.commitRequests.delete(agent.session);
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
644
804
|
/**
|
|
645
805
|
* 提交成功后,把「块号 ↔ checkpoint 节点」记进账本。
|
|
646
806
|
*
|
|
@@ -847,6 +1007,9 @@ export class Tower {
|
|
|
847
1007
|
const unit = Math.min(this.config.distillMinRunRatio * usable, this.config.observerMaxTokens);
|
|
848
1008
|
// unit 是真实口径,而 availableUnits 累加的是节点估算值 → 换算成估算口径再比。
|
|
849
1009
|
const unitH = Math.max(1, Math.round(unit / scale));
|
|
1010
|
+
// 库存与配额都按「一块能回收多少」记账:inventory 数的是 covered − summary,
|
|
1011
|
+
// 拿 unit 当分母会永远差一点点(差的就是摘要本身的大小)。
|
|
1012
|
+
const reserve = this.reserveOf(usable);
|
|
850
1013
|
const inventory = this.inventoryOf(session, blocks);
|
|
851
1014
|
const available = this.availableUnits({
|
|
852
1015
|
nodes, systemSeqs, coveredSeqs, checkpointSeqs, tierOf, unit: unitH, blocks,
|
|
@@ -854,8 +1017,8 @@ export class Tower {
|
|
|
854
1017
|
const plan = planObservation({
|
|
855
1018
|
available,
|
|
856
1019
|
inventory,
|
|
857
|
-
reserveTokens:
|
|
858
|
-
unitTokens:
|
|
1020
|
+
reserveTokens: reserve.tokens,
|
|
1021
|
+
unitTokens: reserve.reclaim,
|
|
859
1022
|
concurrency: this.config.observerConcurrency,
|
|
860
1023
|
});
|
|
861
1024
|
/** 记下这次观察判定,面板据此回答「为什么没蒸」。 */
|
|
@@ -871,7 +1034,7 @@ export class Tower {
|
|
|
871
1034
|
};
|
|
872
1035
|
if (plan.length === 0) {
|
|
873
1036
|
// 全部材料都在保护区里 或 库存已够:两种都记下来,别让人以为观察者没跑。
|
|
874
|
-
const stocked = [...available.keys()].some((tier) => (inventory.get(tier) ?? 0) >=
|
|
1037
|
+
const stocked = [...available.keys()].some((tier) => (inventory.get(tier) ?? 0) >= reserve.tokens);
|
|
875
1038
|
note({ kind: available.size === 0 ? 'no-material' : stocked ? 'stocked' : 'no-quota' });
|
|
876
1039
|
this.debug(`不补蒸:库存=${[...inventory].map(([t, v]) => `T${t}:${Math.round(v)}`).join(',') || '空'} 可用=${[...available].map(([t, v]) => `T${t}:${v}`).join(',') || '空'}`);
|
|
877
1040
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
2
|
"name": "dsh-compaction-tower",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42",
|
|
4
4
|
"description": "Layered background distillation for DeepSeek Harness: distill early history ahead of time, commit whole blocks with zero LLM calls, and expose decompress/search_context to the model",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|