dsh-compact-relay 0.5.0 → 0.5.1

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,92 @@
1
+ /**
2
+ * 文本截断的两个共享口径(0.5.1 D03)—— 消掉散在 6 处的「切一刀加省略号」。
3
+ *
4
+ * 为什么只有两个函数:评审把现有截断按**单位**归成三类 ——
5
+ * 1. UTF-16 码元(`String.prototype.slice`):0.4/0.5 的既有实现,逐字节兼容用;
6
+ * 2. 码点(`Array.from`):0.6 新写的两份逻辑相同「不切坏 emoji」,收在这里;
7
+ * 3. UTF-8 字节:那是宿主 `session-title` 的 `maxTitleBytes` 契约克隆
8
+ * (`core/relay.js:truncateTitleUtf8`),语义是"按宿主预算缩标题",**不是**
9
+ * 展示层截断,按评审结论不并进来(0.5.2 S3 再迁 `dsh/title-budget.js`)。
10
+ *
11
+ * 省略号预算必须显式,因为现网有两种既有输出:
12
+ * - 'inside'(默认):省略号算进上限,结果长度 ≤ max —— 旧 `slice(0, max-1) + '…'`;
13
+ * - 'append':省略号额外多 1 字符 —— `dsh/compaction-log` 的预览既有输出(201 字符
14
+ * 上限),`previewTruncated` 依赖它,合并时**必须保留**;
15
+ * - false:不补省略号。
16
+ *
17
+ * 纯函数、零依赖(可被 client bundle 内联)。非字符串一律返回 ''(调用方本来都先做
18
+ * 了 typeof 判断,这里只是兜底不抛错);max 非法(undefined/NaN)时原样返回 —— 不猜预算。
19
+ */
20
+
21
+ /** 省略号:与本项目其它文案一致,用单个 U+2026(不是三个点)。 */
22
+ const ELLIPSIS = '…';
23
+
24
+ /**
25
+ * 归一化上限:非法 → null(调用方原样返回);小数向下取整;负数按 0 处理。
26
+ * @param {unknown} max
27
+ * @returns {number|null}
28
+ */
29
+ function limitOf(max) {
30
+ if (typeof max !== 'number' || !Number.isFinite(max)) return null;
31
+ return Math.max(0, Math.trunc(max));
32
+ }
33
+
34
+ /**
35
+ * 按预算切一段 + 省略号(长度/切片方式由调用方给:码点数组或码元字符串)。
36
+ * @param {string} text 原文本
37
+ * @param {number} limit 归一化后的上限
38
+ * @param {'inside'|'append'|false} ellipsis 省略号预算
39
+ * @param {(text:string)=>number} lengthOf 按目标单位算长度
40
+ * @param {(text:string, count:number)=>string} slice 按目标单位取前缀
41
+ * @returns {string}
42
+ */
43
+ function cut(text, limit, ellipsis, lengthOf, slice) {
44
+ const length = lengthOf(text);
45
+ if (length <= limit) return text;
46
+ if (ellipsis === 'append') return `${slice(text, limit)}${ELLIPSIS}`;
47
+ if (ellipsis === false) return slice(text, limit);
48
+ // 'inside'(默认):省略号占一个单位;limit=0 时只剩省略号(与旧实现一致)。
49
+ return `${slice(text, Math.max(0, limit - 1))}${ELLIPSIS}`;
50
+ }
51
+
52
+ /**
53
+ * 按**码点**截断(emoji / 代理对不会被切半)。展示层优先用它。
54
+ * @param {string} text
55
+ * @param {number} max 上限(按码点计)
56
+ * @param {{ellipsis?:'inside'|'append'|false}} [options]
57
+ * @returns {string}
58
+ */
59
+ export function clipChars(text, max, { ellipsis = 'inside' } = {}) {
60
+ if (typeof text !== 'string') return '';
61
+ const limit = limitOf(max);
62
+ if (limit === null || text.length === 0) return text;
63
+ const charsOf = (value) => Array.from(value);
64
+ return cut(
65
+ text,
66
+ limit,
67
+ ellipsis,
68
+ (value) => charsOf(value).length,
69
+ (value, count) => charsOf(value).slice(0, count).join(''),
70
+ );
71
+ }
72
+
73
+ /**
74
+ * 按**UTF-16 码元**截断(`String.prototype.slice`,0.4/0.5 的既有行为逐字节兼容)。
75
+ * 注意:代理对(emoji)可能被切在中间 —— 需要「不切半」时用 clipChars。
76
+ * @param {string} text
77
+ * @param {number} max 上限(按码元计,即 `String.length`)
78
+ * @param {{ellipsis?:'inside'|'append'|false}} [options]
79
+ * @returns {string}
80
+ */
81
+ export function clipCodeUnits(text, max, { ellipsis = 'inside' } = {}) {
82
+ if (typeof text !== 'string') return '';
83
+ const limit = limitOf(max);
84
+ if (limit === null || text.length === 0) return text;
85
+ return cut(
86
+ text,
87
+ limit,
88
+ ellipsis,
89
+ (value) => value.length,
90
+ (value, count) => value.slice(0, count),
91
+ );
92
+ }
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * 命令、HTTP、工具三个入口都用这里的文案,不各写一套。
5
5
  */
