dsh-compact-relay 0.5.0 → 0.5.2

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
+ }
@@ -62,8 +62,8 @@ export const CONFIG_FIELDS = Object.freeze([
62
62
  field(['thresholds', 'maxSummaries'], { label: '摘要份数上限', kind: 'positiveInt', default: 6, editable: true, view: true }),
63
63
  field(['contextWindowFallback'], { label: '兜底窗口', kind: 'positiveInt', default: 1_000_000 }),
64
64
  field(['retention', 'recentTailTokens'], { label: '保留近期原文', kind: 'nonNegativeInt', default: 40_000, editable: true, view: true }),
65
- field(['summary', 'maxTokens'], { label: '摘要输出上限', kind: 'positiveInt', default: 8_192 }),
66
- field(['handoff', 'maxTokens'], { label: '交接单输出上限', kind: 'positiveInt', default: 8_192 }),
65
+ field(['summary', 'maxTokens'], { label: '摘要输出上限', kind: 'positiveInt', default: 16_384 }),
66
+ field(['handoff', 'maxTokens'], { label: '交接单输出上限', kind: 'positiveInt', default: 16_384 }),
67
67
  field(['timeouts', 'llmMs'], { label: '模型调用超时', kind: 'positiveInt', default: 180_000 }),
68
68
  field(['dev', 'testRangeNodes'], { label: '测试取段条数', kind: 'positiveInt', default: 4 }),
69
69
  field(['handoffDir'], {
@@ -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
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * 接力链 record 的**唯一构造点**(0.5.2 S2)。
3
+ *
4
+ * 为什么要有这个模块:0.5.1 之前父记录在 `actions/handoff.js` 手写 13–14 个键、
5
+ * 子记录在 `dsh/sessions.js` 手写 9–10 个键 —— 两个写点各拼一份,加字段要改两处,
6
+ * 漏一处就是静默降级(读侧 `normalizeRelayRecord` 对缺失/非法字段一律当没有)。
7
+ * 现在「一条 record 长什么样」只有这一处实现,两个写点只负责传语义参数。
8
+ *
9
+ * ⚠️ **这是 record 的字节契约,不是普通的对象字面量,不许「统一美化」**:
10
+ * 1. 键的**插入顺序**就是 `JSON.stringify` 的顺序(会话日志是 JSONL)。父/子两套
11
+ * 顺序**故意不同**,都必须逐字节保留 —— `test/contract-golden.mjs` 锁住了顺序;
12
+ * 2. 父记录多写 0.3 旧字段 `parentSessionId` / `index`(canonical 是 `parentId` /
13
+ * `ordinal`,新写入双写,旧记录仍按旧名可读);
14
+ * 3. 父记录写 `planId`(可能为 **null**);子记录**没有**这个键;
15
+ * 4. 父记录 `historyTitle` **条件写入**(改名没真的生效就不写这个键,不能记一个
16
+ * 不存在的标题);子记录没有这个键;
17
+ * 5. 父/子 `path` 都是**条件写入**(`typeof path === 'string' && path.length > 0`):
18
+ * 子记录在旧调用方/落盘失败时拿不到交接单,就不写键、不写空串;父记录的调用方
19
+ * 保证交接单已落盘(`path.join(handoffDir, handoffFileName(...))`,必然非空),
20
+ * 所以父记录实际总是带 path —— golden 里有断言;
21
+ * 6. 父/子都写 `titleSeq`,**即使为 null**(不是「省略 null」的统一策略:
22
+ * 0.4.x 记录没有这个键,0.5.0 起显式写 null 表示「拿不到宿主 eventSeq」)。
23
+ *
24
+ * 纯函数:不读文件/环境/时间,不 import 任何 `node:` 模块;`atIso` 由调用方按本地
25
+ * 时区口径算好传进来(core 不依赖 dsh 层),保证可在浏览器侧内联(build-client)。
26
+ */
27
+
28
+ /** record 的两个角色(写点只有这两个:交接的父会话、新建的子会话)。 */
29
+ export const RELAY_RECORD_ROLES = Object.freeze(['parent', 'child']);
30
+
31
+ /**
32
+ * 按角色产出一条 relay record(唯一构造)。
33
+ *
34
+ * 两个角色共用的字段:`parentId` / `childId` / `ordinal` / `title` / `baseTitle` /
35
+ * `scheme` / `titleSeq` / `at` / `atIso`(`title` 是本次新会话标题)。
36
+ *
37
+ * @param {object} input
38
+ * @param {'parent'|'child'} input.role 父记录(旧会话日志)还是子记录(新会话日志)
39
+ * @param {string} input.parentId 父会话 id(写 `parentId`,parent 另写 `parentSessionId`)
40
+ * @param {string} input.childId 子会话 id
41
+ * @param {number} input.ordinal 链号(parent 另写 `index`)
42
+ * @param {string} input.title 本次新会话标题
43
+ * @param {string} input.baseTitle 链上原始标题(不带序号前缀)
44
+ * @param {'handoff-n'|'history-n'} input.scheme 本次链的命名方案
45
+ * @param {number|null} [input.titleSeq] 本次 rename 的宿主 eventSeq(拿不到就是 null)
46
+ * @param {string} [input.path] 交接单路径(非空字符串才写键)
47
+ * @param {string|null} [input.planId] 压缩计划 id(**仅 parent**;null 表示这次没压缩)
48
+ * @param {string} [input.historyTitle] 旧会话改名后的「历史 N」标题(**仅 parent**,且只在改名生效时传)
49
+ * @param {number} input.at 记录时间(epoch ms)
50
+ * @param {string} input.atIso 记录时间(本地 ISO,含时区偏移)
51
+ * @returns {object} 可直接交给 `engine.appendPluginRecord` 的 record
52
+ */
53
+ export function relayRecordOfForm({
54
+ role,
55
+ parentId,
56
+ childId,
57
+ ordinal,
58
+ baseTitle,
59
+ title,
60
+ scheme,
61
+ titleSeq = null,
62
+ path,
63
+ planId,
64
+ historyTitle,
65
+ at,
66
+ atIso,
67
+ } = {}) {
68
+ if (role === 'parent') {
69
+ return {
70
+ parentId,
71
+ childId,
72
+ ordinal,
73
+ at,
74
+ atIso,
75
+ // 0.3 旧字段双写(旧记录只读兼容);顺序与 0.5.1 手写写法逐字节一致。
76
+ parentSessionId: parentId,
77
+ index: ordinal,
78
+ ...(typeof path === 'string' && path.length > 0 ? { path } : {}),
79
+ planId: planId ?? null,
80
+ title,
81
+ baseTitle,
82
+ scheme,
83
+ titleSeq,
84
+ // 改名真的生效(renamed / already)时调用方才传 historyTitle;空串也当没有。
85
+ ...(typeof historyTitle === 'string' && historyTitle.length > 0 ? { historyTitle } : {}),
86
+ };
87
+ }
88
+ if (role === 'child') {
89
+ return {
90
+ parentId,
91
+ childId,
92
+ ordinal,
93
+ baseTitle,
94
+ title,
95
+ scheme,
96
+ titleSeq,
97
+ // 旧调用方/落盘失败拿不到交接单 → 不写这个键(不猜、不写空串)。
98
+ ...(typeof path === 'string' && path.length > 0 ? { path } : {}),
99
+ at,
100
+ atIso,
101
+ };
102
+ }
103
+ // 角色是代码常量,传错属于开发期错误(不是用户输入),直接抛。
104
+ throw new TypeError(`relayRecordOfForm: 未知 role「${String(role)}」(只认 ${RELAY_RECORD_ROLES.join(' / ')})`);
105
+ }
106
+
107
+ /**
108
+ * 父记录(写在**旧会话**日志里;子会话建好、旧会话改名之后)。
109
+ *
110
+ * 多写 0.3 旧字段 `parentSessionId` / `index` 和 `planId`,可选 `historyTitle`。
111
+ * @param {Omit<Parameters<typeof relayRecordOfForm>[0], 'role'>} input
112
+ * @returns {object}
113
+ */
114
+ export function parentRelayRecordOf(input = {}) {
115
+ return relayRecordOfForm({ ...input, role: 'parent' });
116
+ }
117
+
118
+ /**
119
+ * 子记录(写在**新会话**日志里;`followup` 之后、`flush` 之前)。
120
+ *
121
+ * 只写 canonical 字段 + `titleSeq` + 条件 `path`;不写 `parentSessionId` / `index` /
122
+ * `planId` / `historyTitle`(这些是父会话侧的旧字段/审计字段)。
123
+ * @param {Omit<Parameters<typeof relayRecordOfForm>[0], 'role'>} input
124
+ * @returns {object}
125
+ */
126
+ export function childRelayRecordOf(input = {}) {
127
+ return relayRecordOfForm({ ...input, role: 'child' });
128
+ }
@@ -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) {