dsh-compact-relay 0.3.2 → 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 +26 -0
- package/README.en.md +8 -4
- package/README.md +8 -4
- package/ROADMAP.md +30 -3
- package/TUTORIAL.md +3 -2
- 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
package/lib/config.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
fieldAt,
|
|
14
14
|
validateConfig,
|
|
15
15
|
} from './core/config-schema.js';
|
|
16
|
+
import { isPlainObject } from './core/object.js';
|
|
16
17
|
|
|
17
18
|
// 兼容旧导入:其它模块/文档仍可从 config.js 拿默认配置与路径工具。
|
|
18
19
|
export { CONFIG_FIELDS, DEFAULT_CONFIG, defaultDshHome, expandHome } from './core/config-schema.js';
|
|
@@ -27,8 +28,6 @@ const BASE_COLOR_DEFAULTS = {
|
|
|
27
28
|
handoff: fieldAt(['colors', 'base', 'handoff']).default,
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
31
|
-
|
|
32
31
|
function objectOf(raw, key) {
|
|
33
32
|
return isPlainObject(raw[key]) ? raw[key] : {};
|
|
34
33
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.3.4:「可以记一个压缩节点」的动态提示 —— 纯函数,零依赖。
|
|
3
|
+
*
|
|
4
|
+
* 背景(BUG-010):0.3.1 的 `mark_stage` 工具只告诉模型「条件合适时调用」,
|
|
5
|
+
* 但模型根本看不到当前容量,两条条件无法判断 → 427 个真实会话里 0 次调用。
|
|
6
|
+
* 现在由宿主在 system prompt 的动态 context 里给出「当前处于可压缩区间」的提示,
|
|
7
|
+
* 模型才具备可执行判断;`mark_compress_point` 工具再自己复核一次(服务端仍校验)。
|
|
8
|
+
*
|
|
9
|
+
* 这段输出会被塞进**每一轮**的 system prompt,所以两件事很重要:
|
|
10
|
+
* 1. 尽量稳定:容量按 5% 桶取整,避免每个 token 变化都让提示重发;
|
|
11
|
+
* 2. 尽量安静:刚记过 hint(差 10 个百分点内)就不再重复提醒。
|
|
12
|
+
*
|
|
13
|
+
* 只做纯计算:入参都是数字/小对象,不 import 任何 DSH 模块,可裸跑单测。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 动态 context 的名称(宿主按 name 去重/覆盖)。 */
|
|
17
|
+
export const COMPRESS_CUE_NAME = 'compact-relay:compress-cue';
|
|
18
|
+
|
|
19
|
+
/** 动态 context 的排序位置:排在宿主内置 context(100–210)之后。 */
|
|
20
|
+
export const COMPRESS_CUE_ORDER = 300;
|
|
21
|
+
|
|
22
|
+
/** 容量百分比取整桶:5%(0.05)。 */
|
|
23
|
+
export const BUCKET_RATIO = 0.05;
|
|
24
|
+
|
|
25
|
+
/** 已记 hint 后,容量再涨这么多(10 个百分点)才重新提示。 */
|
|
26
|
+
export const HINT_SUPPRESS_RATIO = 0.1;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 把 0–1 的比例向下取整到 5% 桶(避免 system-prompt 每轮都变)。
|
|
30
|
+
* 0.32 → 0.30,0.39 → 0.35,1 → 1(压缩线在 1 之前,正常到不了 100%)。
|
|
31
|
+
* @param {number} ratio
|
|
32
|
+
* @param {number} [step]
|
|
33
|
+
* @returns {number}
|
|
34
|
+
*/
|
|
35
|
+
export function bucketRatio(ratio, step = BUCKET_RATIO) {
|
|
36
|
+
if (!Number.isFinite(ratio) || !Number.isFinite(step) || step <= 0) return 0;
|
|
37
|
+
const clamped = Math.min(1, Math.max(0, ratio));
|
|
38
|
+
const bucketed = Math.floor(clamped / step + 1e-9) * step;
|
|
39
|
+
// 浮点累加(0.05*7=0.35000000000000003)会有尾数,四舍五入回两位小数。
|
|
40
|
+
return Math.round(bucketed * 100) / 100;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 比例 → 百分比文本:0.05 → '5',0.325 → '32.5'。
|
|
45
|
+
*
|
|
46
|
+
* 保留一位小数时**向上取整**:动态最小线可能是 33.569%(128K 窗口 + 40K 保留),
|
|
47
|
+
* 若四舍五入成 33.6/33.5 会出现「提示值低于同一句的区间下限」的自相矛盾;
|
|
48
|
+
* 向上取整保证展示值不小于真实值(宁可说大 0.1 个百分点)。
|
|
49
|
+
* @param {number} ratio
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function formatPercent(ratio) {
|
|
53
|
+
if (!Number.isFinite(ratio)) return '?';
|
|
54
|
+
const percent = Math.ceil(Math.min(100, Math.max(0, ratio * 100)) * 10 - 1e-9) / 10;
|
|
55
|
+
return Number.isInteger(percent) ? String(percent) : percent.toFixed(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 是否处于「可以记压缩节点」的区间:最小压缩线 ≤ 当前 < 压缩线。
|
|
60
|
+
* @param {{ratio:number, minRatio:number, compactRatio:number}} input
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
export function inCompressCueBand({ ratio, minRatio, compactRatio }) {
|
|
64
|
+
if (!Number.isFinite(ratio) || !Number.isFinite(minRatio) || !Number.isFinite(compactRatio)) return false;
|
|
65
|
+
return ratio >= minRatio && ratio < compactRatio;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 生成动态提示;不满足条件返回空串(宿主不会展示空 context)。
|
|
70
|
+
*
|
|
71
|
+
* @param {object} input
|
|
72
|
+
* @param {number} input.ratio 当前上下文占用比例(0–1)
|
|
73
|
+
* @param {number} input.minRatio 最小压缩线(compressionMinRatio 的结果)
|
|
74
|
+
* @param {number} input.compactRatio 压缩提醒线(cfg.thresholds.compactRatio)
|
|
75
|
+
* @param {{ratio:number}|null} [input.compressHint] 当前有效的「适合压缩」记录(没有为 null)
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
export function compressCueText({ ratio, minRatio, compactRatio, compressHint = null }) {
|
|
79
|
+
if (!inCompressCueBand({ ratio, minRatio, compactRatio })) return '';
|
|
80
|
+
// 刚记过断点:容量只涨了不到 10 个百分点 → 不重复提醒(模型已经判断过一次)。
|
|
81
|
+
if (
|
|
82
|
+
compressHint !== null &&
|
|
83
|
+
compressHint !== undefined &&
|
|
84
|
+
Number.isFinite(compressHint.ratio) &&
|
|
85
|
+
ratio - compressHint.ratio < HINT_SUPPRESS_RATIO
|
|
86
|
+
) {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
// 显示值不能低于同一句里的区间下限:5% 桶可能把 33.7% 取整成 30%,而 128K 窗口
|
|
90
|
+
// 的动态最小线是 33.569% —— 那样提示会自相矛盾(「约 30%(可压缩区间 33.6%–40%)」)。
|
|
91
|
+
const shownRatio = Math.max(bucketRatio(ratio), minRatio);
|
|
92
|
+
return (
|
|
93
|
+
`上下文容量约 ${formatPercent(shownRatio)}%(可压缩区间 ${formatPercent(minRatio)}%–${formatPercent(compactRatio)}%)。` +
|
|
94
|
+
'如果刚刚完成一个明确的任务/阶段(自然节点),调用 mark_compress_point 记录一个压缩节点;不确定就不用调。'
|
|
95
|
+
);
|
|
96
|
+
}
|
package/lib/core/errors.js
CHANGED
package/lib/core/handoff-spec.js
CHANGED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻量 Markdown 解析(0.3.4 BUG-005)—— 纯函数,零依赖。
|
|
3
|
+
*
|
|
4
|
+
* 为什么自己写:交接单详情面板以前把 markdown 原样塞进 `<pre>`,用户在面板里看到的是
|
|
5
|
+
* `## 目标`/`- xxx`/`**粗体**` 源码。引第三方 markdown 渲染器会增加运行时依赖
|
|
6
|
+
* (插件只有 peerDependencies、零运行时依赖是硬约束),而 `dangerouslySetInnerHTML`
|
|
7
|
+
* 又引入 XSS 面。所以这里只解析交接单实际用到的子集,产出**结构化数据**,由客户端
|
|
8
|
+
* 用 React 文本节点渲染 —— 从不生成 HTML,任何 `<script>` 都只会显示成文字。
|
|
9
|
+
*
|
|
10
|
+
* 支持(够用即可,不做完整 CommonMark):
|
|
11
|
+
* - 标题 `#`–`######`(段落里的行首)
|
|
12
|
+
* - 段落(连续非空行;保留原始换行)
|
|
13
|
+
* - 无序列表 `-` / `*` / `+`
|
|
14
|
+
* - 有序列表 `1.` / `1)`(保留起始编号)
|
|
15
|
+
* - 围栏代码块 ```(保留语言标记;块内不做行内解析)
|
|
16
|
+
* - 分隔线 `---` / `***` / `___`
|
|
17
|
+
* - 行内:`**粗体**`、`` `行内代码` ``、反斜杠转义
|
|
18
|
+
*
|
|
19
|
+
* 返回值约定(客户端渲染器按此映射 React 节点):
|
|
20
|
+
* blocks: Array<
|
|
21
|
+
* | {type:'heading', level:number, segments:Segment[]}
|
|
22
|
+
* | {type:'paragraph', segments:Segment[]}
|
|
23
|
+
* | {type:'list', ordered:boolean, start:number, items:Segment[][]}
|
|
24
|
+
* | {type:'code', lang:string, text:string}
|
|
25
|
+
* | {type:'hr'}
|
|
26
|
+
* >
|
|
27
|
+
* Segment = {type:'text'|'strong'|'code', text:string}
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const FENCE_RE = /^\s*```\s*([^\s`]*)\s*$/;
|
|
31
|
+
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
|
|
32
|
+
const HR_RE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
33
|
+
const UL_RE = /^\s*[-*+]\s+(.*)$/;
|
|
34
|
+
const OL_RE = /^\s*(\d+)[.)]\s+(.*)$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 解析行内标记:`**粗体**`、`` `代码` ``、反斜杠转义。
|
|
38
|
+
* 未闭合的标记按普通文本处理(不吞字符)。
|
|
39
|
+
* @param {string} text
|
|
40
|
+
* @returns {{type:'text'|'strong'|'code', text:string}[]}
|
|
41
|
+
*/
|
|
42
|
+
export function parseInlineLite(text) {
|
|
43
|
+
const source = typeof text === 'string' ? text : String(text ?? '');
|
|
44
|
+
const segments = [];
|
|
45
|
+
let buffer = '';
|
|
46
|
+
const flush = () => {
|
|
47
|
+
if (buffer.length > 0) {
|
|
48
|
+
segments.push({ type: 'text', text: buffer });
|
|
49
|
+
buffer = '';
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
let index = 0;
|
|
53
|
+
while (index < source.length) {
|
|
54
|
+
const char = source[index];
|
|
55
|
+
// 反斜杠转义:下一个字符按字面量处理(\* 不开启粗体)。
|
|
56
|
+
if (char === '\\' && index + 1 < source.length) {
|
|
57
|
+
buffer += source[index + 1];
|
|
58
|
+
index += 2;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (char === '`') {
|
|
62
|
+
const end = source.indexOf('`', index + 1);
|
|
63
|
+
if (end === -1) {
|
|
64
|
+
buffer += char;
|
|
65
|
+
index += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
flush();
|
|
69
|
+
segments.push({ type: 'code', text: source.slice(index + 1, end) });
|
|
70
|
+
index = end + 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (char === '*' && source[index + 1] === '*') {
|
|
74
|
+
const end = source.indexOf('**', index + 2);
|
|
75
|
+
if (end === -1) {
|
|
76
|
+
buffer += '**';
|
|
77
|
+
index += 2;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
flush();
|
|
81
|
+
segments.push({ type: 'strong', text: source.slice(index + 2, end) });
|
|
82
|
+
index = end + 2;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
buffer += char;
|
|
86
|
+
index += 1;
|
|
87
|
+
}
|
|
88
|
+
flush();
|
|
89
|
+
return segments;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 把 markdown 文本解析成块数组;空文本返回 []。
|
|
94
|
+
* @param {string} text
|
|
95
|
+
* @returns {object[]}
|
|
96
|
+
*/
|
|
97
|
+
export function parseMarkdownLite(text) {
|
|
98
|
+
const source = typeof text === 'string' ? text : String(text ?? '');
|
|
99
|
+
const lines = source.replace(/\r\n?/g, '\n').split('\n');
|
|
100
|
+
const blocks = [];
|
|
101
|
+
let index = 0;
|
|
102
|
+
|
|
103
|
+
// 连续非空行攒成段落;遇到块级语法先 flush。
|
|
104
|
+
let paragraph = [];
|
|
105
|
+
const flushParagraph = () => {
|
|
106
|
+
if (paragraph.length === 0) return;
|
|
107
|
+
blocks.push({ type: 'paragraph', segments: parseInlineLite(paragraph.join('\n')) });
|
|
108
|
+
paragraph = [];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
while (index < lines.length) {
|
|
112
|
+
const line = lines[index];
|
|
113
|
+
|
|
114
|
+
// 围栏代码块
|
|
115
|
+
const fence = FENCE_RE.exec(line);
|
|
116
|
+
if (fence !== null) {
|
|
117
|
+
flushParagraph();
|
|
118
|
+
const codeLines = [];
|
|
119
|
+
index += 1;
|
|
120
|
+
while (index < lines.length && FENCE_RE.exec(lines[index]) === null) {
|
|
121
|
+
codeLines.push(lines[index]);
|
|
122
|
+
index += 1;
|
|
123
|
+
}
|
|
124
|
+
// 吃掉收尾围栏(未闭合时到文件尾)
|
|
125
|
+
if (index < lines.length) index += 1;
|
|
126
|
+
blocks.push({ type: 'code', lang: fence[1] ?? '', text: codeLines.join('\n') });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 空行:结束段落
|
|
131
|
+
if (line.trim().length === 0) {
|
|
132
|
+
flushParagraph();
|
|
133
|
+
index += 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 标题
|
|
138
|
+
const heading = HEADING_RE.exec(line);
|
|
139
|
+
if (heading !== null) {
|
|
140
|
+
flushParagraph();
|
|
141
|
+
blocks.push({ type: 'heading', level: heading[1].length, segments: parseInlineLite(heading[2]) });
|
|
142
|
+
index += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 分隔线(注意:`---` 也匹配无序列表语法,必须先判 hr)
|
|
147
|
+
if (HR_RE.test(line)) {
|
|
148
|
+
flushParagraph();
|
|
149
|
+
blocks.push({ type: 'hr' });
|
|
150
|
+
index += 1;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const unordered = UL_RE.exec(line);
|
|
155
|
+
const ordered = unordered === null ? OL_RE.exec(line) : null;
|
|
156
|
+
if (unordered !== null || ordered !== null) {
|
|
157
|
+
flushParagraph();
|
|
158
|
+
const isOrdered = ordered !== null;
|
|
159
|
+
const start = isOrdered ? Number(ordered[1]) : 1;
|
|
160
|
+
const items = [];
|
|
161
|
+
const itemRe = isOrdered ? OL_RE : UL_RE;
|
|
162
|
+
while (index < lines.length) {
|
|
163
|
+
const matched = itemRe.exec(lines[index]);
|
|
164
|
+
if (matched === null) break;
|
|
165
|
+
items.push(parseInlineLite(isOrdered ? matched[2] : matched[1]));
|
|
166
|
+
index += 1;
|
|
167
|
+
}
|
|
168
|
+
blocks.push({ type: 'list', ordered: isOrdered, start, items });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 普通段落行(保留换行)
|
|
173
|
+
paragraph.push(line);
|
|
174
|
+
index += 1;
|
|
175
|
+
}
|
|
176
|
+
flushParagraph();
|
|
177
|
+
return blocks;
|
|
178
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 纯对象工具 —— 零依赖。
|
|
3
|
+
*
|
|
4
|
+
* `isPlainObject` 曾在本文件出现之前有三份(config.js / dsh/settings-file.js /
|
|
5
|
+
* actions/settings.js):语义完全相同(非 null、typeof object、非数组),
|
|
6
|
+
* 但任何一份改了判定口径,三处的「配置/信封/深合并」就会不一致。
|
|
7
|
+
* 现在只保留这一份,其余位置一律 import。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 是否是「普通对象」(非 null、typeof object、非数组)。
|
|
12
|
+
*
|
|
13
|
+
* 注意:这是本项目对「JSON 对象」的判定口径,不区分原型链(class 实例也算)。
|
|
14
|
+
* @param {unknown} value
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export function isPlainObject(value) {
|
|
18
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
}
|
package/lib/core/ranges.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - selectTestRange:M1 实验用,取最近 N 条(小范围、可预测);
|
|
9
9
|
* - selectFullRange:M2 正式版用,跳过旧摘要(不二压)、保留近期尾部。
|
|
10
10
|
*/
|
|
11
|
+
import { tokenOf } from './tokens.js';
|
|
11
12
|
|
|
12
13
|
const isSeq = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
13
14
|
|
|
@@ -91,8 +92,6 @@ export function selectFullRange({
|
|
|
91
92
|
}
|
|
92
93
|
if (begin >= list.length - 1) return null;
|
|
93
94
|
|
|
94
|
-
const tokenOf = (node) => node.heuristicTokens ?? node.tokens ?? 0;
|
|
95
|
-
|
|
96
95
|
// 终点:从尾部往回留出 recentTailTokens 的逐字尾部。
|
|
97
96
|
let end = list.length - 1;
|
|
98
97
|
let tailSum = 0;
|
package/lib/core/relay.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* 接力链记录(A5)—— 把「这次交接建了哪个子会话」从文案变成数据。
|
|
3
3
|
*
|
|
4
4
|
* 写在**父会话**日志里的一条 plugin record(不进模型上下文、不算 token、压缩碰不到),
|
|
5
|
-
*
|
|
5
|
+
* 和压缩判断记录/pin 同一种模式。下一次从同一会话交接时优先读它,不再靠解析标题/输出文案。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
/** 会话日志里记录 relay
|
|
8
|
+
/** 会话日志里记录 relay 的类型(与压缩判断记录/pin 的 plugin record 同前缀)。 */
|
|
9
9
|
export const RELAY_RECORD_TYPE = 'plugin:compact-relay/relay';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* token 估算公共口径 —— 纯函数,零依赖。
|
|
3
|
+
*
|
|
4
|
+
* surface 节点(dsh/pressure 测出来的 node)同时可能带 `heuristicTokens`
|
|
5
|
+
* 与 `tokens`。口径必须全插件一致:
|
|
6
|
+
* - 选段的「近期尾部预算」(core/ranges.js)
|
|
7
|
+
* - state 帧里算可压范围(actions/state.js)
|
|
8
|
+
* - 压缩预览/执行里算「腾出多少容量」(actions/better-compact.js、actions/test-compact.js)
|
|
9
|
+
*
|
|
10
|
+
* 以前这几处各写一份 `node.heuristicTokens ?? node.tokens ?? 0`,还有两处
|
|
11
|
+
* 完全相同的 `shadowedTokensOf` / `tokenCountOf` 求和函数;现在只保留这里一份。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 单个 surface 节点按哪个字段算 token(heuristic 优先,口径唯一)。
|
|
16
|
+
* @param {{tokens?:number, heuristicTokens?:number}} node
|
|
17
|
+
* @returns {number}
|
|
18
|
+
*/
|
|
19
|
+
export function tokenOf(node) {
|
|
20
|
+
return node?.heuristicTokens ?? node?.tokens ?? 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 被遮罩 seq 集合的 token 总数(压缩范围要「腾出多少容量」的公共算法)。
|
|
25
|
+
* @param {{seq:number, tokens?:number, heuristicTokens?:number}[]} nodes
|
|
26
|
+
* @param {number[]} shadowedSeqs
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
export function sumShadowedTokens(nodes, shadowedSeqs) {
|
|
30
|
+
const wanted = new Set(shadowedSeqs);
|
|
31
|
+
return nodes
|
|
32
|
+
.filter((node) => wanted.has(node.seq))
|
|
33
|
+
.reduce((sum, node) => sum + tokenOf(node), 0);
|
|
34
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* 3. 没有 compactionId 的旧日志按旧口径(有 replace 即算)。
|
|
11
11
|
*/
|
|
12
12
|
import { sessionLog } from './session-view.js';
|
|
13
|
+
import { scanCompactionLog } from './compaction-pair.js';
|
|
13
14
|
|
|
14
15
|
const DEFAULT_LIMIT = 10;
|
|
15
16
|
const MAX_LIMIT = 50;
|
|
@@ -86,25 +87,8 @@ function limitOf(options) {
|
|
|
86
87
|
export function collectCompactions(session, options = {}) {
|
|
87
88
|
const limit = limitOf(options);
|
|
88
89
|
const maxChars = Number.isFinite(options.previewChars) ? Math.max(1, Math.trunc(options.previewChars)) : PREVIEW_CHARS;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const summaries = [];
|
|
92
|
-
const ends = new Map(); // compactionId -> end.data
|
|
93
|
-
const replaceByCoveredSeq = new Map(); // 被 replace 覆盖的 seq -> replace event
|
|
94
|
-
|
|
95
|
-
for (const event of log) {
|
|
96
|
-
if (event === null || typeof event !== 'object') continue;
|
|
97
|
-
if (event.type === 'compaction/summary') {
|
|
98
|
-
summaries.push(event);
|
|
99
|
-
} else if (event.type === 'compaction/end') {
|
|
100
|
-
const id = event.data?.compactionId;
|
|
101
|
-
if (typeof id === 'string') ends.set(id, event.data);
|
|
102
|
-
} else if (event.type === 'user/message' && Array.isArray(event.sourceEventSeqs)) {
|
|
103
|
-
for (const seq of event.sourceEventSeqs) {
|
|
104
|
-
if (typeof seq === 'number' && Number.isFinite(seq)) replaceByCoveredSeq.set(seq, event);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
90
|
+
// 配对扫描与 dsh/session-view.countSummaries 共用(见 compaction-pair.js)。
|
|
91
|
+
const { summaries, ends, replaceByCoveredSeq } = scanCompactionLog(sessionLog(session));
|
|
108
92
|
|
|
109
93
|
const items = [];
|
|
110
94
|
for (let index = summaries.length - 1; index >= 0 && items.length < limit; index -= 1) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 压缩事件配对扫描(A6)—— 从会话日志里把 summary / end / replace 挂上钩。
|
|
3
|
+
*
|
|
4
|
+
* 两处消费方,口径必须一致:
|
|
5
|
+
* - dsh/session-view.countSummaries:数「已提交的压缩份数」(advice/合并旧摘要用);
|
|
6
|
+
* - dsh/compaction-log.collectCompactions:还原最近几次压缩(只读面板 / diff 端点)。
|
|
7
|
+
*
|
|
8
|
+
* 以前两处各自 for 一遍日志、各自建 summaries / ends / replace 索引,键名和过滤
|
|
9
|
+
* 条件还不完全一样(countSummaries 用 replacedSeqs Set,compaction-log 用
|
|
10
|
+
* replaceByCoveredSeq Map)——只要一处改配对规则,另一处就静默漂移。
|
|
11
|
+
* 现在扫描只在这里做一次,两个消费方各自保留自己的「已提交」判定。
|
|
12
|
+
*
|
|
13
|
+
* 只读:不写日志、不碰 session 内部结构。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 扫描事件日志,返回三类索引。
|
|
18
|
+
*
|
|
19
|
+
* - summaries:compaction/summary 事件,按日志顺序;
|
|
20
|
+
* - ends:compactionId → compaction/end 事件的 data(同 id 多次时后者覆盖,与旧实现一致);
|
|
21
|
+
* - replaceByCoveredSeq:被 replace 覆盖的 seq → 该 user/message 事件。
|
|
22
|
+
* 只收 number 且有限的 seq(summary.seq 必然是整数,字符串/NaN 键匹配不上,
|
|
23
|
+
* 和旧实现直接 Set.add 的判定结果一致)。
|
|
24
|
+
*
|
|
25
|
+
* @param {object[]} log 会话事件日志(dsh/session-view.sessionLog 的返回值)
|
|
26
|
+
* @returns {{summaries:object[], ends:Map<string,object>, replaceByCoveredSeq:Map<number,object>}}
|
|
27
|
+
*/
|
|
28
|
+
export function scanCompactionLog(log) {
|
|
29
|
+
const summaries = [];
|
|
30
|
+
const ends = new Map();
|
|
31
|
+
const replaceByCoveredSeq = new Map();
|
|
32
|
+
|
|
33
|
+
for (const event of Array.isArray(log) ? log : []) {
|
|
34
|
+
if (event === null || typeof event !== 'object') continue;
|
|
35
|
+
if (event.type === 'compaction/summary') {
|
|
36
|
+
summaries.push(event);
|
|
37
|
+
} else if (event.type === 'compaction/end') {
|
|
38
|
+
const id = event.data?.compactionId;
|
|
39
|
+
if (typeof id === 'string') ends.set(id, event.data);
|
|
40
|
+
} else if (event.type === 'user/message' && Array.isArray(event.sourceEventSeqs)) {
|
|
41
|
+
for (const seq of event.sourceEventSeqs) {
|
|
42
|
+
if (typeof seq === 'number' && Number.isFinite(seq)) replaceByCoveredSeq.set(seq, event);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { summaries, ends, replaceByCoveredSeq };
|
|
48
|
+
}
|
package/lib/dsh/engine.js
CHANGED
|
@@ -27,9 +27,9 @@ export const engine = Object.freeze({
|
|
|
27
27
|
createUserMessage,
|
|
28
28
|
/** 错误 → 可持久化的 JSON 链。 */
|
|
29
29
|
errorChain,
|
|
30
|
-
/**
|
|
30
|
+
/** 追加一条插件私有记录(压缩判断记录/锁定/接力链;不进模型可见 surface)。 */
|
|
31
31
|
appendPluginRecord,
|
|
32
|
-
/** 声明一个模型可调用的工具(M4
|
|
32
|
+
/** 声明一个模型可调用的工具(M4 压缩判断;0.3.4 起工具名 mark_compress_point)。 */
|
|
33
33
|
defineTool,
|
|
34
34
|
});
|
|
35
35
|
|
package/lib/dsh/handoff-files.js
CHANGED
|
@@ -17,6 +17,34 @@ function headerField(text, label) {
|
|
|
17
17
|
return match === null ? null : match[1].trim();
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 交接单总数(0.3.4 BUG-006:列表端点要能报「共 N 份」,不能只回最近 20)。
|
|
22
|
+
*
|
|
23
|
+
* 只数合法文件名 + 普通文件,口径与 listHandoffFiles 一致;不读文件头,成本是
|
|
24
|
+
* 一次 readdir + 每个文件的 stat(目录里通常几十份,可忽略)。
|
|
25
|
+
* @param {string} dir
|
|
26
|
+
* @returns {number}
|
|
27
|
+
*/
|
|
28
|
+
export function countHandoffFiles(dir) {
|
|
29
|
+
if (typeof dir !== 'string' || dir.length === 0) return 0;
|
|
30
|
+
let names;
|
|
31
|
+
try {
|
|
32
|
+
names = fs.readdirSync(dir);
|
|
33
|
+
} catch {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
let total = 0;
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
if (!NAME_RE.test(name)) continue;
|
|
39
|
+
try {
|
|
40
|
+
if (fs.statSync(path.join(dir, name)).isFile()) total += 1;
|
|
41
|
+
} catch {
|
|
42
|
+
/* 读不到 stat 的文件不计入(与列表口径一致) */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return total;
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
/**
|
|
21
49
|
* 列交接单(新→旧)。
|
|
22
50
|
* @param {string} dir
|
|
@@ -14,9 +14,7 @@ import { HANDOFF_INSTRUCTION, handoffRecoveryHint } from '../core/prompts.js';
|
|
|
14
14
|
import { HANDOFF_MODEL_SECTIONS, HANDOFF_SPEC } from '../core/handoff-spec.js';
|
|
15
15
|
import { describeHandoffProblems, validateHandoff } from '../core/validate-handoff.js';
|
|
16
16
|
import { surfaceMessages } from './prefix.js';
|
|
17
|
-
import {
|
|
18
|
-
import { cacheControls, routeOf } from './route.js';
|
|
19
|
-
import { finishProblem } from './stream.js';
|
|
17
|
+
import { cacheAlignedControls, callModelAttempt, modelAttemptResult, prepareModelCall } from './model-task.js';
|
|
20
18
|
|
|
21
19
|
/** 首次 + 1 次带 recovery 提示的重试。 */
|
|
22
20
|
const MAX_ATTEMPTS = 2;
|
|
@@ -36,21 +34,23 @@ const MAX_ATTEMPTS = 2;
|
|
|
36
34
|
*/
|
|
37
35
|
export async function generateHandoffDoc({ ctx, engine, agent, maxTokens, inputTokens, timeoutMs, signal, programSections = {} }) {
|
|
38
36
|
const session = agent?.session;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
const { target, reasoningEffort } = await prepareModelCall({
|
|
38
|
+
ctx,
|
|
39
|
+
session,
|
|
40
|
+
agent,
|
|
41
|
+
inputTokens,
|
|
42
|
+
maxTokens,
|
|
43
|
+
label: '交接单生成',
|
|
44
|
+
code: 'handoff',
|
|
45
|
+
noSessionMessage: '会话不存在,无法生成交接单',
|
|
46
|
+
noTargetMessage: '没有可用的 provider/model,交接单无法生成',
|
|
47
|
+
});
|
|
48
48
|
|
|
49
49
|
const prefix = surfaceMessages(session);
|
|
50
50
|
if (prefix.length === 0) throw actionError('handoff', '这个会话还没有可交接的内容(没有模型可见的消息)');
|
|
51
51
|
|
|
52
52
|
// 同摘要器:不继承 reasoningEffort(max 思考会把输出上限吃光);是否传 off 由模型能力决定。
|
|
53
|
-
const
|
|
53
|
+
const controls = cacheAlignedControls(session, target);
|
|
54
54
|
let lastProblems = [];
|
|
55
55
|
|
|
56
56
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
@@ -72,16 +72,15 @@ export async function generateHandoffDoc({ ctx, engine, agent, maxTokens, inputT
|
|
|
72
72
|
// 交接是第三方调用,标 'handoff' 属类型层非法(运行时虽然不读,也不该留)。
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
-
const assembler = await
|
|
75
|
+
const { assembler, problem } = await callModelAttempt({
|
|
76
76
|
ctx,
|
|
77
77
|
engine,
|
|
78
78
|
options,
|
|
79
79
|
signal,
|
|
80
80
|
timeoutMs,
|
|
81
81
|
label: '交接单生成',
|
|
82
|
+
code: 'handoff',
|
|
82
83
|
});
|
|
83
|
-
|
|
84
|
-
const problem = finishProblem(assembler, { code: 'handoff', label: '交接单生成' });
|
|
85
84
|
if (problem !== null) {
|
|
86
85
|
if (assembler.finish?.kind === 'max-tokens' && attempt < MAX_ATTEMPTS) {
|
|
87
86
|
lastProblems = ['输出达到 maxTokens 上限被截断(需要更短、更紧凑的交接单)'];
|
|
@@ -112,11 +111,7 @@ export async function generateHandoffDoc({ ctx, engine, agent, maxTokens, inputT
|
|
|
112
111
|
return {
|
|
113
112
|
text: full.text,
|
|
114
113
|
sections: full.sections,
|
|
115
|
-
|
|
116
|
-
model: options.model,
|
|
117
|
-
maxTokens,
|
|
118
|
-
attempts: attempt,
|
|
119
|
-
...(assembler.usage === undefined ? {} : { usage: assembler.usage }),
|
|
114
|
+
...modelAttemptResult({ assembler, options, maxTokens, attempt }),
|
|
120
115
|
};
|
|
121
116
|
}
|
|
122
117
|
lastProblems = validation.problems;
|