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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * 接管「蒸馏那一次 LLM 调用」。
3
+ *
4
+ * 为什么需要它:宿主 `summarizeWithLlm` 把指令写死在模块常量里,没有参数能换;
5
+ * 想用自己的提示词,只能自己把这次请求发一遍。所以这里逐条照抄宿主的做法,
6
+ * 少一条都会退化:
7
+ *
8
+ * - 指令作为**最后一条 user 消息**(不是 system)→ 前缀缓存仍然复用,成本不涨;
9
+ * - `toolHistory` 与 `tools` 必须带上,否则摘要模型看不到工具调用与工具定义;
10
+ * - `purpose: 'compaction'` 是路由与计费的分类标记,不能丢;
11
+ * - `maxTokens` 是**防失控的保险丝**,不是剪摘要的剪刀:给 0 表示不传(交给模型自己的
12
+ * 输出上限)。一旦撞上上限就会触发 max-tokens → 整块作废,所以这个值只应该设得很大;
13
+ * 设小等于让蒸馏反复失败。
14
+ * - 出错 / 中断 / 截断一律算失败,绝不把半截摘要交回去写块。
15
+ */
16
+ import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm';
17
+
18
+ /**
19
+ * 终止状态 → 错误。与宿主 compaction-basic 的同名私有实现一致。
20
+ * @param {object} finish
21
+ */
22
+ function finishError(finish) {
23
+ if (finish === undefined) return undefined;
24
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
25
+ return new LlmError(finish.failure.message, finish.failure.code, finish.failure);
26
+ }
27
+ if (finish.kind === 'max-tokens') {
28
+ const error = new Error('summarization truncated at the token cap');
29
+ error.code = 'MAX_TOKENS';
30
+ return error;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ /** 摘要只要文本块(宿主的 summaryText 也是这个口径)。 */
36
+ function textOf(blocks) {
37
+ return blocks.filter((part) => part.type === 'text');
38
+ }
39
+
40
+ /**
41
+ * 用自己的指令发一次摘要请求。
42
+ *
43
+ * @param {object} params
44
+ * @param {object} params.ctx 插件 context(要 `llm` 服务)
45
+ * @param {object} params.session 会话(要 `toolHistory()` 与 `id`)
46
+ * @param {string} params.provider 摘要模型
47
+ * @param {string} params.model
48
+ * @param {{messages: object[], tools?: object[]}} params.input 重放出来的材料
49
+ * @param {string} params.instruction 末尾那条指令
50
+ * @param {number} params.maxTokens 输出上限(保险丝)
51
+ * @param {AbortSignal} [params.signal]
52
+ * @returns {Promise<{summary: object[], provider: string, model: string, maxTokens: number, usage?: object}>}
53
+ */
54
+ export async function requestSummary({ ctx, session, provider, model, input, instruction, maxTokens, signal }) {
55
+ if (provider === '' || model === '') {
56
+ throw new Error('compaction-tower: 自定义摘要提示词需要明确的 provider / model');
57
+ }
58
+ const assembler = new BlockAssembler();
59
+ const messages = [
60
+ ...input.messages,
61
+ Object.freeze({ role: 'user', content: Object.freeze([Object.freeze({ type: 'text', text: instruction })]) }),
62
+ ];
63
+ const request = {
64
+ provider,
65
+ model,
66
+ messages,
67
+ toolHistory: session.toolHistory(),
68
+ ...(input.tools === undefined ? {} : { tools: [...input.tools] }),
69
+ // 0 = 不设上限(交给适配器/模型自己的输出上限)。默认是一个很大但存在的保险丝。
70
+ ...(maxTokens === undefined || maxTokens === 0 ? {} : { maxTokens }),
71
+ sessionId: session.id,
72
+ purpose: 'compaction',
73
+ ...(signal === undefined ? {} : { signal }),
74
+ };
75
+
76
+ for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk);
77
+ const failure = finishError(assembler.finish);
78
+ if (failure !== undefined) throw failure;
79
+
80
+ const summary = textOf(assembler.blocks());
81
+ if (!summary.some((part) => typeof part.text === 'string' && part.text.trim().length > 0)) {
82
+ throw new Error('compaction-tower: 摘要是空的(模型没有输出文本块)');
83
+ }
84
+ return {
85
+ summary,
86
+ provider,
87
+ model,
88
+ maxTokens,
89
+ ...(assembler.usage === undefined ? {} : { usage: assembler.usage }),
90
+ };
91
+ }
package/lib/tools.js ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * 模型可见的两个工具:decompress / search_context。
3
+ *
4
+ * 两个工具都只读日志,不改任何状态——模型无法通过它们让上下文变短或变长,
5
+ * 只能「看回」已经被压缩掉的内容。
6
+ */
7
+ import { existsSync } from 'node:fs';
8
+ import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
9
+ import { dirname, join, relative, resolve, sep } from 'node:path';
10
+ import { defineTool } from '@deepseek-ai/dsh-tools';
11
+ import { blockHeader, describeMessage, formatTokens, iterateEvents, refOf, roleLabel, seqOfRef } from './core.js';
12
+ import { blockText } from './tower.js';
13
+
14
+ const OUTPUT_SCHEMA = {
15
+ type: 'object',
16
+ additionalProperties: false,
17
+ properties: {
18
+ text: { type: 'string', required: true },
19
+ },
20
+ };
21
+
22
+ const renderText = (_args, value) => [{ type: 'text', text: value.text }];
23
+
24
+ /**
25
+ * 注册两个工具。
26
+ * @param {object} ctx 宿主 context
27
+ * @param {import('./tower.js').Tower} tower
28
+ * @returns {() => void} disposer
29
+ */
30
+ export function registerTools(ctx, tower) {
31
+ const disposers = [
32
+ ctx.tools.register(defineTool({
33
+ name: 'decompress',
34
+ description: '展开被压缩掉的对话历史。给 blockId 精确展开一个块的下一层;给 range(如 ["m00012","m00030"])按事件序号取回一段历史。默认只展开一层:T1 块展开为原文,T2 及以上展开为下一层块摘要。内容过大时会导出到文件而不是内联。仍在上下文中的消息无需展开。',
35
+ parameters: {
36
+ blockId: { type: 'string', description: '块号,如 "b00007"。与 range 二选一。' },
37
+ range: { type: 'array', description: '事件序号区间 [起始, 结束],两端都含。与 blockId 二选一。', items: { type: 'string' } },
38
+ inline: { type: 'boolean', description: '强制内联返回(仍受绝对上限截断)。' },
39
+ },
40
+ output: { schema: OUTPUT_SCHEMA, render: renderText },
41
+ async execute(args, exec) {
42
+ const agent = exec.agent;
43
+ if (agent === undefined) throw new Error('decompress 需要一个 agent 会话');
44
+ const session = agent.session;
45
+ const blocks = tower.blocksOf(session);
46
+ const surface = new Set(session.surface.nodes);
47
+
48
+ if (typeof args.blockId === 'string' && args.blockId.length > 0) {
49
+ const block = blocks.find((candidate) => candidate.blockId === args.blockId);
50
+ if (block === undefined) {
51
+ throw new Error(`块 ${args.blockId} 不存在(已有块:${blocks.map((b) => b.blockId).join(', ') || '无'})`);
52
+ }
53
+ if (block.covers.every((seq) => surface.has(seq))) {
54
+ return { text: `块 ${block.blockId} 的内容仍在上下文中,无需展开。` };
55
+ }
56
+ return { text: await expandBlock(tower, session, block, blocks, args.inline === true, exec.signal) };
57
+ }
58
+
59
+ if (Array.isArray(args.range) && args.range.length === 2) {
60
+ const start = seqOfRef(args.range[0]);
61
+ const end = seqOfRef(args.range[1]);
62
+ if (start === undefined || end === undefined) throw new Error('range 的两端要写成 m00012 这样的序号');
63
+ const lo = Math.min(start, end);
64
+ const hi = Math.max(start, end);
65
+ const seqs = [];
66
+ for (const event of iterateEvents(session)) {
67
+ if (event.seq < lo || event.seq > hi) continue;
68
+ if (session.deriveEventMessage(event) === null) continue;
69
+ seqs.push(event.seq);
70
+ }
71
+ if (seqs.length === 0) return { text: `区间 ${refOf(lo)}…${refOf(hi)} 内没有消息。` };
72
+ const body = `区间 ${refOf(lo)}…${refOf(hi)}(${seqs.length} 条消息)\n${tower.renderSeqs(session, seqs, { maxCharsPerMessage: Number.MAX_SAFE_INTEGER }).join('\n')}`;
73
+ return { text: await emit(ctx, tower, session, body, `range-${lo}-${hi}`, args.inline === true, exec.signal) };
74
+ }
75
+
76
+ throw new Error('decompress 需要 blockId 或 range 之一');
77
+ },
78
+ })),
79
+
80
+ ctx.tools.register(defineTool({
81
+ name: 'search_context',
82
+ description: '在当前会话的完整历史中搜索关键字,包括已经被压缩掉的原文。命中的每条会标注它属于哪个块(或仍在上下文中)。',
83
+ parameters: {
84
+ query: { type: 'string', required: true, description: '要搜索的关键字,尽量短。' },
85
+ limit: { type: 'integer', description: '最多返回多少条命中,默认 10,上限 30。' },
86
+ },
87
+ output: { schema: OUTPUT_SCHEMA, render: renderText },
88
+ async execute(args, exec) {
89
+ const agent = exec.agent;
90
+ if (agent === undefined) throw new Error('search_context 需要一个 agent 会话');
91
+ const query = String(args.query ?? '').trim();
92
+ if (query.length === 0) throw new Error('query 不能为空');
93
+ const limit = Math.min(Math.max(Number(args.limit ?? 10), 1), 30);
94
+
95
+ const session = agent.session;
96
+ const blocks = tower.blocksOf(session);
97
+ const surface = new Set(session.surface.nodes);
98
+ const terms = tokenize(query);
99
+ const hits = [];
100
+ for (const event of iterateEvents(session)) {
101
+ const message = session.deriveEventMessage(event);
102
+ if (message === null || message === undefined) continue;
103
+ const score = scoreText(describeMessage(message, 4000), query, terms);
104
+ if (score <= 0) continue;
105
+ hits.push({ seq: event.seq, event, message, score });
106
+ }
107
+ if (hits.length === 0) return { text: `没有找到「${query}」。` };
108
+ hits.sort((a, b) => b.score - a.score || b.seq - a.seq);
109
+ const top = hits.slice(0, limit);
110
+ const lines = top.map((hit) => {
111
+ const where = surface.has(hit.seq) ? '仍在上下文' : coverageLabel(hit.seq, blocks);
112
+ return `${refOf(hit.seq)} [${roleLabel(hit.event, hit.message)}] ${describeMessage(hit.message, 160)} ← ${where}`;
113
+ });
114
+ const more = hits.length > top.length ? `\n(共 ${hits.length} 条命中,显示前 ${top.length} 条)` : '';
115
+ return { text: `${top.length} 条命中:\n${lines.join('\n')}${more}` };
116
+ },
117
+ })),
118
+ ];
119
+
120
+ return () => { for (const dispose of disposers) dispose?.(); };
121
+ }
122
+
123
+ /** 展开一个块的下一层:有子块就展开子块摘要,没有就展开原文。 */
124
+ async function expandBlock(tower, session, block, blocks, inline, signal) {
125
+ const byId = new Map(blocks.map((candidate) => [candidate.blockId, candidate]));
126
+ const childBlocks = (block.childBlockIds ?? []).map((id) => byId.get(id)).filter((child) => child !== undefined);
127
+ const header = blockHeader({
128
+ blockId: block.blockId,
129
+ tier: block.tier,
130
+ covers: block.covers,
131
+ coveredTokens: block.coveredTokens,
132
+ childBlockIds: childBlocks.map((child) => child.blockId),
133
+ });
134
+ const body = childBlocks.length > 0
135
+ ? childBlocks.map((child) => `${blockHeader({
136
+ blockId: child.blockId,
137
+ tier: child.tier,
138
+ covers: child.covers,
139
+ coveredTokens: child.coveredTokens,
140
+ childBlockIds: child.childBlockIds ?? [],
141
+ })}\n${blockText(child)}`).join('\n\n')
142
+ : tower.renderSeqs(session, block.covers, { maxCharsPerMessage: Number.MAX_SAFE_INTEGER }).join('\n');
143
+ return emit(tower.ctx, tower, session, `${header}\n${body}`, block.blockId, inline, signal);
144
+ }
145
+
146
+ /**
147
+ * 决定内联还是落盘。
148
+ * 内联上限 = inlineMaxRatio × 上下文窗口;绝对上限 = hardMaxTokens。
149
+ */
150
+ async function emit(ctx, tower, session, text, name, inline, signal) {
151
+ // 估算口径换算成真实口径:宿主只有 chars/4 的密度估算,中文会被低估三成,
152
+ // 而这里的上限(内联阈值、硬截断)是真实 token 数。
153
+ const measurement = ctx.tokenMeter.measure(session);
154
+ const scale = tower.calibration(session, measurement, tower.overheadOf(session, measurement.nodes));
155
+ const tokens = Math.round(estimateText(ctx, text) * scale);
156
+ const budget = await routeBudget(tower, session, signal);
157
+ const inlineLimit = Math.max(1000, Math.floor(tower.config.inlineMaxRatio * (budget ?? 100000)));
158
+
159
+ if (inline && tokens > tower.config.hardMaxTokens) {
160
+ return `${truncateToTokens(ctx, text, tower.config.hardMaxTokens, scale)}\n…(已按绝对上限截断;用 blockId 可以落盘展开全文)`;
161
+ }
162
+ if (inline || tokens <= inlineLimit) return text;
163
+
164
+ const root = projectRoot(session);
165
+ const dir = join(root, '.dsh', 'tower', session8(session));
166
+ await mkdir(dir, { recursive: true });
167
+ await writeFile(join(dir, '.gitignore'), '*\n', 'utf8').catch(() => {});
168
+ const file = join(dir, `${name}.md`);
169
+ await writeFile(file, `${text}\n`, 'utf8');
170
+ await pruneExports(tower, join(root, '.dsh', 'tower'));
171
+ // 预览是给模型判断「要不要打开这个文件」的,不是给它的内容本身:
172
+ // 每条消息压到 200 字符,否则一个块的首 20 行就可能又是几十 K。
173
+ const preview = text.split('\n').slice(0, 20)
174
+ .map((line) => (line.length > 200 ? `${line.slice(0, 200)}…` : line))
175
+ .join('\n');
176
+ const shown = relative(root, file).split(sep).join('/');
177
+ return `已导出到 ${shown}(${formatTokens(tokens)} tok)\n前 20 行预览:\n${preview}`;
178
+ }
179
+
180
+ /** 用宿主的估算器数 token;拿不到就退回 字符数/4。 */
181
+ function estimateText(ctx, text) {
182
+ try {
183
+ return ctx.tokenMeter.estimateMessage({ role: 'user', content: [{ type: 'text', text }] });
184
+ } catch {
185
+ return Math.ceil(text.length / 4);
186
+ }
187
+ }
188
+
189
+ function truncateToTokens(ctx, text, maxTokens, scale = 1) {
190
+ const chars = Math.max(200, Math.round((maxTokens * 4) / Math.max(1, scale)));
191
+ return text.length <= chars ? text : `${text.slice(0, chars)}\n…(已截断)`;
192
+ }
193
+
194
+ /** 取当前会话路由的上下文窗口。 */
195
+ async function routeBudget(tower, session, signal) {
196
+ const config = session.requestHeader()?.config;
197
+ if (typeof config?.provider !== 'string' || typeof config?.model !== 'string') return null;
198
+ return tower.budgetOfRoute(config.provider, config.model, signal);
199
+ }
200
+
201
+ /** 命中行的覆盖说明。 */
202
+ function coverageLabel(seq, blocks) {
203
+ const covering = blocks.filter((block) => block.covers.includes(seq));
204
+ if (covering.length === 0) return '已不在上下文';
205
+ return `被 ${covering.map((block) => block.blockId).join(' / ')} 覆盖`;
206
+ }
207
+
208
+ function tokenize(query) {
209
+ return query.split(/[\s,,.。;;::/\\|()[\]{}"'`]+/u).filter((term) => term.length > 0);
210
+ }
211
+
212
+ function scoreText(text, query, terms) {
213
+ if (text.length === 0) return 0;
214
+ let score = 0;
215
+ if (query.length > 1 && text.includes(query)) score += 5;
216
+ for (const term of terms) {
217
+ let index = text.indexOf(term);
218
+ let count = 0;
219
+ while (index !== -1 && count < 5) { count += 1; index = text.indexOf(term, index + term.length); }
220
+ score += count;
221
+ }
222
+ return score;
223
+ }
224
+
225
+ /** 会话 cwd → 项目根(最近一个含 .git 的祖先,找不到就用 cwd)。 */
226
+ function projectRoot(session) {
227
+ const cwd = resolve(session.header?.cwd ?? process.cwd());
228
+ let current = cwd;
229
+ for (;;) {
230
+ if (existsSync(join(current, '.git'))) return current;
231
+ const parent = dirname(current);
232
+ if (parent === current) return cwd;
233
+ current = parent;
234
+ }
235
+ }
236
+
237
+ /** 只保留最近 N 个会话目录(按 mtime)。文件是视图,删掉随时可重建。 */
238
+ async function pruneExports(tower, towerDir) {
239
+ let entries;
240
+ try {
241
+ entries = await readdir(towerDir, { withFileTypes: true });
242
+ } catch {
243
+ return;
244
+ }
245
+ const dirs = [];
246
+ for (const entry of entries) {
247
+ if (!entry.isDirectory()) continue;
248
+ const path = join(towerDir, entry.name);
249
+ try {
250
+ dirs.push({ path, mtime: (await stat(path)).mtimeMs });
251
+ } catch { /* ignore */ }
252
+ }
253
+ if (dirs.length <= tower.config.exportKeepSessions) return;
254
+ dirs.sort((a, b) => b.mtime - a.mtime);
255
+ for (const stale of dirs.slice(tower.config.exportKeepSessions)) {
256
+ await rm(stale.path, { recursive: true, force: true }).catch(() => {});
257
+ }
258
+ }
259
+
260
+ function session8(session) {
261
+ return String(session.id).replace(/[^a-z0-9]/gi, '').slice(0, 8).toLowerCase() || 'session';
262
+ }