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,146 @@
1
+ /**
2
+ * Tower 的配置存储:把「界面上改的值」与「profile 里写的值」分开保存。
3
+ *
4
+ * 优先级:用户覆盖(本文件) > profile 条目配置(cordis.patch.yml 里的 config:) > DEFAULTS。
5
+ * 落盘位置:`<DSH_HOME>/storages/compaction-tower/config.json`,与宿主自己的 storages 同级,
6
+ * 只存用户改过的键(稀疏),删掉这个文件就回到 YAML 里的值。
7
+ *
8
+ * 写入是原子的:先写同目录临时文件再 rename,避免半个 JSON 让下次启动读不出来。
9
+ */
10
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
11
+ import { dirname } from 'node:path';
12
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
13
+ import { DEFAULTS, FIELDS, pickPatch, resolveConfig } from './config.js';
14
+
15
+ /** 覆盖配置文件的位置(跟随 DSH_HOME,沙盒里就是沙盒的 home)。 */
16
+ export function configPath() {
17
+ return dshHomePath('storages', 'compaction-tower', 'config.json');
18
+ }
19
+
20
+ export class ConfigStore {
21
+ /**
22
+ * @param {object} entryConfig profile 条目里写的配置(可能是空对象)
23
+ */
24
+ constructor(entryConfig = {}) {
25
+ this.entry = { ...entryConfig };
26
+ this.overrides = {};
27
+ this.revision = 0;
28
+ this.path = configPath();
29
+ this.loadProblem = undefined;
30
+ /** @type {((next: object) => void) | undefined} 生效后的回调(把新值推给 Tower) */
31
+ this.onChange = undefined;
32
+ this.reload();
33
+ }
34
+
35
+ /** 从磁盘读覆盖层并算出当前生效配置。坏文件只记问题,不阻止启动。 */
36
+ reload() {
37
+ this.overrides = {};
38
+ this.loadProblem = undefined;
39
+ try {
40
+ const raw = readFileSync(this.path, 'utf8');
41
+ const parsed = JSON.parse(raw);
42
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
43
+ this.overrides = pickPatch(parsed);
44
+ } else {
45
+ this.loadProblem = '配置文件不是一个 JSON 对象,已忽略';
46
+ }
47
+ } catch (error) {
48
+ if (error?.code !== 'ENOENT') this.loadProblem = `读取 ${this.path} 失败:${error?.message ?? error}`;
49
+ }
50
+ this.effective = resolveConfig({ ...this.entry, ...this.overrides });
51
+ return this.effective;
52
+ }
53
+
54
+ /** 当前生效配置。 */
55
+ get config() {
56
+ return this.effective;
57
+ }
58
+
59
+ /**
60
+ * 合并一批改动并落盘。
61
+ * @param {object} patch 只含要改的键
62
+ * @returns {object} 新的生效配置
63
+ */
64
+ save(patch) {
65
+ const clean = pickPatch(patch);
66
+ const nextOverrides = { ...this.overrides, ...clean };
67
+ const nextEffective = resolveConfig({ ...this.entry, ...nextOverrides });
68
+ this.persist(nextOverrides);
69
+ this.overrides = nextOverrides;
70
+ this.effective = nextEffective;
71
+ this.revision += 1;
72
+ this.onChange?.(nextEffective);
73
+ return nextEffective;
74
+ }
75
+
76
+ /**
77
+ * 去掉若干键的覆盖,回到 profile / 默认值。
78
+ * @param {string[]} keys 要恢复的键
79
+ * @returns {object} 新的生效配置
80
+ */
81
+ reset(keys) {
82
+ if (!Array.isArray(keys) || keys.length === 0) throw new Error('tower: reset 需要至少一个键');
83
+ const nextOverrides = { ...this.overrides };
84
+ for (const key of keys) {
85
+ if (!(key in DEFAULTS)) throw new Error(`tower: 未知配置项 "${key}"`);
86
+ delete nextOverrides[key];
87
+ }
88
+ const nextEffective = resolveConfig({ ...this.entry, ...nextOverrides });
89
+ this.persist(nextOverrides);
90
+ this.overrides = nextOverrides;
91
+ this.effective = nextEffective;
92
+ this.revision += 1;
93
+ this.onChange?.(nextEffective);
94
+ return nextEffective;
95
+ }
96
+
97
+ persist(overrides) {
98
+ mkdirSync(dirname(this.path), { recursive: true });
99
+ const temp = `${this.path}.tmp`;
100
+ writeFileSync(temp, `${JSON.stringify(overrides, null, 2)}\n`, 'utf8');
101
+ renameSync(temp, this.path);
102
+ }
103
+
104
+ /** 给配置界面用的快照:字段元数据 + 三个来源的值。 */
105
+ snapshot() {
106
+ const groups = [];
107
+ const byGroup = new Map();
108
+ for (const field of FIELDS) {
109
+ if (!byGroup.has(field.group)) {
110
+ const group = { title: field.group, fields: [] };
111
+ byGroup.set(field.group, group);
112
+ groups.push(group);
113
+ }
114
+ const value = this.effective[field.key];
115
+ byGroup.get(field.group).fields.push({
116
+ key: field.key,
117
+ label: field.label,
118
+ type: field.type,
119
+ min: field.min,
120
+ max: field.max,
121
+ step: field.step,
122
+ unit: field.unit,
123
+ hint: field.hint,
124
+ source: field.source,
125
+ pairKey: field.pairKey,
126
+ options: field.options,
127
+ rows: field.rows,
128
+ placeholder: field.placeholder,
129
+ modeKey: field.modeKey,
130
+ presetFor: field.presetFor,
131
+ targetKey: field.targetKey,
132
+ value,
133
+ percent: field.type === 'ratio' ? Math.round(value * 1000) / 10 : undefined,
134
+ default: DEFAULTS[field.key],
135
+ base: this.entry[field.key] ?? DEFAULTS[field.key],
136
+ override: Object.hasOwn(this.overrides, field.key),
137
+ });
138
+ }
139
+ return {
140
+ path: this.path,
141
+ revision: this.revision,
142
+ groups,
143
+ problem: this.loadProblem,
144
+ };
145
+ }
146
+ }
package/lib/config.js ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Tower 的配置:全部是比例,随模型上下文窗口自适应。
3
+ *
4
+ * 这里只做「默认值 + 校验 + 字段元数据」,不做任何 IO。
5
+ * 三处共用同一份事实:DEFAULTS(默认值)、Config(schemastery,给宿主)、
6
+ * FIELDS(配置界面用的标签/说明/范围)。
7
+ */
8
+ import z from '@deepseek-ai/schemastery';
9
+
10
+ /** 默认值。改这里等于改所有未显式配置的部署。 */
11
+ export const DEFAULTS = Object.freeze({
12
+ /** 占用超过比例才提交 */
13
+ triggerRatio: 0.45,
14
+ /** 提交到此为止(尽力而为) */
15
+ targetRatio: 0.15,
16
+ /** 观察者认为「值得蒸馏」的最小 run 占比 */
17
+ distillMinRunRatio: 0.05,
18
+ /** 最近 N 个 step 永不提交(蒸馏不受限) */
19
+ retainRecentGroups: 10,
20
+ /**
21
+ * 单个块原文的 token 上限。
22
+ *
23
+ * 这是保险丝,不是常规截断点:正常的上下文窗口(数百 K)里,
24
+ * 一段该被压缩的历史几乎不可能到 200K;真到了就说明模型在刷垃圾输出,
25
+ * 这时截断反而是对的。所以默认给到很大,只拦住失控的情况。
26
+ */
27
+ observerMaxTokens: 200000,
28
+ /** 观察者用的 provider(空 = 跟随当前会话) */
29
+ observerProvider: '',
30
+ /** 观察者用的 model(空 = 跟随当前会话) */
31
+ observerModel: '',
32
+ /**
33
+ * 每层的目标库存(几个单位)。
34
+ *
35
+ * 越线后要能从触发线一路压到目标线,所以库存 = (触发线 − 目标线) / 一个单位,再加 1 个安全垫。
36
+ * 每层各自计数:低层块顶不了高层的缺(提交顺序是低层优先)。
37
+ */
38
+ /** @deprecated 已改为按 (触发线−目标线)/单位 自动推导(见 reserveUnitsOf);保留键只为兼容已存的旧配置 */
39
+ observerReserveUnits: 4,
40
+ /** 一次最多同时发起几个蒸馏(并发块数,每个都是一次付费调用) */
41
+ observerConcurrency: 10,
42
+ /** 单次蒸馏超时(毫秒) */
43
+ observerTimeoutMs: 120000,
44
+ /** 蒸馏提示词用哪一套:host(宿主英文原文)| zh(中文预设)| custom(自己写) */
45
+ summaryPromptMode: 'host',
46
+ /** 自定义蒸馏提示词(仅 summaryPromptMode === 'custom' 时使用) */
47
+ summaryPrompt: '',
48
+ /** 摘要输出上限(保险丝):接管那次请求时必须显式给足,否则会被模型的默认上限腰斩 */
49
+ summaryMaxTokens: 200000,
50
+ /** 解压内容内联的阈值(占预算比例) */
51
+ inlineMaxRatio: 0.02,
52
+ /** 单次解压的绝对上限 */
53
+ hardMaxTokens: 40000,
54
+ /** 保留最近 N 个导出会话目录 */
55
+ exportKeepSessions: 20,
56
+ /** 相邻 checkpoint 达到几个才值得合并成上层块 */
57
+ mergeMinBlocks: 2,
58
+ });
59
+
60
+ export const Config = z.object({
61
+ triggerRatio: z.number().min(0.001).max(0.95).default(DEFAULTS.triggerRatio),
62
+ targetRatio: z.number().min(0.0001).max(0.9).default(DEFAULTS.targetRatio),
63
+ distillMinRunRatio: z.number().min(0.00001).max(0.5).default(DEFAULTS.distillMinRunRatio),
64
+ retainRecentGroups: z.number().step(1).min(0).default(DEFAULTS.retainRecentGroups),
65
+ observerMaxTokens: z.number().step(1).min(1000).default(DEFAULTS.observerMaxTokens),
66
+ observerProvider: z.string().default(DEFAULTS.observerProvider),
67
+ observerModel: z.string().default(DEFAULTS.observerModel),
68
+ observerReserveUnits: z.number().step(1).min(0).default(DEFAULTS.observerReserveUnits),
69
+ observerConcurrency: z.number().step(1).min(1).max(16).default(DEFAULTS.observerConcurrency),
70
+ observerTimeoutMs: z.number().step(1000).min(1000).default(DEFAULTS.observerTimeoutMs),
71
+ summaryPromptMode: z.string().default(DEFAULTS.summaryPromptMode),
72
+ summaryPrompt: z.string().default(DEFAULTS.summaryPrompt),
73
+ summaryMaxTokens: z.number().step(1).min(0).default(DEFAULTS.summaryMaxTokens),
74
+ inlineMaxRatio: z.number().min(0.001).max(0.5).default(DEFAULTS.inlineMaxRatio),
75
+ hardMaxTokens: z.number().step(1).min(1000).default(DEFAULTS.hardMaxTokens),
76
+ exportKeepSessions: z.number().step(1).min(1).default(DEFAULTS.exportKeepSessions),
77
+ mergeMinBlocks: z.number().step(1).min(2).default(DEFAULTS.mergeMinBlocks),
78
+ });
79
+
80
+ /**
81
+ * 配置界面的字段元数据。数组顺序即显示顺序,`group` 决定分栏。
82
+ * `type`:ratio 是 0..1 的比例,int 是整数,bool 开关,text 自由文本,
83
+ * textarea 多行文本(提示词用,`rows` 是行数),
84
+ * select 是下拉——`options` 是固定的候选项;`source: 'provider' | 'model'` 表示候选项
85
+ * 从宿主的模型目录里取,`pairKey` 指向它跟随的 provider 字段(选 provider 后只列该家的模型)。
86
+ */
87
+ export const FIELDS = Object.freeze([
88
+ {
89
+ key: 'triggerRatio',
90
+ group: '触发与目标',
91
+ label: '触发线',
92
+ type: 'ratio',
93
+ min: 0.001,
94
+ max: 0.95,
95
+ step: 0.01,
96
+ hint: '内容占用超过「可用预算」的这个比例才开始提交。0.45 即 45%。可用预算 = 窗口 − system 提示词 − 工具 schema(它们恒占窗口、永不压缩,所以不进分母)。',
97
+ },
98
+ {
99
+ key: 'targetRatio',
100
+ group: '触发与目标',
101
+ label: '目标线',
102
+ type: 'ratio',
103
+ min: 0.0001,
104
+ max: 0.9,
105
+ step: 0.01,
106
+ hint: '提交做到可用预算的这个比例就停。必须小于触发线;材料不够时停在能到的位置。',
107
+ },
108
+ {
109
+ key: 'distillMinRunRatio',
110
+ group: '观察者',
111
+ label: '单块分量',
112
+ type: 'ratio',
113
+ min: 0.00001,
114
+ max: 0.5,
115
+ step: 0.005,
116
+ hint: '一次蒸馏(或一次合并)的材料量,占可用预算的比例。材料不足这份量就不动手;够了就切这么一块,剩下的原文留在表层等下一回合。0.05 即 5%。',
117
+ },
118
+ {
119
+ key: 'retainRecentGroups',
120
+ group: '观察者',
121
+ label: '保留最近步数',
122
+ type: 'int',
123
+ min: 0,
124
+ max: 200,
125
+ step: 1,
126
+ unit: '步',
127
+ hint: '最近 N 个 step(一次模型请求 = 一步)永不进入蒸馏候选。按用户轮次算会把整段新对话都圈进保护区,所以按步算。0 表示不保护(仅调试用)。',
128
+ },
129
+ {
130
+ key: 'observerMaxTokens',
131
+ group: '观察者',
132
+ label: '单块原文上限',
133
+ type: 'int',
134
+ min: 1000,
135
+ max: 400000,
136
+ step: 1000,
137
+ unit: 'token',
138
+ hint: '绝对保险丝:单块绝不会超过它。正常单块 = 可用预算的「单块分量」(5%),只有单个节点本身就超过分量时才会碰到它;也用来把分量封顶(设得比分量小就以它为准)。',
139
+ },
140
+ {
141
+ key: 'observerTimeoutMs',
142
+ group: '观察者',
143
+ label: '蒸馏超时',
144
+ type: 'int',
145
+ min: 1000,
146
+ max: 600000,
147
+ step: 1000,
148
+ unit: '毫秒',
149
+ hint: '回合结束会等蒸馏完成再关会话;超过这个时间就放弃这一次,块不写入。',
150
+ },
151
+ {
152
+ key: 'observerProvider',
153
+ group: '观察者',
154
+ label: '观察者 provider',
155
+ type: 'select',
156
+ source: 'provider',
157
+ hint: '蒸馏用哪个 provider。留空跟随当前会话。',
158
+ },
159
+ {
160
+ key: 'observerModel',
161
+ group: '观察者',
162
+ label: '观察者 model',
163
+ type: 'select',
164
+ source: 'model',
165
+ pairKey: 'observerProvider',
166
+ hint: '蒸馏用哪个模型。留空跟随当前会话;选了 provider 时只列出那个 provider 的模型。',
167
+ },
168
+
169
+ {
170
+ key: 'observerConcurrency',
171
+ group: '观察者',
172
+ label: '并发蒸馏',
173
+ type: 'int',
174
+ min: 1,
175
+ max: 16,
176
+ step: 1,
177
+ unit: '个',
178
+ hint: '一次最多同时发起几个蒸馏。从最底层开始发名额,发满就停:低层有料就先蒸低层。',
179
+ },
180
+ {
181
+ key: 'summaryPromptMode',
182
+ group: '观察者',
183
+ label: '蒸馏提示词',
184
+ type: 'select',
185
+ targetKey: 'summaryPrompt',
186
+ options: [
187
+ { value: 'host', label: '宿主(英文原文)' },
188
+ { value: 'zh', label: '中文版(同结构)' },
189
+ { value: 'custom', label: '自定义' },
190
+ ],
191
+ hint: '宿主:调用宿主的 summarize,用它的英文指令(默认,格式与宿主原生压缩一致)。中文版:同样的 8 段结构,正文改中文。自定义:用下面那段文本,自己发这次请求;改之前先切成自定义。',
192
+ },
193
+ {
194
+ key: 'summaryPrompt',
195
+ group: '观察者',
196
+ label: '自定义蒸馏提示词',
197
+ type: 'textarea',
198
+ rows: 10,
199
+ placeholder: '留空 = 用宿主英文原文',
200
+ modeKey: 'summaryPromptMode',
201
+ presetFor: { host: 'summaryHost', zh: 'summaryZh' },
202
+ hint: '选了预设时这里只读显示那段预设的原文;切到「自定义」才可编辑。建议保留 8 个英文小节标题——它是「下一个模型能接手」的结构约定。',
203
+ },
204
+ {
205
+ key: 'summaryMaxTokens',
206
+ group: '观察者',
207
+ label: '摘要输出上限',
208
+ type: 'int',
209
+ min: 0,
210
+ max: 400000,
211
+ step: 1000,
212
+ unit: 'token',
213
+ hint: '只管中文版 / 自定义档这一次请求的输出上限(宿主档仍用宿主自己的 65536)。它是防失控的保险丝,不是用来剪摘要的:正常摘要几 K 就停笔,200000 永远碰不到。调小会让摘要撞上限 → 这一块整块作废(宁可这次不压,也不拿半截摘要覆盖原文)。0 = 不传上限,交给适配器的默认值。',
214
+ },
215
+ {
216
+ key: 'mergeMinBlocks',
217
+ group: '塔',
218
+ label: '合并块数下限',
219
+ type: 'int',
220
+ min: 2,
221
+ max: 16,
222
+ step: 1,
223
+ unit: '个',
224
+ hint: '一次合并至少要几个同层 checkpoint。真正决定「什么时候值得合并」的是材料总量要攒到可用预算的 5%(与蒸馏共用 distillMinRunRatio);这个数只是下限。',
225
+ },
226
+ {
227
+ key: 'exportKeepSessions',
228
+ group: '塔',
229
+ label: '导出目录保留',
230
+ type: 'int',
231
+ min: 1,
232
+ max: 200,
233
+ step: 1,
234
+ unit: '个会话',
235
+ hint: '解压内容落盘到 <项目>/.dsh/tower/ 时,只保留最近几个会话的目录。',
236
+ },
237
+ {
238
+ key: 'inlineMaxRatio',
239
+ group: '解压',
240
+ label: '内联阈值',
241
+ type: 'ratio',
242
+ min: 0.001,
243
+ max: 0.5,
244
+ step: 0.005,
245
+ hint: '解压内容小于窗口的这个比例就直接回给模型,否则写文件并给路径。',
246
+ },
247
+ {
248
+ key: 'hardMaxTokens',
249
+ group: '解压',
250
+ label: '硬上限',
251
+ type: 'int',
252
+ min: 1000,
253
+ max: 400000,
254
+ step: 1000,
255
+ unit: 'token',
256
+ hint: '单次解压返回的绝对上限,超过就截断并说明。',
257
+ },
258
+ ]);
259
+
260
+ const KEYS = new Set(Object.keys(DEFAULTS));
261
+
262
+ /**
263
+ * 校验并冻结配置。
264
+ * @param {object} raw 宿主传入的原始配置(通常已经和用户覆盖合并过)。
265
+ * @returns {object} 深冻结的配置。
266
+ */
267
+ export function resolveConfig(raw = {}) {
268
+ for (const key of Object.keys(raw)) {
269
+ if (!KEYS.has(key)) {
270
+ throw new Error(`tower: unknown config key "${key}" (allowed: ${[...KEYS].join(', ')})`);
271
+ }
272
+ }
273
+ const resolved = { ...DEFAULTS, ...raw };
274
+ if (!(resolved.targetRatio < resolved.triggerRatio)) {
275
+ throw new Error(`tower: 目标线(${resolved.targetRatio})必须小于触发线(${resolved.triggerRatio})`);
276
+ }
277
+ if (!Number.isFinite(resolved.hardMaxTokens) || resolved.hardMaxTokens <= 0) {
278
+ throw new Error(`tower: hardMaxTokens(${resolved.hardMaxTokens})必须是正数`);
279
+ }
280
+ for (const field of FIELDS) {
281
+ const value = resolved[field.key];
282
+ if (field.type === 'int') {
283
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
284
+ throw new Error(`tower: ${field.label}(${field.key})必须是整数`);
285
+ }
286
+ }
287
+ if (field.type === 'ratio' && (!Number.isFinite(value) || value <= 0 || value >= 1)) {
288
+ throw new Error(`tower: ${field.label}(${field.key})必须在 0 与 1 之间`);
289
+ }
290
+ if (typeof field.min === 'number' && value < field.min) {
291
+ throw new Error(`tower: ${field.label}(${field.key})不能小于 ${field.min}`);
292
+ }
293
+ if (typeof field.max === 'number' && value > field.max) {
294
+ throw new Error(`tower: ${field.label}(${field.key})不能大于 ${field.max}`);
295
+ }
296
+ if (field.type === 'bool' && typeof value !== 'boolean') {
297
+ throw new Error(`tower: ${field.label}(${field.key})必须是 true / false`);
298
+ }
299
+ if ((field.type === 'text' || field.type === 'select' || field.type === 'textarea') && typeof value !== 'string') {
300
+ throw new Error(`tower: ${field.label}(${field.key})必须是字符串`);
301
+ }
302
+ if (field.type === 'select' && Array.isArray(field.options) && !field.options.some((option) => option.value === value)) {
303
+ throw new Error(`tower: ${field.label}(${field.key})只能是 ${field.options.map((option) => option.value).join(' / ')}`);
304
+ }
305
+ if (field.type === 'textarea' && value.length > 100000) {
306
+ throw new Error(`tower: ${field.label}(${field.key})太长了(上限 100000 字符)`);
307
+ }
308
+ }
309
+ return Object.freeze(resolved);
310
+ }
311
+
312
+ /** 只接受已知字段的补丁(配置界面用;未知键直接拒,别静默丢掉)。 */
313
+ export function pickPatch(raw) {
314
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
315
+ throw new Error('tower: 配置补丁必须是一个对象');
316
+ }
317
+ const patch = {};
318
+ for (const [key, value] of Object.entries(raw)) {
319
+ if (!KEYS.has(key)) throw new Error(`tower: 未知配置项 "${key}"`);
320
+ patch[key] = value;
321
+ }
322
+ return patch;
323
+ }