dsh-compact-relay 0.4.2 → 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
+ }
package/lib/core/relay.js CHANGED
@@ -8,6 +8,45 @@
8
8
  /** 会话日志里记录 relay 的类型(与压缩判断记录/pin 的 plugin record 同前缀)。 */
9
9
  export const RELAY_RECORD_TYPE = 'plugin:compact-relay/relay';
10
10
 
11
+ /** 历史标题前缀(0.5.0 命名反转:历史 N:<原名>)。 */
12
+ export const HISTORY_PREFIX = '历史';
13
+
14
+ /**
15
+ * 会话标题的 UTF-8 字节预算 —— 与 DSH `session-title` 的默认 `maxTitleBytes` 一致。
16
+ *
17
+ * 宿主 `sessionTitle.rename()` 会按这个预算把标题从尾部截断;前缀也算预算。
18
+ * 插件先自己按同一口径缩 base,保证「历史 N:」前缀不会被宿主的尾部截断吃掉。
19
+ */
20
+ export const MAX_TITLE_BYTES = 80;
21
+
22
+ /** 「历史 N:」标题的正则;空白/半角冒号的容忍度与下面的「交接第 N 次:」一致。 */
23
+ const HISTORY_TITLE_RE = new RegExp(`^${HISTORY_PREFIX}\\s*(\\d+)\\s*[::]\\s*([\\s\\S]*)$`);
24
+
25
+ /**
26
+ * 按 UTF-8 字节预算截断标题(不切坏多字节字符 / 代理对)。
27
+ *
28
+ * 与 DSH `session-title` 的 `truncateTitleUtf8` 同口径:取「能放进预算的最长前缀」,
29
+ * 不补省略号(补了会占预算,也会让前缀解析多一个尾巴)。
30
+ * maxBytes 非法/缺失时原样返回(纯函数层保持旧行为,预算只在调用方显式传入时生效)。
31
+ * @param {string} input
32
+ * @param {number} [maxBytes]
33
+ * @returns {string}
34
+ */
35
+ export function truncateTitleUtf8(input, maxBytes) {
36
+ if (typeof input !== 'string') return '';
37
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) return input;
38
+ if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input;
39
+ let used = 0;
40
+ let output = '';
41
+ for (const character of input) {
42
+ const bytes = Buffer.byteLength(character, 'utf8');
43
+ if (used + bytes > maxBytes) break;
44
+ output += character;
45
+ used += bytes;
46
+ }
47
+ return output;
48
+ }
49
+
11
50
  /**
12
51
  * 标题统一格式:交接第 N 次:<原始标题>。
13
52
  * @param {number} index
@@ -19,20 +58,58 @@ export function relayTitleOf(index, baseTitle) {
19
58
  return `交接第 ${index} 次:${base}`;
20
59
  }
21
60
 
61
+ /**
62
+ * 历史标题统一格式:历史 N:<原始标题>。
63
+ *
64
+ * 空格位置/全角冒号/序号写法与 relayTitleOf 完全一致(序号不补零、不换算);
65
+ * 原名先 trim,空/非字符串回落「会话」。
66
+ *
67
+ * 0.5.0 可选第三参 `maxBytes`:给 UTF-8 字节预算时,**前缀先计入预算、超长只缩 base**,
68
+ * 保证宿主按 maxTitleBytes 截断时不会把「历史 N:」本身截掉。
69
+ * 不传时保持 0.4.3 的纯函数行为(不截断)。
70
+ * @param {number} index
71
+ * @param {string} [baseTitle]
72
+ * @param {number} [maxBytes]
73
+ * @returns {string}
74
+ */
75
+ export function historyTitleOf(index, baseTitle, maxBytes) {
76
+ const base = typeof baseTitle === 'string' && baseTitle.trim().length > 0 ? baseTitle.trim() : '会话';
77
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) return `${HISTORY_PREFIX} ${index}:${base}`;
78
+ const prefix = `${HISTORY_PREFIX} ${index}:`;
79
+ const budget = maxBytes - Buffer.byteLength(prefix, 'utf8');
80
+ // 预算连 base 都放不下时只保留前缀(宿主最多再截一次前缀,至少不会切坏字符)。
81
+ if (budget <= 0) return prefix;
82
+ const fitted = truncateTitleUtf8(base, budget).trimEnd() || truncateTitleUtf8('会话', budget);
83
+ return `${prefix}${fitted}`;
84
+ }
85
+
22
86
  /**
23
87
  * 从任意文本里剥掉现代/历史交接前缀,拿到原始标题。
24
88
  * 旧格式(「交接:」/「接棒:」可堆叠)只在读旧标题时兼容,不删除会话标题。
89
+ *
90
+ * 0.4.3 F1:返回值新增 `scheme`(只增不改,baseTitle/handedOff 的名字与语义不变):
91
+ * - 'handoff-n':「交接第 N 次:」;
92
+ * - 'history-n':「历史 N:」;
93
+ * - 'legacy':其余(旧「交接:」/「接棒:」堆叠、无前缀、空/脏输入回落)。
25
94
  * @param {string|undefined} title
26
- * @returns {{baseTitle:string, handedOff:number}}
95
+ * @returns {{baseTitle:string, handedOff:number, scheme:'handoff-n'|'history-n'|'legacy'}}
27
96
  */
28
97
  export function splitHandoffTitle(title) {
29
- if (typeof title !== 'string' || title.trim().length === 0) return { baseTitle: '会话', handedOff: 0 };
30
- const modern = /^交接第\s*(\d+)\s*次[::]\s*([\s\S]*)$/.exec(title.trim());
98
+ if (typeof title !== 'string' || title.trim().length === 0) {
99
+ return { baseTitle: '会话', handedOff: 0, scheme: 'legacy' };
100
+ }
101
+ const trimmed = title.trim();
102
+ const modern = /^交接第\s*(\d+)\s*次[::]\s*([\s\S]*)$/.exec(trimmed);
31
103
  if (modern !== null) {
32
104
  const base = modern[2].trim();
33
- return { baseTitle: base.length > 0 ? base : '会话', handedOff: Number.parseInt(modern[1], 10) };
105
+ return { baseTitle: base.length > 0 ? base : '会话', handedOff: Number.parseInt(modern[1], 10), scheme: 'handoff-n' };
106
+ }
107
+ const history = HISTORY_TITLE_RE.exec(trimmed);
108
+ if (history !== null) {
109
+ const base = history[2].trim();
110
+ return { baseTitle: base.length > 0 ? base : '会话', handedOff: Number.parseInt(history[1], 10), scheme: 'history-n' };
34
111
  }
35
- let base = title.trim();
112
+ let base = trimmed;
36
113
  let handedOff = 0;
37
114
  for (;;) {
38
115
  const match = /^(?:交接|接棒)[::]\s*/.exec(base);
@@ -41,5 +118,5 @@ export function splitHandoffTitle(title) {
41
118
  handedOff += 1;
42
119
  }
43
120
  const clean = base.trim();
44
- return { baseTitle: clean.length > 0 ? clean : '会话', handedOff };
121
+ return { baseTitle: clean.length > 0 ? clean : '会话', handedOff, scheme: 'legacy' };
45
122
  }
@@ -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) {