dsh-compact-relay 0.5.0 → 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.
@@ -176,16 +176,17 @@ export function listPins(deps, { agent } = {}) {
176
176
  }
177
177
 
178
178
  /**
179
- * 菜单用:有锁定就清空,没有就锁最近一轮。
179
+ * 菜单用:有锁定(含全部已失效的快照)就清空,没有就锁最近一轮。
180
180
  *
181
- * P3-03:判断必须和 readState 的展示同口径(`livePinnedSeqs`)。只看日志里的最新
182
- * 快照时,快照 seq 已被压缩/清屏带走的场景会让菜单显示「锁定重要消息」(未锁),
183
- * 点下去却走 unpinAll → 用户看到的是「明明没锁却被解锁」。
181
+ * 0.5.1 批 2:菜单在「全部 pin 已被原生压缩带走」时显示「解除失效锁定(N 条)」,
182
+ * 所以这里必须按**快照**判定:快照非空 → unpinAll,不能因为 live=0 就反过来新锁
183
+ * 一条(按钮文案与动作会完全相反)。`pinLastTurn`/`pinByIndex` 内部仍只带 live
184
+ * 集合进新快照,顺手清掉过期 seq。
184
185
  * @param {{engine:object}} deps
185
186
  * @param {{agent:object}} input
186
187
  */
