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.
- package/CHANGELOG.md +18 -0
- package/README.en.md +6 -3
- package/README.md +6 -3
- package/ROADMAP.md +29 -3
- package/TUTORIAL.md +1 -1
- package/lib/actions/better-compact.js +24 -72
- package/lib/actions/compact-run.js +76 -0
- package/lib/actions/compress-hint.js +16 -7
- package/lib/actions/handoff-docs.js +9 -3
- package/lib/actions/handoff.js +5 -22
- package/lib/actions/settings.js +1 -4
- package/lib/actions/state.js +2 -1
- package/lib/actions/support.js +76 -0
- package/lib/actions/test-compact.js +12 -41
- package/lib/adapters/http.js +2 -1
- package/lib/adapters/prompt.js +72 -0
- package/lib/adapters/tool.js +12 -8
- package/lib/client.js +424 -96
- package/lib/config.js +1 -2
- package/lib/core/compress-cue.js +96 -0
- package/lib/core/compress-hint.js +1 -1
- package/lib/core/errors.js +0 -1
- package/lib/core/handoff-spec.js +1 -1
- package/lib/core/markdown-lite.js +178 -0
- package/lib/core/object.js +19 -0
- package/lib/core/ranges.js +1 -2
- package/lib/core/relay.js +2 -2
- package/lib/core/tokens.js +34 -0
- package/lib/dsh/compaction-log.js +3 -19
- package/lib/dsh/compaction-pair.js +48 -0
- package/lib/dsh/engine.js +2 -2
- package/lib/dsh/handoff-files.js +28 -0
- package/lib/dsh/handoff-writer.js +16 -21
- package/lib/dsh/model-task.js +112 -0
- package/lib/dsh/relay.js +1 -1
- package/lib/dsh/session-view.js +5 -17
- package/lib/dsh/settings-file.js +1 -4
- package/lib/dsh/state-hub.js +95 -19
- package/lib/dsh/summarizer.js +16 -25
- package/lib/index.js +28 -6
- package/package.json +2 -2
- package/lib/actions/stage.js +0 -36
- 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
|
|
5
|
+
* 写:engine.appendPluginRecord(和压缩判断记录/pin 同一个宿主 API)。
|
|
6
6
|
* 记录缺失/损坏一律当作没有(旧会话兼容),不阻断交接。
|
|
7
7
|
*/
|
|
8
8
|
import { RELAY_RECORD_TYPE } from '../core/relay.js';
|
package/lib/dsh/session-view.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* 只读的 session 视图 —— 把 Session 的访问面收在一处。
|
|
3
3
|
*
|
|
4
4
|
* 事务层(compact-transaction)不直接翻 session 内部结构,
|
|
5
|
-
* 都从这里拿;M2 的 checkpoint
|
|
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
|
-
|
|
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 (!
|
|
62
|
+
if (!replaceByCoveredSeq.has(summary.seq)) continue;
|
|
75
63
|
count += 1;
|
|
76
64
|
}
|
|
77
65
|
return count;
|
package/lib/dsh/settings-file.js
CHANGED
|
@@ -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
|
*
|
package/lib/dsh/state-hub.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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;
|
package/lib/dsh/summarizer.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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
|
+
"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"
|
package/lib/actions/stage.js
DELETED
|
@@ -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
|
-
}
|