dsh-compact-relay 0.5.2 → 0.5.4

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
+ }
@@ -45,33 +45,76 @@ function errorMessageOf(error) {
45
45
  * @param {{ctx:object, log?:{warn?:Function}}} deps
46
46
  * @param {{sessionId:string, confirmStop:boolean, confirmUnpin?:boolean}} input
47
47
  * sessionId 是**旧会话** id(不是当前会话);confirmStop 必须是 true(二次确认的凭证)。
48
- * @returns {Promise<{ok:true, status:'archived'|'already'|'pinned', archived:boolean, note:string, archivedSessionIds:string[]|null}>}
48
+ * @returns {Promise<{ok:true, status:'archived'|'already'|'pinned', archived:boolean, note:string, archivedCount:number|null, newlyArchived:string[], stopActivity?:'done'|'skipped'|'failed'}>}
49
49
  */
50
50
  export async function forceArchiveSession(deps, { sessionId, confirmStop, confirmUnpin } = {}) {
51
51
  if (typeof sessionId !== 'string' || sessionId.length === 0) {
52
- throw actionError('force-archive', '缺少要强制归档的会话 id');
53
- }
54
- // 防御性:HTTP 层与客户端都要求显式确认;动作层再挡一次,避免任何调用方误传。
55
- if (confirmStop !== true) {
56
- throw actionError('force-archive', '强制归档会停止旧会话上正在跑的任务,需要显式确认(confirmStop:true)');
52
+ throw actionError('force-archive', '缺少要强制归档的会话 id', undefined, 400);
57
53
  }
58
54
 
59
55
  const registry = workspaceRegistryOf(deps.ctx);
60
56
  if (registry === null || typeof registry.archiveSession !== 'function') {
61
- throw actionError('force-archive', '当前 DSH 宿主没有 Workspace 归档接口,无法强制归档旧会话');
57
+ throw actionError('force-archive', '当前 DSH 宿主没有 Workspace 归档接口,无法强制归档旧会话', undefined, 503);
62
58
  }
63
59
 
64
- // 幂等:已在归档集合里就什么都不做(尤其不能再 stopActivity 一次)。
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 真机:已归档会话点归档直接报错)。
65
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
+ }
66
97
  return {
67
98
  ok: true,
68
99
  status: 'already',
69
100
  archived: true,
70
- note: handoffArchiveNoteOf({ status: 'already', archived: true }),
71
- archivedSessionIds: archivedSessionIdsOf(deps.ctx),
101
+ stopActivity: 'skipped',
102
+ note: `${base}(本次未执行 stopActivity)`,
103
+ archivedCount: archivedCountOf(),
104
+ newlyArchived: [],
72
105
  };
73
106
  }
74
107
 
108
+ // 防御性:真正要归档的路径仍然要求显式确认(HTTP 层与客户端都会带)。
109
+ if (confirmStop !== true) {
110
+ throw actionError(
111
+ 'force-archive',
112
+ '强制归档会停止旧会话上正在跑的任务,需要显式确认(confirmStop:true)',
113
+ undefined,
114
+ 400,
115
+ );
116
+ }
117
+
75
118
  // 置顶保护:归档会取消置顶(宿主在同一笔写盘里做),必须用户单独确认;
76
119
  // 读不到置顶集合(null)时不猜 —— 按「未置顶」继续,由宿主自己决定。
