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
@@ -0,0 +1,191 @@
1
+ /**
2
+ * 设置存储动作层(A9:从根目录 settings-store.js 拆出来)。
3
+ *
4
+ * 分层:cordis 插件配置(基础层)← 用户 JSON(覆盖层)→ resolveConfig 校验。
5
+ * 文件读写在 dsh/settings-file.js;字段白名单/公开视图在 core/config-schema.js。
6
+ * 这里只做「合并 + 校验 + 回落 + 热替换 + 错误上报」。
7
+ *
8
+ * A4(2026-10-20):
9
+ * - 用户文件「合法 JSON + 非法值」不再让插件整体不激活:回落基础配置,
10
+ * 把 `settingsError{field,message}` 交给设置页展示。
11
+ * - 文件写成 `{v:1,config:{...}}` 信封;读侧兼容旧扁平文件。
12
+ */
13
+ import { resolveConfig } from '../config.js';
14
+ import { paletteForBothSchemes } from '../core/colors.js';
15
+ import { readNativeCompaction } from '../dsh/native-compaction.js';
16
+ import { filterSettingsPatch, publicConfigOf } from '../core/config-schema.js';
17
+ import {
18
+ defaultSettingsPath,
19
+ readPackageVersion,
20
+ readSettingsFile,
21
+ removeSettingsFile,
22
+ writeSettingsFile,
23
+ } from '../dsh/settings-file.js';
24
+
25
+ export { defaultSettingsPath } from '../dsh/settings-file.js';
26
+
27
+ function isPlainObject(value) {
28
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
29
+ }
30
+
31
+ /** 深合并普通对象;数组/标量直接覆盖。 */
32
+ function deepMerge(base, patch) {
33
+ if (!isPlainObject(base) || !isPlainObject(patch)) return patch;
34
+ const out = { ...base };
35
+ for (const [key, value] of Object.entries(patch)) {
36
+ out[key] = isPlainObject(value) && isPlainObject(out[key]) ? deepMerge(out[key], value) : value;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /**
42
+ * 把异常统一成 `{field,message}`;field 只用于定位,不写进设置文件。
43
+ * @param {unknown} error
44
+ * @returns {{field:string|null,message:string}}
45
+ */
46
+ function toSettingsError(error) {
47
+ return {
48
+ field: error !== null && typeof error === 'object' && typeof error.field === 'string' ? error.field : null,
49
+ message: error instanceof Error ? error.message : String(error),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * @param {{config?:object, file?:string}} [options]
55
+ */
56
+ export function createSettingsStore({ config = {}, file = defaultSettingsPath() } = {}) {
57
+ const base = isPlainObject(config) ? config : {};
58
+ const loaded = readSettingsFile(file);
59
+ let user = loaded.user;
60
+ let settingsError = loaded.error;
61
+ let current;
62
+
63
+ if (settingsError === null) {
64
+ try {
65
+ current = resolveConfig(deepMerge(base, user));
66
+ } catch (error) {
67
+ // 用户覆盖层非法:忽略它,回落基础配置;错误留给设置页显示。
68
+ settingsError = toSettingsError(error);
69
+ user = {};
70
+ }
71
+ }
72
+ if (current === undefined) {
73
+ try {
74
+ current = resolveConfig(base);
75
+ } catch (error) {
76
+ if (settingsError === null) settingsError = toSettingsError(error);
77
+ // 连基础配置都非法时,保证插件仍能启动(用默认值),错误照常上报。
78
+ current = resolveConfig({});
79
+ }
80
+ }
81
+
82
+ // 设置页「恢复默认」显示的基准:cordis 基础配置(坏的话用默认值兜底)。
83
+ let baseResolved;
84
+ try {
85
+ baseResolved = resolveConfig(base);
86
+ } catch {
87
+ baseResolved = resolveConfig({});
88
+ }
89
+
90
+ const listeners = new Set();
91
+ const notify = () => {
92
+ for (const listener of listeners) {
93
+ try {
94
+ listener(current);
95
+ } catch {
96
+ /* 单个监听器失败不影响保存 */
97
+ }
98
+ }
99
+ };
100
+
101
+ const view = () => ({
102
+ config: publicConfigOf(current),
103
+ defaults: publicConfigOf(baseResolved),
104
+ // A8:浅/深两套派生色板(设置页预览可用;未保存编辑仍走客户端纯函数)。
105
+ palettes: paletteForBothSchemes(current.colors),
106
+ windowFallback: current.contextWindowFallback,
107
+ version: readPackageVersion(),
108
+ // 0.3.1:DSH 原生自动压缩配置(设置页开关 / 交接线上限校验用)。
109
+ native: (() => {
110
+ const nativeCfg = readNativeCompaction();
111
+ return { autoEnabled: nativeCfg.autoEnabled, thresholdRatio: nativeCfg.thresholdRatio, file: nativeCfg.file };
112
+ })(),
113
+ path: file,
114
+ settingsError: settingsError === null ? null : { ...settingsError },
115
+ });
116
+
117
+ return {
118
+ /** 当前生效的完整配置(readState / actions 每次调用时读)。 */
119
+ get current() {
120
+ return current;
121
+ },
122
+ /** 当前设置文件问题(用于 state/设置页提示);null=正常。 */
123
+ get error() {
124
+ return settingsError === null ? null : { ...settingsError };
125
+ },
126
+ filePath: file,
127
+ view,
128
+ /**
129
+ * 合并保存一组设置;先校验再写盘,最后热替换当前配置。
130
+ * @param {object} patch
131
+ * @returns {object} 最新 view
132
+ */
133
+ save(patch) {
134
+ const filtered = filterSettingsPatch(patch);
135
+ if (Object.keys(filtered).length === 0) throw new Error('没有可保存的设置项');
136
+ const nextUser = deepMerge(user, filtered);
137
+ // 先校验,失败不落盘
138
+ let next;
139
+ try {
140
+ next = resolveConfig(deepMerge(base, nextUser));
141
+ } catch (error) {
142
+ const wrapped = new Error(error instanceof Error ? error.message : String(error));
143
+ wrapped.field = error !== null && typeof error === 'object' ? error.field : undefined;
144
+ throw wrapped;
145
+ }
146
+ // 0.3.1:原生自动压缩开着时,交接线不能 >= 原生阈值(否则原生会先动手、我们的提醒失效)。
147
+ const nativeCfg = readNativeCompaction();
148
+ if (nativeCfg.autoEnabled) {
149
+ const limit = nativeCfg.thresholdRatio - 0.01;
150
+ if (next.thresholds.handoffRatio >= nativeCfg.thresholdRatio - 1e-9) {
151
+ const wrapped = new Error(
152
+ `「交接提醒线」必须低于 DSH 原生自动压缩线(${(nativeCfg.thresholdRatio * 100).toFixed(0)}%),否则原生压缩会先动手;` +
153
+ `最高只能设到 ${(limit * 100).toFixed(0)}%。想设更高请先在设置里关闭「DSH 原生自动压缩」。`,
154
+ );
155
+ wrapped.field = 'thresholds.handoffRatio';
156
+ throw wrapped;
157
+ }
158
+ }
159
+ // A4:信封装盘;旧扁平文件在这时自动升级,不丢用户设置。
160
+ writeSettingsFile(file, nextUser);
161
+ user = nextUser;
162
+ current = next;
163
+ settingsError = null;
164
+ notify();
165
+ return view();
166
+ },
167
+ /** 删除用户覆盖,回到默认(含 cordis 基础配置)。 */
168
+ reset() {
169
+ removeSettingsFile(file);
170
+ user = {};
171
+ settingsError = null;
172
+ try {
173
+ current = resolveConfig(base);
174
+ } catch {
175
+ current = resolveConfig({});
176
+ }
177
+ notify();
178
+ return view();
179
+ },
180
+ /** 配置热替换回调;返回取消函数。 */
181
+ onChange(listener) {
182
+ listeners.add(listener);
183
+ return () => listeners.delete(listener);
184
+ },
185
+ close() {
186
+ listeners.clear();
187
+ },
188
+ };
189
+ }
190
+
191
+ export default createSettingsStore;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 标记进度(M4,内部代号「阶段标记」)—— 把「这个阶段完成了」写进会话日志。
3
+ *
4
+ * 只写一条 plugin record:不进模型上下文、不影响 token,压缩/交接都不会碰它。
5
+ * 指示灯读到最新一条后外环变阶段色(默认蓝);下一次压缩/交接(产生新 checkpoint)之后自动失效。
6
+ */
7
+ import { actionError } from '../core/errors.js';
8
+ import { STAGE_RECORD_TYPE, normalizeStageLabel } from '../core/stage.js';
9
+ import { measurePressure } from '../dsh/pressure.js';
10
+
11
+ /**
12
+ * @param {{engine:object, ctx?:object, cfg?:object}} deps
13
+ * @param {{agent:object, label?:string}} input
14
+ * @returns {Promise<{label:string, at:number, seq:number, ratio:number|null}>}
15
+ */
16
+ export async function markStage(deps, { agent, label } = {}) {
17
+ const session = agent?.session;
18
+ if (session === null || session === undefined) throw actionError('stage', '没有活动会话');
19
+ const normalized = normalizeStageLabel(label);
20
+ const at = Date.now();
21
+ // 0.3.1:记下标记那一刻的容量比例,指示灯才能在环上标出「标记位置」。
22
+ // 测不到(会话刚开 / tokenMeter 不可用)就记 null,环上退化到当前比例。
23
+ const measured = await measurePressure(deps.ctx, agent, {
24
+ windowFallback: deps.cfg?.contextWindowFallback ?? 1_000_000,
25
+ }).catch(() => null);
26
+ const ratio =
27
+ measured !== null && typeof measured === 'object' && typeof measured.ratio === 'number' && Number.isFinite(measured.ratio)
28
+ ? Math.max(0, Math.min(1, measured.ratio))
29
+ : null;
30
+ const seq = deps.engine.appendPluginRecord(session, STAGE_RECORD_TYPE, {
31
+ label: normalized,
32
+ at,
33
+ ...(ratio === null ? {} : { ratio }),
34
+ });
35
+ return { label: normalized, at, seq, ratio };
36
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * 状态快照 —— 唯一的「当前上下文怎么样」。
3
+ *
4
+ * 指示灯、菜单、命令前置检查都读这一份;交接单的现场信息由 actions/handoff
5
+ * 直接调 dsh/pressure + dsh/session-view(不走这里)。客户端不再自己算阈值。
6
+ */
7
+ import { paletteForBothSchemes } from '../core/colors.js';
8
+ import { adviceFor, ratioOf, thresholdsForWindow } from '../core/policy.js';
9
+ import { measurePressure } from '../dsh/pressure.js';
10
+ import { selectCompactableRange } from '../dsh/select-range.js';
11
+ import { compressHint as compressHintOf, countSummaries, lastCheckpointSurfaceSeq, livePinnedSeqs, liveSessionOf, pickAgent, surfaceSeqs } from '../dsh/session-view.js';
12
+ import { compressHintIsActive } from '../core/compress-hint.js';
13
+ import { readNativeCompaction } from '../dsh/native-compaction.js';
14
+ import { relayRecordOf } from '../dsh/relay.js';
15
+
16
+ /**
17
+ * 动作层统一解析「这次动作作用在哪个 agent」:只认显式 sessionId,
18
+ * 找不到就是 null(不允许退化成「最后一个 agent」压错会话)。
19
+ * A9:HTTP 适配层不再直接 import dsh/session-view.pickAgent。
20
+ * @param {{ctx:object}} deps
21
+ * @param {{sessionId?:string}} [input]
22
+ * @returns {object|null}
23
+ */
24
+ export function resolveTarget(deps, { sessionId } = {}) {
25
+ return pickAgent(deps.ctx, sessionId);
26
+ }
27
+
28
+ /**
29
+ * 状态帧的单调版本号(P3-12)。
30
+ *
31
+ * 轮询响应与 SSE 帧走同一个 readState,网络乱序时旧响应可能晚到并覆盖新状态。
32
+ * 这里给每帧一个进程内单调递增的 revision:浏览器端只接受更大的那一帧。
33
+ * 只加字段(不破坏既有 HTTP 契约),旧客户端忽略它即可。
34
+ */
35
+ let stateRevision = 0;
36
+
37
+ /**
38
+ * 把 live session 包成 readState 认识的「类 agent」(只需要 id + session)。
39
+ * @param {object|undefined} session
40
+ * @returns {object|null}
41
+ */
42
+ function passivized(session) {
43
+ return session === undefined || session === null ? null : { id: session.id, session };
44
+ }
45
+
46
+ /**
47
+ * @param {{ctx:object, cfg:object, lock:object}} deps
48
+ * @param {{sessionId?:string, agent?:object}} [target]
49
+ */
50
+ export async function readState(deps, { sessionId, agent } = {}) {
51
+ // 指定 agent 就用它;否则按 sessionId 找 agent,找不到但会话已加载 → 退化用 live session。
52
+ // 后者修「刚重启、会话还没起 agent 时,指示灯数字为空/串会话」的坑。
53
+ const picked = agent ?? pickAgent(deps.ctx, sessionId);
54
+ const target = picked ?? passivized(liveSessionOf(deps.ctx, sessionId));
55
+
56
+ const measured =
57
+ target === null
58
+ ? { tokens: null, surfaceTokens: null, window: deps.cfg.contextWindowFallback, ratio: 0, nodes: [] }
59
+ : await measurePressure(deps.ctx, target, { windowFallback: deps.cfg.contextWindowFallback });
60
+
61
+ const summaries = target === null ? 0 : countSummaries(target.session);
62
+ // 阈值按「窗口比例」配置,这里换算成当前模型窗口的绝对 token 线;
63
+ // 窗口从 resolveModelInfo 来(拿不到时 measurePressure 已回落到 contextWindowFallback)。
64
+ const thresholdLines = thresholdsForWindow(deps.cfg.thresholds, measured.window);
65
+ const advice = adviceFor({ pressure: measured.tokens, summaries, thresholds: thresholdLines });
66
+
67
+ // 当前能不能精压(菜单里显示「可压 N 条 / 没有可压范围」,点击前就有明确反馈)。
68
+ // 只对活着的 agent 算;passivized 的会话没有 agent,点了也压不了,直接 null。
69
+ let compactRange = null;
70
+ if (picked !== undefined && picked !== null) {
71
+ const live = picked.session ?? null;
72
+ if (live !== null) {
73
+ compactRange = selectCompactableRange({
74
+ session: live,
75
+ nodes: measured.nodes.filter((node) => Number.isInteger(node?.seq)),
76
+ engine: deps.engine,
77
+ cfg: deps.cfg,
78
+ tokenOf: (node) => node.heuristicTokens ?? node.tokens ?? 0,
79
+ });
80
+ compactRange =
81
+ compactRange === null
82
+ ? null
83
+ : { nodes: compactRange.shadowedSeqs.length, tokens: compactRange.tokenCount, startSeq: compactRange.startSeq, endSeq: compactRange.endSeq };
84
+ }
85
+ }
86
+
87
+ // M6:钉住集合(菜单显示「清除钉住(N 条)」,也能在无 agent 时读 live session)。
88
+ const pinnedSeqs = target === null ? [] : livePinnedSeqs(target.session);
89
+ // 0.3.1:锁定消息在 surface 里的相对位置(0–1,方案 A:消息在历史中的位置),
90
+ // 指示灯环上按位置画锁定块;多个锁定按位置从早到晚排序。
91
+ const surface = target === null ? [] : surfaceSeqs(target.session);
92
+ const surfaceIndex = new Map(surface.map((seq, index) => [seq, index]));
93
+ const pinMarks = pinnedSeqs
94
+ .filter((seq) => surfaceIndex.has(seq))
95
+ .map((seq) => {
96
+ const index = surfaceIndex.get(seq);
97
+ const position = surface.length <= 1 ? 0 : index / (surface.length - 1);
98
+ return { seq, ratio: Math.max(0, Math.min(1, position)) };
99
+ })
100
+ .sort((a, b) => a.ratio - b.ratio);
101
+
102
+ // 0.3.1:模型的「这里适合压缩」判断(比最后一次压缩/交接 checkpoint 新才算数)。
103
+ const checkpointSeq = target === null ? undefined : lastCheckpointSurfaceSeq(target.session);
104
+ const hint = target === null ? null : compressHintOf(target.session);
105
+ const activeHint = compressHintIsActive(hint, checkpointSeq) ? { ratio: hint.ratio, at: hint.at, seq: hint.seq } : null;
106
+ // 0.3.1:DSH 原生自动压缩配置(读 profile 的 cordis.patch.yml + DSH 默认值)。
107
+ const nativeCfg = readNativeCompaction();
108
+
109
+ return {
110
+ revision: ++stateRevision,
111
+ sessionId: target?.id ?? null,
112
+ pressure: measured.tokens,
113
+ surfaceTokens: measured.surfaceTokens,
114
+ window: measured.window,
115
+ ratio: measured.ratio ?? ratioOf(measured.tokens, measured.window),
116
+ summaries,
117
+ advice,
118
+ level: advice === 'none' ? 'idle' : advice,
119
+ pins: { count: pinnedSeqs.length, seqs: pinnedSeqs.slice(-20), marks: pinMarks },
120
+ compactRange,
121
+ busy: deps.lock.isBusy(target?.id),
122
+ thresholds: thresholdLines,
123
+ // A8:两套派生色板由 host 算好下发,浏览器半只按明暗挑一套(见 core/colors.js)。
124
+ colors: { ...deps.cfg.colors, palette: paletteForBothSchemes(deps.cfg.colors) },
125
+ // A4:设置文件有误时随状态下发,设置页/客户端可提示(正常为 null)。
126
+ settingsError: deps.settings?.error ?? null,
127
+ // A5:最近一次交接的接力链记录(没有就是 null)。
128
+ relay: target === null ? null : relayRecordOf(target.session),
129
+ // 0.3.1:模型判断的「适合压缩」断点位置(没有或已失效为 null)。
130
+ compressHint: activeHint,
131
+ // 0.3.1:DSH 原生自动压缩(环上的报警最高阈值 / 设置页开关的数据源)。
132
+ native: { autoEnabled: nativeCfg.autoEnabled, thresholdRatio: nativeCfg.thresholdRatio },
133
+ };
134
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * M1 实验动作 —— `/bctest`。
3
+ *
4
+ * 默认 dry-run 只给预览;加 confirm 才真的执行替换。
5
+ * 走的是和正式压缩旧会话同一条 dsh/compact-transaction,不另写一遍 append。
6
+ */
7
+ import { actionError } from '../core/errors.js';
8
+ import { TEST_INSTRUCTION } from '../core/prompts.js';
9
+ import { selectTestRange } from '../core/ranges.js';
10
+ import { replaceRangeWithSummary } from '../dsh/compact-transaction.js';
11
+ import { runIdle } from '../dsh/idle.js';
12
+ import { measurePressure } from '../dsh/pressure.js';
13
+ import { firstCompactableIdx } from '../dsh/session-view.js';
14
+ import { summarize } from '../dsh/summarizer.js';
15
+ import { withLock } from './lock.js';
16
+
17
+ function tokenCountOf(nodes, shadowedSeqs) {
18
+ const wanted = new Set(shadowedSeqs);
19
+ return nodes
20
+ .filter((node) => wanted.has(node.seq))
21
+ .reduce((sum, node) => sum + (node.heuristicTokens ?? node.tokens ?? 0), 0);
22
+ }
23
+
24
+ /**
25
+ * @param {{ctx:object, cfg:object, lock:object, engine:object}} deps
26
+ * @param {{agent:object, signal?:AbortSignal, confirm?:boolean, commandId?:string}} input
27
+ */
28
+ export async function testCompact(deps, { agent, signal, confirm = false, commandId }) {
29
+ if (agent === undefined || agent === null) throw actionError('nosession', '没有活动会话');
30
+ const session = agent.session;
31
+
32
+ return withLock(deps.lock, agent.id, async () => {
33
+ const measured = await measurePressure(deps.ctx, agent, { windowFallback: deps.cfg.contextWindowFallback });
34
+ const nodes = measured.nodes.filter((node) => Number.isInteger(node?.seq));
35
+
36
+ const range = selectTestRange({
37
+ nodes,
38
+ firstIdx: firstCompactableIdx(session),
39
+ nodeCount: deps.cfg.dev.testRangeNodes,
40
+ isBalancedBefore: (seq) => deps.engine.toolPairingBalancedBefore(session, seq),
41
+ isBalancedAfter: (seq) => deps.engine.toolPairingBalancedAfter(session, seq),
42
+ });
43
+ if (range === null) {
44
+ throw actionError('range', '找不到配对平衡的小范围(会话太短,或工具有未配对调用)');
45
+ }
46
+
47
+ const shadowedTokenCount = tokenCountOf(nodes, range.shadowedSeqs);
48
+ const freshRange = { ...range, shadowedTokenCount };
49
+
50
+ if (!confirm) {
51
+ return {
52
+ dryRun: true,
53
+ range: freshRange,
54
+ shadowedTokenCount,
55
+ message:
56
+ `预览:将把 seq ${range.startSeq}–${range.endSeq} 共 ${range.shadowedSeqs.length} 条历史替换成一条摘要` +
57
+ `(约 ${shadowedTokenCount} token)。确认执行:/bctest confirm`,
58
+ };
59
+ }
60
+
61
+ const flush =
62
+ typeof deps.ctx.sessions?.flush === 'function' ? () => deps.ctx.sessions.flush(session) : undefined;
63
+ const estimateMessage =
64
+ typeof deps.ctx?.tokenMeter?.estimateMessage === 'function'
65
+ ? (message) => deps.ctx.tokenMeter.estimateMessage(message)
66
+ : undefined;
67
+
68
+ const outcome = await runIdle(agent, (idleSignal) => {
69
+ const combined = signal === undefined ? idleSignal : AbortSignal.any([signal, idleSignal]);
70
+ return replaceRangeWithSummary({
71
+ session,
72
+ range: freshRange,
73
+ engine: deps.engine,
74
+ signal: combined,
75
+ sourceCommandId: commandId,
76
+ estimateMessage,
77
+ summarize: ({ shadowedSeqs, signal: innerSignal }) =>
78
+ summarize({
79
+ ctx: deps.ctx,
80
+ engine: deps.engine,
81
+ agent,
82
+ shadowedSeqs,
83
+ endSeq: freshRange.endSeq,
84
+ instruction: TEST_INSTRUCTION,
85
+ requiredSections: [], // /bctest 是短段落摘要,不做六段校验
86
+ maxTokens: deps.cfg.summary.maxTokens,
87
+ signal: innerSignal,
88
+ }),
89
+ flush,
90
+ });
91
+ });
92
+
93
+ return {
94
+ ok: true,
95
+ dryRun: false,
96
+ range: freshRange,
97
+ ...outcome,
98
+ message:
99
+ `替换成功:${outcome.shadowedSeqs.length} 条历史 → 一条摘要` +
100
+ `(compaction seq ${outcome.startSeq}–${outcome.endSeq},约 ${outcome.shadowedTokenCount} token)。`,
101
+ };
102
+ });
103
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * 斜杠命令适配层 —— 只做「参数校验 → 调 action → 结果映射」。
3
+ *
4
+ * /bc、/handoff、/pin、/unpin、/bctest 五个入口都不含业务逻辑;
5
+ * 换实现只动 actions 层。
6
+ */
7
+ import { betterCompact, previewBetterCompact, takePreviewPlan } from '../actions/better-compact.js';
8
+ import { handoff } from '../actions/handoff.js';
9
+ import { listPins, pinByIndex, pinLastTurn, unpinAll } from '../actions/pin.js';
10
+ import { testCompact } from '../actions/test-compact.js';
11
+ import { errorText } from '../core/errors.js';
12
+
13
+ function success(outcome) {
14
+ return {
15
+ kind: 'success',
16
+ text: typeof outcome?.message === 'string' && outcome.message.length > 0 ? outcome.message : '完成。',
17
+ ...(Number.isFinite(outcome?.summarySeq) ? { sourceEventSeq: outcome.summarySeq } : {}),
18
+ // A5:交接命令把结构化 childId 一并交给命令卡(文本里的 #child= 只是兜底)。
19
+ ...(typeof outcome?.childId === 'string' && outcome.childId.length > 0 ? { childId: outcome.childId } : {}),
20
+ };
21
+ }
22
+
23
+ function failure(error, signal) {
24
+ return { kind: 'error', text: signal?.aborted ? '操作已取消。' : errorText(error) };
25
+ }
26
+
27
+ /**
28
+ * @param {object} ctx
29
+ * @param {{ctx:object, cfg:object, lock:object, engine:object}} deps
30
+ */
31
+ export function registerCommands(ctx, deps) {
32
+ const active = new Set();
33
+
34
+ const track = (promise) => {
35
+ active.add(promise);
36
+ const retire = () => active.delete(promise);
37
+ promise.then(retire, retire);
38
+ return promise;
39
+ };
40
+
41
+ const register = (definition) => {
42
+ ctx.effect(function* () {
43
+ yield async () => {
44
+ await Promise.allSettled(active);
45
+ };
46
+ yield ctx.commands.register(definition);
47
+ }, `compact-relay: /${definition.name} command`);
48
+ };
49
+
50
+ register({
51
+ name: 'bc',
52
+ description: '压缩旧会话:把较早会话压成摘要腾出容量,先看压缩预估再确认(/bc confirm)',
53
+ // 与 /bctest 同理:声明 input 才能让「/bc confirm」不被当成普通消息。
54
+ input: { hint: 'confirm' },
55
+ handler: (invocation) =>
56
+ track(
57
+ (async () => {
58
+ const raw = invocation.rawInput.trim();
59
+ if (raw !== '' && raw !== 'confirm') {
60
+ return { kind: 'error', text: '用法:/bc(预览)或 /bc confirm(真正执行)' };
61
+ }
62
+ try {
63
+ if (raw === 'confirm') {
64
+ // A3:确认必须绑定刚才预览的那一段;缓存过期就要求重新预览,不拿新范围冒充旧同意。
65
+ const expected = takePreviewPlan(invocation.agent?.id);
66
+ if (expected === null) {
67
+ return { kind: 'error', text: '预览已过期或还没预览,请先输入 /bc 重新预览,再 /bc confirm。' };
68
+ }
69
+ const outcome = await betterCompact(deps, {
70
+ agent: invocation.agent,
71
+ signal: invocation.signal,
72
+ commandId: invocation.commandId,
73
+ expected,
74
+ });
75
+ return success(outcome);
76
+ }
77
+ const outcome = await previewBetterCompact(deps, { agent: invocation.agent });
78
+ return success(outcome);
79
+ } catch (error) {
80
+ return failure(error, invocation.signal);
81
+ }
82
+ })(),
83
+ ),
84
+ });
85
+
86
+ register({
87
+ name: 'handoff',
88
+ description: '交接新会话:生成交接单并开新会话接着聊,旧会话保留(交接前可能压缩)(/handoff)',
89
+ handler: (invocation) =>
90
+ track(
91
+ (async () => {
92
+ if (invocation.rawInput.trim().length > 0) return { kind: 'error', text: '用法:/handoff(不带参数)' };
93
+ try {
94
+ const outcome = await handoff(deps, {
95
+ agent: invocation.agent,
96
+ signal: invocation.signal,
97
+ commandId: invocation.commandId,
98
+ });
99
+ return success(outcome);
100
+ } catch (error) {
101
+ return failure(error, invocation.signal);
102
+ }
103
+ })(),
104
+ ),
105
+ });
106
+
107
+ register({
108
+ name: 'pin',
109
+ description: '锁定重要消息:/pin 锁定最近一轮|/pin list 按序号挑|/pin clear 全部解锁',
110
+ input: { hint: 'list | 序号 | clear' },
111
+ handler: (invocation) =>
112
+ track(
113
+ (async () => {
114
+ try {
115
+ const raw = invocation.rawInput.trim();
116
+ const lower = raw.toLowerCase();
117
+ const agent = invocation.agent;
118
+ let outcome;
119
+ if (raw === '') outcome = pinLastTurn(deps, { agent });
120
+ else if (lower === 'list') outcome = listPins(deps, { agent });
121
+ else if (lower === 'clear' || lower === 'unpin') outcome = unpinAll(deps, { agent });
122
+ else if (/^\d+$/.test(raw)) outcome = pinByIndex(deps, { agent, index: Number(raw) });
123
+ else outcome = null;
124
+ if (outcome === null) {
125
+ return { kind: 'error', text: '用法:/pin(锁定最近一轮)|/pin list(看最近 10 条)|/pin <序号>|/pin clear(全部解锁)' };
126
+ }
127
+ return success(outcome);
128
+ } catch (error) {
129
+ return failure(error, invocation.signal);
130
+ }
131
+ })(),
132
+ ),
133
+ });
134
+
135
+ register({
136
+ name: 'unpin',
137
+ description: '解锁消息:解除锁定,允许后续压缩合并这些消息',
138
+ handler: (invocation) =>
139
+ track(
140
+ (async () => {
141
+ try {
142
+ return success(unpinAll(deps, { agent: invocation.agent }));
143
+ } catch (error) {
144
+ return failure(error, invocation.signal);
145
+ }
146
+ })(),
147
+ ),
148
+ });
149
+
150
+ register({
151
+ name: 'bctest',
152
+ description: 'M1 实验:把最近几条历史换成一条摘要(默认只预览,confirm 才执行)',
153
+ // 必须声明 input:DSH 客户端只让「声明了 input」的命令接受带参数的行,
154
+ // 否则 `/bctest confirm` 会被当成普通消息发出去(matchEnter 里 bare=false → void 0)。
155
+ input: { hint: 'confirm' },
156
+ handler: (invocation) =>
157
+ track(
158
+ (async () => {
159
+ const raw = invocation.rawInput.trim();
160
+ if (raw !== '' && raw !== 'confirm') {
161
+ return { kind: 'error', text: '用法:/bctest(预览)或 /bctest confirm(真正执行)' };
162
+ }
163
+ try {
164
+ const outcome = await testCompact(deps, {
165
+ agent: invocation.agent,
166
+ signal: invocation.signal,
167
+ confirm: raw === 'confirm',
168
+ commandId: invocation.commandId,
169
+ });
170
+ return success(outcome);
171
+ } catch (error) {
172
+ return failure(error, invocation.signal);
173
+ }
174
+ })(),
175
+ ),
176
+ });
177
+ }