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.
package/lib/dsh/relay.js CHANGED
@@ -5,7 +5,10 @@
5
5
  * 写:engine.appendPluginRecord(和压缩判断记录/pin 同一个宿主 API)。
6
6
  * 记录缺失/损坏一律当作没有(旧会话兼容),不阻断交接。
7
7
  */
8
- import { RELAY_RECORD_TYPE } from '../core/relay.js';
8
+ import { RELAY_RECORD_TYPE, splitHandoffTitle } from '../core/relay.js';
9
+
10
+ /** relay 记录里允许的标题方案(0.5.0 命名反转的两种新标题;legacy 不写进记录)。 */
11
+ const RECORD_SCHEMES = new Set(['handoff-n', 'history-n']);
9
12
 
10
13
  /**
11
14
  * 合法化一条 relay 记录;不合法返回 null。
@@ -13,8 +16,14 @@ import { RELAY_RECORD_TYPE } from '../core/relay.js';
13
16
  * 0.4 P2-2(接力链元数据补全):canonical 字段为 `parentId` / `childId` / `ordinal` / `at` / `atIso`;
14
17
  * 同时保留 0.3 的旧字段 `parentSessionId` / `index`(旧记录只读兼容,新写入两者都带)。
15
18
  *
19
+ * 0.4.3 F2(无痕 handoff 地基):白名单放行可选字段 `scheme` / `titleSeq` / `historyTitle`。
20
+ * 旧记录没有这三个字段 → 一律读成 null;其余字段与旧记录读取语义完全不变。
21
+ *
22
+ * 0.5.0 命名反转起,父/子记录都写 `scheme`(本次链 `history-n`/`handoff-n`)与 `titleSeq`;
23
+ * 新链父记录另写 `historyTitle`(旧会话改名后的「历史 N」)。
24
+ *
16
25
  * @param {unknown} data
17
- * @returns {{parentId:string, childId:string, ordinal:number, at:number, atIso:string|null, parentSessionId:string, index:number, path?:string, planId?:string|null, title?:string, baseTitle?:string}|null}
26
+ * @returns {{parentId:string, childId:string, ordinal:number, at:number, atIso:string|null, parentSessionId:string, index:number, path?:string, planId?:string|null, title?:string, baseTitle?:string, scheme:'handoff-n'|'history-n'|null, titleSeq:number|null, historyTitle:string|null}|null}
18
27
  */
19
28
  export function normalizeRelayRecord(data) {
20
29
  if (data === null || typeof data !== 'object') return null;
@@ -43,26 +52,159 @@ export function normalizeRelayRecord(data) {
43
52
  planId: typeof data.planId === 'string' ? data.planId : null,
44
53
  ...(typeof data.title === 'string' ? { title: data.title } : {}),
45
54
  ...(typeof data.baseTitle === 'string' ? { baseTitle: data.baseTitle } : {}),
55
+ // 0.4.3 F2:新字段只做白名单放行,缺失/非法一律 null(不猜、不补默认值)。
56
+ scheme: RECORD_SCHEMES.has(data.scheme) ? data.scheme : null,
57
+ titleSeq: Number.isInteger(data.titleSeq) && data.titleSeq > 0 ? data.titleSeq : null,
58
+ historyTitle: typeof data.historyTitle === 'string' && data.historyTitle.length > 0 ? data.historyTitle : null,
46
59
  };
47
60
  }
48
61
 
49
62
  /**
50
- * 会话里最近一条接力链记录(没有返回 null)。
63
+ * 从日志尾部向前找第一条满足条件的 relay 记录(没有返回 null)。
64
+ *
65
+ * 拆出来给入站/出站两个读取器共用:只扫一遍日志,判定由调用方给(DCR-01)。
51
66
  * @param {object|undefined} session
67
+ * @param {(record:object)=>boolean} predicate
52
68
  * @returns {object|null}
53
69
  */