77
120
  if (confirmUnpin !== true && isSessionPinned(deps.ctx, sessionId) === true) {
@@ -84,7 +127,8 @@ export async function forceArchiveSession(deps, { sessionId, confirmStop, confir
84
127
  archived: false,
85
128
  // 0.5.2 审查 P1-3:本场景没有「交接确认」可点,不能复用交接流程的 pinned 文案。
86
129
  note: '旧会话已置顶,本次未归档(归档会取消置顶);确认「仍然归档(取消置顶)」后才会归档并可恢复。',
87
- archivedSessionIds: archivedSessionIdsOf(deps.ctx),
130
+ archivedCount: archivedCountOf(),
131
+ newlyArchived: [],
88
132
  };
89
133
  }
90
134
 
@@ -100,6 +144,7 @@ export async function forceArchiveSession(deps, { sessionId, confirmStop, confir
100
144
  ? `强制归档失败:宿主里找不到这个会话(${errorMessageOf(error)})`
101
145
  : `强制归档失败:${errorMessageOf(error)}`,
102
146
  error,
147
+ unknown ? 404 : 500,
103
148
  );
104
149
  }
105
150
 
@@ -108,6 +153,9 @@ export async function forceArchiveSession(deps, { sessionId, confirmStop, confir
108
153
  status: 'archived',
109
154
  archived: true,
110
155
  note: handoffArchiveNoteOf({ status: 'archived', archived: true }),
111
- archivedSessionIds: archivedSessionIdsOf(deps.ctx),
156
+ // 0.5.3 P3-a:不再回全量归档集合(142 条 ≈ 7KB,随归档线性增长,客户端根本不读)。
157
+ // 只回数量 + 本次新归档的 id,语义与体积都对。
158
+ archivedCount: archivedCountOf(),
159
+ newlyArchived: [sessionId],
112
160
  };
113
161
  }
@@ -35,7 +35,7 @@ import { changedFilesOf } from '../dsh/session-files.js';
35
35
  import { errorCodeOf, localIso } from '../dsh/diag-counters.js';
36
36
  import { surfaceMessages } from '../dsh/prefix.js';
37
37
  import { handoffTitlePlanOf, createHandoffSession, renameHistoryOf, sourceTitleOf } from '../dsh/sessions.js';
38
- import { writeRelayRecord } from '../dsh/relay.js';
38
+ import { relayInboundRecordOf, writeRelayRecord } from '../dsh/relay.js';
39
39
  import { archiveHandoffSource, handoffArchiveNoteOf, isSessionPinned } from '../dsh/handoff-archive.js';
40
40
  import { withLock } from './lock.js';
41
41
  import { beginInflight, cancelErrorOf, combinedSignal, finishInflight } from './support.js';
@@ -282,6 +282,8 @@ async function runHandoff(deps, { agent, signal, commandId, edits, archive, conf
282
282
  planId: compactOutcome?.planId,
283
283
  title: titlePlan.title,
284
284
  baseTitle: titlePlan.baseTitle,
285
+ // 0.6:收藏文件按链根分文件——初代传自己,后代沿用入站记录的 rootId。
286
+ rootId: relayInboundRecordOf(session)?.rootId ?? session.id,
285
287
  scheme: titlePlan.scheme,
286
288
  titleSeq: historyRename.titleSeq,
287
289
  historyTitle:
@@ -24,11 +24,11 @@ function errorMessageOf(error) {
24
24
  */
25
25
  export async function recoverArchivedSession(deps, { sessionId } = {}) {
26
26
  if (typeof sessionId !== 'string' || sessionId.length === 0) {
27
- throw actionError('recover', '缺少要恢复的会话 id');
27
+ throw actionError('recover', '缺少要恢复的会话 id', undefined, 400);
28
28
  }
29
29
  const registry = workspaceRegistryOf(deps.ctx);
30
30
  if (registry === null || typeof registry.unarchiveSession !== 'function') {
31
- throw actionError('recover', '当前 DSH 宿主没有 Workspace 归档恢复接口,无法恢复旧会话');
31
+ throw actionError('recover', '当前 DSH 宿主没有 Workspace 归档恢复接口,无法恢复旧会话', undefined, 503);
32
32
  }
33
33
 
34
34
  const wasArchived = isSessionArchived(deps.ctx, sessionId);
@@ -36,7 +36,15 @@ export async function recoverArchivedSession(deps, { sessionId } = {}) {
36
36
  await registry.unarchiveSession(sessionId);
37
37
  } catch (error) {
38
38
  deps.log?.warn?.(`compact-relay: 恢复归档会话 ${sessionId} 失败:${errorMessageOf(error)}`);
39
- throw actionError('recover', `恢复旧会话失败:${errorMessageOf(error)}`, error);
39
+ const unknown = error !== null && typeof error === 'object' && error.name === 'WorkspaceUnknownSessionError';
40
+ throw actionError(
41
+ 'recover',
42
+ unknown
43
+ ? `恢复旧会话失败:宿主里找不到这个会话(${errorMessageOf(error)})`
44
+ : `恢复旧会话失败:${errorMessageOf(error)}`,
45
+ error,
46
+ unknown ? 404 : 500,
47
+ );
40
48
  }
41
49
 
42
50
  return {
@@ -44,6 +52,11 @@ export async function recoverArchivedSession(deps, { sessionId } = {}) {
44
52
  // false 表示本来就没归档(幂等 no-op),仍然算「恢复成功」——调用方可以直接打开。
45
53
  recovered: wasArchived !== false,
46
54
  message: wasArchived === false ? '这个会话本来就没有归档,可直接打开。' : '已恢复旧会话,可从侧栏重新打开。',
47
- archivedSessionIds: archivedSessionIdsOf(deps.ctx),
55
+ // 0.5.3 P3-a:只回数量 + 本次恢复的 id(不再回全量归档集合)。
56
+ archivedCount: (() => {
57
+ const ids = archivedSessionIdsOf(deps.ctx);
58
+ return Array.isArray(ids) ? ids.length : null;
59
+ })(),
60
+ newlyRecovered: wasArchived === false ? [] : [sessionId],
48
61
  };
49
62
  }
@@ -5,6 +5,7 @@
5
5
  * 直接调 dsh/pressure + dsh/session-view(不走这里)。客户端不再自己算阈值。
6
6
  */
7
7
  import { paletteForBothSchemes } from '../core/colors.js';
8
+ import { favoriteStateOf } from '../dsh/favorites-file.js';
8
9
  import { adviceFor, ratioOf, thresholdsForWindow } from '../core/policy.js';
9
10
  import { nativePinnedRiskOf } from '../core/native-pin-risk.js';
10
11
  import { pinHealthOf } from '../core/pin-health.js';
@@ -273,6 +274,11 @@ export async function readState(deps, { sessionId, agent } = {}) {
273
274
  },
274
275
  atRisk: pinRisk,
275
276
  },
277
+ // 0.6:收藏(项目级 JSONL)→ 当前会话 seq 投影,守 32KB 帧预算。
278
+ favorites: favoriteStateOf(deps.ctx, target?.session),
279
+ // 0.5.3 P1-6:推荐收藏建议(模型打分后点亮星标,**不自动收藏**)。
280
+ // 只下发 seq / messageId / 分数 / 原因;没有建议时是空数组(老客户端忽略)。
281
+ favoriteSuggest: deps.favoriteWatch?.suggestionOf?.(target?.session?.id) ?? [],
276
282
  compactRange,
277
283
  // 0.3.5:compactRange 非 null 但无活 agent 时为 'no_agent',其它情况 null。
278
284
  compactRangeReason,