dsh-compact-relay 0.3.6 → 0.4.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.
@@ -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) });
@@ -1,27 +1,74 @@
1
1
  /**
2
- * 模型入口(0.3.4)——「当前在可压缩区间」的动态提示。
2
+ * 模型入口(0.3.4 / 0.4 P0-1)——「当前可以记一个压缩节点」的动态提示。
3
3
  *
4
4
  * BUG-010:0.3.1 的 `mark_stage` 工具在 427 个真实会话里 0 次调用。根因不是模型
5
5
  * 不听话,而是工具描述承诺「系统会告诉你容量」但系统从没说过 —— 条件不可判断。
6
- * 现在宿主每轮把一条 runtime context 注入 system prompt,告诉模型当前容量落在
7
- * 可压缩区间;模型据此判断「刚完成一个自然节点」时该不该调 `mark_compress_point`。
6
+ * 0.3.4 起宿主每轮把一条 runtime context 注入 system prompt,告诉模型当前容量。
7
+ *
8
+ * 0.4 P0-1(Win 173 会话里 cue 注入 18 次、工具仍 0 调用):
9
+ * 1. 文案 v2 与区间 v2 在 core/compress-cue.js(4 类完成信号 + 被拒零副作用);
10
+ * 2. **注入门控**在本文件(方案 §3.2):只有全部满足才注入——
11
+ * a) 容量落在 cue 区间(10%–40%,且不越过压缩线,见 cueBandOf);
12
+ * b) 距上次注入 ≥ CUE_MIN_TURNS 轮(本轮按 text 回调调用次数计,纯内存不落盘);
13
+ * c) 一轮内最多一次(连续调用时 b 天然保证);
14
+ * 3. 实际注入一次就 `deps.diag.bumpCueInjected()`(供 P0-2 诊断包看数),
15
+ * 只记次数、不记提示原文(隐私口径与 P0-2 一致)。
8
16
  *
9
17
  * 约束:
10
18
  * - `systemPrompt.context` 的 text 回调必须是**同步**函数 → 只读
11
19
  * `dsh/state-hub.js` 维护的内存缓存,不 await、不 measure;
12
20
  * - 拿不到会话/缓存/阈值就返回空串(宿主不会展示空 context);
13
- * - 全程 try/catch:提示是锦上添花,任何异常都不能影响宿主组装 prompt。
21
+ * - 全程 try/catch:提示是锦上添花,任何异常都不能影响宿主组装 prompt;
22
+ * - 本文件(adapters)不 import lib/dsh/:计数走 deps.diag 既有注入。
14
23
  */
15
24
  import {
16
25
  COMPRESS_CUE_NAME,
17
26
  COMPRESS_CUE_ORDER,
18
27
  compressCueText,
28
+ cueBandOf,
19
29
  } from '../core/compress-cue.js';
20
- import { compressionMinRatio } from '../core/policy.js';
30
+ import { CUE_MIN_TURNS, compressionMinRatio } from '../core/policy.js';
21
31
 
22
32
  /** 供测试/文档引用的 context 名称。 */
23
33
  export { COMPRESS_CUE_NAME };
24
34
 
