dsh-compact-relay 0.3.3 → 0.3.4

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.en.md +6 -3
  3. package/README.md +6 -3
  4. package/ROADMAP.md +29 -3
  5. package/TUTORIAL.md +1 -1
  6. package/lib/actions/better-compact.js +24 -72
  7. package/lib/actions/compact-run.js +76 -0
  8. package/lib/actions/compress-hint.js +16 -7
  9. package/lib/actions/handoff-docs.js +9 -3
  10. package/lib/actions/handoff.js +5 -22
  11. package/lib/actions/settings.js +1 -4
  12. package/lib/actions/state.js +2 -1
  13. package/lib/actions/support.js +76 -0
  14. package/lib/actions/test-compact.js +12 -41
  15. package/lib/adapters/http.js +2 -1
  16. package/lib/adapters/prompt.js +72 -0
  17. package/lib/adapters/tool.js +12 -8
  18. package/lib/client.js +424 -96
  19. package/lib/config.js +1 -2
  20. package/lib/core/compress-cue.js +96 -0
  21. package/lib/core/compress-hint.js +1 -1
  22. package/lib/core/errors.js +0 -1
  23. package/lib/core/handoff-spec.js +1 -1
  24. package/lib/core/markdown-lite.js +178 -0
  25. package/lib/core/object.js +19 -0
  26. package/lib/core/ranges.js +1 -2
  27. package/lib/core/relay.js +2 -2
  28. package/lib/core/tokens.js +34 -0
  29. package/lib/dsh/compaction-log.js +3 -19
  30. package/lib/dsh/compaction-pair.js +48 -0
  31. package/lib/dsh/engine.js +2 -2
  32. package/lib/dsh/handoff-files.js +28 -0
  33. package/lib/dsh/handoff-writer.js +16 -21
  34. package/lib/dsh/model-task.js +112 -0
  35. package/lib/dsh/relay.js +1 -1
  36. package/lib/dsh/session-view.js +5 -17
  37. package/lib/dsh/settings-file.js +1 -4
  38. package/lib/dsh/state-hub.js +95 -19
  39. package/lib/dsh/summarizer.js +16 -25
  40. package/lib/index.js +28 -6
  41. package/package.json +2 -2
  42. package/lib/actions/stage.js +0 -36
  43. package/lib/core/stage.js +0 -96
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 模型任务公共装配/收尾 —— 摘要器(dsh/summarizer.js)与交接单生成
3
+ * (dsh/handoff-writer.js)共用的一小段「模型调用前后」逻辑。
4
+ *
5
+ * 两个任务不是同一种产物(一个压成六段摘要、一个写九段交接单,校验/重试规则
6
+ * 也不同),所以**不合并重试循环**;但下面四件事完全一样、以前各写一遍:
7
+ * 1. 前置:会话存在性 → 解析 provider/model 路由 → 窗口预算预检 → 推理档位;
8
+ * 2. cache 对齐参数:复用上次请求的 temperature/stop,但**绝不继承 reasoningEffort**
9
+ * (2026-10-19 真机坑:会话最后请求是 max 思考,摘要沿用 max → maxTokens 被思考吃光);
10
+ * 3. 单次流式调用 + finishProblem 归一(超限/工具调用/中断);
11
+ * 4. 成功结果的公共元信息(provider/model/maxTokens/attempts/usage)。
12
+ *
13
+ * 只消费 dsh 层模块,不 import @deepseek-ai/*。
14
+ */
15
+ import { actionError } from '../core/errors.js';
16
+ import { assertBudget, callStream, reasoningEffortFor } from './llm-call.js';
17
+ import { cacheControls, routeOf } from './route.js';
18
+ import { finishProblem } from './stream.js';
19
+
20
+ /**
21
+ * 模型调用前置:会话 → 路由 → 预算 → 推理档位。
22
+ *
23
+ * 错误码/文案由调用方给:摘要器报 'summary'、交接单报 'handoff',保持各自入口的
24
+ * 用户可见文案逐字不变(这里只是把顺序收在一处)。
25
+ *
26
+ * @param {object} input
27
+ * @param {object} input.ctx
28
+ * @param {object|undefined} input.session
29
+ * @param {object} input.agent
30
+ * @param {number} [input.inputTokens] 输入 token 估算(拿不到就跳过预算预检)
31
+ * @param {number} input.maxTokens
32
+ * @param {string} input.label 给预算报错用的任务名
33
+ * @param {string} input.code 动作错误码
34
+ * @param {string} input.noSessionMessage 会话不存在时的用户可见错误文案
35
+ * @param {string} input.noTargetMessage 拿不到 provider/model 时的用户可见错误文案
36
+ * @returns {Promise<{target:{provider:string, model:string}, reasoningEffort:'off'|undefined}>}
37
+ */
38
+ export async function prepareModelCall({
39
+ ctx,
40
+ session,
41
+ agent,
42
+ inputTokens,
43
+ maxTokens,
44
+ label,
45
+ code,
46
+ noSessionMessage,
47
+ noTargetMessage,
48
+ }) {
49
+ if (session === undefined) throw actionError(code, noSessionMessage);
50
+
51
+ const target = routeOf(ctx, session, agent);
52
+ if (target === null) throw actionError(code, noTargetMessage);
53
+
54
+ // 架构 A2:调模型前先做窗口预算预检;超窗直接报 budget,不白等一次失败请求。
55
+ await assertBudget({ ctx, target, inputTokens, maxTokens, label });
56
+ // P2-5:只有模型目录明确支持 off 才传;非推理/不支持 off 的模型交给 adapter 默认。
57
+ const reasoningEffort = await reasoningEffortFor(ctx, target);
58
+
59
+ return { target, reasoningEffort };
60
+ }
61
+
62
+ /**
63
+ * cache 对齐参数:复用上次请求的 temperature/stop,**不要继承 reasoningEffort**。
64
+ *
65
+ * 2026-10-19 真机踩的坑:会话最后请求是 reasoningEffort=max,摘要调用沿用 max,
66
+ * 思考 token 把 maxTokens(4096) 吃光 → finish=max-tokens → 「摘要在输出上限处被截断」。
67
+ * 摘要/交接单本身都不需要长思考,是否固定 off 由模型能力决定(见 prepareModelCall)。
68
+ *
69
+ * @param {object} session
70
+ * @param {{provider:string, model:string}} target
71
+ * @returns {object}
72
+ */
73
+ export function cacheAlignedControls(session, target) {
74
+ const { reasoningEffort: _ignored, ...controls } = cacheControls(session, target);
75
+ return controls;
76
+ }
77
+
78
+ /**
79
+ * 跑一次流式调用并把终止原因归一成可读错误(不抛;调用方决定重试/抛出)。
80
+ * @param {object} input
81
+ * @param {object} input.ctx
82
+ * @param {object} input.engine
83
+ * @param {object} input.options 传给 ctx.llm.stream 的选项(不含 signal)
84
+ * @param {AbortSignal} [input.signal]
85
+ * @param {number} [input.timeoutMs]
86
+ * @param {string} input.label
87
+ * @param {string} input.code
88
+ * @returns {Promise<{assembler:object, problem:Error|null}>}
89
+ */
90
+ export async function callModelAttempt({ ctx, engine, options, signal, timeoutMs, label, code }) {
91
+ const assembler = await callStream({ ctx, engine, options, signal, timeoutMs, label });
92
+ return { assembler, problem: finishProblem(assembler, { code, label }) };
93
+ }
94
+
95
+ /**
96
+ * 成功结果的公共元信息(两个任务返回结构里逐字相同的那一段)。
97
+ * @param {object} input
98
+ * @param {object} input.assembler
99
+ * @param {{provider:string, model:string}} input.options
100
+ * @param {number} input.maxTokens
101
+ * @param {number} input.attempt
102
+ * @returns {{provider:string, model:string, maxTokens:number, attempts:number, usage?:object}}
103
+ */
104
+ export function modelAttemptResult({ assembler, options, maxTokens, attempt }) {
105
+ return {
106
+ provider: options.provider,
107
+ model: options.model,
108
+ maxTokens,
109
+ attempts: attempt,
110
+ ...(assembler.usage === undefined ? {} : { usage: assembler.usage }),
111
+ };
112
+ }
package/lib/dsh/relay.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * 接力链的读写接缝(A5)。
3
3
  *
