dsh-plugin-bridge 0.2.10
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/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +146 -0
- package/cordis.patch.yml +18 -0
- package/docs/articles/agent-session-handoff.md +162 -0
- package/docs/benchmark.md +165 -0
- package/docs/design.md +82 -0
- package/docs/guide.zh.md +152 -0
- package/docs/native-webui-feasibility.md +44 -0
- package/docs/plan.md +65 -0
- package/lib/api-rpc.d.ts +64 -0
- package/lib/api-rpc.js +76 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.js +368 -0
- package/lib/command.d.ts +92 -0
- package/lib/command.js +377 -0
- package/lib/compression.d.ts +90 -0
- package/lib/compression.js +369 -0
- package/lib/fold.d.ts +39 -0
- package/lib/fold.js +406 -0
- package/lib/index.d.ts +55 -0
- package/lib/index.js +76 -0
- package/lib/migrate.d.ts +176 -0
- package/lib/migrate.js +449 -0
- package/lib/rpc.d.ts +34 -0
- package/lib/rpc.js +76 -0
- package/lib/types.d.ts +64 -0
- package/lib/types.js +9 -0
- package/package.json +106 -0
- package/reports/v0.2.3-e2e-2026-08-20T13-19-13-924Z.raw.json +2727 -0
- package/reports/v0.2.3-e2e-report.md +116 -0
- package/reports/v0.2.6-rc11-vision-report.md +65 -0
package/lib/command.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/bridge` —— 人发起的跨 preset 迁移命令。
|
|
3
|
+
*
|
|
4
|
+
* 这是本插件的主入口,形状对齐上游 `dsh-plan-mode` 的 `/plan`:命令由 UI 直接
|
|
5
|
+
* 派发给注册表,**不经过模型**,命令结果也不进模型历史。于是
|
|
6
|
+
*
|
|
7
|
+
* - 不需要模型「愿意」加载什么东西,也不需要 bash 或环境变量;
|
|
8
|
+
* - `minimal` 这种没有 skill 工具的 preset 照样能发起迁移;
|
|
9
|
+
* - 结果文本只给人看,原会话的上下文一个字都不动。
|
|
10
|
+
*
|
|
11
|
+
* 上游 `session.prompt` 的契约保证了这一点:「A prompt whose content is exactly
|
|
12
|
+
* one text block starting with '/' is a slash command: the host executes it
|
|
13
|
+
* through the command registry (mode-agnostic) and it is never sent to the
|
|
14
|
+
* model.」——所以在官方 WebUI 的输入框里打 `/bridge code` 就能用。
|
|
15
|
+
*/
|
|
16
|
+
import { executeMigration, findSession, listPresets, migratedTitle, previewMigration, titleOf, } from './migrate.js';
|
|
17
|
+
import { RpcError } from './rpc.js';
|
|
18
|
+
/** 暂存有效期:超过就要求重新预览,免得拿一份很旧的摘要迁过去。 */
|
|
19
|
+
const PENDING_TTL_MS = 30 * 60_000;
|
|
20
|
+
/** `<preset> [--go] [--continue] [--tier x] [--lang l] [--inject m] [--goal-rounds n] [--file p]` */
|
|
21
|
+
export function parseBridgeInput(rawInput) {
|
|
22
|
+
const tokens = rawInput.trim().split(/\s+/).filter(Boolean);
|
|
23
|
+
const out = { go: false, help: false, doctor: false, autoContinue: false };
|
|
24
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
25
|
+
const token = tokens[i];
|
|
26
|
+
if (!token.startsWith('--')) {
|
|
27
|
+
if (out.preset === undefined)
|
|
28
|
+
out.preset = token;
|
|
29
|
+
else
|
|
30
|
+
return { ...out, error: `多余的参数 "${token}"` };
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const eq = token.indexOf('=');
|
|
34
|
+
const key = eq > 0 ? token.slice(2, eq) : token.slice(2);
|
|
35
|
+
const inlineValue = eq > 0 ? token.slice(eq + 1) : undefined;
|
|
36
|
+
const take = () => {
|
|
37
|
+
if (inlineValue !== undefined)
|
|
38
|
+
return inlineValue;
|
|
39
|
+
const next = tokens[i + 1];
|
|
40
|
+
if (next === undefined || next.startsWith('--'))
|
|
41
|
+
return undefined;
|
|
42
|
+
i += 1;
|
|
43
|
+
return next;
|
|
44
|
+
};
|
|
45
|
+
switch (key) {
|
|
46
|
+
case 'go':
|
|
47
|
+
out.go = true;
|
|
48
|
+
break;
|
|
49
|
+
case 'help':
|
|
50
|
+
out.help = true;
|
|
51
|
+
break;
|
|
52
|
+
case 'doctor':
|
|
53
|
+
out.doctor = true;
|
|
54
|
+
break;
|
|
55
|
+
case 'continue':
|
|
56
|
+
out.autoContinue = true;
|
|
57
|
+
break;
|
|
58
|
+
case 'tier': {
|
|
59
|
+
const value = take();
|
|
60
|
+
if (value !== 'flash' && value !== 'current' && value !== 'pro') {
|
|
61
|
+
return { ...out, error: `--tier 只能是 flash / current / pro` };
|
|
62
|
+
}
|
|
63
|
+
out.tier = value;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case 'lang': {
|
|
67
|
+
const value = take();
|
|
68
|
+
if (value !== 'zh' && value !== 'en' && value !== 'auto')
|
|
69
|
+
return { ...out, error: '--lang 只能是 zh / en / auto' };
|
|
70
|
+
out.lang = value;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case 'inject': {
|
|
74
|
+
const value = take();
|
|
75
|
+
if (value !== 'goal' && value !== 'prompt' && value !== 'both')
|
|
76
|
+
return { ...out, error: '--inject 只能是 goal / prompt / both' };
|
|
77
|
+
out.inject = value;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case 'goal-rounds': {
|
|
81
|
+
const value = Number(take());
|
|
82
|
+
if (!Number.isFinite(value) || value < 1)
|
|
83
|
+
return { ...out, error: '--goal-rounds 需要一个 ≥1 的数字' };
|
|
84
|
+
out.goalRounds = value;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case 'file': {
|
|
88
|
+
const value = take();
|
|
89
|
+
if (!value)
|
|
90
|
+
return { ...out, error: '--file 需要一个路径' };
|
|
91
|
+
out.file = value;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
default:
|
|
95
|
+
return { ...out, error: `不认识的参数 --${key}` };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function displayLang(lang) {
|
|
101
|
+
return lang === 'en' ? 'en' : 'zh';
|
|
102
|
+
}
|
|
103
|
+
function usage(presets, current, lang) {
|
|
104
|
+
const targets = presets.filter((p) => p.id !== current).map((p) => p.id);
|
|
105
|
+
if (lang === 'en') {
|
|
106
|
+
return [
|
|
107
|
+
'Usage:',
|
|
108
|
+
' /bridge <preset> Preview a handoff without changing either session',
|
|
109
|
+
' /bridge <preset> --go Migrate after review; the new session restates and waits',
|
|
110
|
+
' /bridge <preset> --go --continue Restate and continue in the same model request',
|
|
111
|
+
'',
|
|
112
|
+
`Available: ${targets.length ? targets.join(' · ') : '(no other presets in this deployment)'}`,
|
|
113
|
+
...(current ? [`Current: ${current}`] : []),
|
|
114
|
+
'',
|
|
115
|
+
'Options: --continue · --tier flash|current|pro · --lang zh|en|auto · --goal-rounds N · --file <edited-summary>',
|
|
116
|
+
'Check: /bridge --doctor',
|
|
117
|
+
].join('\n');
|
|
118
|
+
}
|
|
119
|
+
return [
|
|
120
|
+
'用法:',
|
|
121
|
+
' /bridge <模式> 生成交接摘要给你过目(不改动任何会话)',
|
|
122
|
+
' /bridge <模式> --go 确认后迁移;新会话复述理解后暂停',
|
|
123
|
+
' /bridge <模式> --go --continue 同一轮复述并继续下一步',
|
|
124
|
+
'',
|
|
125
|
+
`可迁入:${targets.length ? targets.join(' · ') : '(这套部署没有其他 preset)'}`,
|
|
126
|
+
...(current ? [`当前:${current}`] : []),
|
|
127
|
+
'',
|
|
128
|
+
'可选:--continue · --tier flash|current|pro · --lang zh|en|auto · --goal-rounds N · --file <改过的摘要文件>',
|
|
129
|
+
'排查:/bridge --doctor',
|
|
130
|
+
].join('\n');
|
|
131
|
+
}
|
|
132
|
+
function commandMetadata(lang) {
|
|
133
|
+
if (lang === 'en') {
|
|
134
|
+
return {
|
|
135
|
+
description: 'Migrate this session to another tool preset while keeping the original untouched',
|
|
136
|
+
hint: '<preset> [--go] [--continue] | --doctor',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (lang === 'zh') {
|
|
140
|
+
return {
|
|
141
|
+
description: '把这个会话迁移到另一工具 preset,原会话保持不动',
|
|
142
|
+
hint: '<模式> [--go] [--continue] | --doctor',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
description: 'Migrate across tool presets; keep the original untouched · 跨 preset 迁移会话,原会话保持不动',
|
|
147
|
+
hint: '<preset/模式> [--go] [--continue] | --doctor',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** 建一个 `/bridge` 命令定义。返回值形状对齐上游 `CommandDefinition`。 */
|
|
151
|
+
export function createBridgeCommand(deps) {
|
|
152
|
+
const pending = new Map();
|
|
153
|
+
const now = deps.now ?? (() => Date.now());
|
|
154
|
+
const metadata = commandMetadata(deps.config.lang);
|
|
155
|
+
return {
|
|
156
|
+
name: 'bridge',
|
|
157
|
+
description: metadata.description,
|
|
158
|
+
input: { hint: metadata.hint },
|
|
159
|
+
handler: async (invocation) => {
|
|
160
|
+
const sessionId = invocation.agent?.session?.id ?? invocation.agent?.session?.header?.id;
|
|
161
|
+
if (!sessionId)
|
|
162
|
+
return { kind: 'error', text: '取不到当前会话身份,无法迁移。' };
|
|
163
|
+
const parsed = parseBridgeInput(invocation.rawInput ?? '');
|
|
164
|
+
const initialLang = displayLang(parsed.lang ?? deps.config.lang);
|
|
165
|
+
if (parsed.error) {
|
|
166
|
+
return {
|
|
167
|
+
kind: 'error',
|
|
168
|
+
text: initialLang === 'en' ? `${parsed.error}\n\nUse /bridge to see the available syntax.` : `${parsed.error}\n\n用 /bridge 看用法。`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const rpc = deps.rpcFor(invocation.signal);
|
|
172
|
+
const config = deps.config;
|
|
173
|
+
let presets;
|
|
174
|
+
let current;
|
|
175
|
+
try {
|
|
176
|
+
presets = await listPresets(rpc);
|
|
177
|
+
current = (await findSession(rpc, sessionId))?.agentPreset;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
return { kind: 'error', text: describe(error) };
|
|
181
|
+
}
|
|
182
|
+
if (parsed.doctor) {
|
|
183
|
+
const probes = deps.probe?.() ?? [];
|
|
184
|
+
const missing = probes.filter((probe) => !probe.available).map((probe) => probe.method);
|
|
185
|
+
const available = presets.filter((p) => p.id !== current).map((p) => p.id).join(' · ');
|
|
186
|
+
const lines = initialLang === 'en'
|
|
187
|
+
? [
|
|
188
|
+
`Gateway: in-process ctx.apiProxy · ${probes.length - missing.length}/${probes.length} methods available`,
|
|
189
|
+
`Current preset: ${current ?? '(unavailable)'}`,
|
|
190
|
+
`Available targets: ${available || '(none)'}`,
|
|
191
|
+
`Config: tier ${config.modelTier} · source ${config.sourceCharBudget} chars · summary ${config.summaryCharBudget} chars`
|
|
192
|
+
+ ` · goal ${config.goalRounds} rounds · injection ${config.inject}`,
|
|
193
|
+
]
|
|
194
|
+
: [
|
|
195
|
+
`网关:进程内 ctx.apiProxy · ${probes.length - missing.length}/${probes.length} 个方法可用`,
|
|
196
|
+
`当前模式:${current ?? '(读不到)'}`,
|
|
197
|
+
`可迁入:${available || '(无)'}`,
|
|
198
|
+
`配置:档位 ${config.modelTier} · 取材 ${config.sourceCharBudget} 字符 · 摘要 ${config.summaryCharBudget} 字符`
|
|
199
|
+
+ ` · goal ${config.goalRounds} 轮 · 注入 ${config.inject}`,
|
|
200
|
+
];
|
|
201
|
+
if (missing.length) {
|
|
202
|
+
lines.push('');
|
|
203
|
+
if (initialLang === 'en') {
|
|
204
|
+
lines.push(`⚠ Missing: ${missing.join(', ')}`);
|
|
205
|
+
lines.push('This host gateway does not match the plugin contract; the upstream API is still a developer preview.');
|
|
206
|
+
lines.push('Report your dsh version at https://github.com/Totoro-qaq/dsh-plugin-bridge/issues.');
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
lines.push(`⚠ 缺少:${missing.join(', ')}`);
|
|
210
|
+
lines.push('这套 host 的网关面和插件预期的不一致(上游是 developer preview,接口会变)。');
|
|
211
|
+
lines.push('请到 https://github.com/Totoro-qaq/dsh-plugin-bridge/issues 报一下你的 dsh 版本。');
|
|
212
|
+
}
|
|
213
|
+
return { kind: 'error', text: lines.join('\n') };
|
|
214
|
+
}
|
|
215
|
+
return { kind: 'success', text: lines.join('\n') };
|
|
216
|
+
}
|
|
217
|
+
if (parsed.help || !parsed.preset)
|
|
218
|
+
return { kind: 'success', text: usage(presets, current, initialLang) };
|
|
219
|
+
const target = parsed.preset;
|
|
220
|
+
if (!presets.some((p) => p.id === target)) {
|
|
221
|
+
return {
|
|
222
|
+
kind: 'error',
|
|
223
|
+
text: initialLang === 'en'
|
|
224
|
+
? `No usable preset named "${target}".\n\n${usage(presets, current, initialLang)}`
|
|
225
|
+
: `没有叫 "${target}" 的模式(或者它当前是坏的)。\n\n${usage(presets, current, initialLang)}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (target === current) {
|
|
229
|
+
return { kind: 'error', text: initialLang === 'en' ? `This session already uses the ${target} preset.` : `这个会话已经在 ${target} 模式了。` };
|
|
230
|
+
}
|
|
231
|
+
/* ---------------- 执行 ---------------- */
|
|
232
|
+
if (parsed.go) {
|
|
233
|
+
let summary;
|
|
234
|
+
let pendingLang;
|
|
235
|
+
if (parsed.file) {
|
|
236
|
+
try {
|
|
237
|
+
summary = deps.readSummary?.(parsed.file);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
return { kind: 'error', text: initialLang === 'en' ? `Cannot read ${parsed.file}: ${describe(error)}` : `读不到 ${parsed.file}:${describe(error)}` };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
const stashed = pending.get(sessionId);
|
|
245
|
+
if (stashed && stashed.preset === target && now() - stashed.at < PENDING_TTL_MS) {
|
|
246
|
+
summary = stashed.summary;
|
|
247
|
+
pendingLang = stashed.lang;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const runLang = parsed.lang === 'en' || parsed.lang === 'zh' ? parsed.lang : pendingLang ?? initialLang;
|
|
251
|
+
const source = parsed.file ?? (runLang === 'en' ? 'the reviewed preview' : '暂存的预览');
|
|
252
|
+
if (!summary?.trim()) {
|
|
253
|
+
return {
|
|
254
|
+
kind: 'error',
|
|
255
|
+
text: runLang === 'en'
|
|
256
|
+
? `No usable handoff is available; the preview may have expired. Run /bridge ${target}, review it, then add --go.`
|
|
257
|
+
: `没有可用的摘要(预览可能已过期)。先跑 /bridge ${target} 看一眼,确认后再 --go。`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const row = await findSession(rpc, sessionId).catch(() => undefined);
|
|
262
|
+
const sourceTitle = titleOf(row);
|
|
263
|
+
const targetTitle = sourceTitle ? migratedTitle(sourceTitle, target) : (runLang === 'en' ? `Migrated to ${target}` : migratedTitle(sourceTitle, target));
|
|
264
|
+
const result = await executeMigration(rpc, {
|
|
265
|
+
sessionId,
|
|
266
|
+
to: target,
|
|
267
|
+
summary,
|
|
268
|
+
goalRounds: parsed.goalRounds ?? config.goalRounds,
|
|
269
|
+
inject: parsed.inject ?? config.inject,
|
|
270
|
+
title: targetTitle,
|
|
271
|
+
autoContinue: parsed.autoContinue,
|
|
272
|
+
lang: runLang,
|
|
273
|
+
});
|
|
274
|
+
pending.delete(sessionId);
|
|
275
|
+
const lines = runLang === 'en'
|
|
276
|
+
? [
|
|
277
|
+
`Created a new session in the ${result.agentPreset} preset from ${source}.`,
|
|
278
|
+
`Target session: ${targetTitle} · ${result.sessionId}`,
|
|
279
|
+
result.kickoffSent
|
|
280
|
+
? (parsed.autoContinue
|
|
281
|
+
? 'The handoff goal is paused; the new session will restate and continue in the same request without an extra goal round.'
|
|
282
|
+
: 'The new session will only restate the handoff, then wait for your confirmation.')
|
|
283
|
+
: 'The new session did not start automatically; inspect the warnings below before continuing manually.',
|
|
284
|
+
'The source session is untouched and remains available; archive the target if the handoff is unsatisfactory.',
|
|
285
|
+
]
|
|
286
|
+
: [
|
|
287
|
+
`已在 ${result.agentPreset} 模式下建好新会话,摘要来自${source}。`,
|
|
288
|
+
`目标会话:${targetTitle} · ${result.sessionId}`,
|
|
289
|
+
result.kickoffSent
|
|
290
|
+
? (parsed.autoContinue
|
|
291
|
+
? '交接目标已暂停;新会话会在同一轮复述理解并继续下一步,不触发额外 goal 轮次。'
|
|
292
|
+
: '新会话只会复述理解,然后暂停等待你确认。')
|
|
293
|
+
: '新会话没有自动启动;请按下面的警告检查后手动继续。',
|
|
294
|
+
'原会话原封不动,随时点回来;新会话不满意就归档。',
|
|
295
|
+
];
|
|
296
|
+
if (result.imagesSent) {
|
|
297
|
+
lines.push(runLang === 'en'
|
|
298
|
+
? `Images: ${result.imagesSent} unresolved source image(s) were attached to the vision target kickoff.`
|
|
299
|
+
: `图片:${result.imagesSent} 张尚未解析的原图已随 kickoff 搬到视觉目标。`);
|
|
300
|
+
}
|
|
301
|
+
for (const warning of result.warnings)
|
|
302
|
+
lines.push(`⚠ ${warning}`);
|
|
303
|
+
return { kind: 'success', text: lines.join('\n') };
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
return { kind: 'error', text: describe(error) };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/* ---------------- 预览 ---------------- */
|
|
310
|
+
const startedAt = now();
|
|
311
|
+
try {
|
|
312
|
+
const preview = await previewMigration(rpc, {
|
|
313
|
+
sessionId,
|
|
314
|
+
tier: parsed.tier ?? config.modelTier,
|
|
315
|
+
sourceCharBudget: config.sourceCharBudget,
|
|
316
|
+
summaryCharBudget: config.summaryCharBudget,
|
|
317
|
+
lang: parsed.lang ?? config.lang,
|
|
318
|
+
workerTimeoutMs: config.previewTimeoutMs,
|
|
319
|
+
pollMs: 750,
|
|
320
|
+
...(config.workerProvider ? { provider: config.workerProvider } : {}),
|
|
321
|
+
...(config.workerModel ? { model: config.workerModel } : {}),
|
|
322
|
+
});
|
|
323
|
+
const file = deps.writeSummary?.(sessionId, preview.summary);
|
|
324
|
+
pending.set(sessionId, { preset: target, summary: preview.summary, lang: preview.lang, at: now(), ...(file ? { file } : {}) });
|
|
325
|
+
const s = preview.source;
|
|
326
|
+
const outputLang = preview.lang;
|
|
327
|
+
const lines = outputLang === 'en'
|
|
328
|
+
? [
|
|
329
|
+
`─── Handoff · ${current ?? 'current preset'} → ${target} (review numbers and paths) ───`,
|
|
330
|
+
preview.summary,
|
|
331
|
+
'───────────────────────────────────────────',
|
|
332
|
+
`Source ${s.text.length} chars · user messages ${s.userMessagesUsed}/${s.userMessagesTotal}`
|
|
333
|
+
+ `${s.reusedCompaction ? ' · reused compaction' : ''}`
|
|
334
|
+
+ ` · worker ${preview.worker.model || '(session default)'} · ${Math.round((now() - startedAt) / 1000)}s`,
|
|
335
|
+
]
|
|
336
|
+
: [
|
|
337
|
+
`─── 交接摘要 · ${current ?? '当前模式'} → ${target}(请过目,重点看数字与路径)───`,
|
|
338
|
+
preview.summary,
|
|
339
|
+
'───────────────────────────────────────────',
|
|
340
|
+
`取材 ${s.text.length} 字符 · 用户消息 ${s.userMessagesUsed}/${s.userMessagesTotal} 条`
|
|
341
|
+
+ `${s.reusedCompaction ? ' · 复用了 compaction 底稿' : ''}`
|
|
342
|
+
+ ` · 压缩模型 ${preview.worker.model || '(会话默认)'} · 用时 ${Math.round((now() - startedAt) / 1000)}s`,
|
|
343
|
+
];
|
|
344
|
+
if (s.visualEvidence.images) {
|
|
345
|
+
lines.push(outputLang === 'en'
|
|
346
|
+
? `Images ${s.visualEvidence.images} / ${s.visualEvidence.imageMessages} messages`
|
|
347
|
+
+ ` · represented ${s.visualEvidence.represented} · unresolved ${s.visualEvidence.unresolved}`
|
|
348
|
+
: `图片 ${s.visualEvidence.images} 张 / ${s.visualEvidence.imageMessages} 条消息`
|
|
349
|
+
+ ` · 有关联原文 ${s.visualEvidence.represented} 条 · 未解析 ${s.visualEvidence.unresolved} 条`);
|
|
350
|
+
}
|
|
351
|
+
if (s.truncated) {
|
|
352
|
+
lines.push(outputLang === 'en'
|
|
353
|
+
? `⚠ Source material was truncated by budget (${s.dropped.join(' / ')}); this handoff reflects the bounded history.`
|
|
354
|
+
: `⚠ 取材因预算被裁剪(${s.dropped.join(' / ')}),摘要是基于被裁过的历史写的`);
|
|
355
|
+
}
|
|
356
|
+
if (preview.capped)
|
|
357
|
+
lines.push(outputLang === 'en' ? '⚠ The summary worker timed out; the handoff uses the text produced before cancellation.' : '⚠ 压缩工人超时被取消,摘要按已产出文本计');
|
|
358
|
+
lines.push('');
|
|
359
|
+
lines.push(outputLang === 'en' ? `Review and run: /bridge ${target} --go` : `没问题就执行:/bridge ${target} --go`);
|
|
360
|
+
if (file) {
|
|
361
|
+
lines.push(outputLang === 'en'
|
|
362
|
+
? `Edit first: update ${file}, then run /bridge ${target} --go --file ${file}`
|
|
363
|
+
: `要改:编辑 ${file} 之后 /bridge ${target} --go --file ${file}`);
|
|
364
|
+
}
|
|
365
|
+
return { kind: 'success', text: lines.join('\n') };
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
return { kind: 'error', text: describe(error) };
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function describe(error) {
|
|
374
|
+
if (error instanceof RpcError)
|
|
375
|
+
return error.message;
|
|
376
|
+
return error instanceof Error ? error.message : String(error);
|
|
377
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge 跨模式迁移:从折叠后的会话消息取材,构建交接摘要的压缩输入。
|
|
3
|
+
* 纯函数,无 RPC 依赖,便于测试。设计原则见 docs/plan.md:
|
|
4
|
+
* 迁状态不迁痕迹;用户意图优先;工具只留名字与路径;总字符有硬预算。
|
|
5
|
+
*/
|
|
6
|
+
import type { ChatMessage, ImageAttachmentRef } from './types.ts';
|
|
7
|
+
/** 摘要正文的硬预算(字符)。默认 2400 字符 ≈ 900 tokens。 */
|
|
8
|
+
export declare const SUMMARY_CHAR_BUDGET = 2400;
|
|
9
|
+
/** 压缩输入(喂给工人模型的取材)总字符预算,≈30K tokens 内。 */
|
|
10
|
+
export declare const SOURCE_CHAR_BUDGET = 60000;
|
|
11
|
+
/** 逐字视觉证据独立于摘要预算;只按完整块收录,绝不从中间截断。 */
|
|
12
|
+
export declare const VISUAL_EVIDENCE_CHAR_BUDGET = 60000;
|
|
13
|
+
export interface BridgeSource {
|
|
14
|
+
/** 拼接好的压缩输入文本。 */
|
|
15
|
+
text: string;
|
|
16
|
+
/** 实际纳入的用户消息条数。 */
|
|
17
|
+
userMessagesUsed: number;
|
|
18
|
+
/** 会话里的用户消息总条数。 */
|
|
19
|
+
userMessagesTotal: number;
|
|
20
|
+
/** 是否命中并复用了最近一次 compaction 摘要。 */
|
|
21
|
+
reusedCompaction: boolean;
|
|
22
|
+
/** 取材是否因预算被截断。 */
|
|
23
|
+
truncated: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* 因预算被丢弃或裁剪的分区名(`compaction` / `users` / `recent`)。
|
|
26
|
+
* 调用方(GUI 预览弹窗、CLI)应当把它显示出来:静默丢弃是上一版最大的问题。
|
|
27
|
+
*/
|
|
28
|
+
dropped: string[];
|
|
29
|
+
/** 图片与同轮助手文本的逐字证据;不会交给摘要模型改写。 */
|
|
30
|
+
visualEvidence: VisualEvidence;
|
|
31
|
+
}
|
|
32
|
+
export interface VisualEvidenceItem {
|
|
33
|
+
/** 该图片消息在全部用户消息里的 1-based 序号。 */
|
|
34
|
+
userMessage: number;
|
|
35
|
+
imageCount: number;
|
|
36
|
+
/** 原用户文字,逐字保留;图片-only 消息为空。 */
|
|
37
|
+
userText: string;
|
|
38
|
+
/** 下一条用户消息出现前的助手正文,逐字保留;为空表示尚未解析。 */
|
|
39
|
+
assistantText: string;
|
|
40
|
+
/** rc.8 能恢复出的持久化图片引用;旧 host 可能为空。 */
|
|
41
|
+
attachments: ImageAttachmentRef[];
|
|
42
|
+
}
|
|
43
|
+
export interface VisualEvidence {
|
|
44
|
+
imageMessages: number;
|
|
45
|
+
images: number;
|
|
46
|
+
represented: number;
|
|
47
|
+
unresolved: number;
|
|
48
|
+
/** 完整纳入最终交接的图片消息证据。 */
|
|
49
|
+
included: VisualEvidenceItem[];
|
|
50
|
+
/** 因证据预算未纳入的完整块数;从不截断块内文本。 */
|
|
51
|
+
omitted: number;
|
|
52
|
+
truncated: boolean;
|
|
53
|
+
}
|
|
54
|
+
export interface BridgeSourceOptions {
|
|
55
|
+
/** 覆盖取材总字符预算(默认 SOURCE_CHAR_BUDGET)。 */
|
|
56
|
+
sourceCharBudget?: number;
|
|
57
|
+
/** 逐字视觉证据字符预算;完整块原子收录,默认 60K。 */
|
|
58
|
+
visualEvidenceCharBudget?: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 把“含图用户消息”与它到下一条用户消息之间的助手正文配对。
|
|
62
|
+
*
|
|
63
|
+
* Bridge 不声称这些正文一定是图片描述,只称为“关联助手响应”;这样即使助手
|
|
64
|
+
* 只是追问,也不会被误标成已经识图。文本由程序直接复制,不经过摘要模型。
|
|
65
|
+
*/
|
|
66
|
+
export declare function collectVisualEvidence(messages: ChatMessage[], charBudget?: number): VisualEvidence;
|
|
67
|
+
/** 把逐字视觉证据作为独立附录拼到模型摘要后;正文不会被二次改写。 */
|
|
68
|
+
export declare function appendVisualEvidence(summary: string, evidence: VisualEvidence, lang: 'zh' | 'en'): string;
|
|
69
|
+
/** 从折叠消息构建压缩输入。messages 按时间正序。 */
|
|
70
|
+
export declare function buildBridgeSource(messages: ChatMessage[], options?: BridgeSourceOptions): BridgeSource;
|
|
71
|
+
export interface BridgeInstructionOptions {
|
|
72
|
+
/** 摘要正文字符预算,默认 SUMMARY_CHAR_BUDGET。 */
|
|
73
|
+
summaryCharBudget?: number;
|
|
74
|
+
}
|
|
75
|
+
/** 摘要预算(字符)换算成写进指令的 token 上限。 */
|
|
76
|
+
export declare function summaryTokenBudget(summaryCharBudget?: number): number;
|
|
77
|
+
/** 压缩指令:让工人模型输出固定 schema 的交接摘要。 */
|
|
78
|
+
export declare function buildBridgeInstruction(lang: 'zh' | 'en', options?: BridgeInstructionOptions): string;
|
|
79
|
+
/** 注入新会话首轮的交接指令(goal 之后的第一条 prompt)。 */
|
|
80
|
+
export declare function buildBridgeKickoff(lang: 'zh' | 'en', autoContinue?: boolean): string;
|
|
81
|
+
/** 成本预估(粗):按取材字符数估输入 tokens,中英混合按 ~2 字符/token。 */
|
|
82
|
+
export declare function estimateSummaryTokens(sourceChars: number, options?: BridgeInstructionOptions): {
|
|
83
|
+
input: number;
|
|
84
|
+
output: number;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* 取材语言判定:CJK 字符占比超过 15% 视为中文。
|
|
88
|
+
* 用于 `--lang auto`:摘要语言应该跟着会话内容走,而不是跟着部署默认走。
|
|
89
|
+
*/
|
|
90
|
+
export declare function detectLang(text: string): 'zh' | 'en';
|