dsh-compact-relay 0.3.6 → 0.4.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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * 诊断信息动作(dcr 0.4 P0-2)—— HTTP 适配层与(将来的)命令入口共用的唯一组装实现。
3
+ *
4
+ * 为什么放在 actions 层:仓库 A9 架构红线是「adapters 不能直接 import dsh/」,
5
+ * 而诊断产物的构造(白名单裁剪 + 脱敏 + 尺寸上限)在 dsh/diagnostics.js。
6
+ * 这里只做「取数 + 裁剪」:deps.cfg / settings.view() / readState / deps.diag.snapshot(),
7
+ * 然后把白名单输入交给 dsh 层构造,适配层只负责协议转换。
8
+ *
9
+ * 只读、幂等、无副作用:不写文件、不写日志、不改任何插件状态。
10
+ */
11
+ import os from 'node:os';
12
+ import { buildDiagnostics, renderIssueUrl } from '../dsh/diagnostics.js';
13
+ import { readState } from './state.js';
14
+
15
+ /** 运行时环境事实(只取进程/系统信息,不读会话、不读路径)。 */
16
+ function runtimeOf() {
17
+ let osRelease = null;
18
+ try {
19
+ if (typeof os.release === 'function') osRelease = os.release();
20
+ } catch {
21
+ osRelease = null;
22
+ }
23
+ let locale = null;
24
+ try {
25
+ locale = Intl.DateTimeFormat().resolvedOptions().locale;
26
+ } catch {
27
+ locale = null;
28
+ }
29
+ return {
30
+ // 现有代码没有稳定的 DSH 版本来源:不猜,保持 null(方案 §4)。
31
+ dshVersion: null,
32
+ node: typeof process.version === 'string' ? process.version : null,
33
+ platform: typeof process.platform === 'string' ? process.platform : null,
34
+ arch: typeof process.arch === 'string' ? process.arch : null,
35
+ osRelease,
36
+ locale,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * 配置白名单:阈值/保留量/超时/颜色 + 两个「是否被用户改过」的布尔。
42
+ * 交接目录与自定义链接**只出改没改,绝不出值**(方案 §2)。
43
+ */
44
+ function configOf(deps) {
45
+ const cfg = deps.cfg !== null && typeof deps.cfg === 'object' ? deps.cfg : {};
46
+ let view = null;
47
+ try {
48
+ view = typeof deps.settings?.view === 'function' ? deps.settings.view() : null;
49
+ } catch {
50
+ view = null;
51
+ }
52
+ const current = view !== null && view.config !== null && typeof view.config === 'object' ? view.config : null;
53
+ const defaults = view !== null && view.defaults !== null && typeof view.defaults === 'object' ? view.defaults : null;
54
+ const changed = (read) => (current === null || defaults === null ? false : read(current) !== read(defaults));
55
+ const timeouts = cfg.timeouts !== null && typeof cfg.timeouts === 'object' ? cfg.timeouts : {};
56
+ return {
57
+ version: view !== null && typeof view.version === 'string' ? view.version : null,
58
+ config: {
59
+ thresholds: {
60
+ compactRatio: cfg.thresholds?.compactRatio,
61
+ handoffRatio: cfg.thresholds?.handoffRatio,
62
+ maxSummaries: cfg.thresholds?.maxSummaries,
63
+ },
64
+ retention: { recentTailTokens: cfg.retention?.recentTailTokens },
65
+ // 仓库只有一个模型调用超时(llmMs);契约的分项字段没有独立来源时复用它。
66
+ timeouts: {
67
+ compactMs: timeouts.compactMs ?? timeouts.llmMs,
68
+ handoffMs: timeouts.handoffMs ?? timeouts.llmMs,
69
+ },
70
+ colors: { mode: cfg.colors?.mode, autoAdapt: cfg.colors?.autoAdapt },
71
+ handoffDirCustomized: changed((value) => value.handoffDir),
72
+ linksCustomized: changed((value) => `${value.links?.tutorial ?? ''}\u0000${value.links?.feedback ?? ''}`),
73
+ },
74
+ };
75
+ }
76
+
77
+ /** 把 readState 快照裁成契约白名单;无活动会话返回 null(契约里 state 整块 null)。 */
78
+ function stateOf(state) {
79
+ if (state === null || typeof state !== 'object' || typeof state.sessionId !== 'string') return null;
80
+ const range = state.compactRange !== null && typeof state.compactRange === 'object' ? state.compactRange : null;
81
+ const native = state.native !== null && typeof state.native === 'object' ? state.native : null;
82
+ return {
83
+ window: state.window,
84
+ tokens: state.pressure,
85
+ ratio: state.ratio,
86
+ summaries: state.summaries,
87
+ pins: state.pins !== null && typeof state.pins === 'object' ? state.pins.count : state.pins,
88
+ advice: state.advice,
89
+ compactRange: range === null ? null : { nodes: range.nodes, tokens: range.tokens },
90
+ nativeAuto: native === null ? null : { enabled: native.autoEnabled, lineRatio: native.thresholdRatio },
91
+ };
92
+ }
93
+
94
+ /**
95
+ * 组装一份诊断信息(HTTP 端点 / 未来命令入口共用)。
96
+ *
97
+ * @param {{ctx:object, cfg:object, settings?:object, diag?:object}} deps
98
+ * @param {{sessionId?:string}} [input]
99
+ * @returns {Promise<{schema:string, version:string|null, generatedAt:string, markdown:string, json:object, issueUrl:string}>}
100
+ */
101
+ export async function readDiagnostics(deps, { sessionId } = {}) {
102
+ const meta = configOf(deps);
103
+ const runtime = runtimeOf();
104
+ // 无活动会话时 readState 仍返回合法对象(sessionId=null)→ 契约里 state 整块 null。
105
+ let state = null;
106
+ try {
107
+ state = stateOf(await readState(deps, { sessionId }));
108
+ } catch {
109
+ state = null;
110
+ }
111
+ const snapshot = deps.diag?.snapshot?.() ?? {};
112
+ const built = buildDiagnostics({
113
+ version: meta.version,
114
+ runtime,
115
+ config: meta.config,
116
+ state,
117
+ counters: snapshot.counters,
118
+ latency: snapshot.latency,
119
+ errors: snapshot.errors,
120
+ });
121
+ return {
122
+ schema: built.json.schema,
123
+ version: meta.version,
124
+ generatedAt: built.json.generatedAt,
125
+ markdown: built.markdown,
126
+ json: built.json,
127
+ // issue URL 由宿主拼(短字段),客户端只负责 window.open。
128
+ issueUrl: renderIssueUrl({ version: meta.version, runtime }),
129
+ };
130
+ }
@@ -18,7 +18,10 @@
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { actionError } from '../core/errors.js';
21
+ import { pinAnchorOf } from '../core/pin-anchors.js';
21
22
  import { handoffFileName, renderHandoffDoc } from '../core/handoff-doc.js';
23
+ import { applyHandoffEdits, editableHandoffView, normalizeHandoffEdits } from '../core/handoff-edit.js';
24
+ import { composeHandoffText } from '../core/handoff-spec.js';
22
25
  import { betterCompactLocked } from './better-compact.js';
23
26
  import { compressHint, countSummaries, livePinnedSeqs, surfaceSeqs } from '../dsh/session-view.js';
24
27
  import { measurePressure } from '../dsh/pressure.js';
@@ -27,6 +30,7 @@ import { generateHandoffDoc } from '../dsh/handoff-writer.js';
27
30
  import { buildHandoffProgramSections } from '../core/handoff-facts.js';
28
31
  import { checkHandoffReadiness } from '../core/readiness.js';
29
32
  import { changedFilesOf } from '../dsh/session-files.js';
33
+ import { errorCodeOf, localIso } from '../dsh/diag-counters.js';
30
34
  import { surfaceMessages } from '../dsh/prefix.js';
31
35
  import { handoffTitlePlanOf, createHandoffSession, sourceTitleOf } from '../dsh/sessions.js';
32
36
  import { writeRelayRecord } from '../dsh/relay.js';
@@ -39,7 +43,8 @@ import { beginInflight, cancelErrorOf, combinedSignal, finishInflight } from './
39
43
  */
40
44
  async function compactFirst(deps, { agent, signal, commandId, log }) {
41
45
  try {
42
- return { outcome: await betterCompactLocked(deps, { agent, signal, commandId }), warning: null };
46
+ // P1-3:交接内部的压缩不单独记账(避免一次 /handoff 记两条);由交接记录统一反映。
47
+ return { outcome: await betterCompactLocked(deps, { agent, signal, commandId, ledgerRecord: false }), warning: null };
43
48
  } catch (error) {
44
49
  const code = error !== null && typeof error === 'object' ? error.code : undefined;
45
50
  // 只有「事务开始前/未提交」的失败能降级继续(结构、范围、摘要、内容变化)。
@@ -54,7 +59,7 @@ async function compactFirst(deps, { agent, signal, commandId, log }) {
54
59
 
55
60
  /**
56
61
  * 收集交接单需要的全部会话事实(A1):压力/现场快照/摘要数/锁定/改过的文件/程序段。
57
- * @returns {Promise<{session:object, measured:object, now:Date, site:object, summaries:number, pinnedSeqs:number[], changedFiles:string[], programSections:Record<string,string>}>}
62
+ * @returns {Promise<{session:object, measured:object, now:Date, site:object, summaries:number, pinnedSeqs:number[], pinnedAnchors:Array<{seq:number,role:string,snippet:string}>, changedFiles:string[], programSections:Record<string,string>}>}
58
63
  */
59
64
  async function collectHandoffFacts(deps, agent) {
60
65
  const session = agent.session;
@@ -65,27 +70,63 @@ async function collectHandoffFacts(deps, agent) {
65
70
  const site = buildSiteSnapshot({ session, now });
66
71
  const summaries = countSummaries(session);
67
72
  const pinnedSeqs = livePinnedSeqs(session);
73
+ // 0.4 P1-2:pin 保留契约锚点 = seq + 角色 + 一句摘要(≤60 字符,不贴原文全文)。
74
+ const pinnedAnchors = pinnedSeqs
75
+ .map((seq) => {
76
+ const event = typeof session.eventAt === 'function' ? session.eventAt(seq) : undefined;
77
+ return event === undefined || event === null ? null : pinAnchorOf(event);
78
+ })
79
+ .filter((anchor) => anchor !== null);
68
80
  const changedFiles = changedFilesOf(session);
69
81
  const programSections = buildHandoffProgramSections({
70
82
  compressHint: compressHint(session),
71
83
  summaries,
72
84
  pressure: measured.tokens,
73
- pins: { count: pinnedSeqs.length, seqs: pinnedSeqs },
85
+ pins: { count: pinnedSeqs.length, seqs: pinnedSeqs, anchors: pinnedAnchors },
74
86
  changedFiles,
75
87
  site,
76
88
  });
77
- return { session, measured, now, site, summaries, pinnedSeqs, changedFiles, programSections };
89
+ return { session, measured, now, site, summaries, pinnedSeqs, pinnedAnchors, changedFiles, programSections };
78
90
  }
79
91
 
80
92
  /**
93
+ * 交接新会话(/handoff、菜单「交接新会话」、命令卡)—— 对外入口 = 诊断埋点采集点。
94
+ *
95
+ * 0.4 P0-2:只在这里数「交接成功/失败 / 耗时 / 错误码」,失败原因只记 code,
96
+ * 不记 message(message 可能带交接单路径/会话内容)。行为与文案不变。
97
+ *
98
+ * @param {{ctx:object, cfg:object, lock:object, engine:object, log?:object, diag?:object}} deps
99
+ * @param {{agent:object, signal?:AbortSignal, commandId?:string, edits?:Record<string,string>}} input
100
+ * edits 只允许 `{约束?:string, 下一步?:string}`(P2-1;非法字段抛 code='edits')
101
+ */
102
+ export async function handoff(deps, { agent, signal, commandId, edits } = {}) {
103
+ const startedAt = Date.now();
104
+ try {
105
+ const outcome = await runHandoff(deps, { agent, signal, commandId, edits });
106
+ deps.diag?.bumpAction('handoff', true);
107
+ return outcome;
108
+ } catch (error) {
109
+ deps.diag?.bumpAction('handoff', false);
110
+ deps.diag?.recordError(errorCodeOf(error), 'handoff');
111
+ throw error;
112
+ } finally {
113
+ deps.diag?.recordDuration('handoff', Date.now() - startedAt);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * 交接流程本体。
81
119
  * @param {{ctx:object, cfg:object, lock:object, engine:object, log?:object}} deps
82
120
  * @param {{agent:object, signal?:AbortSignal, commandId?:string}} input
83
121
  */
84
- export async function handoff(deps, { agent, signal, commandId }) {
122
+ async function runHandoff(deps, { agent, signal, commandId, edits }) {
85
123
  if (agent === undefined || agent === null) throw actionError('handoff', '当前没有打开的会话,无法交接');
86
124
  const session = agent.session;
87
125
  if (session === undefined) throw actionError('handoff', '没有活动会话,无法交接');
88
126
 
127
+ // 0.4 P2-1:先校验用户编辑(只允许「约束」「下一步」),非法字段在压缩/写盘之前就拒绝。
128
+ const normalizedEdits = normalizeHandoffEdits(edits);
129
+
89
130
  return withLock(deps.lock, agent.id, async () => {
90
131
  // P1-4:交接登记进 inflight,client 的取消按钮才能真正 abort 模型调用。
91
132
  const controller = beginInflight(deps, agent.id, 'handoff');
@@ -104,6 +145,12 @@ export async function handoff(deps, { agent, signal, commandId }) {
104
145
  );
105
146
  }
106
147
 
148
+ // 0.4 P1-3:轻账本——先记下「压前占用」(交接内部的压缩会改变之后的压力读数)。
149
+ const startedAt = Date.now();
150
+ const beforeMeasured = await measurePressure(deps.ctx, agent, { windowFallback: deps.cfg.contextWindowFallback }).catch(
151
+ () => null,
152
+ );
153
+
107
154
  // 1) 先压缩旧会话一次(best effort)。
108
155
  const { outcome: compactOutcome, warning: compactWarning } = await compactFirst(deps, {
109
156
  agent,
@@ -129,9 +176,14 @@ export async function handoff(deps, { agent, signal, commandId }) {
129
176
  });
130
177
  combined?.throwIfAborted?.();
131
178
 
179
+ // 3.2) 0.4 P2-1:套用「约束 / 下一步」两段的用户编辑(预览→确认链路)。
180
+ // 只覆盖这两段;程序/模型生成的其余七段原样保留(前后端都不接受其它字段)。
181
+ const editedSections = applyHandoffEdits(generated.sections, normalizedEdits);
182
+ const editedText = composeHandoffText(editedSections);
183
+
132
184
  // 3.5) 就绪检查:九段缺段/占位阻断;路径核对只警告,不阻断。
133
185
  const readiness = checkHandoffReadiness({
134
- sections: generated.sections,
186
+ sections: editedSections,
135
187
  surfaceText: JSON.stringify(surfaceMessages(facts.session)),
136
188
  changedFiles: facts.changedFiles,
137
189
  messageCount: surfaceSeqs(facts.session).length,
@@ -141,10 +193,11 @@ export async function handoff(deps, { agent, signal, commandId }) {
141
193
  }
142
194
  for (const warning of readiness.warnings) deps.log?.warn?.(`compact-relay: 交接单就绪警告:${warning}`);
143
195
 
144
- // 4) 写盘:文档头带元信息,九段正文 = 模型 7 段 + 程序 2 段。
196
+ // 4) 写盘:文档头带元信息,九段正文 = 模型 7 段 + 程序 2 段 + 用户编辑的两段。
197
+ const titlePlan = handoffTitlePlanOf(deps.ctx, session);
145
198
  const file = path.join(deps.cfg.handoffDir, handoffFileName(facts.now));
146
199
  const doc = renderHandoffDoc({
147
- generated: generated.text,
200
+ generated: editedText,
148
201
  sourceSessionId: facts.session.id,
149
202
  sourceTitle: sourceTitleOf(deps.ctx, facts.session),
150
203
  now: facts.now,
@@ -154,6 +207,8 @@ export async function handoff(deps, { agent, signal, commandId }) {
154
207
  // M6:把「宿主刚重启过 / 工作区分支 / 有没有后台任务在跑」写进文档头,
155
208
  // 免得接手者按一份出厂即过时的「下一步」干活。
156
209
  site: facts.site,
210
+ // 0.4 P2-2:文档头直接写「第 N 次交接」,接手者不用解析标题。
211
+ ordinal: titlePlan.index,
157
212
  });
158
213
  try {
159
214
  fs.writeFileSync(file, doc, 'utf8');
@@ -167,7 +222,6 @@ export async function handoff(deps, { agent, signal, commandId }) {
167
222
 
168
223
  // 5) 新会话:同工作区 / 同 preset / 同模型,注入交接单并起第一轮。
169
224
  // A5:标题序号优先读父会话里的 relay 记录(不依赖解析标题文案)。
170
- const titlePlan = handoffTitlePlanOf(deps.ctx, session);
171
225
  let created;
172
226
  try {
173
227
  created = await createHandoffSession({
@@ -190,13 +244,18 @@ export async function handoff(deps, { agent, signal, commandId }) {
190
244
  }
191
245
 
192
246
  // A5:把接力链写成父会话里的结构化记录;写失败不阻断交接(标题解析仍可兜底)。
247
+ // 0.4 P2-2:canonical 字段 parentId/childId/ordinal/at/atIso 补齐;旧字段 parentSessionId/index 保留兼容。
248
+ const relayAt = Date.now();
193
249
  const relayWrote = writeRelayRecord(deps.engine, session, {
194
- parentSessionId: session.id,
250
+ parentId: session.id,
195
251
  childId: created.childId,
252
+ ordinal: titlePlan.index,
253
+ at: relayAt,
254
+ atIso: localIso(new Date(relayAt)),
255
+ parentSessionId: session.id,
196
256
  index: titlePlan.index,
197
257
  path: file,
198
258
  planId: compactOutcome?.planId ?? null,
199
- at: Date.now(),
200
259
  title: titlePlan.title,
201
260
  baseTitle: titlePlan.baseTitle,
202
261
  });
@@ -219,6 +278,22 @@ export async function handoff(deps, { agent, signal, commandId }) {
219
278
  ? `压缩失败已跳过(${briefWarning});需要的话可先手动 /bc 再交接;`
220
279
  : '';
221
280
 
281
+ // 0.4 P1-3:交接成功记一条轻账本(只记数值/路径;不含消息/摘要/交接单正文)。
282
+ try {
283
+ deps.ledger?.record?.({
284
+ kind: 'handoff',
285
+ durationMs: Date.now() - startedAt,
286
+ beforeTokens: beforeMeasured === null ? null : beforeMeasured.tokens,
287
+ afterTokens: facts.measured.tokens,
288
+ beforeRatio: beforeMeasured === null ? null : beforeMeasured.ratio,
289
+ afterRatio: facts.measured.ratio,
290
+ pinHits: { inRange: compactOutcome?.pinnedKept ?? 0, total: facts.pinnedSeqs.length },
291
+ handoff: { path: file, childId: created.childId, started: created.started === true },
292
+ });
293
+ } catch {
294
+ /* 账本静默降级:不能影响交接结果 */
295
+ }
296
+
222
297
  return {
223
298
  ok: true,
224
299
  childId: created.childId,
@@ -278,6 +353,10 @@ export async function previewHandoff(deps, { agent, signal }) {
278
353
  return {
279
354
  ok: true,
280
355
  draft: generated.text,
356
+ // 0.4 P2-1:预览里只暴露「约束」「下一步」两段给客户端编辑(其余段不可改)。
357
+ editable: editableHandoffView(generated.sections),
358
+ // 0.4 P2-2:本次将是第几次交接(链式序号;首次 1)。
359
+ ordinal: handoffTitlePlanOf(deps.ctx, session).index,
281
360
  readiness,
282
361
  pressure: facts.measured.tokens,
283
362
  summaries: facts.summaries,
@@ -10,37 +10,18 @@
10
10
  * 只写 plugin record:不进模型上下文、不占 token;压缩选段会避开被锁定的消息。
11
11
  */
12
12
  import { actionError } from '../core/errors.js';
13
+ import { messageSnippetOf, roleLabelOf } from '../core/pin-anchors.js';
13
14
  import { PIN_RECORD_TYPE, latestPinnedSeqs, normalizePinnedSeqs } from '../core/pins.js';
14
15
  import { livePinnedSeqs, sessionLog, surfaceSeqs } from '../dsh/session-view.js';
15
16
 
16
- const TYPE_LABEL = {
17
- 'system/message': '系统',
18
- 'user/message': '用户',
19
- 'assistant/message': '助手',
20
- 'tool/result': '工具',
21
- };
22
-
23
17
  /**
24
18
  * 从事件里抠一段可读预览(消息 content 可能是数组或字符串)。
19
+ * 0.4 P1-1/P1-2:摘要/角色判定抽到 core/pin-anchors.js,与「建议锁定」「交接单锚点」共用一份。
25
20
  * @param {object|undefined} event
26
21
  * @returns {string}
27
22
  */
28
23
  function previewOf(event) {
29
- // DSH v4:user/message 的 data 本身就是消息({role,content,source}),
30
- // 只有 system/assistant/tool 包在 data.message 里。2026-10-19 debug 实测:
31
- // 只读 data.message 会让 /pin list 里所有用户消息显示「(无文本)」。
32
- const content = event?.data?.content ?? event?.data?.message?.content;
33
- let text = '';
34
- if (typeof content === 'string') text = content;
35
- else if (Array.isArray(content)) {
36
- text = content
37
- .filter((block) => block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
38
- .map((block) => block.text)
39
- .join(' ');
40
- }
41
- text = text.replace(/\s+/gu, ' ').trim();
42
- if (text.length === 0) return '(无文本)';
43
- return text.length > 60 ? `${text.slice(0, 59)}…` : text;
24
+ return messageSnippetOf(event) || '(无文本)';
44
25
  }
45
26
 
46
27
  /**
@@ -59,7 +40,7 @@ export function pinCandidates(session, limit = 10) {
59
40
  index: picked.length + 1,
60
41
  seq,
61
42
  type: event.type,
62
- role: TYPE_LABEL[event.type] ?? '其他',
43
+ role: roleLabelOf(event.type),
63
44
  preview: previewOf(event),
64
45
  });
65
46
  }
@@ -73,6 +54,8 @@ function pinnedOf(session) {
73
54
  function writePins(deps, session, seqs) {
74
55
  const normalized = normalizePinnedSeqs(seqs);
75
56
  deps.engine.appendPluginRecord(session, PIN_RECORD_TYPE, { seqs: normalized, at: Date.now() });
57
+ // 0.4 P0-2:诊断计数只记「成功锁定」;/pin 与菜单 toggle 共用这里,toggle 锁一次记一次。
58
+ if (normalized.length > 0) deps.diag?.bumpPin();
76
59
  return normalized;
77
60
  }
78
61
 
@@ -7,11 +7,12 @@
7
7
  import { paletteForBothSchemes } from '../core/colors.js';
8
8
  import { adviceFor, ratioOf, thresholdsForWindow } from '../core/policy.js';
9
9
  import { tokenOf } from '../core/tokens.js';
10
- import { measurePressure } from '../dsh/pressure.js';
10
+ import { measurePressure, routeBudgetOf } from '../dsh/pressure.js';
11
11
  import { selectCompactableRange } from '../dsh/select-range.js';
12
12
  import { compressHint as compressHintOf, countSummaries, lastCheckpointSurfaceSeq, livePinnedSeqs, liveSessionOf, pickAgent, surfaceSeqs } from '../dsh/session-view.js';
13
13
  import { compressHintIsActive } from '../core/compress-hint.js';
14
- import { readNativeCompaction } from '../dsh/native-compaction.js';
14
+ import { readNativeCompaction, resolveNativePolicy } from '../dsh/native-compaction.js';
15
+ import { nativeLineOf } from '../core/native-line.js';
15
16
  import { relayRecordOf } from '../dsh/relay.js';
16
17
 
17
18
  /**
@@ -109,11 +110,39 @@ export async function readState(deps, { sessionId, agent } = {}) {
109
110
  .sort((a, b) => a.ratio - b.ratio);
110
111
 
111
112
  // 0.3.1:模型的「这里适合压缩」判断(比最后一次压缩/交接 checkpoint 新才算数)。
112
- const checkpointSeq = target === null ? undefined : lastCheckpointSurfaceSeq(target.session);
113
+ // 0.4 BUG-013:第二个参数(isCheckpointSource)以前漏传,session-view 里的
114
+ // TypeError 被 try/catch 吞掉 → 永远找不到 checkpoint → 压缩/交接后旧 hint 不失效
115
+ // (环上残留旧断点,还会用旧 ratio 抑制 cue)。这里显式传 engine 的判定函数。
116
+ const checkpointSeq =
117
+ target === null ? undefined : lastCheckpointSurfaceSeq(target.session, deps.engine?.isCompactCheckpointSource);
113
118
  const hint = target === null ? null : compressHintOf(target.session);
114
119
  const activeHint = compressHintIsActive(hint, checkpointSeq) ? { ratio: hint.ratio, at: hint.at, seq: hint.seq } : null;
115
120
  // 0.3.1:DSH 原生自动压缩配置(读 profile 的 cordis.patch.yml + DSH 默认值)。
116
- const nativeCfg = readNativeCompaction();
121
+ // 0.4 P2-3:deps.nativeConfig 仅给单测注入用;生产不传,仍读本机 profile。
122
+ const nativeCfg = deps.nativeConfig ?? readNativeCompaction();
123
+
124
+ // 0.4 P2-3:算「真实触发线」而不是窗口比例:
125
+ // thresholdTokens = floor(min(W × rate, W − 输出预留 − headroom)),触发点 = thresholdTokens / W。
126
+ // 输出预留取 requestHeader.config.maxTokens(其次适配器 defaultMaxTokens),headroom 与
127
+ // 路由级 thresholdRatio 来自 compaction-basic 配置(modelPolicies 按 provider+model 覆盖)。
128
+ let nativePolicy = resolveNativePolicy(nativeCfg, null);
129
+ let nativeLine = null;
130
+ let nativeBudget = null;
131
+ if (target !== null && target.session !== undefined) {
132
+ try {
133
+ nativeBudget = await routeBudgetOf(deps.ctx, target.session, picked ?? undefined, measured.window);
134
+ nativePolicy = resolveNativePolicy(nativeCfg, nativeBudget.route);
135
+ nativeLine = nativeLineOf({
136
+ contextWindow: nativeBudget.contextWindow,
137
+ reservedCompletionTokens: nativeBudget.reservedCompletionTokens,
138
+ thresholdRatio: nativePolicy.thresholdRatio,
139
+ headroomTokens: nativePolicy.headroomTokens,
140
+ });
141
+ } catch {
142
+ nativeLine = null;
143
+ nativeBudget = null;
144
+ }
145
+ }
117
146
 
118
147
  return {
119
148
  revision: ++stateRevision,
@@ -140,6 +169,19 @@ export async function readState(deps, { sessionId, agent } = {}) {
140
169
  // 0.3.1:模型判断的「适合压缩」断点位置(没有或已失效为 null)。
141
170
  compressHint: activeHint,
142
171
  // 0.3.1:DSH 原生自动压缩(环上的报警最高阈值 / 设置页开关的数据源)。
143
- native: { autoEnabled: nativeCfg.autoEnabled, thresholdRatio: nativeCfg.thresholdRatio },
172
+ // 0.4 P2-3:额外下发真实触发线(ratio 为 thresholdTokens / window;算不出时 null,
173
+ // 客户端回落 thresholdRatio 的窗口比例,老客户端不受影响)。
174
+ native: {
175
+ autoEnabled: nativeCfg.autoEnabled,
176
+ thresholdRatio: nativePolicy.thresholdRatio,
177
+ ratio: nativeLine !== null && nativeLine.ok === true ? nativeLine.ratio : null,
178
+ thresholdTokens: nativeLine !== null && nativeLine.ok === true ? nativeLine.thresholdTokens : null,
179
+ contextWindow: nativeBudget?.contextWindow ?? null,
180
+ reservedCompletionTokens: nativeBudget?.reservedCompletionTokens ?? null,
181
+ headroomTokens: nativePolicy.headroomTokens,
182
+ overridden: nativePolicy.overridden === true,
183
+ route: nativeBudget?.route ?? null,
184
+ unavailable: nativeLine !== null && nativeLine.ok !== true ? nativeLine.reason : null,
185
+ },
144
186
  };
145
187
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { betterCompact, cancelBetterCompact, previewBetterCompact } from '../actions/better-compact.js';
7
7
  import { readCompactDiffs } from '../actions/compact-diff.js';
8
+ import { readDiagnostics } from '../actions/diagnostics.js';
8
9
  import { handoff, previewHandoff } from '../actions/handoff.js';
9
10
  import { listHandoffDocs, readHandoffDoc } from '../actions/handoff-docs.js';
10
11
  import { togglePin } from '../actions/pin.js';
@@ -129,6 +130,21 @@ function fallbackRejection(webCtx, req) {
129
130
  return undefined;
130
131
  }
131
132
 
133
+ /**
134
+ * 设置端点响应 = 设置视图 + 轻账本累计(0.4 P1-3;客户端「累计」一行数字的数据源)。
135
+ * 读账本失败/没有 ledger 时 `ledger: null`(客户端显示引导文案,不影响设置页)。
136
+ */
137
+ export function settingsPayloadOf(deps) {
138
+ const view = deps.settings.view();
139
+ let ledger = null;
140
+ try {
141
+ ledger = typeof deps.ledger?.summary === 'function' ? deps.ledger.summary() : null;
142
+ } catch {
143
+ ledger = null;
144
+ }
145
+ return { ...view, ledger };
146
+ }
147
+
132
148
  function denyAuth(res, rejection) {
133
149
  const status = rejection === 401 ? 401 : 403;
134
150
  return send(res, status, {
@@ -259,7 +275,7 @@ export function registerHttp(webCtx, deps) {
259
275
  const rejection = hostRejection(webCtx, req);
260
276
  if (rejection !== undefined) return denyAuth(res, rejection);
261
277
  if (req.method === 'GET') {
262
- return send(res, 200, { ok: true, ...deps.settings.view() });
278
+ return send(res, 200, { ok: true, ...settingsPayloadOf(deps) });
263
279
  }
264
280
  if (req.method !== 'POST') return send(res, 405, { ok: false, message: '只支持 GET/POST' });
265
281
  if (!sameOrigin(req)) return send(res, 403, { ok: false, message: '拒绝跨站请求' });
@@ -274,13 +290,13 @@ export function registerHttp(webCtx, deps) {
274
290
  return send(res, 400, { ok: false, message: '请求体必须是 JSON 对象' });
275
291
  }
276
292
  if (parsed.reset === true) {
277
- const view = deps.settings.reset();
293
+ deps.settings.reset();
278
294
  deps.hub?.refreshAll?.();
279
- return send(res, 200, { ok: true, ...view });
295
+ return send(res, 200, { ok: true, ...settingsPayloadOf(deps) });
280
296
  }
281
- const view = deps.settings.save(parsed);
297
+ deps.settings.save(parsed);
282
298
  deps.hub?.refreshAll?.();
283
- return send(res, 200, { ok: true, ...view });
299
+ return send(res, 200, { ok: true, ...settingsPayloadOf(deps) });
284
300
  } catch (error) {
285
301
  try {
286
302
  const status = error?.statusCode === 413 ? 413 : 400;
@@ -302,6 +318,47 @@ export function registerHttp(webCtx, deps) {
302
318
  });
303
319
  webCtx.effect(() => settingsStop, 'compact-relay: settings endpoint');
304
320
 
321
+ // 0.4 P0-2:诊断信息(只读、幂等、无副作用)——本机生成、白名单、已脱敏。
322
+ // 只提供 GET;没有 POST/上传通道(方案 §5.4、§10)。
323
+ const diagnosticsStop = webCtx.webServer.register({
324
+ kind: 'exact',
325
+ path: '/api/compact-relay-diagnostics',
326
+ handler: (req, res) => {
327
+ void (async () => {
328
+ try {
329
+ const rejection = hostRejection(webCtx, req);
330
+ if (rejection !== undefined) return denyAuth(res, rejection);
331
+ if (req.method !== 'GET') return send(res, 405, { ok: false, message: '只支持 GET' });
332
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
333
+ const sessionId = url.searchParams.get('sessionId') ?? undefined;
334
+ // 组装在 actions 层(adapters 不直接碰 dsh/,见 A9 架构守卫)。
335
+ const payload = await readDiagnostics(deps, { sessionId });
336
+ return send(res, 200, { ok: true, ...payload });
337
+ } catch (error) {
338
+ // 只回短文案,不把内部正文/堆栈回给浏览器(方案 §5.4)。
339
+ const code = error !== null && typeof error === 'object' ? error.code : undefined;
340
+ try {
341
+ deps.log?.warn?.(`compact-relay: 生成诊断信息失败${typeof code === 'string' ? `(${code})` : ''}`);
342
+ } catch {
343
+ /* 日志失败不影响响应 */
344
+ }
345
+ try {
346
+ send(res, 500, {
347
+ ok: false,
348
+ message:
349
+ code === 'toolarge'
350
+ ? '诊断信息超过 64KB 上限,请先减少错误记录后再试'
351
+ : '生成诊断信息失败(详情见插件日志)',
352
+ });
353
+ } catch {
354
+ /* 连接可能已断 */
355
+ }
356
+ }
357
+ })();
358
+ },
359
+ });
360
+ webCtx.effect(() => diagnosticsStop, 'compact-relay: diagnostics endpoint');
361
+
305
362
  const actionStop = webCtx.webServer.register({
306
363
  kind: 'exact',
307
364
  path: '/api/compact-relay-action',
@@ -312,7 +369,8 @@ export function registerHttp(webCtx, deps) {
312
369
  if (rejection !== undefined) return denyAuth(res, rejection);
313
370
  if (req.method !== 'POST') return send(res, 405, { ok: false, message: '只支持 POST' });
314
371
  if (!sameOrigin(req)) return send(res, 403, { ok: false, message: '拒绝跨站请求' });
315
- const body = await readBody(req);
372
+ // 0.4 P2-1:handoff 可以带「约束/下一步」两段编辑(各 ≤4000 字符),body 上限放宽到 48KB。
373
+ const body = await readBody(req, 48 * 1024);
316
374
  let parsed;
317
375
  try {
318
376
  parsed = JSON.parse(body === '' ? '{}' : body);
@@ -388,7 +446,8 @@ export function registerHttp(webCtx, deps) {
388
446
  return send(res, 200, outcome);
389
447
  }
390
448
  if (parsed.action === 'handoff') {
391
- const outcome = await handoff(deps, { agent });
449
+ // 0.4 P2-1:`edits` 只允许「约束」「下一步」两段(多余的段/非法类型由 actions 拒绝)。
450
+ const outcome = await handoff(deps, { agent, edits: parsed.edits });
392
451
  return send(res, 200, {
393
452
  ok: true,
394
453
  message: outcome.message,
@@ -412,6 +471,10 @@ export function registerHttp(webCtx, deps) {
412
471
  if (code === 'timeout' || code === 'budget') {
413
472
  return send(res, 500, { ok: false, code, message: errorText(error) });
414
473
  }
474
+ // 0.4 P2-1:交接编辑字段非法(改了程序生成段/超长/类型错)→ 400,明确告诉用户哪些段不能改。
475
+ if (code === 'edits') {
476
+ return send(res, 400, { ok: false, code, message: errorText(error) });
477
+ }
415
478
  // 忙(正在压缩/交接)回 409,和「没有活动会话」同属可重试状态。
416
479
  if (code === 'busy') {
417
480
  return send(res, 409, { ok: false, code, message: errorText(error) });