dsh-compact-relay 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,435 @@
1
+ /**
2
+ * 收藏的动作层 —— 命令与 HTTP 共用,不含 UI。
3
+ *
4
+ * 与旧 pin 的区别:pin 是「压缩时截断范围」,收藏**不改变压缩选段**,
5
+ * 只在压缩摘要里留一段目录 + 让用户随时按序号取回原文。
6
+ */
7
+ import { actionError } from '../core/errors.js';
8
+ import { roleLabelOf } from '../core/pin-anchors.js';
9
+ import { decideFavoriteSuggest } from '../core/favorite-score.js';
10
+ import { FAVORITE_MAX, buildFavoriteDirectoryLines, buildFavoriteTitle } from '../core/favorites.js';
11
+ import { appendFavoriteRecord, appendSnapshotEntries, favoriteMapForRoot, favoritesFileForRoot, favoriteMapOf, favoritesFileOf, rootSessionIdOfId, snapshotFileOf, snapshotTextFor } from '../dsh/favorites-file.js';
12
+ import { scoreFavoriteMessage } from '../dsh/favorite-scorer.js';
13
+ import { sessionLog, surfaceSeqs } from '../dsh/session-view.js';
14
+
15
+ /** 粗略 token 估算(字符数 / 2)——只用于「先报体积」,不参与预算决策。 */
16
+ export function estimateTextTokens(text) {
17
+ const length = typeof text === 'string' ? text.length : 0;
18
+ return Math.max(1, Math.round(length / 2));
19
+ }
20
+
21
+ function blockTextOf(block) {
22
+ return block !== null && typeof block === 'object' && typeof block.text === 'string' ? block.text : '';
23
+ }
24
+
25
+ /**
26
+ * 从事件里抠完整原文(content 可能是字符串或 block 数组)。
27
+ * @param {object|undefined} event
28
+ * @returns {string}
29
+ */
30
+ export function eventTextOf(event) {
31
+ const data = event !== null && typeof event === 'object' ? event.data : undefined;
32
+ const candidates = [data?.content, data?.message?.content];
33
+ for (const content of candidates) {
34
+ if (typeof content === 'string') return content;
35
+ if (Array.isArray(content)) {
36
+ const text = content.map(blockTextOf).filter((part) => part.length > 0).join('\n');
37
+ if (text.length > 0) return text;
38
+ }
39
+ }
40
+ return '';
41
+ }
42
+
43
+ /**
44
+ * messageId → seq:扫会话日志匹配宿主消息 id。
45
+ * 助手消息的 id 在 `data.message.id`,用户消息在 `data.id`(2026-10-28 真机核实)。
46
+ * @param {object} session
47
+ * @param {string} messageId
48
+ * @returns {number|null}
49
+ */
50
+ export function seqOfMessageId(session, messageId) {
51
+ if (typeof messageId !== 'string' || messageId.length === 0) return null;
52
+ const log = sessionLog(session);
53
+ for (let index = log.length - 1; index >= 0; index -= 1) {
54
+ const event = log[index];
55
+ const data = event?.data;
56
+ if (data === null || typeof data !== 'object') continue;
57
+ if (event.type === 'assistant/message' && data.message?.id === messageId) return event.seq;
58
+ if (event.type === 'user/message' && data.id === messageId) return event.seq;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * messageId 落在哪个**活会话**里(收藏兜底用)。
65
+ *
66
+ * 客户端可能没带 sessionId、或带的是切换前的旧会话:messageId 是宿主生成的 UUID,
67
+ * 扫一遍活 agent 的会话日志即可反查。命中 0 个或多个都返回 null——宁可报错,也不收错会话。
68
+ * @param {{ctx:object}} deps
69
+ * @param {string} messageId
70
+ * @returns {object|null}
71
+ */
72
+ export function ownerAgentOfMessage(deps, messageId) {
73
+ const agents = deps?.ctx?.agents;
74
+ if (agents === null || agents === undefined || typeof agents.list !== 'function') return null;
75
+ if (typeof messageId !== 'string' || messageId.length === 0) return null;
76
+ let all = [];
77
+ try {
78
+ all = agents.list();
79
+ } catch {
80
+ return null;
81
+ }
82
+ if (!Array.isArray(all)) return null;
83
+ const hits = [];
84
+ for (const candidate of all) {
85
+ const session = candidate?.session;
86
+ if (session === null || session === undefined) continue;
87
+ if (seqOfMessageId(session, messageId) !== null) hits.push(candidate);
88
+ }
89
+ return hits.length === 1 ? hits[0] : null;
90
+ }
91
+
92
+ function sessionOf(agent) {
93
+ const session = agent?.session;
94
+ if (session === null || session === undefined) throw actionError('favorite', '当前没有打开的会话');
95
+ return session;
96
+ }
97
+
98
+ function writeFavorite(deps, session, op, seq) {
99
+ const record = {
100
+ schema: 'dcr-favorite/v1',
101
+ op,
102
+ sessionId: session.id,
103
+ seq,
104
+ at: Date.now(),
105
+ };
106
+ return appendFavoriteRecord(record, favoritesFileOf(deps.ctx, session)).ok;
107
+ }
108
+
109
+ function liveFavoriteSeqs(deps, session) {
110
+ const map = favoriteMapOf(deps.ctx, session);
111
+ const set = map.get(session.id);
112
+ return set === undefined ? [] : [...set].sort((a, b) => a - b);
113
+ }
114
+
115
+ /** 收藏 / 取消收藏一条消息(按宿主 messageId,UI 按钮走这里)。 */
116
+ export function toggleFavorite(deps, { agent, messageId } = {}) {
117
+ if (typeof messageId !== 'string' || messageId.length === 0) throw actionError('favorite', '缺少 messageId');
118
+ // 传进来的 agent 不一定对(多会话时客户端可能带错 sessionId):先认它,认不出再按 messageId 反查。
119
+ const owner = (() => {
120
+ const passed = agent?.session;
121
+ if (passed !== null && passed !== undefined && seqOfMessageId(passed, messageId) !== null) return agent;
122
+ return ownerAgentOfMessage(deps, messageId);
123
+ })();
124
+ if (owner === null) {
125
+ throw actionError('favorite', '找不到这条消息所属的会话(会话可能已关闭,或这条已被压缩)');
126
+ }
127
+ const session = sessionOf(owner);
128
+ const seq = seqOfMessageId(session, messageId);
129
+ if (seq === null) throw actionError('favorite', '找不到这条消息(会话可能被压缩过),换一条试试');
130
+ const set = new Set(liveFavoriteSeqs(deps, session));
131
+ if (set.has(seq)) {
132
+ writeFavorite(deps, session, 'remove', seq);
133
+ set.delete(seq);
134
+ return { active: false, seq, count: set.size, message: '已取消收藏' };
135
+ }
136
+ if (set.size >= FAVORITE_MAX) throw actionError('favorite', `这条工作的收藏已达上限 ${FAVORITE_MAX} 条,先取消一些`);
137
+ writeFavorite(deps, session, 'add', seq);
138
+ set.add(seq);
139
+ return { active: true, seq, count: set.size, message: '已收藏' };
140
+ }
141
+
142
+ /**
143
+ * 按「会话 id + seq」收藏/取消(**不要求活会话**)——归档/关闭的会话也能标。
144
+ *
145
+ * 这是「标记」与「压缩落档」解耦的第一步:标记只写一行 JSONL,不读会话日志、不碰压缩。
146
+ * 链根解析走 `ctx.get('workspaceRegistry').readSessionHeader`(对已存会话同样可读);
147
+ * 解析不到就把 sessionId 当根(不猜更多)。写失败明确报错,不假装成功。
148
+ *
149
+ * @param {{ctx:object}} deps
150
+ * @param {{sessionId:string, seq:number, active?:boolean}} input
151
+ * active 省略 = 切换;true = 只加;false = 只删。已是目标状态时幂等返回(不重复写)。
152
+ * @returns {Promise<{active:boolean, seq:number, count:number, sessionId:string, note?:string, message:string}>}
153
+ */
154
+ export async function markFavoriteBySeq(deps, { sessionId, seq, active } = {}) {
155
+ if (typeof sessionId !== 'string' || sessionId.length === 0) throw actionError('favorite', '缺少 sessionId');
156
+ if (!Number.isInteger(seq) || seq < 0) throw actionError('favorite', '缺少或非法的消息序号 seq(应为非负整数)');
157
+ const rootId = (await rootSessionIdOfId(deps.ctx, sessionId)) ?? sessionId;
158
+ const file = favoritesFileForRoot(rootId);
159
+ const map = favoriteMapForRoot(rootId);
160
+ const set = new Set(map.get(sessionId) ?? []);
161
+ const already = set.has(seq);
162
+ // active 省略 = 切换;true/false = 只加/只删,已是目标状态就幂等返回(不重复写盘)。
163
+ let add;
164
+ if (active === undefined) {
165
+ add = !already;
166
+ } else if (active === true) {
167
+ if (already) return { active: true, seq, count: set.size, sessionId, note: 'already', message: `第 ${seq} 条已经收藏过了` };
168
+ add = true;
169
+ } else {
170
+ if (!already) return { active: false, seq, count: set.size, sessionId, note: 'already', message: `第 ${seq} 条本来就没收藏` };
171
+ add = false;
172
+ }
173
+ if (add && set.size >= FAVORITE_MAX) throw actionError('favorite', `这条工作的收藏已达上限 ${FAVORITE_MAX} 条,先取消一些`);
174
+ const record = { schema: 'dcr-favorite/v1', op: add ? 'add' : 'remove', sessionId, seq, at: Date.now() };
175
+ const written = appendFavoriteRecord(record, file);
176
+ if (written.ok !== true) throw actionError('favorite', '收藏文件写不进去(检查 ~/.dsh 的权限)');
177
+ if (add) set.add(seq);
178
+ else set.delete(seq);
179
+ return {
180
+ active: add,
181
+ seq,
182
+ count: set.size,
183
+ sessionId,
184
+ message: add ? `已收藏第 ${seq} 条` : `已取消收藏第 ${seq} 条`,
185
+ };
186
+ }
187
+
188
+ /** 日志里最后一条助手消息的 seq(命令 /favorite mark 不带参数时用)。 */
189
+ function lastAssistantSeqOf(session) {
190
+ const log = sessionLog(session);
191
+ for (let index = log.length - 1; index >= 0; index -= 1) {
192
+ const event = log[index];
193
+ if (event !== null && typeof event === 'object' && event.type === 'assistant/message') return event.seq;
194
+ }
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * 命令 `/favorite mark [seq]`:按 seq 收藏(不传 seq = 最后一条已完成的助手回复)。
200
+ * 与星标共用同一个 JSONL;绝不因为「消息被压走」就静默失败——那条不在日志里会明确报错。
201
+ */
202
+ export function markFavoriteInSession(deps, { agent, seq } = {}) {
203
+ const session = sessionOf(agent);
204
+ const target = seq === undefined ? lastAssistantSeqOf(session) : seq;
205
+ if (target === null) throw actionError('favorite', '这个会话里还没有助手回复可收藏');
206
+ if (!Number.isInteger(target) || target < 0) throw actionError('favorite', '序号须为非负整数,例如 /favorite mark 1234');
207
+ const event = typeof session.eventAt === 'function' ? session.eventAt(target) : undefined;
208
+ if (event === undefined || event === null) {
209
+ throw actionError('favorite', `没有 seq ${target} 这条消息(可能已被压缩或清理);要标别的先看 /favorite list 或压缩摘要里的序号`);
210
+ }
211
+ const set = new Set(liveFavoriteSeqs(deps, session));
212
+ if (set.has(target)) {
213
+ const snapshot = snapshotIfCompacted(deps, session, target, event);
214
+ return {
215
+ active: true,
216
+ seq: target,
217
+ count: set.size,
218
+ note: 'already',
219
+ snapshot,
220
+ message: snapshot === 'written' ? `第 ${target} 条已经收藏过了(原文已补写进快照)` : `第 ${target} 条已经收藏过了`,
221
+ };
222
+ }
223
+ if (set.size >= FAVORITE_MAX) throw actionError('favorite', `这条工作的收藏已达上限 ${FAVORITE_MAX} 条,先取消一些`);
224
+ if (writeFavorite(deps, session, 'add', target) !== true) throw actionError('favorite', '收藏文件写不进去(检查 ~/.dsh 的权限)');
225
+ set.add(target);
226
+ const snapshot = snapshotIfCompacted(deps, session, target, event);
227
+ return {
228
+ active: true,
229
+ seq: target,
230
+ count: set.size,
231
+ snapshot,
232
+ message: `已收藏第 ${target} 条${snapshot === 'written' ? '(这条已被压走,原文已写进快照)' : ''}`,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * P0-3 兜底:给「已经被压走、但原文还在日志里」的收藏**当场写快照**。
238
+ *
239
+ * 压缩摘要里的「收藏(本段)」是压缩那一刻的快照,之后补标不会回写摘要;
240
+ * 不写快照就会出现「标记在、原文读不到」。这里只兜日志可读的情形
241
+ * (读不到就保持无快照,由 read 侧明确报错;完整档案读取是 0.6)。
242
+ * @returns {'written'|'present'|'none'}
243
+ */
244
+ function snapshotIfCompacted(deps, session, seq, event) {
245
+ if (event === undefined || event === null) return 'none';
246
+ const surface = new Set(surfaceSeqs(session));
247
+ if (surface.has(seq)) return 'none'; // 还在界面上:走正常星标路径,不需要兜底
248
+ const file = snapshotFileOf(deps.ctx, session);
249
+ if (snapshotTextFor(file, seq) !== null) return 'present';
250
+ const text = eventTextOf(event);
251
+ if (text.length === 0) return 'none';
252
+ const written = appendSnapshotEntries(file, [
253
+ { seq, role: roleLabelOf(event.type), title: buildFavoriteTitle(event), text, at: Date.now() },
254
+ ]);
255
+ return written.ok === true && written.written === 1 ? 'written' : 'none';
256
+ }
257
+
258
+ /** 收藏「我刚发的最后一条」。 */export function favoriteLast(deps, { agent } = {}) {
259
+ const session = sessionOf(agent);
260
+ const seqs = surfaceSeqs(session);
261
+ let seq = null;
262
+ for (let index = seqs.length - 1; index >= 0; index -= 1) {
263
+ const event = typeof session.eventAt === 'function' ? session.eventAt(seqs[index]) : undefined;
264
+ if (event?.type === 'user/message' && event.data?.source?.kind === 'user') {
265
+ seq = seqs[index];
266
+ break;
267
+ }
268
+ }
269
+ if (seq === null) throw actionError('favorite', '没有找到你发的消息');
270
+ const set = new Set(liveFavoriteSeqs(deps, session));
271
+ if (set.has(seq)) {
272
+ writeFavorite(deps, session, 'remove', seq);
273
+ return { active: false, seq, count: set.size, message: '已取消收藏(这条本来已收藏)' };
274
+ }
275
+ if (set.size >= FAVORITE_MAX) throw actionError('favorite', `这条工作的收藏已达上限 ${FAVORITE_MAX} 条`);
276
+ writeFavorite(deps, session, 'add', seq);
277
+ set.add(seq);
278
+ return { active: true, seq, count: set.size, message: '已收藏你刚发的这条' };
279
+ }
280
+
281
+ /** 当前会话收藏的条目(编号从 1 开始,按 seq 升序)。 */
282
+ export function favoriteEntries(deps, session) {
283
+ return liveFavoriteSeqs(deps, session).map((seq, index) => {
284
+ const event = typeof session.eventAt === 'function' ? session.eventAt(seq) : undefined;
285
+ const live = event !== undefined && event !== null;
286
+ const snapshot = live ? null : snapshotTextFor(snapshotFileOf(deps.ctx, session), seq);
287
+ const status = live ? 'live' : snapshot !== null ? 'snapshot' : 'lost';
288
+ return {
289
+ index: index + 1,
290
+ seq,
291
+ role: live ? roleLabelOf(event?.type) : '原消息',
292
+ title: live ? buildFavoriteTitle(event) : snapshot !== null ? snapshot.replace(/\s+/gu, ' ').slice(0, 60) : '(原文已不可达)',
293
+ tokens: estimateTextTokens(live ? eventTextOf(event) : snapshot ?? ''),
294
+ status,
295
+ };
296
+ });
297
+ }
298
+
299
+ /** 列表(目录)。 */
300
+ export function listFavorites(deps, { agent } = {}) {
301
+ const session = sessionOf(agent);
302
+ const entries = favoriteEntries(deps, session);
303
+ const lines = buildFavoriteDirectoryLines(entries, 20);
304
+ const lost = entries.filter((entry) => entry.status !== 'live').length;
305
+ const lostNote =
306
+ lost === 0
307
+ ? ''
308
+ : `\n其中 ${lost} 条原件日志已不在:${entries
309
+ .filter((entry) => entry.status !== 'live')
310
+ .map((entry) => `${entry.index}${entry.status === 'snapshot' ? '(可用快照读)' : '(快照也没有)'}`)
311
+ .join('、')}`;
312
+ return {
313
+ entries,
314
+ count: entries.length,
315
+ message:
316
+ entries.length === 0
317
+ ? '当前会话还没有收藏。点消息下面的星,或输入 /favorite last 收藏你刚发的这条。'
318
+ : `当前会话收藏 ${entries.length} 条:\n${lines.join('\n')}${lostNote}\n\n读原文:/favorite <序号>(先报体积)|取消:/favorite rm <序号>`,
319
+ };
320
+ }
321
+
322
+ function entryOf(deps, session, index) {
323
+ const wanted = Number(index);
324
+ if (!Number.isInteger(wanted) || wanted < 1) throw actionError('favorite', '序号须为正整数,例如 /favorite 2');
325
+ const entries = favoriteEntries(deps, session);
326
+ const entry = entries.find((item) => item.index === wanted);
327
+ if (entry === undefined) {
328
+ throw actionError('favorite', `没有第 ${wanted} 条(当前共 ${entries.length} 条,用 /favorite list 查看)`);
329
+ }
330
+ return entry;
331
+ }
332
+
333
+ /** 读原文(先报体积,不输出正文)。 */
334
+ export function readFavoriteInfo(deps, { agent, index } = {}) {
335
+ const session = sessionOf(agent);
336
+ const entry = entryOf(deps, session, index);
337
+ return {
338
+ entry,
339
+ message: `第 ${entry.index} 条(${entry.role}):${entry.title}\n原文约 ${entry.tokens} token。要读的话输入:/favorite ${entry.index} read`,
340
+ };
341
+ }
342
+
343
+ /** 读原文正文(用户已经看过体积并确认)。 */
344
+ export function readFavoriteFull(deps, { agent, index } = {}) {
345
+ const session = sessionOf(agent);
346
+ const entry = entryOf(deps, session, index);
347
+ const event = typeof session.eventAt === 'function' ? session.eventAt(entry.seq) : undefined;
348
+ let text = eventTextOf(event);
349
+ let source = 'log';
350
+ if (text.length === 0) {
351
+ const snapshot = snapshotTextFor(snapshotFileOf(deps.ctx, session), entry.seq);
352
+ if (snapshot !== null) {
353
+ text = snapshot;
354
+ source = 'snapshot';
355
+ }
356
+ }
357
+ if (text.length === 0) {
358
+ throw actionError('favorite', '这条收藏的原文已不可达(日志与压缩快照里都没有;可能日志被清理且从未经过压缩)');
359
+ }
360
+ const note = source === 'snapshot' ? '(来自压缩快照:原件日志已不在)' : '';
361
+ return {
362
+ entry,
363
+ text,
364
+ source,
365
+ message: `第 ${entry.index} 条原文${note}(约 ${entry.tokens} token):\n\n${text}`,
366
+ };
367
+ }
368
+
369
+ /** 取消收藏(按序号)。 */
370
+ export function unfavoriteByIndex(deps, { agent, index } = {}) {
371
+ const session = sessionOf(agent);
372
+ const entry = entryOf(deps, session, index);
373
+ writeFavorite(deps, session, 'remove', entry.seq);
374
+ return { seq: entry.seq, message: `已取消收藏第 ${entry.index} 条:${entry.title}` };
375
+ }
376
+
377
+ /**
378
+ * 推荐收藏「试算」(0.5.3 P1-7):按传入(或当前配置)档位/词表跑一次真实打分 + 判定。
379
+ * 只读:不写收藏、不改变任何状态;失败时 `score:null` + decisionReason 说明。
380
+ *
381
+ * @param {{ctx:object, engine:object, cfg:object, log?:object}} deps
382
+ * @param {{agent:object, text:string, level?:string, mustWords?:unknown, muteWords?:unknown}} input
383
+ * @returns {Promise<{score:number|null, scoreReason:string, suggest:boolean, decisionReason:string, matched:string|null, level:string|null}>}
384
+ */
385
+ export async function trialFavoriteScore(deps, { agent, text, level, mustWords, muteWords } = {}) {
386
+ const body = typeof text === 'string' ? text : '';
387
+ const useLevel = level ?? deps.cfg?.favorite?.level;
388
+ const useMust = mustWords ?? deps.cfg?.favorite?.mustWords;
389
+ const useMute = muteWords ?? deps.cfg?.favorite?.muteWords;
390
+ const scored = await scoreFavoriteMessage(deps, { agent, text: body });
391
+ const decision = decideFavoriteSuggest({
392
+ text: body,
393
+ score: scored?.score ?? null,
394
+ level: useLevel,
395
+ mustWords: useMust,
396
+ muteWords: useMute,
397
+ });
398
+ return {
399
+ score: scored?.score ?? null,
400
+ scoreReason: scored?.reason ?? '',
401
+ suggest: decision.suggest === true,
402
+ decisionReason: decision.reason,
403
+ matched: decision.matched,
404
+ level: typeof useLevel === 'string' ? useLevel : null,
405
+ };
406
+ }
407
+
408
+ /**
409
+ * 推荐收藏(半自动)—— 命令 `/favorite suggest` 的出口。
410
+ *
411
+ * 0.5.3 P1-6:建议由 `dsh/favorite-watch.js` 在助手消息落地后异步打分生成(模型 0-100 +
412
+ * 三档阈值 + 必亮/屏蔽词),这里只读内存投影,不再做规则匹配。
413
+ * 红线不变:命令只**列出**建议,不写收藏;确认仍要用户点星或 `/favorite mark`。
414
+ */
415
+ export function listFavoriteSuggestions(deps, { agent } = {}) {
416
+ const session = sessionOf(agent);
417
+ const suggestions = deps.favoriteWatch?.suggestionOf?.(session.id) ?? [];
418
+ const roleOf = (item) => {
419
+ const event = typeof session.eventAt === 'function' ? session.eventAt(item.seq) : undefined;
420
+ return roleLabelOf(event?.type ?? '');
421
+ };
422
+ return {
423
+ suggestions,
424
+ message:
425
+ suggestions.length === 0
426
+ ? '暂时没有推荐。推荐会在每轮助手回复落地后自动打分(默认标准档≥75 才亮),' +
427
+ '也可以手动点星或 /favorite mark。'
428
+ : `建议收藏这 ${suggestions.length} 条(只点亮、不会自动收藏):\n${suggestions
429
+ .map((item) => {
430
+ const score = typeof item.score === 'number' ? `${item.score} 分` : '必亮词命中';
431
+ return `- 第 ${item.seq} 条(${roleOf(item)}):${score}${item.matched !== null && item.matched !== undefined ? ` — ${item.matched}` : ''}`;
432
+ })
433
+ .join('\n')}\n\n确认收藏:点消息下面的星,或 /favorite mark <seq>`,
434
+ };
435
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * 0.5.2 L 项:**强制归档旧会话**(显式用户确认后才停止后台任务)。
3
+ *
4
+ * 背景:自动归档(`dsh/handoff-archive.js:archiveHandoffSource`)在旧会话还有
5
+ * turn/subagent/job/schedule 时**故意不归档**(`status:'active'`)—— 静默杀后台任务
6
+ * 比「旧会话多留一行」破坏性大得多。但这也意味着「旧会话一直有任务在跑」时用户
7
+ * 没有清理出路(0.5.x 补丁计划 P4)。这里补的就是那条**显式**出路:
8
+ *
9
+ * - 只有 `confirmStop === true`(用户在二次确认里点过「强制归档(停止任务)」)
10
+ * 才会调用 `workspaceRegistry.archiveSession(sessionId, { stopActivity: true })`;
11
+ * - 没有确认 → 直接拒绝(不静默降级成普通归档,也不静默停止任务);
12
+ * - 旧会话在侧栏置顶时要求 `confirmUnpin === true`(宿主归档会在同一次写盘里
13
+ * 取消置顶),否则返回 `pinned` + 现有置顶文案,**不归档**;
14
+ * - 失败抛可读错误(`code:'force-archive'`),不吞、不假装成功。
15
+ *
16
+ * 宿主实证(`@deepseek-ai/dsh-workspace/lib/index.js`):
17
+ * - `archiveSession(id)` 先跑 `workspace/session-activity` waterfall,有 provider
18
+ * 报活就抛 `WorkspaceActiveSessionError`(带 `activity` 数组);
19
+ * - `archiveSession(id, { stopActivity:true })` 跳过活动检查、先写归档集合,再
20
+ * `workspace/session-stop` 停止旧会话的 turn/subagent/job/schedule;
21
+ * - 归档与置顶互斥:同一笔写盘会把该 id 从 `pinnedSessionIds` 移除。
22
+ */
23
+ import { actionError } from '../core/errors.js';
24
+ import {
25
+ archivedSessionIdsOf,
26
+ handoffArchiveNoteOf,
27
+ isSessionArchived,
28
+ isSessionPinned,
29
+ workspaceRegistryOf,
30
+ } from '../dsh/handoff-archive.js';
31
+
32
+ function errorMessageOf(error) {
33
+ return error instanceof Error ? error.message : String(error);
34
+ }
35
+
36
+ /**
37
+ * 强制归档旧会话(会停止它上面正在跑的 turn / subagent / job / schedule)。
38
+ *
39
+ * 返回值(全部 `ok:true`;失败一律抛 `actionError('force-archive', …)`):
40
+ * - `status:'archived'` 刚写入归档集合(已停止任务);
41
+ * - `status:'already'` 之前就已经归档(幂等,不再写盘、不再停任务);
42
+ * - `status:'pinned'` 已置顶且未确认取消置顶 —— **没有归档**,note 是现有置顶文案。
43
+ * 三种情况的 `note` 都复用 `handoffArchiveNoteOf`(与交接自动归档同一套告知文案)。
44
+ *
45
+ * @param {{ctx:object, log?:{warn?:Function}}} deps
46
+ * @param {{sessionId:string, confirmStop:boolean, confirmUnpin?:boolean}} input
47
+ * sessionId 是**旧会话** id(不是当前会话);confirmStop 必须是 true(二次确认的凭证)。
48
+ * @returns {Promise<{ok:true, status:'archived'|'already'|'pinned', archived:boolean, note:string, archivedCount:number|null, newlyArchived:string[], stopActivity?:'done'|'skipped'|'failed'}>}
49
+ */
50
+ export async function forceArchiveSession(deps, { sessionId, confirmStop, confirmUnpin } = {}) {
51
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
52
+ throw actionError('force-archive', '缺少要强制归档的会话 id', undefined, 400);
53
+ }
54
+
55
+ const registry = workspaceRegistryOf(deps.ctx);
56
+ if (registry === null || typeof registry.archiveSession !== 'function') {
57
+ throw actionError('force-archive', '当前 DSH 宿主没有 Workspace 归档接口,无法强制归档旧会话', undefined, 503);
58
+ }
59
+
60
+ const archivedCountOf = () => {
61
+ const ids = archivedSessionIdsOf(deps.ctx);
62
+ return Array.isArray(ids) ? ids.length : null;
63
+ };
64
+
65
+ // 0.5.3 P2-1:幂等判断在 confirmStop 之前 —— 已归档 + 不带确认应回 already(200),
66
+ // 不再因为「没带二次确认」先 400(devgod 052 真机:已归档会话点归档直接报错)。
67
+ if (isSessionArchived(deps.ctx, sessionId) === true) {
68
+ const base = handoffArchiveNoteOf({ status: 'already', archived: true });
69
+ // 0.5.3 P2-下:already 防漏杀加固 —— 归档会话可能在归档前还有后台任务残留。
70
+ // 用户这次显式要求「强制归档」时,补跑一次 stopActivity 兜底(幂等);未要求则明确
71
+ // 报告「本次未执行 stopActivity」,不假装已经停干净。
72
+ if (confirmStop === true) {
73
+ try {
74
+ await registry.archiveSession(sessionId, { stopActivity: true });
75
+ return {
76
+ ok: true,
77
+ status: 'already',
78
+ archived: true,
79
+ stopActivity: 'done',
80
+ note: `${base}(本次已补停旧会话上的任务)`,
81
+ archivedCount: archivedCountOf(),
82
+ newlyArchived: [],
83
+ };
84
+ } catch (error) {
85
+ deps.log?.warn?.(`compact-relay: 补停归档会话 ${sessionId} 的任务失败:${errorMessageOf(error)}`);
86
+ return {
87
+ ok: true,
88
+ status: 'already',
89
+ archived: true,
90
+ stopActivity: 'failed',
91
+ note: `${base}(补停任务失败:${errorMessageOf(error)})`,
92
+ archivedCount: archivedCountOf(),
93
+ newlyArchived: [],
94
+ };
95
+ }
96
+ }
97
+ return {
98
+ ok: true,
99
+ status: 'already',
100
+ archived: true,
101
+ stopActivity: 'skipped',
102
+ note: `${base}(本次未执行 stopActivity)`,
103
+ archivedCount: archivedCountOf(),
104
+ newlyArchived: [],
105
+ };
106
+ }
107
+
108
+ // 防御性:真正要归档的路径仍然要求显式确认(HTTP 层与客户端都会带)。
109
+ if (confirmStop !== true) {
110
+ throw actionError(
111
+ 'force-archive',
112
+ '强制归档会停止旧会话上正在跑的任务,需要显式确认(confirmStop:true)',
113
+ undefined,
114
+ 400,
115
+ );
116
+ }
117
+
118
+ // 置顶保护:归档会取消置顶(宿主在同一笔写盘里做),必须用户单独确认;
119
+ // 读不到置顶集合(null)时不猜 —— 按「未置顶」继续,由宿主自己决定。
120
+ if (confirmUnpin !== true && isSessionPinned(deps.ctx, sessionId) === true) {
121
+ deps.log?.warn?.(
122
+ `compact-relay: 旧会话 ${sessionId} 在侧栏置顶,未确认取消置顶前不强制归档(归档会取消置顶)`,
123
+ );
124
+ return {
125
+ ok: true,
126
+ status: 'pinned',
127
+ archived: false,
128
+ // 0.5.2 审查 P1-3:本场景没有「交接确认」可点,不能复用交接流程的 pinned 文案。
129
+ note: '旧会话已置顶,本次未归档(归档会取消置顶);确认「仍然归档(取消置顶)」后才会归档并可恢复。',
130
+ archivedCount: archivedCountOf(),
131
+ newlyArchived: [],
132
+ };
133
+ }
134
+
135
+ try {
136
+ // 唯一允许传 stopActivity 的地方:用户已确认「会停止旧会话上正在跑的任务」。
137
+ await registry.archiveSession(sessionId, { stopActivity: true });
138
+ } catch (error) {
139
+ deps.log?.warn?.(`compact-relay: 强制归档旧会话 ${sessionId} 失败:${errorMessageOf(error)}`);
140
+ const unknown = error !== null && typeof error === 'object' && error.name === 'WorkspaceUnknownSessionError';
141
+ throw actionError(
142
+ 'force-archive',
143
+ unknown
144
+ ? `强制归档失败:宿主里找不到这个会话(${errorMessageOf(error)})`
145
+ : `强制归档失败:${errorMessageOf(error)}`,
146
+ error,
147
+ unknown ? 404 : 500,
148
+ );
149
+ }
150
+
151
+ return {
152
+ ok: true,
153
+ status: 'archived',
154
+ archived: true,
155
+ note: handoffArchiveNoteOf({ status: 'archived', archived: true }),
156
+ // 0.5.3 P3-a:不再回全量归档集合(142 条 ≈ 7KB,随归档线性增长,客户端根本不读)。
157
+ // 只回数量 + 本次新归档的 id,语义与体积都对。
158
+ archivedCount: archivedCountOf(),
159
+ newlyArchived: [sessionId],
160
+ };
161
+ }
@@ -30,11 +30,12 @@ import { buildSiteSnapshot } from '../dsh/site-info.js';
30
30
  import { generateHandoffDoc } from '../dsh/handoff-writer.js';
31
31
  import { buildHandoffProgramSections } from '../core/handoff-facts.js';
32
32
  import { checkHandoffReadiness } from '../core/readiness.js';
33
+ import { parentRelayRecordOf } from '../core/relay-record.js';
33
34
  import { changedFilesOf } from '../dsh/session-files.js';
34
35
  import { errorCodeOf, localIso } from '../dsh/diag-counters.js';
35
36
  import { surfaceMessages } from '../dsh/prefix.js';
36
37
  import { handoffTitlePlanOf, createHandoffSession, renameHistoryOf, sourceTitleOf } from '../dsh/sessions.js';
37
- import { writeRelayRecord } from '../dsh/relay.js';
38
+ import { relayInboundRecordOf, writeRelayRecord } from '../dsh/relay.js';
38
39
  import { archiveHandoffSource, handoffArchiveNoteOf, isSessionPinned } from '../dsh/handoff-archive.js';
39
40
  import { withLock } from './lock.js';
40
41
  import { beginInflight, cancelErrorOf, combinedSignal, finishInflight } from './support.js';
@@ -263,29 +264,34 @@ async function runHandoff(deps, { agent, signal, commandId, edits, archive, conf
263
264
  });
264
265
 
265
266
  // A5:把接力链写成父会话里的结构化记录;写失败不阻断交接(标题解析仍可兜底)。
266
- // 0.4 P2-2:canonical 字段 parentId/childId/ordinal/at/atIso 补齐;旧字段 parentSessionId/index 保留兼容。
267
- // 0.5.0:父/子记录都写 scheme(本次链的命名方案)+ titleSeq(各自 rename 的 eventSeq);
268
- // 新链父记录再写 historyTitle(旧会话改名后的标题),供审计/幂等核对。
267
+ // 0.5.2 S2:形状(含 0.3 旧字段双写、planId、条件写入的 historyTitle、**键顺序**)
268
+ // 全部由 core/relay-record 的唯一构造器负责,这里只传语义参数。
269
+ // historyTitle 只在改名真的生效(renamed / already)时传;失败/服务不可用时
270
+ // 不能记一个不存在的标题 —— 构造器对空/非字符串一律不写这个键。
269
271
  const relayAt = Date.now();
270
- const relayWrote = writeRelayRecord(deps.engine, session, {
271
- parentId: session.id,
272
- childId: created.childId,
273
- ordinal: titlePlan.index,
274
- at: relayAt,
275
- atIso: localIso(new Date(relayAt)),
276
- parentSessionId: session.id,
277
- index: titlePlan.index,
278
- path: file,
279
- planId: compactOutcome?.planId ?? null,
280
- title: titlePlan.title,
281
- baseTitle: titlePlan.baseTitle,
282
- scheme: titlePlan.scheme,
283
- titleSeq: historyRename.titleSeq,
284
- // historyTitle 只在改名真的生效时写;失败/服务不可用时不能记一个不存在的标题。
285
- ...(titlePlan.scheme === 'history-n' && (historyRename.renamed || historyRename.reason === 'already')
286
- ? { historyTitle: historyRename.title }
287
- : {}),
288
- });
272
+ const relayWrote = writeRelayRecord(
273
+ deps.engine,
274
+ session,
275
+ parentRelayRecordOf({
276
+ parentId: session.id,
277
+ childId: created.childId,
278
+ ordinal: titlePlan.index,
279
+ at: relayAt,
280
+ atIso: localIso(new Date(relayAt)),
281
+ path: file,
282
+ planId: compactOutcome?.planId,
283
+ title: titlePlan.title,
284
+ baseTitle: titlePlan.baseTitle,
285
+ // 0.6:收藏文件按链根分文件——初代传自己,后代沿用入站记录的 rootId。
286
+ rootId: relayInboundRecordOf(session)?.rootId ?? session.id,
287
+ scheme: titlePlan.scheme,
288
+ titleSeq: historyRename.titleSeq,
289
+ historyTitle:
290
+ titlePlan.scheme === 'history-n' && (historyRename.renamed || historyRename.reason === 'already')
291
+ ? historyRename.title
292
+ : undefined,
293
+ }),
294
+ );
289
295
  if (!relayWrote) deps.log?.warn?.('compact-relay: 接力链记录写入失败,下次交接将回退到标题解析');
290
296
  // renameHistoryOf 的 flush 发生在父记录之前;这里再 flush 一次,保证旧会话的
291
297
  // 「历史 N」标题与这条父记录一起落盘(失败只 warn,不影响交接结果)。