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/TUTORIAL.md ADDED
@@ -0,0 +1,65 @@
1
+ # 压缩接力 · 3 分钟教程
2
+
3
+ 给 DeepSeek Harness 输入框加一个**上下文指示灯**:告诉你现在还剩多少容量、什么时候该压缩旧会话、什么时候该交接新会话,并帮你把重要消息锁住。
4
+
5
+ > 这个文件预留给 GitHub 教程页;建好仓库后,设置页「帮助」区的「使用教程」会直接链到这里。
6
+
7
+ ## 1. 先看环怎么读
8
+
9
+ 指示灯在输入框底部工具行(「完全权限」右侧),外环是容量进度,内圆是交接信号:
10
+
11
+ | 区域/颜色 | 意思 | 你该做什么 |
12
+ |---|---|---|
13
+ | 灰(默认色) | 正常区还没走满(没到第一个断点) | 继续聊 |
14
+ | 蓝(填满色) | 正常区走满了(到了断点1) | 这里是「该压缩」的位置 |
15
+ | 琥珀(调整色) | 压缩区(断点1 → 交接线) | 点指示灯 →「压缩旧会话」,或输入 `/bc` |
16
+ | 红(上限色 / 内圆红点) | 交接区(交接线之后),或摘要太多 | 点指示灯 →「交接新会话」,或输入 `/handoff` |
17
+ | 警示红(原生线) | 80% 处的 DSH 原生自动压缩线(紫色径向刻度);越过 80% 后整环红色闪烁 | 再往前 DSH 会自己压历史,先压缩或交接 |
18
+ | 品红(锁定色) | 这里锁了一条重要消息(按它在对话里的位置显示,可多个) | 知道「我锁的东西在哪」 |
19
+
20
+ **断点**(环上的小缺口)=「我判断这里适合压缩」的位置:模型判断(上下文超过最小压缩线、还没到压缩线、正到自然节点)或到压缩线时自动出现;压缩后环变短,断点自然消失。
21
+
22
+ 把鼠标放到指示灯上,会显示具体数字:`会话容量已用 xx.x%(xxxK / 1.00M),已压缩 n 次|建议压缩 / 建议交接 / 正常|压缩判断点 x%|原生自动压缩线 80%|已锁定 n 条`。
23
+
24
+ **第一次用**会看到一条提示:点指示灯能压缩旧会话、**锁定重要消息**、交接新会话;会顺带告诉你 DSH 原生自动压缩线是多少,以及环上的小缺口什么意思。重要的结论/路径先锁定,压缩时才不会被压掉。
25
+
26
+ 这条引导只自动出现一次。想再看:**设置 → 压缩接力 → 帮助 →「再看一次初次引导」**,点完关掉设置页就会重新播放(手动重播时长更长:光晕 9 秒 / 提示 20 秒)。
27
+
28
+ ## 2. 记住三条命令
29
+
30
+ | 命令 | 作用 |
31
+ |---|---|
32
+ | `/bc` | 压缩旧会话:**先看压缩预估**(压哪段、多少条、多少容量),再 `/bc confirm` 才真正执行 |
33
+ | `/handoff` | 交接新会话:生成九段交接单(先出预览卡)→ 建新会话 → 自动切过去,旧会话保留 |
34
+ | `/pin` | 锁定重要消息,压缩时**不碰**它们;`/pin clear` 或 `/unpin` 解锁 |
35
+
36
+ 菜单里也有同名入口,效果一样。
37
+
38
+ ## 3. 设置在哪
39
+
40
+ 打开 DSH「设置」→ 左侧「压缩接力」。页面标题下有一句话说明这页做什么、边界在哪(只动本插件的压缩/交接历史,不改模型回复);6 个区块:压缩提示 → 压缩行为 → DSH 原生自动压缩 → 颜色与外观 → 帮助 → 关于与危险操作。
41
+
42
+ - **指示灯怎么看**:设置页「帮助」区顶部的教学卡片,5 个示意环(未满灰 / 走满蓝 / 压缩区琥珀 / 交接区红 / 原生线),随你的配色和阈值实时变
43
+ - **再看一次初次引导**:「帮助」区的按钮,点了关掉设置页就会重播引导(手动重播时长更长)
44
+ - **最小压缩线**:低于它压缩不划算(默认 5%,会随保留量和窗口自动抬高)
45
+ - **压缩提醒线 / 交接提醒线**:按上下文窗口的百分比算(1M 窗口默认 40% / 60%),换小窗口模型自动缩放
46
+ - **摘要份数上限**:压到几份摘要后建议交接(默认 6)
47
+ - **保留近期原文**:压缩时这段 token 内的近期消息保持原文(默认 40K)
48
+ - **DSH 原生自动压缩**:状态 + 一键关闭/恢复(会先确认,改 profile 配置前备份,重启生效);原生开着时交接线最高只能设到「原生线 −1%」
49
+ - **交接单目录**:九段交接单保存位置
50
+ - **颜色**:选 5 个基准色(默认/填满/调整/上限/锁定),浅色/深色两套自动派生;每行都有「作用」和「调整后」两行说明
51
+
52
+ 改完点「保存」,**立即生效,不需要重启**。
53
+
54
+ ## 4. 常见问题
55
+
56
+ - **指示灯显示「上下文未知」**:会话刚打开,等半秒到 1.5 秒会自己出数。
57
+ - **没有「可压」内容**:会话太短,或近期内容需要保留原文,或锁定把范围挡住了(先 `/unpin`)。
58
+ - **压缩到一半取消了**:历史不会变、锁会释放;已发出的模型请求可能仍计费。
59
+ - **换模型后阈值变了**:阈值按窗口比例算,小窗口模型会自动变小,这是预期。
60
+ - **设置保存了但没变**:设置栏保存即时生效;改代码或 profile 配置才需要重启 DSH。
61
+
62
+ ## 5. 更多
63
+
64
+ - 安装、完整说明见仓库 `README.md`;设计文档见 `ARCHITECTURE.md`。
65
+ - 遇到 bug / 想提改进:到仓库 Issues 提一条(设置页「帮助」区的「反馈」入口建好后会直接打开)。
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,6 @@
1
+ # Bundle patch for dsh-compact-relay: inserts its plugin row into the profile
2
+ # roster. Applied as a profile bundle layer (the `dsh.bundle.patch` manifest
3
+ # field); installed via the profile package.json dependencies + dsh.profile.bundles.
4
+ - insert:
5
+ - id: compact-relay
6
+ name: 'dsh-compact-relay'
@@ -0,0 +1,326 @@
1
+ /**
2
+ * 压缩旧会话(/bc,内部代号「精压」/BC)—— 唯一实现(M2 正式版 / M6 预览 + 确认 + 取消)。
3
+ *
4
+ * 不再包装原生 compactNow,走自建事务:
5
+ * 选段(跳过旧摘要 = 不二压)→ 摘要(结构化 prompt)→ 替换历史。
6
+ *
7
+ * 泄压阀:摘要份数到 thresholds.maxSummaries 后,选段不再受「最后 checkpoint 之后」
8
+ * 约束(core/policy.compactionPolicy → dsh/select-range 的 afterSeq=undefined),
9
+ * 旧摘要会被新摘要一并覆盖(合并),防止摘要无限堆积。
10
+ *
11
+ * M6 起分三步走:
12
+ * 1. previewBetterCompact —— 只算「能压多少」,不写任何东西(/bc 与菜单先走它);
13
+ * 2. betterCompact —— 真正执行(/bc confirm 或确认卡按钮);
14
+ * 3. cancelBetterCompact —— 执行中取消(HTTP cancel → deps.inflight 里的 AbortController)。
15
+ * 预览与执行共用 planCompact,保证两边口径一致。
16
+ */
17
+ import { createHash } from 'node:crypto';
18
+ import { actionError } from '../core/errors.js';
19
+ import { livePinnedSeqs } from '../dsh/session-view.js';
20
+ import { compactionPolicy } from '../core/policy.js';
21
+ import { SUMMARY_INSTRUCTION } from '../core/prompts.js';
22
+ import { selectCompactableRange } from '../dsh/select-range.js';
23
+ import { replaceRangeWithSummary } from '../dsh/compact-transaction.js';
24
+ import { runIdle } from '../dsh/idle.js';
25
+ import { measurePressure } from '../dsh/pressure.js';
26
+ import { countSummaries } from '../dsh/session-view.js';
27
+ import { summarize } from '../dsh/summarizer.js';
28
+ import { withLock } from './lock.js';
29
+
30
+ /** 范围小于这个 token 数时,摘要大概率比原文大:预览阶段就提示、前端据此禁用确认(0.3.1)。 */
31
+ const MIN_MEANINGFUL_TOKENS = 800;
32
+
33
+ /** 容量数字的紧凑写法(与浏览器半 `20-format.js` 的 fmt 保持同一口径)。 */
34
+ function fmtTokens(tokens) {
35
+ if (!Number.isFinite(tokens)) return '—';
36
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(2)}M`;
37
+ if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
38
+ return String(Math.round(tokens));
39
+ }
40
+
41
+ /**
42
+ * 预览计划缓存(架构 A3)。
43
+ *
44
+ * 「用户看到的范围」与「点确认后实际压的范围」必须是同一份数据:
45
+ * preview 记下 planId + 范围,confirm 带 planId 回来;宿主重算发现不一致就拒绝。
46
+ * 按会话存,5 分钟过期;`/bc confirm` 从这里取(HTTP 客户端则显式带 expected)。
47
+ */
48
+ const PREVIEW_TTL_MS = 5 * 60_000;
49
+ const previewPlans = new Map();
50
+
51
+ function planIdOf(range) {
52
+ return createHash('sha256')
53
+ .update(`${range.startSeq}:${range.endSeq}:${range.shadowedSeqs.join(',')}`)
54
+ .digest('hex')
55
+ .slice(0, 16);
56
+ }
57
+
58
+ function rememberPreviewPlan(sessionId, plan) {
59
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return;
60
+ previewPlans.set(sessionId, { ...plan, expiresAt: Date.now() + PREVIEW_TTL_MS });
61
+ }
62
+
63
+ /**
64
+ * 取出并消费一次预览计划;过期/不存在返回 null。
65
+ * @param {string|undefined} sessionId
66
+ * @returns {{planId:string,startSeq:number,endSeq:number,shadowedSeqs:number[]}|null}
67
+ */
68
+ export function takePreviewPlan(sessionId) {
69
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
70
+ const entry = previewPlans.get(sessionId);
71
+ if (entry === undefined) return null;
72
+ if (entry.expiresAt < Date.now()) {
73
+ previewPlans.delete(sessionId);
74
+ return null;
75
+ }
76
+ return {
77
+ planId: entry.planId,
78
+ startSeq: entry.startSeq,
79
+ endSeq: entry.endSeq,
80
+ shadowedSeqs: [...entry.shadowedSeqs],
81
+ };
82
+ }
83
+
84
+ /** 测试用:清空预览缓存。 */
85
+ export function clearPreviewPlans() {
86
+ previewPlans.clear();
87
+ }
88
+
89
+ function shadowedTokensOf(nodes, shadowedSeqs) {
90
+ const wanted = new Set(shadowedSeqs);
91
+ return nodes
92
+ .filter((node) => wanted.has(node.seq))
93
+ .reduce((sum, node) => sum + (node.heuristicTokens ?? node.tokens ?? 0), 0);
94
+ }
95
+
96
+ /**
97
+ * 算一遍「这次能压什么」—— 预览与正式执行共用,保证两边口径一致。
98
+ * @param {{ctx:object, cfg:object, engine:object}} deps
99
+ * @param {object} agent
100
+ */
101
+ async function planCompact(deps, agent) {
102
+ const measured = await measurePressure(deps.ctx, agent, { windowFallback: deps.cfg.contextWindowFallback });
103
+ const nodes = measured.nodes.filter((node) => Number.isInteger(node?.seq));
104
+
105
+ const summaries = countSummaries(agent.session);
106
+ const { allowMergeOldSummaries } = compactionPolicy({ summaries, thresholds: deps.cfg.thresholds });
107
+ // 只把「当前 surface 上还在」的钉住消息算进去:过期 seq 不该再劝用户 /unpin。
108
+ const pinned = livePinnedSeqs(agent.session);
109
+
110
+ const range = selectCompactableRange({
111
+ session: agent.session,
112
+ nodes,
113
+ engine: deps.engine,
114
+ cfg: deps.cfg,
115
+ tokenOf: (node) => node.heuristicTokens ?? node.tokens ?? 0,
116
+ });
117
+
118
+ return { measured, nodes, summaries, allowMergeOldSummaries, range, pinned, planId: range === null ? null : planIdOf(range) };
119
+ }
120
+
121
+ /**
122
+ * 没有可压范围时给用户的解释——被钉住挡住时要指出来,否则用户不知道去 /unpin。
123
+ * @param {number[]|undefined} pinned
124
+ */
125
+ function noRangeMessage(pinned) {
126
+ return pinned !== undefined && pinned.length > 0
127
+ ? `暂无可压缩内容:有 ${pinned.length} 条消息被「锁定」保护,请先解锁再试(/unpin)。`
128
+ : '暂无可压缩内容(当前会话太短,或近期会话需保留原文)。';
129
+ }
130
+
131
+ /**
132
+ * 压缩本体(不加锁、不登记)—— 交接要先压缩旧会话,复用这一份实现,不要各写一遍。
133
+ * @param {{ctx:object, cfg:object, lock:object, engine:object}} deps
134
+ * @param {{agent:object, signal?:AbortSignal, commandId?:string}} input
135
+ */
136
+ export async function betterCompactLocked(deps, { agent, signal, commandId, expected }) {
137
+ if (agent === undefined || agent === null) throw actionError('nosession', '没有活动会话');
138
+ const session = agent.session;
139
+
140
+ const plan = await planCompact(deps, agent);
141
+ const { nodes, allowMergeOldSummaries, summaries, range, pinned, planId } = plan;
142
+
143
+ // 架构 A3:确认必须绑定同一段范围。重算后发现不是用户看过的那一段 → 拒绝,不写任何东西。
144
+ if (range !== null && expected !== undefined && expected !== null) {
145
+ if (typeof expected !== 'object' || typeof expected.planId !== 'string' || expected.planId !== planId) {
146
+ throw actionError('changed', '会话在确认期间又更新了,压缩范围已变;请重新预览后再确认。');
147
+ }
148
+ }
149
+
150
+ if (range === null) {
151
+ return {
152
+ ok: true,
153
+ compacted: false,
154
+ planId: null,
155
+ message: noRangeMessage(pinned),
156
+ };
157
+ }
158
+
159
+ const freshRange = { ...range, shadowedTokenCount: shadowedTokensOf(nodes, range.shadowedSeqs) };
160
+ const flush =
161
+ typeof deps.ctx.sessions?.flush === 'function' ? () => deps.ctx.sessions.flush(session) : undefined;
162
+ const estimateMessage =
163
+ typeof deps.ctx?.tokenMeter?.estimateMessage === 'function'
164
+ ? (message) => deps.ctx.tokenMeter.estimateMessage(message)
165
+ : undefined;
166
+
167
+ const outcome = await runIdle(agent, (idleSignal) => {
168
+ const combined = signal === undefined ? idleSignal : AbortSignal.any([signal, idleSignal]);
169
+ return replaceRangeWithSummary({
170
+ session,
171
+ range: freshRange,
172
+ engine: deps.engine,
173
+ signal: combined,
174
+ sourceCommandId: commandId,
175
+ estimateMessage,
176
+ summarize: ({ shadowedSeqs, signal: innerSignal }) =>
177
+ summarize({
178
+ ctx: deps.ctx,
179
+ engine: deps.engine,
180
+ agent,
181
+ shadowedSeqs,
182
+ endSeq: freshRange.endSeq,
183
+ instruction: SUMMARY_INSTRUCTION,
184
+ maxTokens: deps.cfg.summary.maxTokens,
185
+ inputTokens: plan.measured.tokens,
186
+ timeoutMs: deps.cfg.timeouts?.llmMs,
187
+ signal: innerSignal,
188
+ }),
189
+ flush,
190
+ });
191
+ });
192
+
193
+ const note = allowMergeOldSummaries
194
+ ? ',同时合并了旧摘要'
195
+ : summaries > 0
196
+ ? ',旧摘要保持原样'
197
+ : '';
198
+ const pinNote = range.pinnedCut === true ? `,已跳过 ${range.pinnedKept} 条锁定的消息` : '';
199
+ return {
200
+ ok: true,
201
+ compacted: true,
202
+ // A3/A5:把被批准的范围 id 带出动作层(交接链记录/0.4 diff 可引用)。
203
+ planId,
204
+ ...outcome,
205
+ message: `压缩完成:${outcome.shadowedSeqs.length} 条旧会话 → 1 条摘要(腾出约 ${outcome.shadowedTokenCount} 容量${note}${pinNote})。`,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * 压缩预览:只算范围与 token,不写会话日志。
211
+ * @param {{ctx:object, cfg:object, lock:object, engine:object}} deps
212
+ * @param {{agent:object}} input
213
+ */
214
+ export async function previewBetterCompact(deps, { agent } = {}) {
215
+ if (agent === undefined || agent === null) throw actionError('nosession', '没有活动会话');
216
+ if (typeof deps.lock?.isBusy === 'function' && deps.lock.isBusy(agent.id)) {
217
+ throw actionError('busy', '当前会话正在压缩或交接,请等完成后再预览');
218
+ }
219
+
220
+ const { nodes, range, pinned, planId } = await planCompact(deps, agent);
221
+ if (range === null) {
222
+ return {
223
+ ok: true,
224
+ compacted: false,
225
+ preview: null,
226
+ message: noRangeMessage(pinned),
227
+ };
228
+ }
229
+
230
+ const shadowedTokenCount = shadowedTokensOf(nodes, range.shadowedSeqs);
231
+ rememberPreviewPlan(agent.id, {
232
+ planId,
233
+ startSeq: range.startSeq,
234
+ endSeq: range.endSeq,
235
+ shadowedSeqs: range.shadowedSeqs,
236
+ });
237
+ // 0.3.1:小范围压缩大概率「摘要比原文还大」被拒,预览阶段就提示并让前端禁用确认。
238
+ const tooSmall = shadowedTokenCount < MIN_MEANINGFUL_TOKENS;
239
+ return {
240
+ ok: true,
241
+ compacted: false,
242
+ planId,
243
+ preview: {
244
+ nodes: range.shadowedSeqs.length,
245
+ tokens: shadowedTokenCount,
246
+ startSeq: range.startSeq,
247
+ endSeq: range.endSeq,
248
+ planId,
249
+ tooSmall,
250
+ ...(range.pinnedCut === true ? { pinnedKept: range.pinnedKept } : {}),
251
+ },
252
+ message:
253
+ `压缩预估:将把 ${range.shadowedSeqs.length} 条旧会话压成 1 条摘要` +
254
+ `(腾出约 ${fmtTokens(shadowedTokenCount)} tokens${range.pinnedCut === true ? `,已跳过 ${range.pinnedKept} 条锁定的消息` : ''})。` +
255
+ (tooSmall
256
+ ? `这段偏小,压了可能反而更大;建议再聊几轮或把「保留近期原文」调小后再压缩。`
257
+ : '只估算范围和容量,摘要内容确认后才生成。') +
258
+ (tooSmall ? '' : '确认执行:/bc confirm'),
259
+ };
260
+ }
261
+
262
+ /**
263
+ * 请求取消正在跑的压缩。
264
+ * @param {{inflight?:object}} deps
265
+ * @param {{agent?:object}} [input]
266
+ */
267
+ export function cancelBetterCompact(deps, { agent } = {}) {
268
+ const kind = deps.inflight?.kindOf?.(agent?.id) ?? null;
269
+ const found =
270
+ deps.inflight !== undefined &&
271
+ typeof deps.inflight.cancel === 'function' &&
272
+ deps.inflight.cancel(agent?.id);
273
+ return found
274
+ ? {
275
+ ok: true,
276
+ cancelled: true,
277
+ kind,
278
+ message:
279
+ kind === 'handoff'
280
+ ? '已请求取消交接(已完成的压缩不会回滚)。'
281
+ : '已请求取消压缩(会话记录不会改动)。',
282
+ }
283
+ : { ok: false, cancelled: false, kind: null, message: '当前没有正在进行的压缩/交接任务。' };
284
+ }
285
+
286
+ function combinedSignal(a, b) {
287
+ if (a === undefined) return b;
288
+ if (b === undefined) return a;
289
+ return AbortSignal.any([a, b]);
290
+ }
291
+
292
+ /**
293
+ * 压缩动作入口:带每会话单飞锁 + 可取消登记。
294
+ * @param {{ctx:object, cfg:object, lock:object, engine:object, inflight?:object}} deps
295
+ * @param {{agent:object, signal?:AbortSignal, commandId?:string}} input
296
+ */
297
+ export async function betterCompact(deps, input) {
298
+ if (input?.agent === undefined || input?.agent === null) throw actionError('nosession', '没有活动会话');
299
+ const agent = input.agent;
300
+ const controller =
301
+ deps.inflight !== undefined && typeof deps.inflight.begin === 'function'
302
+ ? deps.inflight.begin(agent.id, 'compact')
303
+ : null;
304
+ const signal = combinedSignal(controller?.signal, input.signal);
305
+
306
+ try {
307
+ return await withLock(deps.lock, agent.id, () => betterCompactLocked(deps, { ...input, signal }));
308
+ } catch (error) {
309
+ // 取消 → 统一成 cancelled 文案(宿主 HTTP 层据此回「历史没改」)。
310
+ // P2-1:commit/persistence 表示历史可能已改或落盘状态不明,绝不能谎报「历史没改」。
311
+ if (
312
+ signal !== undefined &&
313
+ signal.aborted &&
314
+ error?.code !== 'cancelled' &&
315
+ error?.code !== 'commit' &&
316
+ error?.code !== 'persistence'
317
+ ) {
318
+ throw actionError('cancelled', '已取消,会话记录没有改动。', error);
319
+ }
320
+ throw error;
321
+ } finally {
322
+ if (controller !== null && typeof deps.inflight.finish === 'function') {
323
+ deps.inflight.finish(agent.id, controller);
324
+ }
325
+ }
326
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 压缩历史(只读动作)—— 给 HTTP / 面板用。
3
+ *
4
+ * A6:不做回滚,只展示「压了哪些条、摘要预览、省了多少容量、是否已提交」。
5
+ */
6
+ import { actionError } from '../core/errors.js';
7
+ import { collectCompactions } from '../dsh/compaction-log.js';
8
+ import { pickAgent } from '../dsh/session-view.js';
9
+
10
+ /**
11
+ * @param {object} deps
12
+ * @param {{sessionId?:string, limit?:number}} [input]
13
+ */
14
+ export function readCompactDiffs(deps, input = {}) {
15
+ const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : undefined;
16
+ const agent = pickAgent(deps?.ctx, sessionId);
17
+ if (agent === null) throw actionError('nosession', '当前没有活动会话。');
18
+ const session = agent.session;
19
+ if (session === undefined || session === null) throw actionError('nosession', '当前没有活动会话。');
20
+
21
+ const limit = input.limit;
22
+ const items = collectCompactions(session, { limit });
23
+ return {
24
+ ok: true,
25
+ sessionId: typeof session.id === 'string' ? session.id : (sessionId ?? null),
26
+ items,
27
+ count: items.length,
28
+ latest: items[0] ?? null,
29
+ };
30
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 「这里适合压缩」判断(0.3.1)。
3
+ *
4
+ * 模型工具 `mark_stage` 在这里落一条 plugin record;环上把它画成断点1。
5
+ * 用户手动入口已删除(作者 2026-10-20 拍板:判断由模型/阈值自动完成)。
6
+ */
7
+ import { COMPRESS_HINT_RECORD_TYPE } from '../core/compress-hint.js';
8
+ import { compressionMinRatio } from '../core/policy.js';
9
+ import { measurePressure } from '../dsh/pressure.js';
10
+
11
+ /**
12
+ * 记录一次「适合压缩」的判断。
13
+ * @param {object} deps
14
+ * @param {{agent: object}} input
15
+ * @returns {Promise<{ok:boolean, ratio:number, at:number, seq?:number, reason?:string, message:string}>}
16
+ */
17
+ export async function markCompressHint(deps, { agent } = {}) {
18
+ const session = agent?.session;
19
+ if (session === null || session === undefined) {
20
+ return { ok: false, ratio: 0, at: 0, reason: 'no-session', message: '没有活动会话,无法记录判断' };
21
+ }
22
+ const measured = await measurePressure(deps.ctx, agent, { windowFallback: deps.cfg.contextWindowFallback }).catch(() => null);
23
+ if (measured === null || typeof measured.ratio !== 'number') {
24
+ return { ok: false, ratio: 0, at: 0, reason: 'no-pressure', message: '拿不到当前容量,先别记录' };
25
+ }
26
+ const ratio = measured.ratio;
27
+ const window = measured.window;
28
+ const minRatio = compressionMinRatio(deps.cfg.thresholds, deps.cfg.retention, window);
29
+ const compactRatio = deps.cfg.thresholds.compactRatio;
30
+ const percent = (value) => `${(value * 100).toFixed(1)}%`;
31
+ if (ratio < minRatio) {
32
+ return {
33
+ ok: false,
34
+ ratio,
35
+ at: Date.now(),
36
+ reason: 'below-min',
37
+ message: `当前容量 ${percent(ratio)} 还没到最小压缩线 ${percent(minRatio)}——这个位置压缩不划算,不用记录,继续做你的工作`,
38
+ };
39
+ }
40
+ if (ratio >= compactRatio) {
41
+ return {
42
+ ok: false,
43
+ ratio,
44
+ at: Date.now(),
45
+ reason: 'already-over',
46
+ message: `当前容量 ${percent(ratio)} 已经到压缩线了——直接建议用户压缩就行,不用再记录判断点`,
47
+ };
48
+ }
49
+ const at = Date.now();
50
+ const seq = deps.engine.appendPluginRecord(session, COMPRESS_HINT_RECORD_TYPE, { ratio, at });
51
+ return {
52
+ ok: true,
53
+ ratio,
54
+ at,
55
+ seq,
56
+ message: `已记录「适合压缩」判断:当前 ${percent(ratio)}(在最小线和压缩线之间)。用户会在环上看到一个断点`,
57
+ };
58
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 交接单读取动作(A9)—— 适配层不再直接 import dsh 能力层。
3
+ *
4
+ * HTTP 只关心「列最近 N 份 / 读一篇的 JSON」,目录白名单/防穿越/大小上限
5
+ * 全部留在 dsh/handoff-files.js。
6
+ */
7
+ import { listHandoffFiles, readHandoffFile } from '../dsh/handoff-files.js';
8
+
9
+ /**
10
+ * @param {{cfg:{handoffDir:string}}} deps
11
+ * @param {{limit?:number}} [options]
12
+ */
13
+ export function listHandoffDocs(deps, { limit = 20 } = {}) {
14
+ return {
15
+ dir: deps.cfg.handoffDir,
16
+ items: listHandoffFiles(deps.cfg.handoffDir, { limit }),
17
+ };
18
+ }
19
+
20
+ /**
21
+ * 读一篇交接单;不存在/文件名非法返回 null。
22
+ * @param {{cfg:{handoffDir:string}}} deps
23
+ * @param {{file:string}} input
24
+ */
25
+ export function readHandoffDoc(deps, { file } = {}) {
26
+ return readHandoffFile(deps.cfg.handoffDir, file);
27
+ }