dsh-compact-relay 0.0.0-stage → 0.3.2

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +149 -0
  4. package/README.md +148 -2
  5. package/ROADMAP.md +125 -0
  6. package/TUTORIAL.md +65 -0
  7. package/assets/compact-preview.jpg +0 -0
  8. package/assets/hero-ring.jpg +0 -0
  9. package/assets/menu.jpg +0 -0
  10. package/assets/native-alert.jpg +0 -0
  11. package/assets/onboarding.jpg +0 -0
  12. package/assets/settings.jpg +0 -0
  13. package/assets/teaching-card.jpg +0 -0
  14. package/cordis.patch.yml +6 -0
  15. package/lib/actions/better-compact.js +326 -0
  16. package/lib/actions/compact-diff.js +30 -0
  17. package/lib/actions/compress-hint.js +58 -0
  18. package/lib/actions/handoff-docs.js +27 -0
  19. package/lib/actions/handoff.js +304 -0
  20. package/lib/actions/inflight.js +75 -0
  21. package/lib/actions/lock.js +44 -0
  22. package/lib/actions/native-compaction.js +35 -0
  23. package/lib/actions/pin.js +208 -0
  24. package/lib/actions/settings.js +191 -0
  25. package/lib/actions/stage.js +36 -0
  26. package/lib/actions/state.js +134 -0
  27. package/lib/actions/test-compact.js +103 -0
  28. package/lib/adapters/commands.js +177 -0
  29. package/lib/adapters/http.js +430 -0
  30. package/lib/adapters/tool.js +36 -0
  31. package/lib/client.js +3229 -0
  32. package/lib/config.js +167 -0
  33. package/lib/core/colors.js +150 -0
  34. package/lib/core/compress-hint.js +52 -0
  35. package/lib/core/config-schema.js +285 -0
  36. package/lib/core/errors.js +57 -0
  37. package/lib/core/handoff-doc.js +136 -0
  38. package/lib/core/handoff-facts.js +51 -0
  39. package/lib/core/handoff-spec.js +93 -0
  40. package/lib/core/pins.js +82 -0
  41. package/lib/core/placeholders.js +163 -0
  42. package/lib/core/policy.js +93 -0
  43. package/lib/core/prompts.js +173 -0
  44. package/lib/core/ranges.js +117 -0
  45. package/lib/core/readiness.js +63 -0
  46. package/lib/core/relay.js +45 -0
  47. package/lib/core/ring.js +81 -0
  48. package/lib/core/stage.js +96 -0
  49. package/lib/core/validate-handoff.js +116 -0
  50. package/lib/core/validate-summary.js +147 -0
  51. package/lib/dsh/compact-transaction.js +188 -0
  52. package/lib/dsh/compaction-log.js +146 -0
  53. package/lib/dsh/engine.js +36 -0
  54. package/lib/dsh/handoff-files.js +114 -0
  55. package/lib/dsh/handoff-writer.js +126 -0
  56. package/lib/dsh/idle.js +27 -0
  57. package/lib/dsh/llm-call.js +189 -0
  58. package/lib/dsh/native-compaction.js +185 -0
  59. package/lib/dsh/prefix.js +47 -0
  60. package/lib/dsh/pressure.js +70 -0
  61. package/lib/dsh/relay.js +64 -0
  62. package/lib/dsh/route.js +68 -0
  63. package/lib/dsh/select-range.js +71 -0
  64. package/lib/dsh/session-files.js +63 -0
  65. package/lib/dsh/session-view.js +239 -0
  66. package/lib/dsh/sessions.js +282 -0
  67. package/lib/dsh/settings-file.js +104 -0
  68. package/lib/dsh/site-info.js +112 -0
  69. package/lib/dsh/state-hub.js +203 -0
  70. package/lib/dsh/stream.js +22 -0
  71. package/lib/dsh/summarizer.js +144 -0
  72. package/lib/index.js +70 -0
  73. package/lib/settings-store.js +10 -0
  74. package/package.json +68 -4
  75. package/screenshots.json +9 -0
