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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * 迁移编排:取材 → 压缩工人 → 目标会话。
3
+ *
4
+ * 只依赖注入进来的 `Rpc`,不碰 process / argv / 文件系统,所以可以对着一个
5
+ * 假的 RPC(见 test/migrate.test.mjs)跑完整条链路而不烧任何 token。
6
+ * CLI(`cli.ts`)与客户端 GUI 都消费这里的函数,保证「被验证的」和
7
+ * 「被交付的」是同一条代码路径。
8
+ */
9
+ import { type BridgeSource } from './compression.ts';
10
+ import { type Rpc } from './rpc.ts';
11
+ import type { ChatMessage } from './types.ts';
12
+ export type ModelTier = 'flash' | 'current' | 'pro';
13
+ export type InjectMode = 'goal' | 'prompt' | 'both';
14
+ export type Lang = 'zh' | 'en' | 'auto';
15
+ export interface SessionRow {
16
+ sessionId: string;
17
+ running?: boolean;
18
+ blank?: boolean;
19
+ cwd?: string;
20
+ agentPreset?: string;
21
+ parentSessionId?: string;
22
+ projections?: {
23
+ values?: Record<string, unknown>;
24
+ };
25
+ }
26
+ export interface PresetRow {
27
+ id: string;
28
+ trust?: 'system' | 'user';
29
+ isDefault?: boolean;
30
+ name?: string;
31
+ description?: string;
32
+ broken?: string;
33
+ }
34
+ export interface ModelRoute {
35
+ provider: string;
36
+ model: string;
37
+ /** 为什么选中它:configured / follow-session / tier:<tier> / fallback-current。 */
38
+ reason: string;
39
+ }
40
+ export interface ModelSelection {
41
+ provider: string;
42
+ model: string;
43
+ reasoningEffort?: string;
44
+ }
45
+ /**
46
+ * 列出全部会话。
47
+ *
48
+ * `session.list` 的 v1 一次返回全部,`cursor` 是预留位;这里仍按 cursor 取完,
49
+ * 免得上游哪天真的分页之后这里悄悄只看第一页。
50
+ */
51
+ export declare function listSessions(rpc: Rpc): Promise<SessionRow[]>;
52
+ export declare function findSession(rpc: Rpc, sessionId: string): Promise<SessionRow | undefined>;
53
+ /** 找出会话所属工作区;找不到就返回 undefined(调用方退回用 cwd 建会话)。 */
54
+ export declare function findWorkspaceId(rpc: Rpc, sessionId: string): Promise<string | undefined>;
55
+ /** 可作为迁移目标的 preset(去掉 broken 的)。 */
56
+ export declare function listPresets(rpc: Rpc): Promise<PresetRow[]>;
57
+ /**
58
+ * 选压缩工人的模型。
59
+ *
60
+ * 不写死任何 provider/model:先读源会话的模型目录,再按档位在**同一 provider**
61
+ * 里挑。挑不到就退回源会话当前模型——档位是省钱偏好,不该成为换 provider 的人
62
+ * 装不上的理由。
63
+ */
64
+ export declare function resolveWorkerModel(rpc: Rpc, sessionId: string, tier: ModelTier, override?: {
65
+ provider?: string;
66
+ model?: string;
67
+ }): Promise<ModelRoute>;
68
+ /** 压缩工人用哪个 preset:优先 minimal,其次 standard,再否则 host 默认。 */
69
+ export declare function resolveWorkerPreset(rpc: Rpc): Promise<string | undefined>;
70
+ export interface WaitOptions {
71
+ timeoutMs?: number;
72
+ /** 等「开始跑」的宽限期;超过还没 running 就当它已经跑完了。 */
73
+ startGraceMs?: number;
74
+ pollMs?: number;
75
+ }
76
+ /**
77
+ * 等一个会话回到空闲。
78
+ *
79
+ * 旧实现是「先 sleep 2s,再看 running 是不是 false」——host 排队稍慢一点,
80
+ * 第一次轮询就会把「还没开始」误判成「已经跑完」,取到的是上一轮的回答。
81
+ * 这里先等它真的 running 起来(有宽限期),再等它落回 false。
82
+ */
83
+ export declare function waitIdle(rpc: Rpc, sessionId: string, options?: WaitOptions): Promise<{
84
+ idle: boolean;
85
+ started: boolean;
86
+ }>;
87
+ /** 拉取并折叠会话历史(按需翻页)。 */
88
+ export declare function foldedHistory(rpc: Rpc, sessionId: string, options?: {
89
+ pageMessages?: number;
90
+ maxPages?: number;
91
+ }): Promise<ChatMessage[]>;
92
+ /** 会话里最后一条非空 assistant 文本。 */
93
+ export declare function lastAssistantText(rpc: Rpc, sessionId: string): Promise<string>;
94
+ export interface PreviewOptions {
95
+ sessionId: string;
96
+ tier?: ModelTier;
97
+ provider?: string;
98
+ model?: string;
99
+ sourceCharBudget?: number;
100
+ summaryCharBudget?: number;
101
+ lang?: Lang;
102
+ workerTimeoutMs?: number;
103
+ /** 轮询空闲的间隔,测试里调小。 */
104
+ pollMs?: number;
105
+ /** 只取材、不真的调模型(自检用)。 */
106
+ dryRun?: boolean;
107
+ onProgress?: (message: string) => void;
108
+ }
109
+ export interface PreviewResult {
110
+ summary: string;
111
+ source: BridgeSource;
112
+ lang: 'zh' | 'en';
113
+ worker: {
114
+ sessionId?: string;
115
+ provider: string;
116
+ model: string;
117
+ reason: string;
118
+ preset?: string;
119
+ };
120
+ /** 工人会话是否被限速取消(摘要按已产出文本计)。 */
121
+ capped: boolean;
122
+ sourceSession: SessionRow | undefined;
123
+ }
124
+ /** 生成交接摘要:取材 → 起临时工人 → 收摘要 → 归档工人。 */
125
+ export declare function previewMigration(rpc: Rpc, options: PreviewOptions): Promise<PreviewResult>;
126
+ export interface MigrateOptions {
127
+ sessionId: string;
128
+ to: string;
129
+ summary: string;
130
+ lang?: 'zh' | 'en';
131
+ /**
132
+ * goal 的自主轮次上限。
133
+ *
134
+ * 上游 `goal.create` 的部署默认是 **256**,而 `dsh-goal-round-driver` 会在
135
+ * agent 空闲时把目标渲染成 `<goal_round>` 提示反复跑。也就是说不显式设这个值,
136
+ * 一次「迁移」就等于给新会话开了最多 256 轮的自主循环。交接只需要一轮。
137
+ */
138
+ goalRounds?: number;
139
+ /** 摘要注入方式,默认 both(挂 goal + 首轮提示里也带全文)。 */
140
+ inject?: InjectMode;
141
+ /** 是否发首轮交接指令。 */
142
+ kickoff?: boolean;
143
+ /** 是否在同一轮复述后继续工作。goal 始终先暂停,避免 round driver 另起一轮。 */
144
+ autoContinue?: boolean;
145
+ /** 给新会话起个能看出来源的标题。 */
146
+ title?: string;
147
+ onProgress?: (message: string) => void;
148
+ }
149
+ export interface MigrateResult {
150
+ sessionId: string;
151
+ agentPreset: string;
152
+ /** 是否在 kickoff 前把源会话的模型选择复制到了目标会话。 */
153
+ modelTransferred: boolean;
154
+ /** 迁移时读到的源会话模型;读取失败时省略并给出 warning。 */
155
+ sourceModel?: ModelSelection;
156
+ goalCreated: boolean;
157
+ goalPaused: boolean;
158
+ kickoffSent: boolean;
159
+ titled: boolean;
160
+ /** 自动策略实际把多少张尚未解析的原图注入了 kickoff。 */
161
+ imagesSent: number;
162
+ warnings: string[];
163
+ }
164
+ /**
165
+ * 建目标会话并交接。
166
+ *
167
+ * 注入方式说明:`goal.create` 本身**不会**把目标注入模型上下文(上游
168
+ * `dsh-goal` README:「Goal mutations do not inject model context」),
169
+ * 摘要能被模型看见依赖 goal-round-driver 把它渲染成 `<goal_round>` 提示,
170
+ * 或模型主动调 `get_goal`。所以默认把摘要同时放进首轮提示:任何组装下都成立。
171
+ */
172
+ export declare function executeMigration(rpc: Rpc, options: MigrateOptions): Promise<MigrateResult>;
173
+ /** 默认标题:让新会话在侧栏里一眼看得出来源。 */
174
+ export declare function migratedTitle(sourceTitle: string | undefined, to: string): string;
175
+ /** 从 session.list 行里尽力取出标题(projection 形状随部署而异)。 */
176
+ export declare function titleOf(row: SessionRow | undefined): string | undefined;
package/lib/migrate.js ADDED
@@ -0,0 +1,449 @@
1
+ /**
2
+ * 迁移编排:取材 → 压缩工人 → 目标会话。
3
+ *
4
+ * 只依赖注入进来的 `Rpc`,不碰 process / argv / 文件系统,所以可以对着一个
5
+ * 假的 RPC(见 test/migrate.test.mjs)跑完整条链路而不烧任何 token。
6
+ * CLI(`cli.ts`)与客户端 GUI 都消费这里的函数,保证「被验证的」和
7
+ * 「被交付的」是同一条代码路径。
8
+ */
9
+ import { buildBridgeInstruction, buildBridgeKickoff, buildBridgeSource, appendVisualEvidence, collectVisualEvidence, detectLang, } from './compression.js';
10
+ import { foldSessionEvents } from './fold.js';
11
+ import { RpcError, sleep } from './rpc.js';
12
+ /** 工人会话优先使用的 preset(工具越少越省,也不会误动工作区)。 */
13
+ const WORKER_PRESET_PREFERENCE = ['minimal', 'standard'];
14
+ /** 每页拉多少条消息、最多翻几页。 */
15
+ const HISTORY_PAGE_MESSAGES = 60;
16
+ const HISTORY_MAX_PAGES = 4;
17
+ /* ---------------------------------------------------------------- 基础查询 */
18
+ /**
19
+ * 列出全部会话。
20
+ *
21
+ * `session.list` 的 v1 一次返回全部,`cursor` 是预留位;这里仍按 cursor 取完,
22
+ * 免得上游哪天真的分页之后这里悄悄只看第一页。
23
+ */
24
+ export async function listSessions(rpc) {
25
+ const rows = [];
26
+ let cursor;
27
+ for (let page = 0; page < 20; page += 1) {
28
+ const res = await rpc('session.list', cursor === undefined ? {} : { cursor });
29
+ rows.push(...(res.items ?? []));
30
+ if (!res.nextCursor)
31
+ break;
32
+ cursor = res.nextCursor;
33
+ }
34
+ return rows;
35
+ }
36
+ export async function findSession(rpc, sessionId) {
37
+ return (await listSessions(rpc)).find((row) => row.sessionId === sessionId);
38
+ }
39
+ /** 找出会话所属工作区;找不到就返回 undefined(调用方退回用 cwd 建会话)。 */
40
+ export async function findWorkspaceId(rpc, sessionId) {
41
+ const res = await rpc('workspace.list', {});
42
+ return res.items?.find((ws) => ws.sessionIds?.includes(sessionId))?.workspaceId;
43
+ }
44
+ /** 可作为迁移目标的 preset(去掉 broken 的)。 */
45
+ export async function listPresets(rpc) {
46
+ const res = await rpc('agentPreset.list', {});
47
+ return (res.presets ?? []).filter((preset) => preset.broken === undefined);
48
+ }
49
+ /** 新会话的落点:优先同工作区,否则同 cwd,再否则交给 host 默认。 */
50
+ async function placement(rpc, source, sessionId) {
51
+ const workspaceId = await findWorkspaceId(rpc, sessionId).catch(() => undefined);
52
+ if (workspaceId)
53
+ return { workspaceId };
54
+ if (source?.cwd)
55
+ return { cwd: source.cwd };
56
+ return {};
57
+ }
58
+ /**
59
+ * 选压缩工人的模型。
60
+ *
61
+ * 不写死任何 provider/model:先读源会话的模型目录,再按档位在**同一 provider**
62
+ * 里挑。挑不到就退回源会话当前模型——档位是省钱偏好,不该成为换 provider 的人
63
+ * 装不上的理由。
64
+ */
65
+ export async function resolveWorkerModel(rpc, sessionId, tier, override = {}) {
66
+ if (override.provider && override.model) {
67
+ return { provider: override.provider, model: override.model, reason: 'configured' };
68
+ }
69
+ const models = await rpc('session.models', { sessionId });
70
+ const current = models.current;
71
+ const fallback = {
72
+ provider: override.provider ?? current?.provider ?? '',
73
+ model: override.model ?? current?.model ?? '',
74
+ reason: 'fallback-current',
75
+ };
76
+ if (tier === 'current')
77
+ return { ...fallback, reason: 'follow-session' };
78
+ const group = models.groups?.find((g) => g.id === current?.provider) ?? models.groups?.[0];
79
+ const ids = (group?.models ?? []).map((m) => m.id);
80
+ const pattern = tier === 'flash'
81
+ ? /flash|mini|lite|small|haiku|turbo/i
82
+ : /pro|max|opus|large|reasoner|thinking/i;
83
+ const hit = ids.find((id) => pattern.test(id));
84
+ if (group && hit)
85
+ return { provider: group.id, model: hit, reason: `tier:${tier}` };
86
+ return fallback;
87
+ }
88
+ /** 压缩工人用哪个 preset:优先 minimal,其次 standard,再否则 host 默认。 */
89
+ export async function resolveWorkerPreset(rpc) {
90
+ const presets = await listPresets(rpc).catch(() => []);
91
+ for (const wanted of WORKER_PRESET_PREFERENCE) {
92
+ if (presets.some((preset) => preset.id === wanted))
93
+ return wanted;
94
+ }
95
+ return presets.find((preset) => preset.isDefault)?.id;
96
+ }
97
+ /**
98
+ * 等一个会话回到空闲。
99
+ *
100
+ * 旧实现是「先 sleep 2s,再看 running 是不是 false」——host 排队稍慢一点,
101
+ * 第一次轮询就会把「还没开始」误判成「已经跑完」,取到的是上一轮的回答。
102
+ * 这里先等它真的 running 起来(有宽限期),再等它落回 false。
103
+ */
104
+ export async function waitIdle(rpc, sessionId, options = {}) {
105
+ const pollMs = options.pollMs ?? 2000;
106
+ const deadline = Date.now() + (options.timeoutMs ?? 360_000);
107
+ const startBy = Date.now() + (options.startGraceMs ?? 25_000);
108
+ let started = false;
109
+ while (Date.now() < deadline) {
110
+ await sleep(pollMs);
111
+ const row = await findSession(rpc, sessionId).catch(() => undefined);
112
+ if (row?.running) {
113
+ started = true;
114
+ continue;
115
+ }
116
+ if (started)
117
+ return { idle: true, started: true };
118
+ if (Date.now() > startBy)
119
+ return { idle: true, started: false };
120
+ }
121
+ return { idle: false, started };
122
+ }
123
+ /** 拉取并折叠会话历史(按需翻页)。 */
124
+ export async function foldedHistory(rpc, sessionId, options = {}) {
125
+ const pageMessages = options.pageMessages ?? HISTORY_PAGE_MESSAGES;
126
+ const maxPages = options.maxPages ?? HISTORY_MAX_PAGES;
127
+ let events = [];
128
+ let beforeSeq;
129
+ for (let page = 0; page < maxPages; page += 1) {
130
+ const res = await rpc('session.history', { sessionId, maxMessages: pageMessages, ...(beforeSeq === undefined ? {} : { beforeSeq }) }, 60_000);
131
+ const chunk = (res.events ?? []).map((entry) => entry.event);
132
+ if (!chunk.length)
133
+ break;
134
+ events = [...chunk, ...events];
135
+ if (!res.hasMore)
136
+ break;
137
+ const first = chunk[0]?.seq;
138
+ if (typeof first !== 'number')
139
+ break;
140
+ beforeSeq = first;
141
+ }
142
+ return foldSessionEvents(events);
143
+ }
144
+ /** 会话里最后一条非空 assistant 文本。 */
145
+ export async function lastAssistantText(rpc, sessionId) {
146
+ const messages = await foldedHistory(rpc, sessionId, { pageMessages: 20, maxPages: 1 });
147
+ return [...messages].reverse().find((m) => m.role === 'assistant' && m.content.trim())?.content.trim() ?? '';
148
+ }
149
+ /** 生成交接摘要:取材 → 起临时工人 → 收摘要 → 归档工人。 */
150
+ export async function previewMigration(rpc, options) {
151
+ const progress = options.onProgress ?? (() => { });
152
+ const sourceSession = await findSession(rpc, options.sessionId);
153
+ progress('拉取并折叠会话历史…');
154
+ const messages = await foldedHistory(rpc, options.sessionId);
155
+ const source = buildBridgeSource(messages, { sourceCharBudget: options.sourceCharBudget });
156
+ if (!source.text.trim()) {
157
+ throw new RpcError('bridge.preview', 'empty-source', '这个会话还没有可迁移的内容(取材为空)。直接开一个新会话更省事。');
158
+ }
159
+ const lang = options.lang && options.lang !== 'auto' ? options.lang : detectLang(source.text);
160
+ const tier = options.tier ?? 'pro';
161
+ const route = await resolveWorkerModel(rpc, options.sessionId, tier, options);
162
+ if (options.dryRun) {
163
+ return { summary: '', source, lang, worker: { ...route }, capped: false, sourceSession };
164
+ }
165
+ const preset = await resolveWorkerPreset(rpc);
166
+ const where = await placement(rpc, sourceSession, options.sessionId);
167
+ progress(`起压缩工人(${preset ?? '默认 preset'} / ${route.model || '会话默认模型'})…`);
168
+ const worker = await rpc('session.create', {
169
+ ...where,
170
+ ...(preset === undefined ? {} : { agentPreset: preset }),
171
+ });
172
+ let capped = false;
173
+ try {
174
+ if (route.provider && route.model) {
175
+ await rpc('session.selectModel', { sessionId: worker.sessionId, provider: route.provider, model: route.model })
176
+ .catch((error) => {
177
+ // 选模型失败不该让整次迁移失败:工人用会话默认模型照样能写摘要。
178
+ progress(`选模型失败,改用默认模型(${error instanceof Error ? error.message : String(error)})`);
179
+ });
180
+ }
181
+ const instruction = buildBridgeInstruction(lang, { summaryCharBudget: options.summaryCharBudget });
182
+ await rpc('session.prompt', {
183
+ sessionId: worker.sessionId,
184
+ mode: 'queue',
185
+ content: [{ type: 'text', text: `${instruction}${source.text}` }],
186
+ });
187
+ progress('等待摘要…');
188
+ const settled = await waitIdle(rpc, worker.sessionId, {
189
+ timeoutMs: options.workerTimeoutMs ?? 360_000,
190
+ ...(options.pollMs === undefined ? {} : { pollMs: options.pollMs, startGraceMs: options.pollMs * 6 }),
191
+ });
192
+ if (!settled.idle) {
193
+ capped = true;
194
+ await rpc('session.cancel', { sessionId: worker.sessionId }).catch(() => undefined);
195
+ await sleep(2500);
196
+ }
197
+ const workerSummary = await lastAssistantText(rpc, worker.sessionId);
198
+ if (!workerSummary) {
199
+ throw new RpcError('bridge.preview', 'worker-empty', '压缩工人没有产出摘要(可能是模型不可用或被取消)。');
200
+ }
201
+ const summary = appendVisualEvidence(workerSummary, source.visualEvidence, lang);
202
+ return {
203
+ summary,
204
+ source,
205
+ lang,
206
+ worker: { sessionId: worker.sessionId, ...route, preset },
207
+ capped,
208
+ sourceSession,
209
+ };
210
+ }
211
+ finally {
212
+ // 工人是一次性的:无论成败都归档,不在侧栏留垃圾。
213
+ await rpc('workspace.archiveSession', { sessionId: worker.sessionId }).catch(() => undefined);
214
+ }
215
+ }
216
+ /** 找出没有关联助手正文的图片引用;已有逐字视觉证据时默认不重复烧视觉 token。 */
217
+ function unresolvedImageRefs(messages) {
218
+ const evidence = collectVisualEvidence(messages, Number.MAX_SAFE_INTEGER);
219
+ const refs = [];
220
+ const seen = new Set();
221
+ let missing = 0;
222
+ for (const item of evidence.included) {
223
+ if (item.assistantText)
224
+ continue;
225
+ missing += Math.max(0, item.imageCount - item.attachments.length);
226
+ for (const ref of item.attachments) {
227
+ if (seen.has(ref.attachmentId))
228
+ continue;
229
+ seen.add(ref.attachmentId);
230
+ refs.push(ref);
231
+ }
232
+ }
233
+ return { refs, missing };
234
+ }
235
+ async function readPromptImages(rpc, sourceSessionId, refs) {
236
+ return Promise.all(refs.map(async (ref) => {
237
+ const stored = await rpc('session.attachment', {
238
+ sessionId: sourceSessionId,
239
+ attachmentId: ref.attachmentId,
240
+ });
241
+ if (!stored.data || typeof stored.data !== 'string') {
242
+ throw new RpcError('session.attachment', 'empty-image', `附件 ${ref.attachmentId} 没有返回图片字节。`);
243
+ }
244
+ const attachment = stored.attachment ?? ref;
245
+ return {
246
+ type: 'image',
247
+ mediaType: attachment.mediaType,
248
+ data: stored.data,
249
+ ...(attachment.name ? { name: attachment.name } : {}),
250
+ };
251
+ }));
252
+ }
253
+ function imageFallbackAllowed(error) {
254
+ if (!(error instanceof RpcError))
255
+ return false;
256
+ if (error.method === 'session.attachment')
257
+ return true;
258
+ if (error.method !== 'session.prompt' || error.code !== 'attachment-error')
259
+ return false;
260
+ const reason = error.details && typeof error.details === 'object' && 'reason' in error.details
261
+ ? String(error.details.reason)
262
+ : '';
263
+ return reason === 'MODEL_DOES_NOT_SUPPORT_IMAGES'
264
+ || reason.startsWith('IMAGE_')
265
+ || reason.startsWith('ATTACHMENT_');
266
+ }
267
+ /**
268
+ * 建目标会话并交接。
269
+ *
270
+ * 注入方式说明:`goal.create` 本身**不会**把目标注入模型上下文(上游
271
+ * `dsh-goal` README:「Goal mutations do not inject model context」),
272
+ * 摘要能被模型看见依赖 goal-round-driver 把它渲染成 `<goal_round>` 提示,
273
+ * 或模型主动调 `get_goal`。所以默认把摘要同时放进首轮提示:任何组装下都成立。
274
+ */
275
+ export async function executeMigration(rpc, options) {
276
+ const progress = options.onProgress ?? (() => { });
277
+ const warnings = [];
278
+ const inject = options.inject ?? 'both';
279
+ const lang = options.lang ?? detectLang(options.summary);
280
+ const summary = options.summary.trim();
281
+ if (!summary)
282
+ throw new RpcError('bridge.migrate', 'empty-summary', '摘要为空,拒绝迁移。');
283
+ const sourceSession = await findSession(rpc, options.sessionId);
284
+ const where = await placement(rpc, sourceSession, options.sessionId);
285
+ let sourceModel;
286
+ try {
287
+ const models = await rpc('session.models', { sessionId: options.sessionId });
288
+ const current = models.current;
289
+ if (typeof current?.provider === 'string' && current.provider
290
+ && typeof current.model === 'string' && current.model) {
291
+ sourceModel = {
292
+ provider: current.provider,
293
+ model: current.model,
294
+ ...(typeof current.reasoningEffort === 'string' && current.reasoningEffort
295
+ ? { reasoningEffort: current.reasoningEffort }
296
+ : {}),
297
+ };
298
+ }
299
+ }
300
+ catch (error) {
301
+ warnings.push(`读取源会话模型失败,目标将使用 host 默认模型:${error instanceof Error ? error.message : String(error)}`);
302
+ }
303
+ progress(`在 ${options.to} 模式下新建会话…`);
304
+ const created = await rpc('session.create', {
305
+ ...where,
306
+ agentPreset: options.to,
307
+ });
308
+ // rc.2 的 session.selectModel 会同时保存 host 默认模型。预览 worker 通常
309
+ // 选择 pro 档位,因此如果这里依赖新会话默认值,目标会被 worker 悄悄改成
310
+ // pro,视觉源会话也会失去 VLM。显式复制源选择既保持用户模型意图,也让
311
+ // 未解析原图能在 kickoff 前通过目标模型的图片准入。
312
+ let modelTransferred = false;
313
+ if (sourceModel) {
314
+ try {
315
+ await rpc('session.selectModel', { sessionId: created.sessionId, ...sourceModel });
316
+ modelTransferred = true;
317
+ }
318
+ catch (error) {
319
+ warnings.push(`复制源会话模型失败,目标将使用 host 默认模型:${error instanceof Error ? error.message : String(error)}`);
320
+ }
321
+ }
322
+ let titled = false;
323
+ if (options.title) {
324
+ try {
325
+ await rpc('session.rename', { sessionId: created.sessionId, title: options.title });
326
+ titled = true;
327
+ }
328
+ catch (error) {
329
+ warnings.push(`改标题失败(不影响迁移):${error instanceof Error ? error.message : String(error)}`);
330
+ }
331
+ }
332
+ let goalCreated = false;
333
+ let goalPaused = false;
334
+ let safeToKickoff = true;
335
+ let kickoffSent = false;
336
+ let imagesSent = 0;
337
+ if (inject === 'goal' || inject === 'both') {
338
+ try {
339
+ const createdGoal = await rpc('goal.create', {
340
+ sessionId: created.sessionId,
341
+ objective: summary,
342
+ maxGoalRounds: options.goalRounds ?? 1,
343
+ });
344
+ goalCreated = true;
345
+ try {
346
+ await rpc('goal.pause', { sessionId: created.sessionId, ref: createdGoal.ref });
347
+ goalPaused = true;
348
+ }
349
+ catch (error) {
350
+ // 安全优先:pause 失败时不再发 kickoff。先清除 goal,挡住尚未入队的
351
+ // requestDrive;再 cancel,截住已排队或已开始的 attempt。
352
+ safeToKickoff = false;
353
+ let goalCleared = false;
354
+ try {
355
+ await rpc('goal.clear', { sessionId: created.sessionId, ref: createdGoal.ref });
356
+ goalCleared = true;
357
+ }
358
+ catch (clearError) {
359
+ warnings.push(`清除未暂停的交接目标失败,已继续取消目标会话;请保持目标会话关闭并手动检查:${clearError instanceof Error ? clearError.message : String(clearError)}`);
360
+ }
361
+ await rpc('session.cancel', { sessionId: created.sessionId }).catch(() => undefined);
362
+ warnings.push(goalCleared
363
+ ? `暂停交接目标失败,已清除目标并取消自动启动;请打开目标会话检查后手动继续:${error instanceof Error ? error.message : String(error)}`
364
+ : `暂停交接目标失败,已取消自动启动但无法确认目标已清除;请打开目标会话检查后手动继续:${error instanceof Error ? error.message : String(error)}`);
365
+ }
366
+ }
367
+ catch (error) {
368
+ // 没挂 goal 服务的部署也应该能迁移:摘要还会走首轮提示。
369
+ warnings.push(`挂载会话目标失败,摘要改由首轮提示携带:${error instanceof Error ? error.message : String(error)}`);
370
+ }
371
+ }
372
+ if (options.kickoff !== false && safeToKickoff) {
373
+ // goal mutation 本身不注入模型上下文,而 Bridge 会在 kickoff 前暂停 goal。
374
+ // 只要要发 kickoff,就必须带摘要;不能用一次看不见摘要的目标请求换取表面省 token。
375
+ const baseText = `${handoffPreamble(lang)}\n\n${summary}\n\n${buildBridgeKickoff(lang, options.autoContinue)}`;
376
+ progress('发送首轮交接指令…');
377
+ let unresolved = { refs: [], missing: 0 };
378
+ try {
379
+ unresolved = unresolvedImageRefs(await foldedHistory(rpc, options.sessionId));
380
+ }
381
+ catch (error) {
382
+ warnings.push(`读取图片状态失败,已按纯文本交接:${error instanceof Error ? error.message : String(error)}`);
383
+ }
384
+ if (unresolved.missing > 0) {
385
+ warnings.push(`有 ${unresolved.missing} 张未解析图片缺少可复用的持久附件引用;目标会话必须回源会话核验。`);
386
+ }
387
+ if (unresolved.refs.length) {
388
+ try {
389
+ const images = await readPromptImages(rpc, options.sessionId, unresolved.refs);
390
+ const transferNote = lang === 'en'
391
+ ? 'Bridge transfer note: the unresolved source images listed above are attached to this kickoff. Inspect them directly; do not infer details that are not visible.'
392
+ : 'Bridge 搬运说明:上文列出的未解析源图片已附在本次 kickoff 中。请直接检查原图,不得推断看不清的细节。';
393
+ await rpc('session.prompt', {
394
+ sessionId: created.sessionId,
395
+ mode: 'queue',
396
+ content: [...images, { type: 'text', text: `${baseText}\n\n${transferNote}` }],
397
+ });
398
+ imagesSent = images.length;
399
+ }
400
+ catch (error) {
401
+ if (!imageFallbackAllowed(error))
402
+ throw error;
403
+ warnings.push(`目标模型或 host 不能接收原图,已使用逐字视觉证据/未解析提示降级:${error instanceof Error ? error.message : String(error)}`);
404
+ }
405
+ }
406
+ if (imagesSent === 0) {
407
+ await rpc('session.prompt', {
408
+ sessionId: created.sessionId,
409
+ mode: 'queue',
410
+ content: [{ type: 'text', text: baseText }],
411
+ });
412
+ }
413
+ kickoffSent = true;
414
+ }
415
+ return {
416
+ sessionId: created.sessionId,
417
+ agentPreset: created.agentPreset ?? options.to,
418
+ modelTransferred,
419
+ ...(sourceModel ? { sourceModel } : {}),
420
+ goalCreated,
421
+ goalPaused,
422
+ kickoffSent,
423
+ titled,
424
+ imagesSent,
425
+ warnings,
426
+ };
427
+ }
428
+ function handoffPreamble(lang) {
429
+ return lang === 'en'
430
+ ? 'Handoff summary from a previous session that ran under a different tool preset:'
431
+ : '以下是上个会话(另一套工具模式)留下的交接摘要:';
432
+ }
433
+ /** 默认标题:让新会话在侧栏里一眼看得出来源。 */
434
+ export function migratedTitle(sourceTitle, to) {
435
+ const base = (sourceTitle ?? '').trim();
436
+ return base ? `${base} → ${to}` : `迁移到 ${to}`;
437
+ }
438
+ /** 从 session.list 行里尽力取出标题(projection 形状随部署而异)。 */
439
+ export function titleOf(row) {
440
+ const value = row?.projections?.values?.['title'];
441
+ if (typeof value === 'string' && value.trim())
442
+ return value.trim();
443
+ if (value && typeof value === 'object' && 'title' in value) {
444
+ const nested = value.title;
445
+ if (typeof nested === 'string' && nested.trim())
446
+ return nested.trim();
447
+ }
448
+ return undefined;
449
+ }
package/lib/rpc.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * dsh 网关 RPC 的最小客户端。
3
+ *
4
+ * 线协议见上游 `packages/host/apiproxy/src/fetch/handler.ts`:
5
+ * `POST /api/<method>`,`Content-Type: application/json`(非 JSON 会被 415 挡掉,
6
+ * 这是跨站写入围栏),请求体是 `client-request` 信封,响应体是 `server-response`。
7
+ * 回环无鉴权。
8
+ */
9
+ /** 一次 RPC 的业务错误(网关返回 `result.ok === false`)。 */
10
+ export declare class RpcError extends Error {
11
+ readonly method: string;
12
+ readonly code: string;
13
+ readonly details: unknown;
14
+ constructor(method: string, code: string, message: string, details?: unknown);
15
+ }
16
+ export interface RpcOptions {
17
+ /** API 根,形如 `http://127.0.0.1:3080/api`。 */
18
+ api: string;
19
+ /** 单次请求超时,默认 30s。 */
20
+ timeoutMs?: number;
21
+ /** rpcId 前缀,便于在 host 日志里认领。 */
22
+ prefix?: string;
23
+ /** 注入 fetch,测试用。 */
24
+ fetchImpl?: typeof fetch;
25
+ }
26
+ export type Rpc = <T = unknown>(method: string, payload?: unknown, timeoutMs?: number) => Promise<T>;
27
+ /**
28
+ * 解析 API 根。优先级:显式参数 > `DSH_API` > `DSH_WEB_URL`(模型 shell 环境里
29
+ * 由 web bundle 注入的本机 GUI 地址)> 回环默认值。
30
+ */
31
+ export declare function resolveApiBase(explicit?: string, env?: NodeJS.ProcessEnv): string;
32
+ /** 建一个 RPC 调用器。 */
33
+ export declare function createRpc(options: RpcOptions): Rpc;
34
+ export declare const sleep: (ms: number) => Promise<void>;