54
- export function relayRecordOf(session) {
70
+ function latestRelayRecordWhere(session, predicate) {
55
71
  const log = session?.log;
56
72
  if (!Array.isArray(log)) return null;
57
73
  for (let index = log.length - 1; index >= 0; index -= 1) {
58
74
  const event = log[index];
59
75
  if (event?.type !== RELAY_RECORD_TYPE) continue;
60
76
  const record = normalizeRelayRecord(event.data);
61
- if (record !== null) return record;
77
+ if (record !== null && predicate(record)) return record;
62
78
  }
63
79
  return null;
64
80
  }
65
81
 
82
+ /**
83
+ * 会话里最近一条接力链记录(没有返回 null)。
84
+ *
85
+ * **语义保持不变**(0.5.1 DCR-01 有意不改):这是「最近一条记录」,出站/入站都可能。
86
+ * `readState.relay` 与 `handoffTitlePlanOf` 的链号递增都依赖它(下一次交接要看最近
87
+ * 一跳,可能是本会话交接出去的那条)。
88
+ * @param {object|undefined} session
89
+ * @returns {object|null}
90
+ */
91
+ export function relayRecordOf(session) {
92
+ return latestRelayRecordWhere(session, () => true);
93
+ }
94
+
95
+ /**
96
+ * 本会话的**入站**接力记录:`childId === session.id` 的最新一条(没有返回 null)。
97
+ *
98
+ * 0.5.1 DCR-01:祖先会话再次交接后,日志最新一条变成出站记录(childId=下一代),
99
+ * 旧实现把它当链信息下发给客户端 → 客户端的「是不是本会话的入站记录」判定不成立
100
+ * → 接力条消失。接力条要看的永远是「我是被谁交接出来的」,所以按 childId 认。
101
+ * @param {object|undefined} session
102
+ * @returns {object|null}
103
+ */
104
+ export function relayInboundRecordOf(session) {
105
+ const id = session?.id;
106
+ if (typeof id !== 'string' || id.length === 0) return null;
107
+ return latestRelayRecordWhere(session, (record) => record.childId === id);
108
+ }
109
+
110
+ /**
111
+ * 本会话的**出站**接力记录:`childId !== session.id` 的最新一条(没有返回 null)。
112
+ *
113
+ * 0.5.1 DCR-01:作为 `readState.next` 下发(只加不改),供「父→子」入口与
114
+ * 「子会话向父会话补交接单 path」使用;接力条本身不看它。
115
+ * @param {object|undefined} session
116
+ * @returns {object|null}
117
+ */
118
+ export function relayOutboundRecordOf(session) {
119
+ const id = session?.id;
120
+ if (typeof id !== 'string' || id.length === 0) return null;
121
+ return latestRelayRecordWhere(session, (record) => record.childId !== id);
122
+ }
123
+
124
+ /**
125
+ * 会话头是否具备「可能是一条老链」的血缘(DCR-02 的前置条件,不看标题)。
126
+ *
127
+ * 单独导出是为了让 `readState` 在**没有入站记录**时先做这道便宜的判断,只有可能
128
+ * 是老链才去读标题服务(普通根会话占多数,不该为每条状态读多打一次标题查找)。
129
+ *
130
+ * @param {object|undefined} session
131
+ * @returns {string|null} `header.parentSession`(合法时),否则 null
132
+ */
133
+ export function legacyChainParentIdOf(session) {
134
+ const header = session?.header;
135
+ if (header === null || typeof header !== 'object') return null;
136
+ const parentId = typeof header.parentSession === 'string' ? header.parentSession : '';
137
+ if (parentId.length === 0) return null;
138
+ // subagent 会话有 parentSession(指向派它的父会话),但它不在人看的接力链上。
139
+ if (header.origin === 'subagent') return null;
140
+ // fork(isSeeded=true)继承的是前缀快照,血缘也不是「交接」。
141
+ if (header.isSeeded === true) return null;
142
+ return parentId;
143
+ }
144
+
145
+ /**
146
+ * 0.5.1 DCR-02:0.5.0 之前生成的老链没有 relay record —— 用会话头 + 标题合成只读 chain。
147
+ *
148
+ * 为什么需要:0.4.x 只在**父会话**日志里写记录,子会话自己没有记录;升级到 0.5.0 后
149
+ * 这些老链会话 `chain=null`,接力条永远不显示。这里只做**只读兜底**(不写日志、
150
+ * 不改任何记录),条件全部满足才合成:
151
+ * 1. `header.parentSession` 是非空字符串(宿主血缘字段);
152
+ * 2. `header.origin !== 'subagent'`(subagent 会话不是交接链);
153
+ * 3. `header.isSeeded !== true`(宿主 `isSeeded` = fork 继承前缀,不是交接出来的);
154
+ * 4. 会话标题能解析出链号(`splitHandoffTitle(...).handedOff > 0`)——
155
+ * 老链子会话标题是「交接第 N 次:X」;干净标题说明不是交接出来的会话,不猜。
156
+ *
157
+ * 合成链不带 `path`:老链的交接单路径只写在父会话记录里,客户端缺 path 时会按
158
+ * 入站记录向父会话只读回查(`loadParentPath`)。带 `synthesized:true` 只是给
159
+ * 调试/测试一个明确标记(客户端投影忽略未知字段)。
160
+ *
161
+ * @param {{session?:object, title?:string}} input session 会话;title 当前标题(读不到传 undefined)
162
+ * @returns {{ordinal:number, baseTitle:string, parentId:string, childId:string, scheme:'handoff-n'|'history-n', synthesized:true}|null}
163
+ */
164
+ export function legacyRelayChainOf({ session, title } = {}) {
165
+ const parentId = legacyChainParentIdOf(session);
166
+ if (parentId === null) return null;
167
+ const childId = typeof session?.id === 'string' && session.id.length > 0 ? session.id : null;
168
+ if (childId === null) return null;
169
+ const split = splitHandoffTitle(title);
170
+ if (!(split.handedOff > 0)) return null;
171
+ return {
172
+ ordinal: split.handedOff,
173
+ baseTitle: split.baseTitle,
174
+ parentId,
175
+ childId,
176
+ // 老链只有「交接第 N 次」;标题是「历史 N」时仍是新链形态(record 丢了)——
177
+ // legacy 堆叠(「交接:」)按旧链展示(客户端文案只区分 history-n / 其它)。
178
+ scheme: split.scheme === 'history-n' ? 'history-n' : 'handoff-n',
179
+ synthesized: true,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * 0.5.0 F4:把一条 relay 记录投影成只读的「接力链节点」(交给 readState 下发)。
185
+ *
186
+ * 只是投影,不是解析:字段全部取自记录本身,不读标题、不补默认序号、不猜 scheme。
187
+ * - 记录里没有的字段 → null(0.4 及更早的记录没有 scheme/baseTitle);
188
+ * - path 与记录写法一致:有才带,没有就不出现这个键(不塞 null 占位);
189
+ * - 不是对象 → null(表示「没有链信息」,客户端按无链接口处理)。
190
+ *
191
+ * @param {object|null|undefined} record normalizeRelayRecord 的返回值
192
+ * @returns {{ordinal:number|null, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path?:string}|null}
193
+ */
194
+ export function relayChainOf(record) {
195
+ if (record === null || typeof record !== 'object') return null;
196
+ const text = (value) => (typeof value === 'string' && value.length > 0 ? value : null);
197
+ const path = text(record.path);
198
+ return {
199
+ ordinal: Number.isInteger(record.ordinal) && record.ordinal > 0 ? record.ordinal : null,
200
+ baseTitle: text(record.baseTitle),
201
+ parentId: text(record.parentId),
202
+ childId: text(record.childId),
203
+ scheme: text(record.scheme),
204
+ ...(path === null ? {} : { path }),
205
+ };
206
+ }
207
+
66
208
  /**
67
209
  * 写一条 relay 记录;失败只返回 false(交给调用方决定是否告警),不影响交接结果。
68
210
  * @param {{appendPluginRecord?: Function}} engine
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { latestCompressHint } from '../core/compress-hint.js';
8
8
  import { latestPinnedSeqs } from '../core/pins.js';
9
- import { scanCompactionLog } from './compaction-pair.js';
9
+ import { isCommittedCompaction, scanCompactionLog } from './compaction-pair.js';
10
10
 
11
11
  /**
12
12
  * 0.4 BUG-013:checkpoint 判定的默认实现(漏传第二个参数时也不再静默失效)。
@@ -68,23 +68,29 @@ export function firstCompactableIdx(session) {
68
68
  *
69
69
  * 以前数所有 compaction/summary,事务在 summary 之后失败会留下「幽灵摘要」,
70
70
  * 把 advice 提前推到交接、6 次后提前打开合并旧摘要模式。
71
- * 现在按 compactionId 配对:summary + 无 error 的 end + 覆盖该 summary 的 replace 才算一次;
72
- * 没有 compactionId 的旧日志(或外部夹具)按旧口径计数,保证兼容。
71
+ * 现在按 compactionId 配对:summary + 无 error 的 end + 覆盖该 summary 的 replace 才算一次。
72
+ *
73
+ * 0.5.1 D01:**口径与 `dsh/compaction-log.collectCompactions` 合并**(唯一实现
74
+ * `compaction-pair.isCommittedCompaction`)。以前的宽松分支(没有 compactionId 的
75
+ * 旧日志直接 +1、不看 replace)会与面板的 committed 打架 —— 同一份日志
76
+ * countSummaries=1 / committed=false。现在旧日志同样要求 replace。
73
77
  */
74
78
  export function countSummaries(session) {
75
- // 配对扫描(summary/end/replace)与 dsh/compaction-log 共用,口径见 compaction-pair.js。
79
+ // 配对扫描 + 「已提交」判定都与 dsh/compaction-log 共用(见 compaction-pair.js)。
76
80
  const { summaries, ends, replaceByCoveredSeq } = scanCompactionLog(sessionLog(session));
77
81
  let count = 0;
78
82
  for (const summary of summaries) {
79
83
  const id = summary.data?.compactionId;
80
- if (typeof id !== 'string') {
84
+ const hasId = typeof id === 'string';
85
+ if (
86
+ isCommittedCompaction({
87
+ compactionId: hasId ? id : null,
88
+ end: hasId ? ends.get(id) : undefined,
89
+ replace: replaceByCoveredSeq.get(summary.seq),
90
+ })
91
+ ) {
81
92
  count += 1;
82
- continue;
83
93
  }
84
- const end = ends.get(id);
85
- if (end === undefined || end.error !== undefined) continue;
86
- if (!replaceByCoveredSeq.has(summary.seq)) continue;
87
- count += 1;
88
94
  }
89
95
  return count;
90
96
  }
@@ -12,15 +12,25 @@
12
12
  * childAgent.followup(交接单) → 消息成为新会话第一条消息并启动 turn
13
13
  * (DSH 0.2 里没有 turn/start 的会话在界面上是「空白 Hero」,不会显示
14
14
  * 历史;followup 让交接内容真的显示出来。失败则退回纯 append。)
15
+ * childSession 写 relay 记录 → 子会话自带链号(ordinal/baseTitle/scheme/titleSeq)
16
+ * 与交接单 path(0.5.0:调用方把已落盘的交接单文件路径传进来;子会话的
17
+ * 「查看交接单」不再依赖回查父会话)
15
18
  *
16
19
  * 全部失败点都做「能降级就降级、不能降级就带 childId 报错」,绝不静默。
17
20
  */
18
21
  import { randomUUID } from 'node:crypto';
19
22
  import { actionError } from '../core/errors.js';
20
- import { relayTitleOf, splitHandoffTitle } from '../core/relay.js';
23
+ import {
24
+ MAX_TITLE_BYTES,
25
+ historyTitleOf,
26
+ relayTitleOf,
27
+ splitHandoffTitle,
28
+ truncateTitleUtf8,
29
+ } from '../core/relay.js';
21
30
  import { HANDOFF_PREAMBLE } from '../core/prompts.js';
31
+ import { localIso } from './diag-counters.js';
22
32
  import { cacheControls, routeOf } from './route.js';
23
- import { relayRecordOf } from './relay.js';
33
+ import { relayRecordOf, writeRelayRecord } from './relay.js';
24
34
 
25
35
  /** 插件在消息 source 上的标识。 */
26
36
  export const PLUGIN_NAME = 'compact-relay';
@@ -29,6 +39,11 @@ function errorMessage(error) {
29
39
  return error instanceof Error ? error.message : String(error);
30
40
  }
31
41
 
42
+ /** 标题比较用的宿主口径折叠(连续空白 → 单空格 + trim)。 */
43
+ function sameNormalizedTitle(left, right) {
44
+ return left.replace(/\s+/gu, ' ').trim() === right.replace(/\s+/gu, ' ').trim();
45
+ }
46
+
32
47
  /**
33
48
  * 读原会话的 agent preset id(0.2 里优先会话头,其次 projection)。
34
49
  * @param {object} ctx
@@ -68,29 +83,81 @@ export function sourceTitleOf(ctx, session) {
68
83
  }
69
84
 
70
85
  /**
71
- * 下一次交接的标题计划:序号 + 原始标题。
86
+ * 按 scheme 组装标题计划(纯函数,不读 ctx)。
87
+ *
88
+ * - `handoff-n`(旧链):保持「交接第 N 次:<原名>」原样,旧链一个字符不改;
89
+ * - `history-n`(0.5.0 新链):新会话继承干净原名,旧会话由调用方改名「历史 N:<原名>」。
90
+ * 两个标题都按 `MAX_TITLE_BYTES` 先缩 base(前缀计入预算),避免宿主截断吃掉前缀。
91
+ * @param {'handoff-n'|'history-n'} scheme
92
+ * @param {number} index
93
+ * @param {string} baseTitle
94
+ * @param {boolean} viaRecord
95
+ * @returns {{index:number, baseTitle:string, title:string, historyTitle:string|null, scheme:'handoff-n'|'history-n', viaRecord:boolean}}
96
+ */
97
+ function titlePlanOf(scheme, index, baseTitle, viaRecord) {
98
+ if (scheme === 'handoff-n') {
99
+ return {
100
+ index,
101
+ baseTitle,
102
+ title: relayTitleOf(index, baseTitle),
103
+ historyTitle: null,
104
+ scheme,
105
+ viaRecord,
106
+ };
107
+ }
108
+ const base = typeof baseTitle === 'string' && baseTitle.trim().length > 0 ? baseTitle.trim() : '会话';
109
+ const fitted = truncateTitleUtf8(base, MAX_TITLE_BYTES).trimEnd() || '会话';
110
+ return {
111
+ index,
112
+ baseTitle: base,
113
+ // 新会话标题 = 原名(不带序号);旧会话标题 = 历史 N:原名。
114
+ title: fitted,
115
+ historyTitle: historyTitleOf(index, base, MAX_TITLE_BYTES),
116
+ scheme,
117
+ viaRecord,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * 下一次交接的标题计划:序号 + 原始标题 + 本次链用哪种命名方案。
72
123
  *
73
- * A5:优先读父会话里的 relay 记录(结构化数据,不依赖标题文案);
74
- * 没有记录(旧会话/首次交接)才回退到标题解析(兼容「交接第 N 次:」与历史堆叠前缀)。
124
+ * 序号优先读会话自己的 relay 记录(结构化数据,不依赖标题文案;新链子会话标题
125
+ * 继承原名后标题里已没有链号)。没有记录才回退标题解析:
126
+ * - `历史 N:` → 继续新链(record 丢失时靠标题兜底);
127
+ * - `交接第 N 次:` / 旧「交接:」「接棒:」堆叠 → 旧链,保持旧命名(链级 cutover);
128
+ * - 干净标题(无前缀)→ 新链,本次为第 1 次;
129
+ * - 标题读不到 → 保守按旧链处理(不生成可能与旧链混合的新标题)。
130
+ *
131
+ * 记录存在时方案以记录为准(`scheme='history-n'` 才走新链;0.3/0.4 旧记录
132
+ * scheme 为 null → 旧链,不迁移)。
75
133
  * @param {object} ctx
76
134
  * @param {object} session
77
- * @returns {{index:number, baseTitle:string, title:string, viaRecord:boolean}}
135
+ * @returns {{index:number, baseTitle:string, title:string, historyTitle:string|null, scheme:'handoff-n'|'history-n', viaRecord:boolean}}
78
136
  */
79
137
  export function handoffTitlePlanOf(ctx, session) {
138
+ const currentTitle = sourceTitleOf(ctx, session);
139
+ const split = splitHandoffTitle(currentTitle);
80
140
  const record = relayRecordOf(session);
81
141
  if (record !== null) {
82
- const fallback = splitHandoffTitle(sourceTitleOf(ctx, session));
83
- const baseTitle = typeof record.baseTitle === 'string' && record.baseTitle.length > 0 ? record.baseTitle : fallback.baseTitle;
142
+ const baseTitle = typeof record.baseTitle === 'string' && record.baseTitle.length > 0 ? record.baseTitle : split.baseTitle;
84
143
  const index = (Number.isInteger(record.ordinal) ? record.ordinal : record.index) + 1;
85
- return { index, baseTitle, title: relayTitleOf(index, baseTitle), viaRecord: true };
144
+ const scheme = record.scheme === 'history-n' ? 'history-n' : 'handoff-n';
145
+ return titlePlanOf(scheme, index, baseTitle, true);
86
146
  }
87
- const split = splitHandoffTitle(sourceTitleOf(ctx, session));
88
147
  const index = split.handedOff + 1;
89
- return { index, baseTitle: split.baseTitle, title: relayTitleOf(index, split.baseTitle), viaRecord: false };
148
+ const scheme =
149
+ currentTitle === undefined
150
+ ? 'handoff-n'
151
+ : split.scheme === 'history-n' || (split.scheme === 'legacy' && split.handedOff === 0)
152
+ ? 'history-n'
153
+ : 'handoff-n';
154
+ return titlePlanOf(scheme, index, split.baseTitle, false);
90
155
  }
91
156
 
92
157
  /**
93
- * 给新会话起个可辨识的标题:交接第 N 次:<原题>。
158
+ * 给新会话起可辨识的标题。
159
+ * - 旧链:交接第 N 次:<原题>;
160
+ * - 新链(0.5.0 命名反转):直接继承干净原名。
94
161
  * 具体序号/原题由 handoffTitlePlanOf 决定(优先 relay 记录,其次标题解析)。
95
162
  * @param {object} ctx
96
163
  * @param {object} session
@@ -100,6 +167,93 @@ export function childTitleOf(ctx, session) {
100
167
  return handoffTitlePlanOf(ctx, session).title;
101
168
  }
102
169
 
170
+ /**
171
+ * 0.5.0 命名反转:把旧会话改名「历史 N:<原名>」。
172
+ *
173
+ * 只在 scheme='history-n' 的**新链**上执行;旧链(handoff-n)原样返回,一个字符不改
174
+ * (链级 cutover)。调用方必须在子会话建好之后调用:改名是可见副作用,任何失败都只
175
+ * warn、不阻断交接(子会话已经可用)。
176
+ *
177
+ * 幂等 guard:当前标题已经是本次目标(或宿主按更小预算截断后的同一前缀)时跳过,
178
+ * 同一链重复执行不会重复 append rename,也不会叠成「历史 2:历史 1:X」。
179
+ *
180
+ * 改名成功后 `await ctx.sessions.flush(session)` 收窄持久化窗口(宿主 rename 是同步写日志,
181
+ * flush 失败也只 warn)。
182
+ *
183
+ * @param {object} ctx
184
+ * @param {object} session 旧会话(rename 要求 live;手动 /handoff 时它正是当前会话)
185
+ * @param {{index:number, baseTitle:string, scheme?:'handoff-n'|'history-n', maxBytes?:number, log?:{warn?:Function}}} input
186
+ * @returns {Promise<{renamed:boolean, title:string|null, titleSeq:number|null, reason:'old-chain'|'unavailable'|'already'|'failed'|null}>}
187
+ */
188
+ export async function renameHistoryOf(
189
+ ctx,
190
+ session,
191
+ { index, baseTitle, scheme = 'history-n', maxBytes = MAX_TITLE_BYTES, log } = {},
192
+ ) {
193
+ const result = { renamed: false, title: null, titleSeq: null, reason: null };
194
+ if (scheme !== 'history-n') {
195
+ result.reason = 'old-chain';
196
+ return result;
197
+ }
198
+
199
+ const target = historyTitleOf(index, baseTitle, maxBytes);
200
+ result.title = target;
201
+ const sessionId = session?.id ?? '';
202
+
203
+ let titles;
204
+ try {
205
+ titles = ctx?.get?.('sessionTitle');
206
+ } catch {
207
+ titles = undefined;
208
+ }
209
+ if (titles === undefined || titles === null || typeof titles.rename !== 'function') {
210
+ result.reason = 'unavailable';
211
+ log?.warn?.(
212
+ `compact-relay: 旧会话 ${sessionId} 改名「${target}」失败:sessionTitle 服务不可用(交接已完成,标题保持原样)`,
213
+ );
214
+ return result;
215
+ }
216
+
217
+ // 幂等 guard:读得到当前标题时,已是本次目标就跳过(含宿主按更小预算截断的前缀)。
218
+ // 按宿主 `session-title` 的口径折叠空白再比:宿主会把连续空白压成一个空格,
219
+ // 不折叠的话同一目标会被误判成「还没改」而重复 append。
220
+ try {
221
+ const current = titles.get?.(session)?.title;
222
+ if (typeof current === 'string' && current.length > 0) {
223
+ const parsed = splitHandoffTitle(current);
224
+ const sameHistory = parsed.scheme === 'history-n' && parsed.handedOff === index;
225
+ if (sameNormalizedTitle(current, target) || (sameHistory && target.startsWith(current))) {
226
+ result.reason = 'already';
227
+ return result;
228
+ }
229
+ }
230
+ } catch {
231
+ /* 读不到当前标题就继续 rename(rename 自己会校验 live/空标题) */
232
+ }
233
+
234
+ let renamed;
235
+ try {
236
+ renamed = titles.rename(session, target);
237
+ } catch (error) {
238
+ result.reason = 'failed';
239
+ log?.warn?.(
240
+ `compact-relay: 旧会话 ${sessionId} 改名「${target}」失败:${errorMessage(error)}(交接已完成,标题保持原样)`,
241
+ );
242
+ return result;
243
+ }
244
+
245
+ // 与子会话 rename 一样:快照里的 eventSeq 是链记录要的序号,取不到就留 null(不猜)。
246
+ if (Number.isInteger(renamed?.eventSeq) && renamed.eventSeq > 0) result.titleSeq = renamed.eventSeq;
247
+ result.renamed = true;
248
+
249
+ try {
250
+ await ctx?.sessions?.flush?.(session);
251
+ } catch (error) {
252
+ log?.warn?.(`compact-relay: 旧会话 ${sessionId} 改名后落盘失败:${errorMessage(error)}`);
253
+ }
254
+ return result;
255
+ }
256
+
103
257
  /**
104
258
  * 解析子会话要挂的 agent preset,并装配 setup(系统提示/工具/技能组成)。
105
259
  * 与官方 composeAgent 同构;preset 解析失败时退回默认 preset。
@@ -176,10 +330,12 @@ export function childAgentOptionsOf(ctx, session, agent) {
176
330
  * @param {object} [input.agent] 原 Agent
177
331
  * @param {string} input.doc 交接单正文(已含元信息头)
178
332
  * @param {string} input.title 新会话标题
333
+ * @param {string} [input.path] 交接单文件路径(已落盘;传进子会话 relay 记录,拿不到就不写这个键)
334
+ * @param {object} [input.plan] handoffTitlePlanOf 的结果(不传则现场计算;串起文档/双写记录用同一份)
179
335
  * @param {{warn?: Function}} [input.log]
180
336
  * @returns {Promise<{childId: string, title: string, workspaceAttached: boolean, started: boolean, docSeq?: number}>}
181
337
  */
182
- export async function createHandoffSession({ ctx, engine, session, agent, doc, title, log }) {
338
+ export async function createHandoffSession({ ctx, engine, session, agent, doc, title, path, plan, log }) {
183
339
  if (session === undefined) throw actionError('handoff', '没有可交接的会话');
184
340
 
185
341
  const childId = `session-${randomUUID()}`;
@@ -228,10 +384,16 @@ export async function createHandoffSession({ ctx, engine, session, agent, doc, t
228
384
  }
229
385
 
230
386
  // 2) 可辨识标题(sessionTitle 服务可选)。
387
+ // 0.4.4 F3:rename 返回的是折叠后的标题快照(DSH 0.2 形如
388
+ // { title, messageSeqs, source, eventSeq, updatedAt }),不是日志事件。
389
+ // 记下快照里的 eventSeq 供子会话 relay 记录使用;旧宿主/替身没有 eventSeq
390
+ // 就留 null(不猜)。eventSeq=0 也不收:relay 白名单只认正整数序号。
391
+ let titleSeq = null;
231
392
  try {
232
393
  const titles = ctx.get?.('sessionTitle');
233
394
  if (titles !== undefined && titles !== null && typeof titles.rename === 'function') {
234
- titles.rename(childSession, title);
395
+ const renamed = titles.rename(childSession, title);
396
+ if (Number.isInteger(renamed?.eventSeq) && renamed.eventSeq > 0) titleSeq = renamed.eventSeq;
235
397
  }
236
398
  } catch (error) {
237
399
  log?.warn?.(`compact-relay: 新会话 ${childId} 改标题失败:${errorMessage(error)}`);
@@ -271,7 +433,36 @@ export async function createHandoffSession({ ctx, engine, session, agent, doc, t
271
433
  }
272
434
  }
273
435
 
274
- // 4) 尽力落盘:新会话的事件尽快持久化,重启后不丢。
436
+ // 4) 子会话自己带一条接力链记录(自带链号,不再靠标题解析)。
437
+ // 0.5.0 会把旧会话改名「历史 N:<原名>」、新会话继承原名;那时子会话标题里
438
+ // 没有「交接第 N 次」,splitHandoffTitle 会算出 handedOff=0,第 2 次交接序号
439
+ // 就会重复。所以 ordinal/baseTitle/scheme/titleSeq 必须落在子会话自己的日志里。
440
+ // 写点:followup 之后(新会话首条可见消息仍是交接单)、flush 之前(重启不丢);
441
+ // 走 plugin record 通道:ignorable,不进模型上下文、不算 token。
442
+ // scheme 与父记录同源(本次链是 handoff-n 还是 history-n),titleSeq 是**子会话**
443
+ // rename 返回的 eventSeq;父记录由 lib/actions/handoff.js 另外写,这里只写子会话。
444
+ // 0.5.0 补:交接单已由调用方落盘,path 一并写进子记录 —— 接力条在子会话里直接
445
+ // 拿到「查看交接单」的路径,父会话未加载/已归档时不再退化为「暂无交接单」。
446
+ // path 拿不到(旧调用方/失败)就不写这个键,不猜、不写空串。
447
+ const titlePlan = plan ?? handoffTitlePlanOf(ctx, session);
448
+ const relayAt = Date.now();
449
+ const relayWrote = writeRelayRecord(engine, childSession, {
450
+ parentId: session.id,
451
+ childId,
452
+ ordinal: titlePlan.index,
453
+ baseTitle: titlePlan.baseTitle,
454
+ title,
455
+ scheme: titlePlan.scheme,
456
+ titleSeq,
457
+ ...(typeof path === 'string' && path.length > 0 ? { path } : {}),
458
+ at: relayAt,
459
+ atIso: localIso(new Date(relayAt)),
460
+ });
461
+ if (!relayWrote) {
462
+ log?.warn?.(`compact-relay: 新会话 ${childId} 接力链记录写入失败(下次从该会话交接将回退到标题解析)`);
463
+ }
464
+
465
+ // 5) 尽力落盘:新会话的事件尽快持久化,重启后不丢(含上面那条接力链记录)。
275
466
  try {
276
467
  await ctx.sessions?.flush?.(childSession);
277
468
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-compact-relay",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "DeepSeek Harness 上下文指示器插件:提供容量监控、压缩预览与确认、九段式交接单生成及消息锁定功能。 · Context indicator for DSH: preview-then-confirm compaction, 9-section session handoff, and pinned messages.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -53,7 +53,7 @@
53
53
  "node": ">=22"
54
54
  },
55
55
  "scripts": {
56
- "test": "node scripts/build-client.mjs --check && node --check lib/client.js && node test/smoke.mjs && node test/handoff.mjs && node test/plugin-load.mjs && node test/contract.mjs && node test/compact-log.mjs && node test/compress-cue.mjs && node test/state-passive.mjs && node test/native-compaction.mjs && node test/markdown-lite.mjs && node test/diagnostics.mjs && node test/pin-contract.mjs && node test/ledger.mjs && node test/handoff-p2.mjs && node test/native-line.mjs && node test/handoff-guard.mjs && node test/settings-ux.mjs",
56
+ "test": "node scripts/build-client.mjs --check && node --check lib/client.js && node test/smoke.mjs && node test/handoff.mjs && node test/plugin-load.mjs && node test/contract.mjs && node test/compact-log.mjs && node test/compress-cue.mjs && node test/state-passive.mjs && node test/native-compaction.mjs && node test/markdown-lite.mjs && node test/clip.mjs && node test/diagnostics.mjs && node test/pin-contract.mjs && node test/ledger.mjs && node test/handoff-p2.mjs && node test/relay-title-scheme.mjs && node test/relay-chain.mjs && node test/relay-chain-endpoint.mjs && node test/handoff-naming.mjs && node test/native-line.mjs && node test/handoff-guard.mjs && node test/settings-ux.mjs && node test/pin-health.mjs && node test/archive.mjs && node test/relay-bar.mjs",
57
57
  "test:e2e": "node test/e2e/smoke.mjs",
58
58
  "test:e2e:settings": "node test/e2e/settings-ui.mjs",
59
59
  "test:e2e:settings-ux": "node test/e2e/settings-ux.mjs",