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.
@@ -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';
@@ -238,9 +239,17 @@ export async function readState(deps, { sessionId, agent } = {}) {
238
239
  // 用它决定要不要先 unarchive(宿主拒绝打开归档会话;unarchive 幂等)。
239
240
  // 和 parentArchived 完全对称:各查各的 id,读不到 → null(不猜 false)。
240
241
  const nextArchived = next === null || next.childId === null ? null : isSessionArchived(deps.ctx, next.childId);
242
+ // 0.5.2 L 项:链上父会话(旧会话)是否在侧栏置顶 —— 强制归档的二次确认要提前说明
243
+ // 「归档会取消置顶」(宿主在同一笔写盘里 unpin)。没有父会话/读不到 → null(不猜)。
244
+ const parentPinned = chain === null || chain.parentId === null ? null : isSessionPinned(deps.ctx, chain.parentId);
241
245
  // 0.5.1 DCR-03:宿主有没有「恢复归档会话」的接缝(HTTP recover 动作调的就是它)。
242
246
  // 客户端拿不到 ctx.uiWorkspace.unarchiveSession 时据此隐藏「恢复旧会话」死入口。
243
- const capabilities = { unarchive: typeof workspaceRegistryOf(deps.ctx)?.unarchiveSession === 'function' };
247
+ // 0.5.2 L 项:`forceArchive` = 宿主有没有 archiveSession(接力条据此显示「强制归档旧会话」;
248
+ // 缺这个接缝时隐藏入口——点了必然失败)。
249
+ const capabilities = {
250
+ unarchive: typeof workspaceRegistryOf(deps.ctx)?.unarchiveSession === 'function',
251
+ forceArchive: typeof workspaceRegistryOf(deps.ctx)?.archiveSession === 'function',
252
+ };
244
253
 
245
254
  return {
246
255
  revision: ++stateRevision,
@@ -265,6 +274,11 @@ export async function readState(deps, { sessionId, agent } = {}) {
265
274
  },
266
275
  atRisk: pinRisk,
267
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) ?? [],
268
282
  compactRange,
269
283
  // 0.3.5:compactRange 非 null 但无活 agent 时为 'no_agent',其它情况 null。
270
284
  compactRangeReason,
@@ -289,11 +303,14 @@ export async function readState(deps, { sessionId, agent } = {}) {
289
303
  // 0.5.1 批 3:出站目标(next.childId)是否在同一归档集合里。父会话侧「去新会话」
290
304
  // 据此先恢复再切;没有出站记录或读不到归档集合 → null。只加不改。
291
305
  nextArchived,
306
+ // 0.5.2 L 项:入站父会话(旧会话)是否在侧栏置顶。强制归档的二次确认用它在
307
+ // 「会停止后台任务」之外再说明「归档会取消置顶」;没有父会话/读不到 → null(不猜)。
308
+ parentPinned,
292
309
  // 0.5.1 批 2:本会话(agent.session,不是 agent.id)是否在侧栏置顶。
293
310
  // 交接前客户端据此弹「归档会取消置顶」确认;读不到置顶集合 → null(不猜)。
294
311
  sessionPinned: target === null || target.session === undefined ? null : isSessionPinned(deps.ctx, target.session.id),
295
- // 0.5.1 DCR-03:宿主能力位(目前只有「能不能恢复归档会话」)。老客户端忽略;
296
- // 新客户端在缺 `unarchive` 能力时隐藏恢复入口,不把用户带进必然失败的入口。
312
+ // 0.5.1 DCR-03:宿主能力位(能不能恢复归档会话 / 能不能强制归档)。老客户端忽略;
313
+ // 新客户端在缺 `unarchive` 能力时隐藏恢复入口,缺 `forceArchive` 时隐藏强制归档入口。
297
314
  capabilities,
298
315
  // 0.3.1:模型判断的「适合压缩」断点位置(没有或已失效为 null)。
299
316
  compressHint: activeHint,
@@ -7,6 +7,7 @@
7
7
  import { betterCompact, previewBetterCompact, takePreviewPlan } from '../actions/better-compact.js';
8
8
  import { handoff } from '../actions/handoff.js';
9
9
  import { listPins, pinByIndex, pinLastTurn, unpinAll } from '../actions/pin.js';
10
+ import { favoriteLast, listFavoriteSuggestions, listFavorites, markFavoriteInSession, readFavoriteFull, readFavoriteInfo, unfavoriteByIndex } from '../actions/favorite.js';
10
11
  import { testCompact } from '../actions/test-compact.js';
11
12
  import { errorText } from '../core/errors.js';
12
13
 
@@ -104,6 +105,41 @@ export function registerCommands(ctx, deps) {
104
105
  ),
105
106
  });
