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/lib/core.js ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * 会话日志与块记录的底层读取。
3
+ *
4
+ * 这里不持有任何状态:一切从 session 的事件日志现算。
5
+ * 块记录(plugin:tower/block)是唯一的账本,重启后从日志重建。
6
+ */
7
+ import { appendPluginRecord, pluginRecordOf } from '@deepseek-ai/dsh-session';
8
+ import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compaction';
9
+
10
+ export { pluginRecordOf };
11
+
12
+ /** 块记录的事件类型(宿主语法:plugin: + 全小写分段)。 */
13
+ export const BLOCK_RECORD_TYPE = 'plugin:tower/block';
14
+
15
+ /** 提交记录的事件类型:块号 ↔ 它换进表层的那个 checkpoint 节点。 */
16
+ export const COMMIT_RECORD_TYPE = 'plugin:tower/commit';
17
+
18
+ /**
19
+ * 所有 step/start 的 seq(升序)—— 用来划「保护最近 N 步」的边界。
20
+ *
21
+ * step = 一次模型请求。用户发一句话到 agent 收工是一个 turn,中间可能有很多 step;
22
+ * 保护窗口按 step 算才有意义(10 个用户轮次会把整段对话都圈进去)。
23
+ *
24
+ * @param {object} session
25
+ * @returns {number[]}
26
+ */
27
+ export function stepStartSeqs(session) {
28
+ const seqs = [];
29
+ for (const event of iterateEvents(session)) {
30
+ if (event.type === 'step/start') seqs.push(event.seq);
31
+ }
32
+ return seqs;
33
+ }
34
+
35
+ /**
36
+ * 列出所有**已闭合**的 step 区间(step/start … step/end)。
37
+ *
38
+ * 一次扫描拿全部区间,避免对每条候选消息都重扫一遍事件(块覆盖上百条时是 O(n·m))。
39
+ *
40
+ * @param {object} session
41
+ * @returns {Array<{start: number, end: number}>} 按 seq 升序
42
+ */
43
+ export function stepSpans(session) {
44
+ const spans = [];
45
+ let start = null;
46
+ for (const event of iterateEvents(session)) {
47
+ if (event.type === 'step/start') start = event.seq;
48
+ else if (event.type === 'step/end' && start !== null) {
49
+ spans.push({ start, end: event.seq });
50
+ start = null;
51
+ }
52
+ }
53
+ return spans;
54
+ }
55
+
56
+ /**
57
+ * 找 seq 所在 step 的闭合边界(step/end 的 seq)。
58
+ *
59
+ * 宿主的 `compactRegion` 要求区间**按 step 对齐**:end 必须是某个已闭合 step 的最后一条
60
+ * 消息,否则会抛 `not a balanced boundary (would split a step, or the step is still open)`。
61
+ * 块是按消息切的,边界天然可能落在 step 中间,所以提交前要用这里对齐。
62
+ *
63
+ * @param {object} session
64
+ * @param {number} seq
65
+ * @returns {number|null} 该 step 的 step/end seq;step 还没闭合(或找不到)时返回 null
66
+ */
67
+ export function closedStepEnd(session, seq) {
68
+ let start = null;
69
+ for (const event of iterateEvents(session)) {
70
+ if (event.type === 'step/start') {
71
+ start = event.seq;
72
+ continue;
73
+ }
74
+ if (event.type !== 'step/end') continue;
75
+ if (start !== null && seq >= start && seq <= event.seq) return event.seq;
76
+ start = null;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * 按 seq 顺序遍历全部事件。
83
+ * 宿主没有暴露事件数组,seq 连续且不复用,所以用 eventAt 逐号取到 undefined 为止。
84
+ * @param {object} session
85
+ * @returns {Iterable<object>}
86
+ */
87
+ export function* iterateEvents(session) {
88
+ for (let seq = 0; ; seq += 1) {
89
+ const event = session.eventAt(seq);
90
+ if (event === undefined) return;
91
+ yield event;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * 读出全部块记录,按写入顺序(= 块号顺序)。
97
+ * @param {object} session
98
+ * @returns {Array<object>} 每项带 recordSeq(记录事件自己的 seq)
99
+ */
100
+ export function readBlocks(session) {
101
+ const blocks = [];
102
+ for (const event of iterateEvents(session)) {
103
+ const record = pluginRecordOf(event);
104
+ if (record === undefined || record.type !== BLOCK_RECORD_TYPE) continue;
105
+ const data = record.data;
106
+ if (typeof data?.blockId !== 'string' || !Array.isArray(data.covers)) continue;
107
+ blocks.push({ ...data, recordSeq: event.seq });
108
+ }
109
+ return blocks;
110
+ }
111
+
112
+ /**
113
+ * 分配下一个块号。块号只增不减,删除记录也不会回填。
114
+ * @param {Array<object>} blocks 现有记录
115
+ * @returns {string} 形如 b00007
116
+ */
117
+ export function nextBlockId(blocks) {
118
+ let max = 0;
119
+ for (const block of blocks) {
120
+ const match = /^b(\d+)$/.exec(block.blockId);
121
+ if (match !== null) max = Math.max(max, Number(match[1]));
122
+ }
123
+ return `b${String(max + 1).padStart(5, '0')}`;
124
+ }
125
+
126
+ /**
127
+ * 写入一个块记录。仅日志,不进表层——模型看不见。
128
+ * @param {object} session
129
+ * @param {object} data 无损 JSON
130
+ * @returns {object} 追加的事件
131
+ */
132
+ export function appendBlock(session, data) {
133
+ return appendPluginRecord(session, BLOCK_RECORD_TYPE, data);
134
+ }
135
+
136
+ /**
137
+ * 读出全部提交记录,按写入顺序。
138
+ * @param {object} session
139
+ * @returns {Array<{blockId: string, checkpointSeq: number, compactionId?: string}>}
140
+ */
141
+ export function readCommits(session) {
142
+ const commits = [];
143
+ for (const event of iterateEvents(session)) {
144
+ const record = pluginRecordOf(event);
145
+ if (record === undefined || record.type !== COMMIT_RECORD_TYPE) continue;
146
+ const data = record.data;
147
+ if (typeof data?.blockId !== 'string' || !Number.isInteger(data.checkpointSeq)) continue;
148
+ commits.push({ ...data, recordSeq: event.seq });
149
+ }
150
+ return commits;
151
+ }
152
+
153
+ /**
154
+ * 写入一条提交记录:块号 ↔ 它换进来的 checkpoint 节点。
155
+ *
156
+ * 这是块身份的**唯一权威来源**。不能靠 checkpoint 正文里的块头反推:格式一改,
157
+ * 历史块就全部失联,库存会重新把它们算成待命,塔又停摆。
158
+ * @param {object} session
159
+ * @param {{blockId: string, checkpointSeq: number, compactionId?: string}} data
160
+ * @returns {object} 追加的事件
161
+ */
162
+ export function appendCommit(session, data) {
163
+ return appendPluginRecord(session, COMMIT_RECORD_TYPE, data);
164
+ }
165
+
166
+ /**
167
+ * 表层上每个 checkpoint 节点的「块号 → 该节点 seq」。
168
+ *
169
+ * 两个来源,权威优先:
170
+ * 1. 提交记录(plugin:tower/commit)—— 提交成功时当场写,与正文格式无关;
171
+ * 2. 正文里的块头 `[b00007 · T2 · …]` —— 只给没有提交记录的老会话兜底。
172
+ * 这条必须校验 source(`compact-checkpoint`),否则普通 user 消息里出现
173
+ * 同形字面量(例如让模型去读插件源码)就会被误认成 checkpoint。
174
+ * @param {object} session
175
+ * @returns {Map<string, number>}
176
+ */
177
+ export function checkpointIndex(session) {
178
+ const surface = new Set(session.surface.nodes);
179
+ const index = new Map();
180
+ for (const commit of readCommits(session)) {
181
+ if (surface.has(commit.checkpointSeq)) index.set(commit.blockId, commit.checkpointSeq);
182
+ }
183
+ const claimed = new Set(index.values());
184
+ for (const seq of session.surface.nodes) {
185
+ if (claimed.has(seq)) continue;
186
+ const event = session.eventAt(seq);
187
+ if (event?.type !== 'user/message') continue;
188
+ const source = event.data?.source;
189
+ if (source === undefined || !isCompactCheckpointSource(source)) continue;
190
+ const text = (event.data?.content ?? [])
191
+ .filter((part) => part.type === 'text')
192
+ .map((part) => part.text)
193
+ .join('\n');
194
+ const match = /\[(b\d{5}) · T\d+/u.exec(text);
195
+ if (match !== null && !index.has(match[1])) index.set(match[1], seq);
196
+ }
197
+ return index;
198
+ }
199
+
200
+ /**
201
+ * 反向索引:checkpoint 节点 seq → 块号。
202
+ * 合并任务按位置找成员,需要这个方向;与 checkpointIndex 同源。
203
+ * @param {object} session
204
+ * @returns {Map<number, string>}
205
+ */
206
+ export function checkpointOwner(session) {
207
+ const owner = new Map();
208
+ for (const [blockId, seq] of checkpointIndex(session)) owner.set(seq, blockId);
209
+ return owner;
210
+ }
211
+
212
+ /** 把 seq 渲染成模型可见引用。 */
213
+ export function refOf(seq) {
214
+ return `m${String(seq).padStart(5, '0')}`;
215
+ }
216
+
217
+ /** 由 m00042 反解 seq;无法解析返回 undefined。 */
218
+ export function seqOfRef(ref) {
219
+ const match = /^m(\d+)$/.exec(String(ref).trim());
220
+ return match === null ? undefined : Number(match[1]);
221
+ }
222
+
223
+ /** 8400 → "8.4K"。 */
224
+ export function formatTokens(tokens) {
225
+ if (!Number.isFinite(tokens) || tokens < 0) return '0';
226
+ if (tokens < 1000) return String(Math.round(tokens));
227
+ if (tokens < 1_000_000) return `${(tokens / 1000).toFixed(1)}K`;
228
+ return `${(tokens / 1_000_000).toFixed(1)}M`;
229
+ }
230
+
231
+ /**
232
+ * 摘要消息的头部。这是模型唯一能看到的块元信息。
233
+ *
234
+ * T1(成员全是原文消息)给首尾引用;T2+(成员含下层块,seq 会乱序)不给自己区。
235
+ * @param {object} params
236
+ * @returns {string} 单行头部
237
+ */
238
+ export function blockHeader({ blockId, tier, covers, coveredTokens, childBlockIds }) {
239
+ const count = `${covers.length} 条`;
240
+ const cost = `原 ${formatTokens(coveredTokens)} tok`;
241
+ if (tier === 1) {
242
+ const first = refOf(covers[0]);
243
+ const last = refOf(covers[covers.length - 1]);
244
+ return `[${blockId} · T1 · ${count} · ${first}…${last} · ${cost}]`;
245
+ }
246
+ const inner = childBlockIds.length > 0 ? `${childBlockIds.length} 个 T${tier - 1} 块` : '下层块';
247
+ return `[${blockId} · T${tier} · ${count}(含 ${inner})· ${cost}]`;
248
+ }
249
+
250
+ /** 摘录一条消息的文本,用于搜索命中行与解压预览。 */
251
+ export function describeMessage(message, maxChars = 200) {
252
+ const parts = [];
253
+ for (const block of message.content ?? []) {
254
+ if (block.type === 'text') parts.push(block.text);
255
+ else if (block.type === 'tool-call') parts.push(`→ ${block.name}(${JSON.stringify(block.arguments ?? {})})`);
256
+ else if (block.type === 'image') parts.push('[image]');
257
+ }
258
+ const text = parts.join(' ').replace(/\s+/g, ' ').trim();
259
+ return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
260
+ }
261
+
262
+ /** 消息的角色标签,用于 [user] / [bash] 这种前缀。 */
263
+ export function roleLabel(event, message) {
264
+ if (event.type === 'tool/result') return message.source?.toolName ?? 'tool';
265
+ if (event.type === 'assistant/message') return 'assistant';
266
+ if (event.type === 'system/message') return 'system';
267
+ return message.role ?? 'user';
268
+ }
package/lib/engine.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 引擎补丁。
3
+ *
4
+ * DSH 的压缩后端 `dsh-compaction-basic` 挂在每个 preset 的隔离组里,
5
+ * profile 的 patch 层按 id 打不到那些嵌套行(buildMap 只递归 group + 数组 config)。
6
+ * 但它把两个扩展点挂在 `this` 上动态派发:
7
+ *
8
+ * - `compactIfNeeded(agent, trigger, signal)` —— 什么时候压、压哪一段;
9
+ * - `summarize(input, agent, signal)` —— 摘要从哪来。
10
+ *
11
+ * 两个方法都在调用点重新读 `this.xxx`(见 regionDependencies 的
12
+ * `summarize: (input, owner, abort) => this.summarize(...)`),
13
+ * 所以换原型方法等于换掉全部隔离实例的行为,且不需要动 preset。
14
+ */
15
+ import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic';
16
+
17
+ /** 幂等标记:同一进程内重复加载不会叠加补丁。 */
18
+ const PATCHED = Symbol.for('dsh-compaction-tower.patched');
19
+
20
+ /**
21
+ * 补丁当前指向的实例。
22
+ *
23
+ * 不能直接闭包捕获 `tower`:重新安装插件时 cordis 会重建插件模块,新实例换掉旧实例,
24
+ * 而原型上的补丁还在(`PATCHED` 标记挡住重复安装)。闭包捕获的话,补丁会永远捏着
25
+ * 旧实例 —— 旧实例照常收调用、往自己的 WeakMap 里写,界面读的却是新实例,表现就是
26
+ * 「内容越线了却没有任何判定」「观察者说拿不到压缩引擎」。
27
+ */
28
+ let current = null;
29
+
30
+ /**
31
+ * 安装补丁。
32
+ * @param {object} tower Tower 实例(提供 compactIfNeeded / serveSummary)
33
+ * @returns {'patched'|'already'|'unavailable'}
34
+ */
35
+ export function installEnginePatches(tower) {
36
+ const proto = BasicCompactionEngine?.prototype;
37
+ if (proto === undefined) return 'unavailable';
38
+ if (typeof proto.compactIfNeeded !== 'function' || typeof proto.summarize !== 'function') return 'unavailable';
39
+ if (proto[PATCHED] === true) {
40
+ // 已经打过补丁:只把引用换成新实例,方法体不动。
41
+ current = tower;
42
+ return 'already';
43
+ }
44
+
45
+ const originalSummarize = proto.summarize;
46
+
47
+ proto.compactIfNeeded = function patchedCompactIfNeeded(agent, trigger, signal) {
48
+ // 顺手记下「这个 agent 用的引擎实例」:某些组合(web)从 agent.ctx 取不到它。
49
+ current?.rememberEngine?.(agent, this);
50
+ // 不传原方法:塔不交回宿主,溢出也一样(见 Tower.compactIfNeeded)。
51
+ return current.compactIfNeeded(this, agent, trigger, signal);
52
+ };
53
+ proto.summarize = function patchedSummarize(input, agent, signal) {
54
+ current?.rememberEngine?.(agent, this);
55
+ return current.serveSummary(this, originalSummarize, input, agent, signal);
56
+ };
57
+
58
+ Object.defineProperty(proto, PATCHED, { value: true, configurable: true });
59
+ current = tower;
60
+ return 'patched';
61
+ }
62
+
63
+ /** 补丁是否已经装上(供诊断)。 */
64
+ export function enginePatched() {
65
+ return BasicCompactionEngine?.prototype?.[PATCHED] === true;
66
+ }
package/lib/index.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * DSH Tower:分层压缩后端。
3
+ *
4
+ * 三件事:
5
+ * 1. 补丁 dsh-compaction-basic 的两个扩展点(见 engine.js);
6
+ * 2. 回合结束后台蒸馏(观察者);
7
+ * 3. 注册 decompress / search_context 两个只读工具。
8
+ *
9
+ * 模型不参与压缩决策:没有 compress 工具,也没有提示它去压缩。
10
+ *
11
+ * 配置有三个来源,优先级从高到低:界面写入的覆盖层
12
+ * (`<DSH_HOME>/storages/compaction-tower/config.json`)→ profile 条目里的
13
+ * `config:`(cordis.patch.yml)→ DEFAULTS。见 config-store.js。
14
+ */
15
+ import { Config } from './config.js';
16
+ import { ConfigStore } from './config-store.js';
17
+ import { enginePatched, installEnginePatches } from './engine.js';
18
+ import { registerTools } from './tools.js';
19
+ import { Tower } from './tower.js';
20
+
21
+ export const name = 'compaction-tower';
22
+
23
+ /** 没有这四样就什么都做不了:工具注册表、token 尺子、模型信息、会话落盘。 */
24
+ export const inject = ['tools', 'tokenMeter', 'llm', 'sessions'];
25
+
26
+ export { Config };
27
+
28
+ /**
29
+ * 装载插件。
30
+ * @param {object} ctx 宿主 context
31
+ * @param {object} config 宿主传入的配置(profile 条目里写的那份)
32
+ */
33
+ export function apply(ctx, config) {
34
+ const store = new ConfigStore(config ?? {});
35
+ const tower = new Tower(ctx, store.config);
36
+
37
+ // 界面保存后立刻换值:Tower 所有读配置的地方都走 this.config,无需重启。
38
+ store.onChange = (next) => {
39
+ tower.updateConfig(next);
40
+ if (process.env.TOWER_DEBUG === '1') console.error('tower[debug]: 配置已更新并生效');
41
+ };
42
+ if (store.loadProblem !== undefined) ctx.logger?.warn(`tower: ${store.loadProblem}`);
43
+
44
+ const patch = installEnginePatches(tower);
45
+ if (patch === 'unavailable') {
46
+ ctx.logger?.warn('tower: dsh-compaction-basic 没有暴露 compactIfNeeded/summarize,跳过引擎补丁;压缩将完全交给宿主');
47
+ } else if (patch === 'patched') {
48
+ ctx.logger?.info('tower: 已接管 compaction-basic 的触发与摘要扩展点');
49
+ }
50
+
51
+ ctx.effect(function* registerTowerTools() {
52
+ const dispose = registerTools(ctx, tower);
53
+ yield () => dispose?.();
54
+ }, 'compaction-tower tools');
55
+
56
+ // 请求前:补一批观察(分层库存算法见 select.js 的 planObservation)。
57
+ //
58
+ // 这里**不等**它:发起即返回,块写进内存后由这次请求前后的持久化检查点落盘。
59
+ // 所以它不会拖慢请求,只是让「下一次越线时手里有货」。
60
+ ctx.on('agent/pre-step', ({ agent, signal, turn }, next) => {
61
+ void tower.pump(agent, signal, turn, { wait: false });
62
+ return next();
63
+ });
64
+
65
+ ctx.on('agent/turn-stopping', (payload) => {
66
+ if (process.env.TOWER_DEBUG === '1') {
67
+ console.error(`tower[debug]: turn-stopping fired, keys=${Object.keys(payload ?? {}).join(',')}, hasAgent=${Boolean(payload?.agent)}`);
68
+ }
69
+ // 故意返回 promise:宿主会等它再把回合关掉。回合结束立刻关会话,
70
+ // 不等的话后台写进来的块落不了盘(详见 tower.js 的 observe)。
71
+ return tower.observe(payload?.agent, payload?.turn, payload?.signal);
72
+ });
73
+
74
+ // Web 界面要用的状态与配置路由:只有 web 组合里有 webServer,其余 profile 整块不跑。
75
+ ctx.plugin({
76
+ name: 'compaction-tower-routes',
77
+ inject: ['webServer', 'sessions', 'agents', 'llm'],
78
+ async apply(webCtx) {
79
+ if (process.env.TOWER_DEBUG === '1') console.error('tower[debug]: routes 子插件已激活');
80
+ const { registerTowerRoutes } = await import('./server.js');
81
+ webCtx.effect(function* registerRoutes() {
82
+ let dispose;
83
+ try {
84
+ dispose = registerTowerRoutes(webCtx, { tower, store });
85
+ } catch (error) {
86
+ console.error(`tower: 注册 Web 路由失败:${error?.message ?? error}`);
87
+ return;
88
+ }
89
+ if (process.env.TOWER_DEBUG === '1') {
90
+ console.error('tower[debug]: 已注册 /tower/state、/tower/action、/tower/config');
91
+ }
92
+ yield () => dispose?.();
93
+ }, 'compaction-tower routes');
94
+ },
95
+ });
96
+
97
+ return tower;
98
+ }
99
+
100
+ /** 诊断用:补丁状态。 */
101
+ export { enginePatched };
package/lib/prompts.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * 观察者的提示词。
3
+ *
4
+ * 三档:留空或选 host = 不接管,交给宿主的 `summarize` 用它自己的常量;
5
+ * zh = 同一副骨架的中文版;custom = 用户自己写。
6
+ */
7
+
8
+ /**
9
+ * 宿主 `COMPACTION_INSTRUCTION` 的**展示副本**。
10
+ *
11
+ * 宿主的指令是包内常量、没有导出,插件拿不到正文;而界面上「选了宿主档就要能看见
12
+ * 它写了什么」是要的。所以这里抄一份**只供展示**。真正执行时仍然是宿主的 summarize
13
+ * 用它自己的常量 —— 这份副本即使落后于宿主版本,也只影响面板上显示的文字,
14
+ * 不影响压缩结果。
15
+ */
16
+ export const SUMMARY_PROMPT_HOST_DISPLAY = [
17
+ 'You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context.',
18
+ '',
19
+ 'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
20
+ '',
21
+ '## Primary Request and Intent',
22
+ "- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
23
+ '',
24
+ '## Key Technical Concepts',
25
+ '- [technologies, frameworks, patterns, and conventions in play]',
26
+ '',
27
+ '## Files and Code',
28
+ '- [exact path: why it matters, key changes or snippets]',
29
+ '',
30
+ '## Errors and Fixes',
31
+ '- [error: how it was resolved, plus any related user feedback]',
32
+ '',
33
+ '## Pending Jobs',
34
+ '- [explicitly requested work not yet completed]',
35
+ '',
36
+ '## Current Work',
37
+ '- [precisely what was in progress at this checkpoint]',
38
+ '',
39
+ '## Next Step',
40
+ '- [the single next action, directly in line with the most recent request, or "(none)"]',
41
+ '',
42
+ '## Critical Context',
43
+ '- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
44
+ '',
45
+ 'Rules:',
46
+ '- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.',
47
+ '- Capture user feedback and explicit instructions faithfully, especially corrections.',
48
+ '- Do NOT mention this summarization request or that the context was compacted.',
49
+ '- Output only the checkpoint text: do not call any tool or take any other action.',
50
+ '- If the conversation already contains a <compacted-summary> block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.',
51
+ ].join('\n');
52
+
53
+ /**
54
+ * 观察者的中文预设。
55
+ *
56
+ * 骨架与宿主 `COMPACTION_INSTRUCTION` 逐节对应(8 个英文小节标题一字不改),
57
+ * 只把「用简洁英文写」换成「用简洁中文写」。不是另起炉灶:小节结构是「另一个模型
58
+ * 能立刻接手」的保证,改骨架等于改协议。
59
+ */
60
+ export const SUMMARY_PROMPT_ZH = [
61
+ '你现在充当这个 AI 编码助手的压缩引擎。把上面的对话浓缩成一份结构化检查点,让另一个模型能在不丢失关键上下文的前提下接手这项工作。',
62
+ '',
63
+ '严格按下面的 Markdown 结构输出:保留每一节、保持顺序。使用简短的项目符号,不要写成段落。某一节为空就写 "(none)" —— 任何一节都不许删。',
64
+ '',
65
+ '## Primary Request and Intent',
66
+ '- [用户最初的目标与不断演变的目标;措辞本身重要时逐字引用]',
67
+ '',
68
+ '## Key Technical Concepts',
69
+ '- [涉及的技术、框架、模式与约定]',
70
+ '',
71
+ '## Files and Code',
72
+ '- [精确路径:为什么重要、关键改动或代码片段]',
73
+ '',
74
+ '## Errors and Fixes',
75
+ '- [错误:如何解决,以及相关的用户反馈]',
76
+ '',
77
+ '## Pending Jobs',
78
+ '- [明确要求但尚未完成的工作]',
79
+ '',
80
+ '## Current Work',
81
+ '- [到这一刻为止,正在做的事情到底是什么]',
82
+ '',
83
+ '## Next Step',
84
+ '- [唯一的下一个动作,必须直接对齐最近一次请求;没有就写 "(none)"]',
85
+ '',
86
+ '## Critical Context',
87
+ '- [决定及其理由、约束、用户偏好、未解的问题、继续推进所需的数据]',
88
+ '',
89
+ '规则:',
90
+ '- 用简洁的中文技术叙述来写;上面 8 个小节标题保持英文原样,不要翻译或改写。',
91
+ '- 精确保留文件路径、命令、错误原文、标识符、数值、函数签名与语法片段。',
92
+ '- 忠实记录用户的反馈与明确指令,尤其是纠正。',
93
+ '- 不要提及这次摘要请求,也不要提到上下文被压缩过。',
94
+ '- 只输出检查点文本:不要调用任何工具,也不要做别的动作。',
95
+ '- 如果对话里已经存在 <compacted-summary> 块,那是之前的检查点:不要原样照抄,保留仍然成立的事实、丢掉过期的,把新信息并进同一结构下的一份合并摘要。',
96
+ ].join('\n');
97
+
98
+ /** 观察者提示词三档:留空/宿主 = 用宿主英文原文,zh = 中文预设,custom = 自己写。 */
99
+ export const SUMMARY_PROMPT_MODES = Object.freeze([
100
+ { value: 'host', label: '宿主(英文原文)' },
101
+ { value: 'zh', label: '中文版(同结构)' },
102
+ { value: 'custom', label: '自定义' },
103
+ ]);
104
+
105
+ /** 面板里只读展示用:预设名 → 正文。随 /tower/config 一起下发。 */
106
+ export const PROMPT_PRESETS = Object.freeze({
107
+ summaryHost: SUMMARY_PROMPT_HOST_DISPLAY,
108
+ summaryZh: SUMMARY_PROMPT_ZH,
109
+ });
110
+
111
+ /**
112
+ * 这次蒸馏该用哪段指令。
113
+ * @param {object} config 生效配置
114
+ * @returns {string|null} null 表示「交给宿主的 summarize」,即不接管这次调用
115
+ */
116
+ export function summaryInstruction(config) {
117
+ if (config.summaryPromptMode === 'zh') return SUMMARY_PROMPT_ZH;
118
+ if (config.summaryPromptMode === 'custom') {
119
+ const text = String(config.summaryPrompt ?? '').trim();
120
+ // 选了自定义却没写内容:退回宿主,别让蒸馏发一条空指令出去。
121
+ return text.length === 0 ? null : text;
122
+ }
123
+ return null;
124
+ }