4
4
  * 读:从会话日志尾部找最近一条 `plugin:compact-relay/relay`;
5
- * 写:engine.appendPluginRecord(和 stage/pin 同一个宿主 API)。
5
+ * 写:engine.appendPluginRecord(和压缩判断记录/pin 同一个宿主 API)。
6
6
  * 记录缺失/损坏一律当作没有(旧会话兼容),不阻断交接。
7
7
  */
8
8
  import { RELAY_RECORD_TYPE } from '../core/relay.js';
@@ -2,10 +2,11 @@
2
2
  * 只读的 session 视图 —— 把 Session 的访问面收在一处。
3
3
  *
4
4
  * 事务层(compact-transaction)不直接翻 session 内部结构,
5
- * 都从这里拿;M2 的 checkpoint 定位、M4 的阶段标记也在这里。
5
+ * 都从这里拿;M2 的 checkpoint 定位、压缩判断记录也在这里。
6
6
  */
7
7
  import { latestCompressHint } from '../core/compress-hint.js';
8
8
  import { latestPinnedSeqs } from '../core/pins.js';
9
+ import { scanCompactionLog } from './compaction-pair.js';
9
10
 
10
11
  /** @returns {number[]} 当前 surface 的 event seq(升序) */
11
12
  export function surfaceSeqs(session) {
@@ -47,21 +48,8 @@ export function firstCompactableIdx(session) {
47
48
  * 没有 compactionId 的旧日志(或外部夹具)按旧口径计数,保证兼容。
48
49
  */
49
50
  export function countSummaries(session) {
50
- const log = sessionLog(session);
51
- const summaries = [];
52
- const ends = new Map();
53
- const replacedSeqs = new Set();
54
- for (const event of log) {
55
- if (event === null || event === undefined) continue;
56
- if (event.type === 'compaction/summary') {
57
- summaries.push(event);
58
- } else if (event.type === 'compaction/end') {
59
- const id = event.data?.compactionId;
60
- if (typeof id === 'string') ends.set(id, event.data);
61
- } else if (event.type === 'user/message' && Array.isArray(event.sourceEventSeqs)) {
62
- for (const seq of event.sourceEventSeqs) replacedSeqs.add(seq);
63
- }
64
- }
51
+ // 配对扫描(summary/end/replace)与 dsh/compaction-log 共用,口径见 compaction-pair.js。
52
+ const { summaries, ends, replaceByCoveredSeq } = scanCompactionLog(sessionLog(session));
65
53
  let count = 0;
66
54
  for (const summary of summaries) {
67
55
  const id = summary.data?.compactionId;
@@ -71,7 +59,7 @@ export function countSummaries(session) {
71
59
  }
72
60
  const end = ends.get(id);
73
61
  if (end === undefined || end.error !== undefined) continue;
74
- if (!replacedSeqs.has(summary.seq)) continue;
62
+ if (!replaceByCoveredSeq.has(summary.seq)) continue;
75
63
  count += 1;
76
64
  }
77
65
  return count;
@@ -7,6 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { defaultDshHome } from '../core/config-schema.js';
10
+ import { isPlainObject } from '../core/object.js';
10
11
 
11
12
  /** 设置文件名(放在 DSH_HOME 下)。 */
12
13
  export const SETTINGS_FILE = 'compact-relay.json';
@@ -19,10 +20,6 @@ export function defaultSettingsPath() {
19
20
  return path.join(defaultDshHome(), SETTINGS_FILE);
20
21
  }
21
22
 
22
- function isPlainObject(value) {
23
- return value !== null && typeof value === 'object' && !Array.isArray(value);
24
- }
25
-
26
23
  /**
27
24
  * 把设置文件读成「用户覆盖层」。
28
25
  *
@@ -5,13 +5,18 @@
5
5
  * 收到后对有订阅者的会话做一次去抖刷新(复用 readState),
6
6
  * 通过 SSE 推给浏览器半;浏览器断线会自动退回轮询(见 lib/client.js)。
7
7
  *
8
- * 只推「当前有订阅者的会话」,不给所有会话做无谓测量。
8
+ * 0.3.4(BUG-010):除了 SSE 推送,这里还无条件维护一份「最新状态」缓存。
9
+ * 模型判断「这里适合压缩」需要当前容量,而 `ctx.systemPrompt.context` 的
10
+ * text 回调必须是**同步**的 —— 不能在那里 await readState。所以每条
11
+ * session/event 到达后(150ms 防抖)都算一次状态,把提示需要的最小投影
12
+ * `{ratio, window, compressHint}` 存进 `latest`,供 systemPrompt 同步读取。
13
+ * 会话数量很少,这点内存/计算可忽略;没有订阅者时只是不写 SSE 帧。
9
14
  *
10
15
  * A9:dsh 层不再反向 import actions/state —— readState 由装配层注入,
11
16
  * 这样 state-hub 可以脱离动作层单测,依赖方向回到 dsh → core。
12
17
  */
13
18
 
14
- /** 事件去抖:一轮 turn 里事件很密,攒 150ms 只推一次。 */
19
+ /** 事件去抖:一轮 turn 里事件很密,攒 150ms 只算/推一次。 */
15
20
  const PUSH_DEBOUNCE_MS = 150;
16
21
 
17
22
  /** SSE 心跳:防止中间层把空闲连接掐掉。 */
@@ -44,6 +49,21 @@ export function createStateHub({ readState }) {
44
49
  /** @type {Map<string, ReturnType<typeof setTimeout>>} 未就绪时的退避重试 */
45
50
  const retryTimers = new Map();
46
51
 
52
+ /**
53
+ * 最新状态缓存(systemPrompt 同步读):sessionId → {ratio, window, compressHint}。
54
+ * 只存提示需要的最小投影,不存整个 state(颜色/选段等没人从缓存读)。
55
+ * @type {Map<string, {ratio:number, window:number, compressHint:object|null}>}
56
+ */
57
+ const latest = new Map();
58
+
59
+ /**
60
+ * 缓存对应的 readState.revision(单调递增)。缓存写入有竞态:防抖/重试/SSE 并发时,
61
+ * 先发出的慢读可能后完成并把新值覆盖成旧值。用 revision 做版本守卫:
62
+ * 只有 `state.revision >= 已缓存 revision` 才允许覆盖。
63
+ * @type {Map<string, number>}
64
+ */
65
+ const latestRevision = new Map();
66
+
47
67
  const cancelRetry = (sessionId) => {
48
68
  const timer = retryTimers.get(sessionId);
49
69
  if (timer !== undefined) {
@@ -53,26 +73,71 @@ export function createStateHub({ readState }) {
53
73
  };
54
74
 
55
75
  /**
56
- * 推一次状态;返回是否「就绪」(state.sessionId 非空)。
76
+ * 把 state 投影进缓存。
77
+ *
78
+ * 两条守卫(审计 2026-10-24 修正):
79
+ * 1. `state.sessionId === sessionId` 才写 —— 防止 pickAgent/缓存键对错会话;
80
+ * 2. `state.revision >= 已缓存 revision` 才覆盖 —— 防乱序完成(慢读后到)把新值写回旧值。
81
+ *
82
+ * 缓存键用**事件里的 session.id**(不是 agent.id,两者不同);会话被卸载/还没加载
83
+ * (state.sessionId 为空)时清掉旧缓存,避免动态提示读到已经不存在的会话。
84
+ * @param {string} sessionId 事件里的会话 id(缓存键)
85
+ * @param {object} state readState 的结果
86
+ */
87
+ const remember = (sessionId, state) => {
88
+ if (state === null || typeof state !== 'object') return;
89
+ if (typeof state.sessionId !== 'string' || state.sessionId.length === 0) {
90
+ latest.delete(sessionId);
91
+ latestRevision.delete(sessionId);
92
+ return;
93
+ }
94
+ // 只缓存本会话的状态:sessionId 对不上时宁可不写(保留旧缓存也比串会话安全)。
95
+ if (state.sessionId !== sessionId) return;
96
+ if (!Number.isFinite(state.ratio) || !Number.isFinite(state.window) || state.window <= 0) return;
97
+ const nextRevision = Number.isFinite(state.revision) ? state.revision : null;
98
+ const cachedRevision = latestRevision.get(sessionId);
99
+ if (
100
+ nextRevision !== null &&
101
+ Number.isFinite(cachedRevision) &&
102
+ nextRevision < cachedRevision
103
+ ) {
104
+ return; // 乱序完成的旧结果:不覆盖新缓存
105
+ }
106
+ latest.set(sessionId, {
107
+ ratio: state.ratio,
108
+ window: state.window,
109
+ compressHint:
110
+ state.compressHint !== null && typeof state.compressHint === 'object' ? state.compressHint : null,
111
+ });
112
+ if (nextRevision !== null) latestRevision.set(sessionId, nextRevision);
113
+ };
114
+
115
+ /**
116
+ * 算一次状态:更新缓存 + 推给订阅者。返回是否「就绪」(state.sessionId 非空)。
117
+ * 没有订阅者也照样算 —— 缓存是 systemPrompt 动态提示的唯一数据源。
57
118
  * @returns {Promise<boolean>}
58
119
  */
59
120
  const pushNow = async (sessionId) => {
60
- const listeners = clients.get(sessionId);
61
- if (listeners === undefined || listeners.size === 0) return true;
62
121
  let state;
63
122
  try {
64
123
  state = await readState(sessionId);
65
124
  } catch {
66
- return false; // 测量失败就先不推,下一次事件/重试再试
125
+ return false; // 测量失败就先不推/不缓存,下一次事件/重试再试
67
126
  }
68
- const frame = `data: ${JSON.stringify(state)}\n\n`;
69
- for (const res of [...listeners]) {
70
- if (!writeFrame(res, frame)) {
71
- listeners.delete(res);
127
+ if (state === null || typeof state !== 'object') return false;
128
+ remember(sessionId, state);
129
+
130
+ const listeners = clients.get(sessionId);
131
+ if (listeners !== undefined && listeners.size > 0) {
132
+ const frame = `data: ${JSON.stringify(state)}\n\n`;
133
+ for (const res of [...listeners]) {
134
+ if (!writeFrame(res, frame)) {
135
+ listeners.delete(res);
136
+ }
72
137
  }
138
+ if (listeners.size === 0) clients.delete(sessionId);
73
139
  }
74
- if (listeners.size === 0) clients.delete(sessionId);
75
- return state !== null && typeof state === 'object' && typeof state.sessionId === 'string';
140
+ return typeof state.sessionId === 'string';
76
141
  };
77
142
 
78
143
  /**
@@ -100,11 +165,21 @@ export function createStateHub({ readState }) {
100
165
  timers.delete(sessionId);
101
166
  void pushNow(sessionId);
102
167
  }, PUSH_DEBOUNCE_MS);
103
- // 别让推送定时器拖住进程退出
168
+ // 别让推送/缓存定时器拖住进程退出
104
169
  if (typeof timer.unref === 'function') timer.unref();
105
170
  timers.set(sessionId, timer);
106
171
  };
107
172
 
173
+ /**
174
+ * 同步读最新状态投影(systemPrompt.context 的 text 回调只能同步)。
175
+ * @param {string} sessionId
176
+ * @returns {{ratio:number, window:number, compressHint:object|null}|undefined}
177
+ */
178
+ const latestState = (sessionId) => {
179
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined;
180
+ return latest.get(sessionId);
181
+ };
182
+
108
183
  /**
109
184
  * SSE 入口。`GET /api/compact-relay-stream?sessionId=...`
110
185
  * @param {object} req
@@ -159,17 +234,16 @@ export function createStateHub({ readState }) {
159
234
  };
160
235
 
161
236
  /**
162
- * 订阅 host 的 session/event:只对「有人订阅的会话」安排推送。
237
+ * 订阅 host 的 session/event:不管有没有 SSE 订阅者,都要更新状态缓存
238
+ * (systemPrompt 提示读它);有订阅者的会话顺带推帧。
163
239
  * @param {object} session
164
240
  * @param {object} _event
165
241
  */
166
242
  const onSessionEvent = (session, _event) => {
167
243
  const sessionId = session !== null && typeof session === 'object' ? session.id : undefined;
168
244
  if (typeof sessionId !== 'string' || sessionId.length === 0) return;
169
- if (clients.has(sessionId)) {
170
- cancelRetry(sessionId);
171
- schedule(sessionId);
172
- }
245
+ cancelRetry(sessionId);
246
+ schedule(sessionId);
173
247
  };
174
248
 
175
249
  /** 设置保存后立刻让所有订阅者重读一次状态(颜色/阈值马上变)。 */
@@ -195,9 +269,11 @@ export function createStateHub({ readState }) {
195
269
  }
196
270
  }
197
271
  clients.clear();
272
+ latest.clear();
273
+ latestRevision.clear();
198
274
  };
199
275
 
200
- return { serve, onSessionEvent, refreshAll, close, noop };
276
+ return { serve, onSessionEvent, refreshAll, close, latestState, noop };
201
277
  }
202
278
 
203
279
  export default createStateHub;
@@ -12,9 +12,7 @@ import { actionError } from '../core/errors.js';
12
12
  import { rangeNotice, recoveryHint } from '../core/prompts.js';
13
13
  import { REQUIRED_SECTIONS, describeProblems, validateSummary } from '../core/validate-summary.js';
14
14
  import { buildPrefix } from './prefix.js';
15
- import { assertBudget, callStream, reasoningEffortFor } from './llm-call.js';
16
- import { cacheControls, routeOf } from './route.js';
17
- import { finishProblem as streamProblem } from './stream.js';
15
+ import { cacheAlignedControls, callModelAttempt, modelAttemptResult, prepareModelCall } from './model-task.js';
18
16
 
19
17
  /** 最多尝试次数(首次 + 1 次带 recovery 提示的重试)。 */
20
18
  const MAX_ATTEMPTS = 2;
@@ -59,15 +57,17 @@ export async function summarize({
59
57
  requiredSections = REQUIRED_SECTIONS,
60
58
  }) {
61
59
  const session = agent?.session;
62
- if (session === undefined) throw actionError('summary', '会话不存在,无法生成摘要');
63
-
64
- const target = routeOf(ctx, session, agent);
65
- if (target === null) throw actionError('summary', '没有可用的 provider/model,无法发起压缩');
66
-
67
- // 架构 A2:调模型前先做窗口预算预检;超窗直接报 budget,不白等一次失败请求。
68
- await assertBudget({ ctx, target, inputTokens, maxTokens, label: '压缩摘要' });
69
- // P2-5:只有模型目录明确支持 off 才传;非推理/不支持 off 的模型交给 adapter 默认。
70
- const reasoningEffort = await reasoningEffortFor(ctx, target);
60
+ const { target, reasoningEffort } = await prepareModelCall({
61
+ ctx,
62
+ session,
63
+ agent,
64
+ inputTokens,
65
+ maxTokens,
66
+ label: '压缩摘要',
67
+ code: 'summary',
68
+ noSessionMessage: '会话不存在,无法生成摘要',
69
+ noTargetMessage: '没有可用的 provider/model,无法发起压缩',
70
+ });
71
71
 
72
72
  const regionCount = regionMessageCount(session, shadowedSeqs);
73
73
  if (regionCount === 0) throw actionError('summary', '没有可摘要的消息');
@@ -76,11 +76,7 @@ export async function summarize({
76
76
  if (prefix.length === 0) throw actionError('summary', '回放前缀为空');
77
77
 
78
78
  const header = session.requestHeader?.();
79
- // cache 对齐只保留 temperature/stop;**不要继承 reasoningEffort**。
80
- // 2026-10-19 真机踩的坑:会话最后请求是 reasoningEffort=max,摘要调用沿用 max,
81
- // 思考 token 把 maxTokens(4096) 吃光 → finish=max-tokens → 「摘要在输出上限处被截断,未提交」。
82
- // 压缩摘要本身不需要长思考,固定 off,稳且便宜。
83
- const { reasoningEffort: _ignored, ...controls } = cacheControls(session, target);
79
+ const controls = cacheAlignedControls(session, target);
84
80
  const baseInstruction = `${rangeNotice(regionCount)}\n\n${instruction}`;
85
81
 
86
82
  let lastProblems = [];
@@ -101,16 +97,15 @@ export async function summarize({
101
97
  purpose: 'compaction',
102
98
  };
103
99
 
104
- const assembler = await callStream({
100
+ const { assembler, problem } = await callModelAttempt({
105
101
  ctx,
106
102
  engine,
107
103
  options,
108
104
  signal,
109
105
  timeoutMs,
110
106
  label: '压缩摘要',
107
+ code: 'summary',
111
108
  });
112
-
113
- const problem = streamProblem(assembler, { code: 'summary', label: '压缩摘要' });
114
109
  if (problem !== null) {
115
110
  // 输出被截断 → 还有尝试机会时,带着「写短一点」的提示重试一次(真机 2026-10-19 踩过)。
116
111
  if (assembler.finish?.kind === 'max-tokens' && attempt < MAX_ATTEMPTS) {
@@ -130,11 +125,7 @@ export async function summarize({
130
125
  if (validation.ok) {
131
126
  return {
132
127
  summary: validation.summary,
133
- provider: options.provider,
134
- model: options.model,
135
- maxTokens,
136
- attempts: attempt,
137
- ...(assembler.usage === undefined ? {} : { usage: assembler.usage }),
128
+ ...modelAttemptResult({ assembler, options, maxTokens, attempt }),
138
129
  };
139
130
  }
140
131
  lastProblems = validation.problems;
package/lib/index.js CHANGED
@@ -9,6 +9,7 @@ import { readState } from './actions/state.js';
9
9
  import { createLock } from './actions/lock.js';
10
10
  import { registerCommands } from './adapters/commands.js';
11
11
  import { registerHttp } from './adapters/http.js';
12
+ import { registerCompressCue } from './adapters/prompt.js';
12
13
  import { registerTool } from './adapters/tool.js';
13
14
  import { createSettingsStore } from './actions/settings.js';
14
15
  import engine from './dsh/engine.js';
@@ -16,11 +17,20 @@ import { createStateHub } from './dsh/state-hub.js';
16
17
 
17
18
  export const name = 'compact-relay';
18
19
 
19
- /** 需要的 DSH 服务;webServer 用 ctx.inject 单独要,headless 环境也能加载。
20
- * 注意:`compaction` 是 **agent 作用域**的服务(挂在 preset 的隔离组里),
21
- * 根上下文拿不到——所以不放进 inject,改用 agent.ctx.compaction 动态取。
20
+ /**
21
+ * 需要的 DSH 服务;webServer 用 ctx.inject 单独要,headless 环境也能加载。
22
+ *
23
+ * 注意:`compaction` 是 **agent 作用域**的服务(挂在 preset 的隔离组里),
24
+ * 根上下文拿不到——所以不放进 inject,改用 agent.ctx.compaction 动态取。
25
+ *
26
+ * `systemPrompt`(0.3.4):它是 DSH 0.2.x 的核心根服务(点灯/命令等插件都依赖它),
27
+ * 这里和 memory 插件一样放根 `inject`。**该服务缺失时本插件会 pending、apply 不会执行**
28
+ * ——这是 cordis inject 的语义,不是 cue 特有的降级;apply 里的
29
+ * `typeof ctx.systemPrompt?.context === 'function'` 守卫针对的是「服务在、但形状不对/
30
+ * 被裁剪」的异常环境。cue 只是增强项,但它的依赖声明与其它入口同级,没有为它单独做
31
+ * 可选注入(避免引入 scoping 风险)。报告「兼容性」一节记录了这个前置。
22
32
  */
23
- export const inject = ['commands', 'agents', 'tokenMeter', 'llm', 'sessions', 'tools'];
33
+ export const inject = ['commands', 'agents', 'tokenMeter', 'llm', 'sessions', 'tools', 'systemPrompt'];
24
34
 
25
35
  /**
26
36
  * @param {object} ctx
@@ -53,15 +63,27 @@ export function apply(ctx, config) {
53
63
  });
54
64
  ctx.effect(() => () => settings.close(), 'compact-relay: settings store');
55
65
 
56
- // M5:状态推送中枢(SSE)。订阅会话事件,有人订阅才推。
66
+ // M5:状态推送中枢(SSE)。订阅会话事件;0.3.4 起无论有没有订阅者都维护
67
+ // 一份最新状态缓存,供下面的 systemPrompt 动态提示同步读取。
57
68
  // A9:readState 由这里注入,dsh 层不反向依赖 actions 层。
58
69
  const hub = createStateHub({ readState: (sessionId) => readState(deps, { sessionId }) });
59
70
  deps.hub = hub;
60
71
  ctx.on('session/event', (session, event) => hub.onSessionEvent(session, event));
61
72
  ctx.effect(() => () => hub.close(), 'compact-relay: state hub');
62
73
 
74
+ // 0.3.4(BUG-010):给模型一条「当前在可压缩区间」的动态 system-prompt 提示,
75
+ // 让「这里适合压缩」从不可判断变成可判断。
76
+ // 注意:systemPrompt 在根 inject 里 —— 宿主缺该服务时插件会 pending、apply 根本不会跑
77
+ // (cordis inject 语义,详见文件顶部 inject 注释);下面的 typeof 守卫只覆盖
78
+ // 「服务在、但形状不对/被裁剪」的异常环境,此时跳过 cue(其它入口不受影响)。
79
+ if (typeof ctx.systemPrompt?.context === 'function') {
80
+ ctx.effect(() => registerCompressCue(ctx, deps, hub), 'compact-relay: compress cue');
81
+ } else {
82
+ log?.warn?.('宿主 systemPrompt 服务缺少 context()(形状异常),跳过「可压缩区间」动态提示;其它入口不受影响。');
83
+ }
84
+
63
85
  registerCommands(ctx, deps);
64
- // 0.3.1:模型的「这里适合压缩」判断(环上的断点1)。
86
+ // 0.3.1:模型判断「这里适合压缩」(环上断点1);0.3.4 起工具名改为 mark_compress_point。
65
87
  registerTool(ctx, deps);
66
88
  // 2026-10-20 安全修复:HTTP 端点必须和宿主 /api 用同一个 connection 服务做鉴权
67
89
  // (Host/Origin fence + 浏览器 token)。只 inject webServer 时拿不到 connection,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-compact-relay",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "DeepSeek Harness 上下文指示器插件:提供容量监控、压缩预览与确认、九段式交接单生成及消息锁定功能。 · Context indicator for DSH: preview-then-confirm compaction, 9-section session handoff, and pinned messages.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -53,7 +53,7 @@
53
53
  "node": ">=22"
54
54
  },
55
55
  "scripts": {
56
- "test": "node scripts/build-client.mjs --check && node --check lib/client.js && node test/smoke.mjs && node test/handoff.mjs && node test/plugin-load.mjs && node test/contract.mjs && node test/compact-log.mjs",
56
+ "test": "node scripts/build-client.mjs --check && node --check lib/client.js && node test/smoke.mjs && node test/handoff.mjs && node test/plugin-load.mjs && node test/contract.mjs && node test/compact-log.mjs && node test/compress-cue.mjs && node test/markdown-lite.mjs",
57
57
  "test:e2e": "node test/e2e/smoke.mjs",
58
58
  "test:e2e:settings": "node test/e2e/settings-ui.mjs",
59
59
  "test:e2e:compact": "node test/e2e/real-compact.mjs"
@@ -1,36 +0,0 @@
1
- /**
2
- * 标记进度(M4,内部代号「阶段标记」)—— 把「这个阶段完成了」写进会话日志。
3
- *
4
- * 只写一条 plugin record:不进模型上下文、不影响 token,压缩/交接都不会碰它。
5
- * 指示灯读到最新一条后外环变阶段色(默认蓝);下一次压缩/交接(产生新 checkpoint)之后自动失效。
6
- */
7
- import { actionError } from '../core/errors.js';
8
- import { STAGE_RECORD_TYPE, normalizeStageLabel } from '../core/stage.js';
9
- import { measurePressure } from '../dsh/pressure.js';
10
-
11
- /**
12
- * @param {{engine:object, ctx?:object, cfg?:object}} deps
13
- * @param {{agent:object, label?:string}} input
14
- * @returns {Promise<{label:string, at:number, seq:number, ratio:number|null}>}
15
- */
16
- export async function markStage(deps, { agent, label } = {}) {
17
- const session = agent?.session;
18
- if (session === null || session === undefined) throw actionError('stage', '没有活动会话');
19
- const normalized = normalizeStageLabel(label);
20
- const at = Date.now();
21
- // 0.3.1:记下标记那一刻的容量比例,指示灯才能在环上标出「标记位置」。
22
- // 测不到(会话刚开 / tokenMeter 不可用)就记 null,环上退化到当前比例。
23
- const measured = await measurePressure(deps.ctx, agent, {
24
- windowFallback: deps.cfg?.contextWindowFallback ?? 1_000_000,
25
- }).catch(() => null);
26
- const ratio =
27
- measured !== null && typeof measured === 'object' && typeof measured.ratio === 'number' && Number.isFinite(measured.ratio)
28
- ? Math.max(0, Math.min(1, measured.ratio))
29
- : null;
30
- const seq = deps.engine.appendPluginRecord(session, STAGE_RECORD_TYPE, {
31
- label: normalized,
32
- at,
33
- ...(ratio === null ? {} : { ratio }),
34
- });
35
- return { label: normalized, at, seq, ratio };
36
- }
package/lib/core/stage.js DELETED
@@ -1,96 +0,0 @@
1
- /**
2
- * 标记进度(M4,内部代号「阶段标记」)—— 纯数据与纯判定,不依赖 DSH。
3
- *
4
- * 模型调 `mark_stage` 工具时,往会话日志追加一条 plugin record:
5
- * plugin:compact-relay/stage data: { label, at, ratio }
6
- *
7
- * ratio 是标记那一刻的容量比例(0–1),用来在指示灯的环上标出「标记位置」;
8
- * 旧记录没有这个字段时按 null 处理(环上退化成标记当前比例)。
9
- *
10
- * plugin record 是 DSH 0.2 的「插件私有事件」:`ignorable`、不进模型可见 surface、
11
- * 跟着会话落盘/分叉/迁移。所以「阶段完成」不会被压缩掉,重启后也还在。
12
- */
13
-
14
- /** 记录类型(DSH 语法:`plugin:` + 小写段;只认精确匹配,不收别的插件)。 */
15
- export const STAGE_RECORD_TYPE = 'plugin:compact-relay/stage';
16
-
17
- /** 没给 label 时的默认阶段名。 */
18
- export const STAGE_DEFAULT_LABEL = '本阶段完成';
19
-
20
- /** label 长度上限(超了截断,避免把整段话塞进指示灯标题)。 */
21
- export const STAGE_LABEL_MAX = 80;
22
-
23
- /**
24
- * 规范化阶段名:去首尾空白、折叠连续空白、超长截断;空值回落到默认名。
25
- * @param {unknown} raw
26
- * @returns {string}
27
- */
28
- export function normalizeStageLabel(raw) {
29
- if (typeof raw !== 'string') return STAGE_DEFAULT_LABEL;
30
- const text = raw.replace(/\s+/gu, ' ').trim();
31
- if (text.length === 0) return STAGE_DEFAULT_LABEL;
32
- // 用码点截断,别切裂 emoji 的 surrogate pair(截出孤立代理会显示成 �)。
33
- const chars = Array.from(text);
34
- return chars.length > STAGE_LABEL_MAX ? `${chars.slice(0, STAGE_LABEL_MAX - 1).join('')}…` : text;
35
- }
36
-
37
- /**
38
- * 该事件是不是本插件的阶段记录。
39
- * plugin record 的判定 = `ignorable === true` + 类型精确匹配(我们只写自己的类型)。
40
- * @param {unknown} event
41
- * @returns {boolean}
42
- */
43
- export function isStageRecordEvent(event) {
44
- return event !== null && typeof event === 'object' && event.ignorable === true && event.type === STAGE_RECORD_TYPE;
45
- }
46
-
47
- /**
48
- * 从会话事件流里取最近的 N 条阶段标记(最新的在前)。
49
- *
50
- * 0.3.1:从「只取最新一条」升级为「保留最近几个」——指示灯环上可以同时画出多个
51
- * 阶段块(最新的最亮,旧的半透明)。`limit` 由配置 `stage.maxMarks` 决定(1–10)。
52
- *
53
- * @param {object[]} events
54
- * @param {number} [limit=3]
55
- * @returns {{label:string, at:number, seq:number, ratio:number|null}[]}
56
- */
57
- export function latestStageMarks(events, limit = 3) {
58
- if (!Array.isArray(events)) return [];
59
- const cap = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 20) : 3;
60
- const marks = [];
61
- for (const event of events) {
62
- if (!isStageRecordEvent(event)) continue;
63
- const data = event.data;
64
- if (data === null || typeof data !== 'object') continue;
65
- const at = typeof data.at === 'number' && Number.isFinite(data.at) ? data.at : 0;
66
- const ratio =
67
- typeof data.ratio === 'number' && Number.isFinite(data.ratio)
68
- ? Math.max(0, Math.min(1, data.ratio))
69
- : null;
70
- marks.push({ label: normalizeStageLabel(data.label), at, seq: event.seq, ratio });
71
- }
72
- return marks.slice(-cap).reverse();
73
- }
74
-
75
- /**
76
- * 从会话事件流里取最近一条阶段标记;没有或数据不合法返回 null。
77
- * @param {object[]} events
78
- * @returns {{label:string, at:number, seq:number, ratio:number|null}|null}
79
- */
80
- export function latestStageMark(events) {
81
- return latestStageMarks(events, 1)[0] ?? null;
82
- }
83
-
84
- /**
85
- * 标记进度是否「仍然新鲜」:要比最后一次压缩 checkpoint 新才算数。
86
- *
87
- * 这样压缩/交接之后圆环自动恢复常态,不用额外写「清除」记录。
88
- * @param {{seq:number}|null} mark
89
- * @param {number|undefined} checkpointSeq 最后一次 checkpoint 消息的 event seq
90
- * @returns {boolean}
91
- */
92
- export function stageIsActive(mark, checkpointSeq) {
93
- if (mark === null || mark === undefined) return false;
94
- if (!Number.isInteger(checkpointSeq)) return true;
95
- return mark.seq > checkpointSeq;
96
- }