6
+ import { clipCodeUnits } from './clip.js';
6
7
 
7
8
  /** 已知失败类别 → 一句话。 */
8
9
  const MESSAGES = Object.freeze({
@@ -46,7 +47,8 @@ export function errorText(error) {
46
47
  // 只对 summary 附带原因(其它 code 的 message 是内部措辞);截断防止把整段
47
48
  // 模型输出/校验清单倒给用户,原因本身也是中文可读的。
48
49
  if (code === 'summary' && error instanceof Error && typeof error.message === 'string' && error.message.length > 0) {
49
- const detail = error.message.length > 140 ? `${error.message.slice(0, 139)}…` : error.message;
50
+ // 0.5.1 D03:截断收口到 core/clip(原来是手写 slice(0,139)+'…')。
51
+ const detail = clipCodeUnits(error.message, 140);
50
52
  return `${base}原因:${detail}`;
51
53
  }
52
54
  return base;
@@ -23,6 +23,33 @@
23
23
  /** compaction-basic 的 headroomTokens 默认值(源码:?? 65536)。 */
24
24
  export const NATIVE_HEADROOM_DEFAULT = 65536;
25
25
 
26
+ /**
27
+ * compaction-basic 的 retainRatio 默认值(0.2.1-rc.3 源码:DEFAULT_RETAIN_RATIO = .16)。
28
+ *
29
+ * 只用于「预判原生压缩范围」:宿主没有把生效默认值暴露给插件,这里跟随
30
+ * 0.2.1-rc.3 的源码常量。用户显式配置 retainRatio/retainTokens 时以配置为准。
31
+ */
32
+ export const NATIVE_RETAIN_RATIO_DEFAULT = 0.16;
33
+
34
+ /**
35
+ * 原生压缩的「保留近期尾部」预算(compaction-basic 的 retainTokens):
36
+ * 显式 retainTokens → 直接用;
37
+ * 否则 floor(messageBudgetTokens × retainRatio),messageBudget = 窗口 − 输出预留。
38
+ * 与宿主 resolveCompactSpec 同口径。
39
+ *
40
+ * @param {{contextWindow:number|null, reservedCompletionTokens?:number, retainRatio?:number, retainTokens?:number}} input
41
+ * @returns {number|null} 算不出返回 null
42
+ */
43
+ export function nativeRetainTokensOf({ contextWindow, reservedCompletionTokens = 0, retainRatio, retainTokens } = {}) {
44
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return null;
45
+ if (Number.isInteger(retainTokens) && retainTokens >= 0) return retainTokens;
46
+ const reserved = Number.isFinite(reservedCompletionTokens) && reservedCompletionTokens >= 0 ? reservedCompletionTokens : 0;
47
+ const budget = contextWindow - reserved;
48
+ if (budget <= 0) return null;
49
+ const ratio = Number.isFinite(retainRatio) && retainRatio >= 0 && retainRatio < 1 ? retainRatio : NATIVE_RETAIN_RATIO_DEFAULT;
50
+ return Math.floor(budget * ratio);
51
+ }
52
+
26
53
  /**
27
54
  * 计算真实触发线。
28
55
  *
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 0.5.1 批 2:预判「下一次 DSH 原生自动压缩会覆盖哪些锁定消息」—— 纯函数,零依赖。
3
+ *
4
+ * 宿主 `@deepseek-ai/dsh-compaction-basic` 的选段(0.2.1-rc.3 源码):
5
+ *
6
+ * const firstIdx = systemHead(session, surface[0]) === undefined ? 0 : 1;
7
+ * let accumulated = 0, keepFromIdx = nodes.length;
8
+ * for (let i = nodes.length - 1; i >= 0; i--) {
9
+ * accumulated += nodes[i].tokens;
10
+ * keepFromIdx = i;
11
+ * if (accumulated >= retainTokens) break;
12
+ * }
13
+ * if (keepFromIdx <= firstIdx) return null;
14
+ * while (keepFromIdx > firstIdx && !toolPairingBalancedBefore(session, surface[keepFromIdx])) keepFromIdx -= 1;
15
+ * if (keepFromIdx <= firstIdx) return null;
16
+ * return { start: surface[firstIdx], end: surface[keepFromIdx - 1] };
17
+ *
18
+ * 本模块只复刻「选段」这一段,不执行任何压缩;配对平衡用调用方传入的
19
+ * `engine.toolPairingBalancedBefore`,与宿主同口径(缺回调时跳过该步,宁少报不多报)。
20
+ *
21
+ * 触发条件(由 actions/state 判断):原生自动压缩开启、当前压力 ≥ 真实触发线。
22
+ * 宿主在 step boundary 才真正压;用户看到预警时还有机会先 /handoff 或解锁。
23
+ *
24
+ * @module core/native-pin-risk
25
+ */
26
+ import { tokenOf } from './tokens.js';
27
+
28
+ function isSeq(value) {
29
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0;
30
+ }
31
+
32
+ function normalizeNodes(nodes) {
33
+ return Array.isArray(nodes)
34
+ ? nodes.filter((node) => node !== null && typeof node === 'object' && isSeq(node.seq))
35
+ : [];
36
+ }
37
+
38
+ /**
39
+ * 复刻宿主的保留尾部 + 配对平衡选段,返回会被压缩的 seq 范围。
40
+ *
41
+ * @param {object} input
42
+ * @param {number[]} input.pinnedSeqs 当前仍 live 的锁定 seq
43
+ * @param {{seq:number, tokens?:number, heuristicTokens?:number}[]} input.nodes 当前 surface 节点(含 token 定价)
44
+ * @param {number} [input.firstIdx] 第一个允许被压的下标(system head 存在时为 1)
45
+ * @param {number} [input.retainTokens] 原生保留尾部预算(compaction-basic 的 retainTokens)
46
+ * @param {(seq:number)=>boolean} [input.isBalancedBefore] 该 seq 之前切一刀是否工具配对平衡
47
+ * @returns {{count:number, seqs:number[], startSeq:number, endSeq:number}|null}
48
+ * null = 没有可压范围 / 没有 pin(无需预警)
49
+ */
50
+ export function nativePinnedRiskOf({ pinnedSeqs, nodes, firstIdx = 0, retainTokens = 0, isBalancedBefore } = {}) {
51
+ const pinned = Array.isArray(pinnedSeqs) ? pinnedSeqs.filter(isSeq) : [];
52
+ if (pinned.length === 0) return null;
53
+ const list = normalizeNodes(nodes);
54
+ if (list.length === 0) return null;
55
+
56
+ const startIdx = Math.max(0, Math.min(Number.isInteger(firstIdx) ? firstIdx : 0, list.length));
57
+ if (startIdx >= list.length) return null;
58
+ const budget = Number.isFinite(retainTokens) && retainTokens > 0 ? retainTokens : 0;
59
+
60
+ // 与宿主逐字同序:永远至少保留最后一条(accumulated 在第一轮就 >= 0)。
61
+ let accumulated = 0;
62
+ let keepFromIdx = list.length;
63
+ for (let index = list.length - 1; index >= 0; index -= 1) {
64
+ accumulated += tokenOf(list[index]);
65
+ keepFromIdx = index;
66
+ if (accumulated >= budget) break;
67
+ }
68
+ if (keepFromIdx <= startIdx) return null;
69
+
70
+ if (typeof isBalancedBefore === 'function') {
71
+ let rewind = 0;
72
+ while (keepFromIdx > startIdx && rewind < list.length) {
73
+ const seq = list[keepFromIdx].seq;
74
+ let balanced = false;
75
+ try {
76
+ balanced = isBalancedBefore(seq) === true;
77
+ } catch {
78
+ balanced = true; // 判定失败时按「可切」处理:宁少报(不破坏配对)也不多报
79
+ }
80
+ if (balanced) break;
81
+ keepFromIdx -= 1;
82
+ rewind += 1;
83
+ }
84
+ }
85
+ if (keepFromIdx <= startIdx) return null;
86
+
87
+ const rangeStart = list[startIdx];
88
+ const rangeEnd = list[keepFromIdx - 1];
89
+ const inRange = (seq) => {
90
+ // surface 顺序不一定按 seq 单调(压缩后 checkpoint 插在旧尾部前),按成员判定。
91
+ for (let index = startIdx; index < keepFromIdx; index += 1) {
92
+ if (list[index].seq === seq) return true;
93
+ }
94
+ return false;
95
+ };
96
+ const seqs = pinned.filter(inRange);
97
+ if (seqs.length === 0) return null;
98
+ return {
99
+ count: seqs.length,
100
+ seqs,
101
+ startSeq: rangeStart.seq,
102
+ endSeq: rangeEnd.seq,
103
+ };
104
+ }
105
+
106
+ export default nativePinnedRiskOf;
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * 只做展示:摘要截断到一句(默认 60 字符,不贴原文全文),不读全量历史、不调模型。
10
10
  */
11
+ import { clipCodeUnits } from './clip.js';
11
12
 
12
13
  /** 事件类型 → 中文角色标签(唯一来源)。 */
13
14
  export const ROLE_LABELS = Object.freeze({
@@ -58,7 +59,8 @@ export function messageSnippetOf(event, maxChars = 60) {
58
59
  const text = messageTextOf(event);
59
60
  if (text.length === 0) return '';
60
61
  const limit = Number.isInteger(maxChars) && maxChars > 1 ? maxChars : 60;
61
- return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
62
+ // 0.5.1 D03:截断收口到 core/clip(码元口径 = 旧行为逐字节兼容;emoji 切半问题随 S1 换 clipChars)。
63
+ return clipCodeUnits(text, limit);
62
64
  }
63
65
 
64
66
  /**
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 0.5.1 批 2:pin 失效判定(C 方案)—— 纯函数,零依赖。
3
+ *
4
+ * 背景:DSH 原生自动压缩(compaction-basic)按自己的保留尾部策略选段,
5
+ * 不认本插件的 pin;被它压掉的锁定消息会从 surface 消失。过去的实现用
6
+ * `livePinnedSeqs` 把「快照里有、surface 上没有」的 pin 直接过滤掉 —— 界面上
7
+ * 这些 pin 就**凭空消失**了,用户以为锁还生效,其实原文已经被压走。
8
+ *
9
+ * C 方案:不假装还在(不画到环上、不算进 live 计数),也不消失 ——
10
+ * 用 `pins.stale = { count, seqs }` 在悬停卡/菜单里明确写「已失效」。
11
+ *
12
+ * 失效原因只读日志判定(不猜):
13
+ * - 找到覆盖该 seq 的 `user/message(replace)` 事件;
14
+ * - 顺着它的 `sourceEventSeqs[0]` 找 `compaction/start`;
15
+ * - 原生自动压缩的 start 带 `turn`(非 null,宿主 compaction-basic 的
16
+ * automatic pressure 路径 `owner = entryState.openTurn`);
17
+ * - 其它压缩(本插件 /bc、宿主 /compact、compactNow)的 start `turn: null`,
18
+ * 不论有没有 sourceCommandId 都归到 other(可以确定「被压缩带走」,只是不冒充原生);
19
+ * - 没有 replace 证据(撤销/分支/历史被改写)→ unknown,不冒充压缩。
20
+ *
21
+ * @module core/pin-health
22
+ */
23
+ import { normalizePinnedSeqs } from './pins.js';
24
+
25
+ /** 失效原因枚举(mixed = 多条 pin 的原因不一致)。 */
26
+ export const PIN_STALE_CAUSES = Object.freeze(['native', 'other', 'unknown']);
27
+
28
+ function isSeq(value) {
29
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0;
30
+ }
31
+
32
+ /**
33
+ * 扫描日志:被 replace 覆盖的 seq → replace 事件;compaction/start seq → data。
34
+ * @param {object[]} log
35
+ * @returns {{replaceByCoveredSeq:Map<number,object>, startBySeq:Map<number,object>}}
36
+ */
37
+ function scanCompactionEvidence(log) {
38
+ const replaceByCoveredSeq = new Map();
39
+ const startBySeq = new Map();
40
+ for (const event of Array.isArray(log) ? log : []) {
41
+ if (event === null || typeof event !== 'object') continue;
42
+ if (event.type === 'compaction/start' && Number.isInteger(event.seq)) {
43
+ startBySeq.set(event.seq, event.data);
44
+ continue;
45
+ }
46
+ if (event.type !== 'user/message' || !Array.isArray(event.sourceEventSeqs)) continue;
47
+ for (const seq of event.sourceEventSeqs) {
48
+ if (typeof seq === 'number' && Number.isFinite(seq)) replaceByCoveredSeq.set(seq, event);
49
+ }
50
+ }
51
+ return { replaceByCoveredSeq, startBySeq };
52
+ }
53
+
54
+ /**
55
+ * 一条失效 pin 的归因。
56
+ * @param {number} seq
57
+ * @param {{replaceByCoveredSeq:Map<number,object>, startBySeq:Map<number,object>}} evidence
58
+ * @returns {'native'|'other'|'unknown'}
59
+ */
60
+ function staleCauseOf(seq, evidence) {
61
+ const replace = evidence.replaceByCoveredSeq.get(seq);
62
+ if (replace === undefined) return 'unknown';
63
+ const startSeq = Array.isArray(replace.sourceEventSeqs) ? replace.sourceEventSeqs[0] : undefined;
64
+ const start = Number.isInteger(startSeq) ? evidence.startBySeq.get(startSeq) : undefined;
65
+ if (start === undefined || start === null || typeof start !== 'object') return 'unknown';
66
+ // 原生自动压缩:宿主 compaction-basic 把事件包在 turn 里(turn = openTurn 的 seq)。
67
+ if (typeof start.turn === 'number' && Number.isInteger(start.turn)) return 'native';
68
+ // 其它压缩:turn=null(插件菜单动作 / 宿主手动压缩 / compactNow)。
69
+ // 有 start 证据就说明「被压缩带走」,不因为缺 sourceCommandId 退化成 unknown。
70
+ return 'other';
71
+ }
72
+
73
+ /**
74
+ * 计算 pin 快照相对当前 surface 的健康状态。
75
+ *
76
+ * @param {object} input
77
+ * @param {number[]} input.pinnedSeqs pin 快照集合(latestPinnedSeqs(日志))
78
+ * @param {number[]} input.surfaceSeqs 当前 surface 的 seq(升序)
79
+ * @param {object[]} [input.log] 会话事件日志(用于归因;缺失时全部算 unknown)
80
+ * @returns {{live:number[], stale:number[], cause:'native'|'other'|'unknown'|'mixed'|null, causes:{native:number,other:number,unknown:number}}}
81
+ */
82
+ export function pinHealthOf({ pinnedSeqs, surfaceSeqs, log } = {}) {
83
+ const pinned = normalizePinnedSeqs(pinnedSeqs);
84
+ const liveSet = new Set((Array.isArray(surfaceSeqs) ? surfaceSeqs : []).filter(isSeq));
85
+ const live = pinned.filter((seq) => liveSet.has(seq));
86
+ const stale = pinned.filter((seq) => !liveSet.has(seq));
87
+ const causes = { native: 0, other: 0, unknown: 0 };
88
+ if (stale.length === 0) return { live, stale, cause: null, causes };
89
+
90
+ const evidence = scanCompactionEvidence(log);
91
+ for (const seq of stale) causes[staleCauseOf(seq, evidence)] += 1;
92
+
93
+ const present = PIN_STALE_CAUSES.filter((cause) => causes[cause] > 0);
94
+ const cause = present.length === 1 ? present[0] : 'mixed';
95
+ return { live, stale, cause, causes };
96
+ }
97
+
98
+ /**
99
+ * 失效原因的展示文案(宿主与客户端共用一份口径;客户端只传 counts)。
100
+ *
101
+ * @param {{native?:number, other?:number, unknown?:number}} [causes]
102
+ * @returns {string}
103
+ */
104
+ export function staleCauseTextOf(causes) {
105
+ const native = Number.isInteger(causes?.native) ? causes.native : 0;
106
+ const other = Number.isInteger(causes?.other) ? causes.other : 0;
107
+ const unknown = Number.isInteger(causes?.unknown) ? causes.unknown : 0;
108
+ if (native > 0 && other === 0 && unknown === 0) return '随原生压缩移除';
109
+ if (native === 0 && other > 0 && unknown === 0) return '随压缩移除';
110
+ if (native === 0 && other === 0 && unknown > 0) return '随历史变化失效';
111
+ return '随压缩或历史变化失效';
112
+ }
@@ -4,13 +4,16 @@
4
4
  * 数据本来就在日志里:compaction/start → summary → user/message(replace) → end。
5
5
  * 这里只配对、不写任何东西;面板/接口用 `collectCompactions()`。
6
6
  *
7
- * 「已提交」的判定(和 session-view.countSummaries 同口径):
8
- * 1. 有覆盖该 summary 的 replace(replace 的 sourceEventSeqs 含 summary.seq);
9
- * 2. 有同 compactionId 的 end,且 end 没有 error;
10
- * 3. 没有 compactionId 的旧日志按旧口径(有 replace 即算)。
7
+ * 「已提交」的判定与 `dsh/session-view.countSummaries` **同一份实现**
8
+ * (`compaction-pair.isCommittedCompaction`,0.5.1 D01 合并):
9
+ * 1. 必须有覆盖该 summary 的 replace(replace 的 sourceEventSeqs 含 summary.seq)——
10
+ * 没有 compactionId 的旧日志也一样;
11
+ * 2. 有 compactionId 时,还要求同 id 的 end 存在且没有 error。
11
12
  */
12
13
  import { sessionLog } from './session-view.js';
13
- import { scanCompactionLog } from './compaction-pair.js';
14
+ import { isCommittedCompaction, scanCompactionLog } from './compaction-pair.js';
15
+
16
+ import { clipCodeUnits } from '../core/clip.js';
14
17
 
15
18
  const DEFAULT_LIMIT = 10;
16
19
  const MAX_LIMIT = 50;
@@ -41,10 +44,13 @@ function flatten(value) {
41
44
  return text.replace(/\s+/gu, ' ').trim();
42
45
  }
43
46
 
44
- /** 摘要正文预览(超长截断,保留省略号)。 */
47
+ /** 摘要正文预览(超长截断,保留省略号)。
48
+ *
49
+ * 0.5.1 D03:收口到 core/clip;`ellipsis:'append'` 显式保留「最多 200 字 + 省略号(多 1 字符)」
50
+ * 的既有输出(`previewTruncated` 与 diff 端点都依赖它,合并时不能改成 200)。
51
+ */
45
52
  function previewOf(value, max = PREVIEW_CHARS) {
46
- const text = flatten(value);
47
- return text.length <= max ? text : `${text.slice(0, max)}…`;
53
+ return clipCodeUnits(flatten(value), max, { ellipsis: 'append' });
48
54
  }
49
55
 
50
56
  /** 日志事件里可用的时间戳(不同版本字段可能不同)。 */
@@ -105,7 +111,8 @@ export function collectCompactions(session, options = {}) {
105
111
  seq: typeof summary.seq === 'number' ? summary.seq : null,
106
112
  compactionId,
107
113
  at: atOf(summary),
108
- committed: replace !== undefined && (compactionId === null ? true : end !== undefined && end.error === undefined),
114
+ // 0.5.1 D01:与 countSummaries 共用唯一判定(无 id 也要求 replace)。
115
+ committed: isCommittedCompaction({ compactionId, end, replace }),
109
116
  open: compactionId !== null && end === undefined,
110
117
  error: end?.error ?? null,
111
118
  replaceSeq: typeof replace?.seq === 'number' ? replace.seq : null,
@@ -8,7 +8,7 @@
8
8
  * 以前两处各自 for 一遍日志、各自建 summaries / ends / replace 索引,键名和过滤
9
9
  * 条件还不完全一样(countSummaries 用 replacedSeqs Set,compaction-log 用
10
10
  * replaceByCoveredSeq Map)——只要一处改配对规则,另一处就静默漂移。
11
- * 现在扫描只在这里做一次,两个消费方各自保留自己的「已提交」判定。
11
+ * 现在扫描与「已提交」判定都只在这里做一次(0.5.1 D01:判定也合并)。
12
12
  *
13
13
  * 只读:不写日志、不碰 session 内部结构。
14
14
  */
@@ -46,3 +46,29 @@ export function scanCompactionLog(log) {
46
46
 
47
47
  return { summaries, ends, replaceByCoveredSeq };
48
48
  }
49
+
50
+ /**
51
+ * 一次压缩是否「已提交」(D01:唯一口径)。
52
+ *
53
+ * 0.5.1 之前两个消费方各写一遍,而且在**没有 `compactionId` 的旧日志**上答案不同:
54
+ * - `dsh/compaction-log.collectCompactions`:所有 summary 都要求 replace;
55
+ * - `dsh/session-view.countSummaries`:没有 id 的 summary 直接 +1,不看 replace。
56
+ * 同一份日志于是出现「countSummaries=1 / committed=false」的幽灵压缩。现在统一按
57
+ * 严格版:**没有 id 也必须被 replace 覆盖**(没有 id 无法配对 end,只能要求 replace)。
58
+ *
59
+ * 有 id 时还要求同 id 的 end 存在且没有 error(失败事务的 summary 是幽灵摘要,不算)。
60
+ *
61
+ * @param {{compactionId?:string|null, end?:object, replace?:object|null}} input
62
+ * compactionId:summary.data.compactionId(旧日志没有 → null/undefined)
63
+ * end:同 id 的 compaction/end data(没有 → undefined)
64
+ * replace:覆盖 summary.seq 的 user/message(replace) 事件(没有 → undefined)
65
+ * @returns {boolean}
66
+ */
67
+ export function isCommittedCompaction({ compactionId, end, replace } = {}) {
68
+ // 1) 无论有没有 compactionId,都必须有一条覆盖该 summary 的 replace。
69
+ if (replace === undefined || replace === null) return false;
70
+ // 2) 旧日志没有 id:无法配对 end,replace 即算已提交(0.4 兼容口径)。
71
+ if (typeof compactionId !== 'string' || compactionId.length === 0) return true;
72
+ // 3) 新日志:还要求 end 存在且不是失败 end。
73
+ return end !== undefined && end !== null && end.error === undefined;
74
+ }
@@ -11,6 +11,8 @@
11
11
  * 单例在 lib/index.js 装配到 `deps.diag`;actions 通过 `deps.diag.*` 埋点。
12
12
  */
13
13
 
14
+ import { clipCodeUnits } from '../core/clip.js';
15
+
14
16
  /** 耗时样本上限(最近 20 次)。 */
15
17
  export const LATENCY_SAMPLE_LIMIT = 20;
16
18
  /** 错误码条目上限(按 code+phase 聚合后的条数)。 */
@@ -51,7 +53,8 @@ function normalizeToken(value, maxChars) {
51
53
  if (typeof value !== 'string') return null;
52
54
  const trimmed = value.replace(/\s+/gu, ' ').trim();
53
55
  if (trimmed.length === 0) return null;
54
- return trimmed.length > maxChars ? `${trimmed.slice(0, Math.max(0, maxChars - 1))}…` : trimmed;
56
+ // 0.5.1 D03:截断收口到 core/clip(码元口径,行为不变)。
57
+ return clipCodeUnits(trimmed, maxChars);
55
58
  }
56
59
 
57
60
  /**
@@ -13,6 +13,7 @@
13
13
  * 三道清洗里只有「正则兜底」会改内容,且只把命中串替换成 `[REDACTED]`、只把数量写进
14
14
  * `json.redactions`(可选字段,不保留原文)。
15
15
  */
16
+ import { clipCodeUnits } from '../core/clip.js';
16
17
  import { localIso } from './diag-counters.js';
17
18
 
18
19
  /** 产物 schema 版本(客户端/issue 模板以此识别格式)。 */
@@ -81,7 +82,8 @@ function pickText(value, maxChars) {
81
82
  if (typeof value !== 'string') return null;
82
83
  const trimmed = value.replace(/\s+/gu, ' ').trim();
83
84
  if (trimmed.length === 0) return null;
84
- return trimmed.length > maxChars ? `${trimmed.slice(0, Math.max(0, maxChars - 1))}…` : trimmed;
85
+ // 0.5.1 D03:截断收口到 core/clip(码元口径,行为不变)。
86
+ return clipCodeUnits(trimmed, maxChars);
85
87
  }
86
88
 
87
89
  function pickInt(value) {
@@ -151,6 +151,41 @@ export async function archiveHandoffSource(ctx, sessionId, { log, attempts = 2,
151
151
  return failed(isActiveError(lastError) ? 'active' : 'failed', activityKindsOf(lastError));
152
152
  }
153
153
 
154
+ /**
155
+ * 宿主侧栏置顶集合(0.5.1 批 2)。
156
+ *
157
+ * 宿主实证(`@deepseek-ai/dsh-workspace/lib/index.js`):`WorkspaceRegistry`
158
+ * - `get pinnedSessionIds()`:registry 全局置顶集合,pin 顺序(最近置顶在前);
159
+ * - `pinSession(id)` / `unpinSession(id)`;
160
+ * - 置顶与归档互斥:`archiveSession` 在**同一次写盘**里把该 id 从 pinnedSessionIds 移除
161
+ * (源码注释「Archiving drops the session's pin in the same durable write」)。
162
+ * 所以「归档会取消置顶」不是本插件的推测,是宿主真实行为;这里只读判断,
163
+ * 不替用户 unpin/pin。拿不到/形状异常 → null(不猜 false)。
164
+ * @param {object} ctx
165
+ * @returns {string[]|null}
166
+ */
167
+ export function pinnedSessionIdsOf(ctx) {
168
+ try {
169
+ const registry = ctx?.get?.('workspaceRegistry');
170
+ const ids = registry?.pinnedSessionIds;
171
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : null;
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * 旧会话当前是否在侧栏置顶。
179
+ * @param {object} ctx
180
+ * @param {string} sessionId
181
+ * @returns {boolean|null} null = 宿主没提供可读的置顶集合
182
+ */
183
+ export function isSessionPinned(ctx, sessionId) {
184
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
185
+ const ids = pinnedSessionIdsOf(ctx);
186
+ return ids === null ? null : ids.includes(sessionId);
187
+ }
188
+
154
189
  /**
155
190
  * 归档结果 → 交接完成 message 里的一行(成功/失败都不丢信息)。
156
191
  * 成功时明确告诉用户恢复入口;失败时固定写「归档失败,不影响交接」。
@@ -160,11 +195,22 @@ export async function archiveHandoffSource(ctx, sessionId, { log, attempts = 2,
160
195
  export function handoffArchiveNoteOf(result) {
161
196
  const status = result !== null && typeof result === 'object' ? result.status : 'failed';
162
197
  if (status === 'archived') {
163
- return '旧会话已归档(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回;侧栏视图选「全部会话」也能取消归档)。';
198
+ // 0.5.1 DCR-04:指向宿主真实的视图标签/动作文案(实测 dsh-client-ui-workspace:
199
+ // viewOptions 三选一里是「全部对话(显示已归档)」,会话行动作是「取消归档」;
200
+ // 宿主没有「全部会话」这个视图)。
201
+ return '旧会话已归档(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回;侧栏视图选「全部对话(显示已归档)」,再对该会话点「取消归档」)。';
164
202
  }
165
203
  if (status === 'already') {
166
204
  return '旧会话此前已在归档中(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回)。';
167
205
  }
206
+ // 0.5.1 批 2:用户显式关掉归档(handoff archive:false)。
207
+ if (status === 'skipped') {
208
+ return '按你的选择,旧会话未归档(仍在列表里;侧栏置顶保持不变)。';
209
+ }
210
+ // 0.5.1 批 2:旧会话在侧栏置顶,未经确认不归档(宿主归档会同时取消置顶)。
211
+ if (status === 'pinned') {
212
+ return '旧会话已置顶,本次未自动归档(归档会取消置顶;如要归档,可在交接确认里选「交接并归档(取消置顶)」,或先取消置顶)。';
213
+ }
168
214
  const kinds = Array.isArray(result?.activityKinds) && result.activityKinds.length > 0 ? result.activityKinds.join('/') : null;
169
215
  if (status === 'active') {
170
216
  return `旧会话归档失败,不影响交接(仍有任务在跑:${kinds ?? 'active'};不自动停止任务,旧会话仍在列表里)。`;