187
188
  export function togglePin(deps, { agent } = {}) {
188
189
  const session = agent?.session;
189
- if (session === null || session === undefined) throw actionError('pin', '没有活动会话');
190
- return livePinnedSeqs(session).length > 0 ? unpinAll(deps, { agent }) : pinLastTurn(deps, { agent });
190
+ if (session === null || session === undefined) throw actionError('pin', '当前没有活动会话');
191
+ return latestPinnedSeqs(sessionLog(session)).length > 0 ? unpinAll(deps, { agent }) : pinLastTurn(deps, { agent });
191
192
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 完整交接链(只读)动作层 —— HTTP 适配层与 dsh 读取器之间的薄包装。
3
+ *
4
+ * 0.5.1 DCR-05:客户端点「完整交接链」时一次取回整条链;服务端按需只读持久化日志,
5
+ * **不把祖先会话加载进 ctx.sessions**(宿主对未加载会话的 readState 返回 sessionId:null,
6
+ * 逐级 GET 会停在断点)。读取细节见 dsh/relay-chain.js。
7
+ */
8
+ import { readRelayChain } from '../dsh/relay-chain.js';
9
+
10
+ /**
11
+ * @param {{ctx:object}} deps
12
+ * @param {{sessionId?:string}} [input]
13
+ * @returns {ReturnType<typeof readRelayChain>}
14
+ */
15
+ export function readCompactRelayChain(deps, { sessionId } = {}) {
16
+ return readRelayChain(deps?.ctx, { sessionId });
17
+ }
@@ -6,15 +6,19 @@
6
6
  */
7
7
  import { paletteForBothSchemes } from '../core/colors.js';
8
8
  import { adviceFor, ratioOf, thresholdsForWindow } from '../core/policy.js';
9
+ import { nativePinnedRiskOf } from '../core/native-pin-risk.js';
10
+ import { pinHealthOf } from '../core/pin-health.js';
11
+ import { latestPinnedSeqs } from '../core/pins.js';
9
12
  import { tokenOf } from '../core/tokens.js';
10
13
  import { measurePressure, routeBudgetOf } from '../dsh/pressure.js';
11
14
  import { selectCompactableRange } from '../dsh/select-range.js';
12
- import { compressHint as compressHintOf, countSummaries, lastCheckpointSurfaceSeq, livePinnedSeqs, liveSessionOf, pickAgent, surfaceSeqs } from '../dsh/session-view.js';
15
+ import { compressHint as compressHintOf, countSummaries, firstCompactableIdx, lastCheckpointSurfaceSeq, liveSessionOf, pickAgent, sessionLog, surfaceSeqs } from '../dsh/session-view.js';
13
16
  import { compressHintIsActive } from '../core/compress-hint.js';
14
17
  import { readNativeCompaction, resolveNativePolicy } from '../dsh/native-compaction.js';
15
- import { nativeLineOf } from '../core/native-line.js';
16
- import { relayChainOf, relayRecordOf } from '../dsh/relay.js';
17
- import { isSessionArchived } from '../dsh/handoff-archive.js';
18
+ import { nativeLineOf, nativeRetainTokensOf } from '../core/native-line.js';
19
+ import { legacyChainParentIdOf, legacyRelayChainOf, relayChainOf, relayInboundRecordOf, relayOutboundRecordOf, relayRecordOf } from '../dsh/relay.js';
20
+ import { isSessionArchived, isSessionPinned, workspaceRegistryOf } from '../dsh/handoff-archive.js';
21
+ import { sourceTitleOf } from '../dsh/sessions.js';
18
22
 
19
23
  /**
20
24
  * 动作层统一解析「这次动作作用在哪个 agent」:只认显式 sessionId,
@@ -96,10 +100,20 @@ export async function readState(deps, { sessionId, agent } = {}) {
96
100
  const compactRangeReason = compactRange !== null && !hasLiveAgent ? 'no_agent' : null;
97
101
 
98
102
  // M6:钉住集合(菜单显示「清除钉住(N 条)」,也能在无 agent 时读 live session)。
99
- const pinnedSeqs = target === null ? [] : livePinnedSeqs(target.session);
103
+ // 0.5.1 批 2(C 方案):pin 快照 vs 当前 surface ——
104
+ // live = 还在 surface 上(环/计数/解锁仍按这些);
105
+ // stale = 快照里有、surface 上没了(原生压缩/其它压缩/历史变化带走)——
106
+ // 单独下发,界面标「已失效」,既不假装还在,也不凭空消失。
107
+ const surface = target === null ? [] : surfaceSeqs(target.session);
108
+ const events = target === null ? [] : sessionLog(target.session);
109
+ const pinHealth =
110
+ target === null || surface.length === 0
111
+ ? // 没有 surface(会话刚打开/尚未回放)时不能把全部 pin 判成失效:那是「还不知道」。
112
+ { live: [], stale: [], cause: null, causes: { native: 0, other: 0, unknown: 0 } }
113
+ : pinHealthOf({ pinnedSeqs: latestPinnedSeqs(events), surfaceSeqs: surface, log: events });
114
+ const pinnedSeqs = pinHealth.live;
100
115
  // 0.3.1:锁定消息在 surface 里的相对位置(0–1,方案 A:消息在历史中的位置),
101
116
  // 指示灯环上按位置画锁定块;多个锁定按位置从早到晚排序。
102
- const surface = target === null ? [] : surfaceSeqs(target.session);
103
117
  const surfaceIndex = new Map(surface.map((seq, index) => [seq, index]));
104
118
  const pinMarks = pinnedSeqs
105
119
  .filter((seq) => surfaceIndex.has(seq))
@@ -145,16 +159,88 @@ export async function readState(deps, { sessionId, agent } = {}) {
145
159
  }
146
160
  }
147
161
 
148
- // A5 / 0.5.0 F4:接力链记录只读一次。`relay` 是旧字段(原样下发,一个不改);
149
- // `chain` 是它的只读投影,链号/原题/父子/scheme/path 直接给出,客户端不再自己解析。
150
- // 没有记录 → 两者都是 null;旧记录没有的字段 → chain 里为 null(不猜)。
162
+ // 0.5.1 批 2:预判「下一次原生自动压缩会覆盖哪些锁定消息」——事前提示。
163
+ //
164
+ // 宿主信号:原生自动压缩只在 step boundary 触发(pressure ≥ 真实触发线后),
165
+ // 选段 = `selectCompactableRange(session, measurement, retainTokens)`:从第一个非
166
+ // system 节点起,逐字保留尾部 retainTokens,再按工具配对平衡回退。这里用同一份
167
+ // 定价节点(measured.nodes)+ 同一套保留预算复刻该范围,只读、不触发任何压缩。
168
+ //
169
+ // 只在「自动压缩开着 + 已到真实触发线 + 节点定价与 surface 对齐」时预警;
170
+ // 算不出/对不齐就返回 null(宁可不报,也不误报会覆盖)。
171
+ let pinRisk = null;
172
+ if (
173
+ target !== null &&
174
+ pinnedSeqs.length > 0 &&
175
+ nativeCfg.autoEnabled !== false &&
176
+ nativeLine !== null &&
177
+ nativeLine.ok === true &&
178
+ Number.isFinite(measured.tokens) &&
179
+ measured.tokens >= nativeLine.thresholdTokens &&
180
+ nativeBudget !== null &&
181
+ Array.isArray(measured.nodes) &&
182
+ measured.nodes.length === surface.length &&
183
+ measured.nodes.every((node, index) => node?.seq === surface[index])
184
+ ) {
185
+ const retainTokens = nativeRetainTokensOf({
186
+ contextWindow: nativeBudget.contextWindow,
187
+ reservedCompletionTokens: nativeBudget.reservedCompletionTokens,
188
+ retainRatio: nativePolicy.retainRatio,
189
+ retainTokens: nativePolicy.retainTokens,
190
+ });
191
+ if (retainTokens !== null) {
192
+ const risk = nativePinnedRiskOf({
193
+ pinnedSeqs,
194
+ nodes: measured.nodes,
195
+ firstIdx: firstCompactableIdx(target.session),
196
+ retainTokens,
197
+ isBalancedBefore: (seq) => {
198
+ try {
199
+ return deps.engine?.toolPairingBalancedBefore?.(target.session, seq) === true;
200
+ } catch {
201
+ return true; // 判定失败时按「可切」处理:只影响预警范围,不会真的压
202
+ }
203
+ },
204
+ });
205
+ if (risk !== null) pinRisk = { count: risk.count, seqs: risk.seqs };
206
+ }
207
+ }
208
+
209
+ //
210
+ // `relay` 是旧字段:日志里**最近一条**记录(出站/入站都可能)——语义一个字节不改,
211
+ // 下一次交接的链号递增(handoffTitlePlanOf)也依赖它。
212
+ // `chain` 改成「本会话入站记录」(childId===本会话)的只读投影:祖先会话再次交接后,
213
+ // 最近的记录变成出站(childId=下一代),旧实现照它下发 → 客户端「是不是本会话入站」
214
+ // 判定不成立 → 接力条消失(DCR-01)。没有入站记录时(DCR-02)用会话头
215
+ // header.parentSession + 标题兜底合成只读链(排除 subagent 会话与 fork 出来的会话),
216
+ // 让 0.5.0 之前生成、子会话没有记录的老链也能显示接力条。
151
217
  const relay = target === null ? null : relayRecordOf(target.session);
152
- const chain = relay === null ? null : relayChainOf(relay);
218
+ const inbound = target === null ? null : relayInboundRecordOf(target.session);
219
+ const chain =
220
+ target === null
221
+ ? null
222
+ : inbound !== null
223
+ ? relayChainOf(inbound)
224
+ : // DCR-02:只有会话头看起来像老链时才去读标题(普通会话不付这次查找开销)。
225
+ legacyChainParentIdOf(target.session) === null
226
+ ? null
227
+ : legacyRelayChainOf({ session: target.session, title: sourceTitleOf(deps.ctx, target.session) });
228
+ // DCR-01:`next` = 本会话最近一条**出站**记录的只读投影(只加不改)。接力条不看它,
229
+ // 但「父→子入口」与「子会话向父会话补交接单 path」需要出站那一跳的信息。
230
+ const next = target === null ? null : relayChainOf(relayOutboundRecordOf(target.session));
153
231
  // 0.5.0:旧会话是否已被自动归档(接力条据此显示「恢复旧会话」)。这是宿主
154
- // registry 的独立状态,不塞进 chain(chain 仍然只是 relay 记录的纯投影)。
232
+ // registry 的独立状态,不塞进 chain(chain 仍然只是链投影)。
233
+ // 0.5.1 DCR-01:用**入站** parentId 查(出站记录里的父会话是本会话自己,会查错)。
155
234
  // 拿不到 registry/归档集合 → null(不知道,客户端按「可尝试恢复」处理)。
156
235
  const parentArchived =
157
236
  chain === null || chain.parentId === null ? null : isSessionArchived(deps.ctx, chain.parentId);
237
+ // 0.5.1 批 3:出站记录的目标会话(下一代)是否已在归档集合里。父会话侧的「去新会话」
238
+ // 用它决定要不要先 unarchive(宿主拒绝打开归档会话;unarchive 幂等)。
239
+ // 和 parentArchived 完全对称:各查各的 id,读不到 → null(不猜 false)。
240
+ const nextArchived = next === null || next.childId === null ? null : isSessionArchived(deps.ctx, next.childId);
241
+ // 0.5.1 DCR-03:宿主有没有「恢复归档会话」的接缝(HTTP recover 动作调的就是它)。
242
+ // 客户端拿不到 ctx.uiWorkspace.unarchiveSession 时据此隐藏「恢复旧会话」死入口。
243
+ const capabilities = { unarchive: typeof workspaceRegistryOf(deps.ctx)?.unarchiveSession === 'function' };
158
244
 
159
245
  return {
160
246
  revision: ++stateRevision,
@@ -166,7 +252,19 @@ export async function readState(deps, { sessionId, agent } = {}) {
166
252
  summaries,
167
253
  advice,
168
254
  level: advice === 'none' ? 'idle' : advice,
169
- pins: { count: pinnedSeqs.length, seqs: pinnedSeqs.slice(-20), marks: pinMarks },
255
+ // 0.5.1 批 2:stale = 快照里有、surface 上已没有的 pin(被压缩/历史变化带走)。
256
+ // `count` 仍是 live 集合(老客户端语义不变);`stale` / `atRisk` 只加不改。
257
+ pins: {
258
+ count: pinnedSeqs.length,
259
+ seqs: pinnedSeqs.slice(-20),
260
+ marks: pinMarks,
261
+ stale: {
262
+ count: pinHealth.stale.length,
263
+ seqs: pinHealth.stale.slice(-20),
264
+ ...pinHealth.causes,
265
+ },
266
+ atRisk: pinRisk,
267
+ },
170
268
  compactRange,
171
269
  // 0.3.5:compactRange 非 null 但无活 agent 时为 'no_agent',其它情况 null。
172
270
  compactRangeReason,
@@ -178,12 +276,25 @@ export async function readState(deps, { sessionId, agent } = {}) {
178
276
  settingsError: deps.settings?.error ?? null,
179
277
  // A5:最近一次交接的接力链记录(没有就是 null)。0.5.0 F4:旧字段原样保留。
180
278
  relay,
181
- // 0.5.0 F4:同一条记录的只读链投影 {ordinal, baseTitle, parentId, childId, scheme, path?};
182
- // 只加不改:老客户端忽略它,新客户端不再解析标题。没有记录 → null。
279
+ // 0.5.0 F4 / 0.5.1 DCR-01:本会话**入站**记录的只读链投影
280
+ // {ordinal, baseTitle, parentId, childId, scheme, path?};老链(无记录)可能是
281
+ // header 兜底合成的(多一个 synthesized:true)。没有链信息 → null。
183
282
  chain,
184
- // 0.5.0:链上父会话当前是否在 workspace 归档集合里(true/false);没有父会话或
185
- // 宿主没有可读的归档状态 → null。只加不改。
283
+ // 0.5.1 DCR-01:最近一条**出站**记录的只读投影(本会话交接给下一代那一跳)。
284
+ // 只加不改:老客户端忽略;接力条不用它。没有出站记录 → null。
285
+ next,
286
+ // 0.5.0:链上父会话(入站 parentId)当前是否在 workspace 归档集合里(true/false);
287
+ // 没有父会话或宿主没有可读的归档状态 → null。只加不改。
186
288
  parentArchived,
289
+ // 0.5.1 批 3:出站目标(next.childId)是否在同一归档集合里。父会话侧「去新会话」
290
+ // 据此先恢复再切;没有出站记录或读不到归档集合 → null。只加不改。
291
+ nextArchived,
292
+ // 0.5.1 批 2:本会话(agent.session,不是 agent.id)是否在侧栏置顶。
293
+ // 交接前客户端据此弹「归档会取消置顶」确认;读不到置顶集合 → null(不猜)。
294
+ sessionPinned: target === null || target.session === undefined ? null : isSessionPinned(deps.ctx, target.session.id),
295
+ // 0.5.1 DCR-03:宿主能力位(目前只有「能不能恢复归档会话」)。老客户端忽略;
296
+ // 新客户端在缺 `unarchive` 能力时隐藏恢复入口,不把用户带进必然失败的入口。
297
+ capabilities,
187
298
  // 0.3.1:模型判断的「适合压缩」断点位置(没有或已失效为 null)。
188
299
  compressHint: activeHint,
189
300
  // 0.3.1:DSH 原生自动压缩(环上的报警最高阈值 / 设置页开关的数据源)。
@@ -9,6 +9,7 @@ import { readDiagnostics } from '../actions/diagnostics.js';
9
9
  import { handoff, previewHandoff } from '../actions/handoff.js';
10
10
  import { listHandoffDocs, readHandoffDoc } from '../actions/handoff-docs.js';
11
11
  import { recoverArchivedSession } from '../actions/recover.js';
12
+ import { readCompactRelayChain } from '../actions/relay-chain.js';
12
13
  import { togglePin } from '../actions/pin.js';
13
14
  import { setNativeAutoCompactionEnabled } from '../actions/native-compaction.js';
14
15
  import { readState, resolveTarget } from '../actions/state.js';
@@ -183,6 +184,38 @@ export function registerHttp(webCtx, deps) {
183
184
  });
184
185
  webCtx.effect(() => stateStop, 'compact-relay: state endpoint');
185
186
 
187
+ // 0.5.1 DCR-05:完整交接链(一次读完,最多 8 层)。
188
+ //
189
+ // 旧客户端逐级 GET `/api/compact-relay-state?sessionId=父id`,宿主对未加载的祖先返回
190
+ // `sessionId:null` → 整条链只剩 1 层。这里改为服务端只读持久化日志(不加载会话),
191
+ // 把整条链一次返回;旧宿主没有 sessionPersistence 时返回 `ok:false`,客户端回落逐级读。
192
+ const chainStop = webCtx.webServer.register({
193
+ kind: 'exact',
194
+ path: '/api/compact-relay-chain',
195
+ handler: (req, res) => {
196
+ void (async () => {
197
+ try {
198
+ const rejection = hostRejection(webCtx, req);
199
+ if (rejection !== undefined) return denyAuth(res, rejection);
200
+ if (req.method !== 'GET') return send(res, 405, { ok: false, message: '只支持 GET' });
201
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
202
+ const sessionId = url.searchParams.get('sessionId') ?? '';
203
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
204
+ return send(res, 400, { ok: false, message: '缺少 sessionId' });
205
+ }
206
+ send(res, 200, await readCompactRelayChain(deps, { sessionId }));
207
+ } catch (error) {
208
+ try {
209
+ send(res, 500, { ok: false, message: errorText(error) });
210
+ } catch {
211
+ /* 连接可能已断 */
212
+ }
213
+ }
214
+ })();
215
+ },
216
+ });
217
+ webCtx.effect(() => chainStop, 'compact-relay: relay chain endpoint');
218
+
186
219
  const streamStop = webCtx.webServer.register({
187
220
  kind: 'exact',
188
221
  path: '/api/compact-relay-stream',
@@ -383,6 +416,13 @@ export function registerHttp(webCtx, deps) {
383
416
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
384
417
  return send(res, 400, { ok: false, message: '请求体必须是 JSON 对象' });
385
418
  }
419
+ // 0.5.1 批 2:交接的归档开关 / 置顶确认只接受布尔值;类型错在动历史前就拒绝。
420
+ if (parsed.archive !== undefined && typeof parsed.archive !== 'boolean') {
421
+ return send(res, 400, { ok: false, message: 'archive 必须是 true/false(false = 只交接、不归档旧会话)' });
422
+ }
423
+ if (parsed.confirmUnpin !== undefined && typeof parsed.confirmUnpin !== 'boolean') {
424
+ return send(res, 400, { ok: false, message: 'confirmUnpin 必须是 true/false(true = 确认归档并取消旧会话置顶)' });
425
+ }
386
426
  // 多数动作作用在具体会话上:缺 sessionId 或类型错时绝不能退化成「最后一个 agent」
387
427
  // (2026-10-19 debug:旧实现会压错会话)。
388
428
  // 例外:
@@ -462,12 +502,19 @@ export function registerHttp(webCtx, deps) {
462
502
  }
463
503
  if (parsed.action === 'handoff') {
464
504
  // 0.4 P2-1:`edits` 只允许「约束」「下一步」两段(多余的段/非法类型由 actions 拒绝)。
465
- const outcome = await handoff(deps, { agent, edits: parsed.edits });
505
+ // 0.5.1 批 2:archive 缺省 true(0.5.0 行为);confirmUnpin 只有用户确认「归档会取消置顶」时才传。
506
+ const outcome = await handoff(deps, {
507
+ agent,
508
+ edits: parsed.edits,
509
+ archive: parsed.archive,
510
+ confirmUnpin: parsed.confirmUnpin,
511
+ });
466
512
  return send(res, 200, {
467
513
  ok: true,
468
514
  message: outcome.message,
469
515
  childId: outcome.childId,
470
516
  path: outcome.path,
517
+ archive: outcome.archive,
471
518
  });
472
519
  }
473
520
  return send(res, 400, { ok: false, message: `未知动作:${String(parsed.action)}` });