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.
@@ -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'};不自动停止任务,旧会话仍在列表里)。`;
@@ -49,7 +49,7 @@ export async function generateHandoffDoc({ ctx, engine, agent, maxTokens, inputT
49
49
  const prefix = surfaceMessages(session);
50
50
  if (prefix.length === 0) throw actionError('handoff', '这个会话还没有可交接的内容(没有模型可见的消息)');
51
51
 
52
- // 同摘要器:不继承 reasoningEffort(max 思考会把输出上限吃光);是否传 off 由模型能力决定。
52
+ // reasoningEffort 由 prepareModelCall 决定:优先沿用主请求档位(cache 对齐);拿不到 header 才回退「支持 off 就 off」。
53
53
  const controls = cacheAlignedControls(session, target);
54
54
  let lastProblems = [];
55
55
 
@@ -5,8 +5,8 @@
5
5
  * 两个任务不是同一种产物(一个压成六段摘要、一个写九段交接单,校验/重试规则
6
6
  * 也不同),所以**不合并重试循环**;但下面四件事完全一样、以前各写一遍:
7
7
  * 1. 前置:会话存在性 → 解析 provider/model 路由 → 窗口预算预检 → 推理档位;
8
- * 2. cache 对齐参数:复用上次请求的 temperature/stop,但**绝不继承 reasoningEffort**
9
- * (2026-10-19 真机坑:会话最后请求是 max 思考,摘要沿用 max → maxTokens 被思考吃光);
8
+ * 2. cache 对齐参数:复用上次请求的 temperature/stop;reasoningEffort 由
9
+ * prepareModelCall 显式决定(2026-10-27 起优先沿用主请求的档位,见下);
10
10
  * 3. 单次流式调用 + finishProblem 归一(超限/工具调用/中断);
11
11
  * 4. 成功结果的公共元信息(provider/model/maxTokens/attempts/usage)。
12
12
  *
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { actionError } from '../core/errors.js';
16
16
  import { assertBudget, callStream, reasoningEffortFor } from './llm-call.js';
17
- import { cacheControls, routeOf } from './route.js';
17
+ import { cacheControls, cacheEffortOf, routeOf } from './route.js';
18
18
  import { finishProblem } from './stream.js';
19
19
 
20
20
  /**
@@ -33,7 +33,7 @@ import { finishProblem } from './stream.js';
33
33
  * @param {string} input.code 动作错误码
34
34
  * @param {string} input.noSessionMessage 会话不存在时的用户可见错误文案
35
35
  * @param {string} input.noTargetMessage 拿不到 provider/model 时的用户可见错误文案
36
- * @returns {Promise<{target:{provider:string, model:string}, reasoningEffort:'off'|undefined}>}
36
+ * @returns {Promise<{target:{provider:string, model:string}, reasoningEffort:string|undefined}>}
37
37
  */
38
38
  export async function prepareModelCall({
39
39
  ctx,
@@ -53,18 +53,22 @@ export async function prepareModelCall({
53
53
 
54
54
  // 架构 A2:调模型前先做窗口预算预检;超窗直接报 budget,不白等一次失败请求。
55
55
  await assertBudget({ ctx, target, inputTokens, maxTokens, label });
56
- // P2-5:只有模型目录明确支持 off 才传;非推理/不支持 off 的模型交给 adapter 默认。
57
- const reasoningEffort = await reasoningEffortFor(ctx, target);
56
+ // 2026-10-27 P1 取证:reasoningEffort 会切换 provider 的 thinking 开关,是缓存前缀的
57
+ // 断点。摘要/交接单回放的是主请求的前缀,所以默认**沿用主请求的档位**(cache 对齐);
58
+ // 只有拿不到 header(无请求头 / 模型已切走)时,才退回旧策略:
59
+ // P2-5 的「模型目录支持 off 就 off」(省思考 token;这是从 P1 之前的行为,保留作兜底)。
60
+ const headerEffort = cacheEffortOf(session, target);
61
+ const reasoningEffort = headerEffort !== undefined ? headerEffort : await reasoningEffortFor(ctx, target);
58
62
 
59
63
  return { target, reasoningEffort };
60
64
  }
61
65
 
62
66
  /**
63
- * cache 对齐参数:复用上次请求的 temperature/stop,**不要继承 reasoningEffort**。
67
+ * cache 对齐参数:复用上次请求的 temperature/stop;reasoningEffort 不从这里拿。
64
68
  *
65
- * 2026-10-19 真机踩的坑:会话最后请求是 reasoningEffort=max,摘要调用沿用 max,
66
- * 思考 token 把 maxTokens(4096) 吃光 → finish=max-tokens → 「摘要在输出上限处被截断」。
67
- * 摘要/交接单本身都不需要长思考,是否固定 off 由模型能力决定(见 prepareModelCall)。
69
+ * reasoningEffort 由 prepareModelCall 决定(2026-10-27 起优先与主请求一致,保证缓存命中)。
70
+ * 2026-10-19 的旧坑(max 思考把 maxTokens 吃光)已由「summary/handoff.maxTokens 提到
71
+ * 16K + 保留截断重试」兜住,不再是「一律 off」的理由。
68
72
  *
69
73
  * @param {object} session
70
74
  * @param {{provider:string, model:string}} target
@@ -71,6 +71,9 @@ const CONFIG_RE = /^(\s*)config\s*:\s*(?:#.*)?$/;
71
71
  const AUTO_RE = /^auto\s*:\s*(true|false)\s*(?:#.*)?$/;
72
72
  const RATIO_RE = /^thresholdRatio\s*:\s*['"]?([0-9.]+)['"]?\s*(?:#.*)?$/;
73
73
  const HEADROOM_RE = /^headroomTokens\s*:\s*['"]?(\d+)['"]?\s*(?:#.*)?$/;
74
+ /** 0.5.1 批 2:保留近期尾部的两种写法(compaction-basic 的 retainRatio / retainTokens)。 */
75
+ const RETAIN_RATIO_RE = /^retainRatio\s*:\s*['"]?([0-9.]+)['"]?\s*(?:#.*)?$/;
76
+ const RETAIN_TOKENS_RE = /^retainTokens\s*:\s*['"]?(\d+)['"]?\s*(?:#.*)?$/;
74
77
  /** modelPolicies 列表项里的键(- provider: x / model: y / thresholdRatio: 0.5 / headroomTokens: 4096)。 */
75
78
  const POLICY_PROVIDER_RE = /^provider\s*:\s*['"]?([^'"#\s]+)['"]?\s*(?:#.*)?$/;
76
79
  const POLICY_MODEL_RE = /^model\s*:\s*['"]?([^'"#\s]+)['"]?\s*(?:#.*)?$/;
@@ -196,6 +199,23 @@ export function parseCompactionOverride(text) {
196
199
  if (Number.isInteger(value) && value >= 0) override.headroomTokens = value;
197
200
  }
198
201
  }
202
+ // 0.5.1 批 2:原生压缩保留尾部预算(预判「会覆盖哪些 pin」用;不改原生行为)。
203
+ const retainRatioIndex = findDirectChild(lines, entry, config, RETAIN_RATIO_RE);
204
+ if (retainRatioIndex >= 0) {
205
+ const match = RETAIN_RATIO_RE.exec(lines[retainRatioIndex].trim());
206
+ if (match !== null) {
207
+ const value = Number(match[1]);
208
+ if (Number.isFinite(value) && value >= 0 && value < 1) override.retainRatio = value;
209
+ }
210
+ }
211
+ const retainTokensIndex = findDirectChild(lines, entry, config, RETAIN_TOKENS_RE);
212
+ if (retainTokensIndex >= 0) {
213
+ const match = RETAIN_TOKENS_RE.exec(lines[retainTokensIndex].trim());
214
+ if (match !== null) {
215
+ const value = Number(match[1]);
216
+ if (Number.isInteger(value) && value >= 0) override.retainTokens = value;
217
+ }
218
+ }
199
219
  return override;
200
220
  }
201
221
 
@@ -230,7 +250,14 @@ export function parseModelPolicies(text) {
230
250
  let current = null;
231
251
  const flush = () => {
232
252
  if (current === null) return;
233
- if (current.thresholdRatio !== undefined || current.headroomTokens !== undefined) policies.push(current);
253
+ if (
254
+ current.thresholdRatio !== undefined ||
255
+ current.headroomTokens !== undefined ||
256
+ current.retainRatio !== undefined ||
257
+ current.retainTokens !== undefined
258
+ ) {
259
+ policies.push(current);
260
+ }
234
261
  current = null;
235
262
  };
236
263
  for (let index = start + 1; index < entry.end; index += 1) {
@@ -253,9 +280,18 @@ export function parseModelPolicies(text) {
253
280
  const trimmed = line.trim();
254
281
  const ratioMatch = RATIO_RE.exec(trimmed);
255
282
  const headMatch = HEADROOM_RE.exec(trimmed);
283
+ const retainRatioMatch = RETAIN_RATIO_RE.exec(trimmed);
284
+ const retainTokensMatch = RETAIN_TOKENS_RE.exec(trimmed);
256
285
  const providerMatch = POLICY_PROVIDER_RE.exec(trimmed);
257
286
  const modelMatch = POLICY_MODEL_RE.exec(trimmed);
258
- if (ratioMatch !== null || headMatch !== null || providerMatch !== null || modelMatch !== null) {
287
+ if (
288
+ ratioMatch !== null ||
289
+ headMatch !== null ||
290
+ retainRatioMatch !== null ||
291
+ retainTokensMatch !== null ||
292
+ providerMatch !== null ||
293
+ modelMatch !== null
294
+ ) {
259
295
  if (current === null) current = { provider: null, model: null };
260
296
  if (ratioMatch !== null) {
261
297
  const value = Number(ratioMatch[1]);
@@ -263,6 +299,12 @@ export function parseModelPolicies(text) {
263
299
  } else if (headMatch !== null) {
264
300
  const value = Number(headMatch[1]);
265
301
  if (Number.isInteger(value) && value >= 0) current.headroomTokens = value;
302
+ } else if (retainRatioMatch !== null) {
303
+ const value = Number(retainRatioMatch[1]);
304
+ if (Number.isFinite(value) && value >= 0 && value < 1) current.retainRatio = value;
305
+ } else if (retainTokensMatch !== null) {
306
+ const value = Number(retainTokensMatch[1]);
307
+ if (Number.isInteger(value) && value >= 0) current.retainTokens = value;
266
308
  } else if (providerMatch !== null) current.provider = providerMatch[1];
267
309
  else current.model = modelMatch[1];
268
310
  continue;
@@ -286,8 +328,13 @@ export function parseModelPolicies(text) {
286
328
  export function resolveNativePolicy(nativeCfg, route) {
287
329
  const globalRatio = Number.isFinite(nativeCfg?.thresholdRatio) ? nativeCfg.thresholdRatio : NATIVE_DEFAULTS.thresholdRatio;
288
330
  const globalHeadroom = Number.isFinite(nativeCfg?.headroomTokens) ? nativeCfg.headroomTokens : NATIVE_HEADROOM_DEFAULT;
331
+ // 0.5.1 批 2:保留尾部预算(不配置 = undefined,由 core/native-line 按宿主默认比值算)。
332
+ const globalRetain = {
333
+ ...(Number.isFinite(nativeCfg?.retainRatio) ? { retainRatio: nativeCfg.retainRatio } : {}),
334
+ ...(Number.isInteger(nativeCfg?.retainTokens) ? { retainTokens: nativeCfg.retainTokens } : {}),
335
+ };
289
336
  if (route === null || route === undefined || typeof route.provider !== 'string' || typeof route.model !== 'string') {
290
- return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false };
337
+ return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false, ...globalRetain };
291
338
  }
292
339
  const list = Array.isArray(nativeCfg?.modelPolicies) ? nativeCfg.modelPolicies : [];
293
340
  const hit = list.find(
@@ -297,11 +344,17 @@ export function resolveNativePolicy(nativeCfg, route) {
297
344
  (policy.model === route.model || policy.model === `${route.provider}/${route.model}` || policy.model === null || policy.model === undefined) &&
298
345
  (policy.provider === undefined || policy.provider === null || policy.provider === route.provider),
299
346
  );
300
- if (hit === undefined) return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false };
347
+ if (hit === undefined) return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false, ...globalRetain };
348
+ const routeRetain = {
349
+ ...(Number.isFinite(hit.retainRatio) ? { retainRatio: hit.retainRatio } : {}),
350
+ ...(Number.isInteger(hit.retainTokens) ? { retainTokens: hit.retainTokens } : {}),
351
+ };
301
352
  return {
302
353
  thresholdRatio: Number.isFinite(hit.thresholdRatio) ? hit.thresholdRatio : globalRatio,
303
354
  headroomTokens: Number.isFinite(hit.headroomTokens) ? hit.headroomTokens : globalHeadroom,
304
355
  overridden: true,
356
+ ...globalRetain,
357
+ ...routeRetain,
305
358
  };
306
359
  }
307
360
 
@@ -331,6 +384,9 @@ export function readNativeCompaction(profileDir = resolveProfileDir()) {
331
384
  thresholdRatio: override.thresholdRatio ?? NATIVE_DEFAULTS.thresholdRatio,
332
385
  // 0.4 P2-3:真实触发线要用的余量与路由级覆盖(additive 字段,老调用方不受影响)。
333
386
  headroomTokens: override.headroomTokens ?? NATIVE_HEADROOM_DEFAULT,
387
+ // 0.5.1 批 2:保留尾部预算(未配置时 undefined,由 nativeRetainTokensOf 按宿主默认比值算)。
388
+ ...(override.retainRatio === undefined ? {} : { retainRatio: override.retainRatio }),
389
+ ...(override.retainTokens === undefined ? {} : { retainTokens: override.retainTokens }),
334
390
  modelPolicies: parseModelPolicies(text),
335
391
  override,
336
392
  file,
@@ -0,0 +1,238 @@
1
+ /**
2
+ * 完整交接链的服务端读取(0.5.1 DCR-05)。
3
+ *
4
+ * 背景:0.5.1 批 3 的「完整交接链」由客户端逐级 GET `/api/compact-relay-state?sessionId=父id`。
5
+ * 宿主对**没加载进内存**的会话返回 `sessionId:null`(readState 的 target 为 null),
6
+ * 客户端只能停在断点。重启后祖先会话通常都没加载,因此「只显示 1 层」是常态而非例外。
7
+ *
8
+ * 宿主实证(DeepSeek Harness 0.2.1-rc.3 app.asar):
9
+ * - `@deepseek-ai/dsh-session-persistence`:根服务 `ctx.sessionPersistence`
10
+ * - `stat(id)` → `{header, revision, sizeBytes?}`,**不读事件日志**;
11
+ * - `open(id, 'read')` → 只读 handle(不创建 live Session、不写任何状态),
12
+ * `handle.read()` → `{events, eventState}`,`handle.close()` 释放。
13
+ * - `@deepseek-ai/dsh-session-persistence-jsonl` 对**已归档**会话同样可读:归档只改
14
+ * Workspace registry 的集合,不动持久化文件。
15
+ *
16
+ * 所以整条链可以完全在服务端按「header + 日志尾部」读出来,不需要加载祖先会话。
17
+ * 这里只是只读读取:不 append、不 open 写句柄、不改归档/置顶/标题。
18
+ *
19
+ * 链接形状与客户端原逐级逻辑一致:
20
+ * - 每一行(祖先)的 ordinal/baseTitle/scheme 来自**子会话的入站 relay 记录**;
21
+ * - 交接单 path 优先用这条入站记录自己的 path,缺了再向父会话日志里找
22
+ * `childId===子id && ordinal===该跳序号` 的出站记录补(0.3/0.4 老记录没写 path);
23
+ * - 父会话自己的入站记录(或 DCR-02 老链的 header+标题兜底)继续往更早走。
24
+ */
25
+ import { RELAY_RECORD_TYPE } from '../core/relay.js';
26
+ import { legacyRelayChainOf, normalizeRelayRecord, relayChainOf, relayInboundRecordOf } from './relay.js';
27
+ import { liveSessionOf, sessionLog } from './session-view.js';
28
+ import { sourceTitleOf } from './sessions.js';
29
+
30
+ /** 最多回看 8 层(与客户端 MAX_CHAIN_HOPS 同口径:防成环、防无限链)。 */
31
+ export const MAX_CHAIN_HOPS = 8;
32
+
33
+ /** 取宿主 sessionPersistence 服务;旧宿主/headless 测试没有 → null。 */
34
+ function sessionPersistenceOf(ctx) {
35
+ try {
36
+ const service =
37
+ typeof ctx?.get === 'function' ? ctx.get('sessionPersistence') : ctx?.sessionPersistence;
38
+ if (service === null || service === undefined) return null;
39
+ if (typeof service.open !== 'function') return null;
40
+ return service;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function isNotFoundError(error) {
47
+ return (
48
+ error !== null &&
49
+ typeof error === 'object' &&
50
+ (error.name === 'SessionPersistenceNotFoundError' || error.code === 'session/persistence-not-found')
51
+ );
52
+ }
53
+
54
+ /** 只读打开一个**未加载**的会话;返回 header + 完整事件日志(不进入 ctx.sessions)。 */
55
+ async function readStoredSession(ctx, sessionId) {
56
+ const persistence = sessionPersistenceOf(ctx);
57
+ if (persistence === null) return { unsupported: true };
58
+ let handle = null;
59
+ try {
60
+ let snapshot;
61
+ if (typeof persistence.stat === 'function') {
62
+ snapshot = await persistence.stat(sessionId);
63
+ if (snapshot === null || snapshot === undefined) return { missing: true };
64
+ }
65
+ handle = await persistence.open(sessionId, 'read');
66
+ const result = await handle.read();
67
+ const events = Array.isArray(result?.events) ? result.events : [];
68
+ const header =
69
+ snapshot !== null && typeof snapshot === 'object' && snapshot.header !== null && typeof snapshot.header === 'object'
70
+ ? snapshot.header
71
+ : handle?.header;
72
+ return { id: sessionId, header: header ?? null, events, live: false };
73
+ } catch (error) {
74
+ if (isNotFoundError(error)) return { missing: true };
75
+ return { error: error instanceof Error ? error.message : String(error) };
76
+ } finally {
77
+ if (handle !== null) {
78
+ try {
79
+ await handle.close();
80
+ } catch {
81
+ /* 只读句柄释放失败不影响读取结果 */
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * 会话的链读取素材:优先 live(内存里最新,刚写入未落盘的记录也在),
89
+ * 否则只读持久化日志。
90
+ * @returns {Promise<{id:string, header:object|null, events:Array, live:boolean, session?:object}|{missing:true}|{error:string}|{unsupported:true}>}
91
+ */
92
+ async function sessionInfoOf(ctx, sessionId) {
93
+ const live = liveSessionOf(ctx, sessionId);
94
+ if (live !== undefined && live !== null) {
95
+ return {
96
+ id: sessionId,
97
+ header: live.header !== null && typeof live.header === 'object' ? live.header : null,
98
+ events: sessionLog(live),
99
+ live: true,
100
+ session: live,
101
+ };
102
+ }
103
+ return readStoredSession(ctx, sessionId);
104
+ }
105
+
106
+ /** 会话当前标题(老链兜底合成用):live 走标题服务,持久化日志取最后一条 session/title。 */
107
+ function titleOf(ctx, info) {
108
+ if (info.live === true && info.session !== undefined) {
109
+ const title = sourceTitleOf(ctx, info.session);
110
+ if (title !== undefined) return title;
111
+ }
112
+ const events = Array.isArray(info.events) ? info.events : [];
113
+ for (let index = events.length - 1; index >= 0; index -= 1) {
114
+ const event = events[index];
115
+ if (event?.type === 'session/title' && typeof event.data?.title === 'string' && event.data.title.length > 0) {
116
+ return event.data.title;
117
+ }
118
+ }
119
+ return undefined;
120
+ }
121
+
122
+ /**
123
+ * 这一层会话「自己的入站链」:优先 relay 记录;0.5.0 之前的老链没有记录时,
124
+ * 用 header.parentSession + 标题(「交接第 N 次」/「历史 N」)兜底合成。
125
+ * 合成条件与 readState 完全一致(排除 subagent / fork / 干净标题),见 relay.js。
126
+ */
127
+ function chainCursorOf(ctx, info) {
128
+ const inbound = relayInboundRecordOf({ id: info.id, log: info.events });
129
+ if (inbound !== null) return relayChainOf(inbound);
130
+ const legacy = legacyRelayChainOf({
131
+ session: { id: info.id, header: info.header },
132
+ title: titleOf(ctx, info),
133
+ });
134
+ return legacy === null ? null : relayChainOf(legacy);
135
+ }
136
+
137
+ /**
138
+ * 父会话日志里「交给 childId 的那一跳」的出站记录(用于补交接单 path)。
139
+ * 必须同时校验 childId + ordinal:父会话后来可能又交接给了下一代。
140
+ */
141
+ function outboundRecordFor(info, childId, ordinal) {
142
+ const events = Array.isArray(info.events) ? info.events : [];
143
+ for (let index = events.length - 1; index >= 0; index -= 1) {
144
+ const event = events[index];
145
+ if (event?.type !== RELAY_RECORD_TYPE) continue;
146
+ const record = normalizeRelayRecord(event.data);
147
+ if (record !== null && record.childId === childId && record.ordinal === ordinal) return record;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /** 行投影:只下发客户端画一行需要的字段(与旧逐级 GET 的 row 形状一致)。 */
153
+ function rowOf(sessionId, cursor, path) {
154
+ return {
155
+ sessionId,
156
+ ordinal: cursor.ordinal,
157
+ baseTitle: cursor.baseTitle ?? null,
158
+ scheme: cursor.scheme ?? null,
159
+ path: path ?? null,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * 一次读出整条交接链(最多 8 层)。
165
+ *
166
+ * 不加载任何祖先会话:未加载的会话从持久化日志读 header/事件;已加载的直接用内存日志。
167
+ * 读不到某一层时返回**已读到的行** + `unreadable`(不是把整块清空),客户端据此显示
168
+ * 「上一层会话未打开」+「打开并继续」入口。
169
+ *
170
+ * @param {object} ctx 根上下文
171
+ * @param {{sessionId?:string, maxHops?:number}} [options]
172
+ * @returns {Promise<{
173
+ * ok:true,
174
+ * sessionId:string,
175
+ * rows:Array<{sessionId:string, ordinal:number|null, baseTitle:string|null, scheme:string|null, path:string|null}>,
176
+ * done:boolean,
177
+ * capped:boolean,
178
+ * unreadable:{sessionId:string, reason:'not-found'|'error'}|null
179
+ * } | {ok:false, code:string, message:string}>}
180
+ */
181
+ export async function readRelayChain(ctx, { sessionId, maxHops = MAX_CHAIN_HOPS } = {}) {
182
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
183
+ return { ok: false, code: 'bad-session', message: '缺少 sessionId' };
184
+ }
185
+ if (sessionPersistenceOf(ctx) === null) {
186
+ return { ok: false, code: 'unsupported', message: '当前宿主没有 sessionPersistence,无法在不加载会话的情况下读取接力链' };
187
+ }
188
+
189
+ const start = await sessionInfoOf(ctx, sessionId);
190
+ if (start?.unsupported === true) {
191
+ return { ok: false, code: 'unsupported', message: '当前宿主没有 sessionPersistence,无法在不加载会话的情况下读取接力链' };
192
+ }
193
+ if (start?.missing === true) {
194
+ return { ok: false, code: 'not-found', message: '宿主持久化里找不到这个会话' };
195
+ }
196
+ if (start?.error !== undefined) {
197
+ return { ok: false, code: 'read-failed', message: `读取会话日志失败:${start.error}` };
198
+ }
199
+
200
+ const limit = Number.isInteger(maxHops) && maxHops > 0 ? Math.min(MAX_CHAIN_HOPS, maxHops) : MAX_CHAIN_HOPS;
201
+ let cursor = chainCursorOf(ctx, start);
202
+ let childId = sessionId;
203
+ const rows = [];
204
+ const seen = new Set([sessionId]);
205
+ const partial = (unreadable) => ({
206
+ ok: true,
207
+ sessionId,
208
+ rows,
209
+ done: false,
210
+ capped: false,
211
+ unreadable,
212
+ });
213
+
214
+ while (cursor !== null && cursor.parentId !== null) {
215
+ if (rows.length >= limit) {
216
+ return { ok: true, sessionId, rows, done: true, capped: true, unreadable: null };
217
+ }
218
+ const parentId = cursor.parentId;
219
+ // 链路成环/自指:去重后停在最后一条真实会话,不再重复列同一层。
220
+ if (seen.has(parentId)) {
221
+ return { ok: true, sessionId, rows, done: true, capped: false, unreadable: null };
222
+ }
223
+ const info = await sessionInfoOf(ctx, parentId);
224
+ if (info?.missing === true) return partial({ sessionId: parentId, reason: 'not-found' });
225
+ if (info?.error !== undefined) return partial({ sessionId: parentId, reason: 'error' });
226
+ if (info?.unsupported === true) {
227
+ return { ok: false, code: 'unsupported', message: '当前宿主没有 sessionPersistence,无法在不加载会话的情况下读取接力链' };
228
+ }
229
+
230
+ const outbound = outboundRecordFor(info, childId, cursor.ordinal);
231
+ rows.push(rowOf(parentId, cursor, cursor.path ?? outbound?.path ?? null));
232
+ seen.add(parentId);
233
+ childId = parentId;
234
+ cursor = chainCursorOf(ctx, info);
235
+ }
236
+
237
+ return { ok: true, sessionId, rows, done: true, capped: false, unreadable: null };
238
+ }
package/lib/dsh/relay.js CHANGED
@@ -5,7 +5,7 @@
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
9
 
10
10
  /** relay 记录里允许的标题方案(0.5.0 命名反转的两种新标题;legacy 不写进记录)。 */
11
11
  const RECORD_SCHEMES = new Set(['handoff-n', 'history-n']);
@@ -60,22 +60,126 @@ export function normalizeRelayRecord(data) {
60
60
  }
61
61
 
62
62
  /**
63
- * 会话里最近一条接力链记录(没有返回 null)。
63
+ * 从日志尾部向前找第一条满足条件的 relay 记录(没有返回 null)。
64
+ *
65
+ * 拆出来给入站/出站两个读取器共用:只扫一遍日志,判定由调用方给(DCR-01)。
64
66
  * @param {object|undefined} session
67
+ * @param {(record:object)=>boolean} predicate
65
68
  * @returns {object|null}
66
69
  */
67
- export function relayRecordOf(session) {
70
+ function latestRelayRecordWhere(session, predicate) {
68
71
  const log = session?.log;
69
72
  if (!Array.isArray(log)) return null;
70
73
  for (let index = log.length - 1; index >= 0; index -= 1) {
71
74
  const event = log[index];
72
75
  if (event?.type !== RELAY_RECORD_TYPE) continue;
73
76
  const record = normalizeRelayRecord(event.data);
74
- if (record !== null) return record;
77
+ if (record !== null && predicate(record)) return record;
75
78
  }
76
79
  return null;
77
80
  }
78
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
+
79
183
  /**
80
184
  * 0.5.0 F4:把一条 relay 记录投影成只读的「接力链节点」(交给 readState 下发)。
81
185
  *
package/lib/dsh/route.js CHANGED
@@ -51,6 +51,25 @@ export function routeOf(ctx, session, agent) {
51
51
  return asRoute(agent?.options);
52
52
  }
53
53
 
54
+
55
+ /**
56
+ * 复用「上一条真实请求」的推理档位(cache 对齐用)。
57
+ *
58
+ * 2026-10-27 P1 取证:摘要/交接单回放的是上一条主请求的前缀,但 reasoningEffort
59
+ * 会切换 provider 的 thinking 开关;一旦与主请求不同,缓存前缀会在第一条
60
+ * assistant 消息的渲染处断开(生产实测命中 2–5%,隔离 A/B:off 74.0% vs max 96.9%)。
61
+ * 所以只要 header 里的 provider/model 与本次目标一致,就原样沿用它的 reasoningEffort。
62
+ *
63
+ * @param {object} session
64
+ * @param {{provider: string, model: string}} target
65
+ * @returns {string|undefined}
66
+ */
67
+ export function cacheEffortOf(session, target) {
68
+ const config = session?.requestHeader?.()?.config;
69
+ if (config === undefined || config.provider !== target.provider || config.model !== target.model) return undefined;
70
+ return typeof config.reasoningEffort === 'string' && config.reasoningEffort.length > 0 ? config.reasoningEffort : undefined;
71
+ }
72
+
54
73
  /**
55
74
  * 复用上次真实请求的采样控制参数,让附带调用尽量贴近它的前缀缓存。
56
75
  * @param {object} session