dsh-compact-relay 0.3.6 → 0.4.1
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 +92 -2
- package/README.en.md +22 -5
- package/README.md +23 -6
- package/ROADMAP.md +10 -3
- package/TUTORIAL.md +2 -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 +609 -32
- 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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 诊断信息动作(dcr 0.4 P0-2)—— HTTP 适配层与(将来的)命令入口共用的唯一组装实现。
|
|
3
|
+
*
|
|
4
|
+
* 为什么放在 actions 层:仓库 A9 架构红线是「adapters 不能直接 import dsh/」,
|
|
5
|
+
* 而诊断产物的构造(白名单裁剪 + 脱敏 + 尺寸上限)在 dsh/diagnostics.js。
|
|
6
|
+
* 这里只做「取数 + 裁剪」:deps.cfg / settings.view() / readState / deps.diag.snapshot(),
|
|
7
|
+
* 然后把白名单输入交给 dsh 层构造,适配层只负责协议转换。
|
|
8
|
+
*
|
|
9
|
+
* 只读、幂等、无副作用:不写文件、不写日志、不改任何插件状态。
|
|
10
|
+
*/
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { buildDiagnostics, renderIssueUrl } from '../dsh/diagnostics.js';
|
|
13
|
+
import { readState } from './state.js';
|
|
14
|
+
|
|
15
|
+
/** 运行时环境事实(只取进程/系统信息,不读会话、不读路径)。 */
|
|
16
|
+
function runtimeOf() {
|
|
17
|
+
let osRelease = null;
|
|
18
|
+
try {
|
|
19
|
+
if (typeof os.release === 'function') osRelease = os.release();
|
|
20
|
+
} catch {
|
|
21
|
+
osRelease = null;
|
|
22
|
+
}
|
|
23
|
+
let locale = null;
|
|
24
|
+
try {
|
|
25
|
+
locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
26
|
+
} catch {
|
|
27
|
+
locale = null;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
// 现有代码没有稳定的 DSH 版本来源:不猜,保持 null(方案 §4)。
|
|
31
|
+
dshVersion: null,
|
|
32
|
+
node: typeof process.version === 'string' ? process.version : null,
|
|
33
|
+
platform: typeof process.platform === 'string' ? process.platform : null,
|
|
34
|
+
arch: typeof process.arch === 'string' ? process.arch : null,
|
|
35
|
+
osRelease,
|
|
36
|
+
locale,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 配置白名单:阈值/保留量/超时/颜色 + 两个「是否被用户改过」的布尔。
|
|
42
|
+
* 交接目录与自定义链接**只出改没改,绝不出值**(方案 §2)。
|
|
43
|
+
*/
|
|
44
|
+
function configOf(deps) {
|
|
45
|
+
const cfg = deps.cfg !== null && typeof deps.cfg === 'object' ? deps.cfg : {};
|
|
46
|
+
let view = null;
|
|
47
|
+
try {
|
|
48
|
+
view = typeof deps.settings?.view === 'function' ? deps.settings.view() : null;
|
|
49
|
+
} catch {
|
|
50
|
+
view = null;
|
|
51
|
+
}
|
|
52
|
+
const current = view !== null && view.config !== null && typeof view.config === 'object' ? view.config : null;
|
|
53
|
+
const defaults = view !== null && view.defaults !== null && typeof view.defaults === 'object' ? view.defaults : null;
|
|
54
|
+
const changed = (read) => (current === null || defaults === null ? false : read(current) !== read(defaults));
|
|
55
|
+
const timeouts = cfg.timeouts !== null && typeof cfg.timeouts === 'object' ? cfg.timeouts : {};
|
|
56
|
+
return {
|
|
57
|
+
version: view !== null && typeof view.version === 'string' ? view.version : null,
|
|
58
|
+
config: {
|
|
59
|
+
thresholds: {
|
|
60
|
+
compactRatio: cfg.thresholds?.compactRatio,
|
|
61
|
+
handoffRatio: cfg.thresholds?.handoffRatio,
|
|
62
|
+
maxSummaries: cfg.thresholds?.maxSummaries,
|
|
63
|
+
},
|
|
64
|
+
retention: { recentTailTokens: cfg.retention?.recentTailTokens },
|
|
65
|
+
// 仓库只有一个模型调用超时(llmMs);契约的分项字段没有独立来源时复用它。
|
|
66
|
+
timeouts: {
|
|
67
|
+
compactMs: timeouts.compactMs ?? timeouts.llmMs,
|
|
68
|
+
handoffMs: timeouts.handoffMs ?? timeouts.llmMs,
|
|
69
|
+
},
|
|
70
|
+
colors: { mode: cfg.colors?.mode, autoAdapt: cfg.colors?.autoAdapt },
|
|
71
|
+
handoffDirCustomized: changed((value) => value.handoffDir),
|
|
72
|
+
linksCustomized: changed((value) => `${value.links?.tutorial ?? ''}\u0000${value.links?.feedback ?? ''}`),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 把 readState 快照裁成契约白名单;无活动会话返回 null(契约里 state 整块 null)。 */
|
|
78
|
+
function stateOf(state) {
|
|
79
|
+
if (state === null || typeof state !== 'object' || typeof state.sessionId !== 'string') return null;
|
|
80
|
+
const range = state.compactRange !== null && typeof state.compactRange === 'object' ? state.compactRange : null;
|
|
81
|
+
const native = state.native !== null && typeof state.native === 'object' ? state.native : null;
|
|
82
|
+
return {
|
|
83
|
+
window: state.window,
|
|
84
|
+
tokens: state.pressure,
|
|
85
|
+
ratio: state.ratio,
|
|
86
|
+
summaries: state.summaries,
|
|
87
|
+
pins: state.pins !== null && typeof state.pins === 'object' ? state.pins.count : state.pins,
|
|
88
|
+
advice: state.advice,
|
|
89
|
+
compactRange: range === null ? null : { nodes: range.nodes, tokens: range.tokens },
|
|
90
|
+
nativeAuto: native === null ? null : { enabled: native.autoEnabled, lineRatio: native.thresholdRatio },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 组装一份诊断信息(HTTP 端点 / 未来命令入口共用)。
|
|
96
|
+
*
|
|
97
|
+
* @param {{ctx:object, cfg:object, settings?:object, diag?:object}} deps
|
|
98
|
+
* @param {{sessionId?:string}} [input]
|
|
99
|
+
* @returns {Promise<{schema:string, version:string|null, generatedAt:string, markdown:string, json:object, issueUrl:string}>}
|
|
100
|
+
*/
|
|
101
|
+
export async function readDiagnostics(deps, { sessionId } = {}) {
|
|
102
|
+
const meta = configOf(deps);
|
|
103
|
+
const runtime = runtimeOf();
|
|
104
|
+
// 无活动会话时 readState 仍返回合法对象(sessionId=null)→ 契约里 state 整块 null。
|
|
105
|
+
let state = null;
|
|
106
|
+
try {
|
|
107
|
+
state = stateOf(await readState(deps, { sessionId }));
|
|
108
|
+
} catch {
|
|
109
|
+
state = null;
|
|
110
|
+
}
|
|
111
|
+
const snapshot = deps.diag?.snapshot?.() ?? {};
|
|
112
|
+
const built = buildDiagnostics({
|
|
113
|
+
version: meta.version,
|
|
114
|
+
runtime,
|
|
115
|
+
config: meta.config,
|
|
116
|
+
state,
|
|
117
|
+
counters: snapshot.counters,
|
|
118
|
+
latency: snapshot.latency,
|
|
119
|
+
errors: snapshot.errors,
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
schema: built.json.schema,
|
|
123
|
+
version: meta.version,
|
|
124
|
+
generatedAt: built.json.generatedAt,
|
|
125
|
+
markdown: built.markdown,
|
|
126
|
+
json: built.json,
|
|
127
|
+
// issue URL 由宿主拼(短字段),客户端只负责 window.open。
|
|
128
|
+
issueUrl: renderIssueUrl({ version: meta.version, runtime }),
|
|
129
|
+
};
|
|
130
|
+
}
|
package/lib/actions/handoff.js
CHANGED
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { actionError } from '../core/errors.js';
|
|
21
|
+
import { pinAnchorOf } from '../core/pin-anchors.js';
|
|
21
22
|
import { handoffFileName, renderHandoffDoc } from '../core/handoff-doc.js';
|
|
23
|
+
import { applyHandoffEdits, editableHandoffView, normalizeHandoffEdits } from '../core/handoff-edit.js';
|
|
24
|
+
import { composeHandoffText } from '../core/handoff-spec.js';
|
|
22
25
|
import { betterCompactLocked } from './better-compact.js';
|
|
23
26
|
import { compressHint, countSummaries, livePinnedSeqs, surfaceSeqs } from '../dsh/session-view.js';
|
|
24
27
|
import { measurePressure } from '../dsh/pressure.js';
|
|
@@ -27,6 +30,7 @@ import { generateHandoffDoc } from '../dsh/handoff-writer.js';
|
|
|
27
30
|
import { buildHandoffProgramSections } from '../core/handoff-facts.js';
|
|
28
31
|
import { checkHandoffReadiness } from '../core/readiness.js';
|
|
29
32
|
import { changedFilesOf } from '../dsh/session-files.js';
|
|
33
|
+
import { errorCodeOf, localIso } from '../dsh/diag-counters.js';
|
|
30
34
|
import { surfaceMessages } from '../dsh/prefix.js';
|
|
31
35
|
import { handoffTitlePlanOf, createHandoffSession, sourceTitleOf } from '../dsh/sessions.js';
|
|
32
36
|
import { writeRelayRecord } from '../dsh/relay.js';
|
|
@@ -39,7 +43,8 @@ import { beginInflight, cancelErrorOf, combinedSignal, finishInflight } from './
|
|
|
39
43
|
*/
|
|
40
44
|
async function compactFirst(deps, { agent, signal, commandId, log }) {
|
|
41
45
|
try {
|
|
42
|
-
|
|
46
|
+
// P1-3:交接内部的压缩不单独记账(避免一次 /handoff 记两条);由交接记录统一反映。
|
|
47
|
+
return { outcome: await betterCompactLocked(deps, { agent, signal, commandId, ledgerRecord: false }), warning: null };
|
|
43
48
|
} catch (error) {
|
|
44
49
|
const code = error !== null && typeof error === 'object' ? error.code : undefined;
|
|
45
50
|
// 只有「事务开始前/未提交」的失败能降级继续(结构、范围、摘要、内容变化)。
|
|
@@ -54,7 +59,7 @@ async function compactFirst(deps, { agent, signal, commandId, log }) {
|
|
|
54
59
|
|
|
55
60
|
/**
|
|
56
61
|
* 收集交接单需要的全部会话事实(A1):压力/现场快照/摘要数/锁定/改过的文件/程序段。
|
|
57
|
-
* @returns {Promise<{session:object, measured:object, now:Date, site:object, summaries:number, pinnedSeqs:number[], changedFiles:string[], programSections:Record<string,string>}>}
|
|
62
|
+
* @returns {Promise<{session:object, measured:object, now:Date, site:object, summaries:number, pinnedSeqs:number[], pinnedAnchors:Array<{seq:number,role:string,snippet:string}>, changedFiles:string[], programSections:Record<string,string>}>}
|
|
58
63
|
*/
|
|
59
64
|
async function collectHandoffFacts(deps, agent) {
|
|
60
65
|
const session = agent.session;
|
|
@@ -65,27 +70,63 @@ async function collectHandoffFacts(deps, agent) {
|
|
|
65
70
|
const site = buildSiteSnapshot({ session, now });
|
|
66
71
|
const summaries = countSummaries(session);
|
|
67
72
|
const pinnedSeqs = livePinnedSeqs(session);
|
|
73
|
+
// 0.4 P1-2:pin 保留契约锚点 = seq + 角色 + 一句摘要(≤60 字符,不贴原文全文)。
|
|
74
|
+
const pinnedAnchors = pinnedSeqs
|
|
75
|
+
.map((seq) => {
|
|
76
|
+
const event = typeof session.eventAt === 'function' ? session.eventAt(seq) : undefined;
|
|
77
|
+
return event === undefined || event === null ? null : pinAnchorOf(event);
|
|
78
|
+
})
|
|
79
|
+
.filter((anchor) => anchor !== null);
|
|
68
80
|
const changedFiles = changedFilesOf(session);
|
|
69
81
|
const programSections = buildHandoffProgramSections({
|
|
70
82
|
compressHint: compressHint(session),
|
|
71
83
|
summaries,
|
|
72
84
|
pressure: measured.tokens,
|
|
73
|
-
pins: { count: pinnedSeqs.length, seqs: pinnedSeqs },
|
|
85
|
+
pins: { count: pinnedSeqs.length, seqs: pinnedSeqs, anchors: pinnedAnchors },
|
|
74
86
|
changedFiles,
|
|
75
87
|
site,
|
|
76
88
|
});
|
|
77
|
-
return { session, measured, now, site, summaries, pinnedSeqs, changedFiles, programSections };
|
|
89
|
+
return { session, measured, now, site, summaries, pinnedSeqs, pinnedAnchors, changedFiles, programSections };
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
/**
|
|
93
|
+
* 交接新会话(/handoff、菜单「交接新会话」、命令卡)—— 对外入口 = 诊断埋点采集点。
|
|
94
|
+
*
|
|
95
|
+
* 0.4 P0-2:只在这里数「交接成功/失败 / 耗时 / 错误码」,失败原因只记 code,
|
|
96
|
+
* 不记 message(message 可能带交接单路径/会话内容)。行为与文案不变。
|
|
97
|
+
*
|
|
98
|
+
* @param {{ctx:object, cfg:object, lock:object, engine:object, log?:object, diag?:object}} deps
|
|
99
|
+
* @param {{agent:object, signal?:AbortSignal, commandId?:string, edits?:Record<string,string>}} input
|
|
100
|
+
* edits 只允许 `{约束?:string, 下一步?:string}`(P2-1;非法字段抛 code='edits')
|
|
101
|
+
*/
|
|
102
|
+
export async function handoff(deps, { agent, signal, commandId, edits } = {}) {
|
|
103
|
+
const startedAt = Date.now();
|
|
104
|
+
try {
|
|
105
|
+
const outcome = await runHandoff(deps, { agent, signal, commandId, edits });
|
|
106
|
+
deps.diag?.bumpAction('handoff', true);
|
|
107
|
+
return outcome;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
deps.diag?.bumpAction('handoff', false);
|
|
110
|
+
deps.diag?.recordError(errorCodeOf(error), 'handoff');
|
|
111
|
+
throw error;
|
|
112
|
+
} finally {
|
|
113
|
+
deps.diag?.recordDuration('handoff', Date.now() - startedAt);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 交接流程本体。
|
|
81
119
|
* @param {{ctx:object, cfg:object, lock:object, engine:object, log?:object}} deps
|
|
82
120
|
* @param {{agent:object, signal?:AbortSignal, commandId?:string}} input
|
|
83
121
|
*/
|
|
84
|
-
|
|
122
|
+
async function runHandoff(deps, { agent, signal, commandId, edits }) {
|
|
85
123
|
if (agent === undefined || agent === null) throw actionError('handoff', '当前没有打开的会话,无法交接');
|
|
86
124
|
const session = agent.session;
|
|
87
125
|
if (session === undefined) throw actionError('handoff', '没有活动会话,无法交接');
|
|
88
126
|
|
|
127
|
+
// 0.4 P2-1:先校验用户编辑(只允许「约束」「下一步」),非法字段在压缩/写盘之前就拒绝。
|
|
128
|
+
const normalizedEdits = normalizeHandoffEdits(edits);
|
|
129
|
+
|
|
89
130
|
return withLock(deps.lock, agent.id, async () => {
|
|
90
131
|
// P1-4:交接登记进 inflight,client 的取消按钮才能真正 abort 模型调用。
|
|
91
132
|
const controller = beginInflight(deps, agent.id, 'handoff');
|
|
@@ -104,6 +145,12 @@ export async function handoff(deps, { agent, signal, commandId }) {
|
|
|
104
145
|
);
|
|
105
146
|
}
|
|
106
147
|
|
|
148
|
+
// 0.4 P1-3:轻账本——先记下「压前占用」(交接内部的压缩会改变之后的压力读数)。
|
|
149
|
+
const startedAt = Date.now();
|
|
150
|
+
const beforeMeasured = await measurePressure(deps.ctx, agent, { windowFallback: deps.cfg.contextWindowFallback }).catch(
|
|
151
|
+
() => null,
|
|
152
|
+
);
|
|
153
|
+
|
|
107
154
|
// 1) 先压缩旧会话一次(best effort)。
|
|
108
155
|
const { outcome: compactOutcome, warning: compactWarning } = await compactFirst(deps, {
|
|
109
156
|
agent,
|
|
@@ -129,9 +176,14 @@ export async function handoff(deps, { agent, signal, commandId }) {
|
|
|
129
176
|
});
|
|
130
177
|
combined?.throwIfAborted?.();
|
|
131
178
|
|
|
179
|
+
// 3.2) 0.4 P2-1:套用「约束 / 下一步」两段的用户编辑(预览→确认链路)。
|
|
180
|
+
// 只覆盖这两段;程序/模型生成的其余七段原样保留(前后端都不接受其它字段)。
|
|
181
|
+
const editedSections = applyHandoffEdits(generated.sections, normalizedEdits);
|
|
182
|
+
const editedText = composeHandoffText(editedSections);
|
|
183
|
+
|
|
132
184
|
// 3.5) 就绪检查:九段缺段/占位阻断;路径核对只警告,不阻断。
|
|
133
185
|
const readiness = checkHandoffReadiness({
|
|
134
|
-
sections:
|
|
186
|
+
sections: editedSections,
|
|
135
187
|
surfaceText: JSON.stringify(surfaceMessages(facts.session)),
|
|
136
188
|
changedFiles: facts.changedFiles,
|
|
137
189
|
messageCount: surfaceSeqs(facts.session).length,
|
|
@@ -141,10 +193,11 @@ export async function handoff(deps, { agent, signal, commandId }) {
|
|
|
141
193
|
}
|
|
142
194
|
for (const warning of readiness.warnings) deps.log?.warn?.(`compact-relay: 交接单就绪警告:${warning}`);
|
|
143
195
|
|
|
144
|
-
// 4) 写盘:文档头带元信息,九段正文 = 模型 7 段 + 程序 2
|
|
196
|
+
// 4) 写盘:文档头带元信息,九段正文 = 模型 7 段 + 程序 2 段 + 用户编辑的两段。
|
|
197
|
+
const titlePlan = handoffTitlePlanOf(deps.ctx, session);
|
|
145
198
|
const file = path.join(deps.cfg.handoffDir, handoffFileName(facts.now));
|
|
146
199
|
const doc = renderHandoffDoc({
|
|
147
|
-
generated:
|
|
200
|
+
generated: editedText,
|
|
148
201
|
sourceSessionId: facts.session.id,
|
|
149
202
|
sourceTitle: sourceTitleOf(deps.ctx, facts.session),
|
|
150
203
|
now: facts.now,
|
|
@@ -154,6 +207,8 @@ export async function handoff(deps, { agent, signal, commandId }) {
|
|
|
154
207
|
// M6:把「宿主刚重启过 / 工作区分支 / 有没有后台任务在跑」写进文档头,
|
|
155
208
|
// 免得接手者按一份出厂即过时的「下一步」干活。
|
|
156
209
|
site: facts.site,
|
|
210
|
+
// 0.4 P2-2:文档头直接写「第 N 次交接」,接手者不用解析标题。
|
|
211
|
+
ordinal: titlePlan.index,
|
|
157
212
|
});
|
|
158
213
|
try {
|
|
159
214
|
fs.writeFileSync(file, doc, 'utf8');
|
|
@@ -167,7 +222,6 @@ export async function handoff(deps, { agent, signal, commandId }) {
|
|
|
167
222
|
|
|
168
223
|
// 5) 新会话:同工作区 / 同 preset / 同模型,注入交接单并起第一轮。
|
|
169
224
|
// A5:标题序号优先读父会话里的 relay 记录(不依赖解析标题文案)。
|
|
170
|
-
const titlePlan = handoffTitlePlanOf(deps.ctx, session);
|
|
171
225
|
let created;
|
|
172
226
|
try {
|
|
173
227
|
created = await createHandoffSession({
|
|
@@ -190,13 +244,18 @@ export async function handoff(deps, { agent, signal, commandId }) {
|
|
|
190
244
|
}
|
|
191
245
|
|
|
192
246
|
// A5:把接力链写成父会话里的结构化记录;写失败不阻断交接(标题解析仍可兜底)。
|
|
247
|
+
// 0.4 P2-2:canonical 字段 parentId/childId/ordinal/at/atIso 补齐;旧字段 parentSessionId/index 保留兼容。
|
|
248
|
+
const relayAt = Date.now();
|
|
193
249
|
const relayWrote = writeRelayRecord(deps.engine, session, {
|
|
194
|
-
|
|
250
|
+
parentId: session.id,
|
|
195
251
|
childId: created.childId,
|
|
252
|
+
ordinal: titlePlan.index,
|
|
253
|
+
at: relayAt,
|
|
254
|
+
atIso: localIso(new Date(relayAt)),
|
|
255
|
+
parentSessionId: session.id,
|
|
196
256
|
index: titlePlan.index,
|
|
197
257
|
path: file,
|
|
198
258
|
planId: compactOutcome?.planId ?? null,
|
|
199
|
-
at: Date.now(),
|
|
200
259
|
title: titlePlan.title,
|
|
201
260
|
baseTitle: titlePlan.baseTitle,
|
|
202
261
|
});
|
|
@@ -219,6 +278,22 @@ export async function handoff(deps, { agent, signal, commandId }) {
|
|
|
219
278
|
? `压缩失败已跳过(${briefWarning});需要的话可先手动 /bc 再交接;`
|
|
220
279
|
: '';
|
|
221
280
|
|
|
281
|
+
// 0.4 P1-3:交接成功记一条轻账本(只记数值/路径;不含消息/摘要/交接单正文)。
|
|
282
|
+
try {
|
|
283
|
+
deps.ledger?.record?.({
|
|
284
|
+
kind: 'handoff',
|
|
285
|
+
durationMs: Date.now() - startedAt,
|
|
286
|
+
beforeTokens: beforeMeasured === null ? null : beforeMeasured.tokens,
|
|
287
|
+
afterTokens: facts.measured.tokens,
|
|
288
|
+
beforeRatio: beforeMeasured === null ? null : beforeMeasured.ratio,
|
|
289
|
+
afterRatio: facts.measured.ratio,
|
|
290
|
+
pinHits: { inRange: compactOutcome?.pinnedKept ?? 0, total: facts.pinnedSeqs.length },
|
|
291
|
+
handoff: { path: file, childId: created.childId, started: created.started === true },
|
|
292
|
+
});
|
|
293
|
+
} catch {
|
|
294
|
+
/* 账本静默降级:不能影响交接结果 */
|
|
295
|
+
}
|
|
296
|
+
|
|
222
297
|
return {
|
|
223
298
|
ok: true,
|
|
224
299
|
childId: created.childId,
|
|
@@ -278,6 +353,10 @@ export async function previewHandoff(deps, { agent, signal }) {
|
|
|
278
353
|
return {
|
|
279
354
|
ok: true,
|
|
280
355
|
draft: generated.text,
|
|
356
|
+
// 0.4 P2-1:预览里只暴露「约束」「下一步」两段给客户端编辑(其余段不可改)。
|
|
357
|
+
editable: editableHandoffView(generated.sections),
|
|
358
|
+
// 0.4 P2-2:本次将是第几次交接(链式序号;首次 1)。
|
|
359
|
+
ordinal: handoffTitlePlanOf(deps.ctx, session).index,
|
|
281
360
|
readiness,
|
|
282
361
|
pressure: facts.measured.tokens,
|
|
283
362
|
summaries: facts.summaries,
|
package/lib/actions/pin.js
CHANGED
|
@@ -10,37 +10,18 @@
|
|
|
10
10
|
* 只写 plugin record:不进模型上下文、不占 token;压缩选段会避开被锁定的消息。
|
|
11
11
|
*/
|
|
12
12
|
import { actionError } from '../core/errors.js';
|
|
13
|
+
import { messageSnippetOf, roleLabelOf } from '../core/pin-anchors.js';
|
|
13
14
|
import { PIN_RECORD_TYPE, latestPinnedSeqs, normalizePinnedSeqs } from '../core/pins.js';
|
|
14
15
|
import { livePinnedSeqs, sessionLog, surfaceSeqs } from '../dsh/session-view.js';
|
|
15
16
|
|
|
16
|
-
const TYPE_LABEL = {
|
|
17
|
-
'system/message': '系统',
|
|
18
|
-
'user/message': '用户',
|
|
19
|
-
'assistant/message': '助手',
|
|
20
|
-
'tool/result': '工具',
|
|
21
|
-
};
|
|
22
|
-
|
|
23
17
|
/**
|
|
24
18
|
* 从事件里抠一段可读预览(消息 content 可能是数组或字符串)。
|
|
19
|
+
* 0.4 P1-1/P1-2:摘要/角色判定抽到 core/pin-anchors.js,与「建议锁定」「交接单锚点」共用一份。
|
|
25
20
|
* @param {object|undefined} event
|
|
26
21
|
* @returns {string}
|
|
27
22
|
*/
|
|
28
23
|
function previewOf(event) {
|
|
29
|
-
|
|
30
|
-
// 只有 system/assistant/tool 包在 data.message 里。2026-10-19 debug 实测:
|
|
31
|
-
// 只读 data.message 会让 /pin list 里所有用户消息显示「(无文本)」。
|
|
32
|
-
const content = event?.data?.content ?? event?.data?.message?.content;
|
|
33
|
-
let text = '';
|
|
34
|
-
if (typeof content === 'string') text = content;
|
|
35
|
-
else if (Array.isArray(content)) {
|
|
36
|
-
text = content
|
|
37
|
-
.filter((block) => block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
|
|
38
|
-
.map((block) => block.text)
|
|
39
|
-
.join(' ');
|
|
40
|
-
}
|
|
41
|
-
text = text.replace(/\s+/gu, ' ').trim();
|
|
42
|
-
if (text.length === 0) return '(无文本)';
|
|
43
|
-
return text.length > 60 ? `${text.slice(0, 59)}…` : text;
|
|
24
|
+
return messageSnippetOf(event) || '(无文本)';
|
|
44
25
|
}
|
|
45
26
|
|
|
46
27
|
/**
|
|
@@ -59,7 +40,7 @@ export function pinCandidates(session, limit = 10) {
|
|
|
59
40
|
index: picked.length + 1,
|
|
60
41
|
seq,
|
|
61
42
|
type: event.type,
|
|
62
|
-
role:
|
|
43
|
+
role: roleLabelOf(event.type),
|
|
63
44
|
preview: previewOf(event),
|
|
64
45
|
});
|
|
65
46
|
}
|
|
@@ -73,6 +54,8 @@ function pinnedOf(session) {
|
|
|
73
54
|
function writePins(deps, session, seqs) {
|
|
74
55
|
const normalized = normalizePinnedSeqs(seqs);
|
|
75
56
|
deps.engine.appendPluginRecord(session, PIN_RECORD_TYPE, { seqs: normalized, at: Date.now() });
|
|
57
|
+
// 0.4 P0-2:诊断计数只记「成功锁定」;/pin 与菜单 toggle 共用这里,toggle 锁一次记一次。
|
|
58
|
+
if (normalized.length > 0) deps.diag?.bumpPin();
|
|
76
59
|
return normalized;
|
|
77
60
|
}
|
|
78
61
|
|
package/lib/actions/state.js
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
import { paletteForBothSchemes } from '../core/colors.js';
|
|
8
8
|
import { adviceFor, ratioOf, thresholdsForWindow } from '../core/policy.js';
|
|
9
9
|
import { tokenOf } from '../core/tokens.js';
|
|
10
|
-
import { measurePressure } from '../dsh/pressure.js';
|
|
10
|
+
import { measurePressure, routeBudgetOf } from '../dsh/pressure.js';
|
|
11
11
|
import { selectCompactableRange } from '../dsh/select-range.js';
|
|
12
12
|
import { compressHint as compressHintOf, countSummaries, lastCheckpointSurfaceSeq, livePinnedSeqs, liveSessionOf, pickAgent, surfaceSeqs } from '../dsh/session-view.js';
|
|
13
13
|
import { compressHintIsActive } from '../core/compress-hint.js';
|
|
14
|
-
import { readNativeCompaction } from '../dsh/native-compaction.js';
|
|
14
|
+
import { readNativeCompaction, resolveNativePolicy } from '../dsh/native-compaction.js';
|
|
15
|
+
import { nativeLineOf } from '../core/native-line.js';
|
|
15
16
|
import { relayRecordOf } from '../dsh/relay.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -109,11 +110,39 @@ export async function readState(deps, { sessionId, agent } = {}) {
|
|
|
109
110
|
.sort((a, b) => a.ratio - b.ratio);
|
|
110
111
|
|
|
111
112
|
// 0.3.1:模型的「这里适合压缩」判断(比最后一次压缩/交接 checkpoint 新才算数)。
|
|
112
|
-
|
|
113
|
+
// 0.4 BUG-013:第二个参数(isCheckpointSource)以前漏传,session-view 里的
|
|
114
|
+
// TypeError 被 try/catch 吞掉 → 永远找不到 checkpoint → 压缩/交接后旧 hint 不失效
|
|
115
|
+
// (环上残留旧断点,还会用旧 ratio 抑制 cue)。这里显式传 engine 的判定函数。
|
|
116
|
+
const checkpointSeq =
|
|
117
|
+
target === null ? undefined : lastCheckpointSurfaceSeq(target.session, deps.engine?.isCompactCheckpointSource);
|
|
113
118
|
const hint = target === null ? null : compressHintOf(target.session);
|
|
114
119
|
const activeHint = compressHintIsActive(hint, checkpointSeq) ? { ratio: hint.ratio, at: hint.at, seq: hint.seq } : null;
|
|
115
120
|
// 0.3.1:DSH 原生自动压缩配置(读 profile 的 cordis.patch.yml + DSH 默认值)。
|
|
116
|
-
|
|
121
|
+
// 0.4 P2-3:deps.nativeConfig 仅给单测注入用;生产不传,仍读本机 profile。
|
|
122
|
+
const nativeCfg = deps.nativeConfig ?? readNativeCompaction();
|
|
123
|
+
|
|
124
|
+
// 0.4 P2-3:算「真实触发线」而不是窗口比例:
|
|
125
|
+
// thresholdTokens = floor(min(W × rate, W − 输出预留 − headroom)),触发点 = thresholdTokens / W。
|
|
126
|
+
// 输出预留取 requestHeader.config.maxTokens(其次适配器 defaultMaxTokens),headroom 与
|
|
127
|
+
// 路由级 thresholdRatio 来自 compaction-basic 配置(modelPolicies 按 provider+model 覆盖)。
|
|
128
|
+
let nativePolicy = resolveNativePolicy(nativeCfg, null);
|
|
129
|
+
let nativeLine = null;
|
|
130
|
+
let nativeBudget = null;
|
|
131
|
+
if (target !== null && target.session !== undefined) {
|
|
132
|
+
try {
|
|
133
|
+
nativeBudget = await routeBudgetOf(deps.ctx, target.session, picked ?? undefined, measured.window);
|
|
134
|
+
nativePolicy = resolveNativePolicy(nativeCfg, nativeBudget.route);
|
|
135
|
+
nativeLine = nativeLineOf({
|
|
136
|
+
contextWindow: nativeBudget.contextWindow,
|
|
137
|
+
reservedCompletionTokens: nativeBudget.reservedCompletionTokens,
|
|
138
|
+
thresholdRatio: nativePolicy.thresholdRatio,
|
|
139
|
+
headroomTokens: nativePolicy.headroomTokens,
|
|
140
|
+
});
|
|
141
|
+
} catch {
|
|
142
|
+
nativeLine = null;
|
|
143
|
+
nativeBudget = null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
117
146
|
|
|
118
147
|
return {
|
|
119
148
|
revision: ++stateRevision,
|
|
@@ -140,6 +169,19 @@ export async function readState(deps, { sessionId, agent } = {}) {
|
|
|
140
169
|
// 0.3.1:模型判断的「适合压缩」断点位置(没有或已失效为 null)。
|
|
141
170
|
compressHint: activeHint,
|
|
142
171
|
// 0.3.1:DSH 原生自动压缩(环上的报警最高阈值 / 设置页开关的数据源)。
|
|
143
|
-
|
|
172
|
+
// 0.4 P2-3:额外下发真实触发线(ratio 为 thresholdTokens / window;算不出时 null,
|
|
173
|
+
// 客户端回落 thresholdRatio 的窗口比例,老客户端不受影响)。
|
|
174
|
+
native: {
|
|
175
|
+
autoEnabled: nativeCfg.autoEnabled,
|
|
176
|
+
thresholdRatio: nativePolicy.thresholdRatio,
|
|
177
|
+
ratio: nativeLine !== null && nativeLine.ok === true ? nativeLine.ratio : null,
|
|
178
|
+
thresholdTokens: nativeLine !== null && nativeLine.ok === true ? nativeLine.thresholdTokens : null,
|
|
179
|
+
contextWindow: nativeBudget?.contextWindow ?? null,
|
|
180
|
+
reservedCompletionTokens: nativeBudget?.reservedCompletionTokens ?? null,
|
|
181
|
+
headroomTokens: nativePolicy.headroomTokens,
|
|
182
|
+
overridden: nativePolicy.overridden === true,
|
|
183
|
+
route: nativeBudget?.route ?? null,
|
|
184
|
+
unavailable: nativeLine !== null && nativeLine.ok !== true ? nativeLine.reason : null,
|
|
185
|
+
},
|
|
144
186
|
};
|
|
145
187
|
}
|
package/lib/adapters/http.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { betterCompact, cancelBetterCompact, previewBetterCompact } from '../actions/better-compact.js';
|
|
7
7
|
import { readCompactDiffs } from '../actions/compact-diff.js';
|
|
8
|
+
import { readDiagnostics } from '../actions/diagnostics.js';
|
|
8
9
|
import { handoff, previewHandoff } from '../actions/handoff.js';
|
|
9
10
|
import { listHandoffDocs, readHandoffDoc } from '../actions/handoff-docs.js';
|
|
10
11
|
import { togglePin } from '../actions/pin.js';
|
|
@@ -129,6 +130,21 @@ function fallbackRejection(webCtx, req) {
|
|
|
129
130
|
return undefined;
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
/**
|
|
134
|
+
* 设置端点响应 = 设置视图 + 轻账本累计(0.4 P1-3;客户端「累计」一行数字的数据源)。
|
|
135
|
+
* 读账本失败/没有 ledger 时 `ledger: null`(客户端显示引导文案,不影响设置页)。
|
|
136
|
+
*/
|
|
137
|
+
export function settingsPayloadOf(deps) {
|
|
138
|
+
const view = deps.settings.view();
|
|
139
|
+
let ledger = null;
|
|
140
|
+
try {
|
|
141
|
+
ledger = typeof deps.ledger?.summary === 'function' ? deps.ledger.summary() : null;
|
|
142
|
+
} catch {
|
|
143
|
+
ledger = null;
|
|
144
|
+
}
|
|
145
|
+
return { ...view, ledger };
|
|
146
|
+
}
|
|
147
|
+
|
|
132
148
|
function denyAuth(res, rejection) {
|
|
133
149
|
const status = rejection === 401 ? 401 : 403;
|
|
134
150
|
return send(res, status, {
|
|
@@ -259,7 +275,7 @@ export function registerHttp(webCtx, deps) {
|
|
|
259
275
|
const rejection = hostRejection(webCtx, req);
|
|
260
276
|
if (rejection !== undefined) return denyAuth(res, rejection);
|
|
261
277
|
if (req.method === 'GET') {
|
|
262
|
-
return send(res, 200, { ok: true, ...deps
|
|
278
|
+
return send(res, 200, { ok: true, ...settingsPayloadOf(deps) });
|
|
263
279
|
}
|
|
264
280
|
if (req.method !== 'POST') return send(res, 405, { ok: false, message: '只支持 GET/POST' });
|
|
265
281
|
if (!sameOrigin(req)) return send(res, 403, { ok: false, message: '拒绝跨站请求' });
|
|
@@ -274,13 +290,13 @@ export function registerHttp(webCtx, deps) {
|
|
|
274
290
|
return send(res, 400, { ok: false, message: '请求体必须是 JSON 对象' });
|
|
275
291
|
}
|
|
276
292
|
if (parsed.reset === true) {
|
|
277
|
-
|
|
293
|
+
deps.settings.reset();
|
|
278
294
|
deps.hub?.refreshAll?.();
|
|
279
|
-
return send(res, 200, { ok: true, ...
|
|
295
|
+
return send(res, 200, { ok: true, ...settingsPayloadOf(deps) });
|
|
280
296
|
}
|
|
281
|
-
|
|
297
|
+
deps.settings.save(parsed);
|
|
282
298
|
deps.hub?.refreshAll?.();
|
|
283
|
-
return send(res, 200, { ok: true, ...
|
|
299
|
+
return send(res, 200, { ok: true, ...settingsPayloadOf(deps) });
|
|
284
300
|
} catch (error) {
|
|
285
301
|
try {
|
|
286
302
|
const status = error?.statusCode === 413 ? 413 : 400;
|
|
@@ -302,6 +318,47 @@ export function registerHttp(webCtx, deps) {
|
|
|
302
318
|
});
|
|
303
319
|
webCtx.effect(() => settingsStop, 'compact-relay: settings endpoint');
|
|
304
320
|
|
|
321
|
+
// 0.4 P0-2:诊断信息(只读、幂等、无副作用)——本机生成、白名单、已脱敏。
|
|
322
|
+
// 只提供 GET;没有 POST/上传通道(方案 §5.4、§10)。
|
|
323
|
+
const diagnosticsStop = webCtx.webServer.register({
|
|
324
|
+
kind: 'exact',
|
|
325
|
+
path: '/api/compact-relay-diagnostics',
|
|
326
|
+
handler: (req, res) => {
|
|
327
|
+
void (async () => {
|
|
328
|
+
try {
|
|
329
|
+
const rejection = hostRejection(webCtx, req);
|
|
330
|
+
if (rejection !== undefined) return denyAuth(res, rejection);
|
|
331
|
+
if (req.method !== 'GET') return send(res, 405, { ok: false, message: '只支持 GET' });
|
|
332
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
333
|
+
const sessionId = url.searchParams.get('sessionId') ?? undefined;
|
|
334
|
+
// 组装在 actions 层(adapters 不直接碰 dsh/,见 A9 架构守卫)。
|
|
335
|
+
const payload = await readDiagnostics(deps, { sessionId });
|
|
336
|
+
return send(res, 200, { ok: true, ...payload });
|
|
337
|
+
} catch (error) {
|
|
338
|
+
// 只回短文案,不把内部正文/堆栈回给浏览器(方案 §5.4)。
|
|
339
|
+
const code = error !== null && typeof error === 'object' ? error.code : undefined;
|
|
340
|
+
try {
|
|
341
|
+
deps.log?.warn?.(`compact-relay: 生成诊断信息失败${typeof code === 'string' ? `(${code})` : ''}`);
|
|
342
|
+
} catch {
|
|
343
|
+
/* 日志失败不影响响应 */
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
send(res, 500, {
|
|
347
|
+
ok: false,
|
|
348
|
+
message:
|
|
349
|
+
code === 'toolarge'
|
|
350
|
+
? '诊断信息超过 64KB 上限,请先减少错误记录后再试'
|
|
351
|
+
: '生成诊断信息失败(详情见插件日志)',
|
|
352
|
+
});
|
|
353
|
+
} catch {
|
|
354
|
+
/* 连接可能已断 */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
})();
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
webCtx.effect(() => diagnosticsStop, 'compact-relay: diagnostics endpoint');
|
|
361
|
+
|
|
305
362
|
const actionStop = webCtx.webServer.register({
|
|
306
363
|
kind: 'exact',
|
|
307
364
|
path: '/api/compact-relay-action',
|
|
@@ -312,7 +369,8 @@ export function registerHttp(webCtx, deps) {
|
|
|
312
369
|
if (rejection !== undefined) return denyAuth(res, rejection);
|
|
313
370
|
if (req.method !== 'POST') return send(res, 405, { ok: false, message: '只支持 POST' });
|
|
314
371
|
if (!sameOrigin(req)) return send(res, 403, { ok: false, message: '拒绝跨站请求' });
|
|
315
|
-
|
|
372
|
+
// 0.4 P2-1:handoff 可以带「约束/下一步」两段编辑(各 ≤4000 字符),body 上限放宽到 48KB。
|
|
373
|
+
const body = await readBody(req, 48 * 1024);
|
|
316
374
|
let parsed;
|
|
317
375
|
try {
|
|
318
376
|
parsed = JSON.parse(body === '' ? '{}' : body);
|
|
@@ -388,7 +446,8 @@ export function registerHttp(webCtx, deps) {
|
|
|
388
446
|
return send(res, 200, outcome);
|
|
389
447
|
}
|
|
390
448
|
if (parsed.action === 'handoff') {
|
|
391
|
-
|
|
449
|
+
// 0.4 P2-1:`edits` 只允许「约束」「下一步」两段(多余的段/非法类型由 actions 拒绝)。
|
|
450
|
+
const outcome = await handoff(deps, { agent, edits: parsed.edits });
|
|
392
451
|
return send(res, 200, {
|
|
393
452
|
ok: true,
|
|
394
453
|
message: outcome.message,
|
|
@@ -412,6 +471,10 @@ export function registerHttp(webCtx, deps) {
|
|
|
412
471
|
if (code === 'timeout' || code === 'budget') {
|
|
413
472
|
return send(res, 500, { ok: false, code, message: errorText(error) });
|
|
414
473
|
}
|
|
474
|
+
// 0.4 P2-1:交接编辑字段非法(改了程序生成段/超长/类型错)→ 400,明确告诉用户哪些段不能改。
|
|
475
|
+
if (code === 'edits') {
|
|
476
|
+
return send(res, 400, { ok: false, code, message: errorText(error) });
|
|
477
|
+
}
|
|
415
478
|
// 忙(正在压缩/交接)回 409,和「没有活动会话」同属可重试状态。
|
|
416
479
|
if (code === 'busy') {
|
|
417
480
|
return send(res, 409, { ok: false, code, message: errorText(error) });
|