106
107
 
108
+ register({
109
+ name: 'favorite',
110
+ description: '收藏消息:/favorite 列表|/favorite mark [序号] 收藏(不带序号=最后一条回复)|/favorite last 收藏我刚发的|/favorite <序号> 看体积|/favorite <序号> read 读原文|/favorite rm <序号> 取消',
111
+ input: { hint: 'list | mark [序号] | last | suggest | 序号 | 序号 read | rm 序号' },
112
+ handler: (invocation) =>
113
+ track(
114
+ (async () => {
115
+ try {
116
+ const raw = invocation.rawInput.trim();
117
+ const lower = raw.toLowerCase();
118
+ const agent = invocation.agent;
119
+ let outcome = null;
120
+ if (raw === '' || lower === 'list') outcome = listFavorites(deps, { agent });
121
+ else if (lower === 'last') outcome = favoriteLast(deps, { agent });
122
+ else if (lower === 'suggest') outcome = listFavoriteSuggestions(deps, { agent });
123
+ else if (/^mark$/i.test(raw)) outcome = markFavoriteInSession(deps, { agent });
124
+ else if (/^mark\s+\d+$/i.test(raw)) outcome = markFavoriteInSession(deps, { agent, seq: Number(raw.slice(4).trim()) });
125
+ else if (/^\d+$/.test(raw)) outcome = readFavoriteInfo(deps, { agent, index: Number(raw) });
126
+ else {
127
+ const readMatch = /^(\d+)\s+read$/i.exec(raw);
128
+ const rmMatch = /^rm\s+(\d+)$/i.exec(raw);
129
+ if (readMatch !== null) outcome = readFavoriteFull(deps, { agent, index: Number(readMatch[1]) });
130
+ else if (rmMatch !== null) outcome = unfavoriteByIndex(deps, { agent, index: Number(rmMatch[1]) });
131
+ }
132
+ if (outcome === null) {
133
+ return { kind: 'error', text: '用法:/favorite(列表)|/favorite mark [序号](收藏,不带序号=最后一条回复)|/favorite last|/favorite <序号>(看体积)|/favorite <序号> read(读原文)|/favorite rm <序号>(取消)' };
134
+ }
135
+ return success(outcome);
136
+ } catch (error) {
137
+ return failure(error, invocation.signal);
138
+ }
139
+ })(),
140
+ ),
141
+ });
142
+
107
143
  register({
108
144
  name: 'pin',
109
145
  description: '锁定重要消息:/pin 锁定最近一轮|/pin list 按序号挑|/pin clear 全部解锁',
@@ -6,11 +6,13 @@
6
6
  import { betterCompact, cancelBetterCompact, previewBetterCompact } from '../actions/better-compact.js';
7
7
  import { readCompactDiffs } from '../actions/compact-diff.js';
8
8
  import { readDiagnostics } from '../actions/diagnostics.js';
9
+ import { forceArchiveSession } from '../actions/force-archive.js';
9
10
  import { handoff, previewHandoff } from '../actions/handoff.js';
10
11
  import { listHandoffDocs, readHandoffDoc } from '../actions/handoff-docs.js';
11
12
  import { recoverArchivedSession } from '../actions/recover.js';
12
13
  import { readCompactRelayChain } from '../actions/relay-chain.js';
13
14
  import { togglePin } from '../actions/pin.js';
15
+ import { markFavoriteBySeq, ownerAgentOfMessage, toggleFavorite, trialFavoriteScore } from '../actions/favorite.js';
14
16
  import { setNativeAutoCompactionEnabled } from '../actions/native-compaction.js';
15
17
  import { readState, resolveTarget } from '../actions/state.js';
16
18
  import { errorText } from '../core/errors.js';
@@ -423,6 +425,14 @@ export function registerHttp(webCtx, deps) {
423
425
  if (parsed.confirmUnpin !== undefined && typeof parsed.confirmUnpin !== 'boolean') {
424
426
  return send(res, 400, { ok: false, message: 'confirmUnpin 必须是 true/false(true = 确认归档并取消旧会话置顶)' });
425
427
  }
428
+ // 0.5.2 L 项:强制归档的破坏性动作只有用户点过二次确认才允许执行
429
+ // (confirmStop:true = 会停止旧会话上正在跑的 turn/subagent/job/schedule)。
430
+ if (parsed.confirmStop !== undefined && typeof parsed.confirmStop !== 'boolean') {
431
+ return send(res, 400, {
432
+ ok: false,
433
+ message: 'confirmStop 必须是 true/false(true = 确认停止旧会话上的任务后归档)',
434
+ });
435
+ }
426
436
  // 多数动作作用在具体会话上:缺 sessionId 或类型错时绝不能退化成「最后一个 agent」
427
437
  // (2026-10-19 debug:旧实现会压错会话)。
428
438
  // 例外:
@@ -431,13 +441,30 @@ export function registerHttp(webCtx, deps) {
431
441
  // 恒 400「缺少 sessionId」,于是「关闭自动压缩」永远写不进去、也看不到反馈。
432
442
  // - recover 作用在**已归档**的旧会话上,它通常没有活 agent;不能走 resolveTarget
433
443
  // (否则恒 409「没有活动会话」)。sessionId 由 recover 分支自己校验。
444
+ // - force-archive 同样作用在**旧会话**(交接前的那个)上,通常没有活 agent 可解;
445
+ // 它自己的动作层会校验 sessionId 与 confirmStop,不走 resolveTarget。
434
446
  let agent = null;
435
- if (parsed.action !== 'native-auto' && parsed.action !== 'recover') {
447
+ // favorite-toggle 例外:messageId 是宿主 UUID,客户端没带对 sessionId 也能由
448
+ // ownerAgentOfMessage 反查所属会话(2026-10-29 真机 BUG:多会话时点星静默失败)。
449
+ const sessionOptionalAction =
450
+ parsed.action === 'native-auto' ||
451
+ parsed.action === 'recover' ||
452
+ parsed.action === 'force-archive' ||
453
+ parsed.action === 'favorite-toggle' ||
454
+ parsed.action === 'favorite-mark';
455
+ if (!sessionOptionalAction) {
436
456
  if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0) {
437
457
  return send(res, 400, { ok: false, message: '缺少 sessionId' });
438
458
  }
439
459
  agent = resolveTarget(deps, { sessionId: parsed.sessionId });
440
460
  if (agent === null) return send(res, 409, { ok: false, message: '没有活动会话' });
461
+ } else if (
462
+ parsed.action === 'favorite-toggle' &&
463
+ typeof parsed.sessionId === 'string' &&
464
+ parsed.sessionId.length > 0
465
+ ) {
466
+ // 带了 sessionId 就按它取 agent;取不到(会话已关)也继续,后面按 messageId 反查。
467
+ agent = resolveTarget(deps, { sessionId: parsed.sessionId });
441
468
  }
442
469
 
443
470
  if (parsed.action === 'recover') {
@@ -449,6 +476,21 @@ export function registerHttp(webCtx, deps) {
449
476
  return send(res, 200, outcome);
450
477
  }
451
478
 
479
+ if (parsed.action === 'force-archive') {
480
+ // 0.5.2 L 项:用户确认「会停止旧会话上正在跑的任务」后强制归档(唯一 stopActivity 调用点)。
481
+ // 0.5.3 P2-1:这里不再提前 400 —— 已归档 + 没带确认应回 already(动作层先判幂等),
482
+ // 只有「真要归档」的路径才要求 confirmStop:true(动作层给 statusCode 400)。
483
+ if (parsed.confirmStop !== undefined && typeof parsed.confirmStop !== 'boolean') {
484
+ return send(res, 400, { ok: false, message: 'confirmStop 必须是 true/false(true = 确认停止旧会话上的任务后归档)' });
485
+ }
486
+ const outcome = await forceArchiveSession(deps, {
487
+ sessionId: parsed.sessionId,
488
+ confirmStop: parsed.confirmStop,
489
+ confirmUnpin: parsed.confirmUnpin,
490
+ });
491
+ return send(res, 200, outcome);
492
+ }
493
+
452
494
  if (parsed.action === 'preview') {
453
495
  // M6:只算不写——先告诉用户「要压哪段、多少 token」,再让他确认。
454
496
  // A3:返回 planId,confirm 会带着它回来校验「同一段范围」。
@@ -478,6 +520,62 @@ export function registerHttp(webCtx, deps) {
478
520
  const outcome = await betterCompact(deps, { agent, expected });
479
521
  return send(res, 200, { ok: true, message: outcome.message });
480
522
  }
523
+ if (parsed.action === 'favorite-score-trial') {
524
+ // 0.5.3 P1-7:设置页「试算」——对一段文本按当前(可覆盖)配置跑一次打分+判定。
525
+ // 只读:不写收藏、不影响任何状态;没有活会话拿不到模型路由时明确 409。
526
+ if (agent === null) return send(res, 409, { ok: false, message: '没有活动会话,无法试算(先发一条消息唤醒 agent)' });
527
+ const text = typeof parsed.text === 'string' ? parsed.text : '';
528
+ if ([...text.trim()].length === 0) return send(res, 400, { ok: false, message: '缺少要试算的文本' });
529
+ const outcome = await trialFavoriteScore(deps, {
530
+ agent,
531
+ text,
532
+ level: parsed.level,
533
+ mustWords: parsed.mustWords,
534
+ muteWords: parsed.muteWords,
535
+ });
536
+ return send(res, 200, { ok: true, ...outcome });
537
+ }
538
+ if (parsed.action === 'favorite-mark') {
539
+ // 0.5.3:标记层解耦——按 sessionId+seq 收藏,**不需要活会话**(归档/关闭的会话也能标)。
540
+ // 只写一行 JSONL;原文等压缩时落档,不读会话日志、不碰压缩。
541
+ if (parsed.active !== undefined && typeof parsed.active !== 'boolean') {
542
+ return send(res, 400, { ok: false, message: 'active 必须是 true/false(省略=切换)' });
543
+ }
544
+ try {
545
+ const outcome = await markFavoriteBySeq(deps, {
546
+ sessionId: parsed.sessionId,
547
+ seq: Number(parsed.seq),
548
+ active: parsed.active,
549
+ });
550
+ return send(res, 200, { ok: true, ...outcome });
551
+ } catch (error) {
552
+ const code = error !== null && typeof error === 'object' ? error.code : undefined;
553
+ return send(res, code === 'favorite' ? 400 : 500, {
554
+ ok: false,
555
+ ...(code === undefined ? {} : { code }),
556
+ message: errorText(error),
557
+ });
558
+ }
559
+ }
560
+ if (parsed.action === 'favorite-toggle') {
561
+ // 0.6:助手消息底部星标——按宿主 messageId 收藏/取消。
562
+ // 客户端 sessionId 可能缺失或过期:messageId 唯一,按它反查所属活会话。
563
+ const messageId =
564
+ typeof parsed.messageId === 'string' && parsed.messageId.length > 0 ? parsed.messageId : null;
565
+ if (messageId === null) return send(res, 400, { ok: false, message: '缺少 messageId' });
566
+ try {
567
+ const actor = agent ?? ownerAgentOfMessage(deps, messageId);
568
+ const outcome = toggleFavorite(deps, { agent: actor, messageId });
569
+ return send(res, 200, { ok: true, ...outcome });
570
+ } catch (error) {
571
+ const code = error !== null && typeof error === 'object' ? error.code : undefined;
572
+ return send(res, code === 'favorite' ? 400 : 500, {
573
+ ok: false,
574
+ ...(code === undefined ? {} : { code }),
575
+ message: errorText(error),
576
+ });
577
+ }
578
+ }
481
579
  if (parsed.action === 'pin-toggle') {
482
580
  // M6:菜单「锁定重要消息 / 解锁消息」——有锁定就清空,没有就锁最近一轮。
483
581
  const outcome = togglePin(deps, { agent });
@@ -541,6 +639,25 @@ export function registerHttp(webCtx, deps) {
541
639
  if (code === 'busy') {
542
640
  return send(res, 409, { ok: false, code, message: errorText(error) });
543
641
  }
642
+ // 0.5.2 L 项:强制归档失败要把**具体原因**带回界面(不静默、不吞成通用文案)。
643
+ // 0.5.3 P2-2:状态码由动作层标注(未知会话 404 / 宿主缺接缝 503 / 内部错 500),
644
+ // 不再一律 400。
645
+ if (code === 'force-archive') {
646
+ const status = Number.isInteger(error?.statusCode) ? error.statusCode : 400;
647
+ return send(res, status, {
648
+ ok: false,
649
+ code,
650
+ message: error instanceof Error ? error.message : String(error),
651
+ });
652
+ }
653
+ // 0.5.3 P2-2:recover 等动作层显式标注的状态码(404/503/500)直接映射。
654
+ if (Number.isInteger(error?.statusCode) && error.statusCode >= 400) {
655
+ return send(res, error.statusCode, {
656
+ ok: false,
657
+ ...(code === undefined ? {} : { code }),
658
+ message: error instanceof Error ? error.message : String(error),
659
+ });
660
+ }
544
661
  if (error?.statusCode === 413) {
545
662
  return send(res, 413, { ok: false, message: '请求体太大' });
546
663
  }