dsh-compaction-tower 0.0.0-stage → 0.1.39
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 +210 -3
- package/docs/compression.png +0 -0
- package/docs/config.png +0 -0
- package/docs/design/DSH-Tower-/346/217/222/344/273/266/350/256/276/350/256/241/350/247/204/346/240/274.md +723 -0
- package/docs/design/compaction.md +157 -0
- package/docs/panel-narrow.png +0 -0
- package/docs/panel.png +0 -0
- package/dsh.bundle.patch.yml +9 -0
- package/lib/client.js +841 -0
- package/lib/config-store.js +146 -0
- package/lib/config.js +323 -0
- package/lib/core.js +268 -0
- package/lib/engine.js +66 -0
- package/lib/index.js +101 -0
- package/lib/prompts.js +124 -0
- package/lib/select.js +248 -0
- package/lib/server.js +253 -0
- package/lib/state.js +192 -0
- package/lib/summarize.js +91 -0
- package/lib/tools.js +262 -0
- package/lib/tower.js +1366 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
package/lib/select.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 选段:纯函数,不碰 session。
|
|
3
|
+
*
|
|
4
|
+
* 所有决策都建立在宿主的 TokenSurfaceNode 上(seq / tokens / heuristicTokens),
|
|
5
|
+
* 这样触发判断与提交范围用的是同一把尺子。
|
|
6
|
+
*
|
|
7
|
+
* 两个消费者:
|
|
8
|
+
* - 提交计划 planCommits:请求前决定「压哪几块」,按层级升序(保真优先);
|
|
9
|
+
* - 观察者材料 selectRawRun / selectMergeRun:回合尾决定「蒸什么」。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 表层的「可压缩区」:跳过开头 system,止于第一个受保护节点。
|
|
14
|
+
*
|
|
15
|
+
* @param {ReadonlyArray<{seq:number}>} nodes
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {Set<number>} options.systemSeqs
|
|
18
|
+
* @param {Set<number>} options.protectedSeqs
|
|
19
|
+
* @returns {{seqs:number[], startIndex:number, endIndex:number}|null}
|
|
20
|
+
*/
|
|
21
|
+
export function headRun(nodes, { systemSeqs, protectedSeqs }) {
|
|
22
|
+
let start = 0;
|
|
23
|
+
while (start < nodes.length && systemSeqs.has(nodes[start].seq)) start += 1;
|
|
24
|
+
let end = start;
|
|
25
|
+
while (end < nodes.length && !protectedSeqs.has(nodes[end].seq)) end += 1;
|
|
26
|
+
if (end === start) return null;
|
|
27
|
+
const seqs = [];
|
|
28
|
+
for (let i = start; i < end; i += 1) seqs.push(nodes[i].seq);
|
|
29
|
+
return { seqs, startIndex: start, endIndex: end };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 一块材料是否还「可用」:covers 全部仍在表层,且摘要确实更小。
|
|
34
|
+
*
|
|
35
|
+
* 提交计划与分层库存都必须过这一关,但只共用到这里:提交还要求材料落在可压区
|
|
36
|
+
* (保护区之外),库存不管保护区 —— 蒸馏本来就不受保护区限制,块只是提前备好,
|
|
37
|
+
* 等材料滑出保护区再提交。
|
|
38
|
+
*
|
|
39
|
+
* 之所以按「还在不在表层」判,是因为塔外的压缩(宿主的 /compact,或老会话里
|
|
40
|
+
* 已经存在的 checkpoint)会把一段历史换成没有块号的 checkpoint。覆盖那段的块
|
|
41
|
+
* 从此永远提交不了;库存若照旧把它们算成待命,观察者会以为库存满了而停止补料,
|
|
42
|
+
* 塔就慢性停摆了。
|
|
43
|
+
*
|
|
44
|
+
* @param {object} block 块记录
|
|
45
|
+
* @param {object} scope
|
|
46
|
+
* @param {Set<number>} scope.surfaceSeqs 仍在表层的 seq 集合
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function usable(block, { surfaceSeqs }) {
|
|
50
|
+
if (!Array.isArray(block.covers) || block.covers.length === 0) return false;
|
|
51
|
+
if (!block.covers.every((seq) => surfaceSeqs.has(seq))) return false;
|
|
52
|
+
const summaryTokens = Number(block.summaryTokens ?? 0);
|
|
53
|
+
// 公理:提交只缩小
|
|
54
|
+
return summaryTokens > 0 && summaryTokens < Number(block.coveredTokens ?? 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 提交计划:在可压缩区里按「层级升序、同层最老优先」挑块,累加到目标即停。
|
|
59
|
+
*
|
|
60
|
+
* 这就是 Sisyph 的「最少提交 + 分层优先」:先用 T1(原文换摘要,只损失一层),
|
|
61
|
+
* T1 用尽仍不达标才动 T2(若干已提交 checkpoint 合成一条)。所有材料一次算清,
|
|
62
|
+
* 不会出现「某块压过线就卡住」。
|
|
63
|
+
*
|
|
64
|
+
* @param {object} params
|
|
65
|
+
* @param {Array<object>} params.blocks 全部块记录
|
|
66
|
+
* @param {number[]} params.runSeqs 可压缩区的表层 seq(按位置顺序)
|
|
67
|
+
* @param {Set<number>} params.surfaceSeqs 仍在表层的 seq
|
|
68
|
+
* @param {number} params.contentTokens 当前内容用量
|
|
69
|
+
* @param {number} params.targetTokens 目标
|
|
70
|
+
* @returns {{blocks:Array<object>, projectedTokens:number}}
|
|
71
|
+
*/
|
|
72
|
+
export function planCommits({ blocks, runSeqs, surfaceSeqs, contentTokens, targetTokens }) {
|
|
73
|
+
const inRun = new Set(runSeqs);
|
|
74
|
+
const position = new Map(runSeqs.map((seq, index) => [seq, index]));
|
|
75
|
+
|
|
76
|
+
// 两道筛:材料还在(与库存同一判据)→ 材料在可压区。
|
|
77
|
+
const candidates = blocks.filter((block) => usable(block, { surfaceSeqs })
|
|
78
|
+
&& block.covers.every((seq) => inRun.has(seq)));
|
|
79
|
+
|
|
80
|
+
candidates.sort((a, b) => (Number(a.tier ?? 1) - Number(b.tier ?? 1))
|
|
81
|
+
|| ((position.get(a.covers[0]) ?? 0) - (position.get(b.covers[0]) ?? 0)));
|
|
82
|
+
|
|
83
|
+
const chosen = [];
|
|
84
|
+
let content = contentTokens;
|
|
85
|
+
for (const block of candidates) {
|
|
86
|
+
if (content <= targetTokens) break;
|
|
87
|
+
if (chosen.some((picked) => overlaps(picked, block))) continue;
|
|
88
|
+
chosen.push(block);
|
|
89
|
+
content += Number(block.summaryTokens ?? 0) - Number(block.coveredTokens ?? 0);
|
|
90
|
+
}
|
|
91
|
+
return { blocks: chosen, projectedTokens: content };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 两个块是否有覆盖交集。 */
|
|
95
|
+
function overlaps(a, b) {
|
|
96
|
+
const seen = new Set(a.covers);
|
|
97
|
+
return b.covers.some((seq) => seen.has(seq));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 观察配额:从最底层开始逐层发名额,发满并发上限就停。
|
|
102
|
+
*
|
|
103
|
+
* 每层独立记账 —— 低层块只能顶低层的缺,高层块顶不了(提交顺序是低层优先,
|
|
104
|
+
* 高层要等低层用完才会被动用)。所以「T1 有 2 个 + T2 有 2 个」不等于「库存够了」。
|
|
105
|
+
*
|
|
106
|
+
* @param {object} params
|
|
107
|
+
* @param {Map<number, number>} params.available 每层现在有几个单位的材料
|
|
108
|
+
* @param {Map<number, number>} params.inventory 每层未提交块的可回收 token
|
|
109
|
+
* @param {number} params.reserveTokens 每层目标库存(token)
|
|
110
|
+
* @param {number} params.unitTokens 一个单位多少 token
|
|
111
|
+
* @param {number} params.concurrency 一次最多发几个
|
|
112
|
+
* @returns {Array<{tier: number, count: number}>}
|
|
113
|
+
*/
|
|
114
|
+
export function planObservation({ available, inventory, reserveTokens, unitTokens, concurrency }) {
|
|
115
|
+
const tiers = [...available.keys()].sort((a, b) => a - b);
|
|
116
|
+
const plan = [];
|
|
117
|
+
let slots = Math.max(0, Math.floor(concurrency));
|
|
118
|
+
for (const tier of tiers) {
|
|
119
|
+
if (slots <= 0) break;
|
|
120
|
+
const missing = reserveTokens - (inventory.get(tier) ?? 0);
|
|
121
|
+
if (missing <= 0) continue; // 这一层库存够了,不再主动蒸
|
|
122
|
+
const need = Math.max(1, Math.ceil(missing / unitTokens));
|
|
123
|
+
const count = Math.min(need, available.get(tier) ?? 0, slots);
|
|
124
|
+
if (count <= 0) continue;
|
|
125
|
+
plan.push({ tier, count });
|
|
126
|
+
slots -= count;
|
|
127
|
+
}
|
|
128
|
+
return plan;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 观察者材料 A:一段连续的原文。
|
|
133
|
+
*
|
|
134
|
+
* 从最老的「既没被块覆盖、也不是 checkpoint」的位置起算,遇到 checkpoint 就断——
|
|
135
|
+
* 段内位置连续,covers 才能诚实地对应一个区间。
|
|
136
|
+
*
|
|
137
|
+
* 取多少:**攒够一个单位就收手**(`stopAtTokens`,通常是窗口的 5%)。多出来的原文
|
|
138
|
+
* 留在表层不动,下一回合接着蒸——所以单块分量是稳定的「一个单位」,不会因为旧料
|
|
139
|
+
* 攒了半天就长成一百多 K。`maxTokens` 只是保险丝(拦住异常巨大的单个节点),
|
|
140
|
+
* 不是常规尺寸。
|
|
141
|
+
*
|
|
142
|
+
* @param {ReadonlyArray<{seq:number, tokens:number}>} nodes
|
|
143
|
+
* @param {object} options
|
|
144
|
+
* @param {number} [options.stopAtTokens] 攒到这个量就停
|
|
145
|
+
* @returns {number[]} 可为空
|
|
146
|
+
*/
|
|
147
|
+
export function selectRawRun(nodes, { systemSeqs, protectedSeqs, coveredSeqs, checkpointSeqs, minTokens, maxTokens, stopAtTokens }) {
|
|
148
|
+
let start = 0;
|
|
149
|
+
while (start < nodes.length && systemSeqs.has(nodes[start].seq)) start += 1;
|
|
150
|
+
while (start < nodes.length) {
|
|
151
|
+
const node = nodes[start];
|
|
152
|
+
const usable = !coveredSeqs.has(node.seq) && !checkpointSeqs.has(node.seq) && !protectedSeqs.has(node.seq);
|
|
153
|
+
if (usable) break;
|
|
154
|
+
start += 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const seqs = [];
|
|
158
|
+
let tokens = 0;
|
|
159
|
+
for (let i = start; i < nodes.length; i += 1) {
|
|
160
|
+
const node = nodes[i];
|
|
161
|
+
if (protectedSeqs.has(node.seq) || coveredSeqs.has(node.seq) || checkpointSeqs.has(node.seq)) break;
|
|
162
|
+
if (tokens + node.tokens > maxTokens && seqs.length > 0) break;
|
|
163
|
+
seqs.push(node.seq);
|
|
164
|
+
tokens += node.tokens;
|
|
165
|
+
if (stopAtTokens !== undefined && tokens >= stopAtTokens) break;
|
|
166
|
+
}
|
|
167
|
+
if (tokens < minTokens) return [];
|
|
168
|
+
return seqs;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 观察者材料 B:相邻的、**同一层**的已提交 checkpoint。
|
|
173
|
+
*
|
|
174
|
+
* 只合并同层,是为了让塔真的按层长:两个 T1 合成一个 T2,两个 T2 才合成 T3。
|
|
175
|
+
* 若允许 T1 与 T2 混合合并,每次都会把「最新的浅块」吸进「上一层」,深度随
|
|
176
|
+
* 提交次数线性增长,分层就名存实亡了。
|
|
177
|
+
*
|
|
178
|
+
* 合并与蒸馏用同一把尺子:**材料攒够 minTokens(窗口的 5%)才值得花一次调用**。
|
|
179
|
+
* 所以从最老的 checkpoint 往后累加,一旦累计达到阈值就收手——这一次要合并的
|
|
180
|
+
* 就是「刚好够分量」的那几个,多出来的留给下一轮,别把一堆小 checkpoint 一次
|
|
181
|
+
* 吞掉(那样每次合并都只值几 K token,白花一次模型调用)。
|
|
182
|
+
*
|
|
183
|
+
* @param {ReadonlyArray<{seq:number, tokens:number}>} nodes
|
|
184
|
+
* @param {object} options
|
|
185
|
+
* @param {Map<number, number>} options.tierOf checkpoint 的层级
|
|
186
|
+
* @param {number} [options.memberTier] 只从这一层的 checkpoint 里选。塔要蒸第 N 层时
|
|
187
|
+
* 必须传 N-1:不过滤的话,从最老的 checkpoint 扫起会先撞上更深的 run,回来后
|
|
188
|
+
* 一比对「合并出来不是我要的层」就整轮空手,浅层的材料永远轮不到。
|
|
189
|
+
* @param {number} options.minCount 一次合并的块数下限(≥2)
|
|
190
|
+
* @param {number} options.minTokens 材料总量下限(通常是窗口的 5%)
|
|
191
|
+
* @param {number} options.maxTokens 一次合并的原文上限
|
|
192
|
+
* @param {Set<number>} [options.coveredSeqs] 已被更高层块覆盖的 checkpoint,不再重复合并
|
|
193
|
+
* @returns {number[]} 可为空
|
|
194
|
+
*/
|
|
195
|
+
export function selectMergeRun(nodes, {
|
|
196
|
+
protectedSeqs,
|
|
197
|
+
coveredSeqs,
|
|
198
|
+
checkpointSeqs,
|
|
199
|
+
tierOf,
|
|
200
|
+
memberTier,
|
|
201
|
+
minCount,
|
|
202
|
+
minTokens,
|
|
203
|
+
maxTokens,
|
|
204
|
+
}) {
|
|
205
|
+
const covered = coveredSeqs ?? new Set();
|
|
206
|
+
let index = 0;
|
|
207
|
+
while (index < nodes.length) {
|
|
208
|
+
const seq = nodes[index].seq;
|
|
209
|
+
if (!checkpointSeqs.has(seq) || protectedSeqs.has(seq) || covered.has(seq)) { index += 1; continue; }
|
|
210
|
+
const tier = tierOf.get(seq);
|
|
211
|
+
if (tier === undefined) { index += 1; continue; }
|
|
212
|
+
if (memberTier !== undefined && tier !== memberTier) { index += 1; continue; }
|
|
213
|
+
|
|
214
|
+
const run = [];
|
|
215
|
+
let tokens = 0;
|
|
216
|
+
let next = index;
|
|
217
|
+
while (next < nodes.length) {
|
|
218
|
+
const candidate = nodes[next].seq;
|
|
219
|
+
if (!checkpointSeqs.has(candidate) || protectedSeqs.has(candidate) || covered.has(candidate)) break;
|
|
220
|
+
if (tierOf.get(candidate) !== tier) break;
|
|
221
|
+
if (run.length > 0 && tokens + nodes[next].tokens > maxTokens) break;
|
|
222
|
+
run.push(candidate);
|
|
223
|
+
tokens += nodes[next].tokens;
|
|
224
|
+
next += 1;
|
|
225
|
+
if (tokens >= minTokens) break;
|
|
226
|
+
}
|
|
227
|
+
if (run.length >= minCount && tokens >= minTokens) return run;
|
|
228
|
+
index = next;
|
|
229
|
+
}
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 从「最近 retain 个 step」推导受保护的表层节点。
|
|
235
|
+
*
|
|
236
|
+
* 一个 step = 一次模型请求。保护窗口按 step 算,不按用户轮次 —— 用户发一句话之后
|
|
237
|
+
* agent 可能连续跑几十步,按轮次算会把整段新对话都圈成保护区,永远没料可蒸。
|
|
238
|
+
*
|
|
239
|
+
* @param {ReadonlyArray<number>} surfaceSeqs 表层节点 seq,按位置顺序
|
|
240
|
+
* @param {ReadonlyArray<number>} stepStarts 所有 step/start 的 seq(升序)
|
|
241
|
+
* @param {number} retainSteps 保护最近几个 step;0 = 不保护
|
|
242
|
+
* @returns {Set<number>}
|
|
243
|
+
*/
|
|
244
|
+
export function protectedTail(surfaceSeqs, stepStarts, retainSteps) {
|
|
245
|
+
if (retainSteps <= 0 || stepStarts.length === 0) return new Set();
|
|
246
|
+
const cutSeq = stepStarts[Math.max(0, stepStarts.length - retainSteps)];
|
|
247
|
+
return new Set(surfaceSeqs.filter((seq) => seq >= cutSeq));
|
|
248
|
+
}
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 给 Web 界面用的路由:状态、手动操作、配置读写。
|
|
3
|
+
*
|
|
4
|
+
* 只挂在宿主的 webServer 上(与 GUI 同源),所以客户端用相对路径 fetch 即可。
|
|
5
|
+
* headless / TUI 之类没有 webServer 的 profile 不会装载本模块。
|
|
6
|
+
*/
|
|
7
|
+
import { buildState } from './state.js';
|
|
8
|
+
import { PROMPT_PRESETS } from './prompts.js';
|
|
9
|
+
|
|
10
|
+
/** 允许的手动操作。 */
|
|
11
|
+
const ACTIONS = new Set(['distill', 'commit', 'merge']);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 注册路由。
|
|
15
|
+
* @param {object} ctx 带 webServer / sessions / agents 服务的 context
|
|
16
|
+
* @param {{tower: import('./tower.js').Tower, store: import('./config-store.js').ConfigStore}} deps
|
|
17
|
+
* @returns {() => void} disposer
|
|
18
|
+
*/
|
|
19
|
+
export function registerTowerRoutes(ctx, deps) {
|
|
20
|
+
const { tower, store } = deps;
|
|
21
|
+
// 宿主自带的 named route 不套登录鉴权(只有它自己的 /api 走),
|
|
22
|
+
// 所以这里自己问一次连接服务:没带会话 cookie 就 401,别把会话状态漏给本机任何进程。
|
|
23
|
+
// 注意用 ctx.get():直接 ctx.connection 会因为没有 inject 而抛错;
|
|
24
|
+
// 也不能在注册时就取 —— 路由比连接服务先装载,那时它还是 undefined。
|
|
25
|
+
let cached;
|
|
26
|
+
const connectionOf = () => {
|
|
27
|
+
if (cached !== undefined) return cached;
|
|
28
|
+
try {
|
|
29
|
+
cached = ctx.get?.('connection');
|
|
30
|
+
} catch {
|
|
31
|
+
cached = undefined;
|
|
32
|
+
}
|
|
33
|
+
return cached;
|
|
34
|
+
};
|
|
35
|
+
const authorize = (req, res) => {
|
|
36
|
+
const connection = connectionOf();
|
|
37
|
+
if (connection === undefined || typeof connection.authorizeIndex !== 'function') return true;
|
|
38
|
+
try {
|
|
39
|
+
return connection.authorizeIndex(req, res) === true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const disposers = [
|
|
45
|
+
ctx.webServer.register({
|
|
46
|
+
kind: 'exact',
|
|
47
|
+
path: '/tower/state',
|
|
48
|
+
handler: (req, res) => (authorize(req, res) ? handleState(ctx, tower, req, res) : undefined),
|
|
49
|
+
}),
|
|
50
|
+
ctx.webServer.register({
|
|
51
|
+
kind: 'exact',
|
|
52
|
+
path: '/tower/action',
|
|
53
|
+
handler: (req, res) => (authorize(req, res) ? handleAction(ctx, tower, req, res) : undefined),
|
|
54
|
+
}),
|
|
55
|
+
ctx.webServer.register({
|
|
56
|
+
kind: 'exact',
|
|
57
|
+
path: '/tower/config',
|
|
58
|
+
handler: (req, res) => (authorize(req, res) ? handleConfig(ctx, store, req, res) : undefined),
|
|
59
|
+
}),
|
|
60
|
+
];
|
|
61
|
+
return () => {
|
|
62
|
+
for (const dispose of disposers) dispose?.();
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** GET /tower/state?sessionId=…(省略时取最近一个活动会话) */
|
|
67
|
+
async function handleState(ctx, tower, req, res) {
|
|
68
|
+
if (req.method !== 'GET') return fail(res, 405, 'method not allowed');
|
|
69
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
70
|
+
const wanted = url.searchParams.get('sessionId');
|
|
71
|
+
const live = listSessions(ctx);
|
|
72
|
+
const session = wanted === null || wanted.length === 0
|
|
73
|
+
? live.at(-1)
|
|
74
|
+
: live.find((candidate) => String(candidate.id) === wanted);
|
|
75
|
+
|
|
76
|
+
if (session === undefined) {
|
|
77
|
+
return fail(res, 404, wanted === null ? 'no live session' : `session "${wanted}" is not open`);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const state = await buildState(tower, session);
|
|
81
|
+
return json(res, 200, { ...state, sessions: live.map((candidate) => String(candidate.id)) });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return fail(res, 500, String(error?.message ?? error));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** POST /tower/action {kind, sessionId, blockIds?} */
|
|
88
|
+
async function handleAction(ctx, tower, req, res) {
|
|
89
|
+
if (req.method !== 'POST') return fail(res, 405, 'method not allowed');
|
|
90
|
+
let body;
|
|
91
|
+
try {
|
|
92
|
+
body = JSON.parse(await readBody(req));
|
|
93
|
+
} catch {
|
|
94
|
+
return fail(res, 400, 'body must be JSON');
|
|
95
|
+
}
|
|
96
|
+
const kind = String(body?.kind ?? '');
|
|
97
|
+
if (!ACTIONS.has(kind)) return fail(res, 400, `unknown action "${kind}"`);
|
|
98
|
+
|
|
99
|
+
const sessionId = String(body?.sessionId ?? '');
|
|
100
|
+
const agent = lookupAgent(ctx, sessionId);
|
|
101
|
+
if (agent === undefined) return fail(res, 404, `no live agent for session "${sessionId}"`);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
if (kind === 'distill') {
|
|
105
|
+
await tower.observe(agent, undefined, undefined);
|
|
106
|
+
return json(res, 200, { ok: true, message: '已蒸馏一次' });
|
|
107
|
+
}
|
|
108
|
+
if (kind === 'commit') {
|
|
109
|
+
// 真正换成 checkpoint 必须发生在回合里,这里只排队(见 tower.requestCommit)。
|
|
110
|
+
const queued = tower.requestCommit(agent);
|
|
111
|
+
return json(res, 200, {
|
|
112
|
+
ok: true,
|
|
113
|
+
queued,
|
|
114
|
+
message: queued ? '已排队:下一个回合开始时提交' : '这个会话没有可排队的回合',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const blockIds = Array.isArray(body?.blockIds) ? body.blockIds.map(String) : [];
|
|
118
|
+
const record = await tower.mergeBlocks(agent, undefined, blockIds);
|
|
119
|
+
if (record === null) return fail(res, 400, '这两个块无法合并(需同层且仍在表层)');
|
|
120
|
+
return json(res, 200, { ok: true, blockId: record.blockId });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return fail(res, 500, String(error?.message ?? error));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** GET /tower/config(读快照)· POST /tower/config {patch} 或 {reset:[key…]} */
|
|
127
|
+
async function handleConfig(ctx, store, req, res) {
|
|
128
|
+
if (req.method === 'GET') {
|
|
129
|
+
try {
|
|
130
|
+
return json(res, 200, { ok: true, ...store.snapshot(), catalog: await modelCatalog(ctx), presets: PROMPT_PRESETS });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return fail(res, 500, String(error?.message ?? error));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (req.method !== 'POST') return fail(res, 405, 'method not allowed');
|
|
136
|
+
|
|
137
|
+
let body;
|
|
138
|
+
try {
|
|
139
|
+
body = JSON.parse(await readBody(req));
|
|
140
|
+
} catch {
|
|
141
|
+
return fail(res, 400, 'body must be JSON');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
if (body?.reset !== undefined) {
|
|
146
|
+
store.reset(body.reset);
|
|
147
|
+
} else if (body?.patch !== undefined) {
|
|
148
|
+
store.save(body.patch);
|
|
149
|
+
} else {
|
|
150
|
+
return fail(res, 400, '需要 patch 或 reset 字段');
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return fail(res, 400, String(error?.message ?? error));
|
|
154
|
+
}
|
|
155
|
+
return json(res, 200, { ok: true, ...store.snapshot(), catalog: await modelCatalog(ctx), presets: PROMPT_PRESETS });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 宿主的模型目录:下拉框的数据源。
|
|
160
|
+
*
|
|
161
|
+
* `llm.listModels` 可能去问 provider,所以结果缓存一分钟;某个 provider 挂了
|
|
162
|
+
* 只有它自己的模型缺失,不影响其它 provider(allSettled)。
|
|
163
|
+
*/
|
|
164
|
+
const catalogCache = { at: 0, value: undefined, pending: undefined };
|
|
165
|
+
|
|
166
|
+
async function modelCatalog(ctx) {
|
|
167
|
+
if (catalogCache.value !== undefined && Date.now() - catalogCache.at < 60000) return catalogCache.value;
|
|
168
|
+
if (catalogCache.pending !== undefined) return catalogCache.pending;
|
|
169
|
+
|
|
170
|
+
catalogCache.pending = (async () => {
|
|
171
|
+
const catalog = { providers: [], models: [] };
|
|
172
|
+
let llm;
|
|
173
|
+
try {
|
|
174
|
+
llm = ctx.get?.('llm');
|
|
175
|
+
} catch {
|
|
176
|
+
llm = undefined;
|
|
177
|
+
}
|
|
178
|
+
if (llm === undefined || llm === null || typeof llm.listProviders !== 'function') return catalog;
|
|
179
|
+
try {
|
|
180
|
+
catalog.providers = (llm.listProviders() ?? []).map((provider) => ({
|
|
181
|
+
id: String(provider.id),
|
|
182
|
+
name: String(provider.name ?? provider.id),
|
|
183
|
+
}));
|
|
184
|
+
const settled = await Promise.allSettled(
|
|
185
|
+
catalog.providers.map((provider) => llm.listModels(provider.id)),
|
|
186
|
+
);
|
|
187
|
+
settled.forEach((result, index) => {
|
|
188
|
+
if (result.status !== 'fulfilled') return;
|
|
189
|
+
const provider = catalog.providers[index].id;
|
|
190
|
+
for (const model of result.value ?? []) {
|
|
191
|
+
catalog.models.push({
|
|
192
|
+
provider,
|
|
193
|
+
id: String(model.id),
|
|
194
|
+
name: String(model.name ?? model.id),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
// 目录拿不到就当空目录:界面退化成手填不了也不至于报错。
|
|
200
|
+
}
|
|
201
|
+
return catalog;
|
|
202
|
+
})();
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const value = await catalogCache.pending;
|
|
206
|
+
if (value.providers.length > 0) {
|
|
207
|
+
catalogCache.value = value;
|
|
208
|
+
catalogCache.at = Date.now();
|
|
209
|
+
}
|
|
210
|
+
return value;
|
|
211
|
+
} finally {
|
|
212
|
+
catalogCache.pending = undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function lookupAgent(ctx, sessionId) { try {
|
|
217
|
+
return ctx.agents.get(sessionId);
|
|
218
|
+
} catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 当前打开着的会话,按打开顺序。 */
|
|
224
|
+
function listSessions(ctx) {
|
|
225
|
+
try {
|
|
226
|
+
return ctx.sessions.list();
|
|
227
|
+
} catch {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readBody(req) {
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
const chunks = [];
|
|
235
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
236
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
237
|
+
req.on('error', reject);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function json(res, status, value) {
|
|
242
|
+
const body = JSON.stringify(value);
|
|
243
|
+
res.writeHead(status, {
|
|
244
|
+
'content-type': 'application/json; charset=utf-8',
|
|
245
|
+
'cache-control': 'no-store',
|
|
246
|
+
'content-length': Buffer.byteLength(body),
|
|
247
|
+
});
|
|
248
|
+
res.end(body);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function fail(res, status, message) {
|
|
252
|
+
json(res, status, { ok: false, error: message });
|
|
253
|
+
}
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI 数据:把一块会话的压缩状态整理成一份纯 JSON。
|
|
3
|
+
*
|
|
4
|
+
* 这里是唯一的"给界面看"的出口:不依赖任何 UI 框架,也不写日志。
|
|
5
|
+
* 块的三种状态全部从事件现算——不缓存,避免界面与账本不一致。
|
|
6
|
+
*/
|
|
7
|
+
import { blockText } from './tower.js';
|
|
8
|
+
import {
|
|
9
|
+
checkpointIndex, formatTokens, refOf,
|
|
10
|
+
} from './core.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 构造一份可序列化的状态快照。
|
|
14
|
+
* @param {import('./tower.js').Tower} tower
|
|
15
|
+
* @param {object} session
|
|
16
|
+
* @param {AbortSignal} [signal]
|
|
17
|
+
* @returns {Promise<object>}
|
|
18
|
+
*/
|
|
19
|
+
export async function buildState(tower, session, signal) {
|
|
20
|
+
const measurement = tower.ctx.tokenMeter.measure(session);
|
|
21
|
+
// 固定开销(system + 工具 schema)不从窗口里扣掉,进度条就会把「还能塞 700K」
|
|
22
|
+
// 画成可用空间。阈值与面板必须用同一个分母,所以两边都走 overheadOf。
|
|
23
|
+
const overhead = tower.overheadOf(session, measurement.nodes);
|
|
24
|
+
// 估算值统一乘事实修正系数,和 provider 报的真实 token 同一把尺子。
|
|
25
|
+
const scale = tower.calibration(session, measurement, overhead);
|
|
26
|
+
const blocks = tower.blocksOf(session);
|
|
27
|
+
const surfaceSeqs = session.surface.nodes;
|
|
28
|
+
const surface = new Set(surfaceSeqs);
|
|
29
|
+
const checkpointOf = checkpointIndex(session);
|
|
30
|
+
const childOf = new Set();
|
|
31
|
+
for (const block of blocks) for (const id of block.childBlockIds ?? []) childOf.add(id);
|
|
32
|
+
|
|
33
|
+
const header = session.requestHeader();
|
|
34
|
+
const route = header?.config;
|
|
35
|
+
const budget = await budgetOf(tower, route, signal);
|
|
36
|
+
const messages = Math.max(0, Number(measurement.surfaceTokens ?? 0) - overhead.system);
|
|
37
|
+
const content = Math.max(0, messages * scale);
|
|
38
|
+
const usable = budget === null ? null : Math.max(1, budget - overhead.total * scale);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
session: {
|
|
42
|
+
id: String(session.id),
|
|
43
|
+
cwd: session.header?.cwd ?? null,
|
|
44
|
+
},
|
|
45
|
+
pressure: pressureOf(tower, session),
|
|
46
|
+
observation: observationOf(tower, session),
|
|
47
|
+
budget: {
|
|
48
|
+
contextWindow: budget,
|
|
49
|
+
total: measurement.totalTokens,
|
|
50
|
+
content,
|
|
51
|
+
fixed: { system: overhead.system, tools: overhead.tools, total: Math.round(overhead.total * scale), ratio: scale },
|
|
52
|
+
usable,
|
|
53
|
+
triggerTokens: usable === null ? null : Math.round(tower.config.triggerRatio * usable),
|
|
54
|
+
targetTokens: usable === null ? null : Math.round(tower.config.targetRatio * usable),
|
|
55
|
+
triggerRatio: tower.config.triggerRatio,
|
|
56
|
+
targetRatio: tower.config.targetRatio,
|
|
57
|
+
retainRecentGroups: tower.config.retainRecentGroups,
|
|
58
|
+
},
|
|
59
|
+
blocks: blocks.map((block) => ({
|
|
60
|
+
blockId: block.blockId,
|
|
61
|
+
tier: block.tier,
|
|
62
|
+
kind: block.kind ?? 'run',
|
|
63
|
+
covers: block.covers,
|
|
64
|
+
coverRefs: block.covers.map(refOf),
|
|
65
|
+
coveredTokens: block.coveredTokens ?? 0,
|
|
66
|
+
summaryTokens: block.summaryTokens ?? 0,
|
|
67
|
+
coveredLabel: formatTokens(block.coveredTokens ?? 0),
|
|
68
|
+
summaryLabel: formatTokens(block.summaryTokens ?? 0),
|
|
69
|
+
childBlockIds: block.childBlockIds ?? [],
|
|
70
|
+
checkpointSeq: checkpointOf.get(block.blockId) ?? null,
|
|
71
|
+
status: statusOf(block, checkpointOf, surface, childOf),
|
|
72
|
+
createdAt: block.createdAt ?? null,
|
|
73
|
+
origin: block.origin ?? null,
|
|
74
|
+
summary: blockText(block),
|
|
75
|
+
})),
|
|
76
|
+
surface: surfaceSeqs.map((seq) => {
|
|
77
|
+
const event = session.eventAt(seq);
|
|
78
|
+
return {
|
|
79
|
+
seq,
|
|
80
|
+
ref: refOf(seq),
|
|
81
|
+
type: event?.type ?? 'unknown',
|
|
82
|
+
checkpoint: checkpointOf.size > 0 ? checkpointBlockOf(checkpointOf, seq) : null,
|
|
83
|
+
};
|
|
84
|
+
}),
|
|
85
|
+
stats: { ...tower.stats },
|
|
86
|
+
// 「当前组装」:表层上到底拼了些什么。只数表层上的东西 —— 已提交的块按层计数,
|
|
87
|
+
// 还没被压掉的消息算原文;待提交的块不在上下文里,不进这行。
|
|
88
|
+
composition: compositionOf(surfaceSeqs, checkpointOf, blocks, overhead.systemSeqs),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 表层配方:每层几个块 + 还剩多少条原文。
|
|
94
|
+
* @param {number[]} surfaceSeqs 表层节点(顺序即时间顺序)
|
|
95
|
+
* @param {Map<string, number>} checkpointOf blockId → checkpoint 节点
|
|
96
|
+
* @param {Array<object>} blocks 账本里的块
|
|
97
|
+
* @param {Set<number>} systemSeqs system 消息的节点(不算原文)
|
|
98
|
+
* @returns {{tiers: Array<{tier:number,count:number}>, raw:number}}
|
|
99
|
+
*/
|
|
100
|
+
function compositionOf(surfaceSeqs, checkpointOf, blocks, systemSeqs) {
|
|
101
|
+
const tierOf = new Map(blocks.map((block) => [block.blockId, block.tier ?? 1]));
|
|
102
|
+
const counts = new Map();
|
|
103
|
+
let raw = 0;
|
|
104
|
+
for (const seq of surfaceSeqs) {
|
|
105
|
+
if (systemSeqs?.has(seq) === true) continue;
|
|
106
|
+
const blockId = checkpointOf.size > 0 ? checkpointBlockOf(checkpointOf, seq) : null;
|
|
107
|
+
if (blockId === null) { raw += 1; continue; }
|
|
108
|
+
const tier = tierOf.get(blockId) ?? 1;
|
|
109
|
+
counts.set(tier, (counts.get(tier) ?? 0) + 1);
|
|
110
|
+
}
|
|
111
|
+
const tiers = [...counts.entries()]
|
|
112
|
+
.map(([tier, count]) => ({ tier, count }))
|
|
113
|
+
.sort((left, right) => right.tier - left.tier);
|
|
114
|
+
return { tiers, raw };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 会话里所有 checkpoint 节点:blockId → 该节点的 seq。 */
|
|
118
|
+
function checkpointBlockOf(index, seq) {
|
|
119
|
+
for (const [blockId, at] of index) if (at === seq) return blockId;
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 块状态:
|
|
125
|
+
* consumed = 已被上层块覆盖(上层块的 childBlockIds 里有它)
|
|
126
|
+
* committed = 摘要节点仍在表层
|
|
127
|
+
* dormant = 已蒸馏但还没进过上下文
|
|
128
|
+
*/
|
|
129
|
+
function statusOf(block, checkpointOf, surface, childOf) {
|
|
130
|
+
if (childOf.has(block.blockId)) return 'consumed';
|
|
131
|
+
const seq = checkpointOf.get(block.blockId);
|
|
132
|
+
if (seq !== undefined && surface.has(seq)) return 'committed';
|
|
133
|
+
return 'dormant';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 上一次压力判定(为什么压 / 为什么没压)。只暴露可序列化的字段。
|
|
138
|
+
* @param {object} tower
|
|
139
|
+
* @param {object} session
|
|
140
|
+
*/
|
|
141
|
+
function pressureOf(tower, session) {
|
|
142
|
+
const last = tower.lastPressure?.get(session);
|
|
143
|
+
if (last === undefined || last === null) return null;
|
|
144
|
+
return {
|
|
145
|
+
at: last.at ?? null,
|
|
146
|
+
kind: last.kind ?? null,
|
|
147
|
+
total: last.total ?? null,
|
|
148
|
+
content: last.content ?? null,
|
|
149
|
+
fixed: last.fixed ?? null,
|
|
150
|
+
usable: last.usable ?? null,
|
|
151
|
+
trigger: last.trigger ?? null,
|
|
152
|
+
budget: last.budget ?? null,
|
|
153
|
+
blockIds: Array.isArray(last.blockIds) ? last.blockIds : null,
|
|
154
|
+
blockCount: Array.isArray(last.blockIds) ? last.blockIds.length : null,
|
|
155
|
+
projected: last.projected ?? null,
|
|
156
|
+
blocks: Array.isArray(last.blocks) ? last.blocks.length : (last.blocks ?? null),
|
|
157
|
+
run: last.run ?? null,
|
|
158
|
+
calls: last.calls ?? null,
|
|
159
|
+
outcome: last.outcome === undefined || last.outcome === null ? null : {
|
|
160
|
+
planned: last.outcome.planned ?? 0,
|
|
161
|
+
succeeded: last.outcome.succeeded ?? 0,
|
|
162
|
+
error: last.outcome.error ?? null,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 上一次观察判定(为什么蒸 / 为什么没蒸)。只暴露可序列化的字段。
|
|
169
|
+
* @param {object} tower
|
|
170
|
+
* @param {object} session
|
|
171
|
+
*/
|
|
172
|
+
function observationOf(tower, session) {
|
|
173
|
+
const last = tower.lastObservation?.get(session);
|
|
174
|
+
if (last === undefined || last === null) return null;
|
|
175
|
+
return {
|
|
176
|
+
at: last.at ?? null,
|
|
177
|
+
kind: last.kind ?? null,
|
|
178
|
+
unit: last.unit ?? null,
|
|
179
|
+
protected: last.protected ?? null,
|
|
180
|
+
plan: Array.isArray(last.plan) ? last.plan : null,
|
|
181
|
+
available: Array.isArray(last.available) ? last.available : null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function budgetOf(tower, route, signal) {
|
|
186
|
+
if (typeof route?.provider !== 'string' || typeof route?.model !== 'string') return null;
|
|
187
|
+
try {
|
|
188
|
+
return await tower.budgetOfRoute(route.provider, route.model, signal);
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|