dsh-compact-relay 0.3.5 → 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 +86 -3
- package/README.en.md +18 -1
- package/README.md +20 -3
- package/ROADMAP.md +9 -1
- 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 +294 -57
- 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/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) });
|
package/lib/adapters/prompt.js
CHANGED
|
@@ -1,27 +1,74 @@
|
|
|
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` 工具在 427 个真实会话里 0 次调用。根因不是模型
|
|
5
5
|
* 不听话,而是工具描述承诺「系统会告诉你容量」但系统从没说过 —— 条件不可判断。
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* 0.3.4 起宿主每轮把一条 runtime context 注入 system prompt,告诉模型当前容量。
|
|
7
|
+
*
|
|
8
|
+
* 0.4 P0-1(Win 173 会话里 cue 注入 18 次、工具仍 0 调用):
|
|
9
|
+
* 1. 文案 v2 与区间 v2 在 core/compress-cue.js(4 类完成信号 + 被拒零副作用);
|
|
10
|
+
* 2. **注入门控**在本文件(方案 §3.2):只有全部满足才注入——
|
|
11
|
+
* a) 容量落在 cue 区间(10%–40%,且不越过压缩线,见 cueBandOf);
|
|
12
|
+
* b) 距上次注入 ≥ CUE_MIN_TURNS 轮(本轮按 text 回调调用次数计,纯内存不落盘);
|
|
13
|
+
* c) 一轮内最多一次(连续调用时 b 天然保证);
|
|
14
|
+
* 3. 实际注入一次就 `deps.diag.bumpCueInjected()`(供 P0-2 诊断包看数),
|
|
15
|
+
* 只记次数、不记提示原文(隐私口径与 P0-2 一致)。
|
|
8
16
|
*
|
|
9
17
|
* 约束:
|
|
10
18
|
* - `systemPrompt.context` 的 text 回调必须是**同步**函数 → 只读
|
|
11
19
|
* `dsh/state-hub.js` 维护的内存缓存,不 await、不 measure;
|
|
12
20
|
* - 拿不到会话/缓存/阈值就返回空串(宿主不会展示空 context);
|
|
13
|
-
* - 全程 try/catch:提示是锦上添花,任何异常都不能影响宿主组装 prompt
|
|
21
|
+
* - 全程 try/catch:提示是锦上添花,任何异常都不能影响宿主组装 prompt;
|
|
22
|
+
* - 本文件(adapters)不 import lib/dsh/:计数走 deps.diag 既有注入。
|
|
14
23
|
*/
|
|
15
24
|
import {
|
|
16
25
|
COMPRESS_CUE_NAME,
|
|
17
26
|
COMPRESS_CUE_ORDER,
|
|
18
27
|
compressCueText,
|
|
28
|
+
cueBandOf,
|
|
19
29
|
} from '../core/compress-cue.js';
|
|
20
|
-
import { compressionMinRatio } from '../core/policy.js';
|
|
30
|
+
import { CUE_MIN_TURNS, compressionMinRatio } from '../core/policy.js';
|
|
21
31
|
|
|
22
32
|
/** 供测试/文档引用的 context 名称。 */
|
|
23
33
|
export { COMPRESS_CUE_NAME };
|
|
24
34
|
|
|
35
|
+
/**
|
|
36
|
+
* 每会话的注入节奏(进程内,不新增持久状态):
|
|
37
|
+
* calls text 回调被调用次数(≈轮数)
|
|
38
|
+
* lastInjectedCall 上次真正注入发生在第几次调用(null = 还没注入过)
|
|
39
|
+
*
|
|
40
|
+
* 进程内 Map 只服务宿主生命周期;上限 200 个会话,避免长期运行无限增长。
|
|
41
|
+
*/
|
|
42
|
+
const MAX_TRACKED_SESSIONS = 200;
|
|
43
|
+
const cueTurns = new Map();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 取(并前移)某会话的注入节奏计数。
|
|
47
|
+
* @param {string} sessionId
|
|
48
|
+
* @returns {{calls:number, lastInjectedCall:number|null}}
|
|
49
|
+
*/
|
|
50
|
+
function cueTurnOf(sessionId) {
|
|
51
|
+
let turn = cueTurns.get(sessionId);
|
|
52
|
+
if (turn === undefined) {
|
|
53
|
+
turn = { calls: 0, lastInjectedCall: null };
|
|
54
|
+
cueTurns.set(sessionId, turn);
|
|
55
|
+
if (cueTurns.size > MAX_TRACKED_SESSIONS) {
|
|
56
|
+
const oldest = cueTurns.keys().next().value;
|
|
57
|
+
cueTurns.delete(oldest);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
turn.calls += 1;
|
|
61
|
+
return turn;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 测试用:清空注入节奏(相当于宿主重启)。生产路径不需要调用。
|
|
66
|
+
* @returns {void}
|
|
67
|
+
*/
|
|
68
|
+
export function resetCueTurns() {
|
|
69
|
+
cueTurns.clear();
|
|
70
|
+
}
|
|
71
|
+
|
|
25
72
|
/**
|
|
26
73
|
* 计算某个 agent 本轮该显示的动态提示(同步、异常安全)。
|
|
27
74
|
* @param {{context?:object, deps?:object, hub?:object}} input
|
|
@@ -42,14 +89,28 @@ export function compressCueFor({ context, deps, hub } = {}) {
|
|
|
42
89
|
if (!Number.isFinite(thresholds.compactRatio)) return '';
|
|
43
90
|
if (!Number.isFinite(cached.ratio) || !Number.isFinite(cached.window) || cached.window <= 0) return '';
|
|
44
91
|
|
|
45
|
-
//
|
|
46
|
-
const
|
|
47
|
-
|
|
92
|
+
// 每次 text 回调(≈一轮)都计数:即使本轮不注入,也要推进轮次,限频才有意义。
|
|
93
|
+
const turn = cueTurnOf(sessionId);
|
|
94
|
+
|
|
95
|
+
// 门控 a:区间 = max(10%, 动态最小线) ≤ ratio < min(40%, 压缩线)。
|
|
96
|
+
const dynamicMinRatio = compressionMinRatio(thresholds, deps?.cfg?.retention, cached.window);
|
|
97
|
+
const band = cueBandOf({ compactRatio: thresholds.compactRatio, dynamicMinRatio });
|
|
98
|
+
if (!(cached.ratio >= band.minRatio && cached.ratio < band.maxRatio)) return '';
|
|
99
|
+
|
|
100
|
+
// 门控 b/c:距上次注入 ≥ CUE_MIN_TURNS 轮(同一轮内多次调用必然不足)。
|
|
101
|
+
if (turn.lastInjectedCall !== null && turn.calls - turn.lastInjectedCall < CUE_MIN_TURNS) return '';
|
|
102
|
+
|
|
103
|
+
const text = compressCueText({
|
|
48
104
|
ratio: cached.ratio,
|
|
49
|
-
minRatio,
|
|
50
|
-
|
|
105
|
+
minRatio: band.minRatio,
|
|
106
|
+
maxRatio: band.maxRatio,
|
|
51
107
|
compressHint: cached.compressHint ?? null,
|
|
52
108
|
});
|
|
109
|
+
if (text === '') return '';
|
|
110
|
+
|
|
111
|
+
turn.lastInjectedCall = turn.calls;
|
|
112
|
+
deps?.diag?.bumpCueInjected?.();
|
|
113
|
+
return text;
|
|
53
114
|
} catch {
|
|
54
115
|
return '';
|
|
55
116
|
}
|
package/lib/adapters/tool.js
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* 模型无法判断条件 → 427 个真实会话 0 次调用。0.3.4 起由宿主每轮注入可压缩区间
|
|
9
9
|
* 提示(lib/adapters/prompt.js),工具描述只描述「它自己会做什么」:
|
|
10
10
|
* 服务端工具会返回当前容量并复核区间,太低/到线都拒绝且不改历史。
|
|
11
|
+
*
|
|
12
|
+
* 0.4 P0-1(方案 §3.3):Win 173 会话 cue 注入 18 次、工具 0 调用 → 描述 v2:
|
|
13
|
+
* - 明确「用户和模型都可以调用」(不只是模型的自发动作);
|
|
14
|
+
* - 「被拒不需要致歉」:拒绝零副作用、不改历史、不需要重试或道歉(降低调用心理成本);
|
|
15
|
+
* - 给出典型调用时机;把「不需要每个小步骤都调用」弱化后挪到句尾,不再抢在条件前面。
|
|
11
16
|
*/
|
|
12
17
|
import { markCompressHint } from '../actions/compress-hint.js';
|
|
13
18
|
import engine from '../dsh/engine.js';
|
|
@@ -22,10 +27,10 @@ export function registerTool(ctx, deps) {
|
|
|
22
27
|
name: 'mark_compress_point',
|
|
23
28
|
description:
|
|
24
29
|
'记录一个「压缩节点」:在指示灯环上标出「这里适合把前面的旧历史压成摘要」。' +
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
30
|
+
'用户和模型都可以调用;不确定时调用是安全的:工具会自己判断容量,太早或已到压缩线会拒绝,' +
|
|
31
|
+
'被拒不需要致歉、不会改动历史、也不影响会话,不需要重试。' +
|
|
32
|
+
'典型调用时机:阶段完成(功能已实现并验证 / 文档已交付 / 问题已定位并修复)、准备交接前、长任务中途确认可回退点。' +
|
|
33
|
+
'调用后工具会返回当前容量与判断结果;每次判断只记一条,压缩或交接后旧记录自动失效。小步骤不用调用。',
|
|
29
34
|
parameters: {},
|
|
30
35
|
output: {
|
|
31
36
|
schema: { type: 'string' },
|