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,304 @@
1
+ /**
2
+ * 交接新会话(/handoff,代码里沿用 handoff/HO 命名)—— M3 正式版
3
+ * (照 REFERENCES §1.6 WeiYe6 路线,按 DSH 0.2 API 落地)。
4
+ *
5
+ * 流程(REQUIREMENTS §三,交接部分):
6
+ * 1. 先压缩旧会话一次(复用 M2 的 betterCompact 本体,不二压政策不变);
7
+ * 2. 让模型看着压缩后的完整 surface 写九段交接单(不是压缩摘要;程序段由代码收集);
8
+ * 3. 写盘到 cfg.handoffDir(默认 ~/.dsh/handoffs)/handoff-<时间>.md;
9
+ * 4. 在同一工作区建新会话(同 preset、同模型),把交接单注入并起第一轮;
10
+ * 5. 返回新会话 id,客户端据此自动切过去。
11
+ *
12
+ * 失败语义:
13
+ * - 压缩失败但可继续(结构/范围问题)→ 降级为「不压缩,直接写文档」并记日志;
14
+ * - 压缩 busy / 用户取消 → 直接中止(会话可能正在跑);
15
+ * - 文档生成不合格 → 不建新会话(无副作用,只多一次模型调用);
16
+ * - 新会话建好但注入失败 → 报错里带 childId,用户可从侧栏手动打开。
17
+ */
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { actionError } from '../core/errors.js';
21
+ import { handoffFileName, renderHandoffDoc } from '../core/handoff-doc.js';
22
+ import { betterCompactLocked } from './better-compact.js';
23
+ import { compressHint, countSummaries, livePinnedSeqs, surfaceSeqs } from '../dsh/session-view.js';
24
+ import { measurePressure } from '../dsh/pressure.js';
25
+ import { buildSiteSnapshot } from '../dsh/site-info.js';
26
+ import { generateHandoffDoc } from '../dsh/handoff-writer.js';
27
+ import { buildHandoffProgramSections } from '../core/handoff-facts.js';
28
+ import { checkHandoffReadiness } from '../core/readiness.js';
29
+ import { changedFilesOf } from '../dsh/session-files.js';
30
+ import { surfaceMessages } from '../dsh/prefix.js';
31
+ import { handoffTitlePlanOf, createHandoffSession, sourceTitleOf } from '../dsh/sessions.js';
32
+ import { writeRelayRecord } from '../dsh/relay.js';
33
+ import { withLock } from './lock.js';
34
+
35
+ /**
36
+ * 压缩阶段:能压就压,压不了就带着原因往下走。
37
+ * @returns {Promise<{outcome: object|null, warning: string|null}>}
38
+ */
39
+ async function compactFirst(deps, { agent, signal, commandId, log }) {
40
+ try {
41
+ return { outcome: await betterCompactLocked(deps, { agent, signal, commandId }), warning: null };
42
+ } catch (error) {
43
+ const code = error !== null && typeof error === 'object' ? error.code : undefined;
44
+ // 只有「事务开始前/未提交」的失败能降级继续(结构、范围、摘要、内容变化)。
45
+ // commit/persistence 说明历史可能已被改写但没落盘,再写交接单会给出与真实历史不符的文档,
46
+ // 所以直接中止交接(2026-10-19 debug 实测:旧实现会报「压缩失败已跳过」却已替换历史)。
47
+ if (code === 'busy' || code === 'cancelled' || code === 'commit' || code === 'persistence') throw error;
48
+ const warning = error instanceof Error ? error.message : String(error);
49
+ log?.warn?.(`compact-relay: 交接前压缩失败,按当前历史继续:${warning}`);
50
+ return { outcome: null, warning };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 收集交接单需要的全部会话事实(A1):压力/现场快照/摘要数/锁定/改过的文件/程序段。
56
+ * @returns {Promise<{session:object, measured:object, now:Date, site:object, summaries:number, pinnedSeqs:number[], changedFiles:string[], programSections:Record<string,string>}>}
57
+ */
58
+ async function collectHandoffFacts(deps, agent) {
59
+ const session = agent.session;
60
+ const measured = await measurePressure(deps.ctx, agent, { windowFallback: deps.cfg.contextWindowFallback }).catch(
61
+ () => ({ tokens: null }),
62
+ );
63
+ const now = new Date();
64
+ const site = buildSiteSnapshot({ session, now });
65
+ const summaries = countSummaries(session);
66
+ const pinnedSeqs = livePinnedSeqs(session);
67
+ const changedFiles = changedFilesOf(session);
68
+ const programSections = buildHandoffProgramSections({
69
+ compressHint: compressHint(session),
70
+ summaries,
71
+ pressure: measured.tokens,
72
+ pins: { count: pinnedSeqs.length, seqs: pinnedSeqs },
73
+ changedFiles,
74
+ site,
75
+ });
76
+ return { session, measured, now, site, summaries, pinnedSeqs, changedFiles, programSections };
77
+ }
78
+
79
+ /**
80
+ * @param {{ctx:object, cfg:object, lock:object, engine:object, log?:object}} deps
81
+ * @param {{agent:object, signal?:AbortSignal, commandId?:string}} input
82
+ */
83
+ export async function handoff(deps, { agent, signal, commandId }) {
84
+ if (agent === undefined || agent === null) throw actionError('handoff', '当前没有打开的会话,无法交接');
85
+ const session = agent.session;
86
+ if (session === undefined) throw actionError('handoff', '没有活动会话,无法交接');
87
+
88
+ return withLock(deps.lock, agent.id, async () => {
89
+ // P1-4:交接登记进 inflight,client 的取消按钮才能真正 abort 模型调用。
90
+ const controller =
91
+ deps.inflight !== undefined && typeof deps.inflight.begin === 'function'
92
+ ? deps.inflight.begin(agent.id, 'handoff')
93
+ : null;
94
+ const combined =
95
+ controller?.signal === undefined
96
+ ? signal
97
+ : signal === undefined
98
+ ? controller.signal
99
+ : AbortSignal.any([controller.signal, signal]);
100
+
101
+ try {
102
+ // P2-2:先做可失败的 preflight(目录可写),再动历史(压缩)。
103
+ try {
104
+ fs.mkdirSync(deps.cfg.handoffDir, { recursive: true });
105
+ fs.accessSync(deps.cfg.handoffDir, fs.constants.W_OK);
106
+ } catch (error) {
107
+ throw actionError(
108
+ 'handoff',
109
+ `交接单目录不可写(${deps.cfg.handoffDir}):${error instanceof Error ? error.message : String(error)}`,
110
+ error,
111
+ );
112
+ }
113
+
114
+ // 1) 先压缩旧会话一次(best effort)。
115
+ const { outcome: compactOutcome, warning: compactWarning } = await compactFirst(deps, {
116
+ agent,
117
+ signal: combined,
118
+ commandId,
119
+ log: deps.log,
120
+ });
121
+ combined?.throwIfAborted?.();
122
+
123
+ // 2) 收集事实(A1 程序段)+ 预算/文档头用的压力。
124
+ const facts = await collectHandoffFacts(deps, agent);
125
+
126
+ // 3) 让模型写七段(压缩后的完整 surface 为输入),程序段由 writer 拼成九段。
127
+ const generated = await generateHandoffDoc({
128
+ ctx: deps.ctx,
129
+ engine: deps.engine,
130
+ agent,
131
+ maxTokens: deps.cfg.handoff.maxTokens,
132
+ inputTokens: facts.measured.tokens,
133
+ timeoutMs: deps.cfg.timeouts?.llmMs,
134
+ signal: combined,
135
+ programSections: facts.programSections,
136
+ });
137
+ combined?.throwIfAborted?.();
138
+
139
+ // 3.5) 就绪检查:九段缺段/占位阻断;路径核对只警告,不阻断。
140
+ const readiness = checkHandoffReadiness({
141
+ sections: generated.sections,
142
+ surfaceText: JSON.stringify(surfaceMessages(facts.session)),
143
+ changedFiles: facts.changedFiles,
144
+ messageCount: surfaceSeqs(facts.session).length,
145
+ });
146
+ if (!readiness.ok) {
147
+ throw actionError('handoff', `交接单就绪检查未通过:缺少或为空的小节:${readiness.missing.join('、')}`);
148
+ }
149
+ for (const warning of readiness.warnings) deps.log?.warn?.(`compact-relay: 交接单就绪警告:${warning}`);
150
+
151
+ // 4) 写盘:文档头带元信息,九段正文 = 模型 7 段 + 程序 2 段。
152
+ const file = path.join(deps.cfg.handoffDir, handoffFileName(facts.now));
153
+ const doc = renderHandoffDoc({
154
+ generated: generated.text,
155
+ sourceSessionId: facts.session.id,
156
+ sourceTitle: sourceTitleOf(deps.ctx, facts.session),
157
+ now: facts.now,
158
+ pressure: facts.measured.tokens,
159
+ summaries: facts.summaries,
160
+ file,
161
+ // M6:把「宿主刚重启过 / 工作区分支 / 有没有后台任务在跑」写进文档头,
162
+ // 免得接手者按一份出厂即过时的「下一步」干活。
163
+ site: facts.site,
164
+ });
165
+ try {
166
+ fs.writeFileSync(file, doc, 'utf8');
167
+ } catch (error) {
168
+ throw actionError(
169
+ 'handoff',
170
+ `交接单写盘失败(${deps.cfg.handoffDir}):${error instanceof Error ? error.message : String(error)}`,
171
+ error,
172
+ );
173
+ }
174
+
175
+ // 5) 新会话:同工作区 / 同 preset / 同模型,注入交接单并起第一轮。
176
+ // A5:标题序号优先读父会话里的 relay 记录(不依赖解析标题文案)。
177
+ const titlePlan = handoffTitlePlanOf(deps.ctx, session);
178
+ let created;
179
+ try {
180
+ created = await createHandoffSession({
181
+ ctx: deps.ctx,
182
+ engine: deps.engine,
183
+ session,
184
+ agent,
185
+ doc,
186
+ title: titlePlan.title,
187
+ log: deps.log,
188
+ });
189
+ } catch (error) {
190
+ // 建会话失败 → 清掉刚写的交接单,避免留孤儿文件(P2-2)。
191
+ try {
192
+ fs.unlinkSync(file);
193
+ } catch {
194
+ /* 清不掉也别盖住原始错误 */
195
+ }
196
+ throw error;
197
+ }
198
+
199
+ // A5:把接力链写成父会话里的结构化记录;写失败不阻断交接(标题解析仍可兜底)。
200
+ const relayWrote = writeRelayRecord(deps.engine, session, {
201
+ parentSessionId: session.id,
202
+ childId: created.childId,
203
+ index: titlePlan.index,
204
+ path: file,
205
+ planId: compactOutcome?.planId ?? null,
206
+ at: Date.now(),
207
+ title: titlePlan.title,
208
+ baseTitle: titlePlan.baseTitle,
209
+ });
210
+ if (!relayWrote) deps.log?.warn?.('compact-relay: 接力链记录写入失败,下次交接将回退到标题解析');
211
+
212
+ // BUG-004:失败原因可能是「摘要模型调用了工具」这类可操作信息,以前只写「日志有记录」
213
+ // (用户看不到 host 日志);这里带上短原因 + 可手动 /bc 的建议。
214
+ const briefWarning =
215
+ compactWarning === null
216
+ ? ''
217
+ : compactWarning.length > 120
218
+ ? `${compactWarning.slice(0, 119)}…`
219
+ : compactWarning;
220
+ const compactNote =
221
+ compactOutcome !== null && compactOutcome.compacted === true
222
+ ? `已将 ${compactOutcome.shadowedSeqs.length} 条旧会话压成 1 条摘要;`
223
+ : compactOutcome !== null
224
+ ? '没有可压缩内容,交接单基于完整历史;'
225
+ : compactWarning !== null
226
+ ? `压缩失败已跳过(${briefWarning});需要的话可先手动 /bc 再交接;`
227
+ : '';
228
+
229
+ return {
230
+ ok: true,
231
+ childId: created.childId,
232
+ path: file,
233
+ readiness,
234
+ started: created.started,
235
+ message:
236
+ `交接完成:${compactNote}` +
237
+ `交接单已存至 ${file};` +
238
+ `新会话(${created.childId})已就绪` +
239
+ (created.started ? ',正在切换。' : ',正在切换(首轮未自动启动,可在新会话里发一条消息继续)。') +
240
+ // A5 兜底:结构化 childId 没传下来时,客户端可从文本里提取。
241
+ `\n#child=${created.childId}`,
242
+ };
243
+ } catch (error) {
244
+ // 取消统一收口;已提交/落盘状态不明的错误不许谎报「没改」。
245
+ if (
246
+ combined?.aborted === true &&
247
+ error?.code !== 'cancelled' &&
248
+ error?.code !== 'commit' &&
249
+ error?.code !== 'persistence'
250
+ ) {
251
+ throw actionError('cancelled', '已取消交接;已完成的压缩不会回滚。', error);
252
+ }
253
+ throw error;
254
+ } finally {
255
+ if (controller !== null && typeof deps.inflight.finish === 'function') {
256
+ deps.inflight.finish(agent.id, controller);
257
+ }
258
+ }
259
+ });
260
+ }
261
+
262
+ /**
263
+ * 交接预览(A1b):生成九段草稿 + 就绪清单,但不写盘、不建会话、不改历史。
264
+ *
265
+ * 注意:真实交接会先 best-effort 压缩再重新生成,所以这里只代表「按当前历史
266
+ * 交接会写成什么样」;确认后仍走 handoff() 的完整流程。
267
+ * @param {{ctx:object, cfg:object, engine:object, log?:object}} deps
268
+ * @param {{agent:object, signal?:AbortSignal}} input
269
+ */
270
+ export async function previewHandoff(deps, { agent, signal }) {
271
+ if (agent === undefined || agent === null) throw actionError('handoff', '当前没有打开的会话,无法预览交接');
272
+ const session = agent.session;
273
+ if (session === undefined) throw actionError('handoff', '没有活动会话,无法预览交接');
274
+
275
+ const facts = await collectHandoffFacts(deps, agent);
276
+ const generated = await generateHandoffDoc({
277
+ ctx: deps.ctx,
278
+ engine: deps.engine,
279
+ agent,
280
+ maxTokens: deps.cfg.handoff.maxTokens,
281
+ inputTokens: facts.measured.tokens,
282
+ timeoutMs: deps.cfg.timeouts?.llmMs,
283
+ signal,
284
+ programSections: facts.programSections,
285
+ });
286
+ const readiness = checkHandoffReadiness({
287
+ sections: generated.sections,
288
+ surfaceText: JSON.stringify(surfaceMessages(facts.session)),
289
+ changedFiles: facts.changedFiles,
290
+ messageCount: surfaceSeqs(facts.session).length,
291
+ });
292
+ if (!readiness.ok) {
293
+ throw actionError('handoff', `交接单就绪检查未通过:缺少或为空的小节:${readiness.missing.join('、')}`);
294
+ }
295
+ return {
296
+ ok: true,
297
+ draft: generated.text,
298
+ readiness,
299
+ pressure: facts.measured.tokens,
300
+ summaries: facts.summaries,
301
+ changedFiles: facts.changedFiles,
302
+ attempts: generated.attempts,
303
+ };
304
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 进行中动作登记表(M6)—— 让 HTTP 层能取消正在跑的压缩。
3
+ *
4
+ * 与 actions/lock.js 的单飞锁分工:
5
+ * - lock:防并发,决定「能不能开始」;
6
+ * - inflight:登记 AbortController,决定「正在跑的这个能不能被取消」。
7
+ *
8
+ * 取消只是 abort;事务层会补一条 compaction/end(error),历史不变。
9
+ */
10
+ import { actionError } from '../core/errors.js';
11
+
12
+ const keyOf = (sessionId) => (typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null);
13
+
14
+ export function createInflight() {
15
+ /** @type {Map<string, {controller:AbortController, kind:string}>} */
16
+ const entries = new Map();
17
+
18
+ return {
19
+ /**
20
+ * 登记一次动作;同会话已有登记则抛 busy(外层锁会再兜一层)。
21
+ * @param {string|undefined} sessionId
22
+ * @param {string} [kind]
23
+ * @returns {AbortController|null}
24
+ */
25
+ begin(sessionId, kind = 'compact') {
26
+ const key = keyOf(sessionId);
27
+ if (key === null) return null;
28
+ if (entries.has(key)) throw actionError('busy', '这个会话已经有压缩/交接在跑');
29
+ const controller = new AbortController();
30
+ entries.set(key, { controller, kind: typeof kind === 'string' && kind.length > 0 ? kind : 'compact' });
31
+ return controller;
32
+ },
33
+ /**
34
+ * 结束登记(只清自己那一份,避免误清后来的一轮)。
35
+ * @param {string|undefined} sessionId
36
+ * @param {AbortController|null} controller
37
+ */
38
+ finish(sessionId, controller) {
39
+ const key = keyOf(sessionId);
40
+ if (key === null) return;
41
+ const entry = entries.get(key);
42
+ if (entry !== undefined && entry.controller === controller) entries.delete(key);
43
+ },
44
+ /**
45
+ * 请求取消;返回是否真的找到了在跑的动作。
46
+ * @param {string|undefined} sessionId
47
+ * @returns {boolean}
48
+ */
49
+ cancel(sessionId) {
50
+ const key = keyOf(sessionId);
51
+ if (key === null) return false;
52
+ const entry = entries.get(key);
53
+ if (entry === undefined) return false;
54
+ entry.controller.abort();
55
+ return true;
56
+ },
57
+ /**
58
+ * @param {string|undefined} sessionId
59
+ * @returns {boolean}
60
+ */
61
+ isActive(sessionId) {
62
+ const key = keyOf(sessionId);
63
+ return key !== null && entries.has(key);
64
+ },
65
+ /**
66
+ * @param {string|undefined} sessionId
67
+ * @returns {string|null}
68
+ */
69
+ kindOf(sessionId) {
70
+ const key = keyOf(sessionId);
71
+ const entry = key === null ? undefined : entries.get(key);
72
+ return entry === undefined ? null : entry.kind;
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 每会话单飞锁 —— 压缩/交接/测试共用。
3
+ *
4
+ * 同一会话同时只允许一个改历史的动作;
5
+ * 指示灯里的 busy 状态也从这里读,不各写一套。
6
+ */
7
+ import { actionError } from '../core/errors.js';
8
+
9
+ /** @returns {{tryAcquire:(id:string)=>boolean, release:(id:string)=>void, isBusy:(id?:string|null)=>boolean}} */
10
+ export function createLock() {
11
+ const active = new Set();
12
+ return {
13
+ tryAcquire(id) {
14
+ if (active.has(id)) return false;
15
+ active.add(id);
16
+ return true;
17
+ },
18
+ release(id) {
19
+ active.delete(id);
20
+ },
21
+ isBusy(id) {
22
+ return typeof id === 'string' && id.length > 0 && active.has(id);
23
+ },
24
+ };
25
+ }
26
+
27
+ /**
28
+ * @template T
29
+ * @param {{tryAcquire:(id:string)=>boolean, release:(id:string)=>void}} lock
30
+ * @param {string|undefined} sessionId
31
+ * @param {() => Promise<T>} job
32
+ * @returns {Promise<T>}
33
+ */
34
+ export async function withLock(lock, sessionId, job) {
35
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return job();
36
+ if (!lock.tryAcquire(sessionId)) {
37
+ throw actionError('busy', '这个会话已经有压缩/交接在跑');
38
+ }
39
+ try {
40
+ return await job();
41
+ } finally {
42
+ lock.release(sessionId);
43
+ }
44
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * DSH 原生自动压缩开关(0.3.1)—— actions 层包装。
3
+ *
4
+ * 真正的读写逻辑在 dsh/native-compaction.js;这里只做参数校验与返回结构,
5
+ * 让 adapters/http 不直接碰 dsh/(A9 架构守卫)。
6
+ */
7
+ import { readNativeCompaction, setNativeAutoCompaction } from '../dsh/native-compaction.js';
8
+
9
+ /**
10
+ * 读当前原生压缩配置(设置页/状态用)。
11
+ * @returns {{autoEnabled:boolean, thresholdRatio:number}}
12
+ */
13
+ export function nativeCompactionInfo() {
14
+ const current = readNativeCompaction();
15
+ return { autoEnabled: current.autoEnabled, thresholdRatio: current.thresholdRatio };
16
+ }
17
+
18
+ /**
19
+ * 关闭/恢复 DSH 原生自动压缩。
20
+ * @param {{enabled:boolean}} input
21
+ * @returns {{autoEnabled:boolean, thresholdRatio:number, backup:string|null, file:string, message:string}}
22
+ */
23
+ export function setNativeAutoCompactionEnabled({ enabled } = {}) {
24
+ if (typeof enabled !== 'boolean') throw new Error('缺少 enabled(true=恢复原生自动压缩,false=关闭)');
25
+ const outcome = setNativeAutoCompaction(enabled);
26
+ return {
27
+ autoEnabled: outcome.autoEnabled,
28
+ thresholdRatio: outcome.thresholdRatio,
29
+ backup: outcome.backup,
30
+ file: outcome.file,
31
+ message: outcome.autoEnabled
32
+ ? `已恢复 DSH 原生自动压缩(当前原生阈值 ${(outcome.thresholdRatio * 100).toFixed(0)}%);重启 DSH 后生效`
33
+ : `已关闭 DSH 原生自动压缩(备份:${outcome.backup ?? '无'});重启 DSH 后生效`,
34
+ };
35
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * 锁定重要消息(M6,内部代号「钉住」/pin)—— 用户指定「必须原样保留、不被压缩」的消息。
3
+ *
4
+ * 入口:
5
+ * - `/pin`(钉最近一轮)/ `/pin list`(列最近 10 条 + 已钉集合)
6
+ * - `/pin <序号>`(钉列表里的第 n 条)/ `/pin clear`
7
+ * - `/unpin`(清空全部)
8
+ * - 指示灯菜单「锁定重要消息 / 解锁消息(N 条)」(HTTP action pin-toggle)
9
+ *
10
+ * 只写 plugin record:不进模型上下文、不占 token;压缩选段会避开被锁定的消息。
11
+ */
12
+ import { actionError } from '../core/errors.js';
13
+ import { PIN_RECORD_TYPE, latestPinnedSeqs, normalizePinnedSeqs } from '../core/pins.js';
14
+ import { livePinnedSeqs, sessionLog, surfaceSeqs } from '../dsh/session-view.js';
15
+
16
+ const TYPE_LABEL = {
17
+ 'system/message': '系统',
18
+ 'user/message': '用户',
19
+ 'assistant/message': '助手',
20
+ 'tool/result': '工具',
21
+ };
22
+
23
+ /**
24
+ * 从事件里抠一段可读预览(消息 content 可能是数组或字符串)。
25
+ * @param {object|undefined} event
26
+ * @returns {string}
27
+ */
28
+ function previewOf(event) {
29
+ // DSH v4:user/message 的 data 本身就是消息({role,content,source}),
30
+ // 只有 system/assistant/tool 包在 data.message 里。2026-10-19 debug 实测:
31
+ // 只读 data.message 会让 /pin list 里所有用户消息显示「(无文本)」。
32
+ const content = event?.data?.content ?? event?.data?.message?.content;
33
+ let text = '';
34
+ if (typeof content === 'string') text = content;
35
+ else if (Array.isArray(content)) {
36
+ text = content
37
+ .filter((block) => block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
38
+ .map((block) => block.text)
39
+ .join(' ');
40
+ }
41
+ text = text.replace(/\s+/gu, ' ').trim();
42
+ if (text.length === 0) return '(无文本)';
43
+ return text.length > 60 ? `${text.slice(0, 59)}…` : text;
44
+ }
45
+
46
+ /**
47
+ * 最近 N 条 surface 消息(新→旧),编号 1 开始。
48
+ * @param {object} session
49
+ * @param {number} [limit]
50
+ */
51
+ export function pinCandidates(session, limit = 10) {
52
+ const seqs = surfaceSeqs(session);
53
+ const picked = [];
54
+ for (let index = seqs.length - 1; index >= 0 && picked.length < limit; index -= 1) {
55
+ const seq = seqs[index];
56
+ const event = typeof session.eventAt === 'function' ? session.eventAt(seq) : undefined;
57
+ if (event === undefined || event === null) continue;
58
+ picked.push({
59
+ index: picked.length + 1,
60
+ seq,
61
+ type: event.type,
62
+ role: TYPE_LABEL[event.type] ?? '其他',
63
+ preview: previewOf(event),
64
+ });
65
+ }
66
+ return picked;
67
+ }
68
+
69
+ function pinnedOf(session) {
70
+ return latestPinnedSeqs(sessionLog(session));
71
+ }
72
+
73
+ function writePins(deps, session, seqs) {
74
+ const normalized = normalizePinnedSeqs(seqs);
75
+ deps.engine.appendPluginRecord(session, PIN_RECORD_TYPE, { seqs: normalized, at: Date.now() });
76
+ return normalized;
77
+ }
78
+
79
+ /**
80
+ * 锁定最近一轮对话(最后一条 user/message;没有就退回最后一条 surface 消息)。
81
+ * @param {{engine:object}} deps
82
+ * @param {{agent:object}} input
83
+ */
84
+ export function pinLastTurn(deps, { agent } = {}) {
85
+ const session = agent?.session;
86
+ if (session === null || session === undefined) throw actionError('pin', '当前没有打开的会话,无法锁定消息');
87
+ // P3-03:只把「还活着」的锁定带进新快照;过期的 seq 顺手清掉,不让快照越滚越脏。
88
+ const current = livePinnedSeqs(session);
89
+
90
+ let seq = null;
91
+ const seqs = surfaceSeqs(session);
92
+ // 优先锁「人真正说的上一句」:v4 里 runtime-context / time-context / checkpoint
93
+ // 也是 user/message,直接取最后一条会锁到合成消息(2026-10-19 debug 实测)。
94
+ for (let index = seqs.length - 1; index >= 0; index -= 1) {
95
+ const event = typeof session.eventAt === 'function' ? session.eventAt(seqs[index]) : undefined;
96
+ if (event?.type === 'user/message' && event.data?.source?.kind === 'user') {
97
+ seq = seqs[index];
98
+ break;
99
+ }
100
+ }
101
+ if (seq === null) {
102
+ // 老会话/测试 mock 没有 source 信息:退回「最后一条 user/message」,再退回最后一条 surface。
103
+ for (let index = seqs.length - 1; index >= 0; index -= 1) {
104
+ const event = typeof session.eventAt === 'function' ? session.eventAt(seqs[index]) : undefined;
105
+ if (event?.type === 'user/message') {
106
+ seq = seqs[index];
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ if (seq === null && seqs.length > 0) seq = seqs[seqs.length - 1];
112
+ if (seq === null) throw actionError('pin', '当前会话还没有可锁定的消息');
113
+
114
+ const pinned = writePins(deps, session, [...current, seq]);
115
+ return {
116
+ pinned,
117
+ added: seq,
118
+ message: `已锁定最近一轮会话(共 ${pinned.length} 条);后续压缩将原样保留。`,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * 按 `/pin list` 的编号锁定某条消息(1 = 最新)。
124
+ * @param {{engine:object}} deps
125
+ * @param {{agent:object, index:number}} input
126
+ */
127
+ export function pinByIndex(deps, { agent, index } = {}) {
128
+ const session = agent?.session;
129
+ if (session === null || session === undefined) throw actionError('pin', '没有活动会话,无法锁定消息');
130
+ const wanted = Number(index);
131
+ if (!Number.isInteger(wanted) || wanted < 1) throw actionError('pin', '序号须为正整数,例如 /pin 3');
132
+ const candidates = pinCandidates(session, 10);
133
+ const candidate = candidates.find((entry) => entry.index === wanted);
134
+ if (candidate === undefined) {
135
+ throw actionError('pin', `只能从最近 ${candidates.length} 条消息中选择(用 /pin list 查看序号)`);
136
+ }
137
+ const current = livePinnedSeqs(session);
138
+ const pinned = writePins(deps, session, [...current, candidate.seq]);
139
+ return {
140
+ pinned,
141
+ added: candidate.seq,
142
+ message: `已锁定 1 条消息(${candidate.role}:${candidate.preview});当前共锁定 ${pinned.length} 条。`,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * 清空全部锁定。
148
+ * @param {{engine:object}} deps
149
+ * @param {{agent:object}} input
150
+ */
151
+ export function unpinAll(deps, { agent } = {}) {
152
+ const session = agent?.session;
153
+ if (session === null || session === undefined) throw actionError('pin', '当前没有打开的会话,无法解锁消息');
154
+ const current = pinnedOf(session);
155
+ // 本来就是空集合:不要为「清空空集合」再写一条记录(白触发 SSE/落盘)。
156
+ if (current.length === 0) {
157
+ return { pinned: [], cleared: 0, message: '当前没有锁定的消息。' };
158
+ }
159
+ // P3-03:和 readState 的展示口径对齐——只数「当前 surface 里还活着」的锁定。
160
+ // 快照里的 seq 可能已被原生压缩/清屏带走,那些只是过期记录,不该算进「解锁了几条」。
161
+ const live = livePinnedSeqs(session);
162
+ writePins(deps, session, []);
163
+ return {
164
+ pinned: [],
165
+ cleared: live.length,
166
+ message:
167
+ live.length === 0
168
+ ? '已清理过期的锁定记录(原快照里的消息已不在当前会话中),后续压缩不受影响。'
169
+ : `已全部解锁(原 ${live.length} 条),后续压缩可正常合并这些消息。`,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * 列表:最近 10 条可钉消息 + 当前已钉集合。
175
+ * @param {{}} deps
176
+ * @param {{agent:object}} input
177
+ */
178
+ export function listPins(deps, { agent } = {}) {
179
+ const session = agent?.session;
180
+ if (session === null || session === undefined) throw actionError('pin', '没有活动会话');
181
+ const candidates = pinCandidates(session, 10);
182
+ const pinned = pinnedOf(session);
183
+ const lines = candidates.map((entry) => `${entry.index}. ${entry.role}:${entry.preview}`);
184
+ return {
185
+ pinned,
186
+ candidates,
187
+ message:
188
+ (candidates.length === 0
189
+ ? '当前会话还没有可锁定的消息。'
190
+ : `最近 ${candidates.length} 条消息(输入 /pin <序号> 锁定指定消息):\n${lines.join('\n')}`) +
191
+ `\n当前已锁定:${pinned.length === 0 ? '无' : `${pinned.length} 条`}`,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * 菜单用:有锁定就清空,没有就锁最近一轮。
197
+ *
198
+ * P3-03:判断必须和 readState 的展示同口径(`livePinnedSeqs`)。只看日志里的最新
199
+ * 快照时,快照 seq 已被压缩/清屏带走的场景会让菜单显示「锁定重要消息」(未锁),
200
+ * 点下去却走 unpinAll → 用户看到的是「明明没锁却被解锁」。
201
+ * @param {{engine:object}} deps
202
+ * @param {{agent:object}} input
203
+ */
204
+ export function togglePin(deps, { agent } = {}) {
205
+ const session = agent?.session;
206
+ if (session === null || session === undefined) throw actionError('pin', '没有活动会话');
207
+ return livePinnedSeqs(session).length > 0 ? unpinAll(deps, { agent }) : pinLastTurn(deps, { agent });
208
+ }