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.
- package/CHANGELOG.md +76 -3
- package/README.en.md +18 -1
- package/README.md +20 -3
- package/ROADMAP.md +9 -2
- package/lib/actions/better-compact.js +38 -1
- package/lib/actions/compact-run.js +29 -3
- package/lib/actions/compress-hint.js +21 -1
- package/lib/actions/diagnostics.js +130 -0
- package/lib/actions/handoff.js +90 -11
- package/lib/actions/pin.js +6 -23
- package/lib/actions/state.js +47 -5
- package/lib/adapters/http.js +70 -7
- package/lib/adapters/prompt.js +71 -10
- package/lib/adapters/tool.js +9 -4
- package/lib/client.js +485 -13
- package/lib/core/compress-cue.js +48 -16
- package/lib/core/handoff-doc.js +4 -0
- package/lib/core/handoff-edit.js +86 -0
- package/lib/core/handoff-facts.js +17 -2
- package/lib/core/handoff-spec.js +18 -0
- package/lib/core/ledger.js +176 -0
- package/lib/core/native-line.js +65 -0
- package/lib/core/pin-anchors.js +93 -0
- package/lib/core/pin-suggest.js +129 -0
- package/lib/core/policy.js +16 -0
- package/lib/dsh/diag-counters.js +204 -0
- package/lib/dsh/diagnostics.js +527 -0
- package/lib/dsh/handoff-writer.js +7 -5
- package/lib/dsh/ledger.js +108 -0
- package/lib/dsh/native-compaction.js +128 -0
- package/lib/dsh/pressure.js +62 -10
- package/lib/dsh/relay.js +23 -6
- package/lib/dsh/session-view.js +32 -4
- package/lib/dsh/sessions.js +1 -1
- package/lib/index.js +6 -0
- package/package.json +2 -2
package/lib/core/compress-cue.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
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` 工具只告诉模型「条件合适时调用」,
|
|
5
5
|
* 但模型根本看不到当前容量,两条条件无法判断 → 427 个真实会话里 0 次调用。
|
|
6
|
-
*
|
|
7
|
-
* 模型才具备可执行判断;`mark_compress_point` 工具再自己复核一次(服务端仍校验)。
|
|
6
|
+
* 0.3.4 起由宿主在 system prompt 的动态 context 里给出「当前处于可压缩区间」的提示。
|
|
8
7
|
*
|
|
9
|
-
*
|
|
8
|
+
* 0.4 P0-1(BUG-010 的后续,Win 173 会话 cue 注入 18 次但工具 0 调用):
|
|
9
|
+
* 1. 文案 v2:把抽象的「自然节点」换成 4 个可自检的完成信号(功能已实现并验证 /
|
|
10
|
+
* 文档已交付 / 问题已定位并修复 / 准备交接),并把「不确定就别调」改成
|
|
11
|
+
* 「不确定也建议调一次让工具判断:被拒绝不会改动历史」——零成本试错;
|
|
12
|
+
* 2. 区间 v2:cue 只在 [CUE_MIN_RATIO, min(CUE_MAX_RATIO, compactRatio)) 内生成;
|
|
13
|
+
* 下限与动态最小线(保留尾部 + 最小可压 token / 窗口)取较大者,保证 `min` 显示的
|
|
14
|
+
* 是「真正压得划算」的线;上限不含压缩线(到线该直接建议压缩,不该再记节点)。
|
|
15
|
+
*
|
|
16
|
+
* 这段输出会被塞进 system prompt,所以两件事很重要:
|
|
10
17
|
* 1. 尽量稳定:容量按 5% 桶取整,避免每个 token 变化都让提示重发;
|
|
11
|
-
* 2. 尽量安静:刚记过 hint(差 10
|
|
18
|
+
* 2. 尽量安静:刚记过 hint(差 10 个百分点内)不再重复提醒;注入频率由
|
|
19
|
+
* adapters/prompt.js 的门控(≥ CUE_MIN_TURNS 轮一次)再收一道。
|
|
12
20
|
*
|
|
13
21
|
* 只做纯计算:入参都是数字/小对象,不 import 任何 DSH 模块,可裸跑单测。
|
|
14
22
|
*/
|
|
23
|
+
import { CUE_MAX_RATIO, CUE_MIN_RATIO } from './policy.js';
|
|
15
24
|
|
|
16
25
|
/** 动态 context 的名称(宿主按 name 去重/覆盖)。 */
|
|
17
26
|
export const COMPRESS_CUE_NAME = 'compact-relay:compress-cue';
|
|
@@ -56,13 +65,35 @@ export function formatPercent(ratio) {
|
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
68
|
+
* 0.4 P0-1:计算本会话的 cue 区间。
|
|
69
|
+
*
|
|
70
|
+
* - 下限 = max(CUE_MIN_RATIO, 动态最小线):10% 以下压不划算,动态最小线以下同样不划算;
|
|
71
|
+
* - 上限 = min(CUE_MAX_RATIO, compactRatio):40% 及以上该直接建议压缩,用户把压缩线
|
|
72
|
+
* 配得更低时也不能越过它(否则 cue 会诱导一次注定被拒的调用)。
|
|
73
|
+
*
|
|
74
|
+
* 返回的 `{minRatio, maxRatio}` 直接喂给 inCompressCueBand / compressCueText,
|
|
75
|
+
* 也用于提示里的「可压缩区间 X%–Y%」显示(与真实可接受区间一致)。
|
|
76
|
+
*
|
|
77
|
+
* @param {{compactRatio?:number, dynamicMinRatio?:number}} [input]
|
|
78
|
+
* @returns {{minRatio:number, maxRatio:number}}
|
|
79
|
+
*/
|
|
80
|
+
export function cueBandOf({ compactRatio, dynamicMinRatio } = {}) {
|
|
81
|
+
const dynamic = Number.isFinite(dynamicMinRatio) ? dynamicMinRatio : 0;
|
|
82
|
+
const lower = Math.max(CUE_MIN_RATIO, dynamic);
|
|
83
|
+
const configured = Number.isFinite(compactRatio) ? compactRatio : CUE_MAX_RATIO;
|
|
84
|
+
const upper = Math.min(CUE_MAX_RATIO, configured);
|
|
85
|
+
return { minRatio: lower, maxRatio: upper };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 是否处于「可以记压缩节点」的区间:minRatio ≤ 当前 < maxRatio。
|
|
90
|
+
* 上限不含压缩线本身 —— 到线该直接建议压缩,而不是记节点(工具也会拒绝)。
|
|
91
|
+
* @param {{ratio:number, minRatio:number, maxRatio:number}} input
|
|
61
92
|
* @returns {boolean}
|
|
62
93
|
*/
|
|
63
|
-
export function inCompressCueBand({ ratio, minRatio,
|
|
64
|
-
if (!Number.isFinite(ratio) || !Number.isFinite(minRatio) || !Number.isFinite(
|
|
65
|
-
return ratio >= minRatio && ratio <
|
|
94
|
+
export function inCompressCueBand({ ratio, minRatio, maxRatio }) {
|
|
95
|
+
if (!Number.isFinite(ratio) || !Number.isFinite(minRatio) || !Number.isFinite(maxRatio)) return false;
|
|
96
|
+
return ratio >= minRatio && ratio < maxRatio;
|
|
66
97
|
}
|
|
67
98
|
|
|
68
99
|
/**
|
|
@@ -70,13 +101,13 @@ export function inCompressCueBand({ ratio, minRatio, compactRatio }) {
|
|
|
70
101
|
*
|
|
71
102
|
* @param {object} input
|
|
72
103
|
* @param {number} input.ratio 当前上下文占用比例(0–1)
|
|
73
|
-
* @param {number} input.minRatio
|
|
74
|
-
* @param {number} input.
|
|
104
|
+
* @param {number} input.minRatio cue 区间下限(cueBandOf 的结果,已含动态最小线)
|
|
105
|
+
* @param {number} input.maxRatio cue 区间上限(cueBandOf 的结果,已含压缩线)
|
|
75
106
|
* @param {{ratio:number}|null} [input.compressHint] 当前有效的「适合压缩」记录(没有为 null)
|
|
76
107
|
* @returns {string}
|
|
77
108
|
*/
|
|
78
|
-
export function compressCueText({ ratio, minRatio,
|
|
79
|
-
if (!inCompressCueBand({ ratio, minRatio,
|
|
109
|
+
export function compressCueText({ ratio, minRatio, maxRatio, compressHint = null }) {
|
|
110
|
+
if (!inCompressCueBand({ ratio, minRatio, maxRatio })) return '';
|
|
80
111
|
// 刚记过断点:容量只涨了不到 10 个百分点 → 不重复提醒(模型已经判断过一次)。
|
|
81
112
|
if (
|
|
82
113
|
compressHint !== null &&
|
|
@@ -90,7 +121,8 @@ export function compressCueText({ ratio, minRatio, compactRatio, compressHint =
|
|
|
90
121
|
// 的动态最小线是 33.569% —— 那样提示会自相矛盾(「约 30%(可压缩区间 33.6%–40%)」)。
|
|
91
122
|
const shownRatio = Math.max(bucketRatio(ratio), minRatio);
|
|
92
123
|
return (
|
|
93
|
-
`上下文容量约 ${formatPercent(shownRatio)}%(可压缩区间 ${formatPercent(minRatio)}%–${formatPercent(
|
|
94
|
-
'
|
|
124
|
+
`上下文容量约 ${formatPercent(shownRatio)}%(可压缩区间 ${formatPercent(minRatio)}%–${formatPercent(maxRatio)}%)。` +
|
|
125
|
+
'如果你刚刚完成了一个明确阶段——功能已实现并验证 / 文档已交付 / 问题已定位并修复 / 准备交接——就在回复前调用一次 mark_compress_point 记录节点。' +
|
|
126
|
+
'不确定也建议调一次让工具判断:被拒绝不会改动历史、不影响会话。不处于阶段收尾就不用管这句。'
|
|
95
127
|
);
|
|
96
128
|
}
|
package/lib/core/handoff-doc.js
CHANGED
|
@@ -106,6 +106,7 @@ function siteLines(site) {
|
|
|
106
106
|
* @param {number} [input.summaries] 原会话已发生的压缩摘要份数
|
|
107
107
|
* @param {string} input.file 本文件绝对路径(写进文档头,方便回查)
|
|
108
108
|
* @param {object} [input.site] 现场快照(dsh/site-info.buildSiteSnapshot 的产物)
|
|
109
|
+
* @param {number} [input.ordinal] 0.4 P2-2:本次是第几次交接(链式序号,首次 1)
|
|
109
110
|
* @returns {string}
|
|
110
111
|
*/
|
|
111
112
|
export function renderHandoffDoc({
|
|
@@ -117,6 +118,7 @@ export function renderHandoffDoc({
|
|
|
117
118
|
summaries,
|
|
118
119
|
file,
|
|
119
120
|
site,
|
|
121
|
+
ordinal,
|
|
120
122
|
}) {
|
|
121
123
|
const lines = [
|
|
122
124
|
'# 交接单',
|
|
@@ -125,6 +127,8 @@ export function renderHandoffDoc({
|
|
|
125
127
|
...siteLines(site),
|
|
126
128
|
`- 原会话:${sourceSessionId ?? '未知'}`,
|
|
127
129
|
`- 原会话标题:${sourceTitle ?? '(未命名)'}`,
|
|
130
|
+
// 0.4 P2-2:接力链元数据可读化——文档头直接写「第 N 次交接」,接手者不用解析标题。
|
|
131
|
+
...(Number.isInteger(ordinal) && ordinal > 0 ? [`- 交接序号:第 ${ordinal} 次交接`] : []),
|
|
128
132
|
`- 已压缩次数:${summaries ?? 0}`,
|
|
129
133
|
`- 交接时已用容量:${pressure ?? '未知'}`,
|
|
130
134
|
`- 交接单路径:${file ?? '(未落盘)'}`,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 交接预览「两段可编辑」(0.4 P2-1)—— 纯函数,零依赖。
|
|
3
|
+
*
|
|
4
|
+
* 只有 `约束` / `下一步` 两段允许用户改;其它七段一律来自模型或程序,**前后端都不接受覆盖**。
|
|
5
|
+
* 校验口径(方案 P2-1):
|
|
6
|
+
* - 非法字段(程序生成段/拼错的段名/非对象)→ 抛 `actionError('edits', …)`,由 HTTP 适配层回 400;
|
|
7
|
+
* - 值必须是字符串;trim 后为空 → 视为「没改」,回落生成后的原文(不覆盖);
|
|
8
|
+
* - 单段长度上限 `HANDOFF_EDIT_MAX_CHARS`,超限拒绝(不截断,避免用户以为保存成功);
|
|
9
|
+
* - 只有用户确认交接时才应用(本模块只算覆盖结果,不写盘)。
|
|
10
|
+
*/
|
|
11
|
+
import { actionError } from './errors.js';
|
|
12
|
+
import { HANDOFF_EDITABLE_SECTIONS } from './handoff-spec.js';
|
|
13
|
+
|
|
14
|
+
/** 单段编辑长度上限(字符)。 */
|
|
15
|
+
export const HANDOFF_EDIT_MAX_CHARS = 4000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 校验并规整用户编辑。只返回「真正要覆盖」的段(空值已剔除)。
|
|
19
|
+
*
|
|
20
|
+
* @param {unknown} edits 客户端提交的 { 段标题: 文本 }
|
|
21
|
+
* @returns {Record<string, string>} 段标题 → trim 后的新正文(可能为空对象)
|
|
22
|
+
* @throws {Error & {code:'edits'}} 非法字段/类型/超长
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeHandoffEdits(edits) {
|
|
25
|
+
if (edits === undefined || edits === null) return {};
|
|
26
|
+
if (typeof edits !== 'object' || Array.isArray(edits)) {
|
|
27
|
+
throw actionError('edits', '编辑内容必须是一个对象(只允许「约束」「下一步」两段)');
|
|
28
|
+
}
|
|
29
|
+
const allowed = new Set(HANDOFF_EDITABLE_SECTIONS);
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const [title, raw] of Object.entries(edits)) {
|
|
32
|
+
if (!allowed.has(title)) {
|
|
33
|
+
const program = title === '当前进度' || title === '改过的文件' ? '(程序生成的段)' : '';
|
|
34
|
+
throw actionError(
|
|
35
|
+
'edits',
|
|
36
|
+
`只能编辑「${HANDOFF_EDITABLE_SECTIONS.join('」「')}」两段,不能改「${title}」${program};其余段落由模型/程序生成`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (typeof raw !== 'string') {
|
|
40
|
+
throw actionError('edits', `「${title}」的编辑内容必须是字符串`);
|
|
41
|
+
}
|
|
42
|
+
const text = raw.trim();
|
|
43
|
+
if (text.length === 0) continue; // 空值回落原文
|
|
44
|
+
if (text.length > HANDOFF_EDIT_MAX_CHARS) {
|
|
45
|
+
throw actionError('edits', `「${title}」超过长度上限 ${HANDOFF_EDIT_MAX_CHARS} 字符(当前 ${text.length})`);
|
|
46
|
+
}
|
|
47
|
+
out[title] = text;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 把编辑覆盖到生成后的九段上(只动可编辑段,返回新对象,不改入参)。
|
|
54
|
+
* @param {Record<string,string>} sections 生成后的九段(标题 → 正文)
|
|
55
|
+
* @param {Record<string,string>} edits normalizeHandoffEdits 的产物
|
|
56
|
+
* @returns {Record<string,string>} 覆盖后的九段(新对象)
|
|
57
|
+
*/
|
|
58
|
+
export function applyHandoffEdits(sections, edits) {
|
|
59
|
+
const source = sections !== null && typeof sections === 'object' ? sections : {};
|
|
60
|
+
const overrides = edits !== null && typeof edits === 'object' ? edits : {};
|
|
61
|
+
const out = { ...source };
|
|
62
|
+
for (const title of HANDOFF_EDITABLE_SECTIONS) {
|
|
63
|
+
const text = overrides[title];
|
|
64
|
+
if (typeof text === 'string' && text.trim().length > 0) out[title] = text.trim();
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 预览接口给客户端的「可编辑段」信息(客户端只渲染这两段 textarea)。
|
|
71
|
+
* @param {Record<string,string>} sections 生成后的九段
|
|
72
|
+
* @returns {{titles:string[], maxChars:number, note:string, sections:Record<string,string>}}
|
|
73
|
+
*/
|
|
74
|
+
export function editableHandoffView(sections) {
|
|
75
|
+
const source = sections !== null && typeof sections === 'object' ? sections : {};
|
|
76
|
+
const picked = {};
|
|
77
|
+
for (const title of HANDOFF_EDITABLE_SECTIONS) {
|
|
78
|
+
picked[title] = typeof source[title] === 'string' ? source[title] : '';
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
titles: [...HANDOFF_EDITABLE_SECTIONS],
|
|
82
|
+
maxChars: HANDOFF_EDIT_MAX_CHARS,
|
|
83
|
+
note: '只允许编辑这两段;总体目标 / 当前进度 / 改过的文件等由模型或程序生成,不能改(留空 = 使用原文)。',
|
|
84
|
+
sections: picked,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -17,7 +17,8 @@ function shortPath(file, cwd) {
|
|
|
17
17
|
* @param {object|null} [facts.compressHint] 模型判断「这里适合压缩」的产物
|
|
18
18
|
* @param {number} [facts.summaries] 已压缩次数
|
|
19
19
|
* @param {number|null} [facts.pressure] 已用容量(token)
|
|
20
|
-
* @param {{count:number, seqs:number[]}|null} [facts.pins]
|
|
20
|
+
* @param {{count:number, seqs:number[], anchors?:Array<{seq:number,role:string,snippet:string}>}|null} [facts.pins]
|
|
21
|
+
* 锁定消息;anchors 是「保留契约」锚点(seq + 角色 + 一句摘要,≤60 字符,不贴全文)
|
|
21
22
|
* @param {string[]} [facts.changedFiles] 本会话写/改过的文件
|
|
22
23
|
* @param {object|null} [facts.site] buildSiteSnapshot 的产物
|
|
23
24
|
* @returns {Record<string,string>} 标题 → 正文
|
|
@@ -32,8 +33,22 @@ export function buildHandoffProgramSections(facts = {}) {
|
|
|
32
33
|
`- 已压缩:${Number.isFinite(summaries) ? summaries : 0} 次`,
|
|
33
34
|
`- 已用容量:${Number.isFinite(pressure) ? pressure : '未知'}`,
|
|
34
35
|
`- 锁定消息:${pins !== null && pins !== undefined && Number.isFinite(pins.count) ? `${pins.count} 条` : '无'}`,
|
|
35
|
-
`- 后台任务:${jobs.length === 0 ? '无' : `${jobs.length} 个可能仍在运行`}`,
|
|
36
36
|
];
|
|
37
|
+
// 0.4 P1-2:pin 作为「保留契约」——在「当前进度」程序段里列出锚点(seq + 角色 + 一句摘要)。
|
|
38
|
+
// 不动九段结构(仍是 9 个 ## 段),因为这是程序收集的事实,属于「当前进度」的一部分。
|
|
39
|
+
const pinAnchors =
|
|
40
|
+
pins !== null && pins !== undefined && Array.isArray(pins.anchors) ? pins.anchors.filter((anchor) => anchor !== null && typeof anchor === 'object') : [];
|
|
41
|
+
if (pinAnchors.length > 0) {
|
|
42
|
+
progressLines.push('- 锁定锚点(保留契约,接手者请原样保留这些消息):');
|
|
43
|
+
for (const anchor of pinAnchors.slice(0, 10)) {
|
|
44
|
+
progressLines.push(` - seq ${anchor.seq} · ${anchor.role} · 「${anchor.snippet}」`);
|
|
45
|
+
}
|
|
46
|
+
if (pinAnchors.length > 10) {
|
|
47
|
+
progressLines.push(` - …另有 ${pinAnchors.length - 10} 条锁定未列出(会话内 /pin list 可看全)`);
|
|
48
|
+
}
|
|
49
|
+
progressLines.push('- 说明:以上锚点是「保留意图」的声明;跨引擎压缩器是否遵守取决于其实现。');
|
|
50
|
+
}
|
|
51
|
+
progressLines.push(`- 后台任务:${jobs.length === 0 ? '无' : `${jobs.length} 个可能仍在运行`}`);
|
|
37
52
|
if (site !== null && typeof site === 'object' && typeof site.cwd === 'string') {
|
|
38
53
|
progressLines.push(`- 工作目录:${site.cwd}`);
|
|
39
54
|
}
|
package/lib/core/handoff-spec.js
CHANGED
|
@@ -21,6 +21,8 @@ export const HANDOFF_SPEC = Object.freeze([
|
|
|
21
21
|
title: '约束',
|
|
22
22
|
owner: 'model',
|
|
23
23
|
required: true,
|
|
24
|
+
// 0.4 P2-1:只有这两段允许用户在手改预览里编辑(其余段一律程序/模型写,前后端都不接受覆盖)。
|
|
25
|
+
editable: true,
|
|
24
26
|
hint: '不能违反的边界:权限/安全/用户明确要求/禁止事项',
|
|
25
27
|
},
|
|
26
28
|
{
|
|
@@ -70,6 +72,7 @@ export const HANDOFF_SPEC = Object.freeze([
|
|
|
70
72
|
title: '下一步',
|
|
71
73
|
owner: 'model',
|
|
72
74
|
required: true,
|
|
75
|
+
editable: true,
|
|
73
76
|
hint: '接手者第一步做什么;可执行、别写「继续努力」',
|
|
74
77
|
},
|
|
75
78
|
]);
|
|
@@ -87,6 +90,21 @@ export const HANDOFF_PROGRAM_SECTIONS = Object.freeze(
|
|
|
87
90
|
HANDOFF_SPEC.filter((section) => section.owner === 'program').map((section) => section.title),
|
|
88
91
|
);
|
|
89
92
|
|
|
93
|
+
/** 0.4 P2-1:允许用户编辑的段落标题(当前只有「约束」「下一步」)。 */
|
|
94
|
+
export const HANDOFF_EDITABLE_SECTIONS = Object.freeze(
|
|
95
|
+
HANDOFF_SPEC.filter((section) => section.editable === true).map((section) => section.title),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 把「标题 → 正文」按九段规格顺序拼成 markdown(段落顺序/标题的唯一渲染口径)。
|
|
100
|
+
* @param {Record<string, string>} sections
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function composeHandoffText(sections) {
|
|
104
|
+
const source = sections !== null && typeof sections === 'object' ? sections : {};
|
|
105
|
+
return HANDOFF_SPEC.map((spec) => `## ${spec.title}\n${String(source[spec.title] ?? '').trim()}`).join('\n\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
/** 按标题查规格;找不到返回 null。 */
|
|
91
109
|
export function handoffSpecOf(title) {
|
|
92
110
|
return HANDOFF_SPEC.find((section) => section.title === title) ?? null;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻账本(0.4 P1-3)—— 压缩/交接「可审计流水」的**纯数据层**(零 IO、零依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 只做三件事:
|
|
5
|
+
* 1. `buildLedgerRecord(input)`:按 allowlist 构造一条记录(只收数值/枚举/时间/交接单路径;
|
|
6
|
+
* **绝不收消息原文、摘要、交接单正文、sessionId、cwd、用户名**等隐私内容);
|
|
7
|
+
* 2. `normalizeLedgerRecord(raw)` / `parseLedgerLines(text)`:读侧容错——坏行/坏记录直接跳过,
|
|
8
|
+
* 让损坏文件永远不会影响宿主动作;
|
|
9
|
+
* 3. `summarizeLedgerRecords(records)`:设置页那一行累计数字(次数 / 腾出 tokens / 平均耗时)。
|
|
10
|
+
*
|
|
11
|
+
* 文件读写在 `dsh/ledger.js`;本模块可裸跑单测。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 账本 schema 版本(JSONL 每行一个对象)。 */
|
|
15
|
+
export const LEDGER_SCHEMA = 'dcr-ledger/v1';
|
|
16
|
+
|
|
17
|
+
/** 保留的最近条数上限(超过就裁掉最旧的)。 */
|
|
18
|
+
export const LEDGER_MAX_ENTRIES = 200;
|
|
19
|
+
|
|
20
|
+
/** 允许的 kind。 */
|
|
21
|
+
export const LEDGER_KINDS = Object.freeze(['compact', 'handoff']);
|
|
22
|
+
|
|
23
|
+
function finiteOrNull(value) {
|
|
24
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** token 数:非负整数(>2^53 的怪值也夹住,防手滑)。 */
|
|
28
|
+
function tokensOrNull(value) {
|
|
29
|
+
const number = finiteOrNull(value);
|
|
30
|
+
if (number === null) return null;
|
|
31
|
+
return Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, Math.round(number)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 比例:0–1,保留 3 位小数。 */
|
|
35
|
+
function ratioOrNull(value) {
|
|
36
|
+
const number = finiteOrNull(value);
|
|
37
|
+
if (number === null) return null;
|
|
38
|
+
return Math.round(Math.min(1, Math.max(0, number)) * 1000) / 1000;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 耗时 ms:非负整数。 */
|
|
42
|
+
function durationOrNull(value) {
|
|
43
|
+
const number = finiteOrNull(value);
|
|
44
|
+
if (number === null) return null;
|
|
45
|
+
return Math.max(0, Math.round(number));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 单行文本(路径/id 用):折叠空白 + 截断;空串 → null。 */
|
|
49
|
+
function lineOrNull(value, maxChars) {
|
|
50
|
+
if (typeof value !== 'string') return null;
|
|
51
|
+
const text = value.replace(/\s+/gu, ' ').trim();
|
|
52
|
+
if (text.length === 0) return null;
|
|
53
|
+
return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 构造一条账本记录;kind 非法返回 null(调用方静默丢弃)。
|
|
58
|
+
*
|
|
59
|
+
* @param {object} input
|
|
60
|
+
* @param {'compact'|'handoff'} input.kind
|
|
61
|
+
* @param {string} [input.at] 本地 ISO 时间(由 dsh/ledger 注入;纯函数这里只做字符串校验)
|
|
62
|
+
* @param {number} [input.durationMs]
|
|
63
|
+
* @param {number|null} [input.beforeTokens]
|
|
64
|
+
* @param {number|null} [input.afterTokens]
|
|
65
|
+
* @param {number|null} [input.beforeRatio]
|
|
66
|
+
* @param {number|null} [input.afterRatio]
|
|
67
|
+
* @param {{inRange?:number|null, total?:number|null}|null} [input.pinHits]
|
|
68
|
+
* @param {{path?:string, childId?:string, started?:boolean}|null} [input.handoff] 仅交接记录
|
|
69
|
+
* @returns {object|null}
|
|
70
|
+
*/
|
|
71
|
+
export function buildLedgerRecord(input) {
|
|
72
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
73
|
+
const kind = LEDGER_KINDS.includes(input.kind) ? input.kind : null;
|
|
74
|
+
if (kind === null) return null;
|
|
75
|
+
|
|
76
|
+
const beforeTokens = tokensOrNull(input.beforeTokens);
|
|
77
|
+
const afterTokens = tokensOrNull(input.afterTokens);
|
|
78
|
+
// 「腾出」= 压前 - 压后;拿不到两个数就 null(不猜)。
|
|
79
|
+
const freedTokens = beforeTokens !== null && afterTokens !== null ? Math.max(0, beforeTokens - afterTokens) : null;
|
|
80
|
+
const pinSource = input.pinHits !== null && typeof input.pinHits === 'object' ? input.pinHits : {};
|
|
81
|
+
const pinInRange = tokensOrNull(pinSource.inRange);
|
|
82
|
+
const pinTotal = tokensOrNull(pinSource.total);
|
|
83
|
+
const handoffSource = input.handoff !== null && typeof input.handoff === 'object' ? input.handoff : null;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
schema: LEDGER_SCHEMA,
|
|
87
|
+
kind,
|
|
88
|
+
at: lineOrNull(input.at, 40),
|
|
89
|
+
durationMs: durationOrNull(input.durationMs),
|
|
90
|
+
beforeTokens,
|
|
91
|
+
afterTokens,
|
|
92
|
+
freedTokens,
|
|
93
|
+
beforeRatio: ratioOrNull(input.beforeRatio),
|
|
94
|
+
afterRatio: ratioOrNull(input.afterRatio),
|
|
95
|
+
pinHits: { inRange: pinInRange, total: pinTotal },
|
|
96
|
+
handoff:
|
|
97
|
+
kind === 'handoff' && handoffSource !== null
|
|
98
|
+
? {
|
|
99
|
+
// 只记路径(任务要求)与短 id/布尔;不记交接单正文。
|
|
100
|
+
path: lineOrNull(handoffSource.path, 500),
|
|
101
|
+
childId: lineOrNull(handoffSource.childId, 120),
|
|
102
|
+
started: typeof handoffSource.started === 'boolean' ? handoffSource.started : null,
|
|
103
|
+
}
|
|
104
|
+
: null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 读侧单条记录容错:形状不对/kind 非法 → null(该行跳过)。
|
|
110
|
+
* @param {unknown} raw
|
|
111
|
+
* @returns {object|null}
|
|
112
|
+
*/
|
|
113
|
+
export function normalizeLedgerRecord(raw) {
|
|
114
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
115
|
+
if (raw.schema !== LEDGER_SCHEMA) return null;
|
|
116
|
+
return buildLedgerRecord(raw);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 解析 JSONL 文本:逐行 JSON.parse,坏行/坏记录跳过(损坏文件静默降级)。
|
|
121
|
+
* @param {string} text
|
|
122
|
+
* @returns {object[]}
|
|
123
|
+
*/
|
|
124
|
+
export function parseLedgerLines(text) {
|
|
125
|
+
if (typeof text !== 'string' || text.length === 0) return [];
|
|
126
|
+
const records = [];
|
|
127
|
+
for (const line of text.split('\n')) {
|
|
128
|
+
const trimmed = line.trim();
|
|
129
|
+
if (trimmed.length === 0) continue;
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = JSON.parse(trimmed);
|
|
133
|
+
} catch {
|
|
134
|
+
continue; // 坏行(半截写/手改)直接跳过
|
|
135
|
+
}
|
|
136
|
+
const record = normalizeLedgerRecord(parsed);
|
|
137
|
+
if (record !== null) records.push(record);
|
|
138
|
+
}
|
|
139
|
+
return records;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 设置页「一行累计数字」的数据(与账本同源,可直接对照)。
|
|
144
|
+
*
|
|
145
|
+
* @param {object[]} records 账本记录(已按时间顺序)
|
|
146
|
+
* @returns {{count:number, compactCount:number, handoffCount:number, freedTokens:number, avgDurationMs:number|null, lastAt:string|null}}
|
|
147
|
+
*/
|
|
148
|
+
export function summarizeLedgerRecords(records) {
|
|
149
|
+
const list = Array.isArray(records) ? records.filter((record) => record !== null && typeof record === 'object') : [];
|
|
150
|
+
let compactCount = 0;
|
|
151
|
+
let handoffCount = 0;
|
|
152
|
+
let freedTokens = 0;
|
|
153
|
+
let durationSum = 0;
|
|
154
|
+
let durationCount = 0;
|
|
155
|
+
let lastAt = null;
|
|
156
|
+
for (const record of list) {
|
|
157
|
+
if (record.kind === 'compact') compactCount += 1;
|
|
158
|
+
else if (record.kind === 'handoff') handoffCount += 1;
|
|
159
|
+
if (typeof record.freedTokens === 'number' && Number.isFinite(record.freedTokens) && record.freedTokens > 0) {
|
|
160
|
+
freedTokens += record.freedTokens;
|
|
161
|
+
}
|
|
162
|
+
if (typeof record.durationMs === 'number' && Number.isFinite(record.durationMs) && record.durationMs >= 0) {
|
|
163
|
+
durationSum += record.durationMs;
|
|
164
|
+
durationCount += 1;
|
|
165
|
+
}
|
|
166
|
+
if (typeof record.at === 'string' && record.at.length > 0) lastAt = record.at;
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
count: compactCount + handoffCount,
|
|
170
|
+
compactCount,
|
|
171
|
+
handoffCount,
|
|
172
|
+
freedTokens,
|
|
173
|
+
avgDurationMs: durationCount === 0 ? null : Math.round(durationSum / durationCount),
|
|
174
|
+
lastAt,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.4 P2-3:DSH 原生自动压缩的**真实触发线**(纯函数,零依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 背景(BUG-012 后续 / Win 真实会话 session-1f90f5ee):
|
|
5
|
+
* 指示灯过去把 compaction-basic 的 `thresholdRatio` 直接当触发线显示(默认 80%),
|
|
6
|
+
* 但 DSH 实际判断压力用的是 token 数,公式(见 dsh-compaction-basic 源码):
|
|
7
|
+
*
|
|
8
|
+
* messageBudgetTokens = contextWindow − reservedCompletionTokens
|
|
9
|
+
* pressureBudgetTokens = messageBudgetTokens − headroomTokens
|
|
10
|
+
* thresholdTokens = floor(min(contextWindow × thresholdRatio, pressureBudgetTokens))
|
|
11
|
+
* 真实触发点 = thresholdTokens / contextWindow
|
|
12
|
+
*
|
|
13
|
+
* 其中:
|
|
14
|
+
* - contextWindow:路由模型的上下文窗口(dcr 已从 ctx.llm.resolveModelInfo 拿到);
|
|
15
|
+
* - reservedCompletionTokens:该次路由请求给输出预留的 token 数
|
|
16
|
+
* = session.requestHeader().config.maxTokens ?? 适配器 defaultMaxTokens ?? 0;
|
|
17
|
+
* - headroomTokens:compaction-basic 的余量(默认 65536,可被全局或 modelPolicies 覆盖)。
|
|
18
|
+
*
|
|
19
|
+
* 只有 `min` 的第二项更小时(输出预留 + 余量把可用空间压下去),真实线才低于
|
|
20
|
+
* 窗口比例;这时显示「原生自动压缩线 67.8%」而不是 80%。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** compaction-basic 的 headroomTokens 默认值(源码:?? 65536)。 */
|
|
24
|
+
export const NATIVE_HEADROOM_DEFAULT = 65536;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 计算真实触发线。
|
|
28
|
+
*
|
|
29
|
+
* @param {object} input
|
|
30
|
+
* @param {number} input.contextWindow 路由上下文窗口(>0 的整数)
|
|
31
|
+
* @param {number} [input.reservedCompletionTokens] 路由输出预留(缺省 0)
|
|
32
|
+
* @param {number} input.thresholdRatio compaction-basic 有效窗口比例(0–1)
|
|
33
|
+
* @param {number} [input.headroomTokens] 余量(缺省 NATIVE_HEADROOM_DEFAULT)
|
|
34
|
+
* @returns {{ok:boolean, reason:string|null, thresholdTokens:number|null, ratio:number|null, pressureBudgetTokens:number|null, messageBudgetTokens:number|null}}
|
|
35
|
+
* ok=false 时 thresholdTokens/ratio 为 null,reason 说明原因(input/no-budget/overflow)
|
|
36
|
+
*/
|
|
37
|
+
export function nativeLineOf({ contextWindow, reservedCompletionTokens, thresholdRatio, headroomTokens } = {}) {
|
|
38
|
+
const window = contextWindow;
|
|
39
|
+
if (!Number.isFinite(window) || window <= 0) {
|
|
40
|
+
return { ok: false, reason: 'input', thresholdTokens: null, ratio: null, pressureBudgetTokens: null, messageBudgetTokens: null };
|
|
41
|
+
}
|
|
42
|
+
const ratioCfg = thresholdRatio;
|
|
43
|
+
if (!Number.isFinite(ratioCfg) || ratioCfg <= 0 || ratioCfg > 1) {
|
|
44
|
+
return { ok: false, reason: 'input', thresholdTokens: null, ratio: null, pressureBudgetTokens: null, messageBudgetTokens: null };
|
|
45
|
+
}
|
|
46
|
+
const reserved = Number.isFinite(reservedCompletionTokens) && reservedCompletionTokens >= 0 ? reservedCompletionTokens : 0;
|
|
47
|
+
const headroom = Number.isFinite(headroomTokens) && headroomTokens >= 0 ? headroomTokens : NATIVE_HEADROOM_DEFAULT;
|
|
48
|
+
const messageBudget = window - reserved;
|
|
49
|
+
const pressureBudget = messageBudget - headroom;
|
|
50
|
+
if (pressureBudget <= 0) {
|
|
51
|
+
return { ok: false, reason: 'no-budget', thresholdTokens: null, ratio: null, pressureBudgetTokens: pressureBudget, messageBudgetTokens: messageBudget };
|
|
52
|
+
}
|
|
53
|
+
const threshold = Math.floor(Math.min(window * ratioCfg, pressureBudget));
|
|
54
|
+
if (!(threshold > 0) || threshold >= window) {
|
|
55
|
+
return { ok: false, reason: 'overflow', thresholdTokens: threshold, ratio: null, pressureBudgetTokens: pressureBudget, messageBudgetTokens: messageBudget };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
reason: null,
|
|
60
|
+
thresholdTokens: threshold,
|
|
61
|
+
ratio: threshold / window,
|
|
62
|
+
pressureBudgetTokens: pressureBudget,
|
|
63
|
+
messageBudgetTokens: messageBudget,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pin 锚点 / 消息摘要 —— 纯函数,零依赖(0.4 P1-1 / P1-2)。
|
|
3
|
+
*
|
|
4
|
+
* 三处展示共用同一份「怎么从会话事件里取一句可读短摘要 + 角色」的实现:
|
|
5
|
+
* 1. `/pin list` 的候选列表(actions/pin.js,0.3.1 起就有这段逻辑,挪到这里收口);
|
|
6
|
+
* 2. 压缩预览的「建议锁定」候选(core/pin-suggest.js,P1-1);
|
|
7
|
+
* 3. 交接单的「锁定锚点(保留契约)」段(core/handoff-facts.js,P1-2)。
|
|
8
|
+
*
|
|
9
|
+
* 只做展示:摘要截断到一句(默认 60 字符,不贴原文全文),不读全量历史、不调模型。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 事件类型 → 中文角色标签(唯一来源)。 */
|
|
13
|
+
export const ROLE_LABELS = Object.freeze({
|
|
14
|
+
'system/message': '系统',
|
|
15
|
+
'user/message': '用户',
|
|
16
|
+
'assistant/message': '助手',
|
|
17
|
+
'tool/result': '工具',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string|undefined} type 事件类型
|
|
22
|
+
* @returns {string} 角色标签;未知类型返回「其他」
|
|
23
|
+
*/
|
|
24
|
+
export function roleLabelOf(type) {
|
|
25
|
+
return ROLE_LABELS[type] ?? '其他';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 从事件里抠出纯文本(消息 content 可能是字符串,也可能是 text block 数组)。
|
|
30
|
+
*
|
|
31
|
+
* DSH v4:user/message 的 data 本身就是消息({role,content,source}),
|
|
32
|
+
* 只有 system/assistant/tool 包在 data.message 里。2026-10-19 debug 实测:
|
|
33
|
+
* 只读 data.message 会让用户消息全部显示「(无文本)」。
|
|
34
|
+
*
|
|
35
|
+
* @param {object|undefined|null} event
|
|
36
|
+
* @returns {string} 空白折叠后的文本;没有文本返回 ''
|
|
37
|
+
*/
|
|
38
|
+
export function messageTextOf(event) {
|
|
39
|
+
const content = event?.data?.content ?? event?.data?.message?.content;
|
|
40
|
+
let text = '';
|
|
41
|
+
if (typeof content === 'string') text = content;
|
|
42
|
+
else if (Array.isArray(content)) {
|
|
43
|
+
text = content
|
|
44
|
+
.filter((block) => block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
|
|
45
|
+
.map((block) => block.text)
|
|
46
|
+
.join(' ');
|
|
47
|
+
}
|
|
48
|
+
return text.replace(/\s+/gu, ' ').trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 一句可读摘要:折叠空白 + 截断(默认 60 字符,超长加省略号)。
|
|
53
|
+
* @param {object|undefined|null} event
|
|
54
|
+
* @param {number} [maxChars]
|
|
55
|
+
* @returns {string} 没有文本返回 ''
|
|
56
|
+
*/
|
|
57
|
+
export function messageSnippetOf(event, maxChars = 60) {
|
|
58
|
+
const text = messageTextOf(event);
|
|
59
|
+
if (text.length === 0) return '';
|
|
60
|
+
const limit = Number.isInteger(maxChars) && maxChars > 1 ? maxChars : 60;
|
|
61
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 是不是「真正的用户消息」:user/message 且 source 缺失(老夹具/测试)或 source.kind==='user'。
|
|
66
|
+
*
|
|
67
|
+
* 运行时合成的 user/message(runtime-context / time-context / compact-checkpoint 等)
|
|
68
|
+
* source.kind 不是 'user',不该被当成「用户明确要求」去做建议锁定/保留锚点。
|
|
69
|
+
* @param {object|undefined|null} event
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
export function isUserAuthoredMessage(event) {
|
|
73
|
+
if (event?.type !== 'user/message') return false;
|
|
74
|
+
const source = event?.data?.source;
|
|
75
|
+
if (source === undefined || source === null) return true;
|
|
76
|
+
return source.kind === 'user';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 一个 pin 锚点:seq + 角色 + 一句摘要(不贴全文)。
|
|
81
|
+
* @param {object|undefined|null} event
|
|
82
|
+
* @param {{maxChars?:number}} [options]
|
|
83
|
+
* @returns {{seq:number, role:string, snippet:string}|null} 没有可用 seq 返回 null
|
|
84
|
+
*/
|
|
85
|
+
export function pinAnchorOf(event, { maxChars = 60 } = {}) {
|
|
86
|
+
const seq = event?.seq;
|
|
87
|
+
if (!Number.isInteger(seq) || seq < 0) return null;
|
|
88
|
+
return {
|
|
89
|
+
seq,
|
|
90
|
+
role: roleLabelOf(event.type),
|
|
91
|
+
snippet: messageSnippetOf(event, maxChars) || '(无文本)',
|
|
92
|
+
};
|
|
93
|
+
}
|