package/lib/config.js ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * 配置合并与校验。
3
+ *
4
+ * 字段清单、默认值、中文校验文案都在 core/config-schema.js(A4:单表派生)。
5
+ * 这里只负责:cordis 基础配置 + 用户覆盖层的深合并、旧写法兼容(绝对阈值/扁平颜色)、
6
+ * 颜色派生前的归一化。
7
+ */
8
+ import path from 'node:path';
9
+ import {
10
+ DEFAULT_CONFIG,
11
+ expandHome,
12
+ failConfig,
13
+ fieldAt,
14
+ validateConfig,
15
+ } from './core/config-schema.js';
16
+
17
+ // 兼容旧导入:其它模块/文档仍可从 config.js 拿默认配置与路径工具。
18
+ export { CONFIG_FIELDS, DEFAULT_CONFIG, defaultDshHome, expandHome } from './core/config-schema.js';
19
+
20
+ const COLOR_KEYS = ['idle', 'stage', 'pin', 'compact', 'handoff'];
21
+ const COLOR_MODES = fieldAt(['colors', 'mode']).values;
22
+ const BASE_COLOR_DEFAULTS = {
23
+ idle: fieldAt(['colors', 'base', 'idle']).default,
24
+ stage: fieldAt(['colors', 'base', 'stage']).default,
25
+ pin: fieldAt(['colors', 'base', 'pin']).default,
26
+ compact: fieldAt(['colors', 'base', 'compact']).default,
27
+ handoff: fieldAt(['colors', 'base', 'handoff']).default,
28
+ };
29
+
30
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
31
+
32
+ function objectOf(raw, key) {
33
+ return isPlainObject(raw[key]) ? raw[key] : {};
34
+ }
35
+
36
+ /**
37
+ * 颜色配置归一化:
38
+ * - 新结构 `{ mode, autoAdapt, base:{idle,compact,handoff,pin,stage} }`;
39
+ * - 兼容旧结构 `{ idle, stage, compact, handoff }`:当作 base,且默认关掉自动适配
40
+ * (旧版本来就是一套颜色用到底,保持原样);
41
+ * - 逐字段校验:给了就必须是非空字符串(合法 CSS 颜色)。
42
+ * @param {object} raw
43
+ * @returns {{mode:string, autoAdapt:boolean, base:object}}
44
+ */
45
+ function normalizeColors(raw) {
46
+ const input = isPlainObject(raw) ? raw : {};
47
+ const mode = input.mode ?? DEFAULT_CONFIG.colors.mode;
48
+ if (!COLOR_MODES.includes(mode)) failConfig(['colors', 'mode'], `只能是 ${COLOR_MODES.join(' / ')}`, mode);
49
+ if (input.autoAdapt !== undefined && typeof input.autoAdapt !== 'boolean') {
50
+ failConfig(['colors', 'autoAdapt'], '必须是开/关(true/false)', input.autoAdapt);
51
+ }
52
+
53
+ const take = (source) => {
54
+ const out = {};
55
+ for (const key of COLOR_KEYS) {
56
+ const value = isPlainObject(source) ? source[key] : undefined;
57
+ if (value === undefined) continue;
58
+ if (typeof value !== 'string' || value.trim().length === 0) {
59
+ failConfig(['colors', 'base', key], '不能为空,必须是颜色值(如 #6F7680)', value);
60
+ }
61
+ out[key] = value.trim();
62
+ }
63
+ return out;
64
+ };
65
+ const legacy = take(input);
66
+ const hasLegacy = Object.keys(legacy).length > 0;
67
+ const base = { ...BASE_COLOR_DEFAULTS, ...legacy, ...take(input.base) };
68
+ // 旧扁平结构默认关自动适配(保持旧观感);新结构默认开。
69
+ const autoAdapt = typeof input.autoAdapt === 'boolean' ? input.autoAdapt : !hasLegacy;
70
+ return { mode, autoAdapt, base };
71
+ }
72
+
73
+ /**
74
+ * 合并默认配置并做校验。未知键忽略(cordis 可能塞别的字段进来)。
75
+ *
76
+ * 阈值按**窗口比例**配置(compactRatio / handoffRatio):换 1M / 200k / 128k
77
+ * 窗口的模型都自动按比例缩放,不会出现「绝对阈值超过窗口、提示永不触发」。
78
+ * 兼容旧版绝对写法(thresholds.compactAt/handoffAt 或顶层同名字段):
79
+ * 用 contextWindowFallback 折算成比例;新旧同时给时以比例为准。
80
+ *
81
+ * @param {object} [raw]
82
+ * @returns {object}
83
+ */
84
+ export function resolveConfig(raw = {}) {
85
+ const input = isPlainObject(raw) ? raw : {};
86
+ const thresholds = objectOf(input, 'thresholds');
87
+ const retention = objectOf(input, 'retention');
88
+ const summary = objectOf(input, 'summary');
89
+ const handoff = objectOf(input, 'handoff');
90
+ const timeouts = objectOf(input, 'timeouts');
91
+ const dev = objectOf(input, 'dev');
92
+ const colors = objectOf(input, 'colors');
93
+
94
+ const windowFallback = input.contextWindowFallback ?? DEFAULT_CONFIG.contextWindowFallback;
95
+ const legacyCompactAt = thresholds.compactAt ?? input.compactAt;
96
+ const legacyHandoffAt = thresholds.handoffAt ?? input.handoffAt;
97
+ const ratioFromLegacy = (value, pathParts) => {
98
+ if (value === undefined) return undefined;
99
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
100
+ failConfig(pathParts, '旧版绝对阈值必须是正数(或改用窗口比例 0~1)', value);
101
+ }
102
+ return value / windowFallback;
103
+ };
104
+
105
+ const merged = {
106
+ thresholds: {
107
+ compactRatio:
108
+ thresholds.compactRatio ??
109
+ ratioFromLegacy(legacyCompactAt, ['thresholds', 'compactRatio']) ??
110
+ DEFAULT_CONFIG.thresholds.compactRatio,
111
+ handoffRatio:
112
+ thresholds.handoffRatio ??
113
+ ratioFromLegacy(legacyHandoffAt, ['thresholds', 'handoffRatio']) ??
114
+ DEFAULT_CONFIG.thresholds.handoffRatio,
115
+ maxSummaries: thresholds.maxSummaries ?? DEFAULT_CONFIG.thresholds.maxSummaries,
116
+ minRatio: thresholds.minRatio ?? DEFAULT_CONFIG.thresholds.minRatio,
117
+ },
118
+ contextWindowFallback: windowFallback,
119
+ retention: {
120
+ recentTailTokens: retention.recentTailTokens ?? DEFAULT_CONFIG.retention.recentTailTokens,
121
+ },
122
+ summary: {
123
+ maxTokens: summary.maxTokens ?? DEFAULT_CONFIG.summary.maxTokens,
124
+ },
125
+ handoff: {
126
+ maxTokens: handoff.maxTokens ?? DEFAULT_CONFIG.handoff.maxTokens,
127
+ },
128
+ timeouts: {
129
+ llmMs: timeouts.llmMs ?? DEFAULT_CONFIG.timeouts.llmMs,
130
+ },
131
+ dev: {
132
+ testRangeNodes: dev.testRangeNodes ?? DEFAULT_CONFIG.dev.testRangeNodes,
133
+ },
134
+ // 配置里允许写 `~/...`:统一展开成绝对路径,别在 cwd 下建字面 `~` 目录。
135
+ handoffDir: path.resolve(
136
+ typeof input.handoffDir === 'string' && input.handoffDir.length > 0
137
+ ? expandHome(input.handoffDir)
138
+ : DEFAULT_CONFIG.handoffDir,
139
+ ),
140
+ colors: normalizeColors(colors),
141
+ links: {
142
+ tutorial: input.links?.tutorial ?? DEFAULT_CONFIG.links.tutorial,
143
+ feedback: input.links?.feedback ?? DEFAULT_CONFIG.links.feedback,
144
+ },
145
+ };
146
+
147
+ // schema 里的所有非颜色字段统一校验(中文文案由 schema 提供)。
148
+ validateConfig(merged);
149
+
150
+ const { compactRatio, handoffRatio, minRatio } = merged.thresholds;
151
+ if (minRatio >= compactRatio) {
152
+ failConfig(
153
+ ['thresholds', 'minRatio'],
154
+ '必须小于「压缩提醒线」;最小压缩线是「值得压缩」的门槛,要在压缩线之前',
155
+ minRatio,
156
+ );
157
+ }
158
+ if (compactRatio >= handoffRatio) {
159
+ failConfig(
160
+ ['thresholds', 'compactRatio'],
161
+ '必须小于「交接提醒线」;压缩线要在交接线之前,否则会先建议换会话',
162
+ compactRatio,
163
+ );
164
+ }
165
+
166
+ return merged;
167
+ }
@@ -0,0 +1,150 @@
1
+ export const DEFAULT_BASE = { idle: '#6F7680', compact: '#C26A00', handoff: '#D92D20', stage: '#2563EB', pin: '#C026D3' };
2
+
3
+ /** DSH 原生自动压缩线的标记色:刻意不用五级配色里的任何颜色,保证一眼和环上的红/琥珀分开。 */
4
+ export const NATIVE_LINE_COLOR = '#7C3AED';
5
+ export const SCHEME_BG = { light: '#ffffff', dark: '#1c1c22' };
6
+
7
+ export function normalizeHex(value, fallback) {
8
+ const text = typeof value === 'string' ? value.trim() : '';
9
+ if (/^#[0-9a-f]{6}$/i.test(text)) return text.toLowerCase();
10
+ if (/^#[0-9a-f]{3}$/i.test(text)) {
11
+ return `#${text[1]}${text[1]}${text[2]}${text[2]}${text[3]}${text[3]}`.toLowerCase();
12
+ }
13
+ return fallback;
14
+ }
15
+
16
+ export function hexToHsl(hex) {
17
+ const n = parseInt(normalizeHex(hex, '#888888').slice(1), 16);
18
+ const r = ((n >> 16) & 255) / 255;
19
+ const g = ((n >> 8) & 255) / 255;
20
+ const b = (n & 255) / 255;
21
+ const max = Math.max(r, g, b);
22
+ const min = Math.min(r, g, b);
23
+ const l = (max + min) / 2;
24
+ if (max === min) return { h: 0, s: 0, l };
25
+ const d = max - min;
26
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
27
+ let h;
28
+ if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
29
+ else if (max === g) h = (b - r) / d + 2;
30
+ else h = (r - g) / d + 4;
31
+ return { h: h * 60, s, l };
32
+ }
33
+
34
+ export function hslToHex(h, s, l) {
35
+ const hue = ((h % 360) + 360) % 360 / 360;
36
+ const sat = Math.min(1, Math.max(0, s));
37
+ const lig = Math.min(1, Math.max(0, l));
38
+ const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat;
39
+ const p = 2 * lig - q;
40
+ const channel = (t) => {
41
+ const x = ((t % 1) + 1) % 1;
42
+ if (x < 1 / 6) return p + (q - p) * 6 * x;
43
+ if (x < 1 / 2) return q;
44
+ if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6;
45
+ return p;
46
+ };
47
+ const to = (value) => Math.round(value * 255).toString(16).padStart(2, '0');
48
+ return `#${to(channel(hue + 1 / 3))}${to(channel(hue))}${to(channel(hue - 1 / 3))}`;
49
+ }
50
+
51
+ export function luminance(hex) {
52
+ const n = parseInt(normalizeHex(hex, '#888888').slice(1), 16);
53
+ const channel = (v) => {
54
+ const c = v / 255;
55
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
56
+ };
57
+ return 0.2126 * channel((n >> 16) & 255) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255);
58
+ }
59
+
60
+ export function contrastRatio(a, b) {
61
+ const la = luminance(a);
62
+ const lb = luminance(b);
63
+ const hi = Math.max(la, lb);
64
+ const lo = Math.min(la, lb);
65
+ return (hi + 0.05) / (lo + 0.05);
66
+ }
67
+
68
+ export function deriveColor(baseHex, scheme) {
69
+ const { h, s, l } = hexToHsl(normalizeHex(baseHex, '#888888'));
70
+ const neutral = s < 0.15;
71
+ let lig = scheme === 'dark' ? Math.max(l, 0.62) : Math.min(l, 0.46);
72
+ const sat = neutral ? s : Math.min(0.92, Math.max(0.4, s));
73
+ const bg = SCHEME_BG[scheme];
74
+ let out = hslToHex(h, sat, lig);
75
+ for (let i = 0; i < 60 && contrastRatio(out, bg) < 3; i += 1) {
76
+ lig = scheme === 'dark' ? Math.min(0.94, lig + 0.01) : Math.max(0.06, lig - 0.01);
77
+ out = hslToHex(h, sat, lig);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** 取用户选的基准色(兼容旧版直接下发一套颜色)。 */
83
+ export function basePalette(colors) {
84
+ if (colors !== null && typeof colors === 'object' && colors.base !== null && typeof colors.base === 'object') {
85
+ const base = {};
86
+ for (const key of ['idle', 'stage', 'pin', 'compact', 'handoff']) {
87
+ base[key] = normalizeHex(colors.base[key], DEFAULT_BASE[key]);
88
+ }
89
+ return base;
90
+ }
91
+ if (colors !== null && typeof colors === 'object' && typeof colors.idle === 'string') {
92
+ return {
93
+ idle: normalizeHex(colors.idle, DEFAULT_BASE.idle),
94
+ stage: normalizeHex(colors.stage, DEFAULT_BASE.stage),
95
+ pin: normalizeHex(colors.pin, DEFAULT_BASE.pin),
96
+ compact: normalizeHex(colors.compact, DEFAULT_BASE.compact),
97
+ handoff: normalizeHex(colors.handoff, DEFAULT_BASE.handoff),
98
+ };
99
+ }
100
+ return { ...DEFAULT_BASE };
101
+ }
102
+
103
+ /** 按指定明暗方案生成一套颜色(关闭自动适配时原样返回基准色)。 */
104
+ export function paletteForScheme(colors, scheme) {
105
+ const base = basePalette(colors);
106
+ const auto = !(colors !== null && typeof colors === 'object' && colors.autoAdapt === false);
107
+ if (!auto) return base;
108
+ return {
109
+ idle: deriveColor(base.idle, scheme),
110
+ stage: deriveColor(base.stage, scheme),
111
+ pin: deriveColor(base.pin, scheme),
112
+ compact: deriveColor(base.compact, scheme),
113
+ handoff: deriveColor(base.handoff, scheme),
114
+ };
115
+ }
116
+
117
+ /** 决定当前用浅色还是深色方案:设置里强制优先,否则跟随界面实际明暗。 */
118
+ export function effectiveScheme(colors, detectedDark) {
119
+ if (colors !== null && typeof colors === 'object' && (colors.mode === 'light' || colors.mode === 'dark')) return colors.mode;
120
+ return detectedDark ? 'dark' : 'light';
121
+ }
122
+
123
+ /**
124
+ * 选当前生效的一套颜色。
125
+ *
126
+ * A8:优先用 host 在 state/settings view 里下发的 colors.palette[scheme]
127
+ * (两套都算好,浏览器半只挑一套,不必每帧重算派生);没有下发时按基准色
128
+ * 本地派生 —— 兼容旧 payload,也覆盖设置页「改了还没保存」的即时预览。
129
+ */
130
+ export function resolvePalette(colors, detectedDark) {
131
+ const scheme = effectiveScheme(colors, detectedDark);
132
+ const palette = colors !== null && typeof colors === 'object' ? colors.palette : undefined;
133
+ if (palette !== null && typeof palette === 'object') {
134
+ const selected = palette[scheme];
135
+ if (selected !== null && typeof selected === 'object') {
136
+ const base = basePalette(colors);
137
+ const out = {};
138
+ for (const key of ['idle', 'stage', 'pin', 'compact', 'handoff']) {
139
+ out[key] = normalizeHex(selected[key], base[key]);
140
+ }
141
+ return out;
142
+ }
143
+ }
144
+ return paletteForScheme(colors, scheme);
145
+ }
146
+
147
+ /** 浅/深两套派生色板(host 下发给浏览器半;纯函数,可单测)。 */
148
+ export function paletteForBothSchemes(colors) {
149
+ return { light: paletteForScheme(colors, 'light'), dark: paletteForScheme(colors, 'dark') };
150
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 「这里适合压缩」判断记录(0.3.1)—— 纯数据与纯判定,不依赖 DSH。
3
+ *
4
+ * 模型判断:最小线 < 当前 < 压缩线,且工作到了自然节点 → 调用工具记一条:
5
+ * plugin:compact-relay/compress-hint data: { ratio, at }
6
+ *
7
+ * 环上把它画成「断点1」(正常区/压缩区的分界);压缩或交接改变历史后自动失效
8
+ * (和 checkpoint 比较 seq,跟旧阶段标记一个套路)。
9
+ */
10
+
11
+ /** 记录类型(DSH 语法:`plugin:` + 小写段;只认精确匹配)。 */
12
+ export const COMPRESS_HINT_RECORD_TYPE = 'plugin:compact-relay/compress-hint';
13
+
14
+ /** 判断记录的判定 = ignorable + 类型精确匹配。 */
15
+ export function isCompressHintEvent(event) {
16
+ return event !== null && typeof event === 'object' && event.ignorable === true && event.type === COMPRESS_HINT_RECORD_TYPE;
17
+ }
18
+
19
+ /**
20
+ * 从会话事件流里取最近一条「适合压缩」判断;没有或不合法返回 null。
21
+ * @param {object[]} events
22
+ * @returns {{ratio:number, at:number, seq:number}|null}
23
+ */
24
+ export function latestCompressHint(events) {
25
+ if (!Array.isArray(events)) return null;
26
+ let hint = null;
27
+ for (const event of events) {
28
+ if (!isCompressHintEvent(event)) continue;
29
+ const data = event.data;
30
+ if (data === null || typeof data !== 'object') continue;
31
+ const ratio =
32
+ typeof data.ratio === 'number' && Number.isFinite(data.ratio)
33
+ ? Math.max(0, Math.min(1, data.ratio))
34
+ : null;
35
+ if (ratio === null) continue;
36
+ const at = typeof data.at === 'number' && Number.isFinite(data.at) ? data.at : 0;
37
+ hint = { ratio, at, seq: event.seq };
38
+ }
39
+ return hint;
40
+ }
41
+
42
+ /**
43
+ * 判断记录是否仍然有效:要比最后一次压缩/交接 checkpoint 新才算数。
44
+ * @param {{seq:number}|null} hint
45
+ * @param {number|undefined} checkpointSeq 最后一次 checkpoint 消息的 event seq
46
+ * @returns {boolean}
47
+ */
48
+ export function compressHintIsActive(hint, checkpointSeq) {
49
+ if (hint === null || hint === undefined) return false;
50
+ if (!Number.isInteger(checkpointSeq)) return true;
51
+ return hint.seq > checkpointSeq;
52
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * 配置 schema —— 所有配置项的唯一清单。
3
+ *
4
+ * 加一个设置项只改这里:默认值、类型/范围校验、中文文案、设置页白名单、
5
+ * 公开视图(publicConfig)全部由这张表派生。config.js 只负责合并兼容写法与颜色归一。
6
+ *
7
+ * 字段标志:
8
+ * - `editable`: 允许从设置栏写入 `compact-relay.json`(filterPatch 白名单)
9
+ * - `view`: 出现在设置栏读取的公开视图里(publicConfig)
10
+ * - `internal`: 内部配置(不出现在设置页,也不允许设置页写)
11
+ */
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ /**
16
+ * 展开开头的 `~`(只认 `~` 或 `~/`;`~user` 形式不猜)。
17
+ * @param {string} value
18
+ * @returns {string}
19
+ */
20
+ export function expandHome(value) {
21
+ if (value === '~') return os.homedir();
22
+ if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2));
23
+ return value;
24
+ }
25
+
26
+ /**
27
+ * 默认 DSH home:优先环境变量 DSH_HOME(隔离复刻/测试会用),否则 ~/.dsh。
28
+ * @returns {string}
29
+ */
30
+ export function defaultDshHome() {
31
+ const fromEnv = process.env.DSH_HOME;
32
+ if (typeof fromEnv === 'string' && fromEnv.trim().length > 0) {
33
+ return path.resolve(expandHome(fromEnv));
34
+ }
35
+ return path.join(os.homedir(), '.dsh');
36
+ }
37
+
38
+ function field(pathParts, options) {
39
+ return Object.freeze({
40
+ path: Object.freeze(pathParts),
41
+ label: options.label,
42
+ kind: options.kind,
43
+ values: options.values === undefined ? undefined : Object.freeze(options.values),
44
+ max: options.max,
45
+ default: options.default,
46
+ editable: options.editable === true,
47
+ view: options.view === true,
48
+ });
49
+ }
50
+
51
+ /**
52
+ * 单表:路径 / 类型 / 默认值 / 中文标签 / 是否设置页可改。
53
+ * 顺序即设置页展示顺序(settings 面板里仍按自己的分组渲染,这里只管数据)。
54
+ */
55
+ export const CONFIG_FIELDS = Object.freeze([
56
+ // 0.3.1:「值得压缩」的最小线——低于它压缩不划算(摘要可能比原文还大),模型不会建议压缩。
57
+ field(['thresholds', 'minRatio'], { label: '最小压缩线', kind: 'ratio', default: 0.05, editable: true, view: true }),
58
+ field(['thresholds', 'compactRatio'], { label: '压缩提醒线', kind: 'ratio', default: 0.4, editable: true, view: true }),
59
+ field(['thresholds', 'handoffRatio'], { label: '交接提醒线', kind: 'ratio', default: 0.6, editable: true, view: true }),
60
+ // P3-02:必须 ≥1。0 在策略层会同时触发「恒交接 + 恒合并旧摘要」,没有可用语义,
61
+ // 以前允许 0 会让指示灯永远停在交接状态;现在保存 0 直接给中文校验错误。
62
+ field(['thresholds', 'maxSummaries'], { label: '摘要份数上限', kind: 'positiveInt', default: 6, editable: true, view: true }),
63
+ field(['contextWindowFallback'], { label: '兜底窗口', kind: 'positiveInt', default: 1_000_000 }),
64
+ field(['retention', 'recentTailTokens'], { label: '保留近期原文', kind: 'nonNegativeInt', default: 40_000, editable: true, view: true }),
65
+ field(['summary', 'maxTokens'], { label: '摘要输出上限', kind: 'positiveInt', default: 8_192 }),
66
+ field(['handoff', 'maxTokens'], { label: '交接单输出上限', kind: 'positiveInt', default: 8_192 }),
67
+ field(['timeouts', 'llmMs'], { label: '模型调用超时', kind: 'positiveInt', default: 180_000 }),
68
+ field(['dev', 'testRangeNodes'], { label: '测试取段条数', kind: 'positiveInt', default: 4 }),
69
+ field(['handoffDir'], {
70
+ label: '交接单目录',
71
+ kind: 'path',
72
+ default: () => path.join(defaultDshHome(), 'handoffs'),
73
+ editable: true,
74
+ view: true,
75
+ }),
76
+ field(['colors', 'mode'], { label: '颜色模式', kind: 'enum', values: ['auto', 'light', 'dark'], default: 'auto', editable: true, view: true }),
77
+ field(['colors', 'autoAdapt'], { label: '自动适配颜色', kind: 'boolean', default: true, editable: true, view: true }),
78
+ field(['colors', 'base', 'idle'], { label: '默认颜色', kind: 'color', default: '#6F7680', editable: true, view: true }),
79
+ field(['colors', 'base', 'stage'], { label: '填满颜色', kind: 'color', default: '#2563EB', editable: true, view: true }),
80
+ field(['colors', 'base', 'pin'], { label: '锁定颜色', kind: 'color', default: '#C026D3', editable: true, view: true }),
81
+ field(['colors', 'base', 'compact'], { label: '调整颜色', kind: 'color', default: '#C26A00', editable: true, view: true }),
82
+ field(['colors', 'base', 'handoff'], { label: '上限颜色', kind: 'color', default: '#D92D20', editable: true, view: true }),
83
+ // 0.3.1:仓库已建(cfanzx/dsh-compact-relay),默认指向仓库内的教程与 Issues;
84
+ // 用户仍可在设置文件里覆盖 links.tutorial / links.feedback。
85
+ field(['links', 'tutorial'], { label: '使用教程链接', kind: 'urlOrEmpty', default: 'https://github.com/cfanzx/dsh-compact-relay/blob/main/TUTORIAL.md', view: true }),
86
+ field(['links', 'feedback'], { label: '反馈链接', kind: 'urlOrEmpty', default: 'https://github.com/cfanzx/dsh-compact-relay/issues', view: true }),
87
+ ]);
88
+
89
+ /** 路径 → 字段 的索引。 */
90
+ const FIELD_BY_PATH = new Map(CONFIG_FIELDS.map((item) => [item.path.join('.'), item]));
91
+
92
+ /**
93
+ * @param {string[]} pathParts
94
+ * @returns {object|undefined}
95
+ */
96
+ export function fieldAt(pathParts) {
97
+ return FIELD_BY_PATH.get(pathParts.join('.'));
98
+ }
99
+
100
+ /**
101
+ * 合并后的配置对象里取一个路径的值。
102
+ * @param {object} object
103
+ * @param {readonly string[]} pathParts
104
+ * @returns {unknown}
105
+ */
106
+ export function getAt(object, pathParts) {
107
+ let cursor = object;
108
+ for (const key of pathParts) {
109
+ if (cursor === null || typeof cursor !== 'object') return undefined;
110
+ cursor = cursor[key];
111
+ }
112
+ return cursor;
113
+ }
114
+
115
+ /**
116
+ * 往对象里写一个路径(就地写;调用方负责保证起点是新对象)。
117
+ * @param {object} object
118
+ * @param {readonly string[]} pathParts
119
+ * @param {unknown} value
120
+ */
121
+ export function setAt(object, pathParts, value) {
122
+ let cursor = object;
123
+ for (let index = 0; index < pathParts.length - 1; index += 1) {
124
+ const key = pathParts[index];
125
+ if (cursor[key] === null || typeof cursor[key] !== 'object') cursor[key] = {};
126
+ cursor = cursor[key];
127
+ }
128
+ cursor[pathParts[pathParts.length - 1]] = value;
129
+ }
130
+
131
+ /**
132
+ * 递归冻结普通对象/数组(默认配置只读)。
133
+ * @template T
134
+ * @param {T} value
135
+ * @returns {T}
136
+ */
137
+ function deepFreeze(value) {
138
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
139
+ for (const child of Object.values(value)) deepFreeze(child);
140
+ Object.freeze(value);
141
+ }
142
+ return value;
143
+ }
144
+
145
+ /**
146
+ * 按 schema 生成一份默认配置。
147
+ * @returns {object}
148
+ */
149
+ export function buildDefaultConfig() {
150
+ const out = {};
151
+ for (const item of CONFIG_FIELDS) {
152
+ const value = typeof item.default === 'function' ? item.default() : item.default;
153
+ setAt(out, item.path, value);
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /** 默认配置(由 schema 派生;模块加载时冻结一次)。 */
159
+ export const DEFAULT_CONFIG = deepFreeze(buildDefaultConfig());
160
+
161
+ /** 配置错误:带字段路径与中文文案,设置页/日志可定位到具体项。 */
162
+ export class ConfigError extends Error {
163
+ /**
164
+ * @param {object} item schema 字段
165
+ * @param {string} message 中文说明(不含字段名)
166
+ * @param {unknown} [value] 实际值(只用于文案,不写进设置文件)
167
+ */
168
+ constructor(item, message, value) {
169
+ const suffix = value === undefined ? '' : `(当前值:${formatValue(value)})`;
170
+ super(`「${item.label}」${message}${suffix}`);
171
+ this.name = 'ConfigError';
172
+ this.field = item.path.join('.');
173
+ this.fieldLabel = item.label;
174
+ this.value = value;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * 抛一个带字段路径的配置错误。
180
+ * @param {string[]} pathParts
181
+ * @param {string} message
182
+ * @param {unknown} [value]
183
+ * @returns {never}
184
+ */
185
+ export function failConfig(pathParts, message, value) {
186
+ const item = fieldAt(pathParts);
187
+ if (item === undefined) throw new Error(`compact-relay: 未知配置字段 ${pathParts.join('.')}`);
188
+ throw new ConfigError(item, message, value);
189
+ }
190
+
191
+ function formatValue(value) {
192
+ if (typeof value === 'string') return JSON.stringify(value);
193
+ if (value === null) return 'null';
194
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
195
+ try {
196
+ return JSON.stringify(value);
197
+ } catch {
198
+ return String(value);
199
+ }
200
+ }
201
+
202
+ const INTEGER_HINT = '必须是正整数';
203
+
204
+ /**
205
+ * 校验一个字段值;合法返回 null,不合法返回中文原因(不含字段名)。
206
+ * @param {object} item
207
+ * @param {unknown} value
208
+ * @returns {string|null}
209
+ */
210
+ export function validateFieldValue(item, value) {
211
+ switch (item.kind) {
212
+ case 'ratio':
213
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value >= 1) {
214
+ return '必须是 0 到 1 之间的小数(例如 0.4 表示 40%)';
215
+ }
216
+ return null;
217
+ case 'positiveInt': {
218
+ if (!Number.isInteger(value) || value <= 0) return INTEGER_HINT;
219
+ if (typeof item.max === 'number' && value > item.max) return `不能超过 ${item.max}`;
220
+ return null;
221
+ }
222
+ case 'nonNegativeInt':
223
+ return Number.isInteger(value) && value >= 0 ? null : '必须是非负整数';
224
+ case 'boolean':
225
+ return typeof value === 'boolean' ? null : '必须是开/关(true/false)';
226
+ case 'enum':
227
+ return item.values.includes(value) ? null : `只能是 ${item.values.join(' / ')}`;
228
+ case 'color':
229
+ return typeof value === 'string' && value.trim().length > 0 ? null : '不能为空,必须是颜色值(如 #6F7680)';
230
+ case 'path':
231
+ return typeof value === 'string' && value.length > 0 ? null : '不能为空';
232
+ case 'urlOrEmpty':
233
+ if (typeof value !== 'string') return '必须是字符串';
234
+ if (value.length > 0 && !/^https?:\/\//i.test(value)) return '必须是 http(s) 链接,或留空';
235
+ return null;
236
+ default:
237
+ return `未知类型 ${item.kind}`;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * 校验完整配置里的所有非颜色字段(颜色由 config.js 的颜色归一逻辑校验,
243
+ * 以保留旧扁平颜色结构的兼容)。
244
+ * @param {object} cfg
245
+ */
246
+ export function validateConfig(cfg) {
247
+ for (const item of CONFIG_FIELDS) {
248
+ if (item.path[0] === 'colors') continue;
249
+ const value = getAt(cfg, item.path);
250
+ const problem = validateFieldValue(item, value);
251
+ if (problem !== null) throw new ConfigError(item, problem, value);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * 只保留设置栏允许写入的字段(派生自 schema.editable)。
257
+ * @param {object} patch
258
+ * @returns {object}
259
+ */
260
+ export function filterSettingsPatch(patch) {
261
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
262
+ throw new Error('设置必须是 JSON 对象');
263
+ }
264
+ const out = {};
265
+ for (const item of CONFIG_FIELDS) {
266
+ if (!item.editable) continue;
267
+ const value = getAt(patch, item.path);
268
+ if (value !== undefined) setAt(out, item.path, value);
269
+ }
270
+ return out;
271
+ }
272
+
273
+ /**
274
+ * 设置栏读取用的公开视图(派生自 schema.view)。
275
+ * @param {object} cfg
276
+ * @returns {object}
277
+ */
278
+ export function publicConfigOf(cfg) {
279
+ const out = {};
280
+ for (const item of CONFIG_FIELDS) {
281
+ if (!item.view) continue;
282
+ setAt(out, item.path, getAt(cfg, item.path));
283
+ }
284
+ return out;
285
+ }