dsh-compact-relay 0.4.1 → 0.5.0

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.
@@ -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.1",
3
+ "version": "0.5.0",
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,9 +53,10 @@
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",
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/relay-title-scheme.mjs && node test/relay-chain.mjs && node test/handoff-naming.mjs && node test/native-line.mjs && node test/handoff-guard.mjs && node test/settings-ux.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
+ "test:e2e:settings-ux": "node test/e2e/settings-ux.mjs",
59
60
  "test:e2e:compact": "node test/e2e/real-compact.mjs"
60
61
  },
61
62
  "author": "cfanzx <cfanzx@users.noreply.github.com>",