35
+ /**
36
+ * 每会话的注入节奏(进程内,不新增持久状态):
37
+ * calls text 回调被调用次数(≈轮数)
38
+ * lastInjectedCall 上次真正注入发生在第几次调用(null = 还没注入过)
39
+ *
40
+ * 进程内 Map 只服务宿主生命周期;上限 200 个会话,避免长期运行无限增长。
41
+ */
42
+ const MAX_TRACKED_SESSIONS = 200;
43
+ const cueTurns = new Map();
44
+
45
+ /**
46
+ * 取(并前移)某会话的注入节奏计数。
47
+ * @param {string} sessionId
48
+ * @returns {{calls:number, lastInjectedCall:number|null}}
49
+ */
50
+ function cueTurnOf(sessionId) {
51
+ let turn = cueTurns.get(sessionId);
52
+ if (turn === undefined) {
53
+ turn = { calls: 0, lastInjectedCall: null };
54
+ cueTurns.set(sessionId, turn);
55
+ if (cueTurns.size > MAX_TRACKED_SESSIONS) {
56
+ const oldest = cueTurns.keys().next().value;
57
+ cueTurns.delete(oldest);
58
+ }
59
+ }
60
+ turn.calls += 1;
61
+ return turn;
62
+ }
63
+
64
+ /**
65
+ * 测试用:清空注入节奏(相当于宿主重启)。生产路径不需要调用。
66
+ * @returns {void}
67
+ */
68
+ export function resetCueTurns() {
69
+ cueTurns.clear();
70
+ }
71
+
25
72
  /**
26
73
  * 计算某个 agent 本轮该显示的动态提示(同步、异常安全)。
27
74
  * @param {{context?:object, deps?:object, hub?:object}} input
@@ -42,14 +89,28 @@ export function compressCueFor({ context, deps, hub } = {}) {
42
89
  if (!Number.isFinite(thresholds.compactRatio)) return '';
43
90
  if (!Number.isFinite(cached.ratio) || !Number.isFinite(cached.window) || cached.window <= 0) return '';
44
91
 
45
- // 最小线是「配置值 vs 动态下限(保留尾部 + 最小可压 token)/窗口」的较大者(纯函数)。
46
- const minRatio = compressionMinRatio(thresholds, deps?.cfg?.retention, cached.window);
47
- return compressCueText({
92
+ // 每次 text 回调(≈一轮)都计数:即使本轮不注入,也要推进轮次,限频才有意义。
93
+ const turn = cueTurnOf(sessionId);
94
+
95
+ // 门控 a:区间 = max(10%, 动态最小线) ≤ ratio < min(40%, 压缩线)。
96
+ const dynamicMinRatio = compressionMinRatio(thresholds, deps?.cfg?.retention, cached.window);
97
+ const band = cueBandOf({ compactRatio: thresholds.compactRatio, dynamicMinRatio });
98
+ if (!(cached.ratio >= band.minRatio && cached.ratio < band.maxRatio)) return '';
99
+
100
+ // 门控 b/c:距上次注入 ≥ CUE_MIN_TURNS 轮(同一轮内多次调用必然不足)。
101
+ if (turn.lastInjectedCall !== null && turn.calls - turn.lastInjectedCall < CUE_MIN_TURNS) return '';
102
+
103
+ const text = compressCueText({
48
104
  ratio: cached.ratio,
49
- minRatio,
50
- compactRatio: thresholds.compactRatio,
105
+ minRatio: band.minRatio,
106
+ maxRatio: band.maxRatio,
51
107
  compressHint: cached.compressHint ?? null,
52
108
  });
109
+ if (text === '') return '';
110
+
111
+ turn.lastInjectedCall = turn.calls;
112
+ deps?.diag?.bumpCueInjected?.();
113
+ return text;
53
114
  } catch {
54
115
  return '';
55
116
  }
@@ -8,6 +8,11 @@
8
8
  * 模型无法判断条件 → 427 个真实会话 0 次调用。0.3.4 起由宿主每轮注入可压缩区间
9
9
  * 提示(lib/adapters/prompt.js),工具描述只描述「它自己会做什么」:
10
10
  * 服务端工具会返回当前容量并复核区间,太低/到线都拒绝且不改历史。
11
+ *
12
+ * 0.4 P0-1(方案 §3.3):Win 173 会话 cue 注入 18 次、工具 0 调用 → 描述 v2:
13
+ * - 明确「用户和模型都可以调用」(不只是模型的自发动作);
14
+ * - 「被拒不需要致歉」:拒绝零副作用、不改历史、不需要重试或道歉(降低调用心理成本);
15
+ * - 给出典型调用时机;把「不需要每个小步骤都调用」弱化后挪到句尾,不再抢在条件前面。
11
16
  */
12
17
  import { markCompressHint } from '../actions/compress-hint.js';
13
18
  import engine from '../dsh/engine.js';
@@ -22,10 +27,10 @@ export function registerTool(ctx, deps) {
22
27
  name: 'mark_compress_point',
23
28
  description:
24
29
  '记录一个「压缩节点」:在指示灯环上标出「这里适合把前面的旧历史压成摘要」。' +
25
- '在你刚完成一个明确的任务/阶段、并且认为之后可以安全地压缩旧历史继续往下做时调用。' +
26
- '不确定当前是否合适时也可以调用:工具会返回当前上下文容量并做判断,' +
27
- '容量太低(压缩不划算)或已经到压缩线(直接建议用户压缩即可)会被拒绝,拒绝不会改动任何历史。' +
28
- '不需要每个小步骤都调用。每次判断只记一条,压缩或交接后旧记录自动失效。',
30
+ '用户和模型都可以调用;不确定时调用是安全的:工具会自己判断容量,太早或已到压缩线会拒绝,' +
31
+ '被拒不需要致歉、不会改动历史、也不影响会话,不需要重试。' +
32
+ '典型调用时机:阶段完成(功能已实现并验证 / 文档已交付 / 问题已定位并修复)、准备交接前、长任务中途确认可回退点。' +
33
+ '调用后工具会返回当前容量与判断结果;每次判断只记一条,压缩或交接后旧记录自动失效。小步骤不用调用。',
29
34
  parameters: {},
30
35
  output: {
31
36
  schema: { type: 'string' },