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/fold.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/** 上游 compaction 检查点的消息 provenance 标记(后端无关)。 */
|
|
2
|
+
const COMPACT_CHECKPOINT_PLUGIN = 'compact';
|
|
3
|
+
/** 上游 `frameSummary` 包裹摘要用的标签。 */
|
|
4
|
+
const SUMMARY_OPEN_TAG = '<compacted-summary>';
|
|
5
|
+
const SUMMARY_CLOSE_TAG = '</compacted-summary>';
|
|
6
|
+
function asRecord(value) {
|
|
7
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
8
|
+
}
|
|
9
|
+
function asNum(value) {
|
|
10
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
function blockBody(block) {
|
|
13
|
+
if (typeof block.text === 'string' && block.text)
|
|
14
|
+
return block.text;
|
|
15
|
+
if (typeof block.thinking === 'string' && block.thinking)
|
|
16
|
+
return block.thinking;
|
|
17
|
+
if (typeof block.content === 'string' && block.content)
|
|
18
|
+
return block.content;
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
function isReasoningBlock(block) {
|
|
22
|
+
return block.type === 'reasoning' || block.type === 'thinking';
|
|
23
|
+
}
|
|
24
|
+
function peelThinkTags(text) {
|
|
25
|
+
// 线性扫描替代懒惰量词正则(CodeQL js/polynomial-redos):
|
|
26
|
+
// /<think>([\s\S]*?)<\/think>/ 在大量未闭合 <think> 的输入上会回溯成多项式时间。
|
|
27
|
+
const parts = [];
|
|
28
|
+
const visibleParts = [];
|
|
29
|
+
const lower = text.toLowerCase();
|
|
30
|
+
let cursor = 0;
|
|
31
|
+
while (cursor <= text.length) {
|
|
32
|
+
const open = lower.indexOf('<think>', cursor);
|
|
33
|
+
if (open === -1) {
|
|
34
|
+
visibleParts.push(text.slice(cursor));
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
visibleParts.push(text.slice(cursor, open));
|
|
38
|
+
const close = lower.indexOf('</think>', open + 7);
|
|
39
|
+
if (close === -1) {
|
|
40
|
+
// 未闭合的标签按原文保留(与原正则不匹配时的行为一致)
|
|
41
|
+
visibleParts.push(text.slice(open));
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
const trimmed = text.slice(open + 7, close).trim();
|
|
45
|
+
if (trimmed)
|
|
46
|
+
parts.push(trimmed);
|
|
47
|
+
cursor = close + 8;
|
|
48
|
+
}
|
|
49
|
+
return { text: visibleParts.join('').trim(), thinking: parts.join('\n\n') };
|
|
50
|
+
}
|
|
51
|
+
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
52
|
+
function imageAttachmentFromBlock(block) {
|
|
53
|
+
const attachment = asRecord(block.attachment);
|
|
54
|
+
if (!attachment)
|
|
55
|
+
return undefined;
|
|
56
|
+
const { attachmentId, mediaType, bytes, width, height, name } = attachment;
|
|
57
|
+
if (typeof attachmentId !== 'string' || !attachmentId)
|
|
58
|
+
return undefined;
|
|
59
|
+
if (typeof mediaType !== 'string' || !IMAGE_MEDIA_TYPES.has(mediaType))
|
|
60
|
+
return undefined;
|
|
61
|
+
if (typeof bytes !== 'number' || typeof width !== 'number' || typeof height !== 'number')
|
|
62
|
+
return undefined;
|
|
63
|
+
return {
|
|
64
|
+
attachmentId,
|
|
65
|
+
mediaType: mediaType,
|
|
66
|
+
bytes,
|
|
67
|
+
width,
|
|
68
|
+
height,
|
|
69
|
+
...(typeof name === 'string' && name ? { name } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function splitContentBlocks(blocks) {
|
|
73
|
+
if (!Array.isArray(blocks))
|
|
74
|
+
return { text: '', thinking: '', imageCount: 0, imageAttachments: [] };
|
|
75
|
+
let text = '';
|
|
76
|
+
let thinking = '';
|
|
77
|
+
let imageCount = 0;
|
|
78
|
+
const imageAttachments = [];
|
|
79
|
+
for (const item of blocks) {
|
|
80
|
+
const block = asRecord(item);
|
|
81
|
+
if (!block)
|
|
82
|
+
continue;
|
|
83
|
+
if (block.type === 'image') {
|
|
84
|
+
imageCount += 1;
|
|
85
|
+
const attachment = imageAttachmentFromBlock(block);
|
|
86
|
+
if (attachment)
|
|
87
|
+
imageAttachments.push(attachment);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const body = blockBody(block);
|
|
91
|
+
if (!body)
|
|
92
|
+
continue;
|
|
93
|
+
if (isReasoningBlock(block))
|
|
94
|
+
thinking += body;
|
|
95
|
+
else if (block.type === 'text' || block.type == null)
|
|
96
|
+
text += body;
|
|
97
|
+
}
|
|
98
|
+
const peeled = peelThinkTags(text);
|
|
99
|
+
return { text: peeled.text, thinking: thinking || peeled.thinking, imageCount, imageAttachments };
|
|
100
|
+
}
|
|
101
|
+
function textFromBlocks(blocks) {
|
|
102
|
+
return splitContentBlocks(blocks).text;
|
|
103
|
+
}
|
|
104
|
+
function formatTime(ms) {
|
|
105
|
+
if (!ms)
|
|
106
|
+
return '';
|
|
107
|
+
const d = new Date(ms);
|
|
108
|
+
if (Number.isNaN(d.getTime()))
|
|
109
|
+
return '';
|
|
110
|
+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
111
|
+
}
|
|
112
|
+
/** host 注入的运行时上下文块:对交接摘要没有价值,且体量不小。 */
|
|
113
|
+
function isNoiseUserText(text) {
|
|
114
|
+
const trimmed = text.trim();
|
|
115
|
+
if (trimmed.startsWith('<system-reminder>') || trimmed.startsWith('<system-notification>'))
|
|
116
|
+
return true;
|
|
117
|
+
if (/^<\/?runtime-context\b/i.test(trimmed) || /<\/runtime-context>/i.test(trimmed))
|
|
118
|
+
return true;
|
|
119
|
+
if (/^Current runtime context\b/i.test(trimmed))
|
|
120
|
+
return true;
|
|
121
|
+
if (trimmed.includes('supersedes earlier runtime-context'))
|
|
122
|
+
return true;
|
|
123
|
+
if (/^Current DSH file policy:/im.test(trimmed) && /Approval policy:/i.test(trimmed))
|
|
124
|
+
return true;
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
function compactJson(value, limit = 240) {
|
|
128
|
+
if (value == null)
|
|
129
|
+
return '';
|
|
130
|
+
try {
|
|
131
|
+
const json = JSON.stringify(value);
|
|
132
|
+
if (!json || json === '{}' || json === '[]' || json === 'null')
|
|
133
|
+
return '';
|
|
134
|
+
return json.length > limit ? `${json.slice(0, limit)}…` : json;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return '';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function firstString(values) {
|
|
141
|
+
for (const value of values) {
|
|
142
|
+
if (typeof value === 'string' && value.trim())
|
|
143
|
+
return value.trim();
|
|
144
|
+
}
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
/** 工具调用的入参摘要:命令 / 路径 / 查询串,取第一个有值的。 */
|
|
148
|
+
export function toolDetail(data) {
|
|
149
|
+
if (!data)
|
|
150
|
+
return '';
|
|
151
|
+
const call = asRecord(data.call);
|
|
152
|
+
const input = asRecord(data.input) ?? asRecord(data.arguments) ?? asRecord(data.args)
|
|
153
|
+
?? asRecord(call?.arguments) ?? asRecord(call?.input);
|
|
154
|
+
return firstString([
|
|
155
|
+
data.command,
|
|
156
|
+
data.path,
|
|
157
|
+
data.file,
|
|
158
|
+
data.query,
|
|
159
|
+
input?.command,
|
|
160
|
+
input?.path,
|
|
161
|
+
input?.file_path,
|
|
162
|
+
input?.file,
|
|
163
|
+
input?.query,
|
|
164
|
+
input?.pattern,
|
|
165
|
+
]) || compactJson(input);
|
|
166
|
+
}
|
|
167
|
+
/** 工具输出(折叠保留,取材不使用)。 */
|
|
168
|
+
export function toolOutput(data) {
|
|
169
|
+
if (!data)
|
|
170
|
+
return '';
|
|
171
|
+
const pieces = [data.output, data.result, data.text, data.content];
|
|
172
|
+
for (const piece of pieces) {
|
|
173
|
+
if (typeof piece === 'string' && piece.trim())
|
|
174
|
+
return piece.trim();
|
|
175
|
+
if (Array.isArray(piece)) {
|
|
176
|
+
const text = piece
|
|
177
|
+
.map((item) => (typeof item === 'string' ? item : blockBody(asRecord(item) ?? {})))
|
|
178
|
+
.join('')
|
|
179
|
+
.trim();
|
|
180
|
+
if (text)
|
|
181
|
+
return text;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const error = asRecord(data.error);
|
|
185
|
+
if (typeof error?.message === 'string' && error.message)
|
|
186
|
+
return error.message;
|
|
187
|
+
if (typeof data.error === 'string' && data.error)
|
|
188
|
+
return data.error;
|
|
189
|
+
return compactJson(data.output ?? data.result, 400);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 是否是 compaction 检查点消息。
|
|
193
|
+
*
|
|
194
|
+
* 上游把这个判据专门导出成 `isCompactCheckpointSource`
|
|
195
|
+
* (`@deepseek-ai/dsh-compaction/checkpoint`,一个不依赖 cordis 的纯谓词出口,
|
|
196
|
+
* 就是给客户端/wire 程序用的)。这里保持同一语义:认 provenance 标记,
|
|
197
|
+
* 文本标签只作为兜底。
|
|
198
|
+
*/
|
|
199
|
+
export function isCompactCheckpoint(data, text = '') {
|
|
200
|
+
const source = asRecord(data?.source);
|
|
201
|
+
if (source?.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_PLUGIN)
|
|
202
|
+
return true;
|
|
203
|
+
return text.includes(SUMMARY_OPEN_TAG);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 取出检查点里真正的摘要正文。
|
|
207
|
+
*
|
|
208
|
+
* 上游 `frameSummary()` 拼出的形状是
|
|
209
|
+
* `CHECKPOINT_PREAMBLE + "\n\n<compacted-summary>" + 摘要 + "</compacted-summary>"`,
|
|
210
|
+
* 而 preamble 是一句面向模型的指令("把它当既有背景,别提这个 checkpoint,
|
|
211
|
+
* 直接继续")。只去标签会把这句指令一起喂给压缩工人,所以这里连 preamble 一起剥掉;
|
|
212
|
+
* 找不到标签时退回原文。
|
|
213
|
+
*/
|
|
214
|
+
export function stripCompactTags(text) {
|
|
215
|
+
const open = text.indexOf(SUMMARY_OPEN_TAG);
|
|
216
|
+
const close = text.lastIndexOf(SUMMARY_CLOSE_TAG);
|
|
217
|
+
if (open >= 0) {
|
|
218
|
+
const start = open + SUMMARY_OPEN_TAG.length;
|
|
219
|
+
const end = close > start ? close : text.length;
|
|
220
|
+
return text.slice(start, end).trim();
|
|
221
|
+
}
|
|
222
|
+
return text.replace(/<\/?compacted-summary>/g, '').trim();
|
|
223
|
+
}
|
|
224
|
+
/** `turn:step`,两者都在 payload 上时才有值。 */
|
|
225
|
+
export function stepKey(event) {
|
|
226
|
+
const turn = asNum(event.data?.turn);
|
|
227
|
+
const step = asNum(event.data?.step);
|
|
228
|
+
if (turn == null || step == null)
|
|
229
|
+
return null;
|
|
230
|
+
return `${turn}:${step}`;
|
|
231
|
+
}
|
|
232
|
+
export function toolName(data) {
|
|
233
|
+
if (!data)
|
|
234
|
+
return 'tool';
|
|
235
|
+
const name = data.tool ?? data.name ?? data.toolName;
|
|
236
|
+
if (typeof name === 'string' && name)
|
|
237
|
+
return name;
|
|
238
|
+
const call = asRecord(data.call);
|
|
239
|
+
if (typeof call?.name === 'string' && call.name)
|
|
240
|
+
return call.name;
|
|
241
|
+
return 'tool';
|
|
242
|
+
}
|
|
243
|
+
function callIdOf(data) {
|
|
244
|
+
if (!data)
|
|
245
|
+
return undefined;
|
|
246
|
+
if (typeof data.callId === 'string' && data.callId)
|
|
247
|
+
return data.callId;
|
|
248
|
+
const message = asRecord(data.message);
|
|
249
|
+
if (typeof message?.callId === 'string' && message.callId)
|
|
250
|
+
return message.callId;
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
/** 已被 `assistant/message` 终结的 step:其 text chunk 不再重复累加。 */
|
|
254
|
+
function finalizedSteps(events) {
|
|
255
|
+
const keys = new Set();
|
|
256
|
+
for (const event of events) {
|
|
257
|
+
if (event.type !== 'assistant/message')
|
|
258
|
+
continue;
|
|
259
|
+
const key = stepKey(event);
|
|
260
|
+
if (key)
|
|
261
|
+
keys.add(key);
|
|
262
|
+
}
|
|
263
|
+
return keys;
|
|
264
|
+
}
|
|
265
|
+
function keepAssistant(message) {
|
|
266
|
+
return Boolean(message.content || message.thinking || message.toolNodes?.length);
|
|
267
|
+
}
|
|
268
|
+
function newAssistant(event) {
|
|
269
|
+
return {
|
|
270
|
+
id: `e-${event.seq ?? 0}`,
|
|
271
|
+
role: 'assistant',
|
|
272
|
+
latestEventSeq: event.seq ?? 0,
|
|
273
|
+
content: '',
|
|
274
|
+
timestamp: formatTime(event.time),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function stampThinkingMs(assistant, startedAt, endedAt) {
|
|
278
|
+
if (assistant.thinkingMs != null || !assistant.thinking || startedAt == null || endedAt == null)
|
|
279
|
+
return;
|
|
280
|
+
const delta = endedAt - startedAt;
|
|
281
|
+
if (delta >= 0)
|
|
282
|
+
assistant.thinkingMs = delta;
|
|
283
|
+
}
|
|
284
|
+
function applyAssistantBlocks(assistant, blocks) {
|
|
285
|
+
const split = splitContentBlocks(blocks);
|
|
286
|
+
if (split.text)
|
|
287
|
+
assistant.content = split.text;
|
|
288
|
+
if (split.thinking)
|
|
289
|
+
assistant.thinking = split.thinking;
|
|
290
|
+
}
|
|
291
|
+
/** 把一页 history(或实时 mux 事件)折叠成会话消息,按时间正序。 */
|
|
292
|
+
export function foldSessionEvents(events) {
|
|
293
|
+
const skipChunks = finalizedSteps(events);
|
|
294
|
+
const out = [];
|
|
295
|
+
let assistant = null;
|
|
296
|
+
let thinkingStartedAt;
|
|
297
|
+
const flush = (endedAt) => {
|
|
298
|
+
if (assistant && keepAssistant(assistant)) {
|
|
299
|
+
stampThinkingMs(assistant, thinkingStartedAt, endedAt);
|
|
300
|
+
out.push(assistant);
|
|
301
|
+
}
|
|
302
|
+
assistant = null;
|
|
303
|
+
thinkingStartedAt = undefined;
|
|
304
|
+
};
|
|
305
|
+
for (const event of events) {
|
|
306
|
+
const data = event.data;
|
|
307
|
+
if (event.type === 'user/message') {
|
|
308
|
+
flush(event.time);
|
|
309
|
+
const split = splitContentBlocks(data?.content);
|
|
310
|
+
const { text, imageCount, imageAttachments } = split;
|
|
311
|
+
if (isCompactCheckpoint(data, text)) {
|
|
312
|
+
out.push({
|
|
313
|
+
id: `e-${event.seq ?? 0}`,
|
|
314
|
+
role: 'system',
|
|
315
|
+
kind: 'compaction',
|
|
316
|
+
content: stripCompactTags(text),
|
|
317
|
+
timestamp: formatTime(event.time),
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (isNoiseUserText(text))
|
|
322
|
+
continue;
|
|
323
|
+
if (!text && imageCount === 0)
|
|
324
|
+
continue;
|
|
325
|
+
out.push({
|
|
326
|
+
id: `e-${event.seq ?? 0}`,
|
|
327
|
+
role: 'user',
|
|
328
|
+
content: text,
|
|
329
|
+
imageCount,
|
|
330
|
+
...(imageAttachments.length ? { imageAttachments } : {}),
|
|
331
|
+
timestamp: formatTime(event.time),
|
|
332
|
+
});
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (event.type === 'assistant/message') {
|
|
336
|
+
if (!assistant)
|
|
337
|
+
assistant = newAssistant(event);
|
|
338
|
+
assistant.latestEventSeq = event.seq ?? assistant.latestEventSeq;
|
|
339
|
+
const message = asRecord(data?.message);
|
|
340
|
+
applyAssistantBlocks(assistant, message?.content ?? data?.content);
|
|
341
|
+
stampThinkingMs(assistant, thinkingStartedAt, event.time);
|
|
342
|
+
if (assistant.content)
|
|
343
|
+
flush(event.time);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (event.type === 'assistant/chunk') {
|
|
347
|
+
const key = stepKey(event);
|
|
348
|
+
const chunk = asRecord(data?.chunk);
|
|
349
|
+
const skipText = Boolean(key && skipChunks.has(key));
|
|
350
|
+
if (skipText && chunk?.type !== 'reasoning-delta')
|
|
351
|
+
continue;
|
|
352
|
+
if (!assistant)
|
|
353
|
+
assistant = newAssistant(event);
|
|
354
|
+
assistant.latestEventSeq = event.seq ?? assistant.latestEventSeq;
|
|
355
|
+
const text = typeof chunk?.text === 'string' ? chunk.text
|
|
356
|
+
: typeof chunk?.delta === 'string' ? chunk.delta
|
|
357
|
+
: blockBody(asRecord(chunk?.block) ?? {});
|
|
358
|
+
if (chunk?.type === 'text-delta' && text && !skipText)
|
|
359
|
+
assistant.content += text;
|
|
360
|
+
if (chunk?.type === 'reasoning-delta' && text) {
|
|
361
|
+
if (thinkingStartedAt == null)
|
|
362
|
+
thinkingStartedAt = event.time;
|
|
363
|
+
assistant.thinkingStartedAt = thinkingStartedAt;
|
|
364
|
+
assistant.thinking = (assistant.thinking ?? '') + text;
|
|
365
|
+
}
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (event.type === 'tool/call') {
|
|
369
|
+
if (!assistant)
|
|
370
|
+
assistant = newAssistant(event);
|
|
371
|
+
assistant.latestEventSeq = event.seq ?? assistant.latestEventSeq;
|
|
372
|
+
const node = {
|
|
373
|
+
type: 'bash',
|
|
374
|
+
title: toolName(data),
|
|
375
|
+
status: 'running',
|
|
376
|
+
callId: callIdOf(data),
|
|
377
|
+
eventSeq: event.seq ?? 0,
|
|
378
|
+
detail: toolDetail(data),
|
|
379
|
+
};
|
|
380
|
+
assistant.toolNodes = [...(assistant.toolNodes ?? []), node];
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (event.type === 'tool/result') {
|
|
384
|
+
if (!assistant?.toolNodes?.length)
|
|
385
|
+
continue;
|
|
386
|
+
assistant.latestEventSeq = event.seq ?? assistant.latestEventSeq;
|
|
387
|
+
const id = callIdOf(data);
|
|
388
|
+
const output = toolOutput(data);
|
|
389
|
+
let marked = false;
|
|
390
|
+
assistant.toolNodes = assistant.toolNodes.map((node) => {
|
|
391
|
+
if (id && node.callId === id)
|
|
392
|
+
return { ...node, status: 'done', output: output || node.output };
|
|
393
|
+
if (!id && !marked && node.status === 'running') {
|
|
394
|
+
marked = true;
|
|
395
|
+
return { ...node, status: 'done', output: output || node.output };
|
|
396
|
+
}
|
|
397
|
+
return node;
|
|
398
|
+
});
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (event.type === 'turn/end')
|
|
402
|
+
flush(event.time);
|
|
403
|
+
}
|
|
404
|
+
flush(events[events.length - 1]?.time);
|
|
405
|
+
return out;
|
|
406
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
3
|
+
export declare const name = "dsh-plugin-bridge";
|
|
4
|
+
/**
|
|
5
|
+
* `commands` 是入口,`apiProxy` 是引擎——两个都是硬依赖:
|
|
6
|
+
* 缺哪个这个插件都无事可做,与其静默半挂,不如让 cordis 挂起等待。
|
|
7
|
+
* 两者在官方 `web` profile 里都在(base 挂 commands,web-app 挂 api-gateway)。
|
|
8
|
+
*/
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
export interface Config {
|
|
11
|
+
/** 压缩工人模型档位:flash 省 / current 跟随 / pro 准(实验:pro 几乎不加价,且没有全灭尾部风险)。 */
|
|
12
|
+
modelTier?: 'flash' | 'current' | 'pro';
|
|
13
|
+
/** 压缩取材总字符预算(≈30K tokens)。 */
|
|
14
|
+
sourceCharBudget?: number;
|
|
15
|
+
/** 交接摘要正文字符预算(≈900 tokens)。 */
|
|
16
|
+
summaryCharBudget?: number;
|
|
17
|
+
/**
|
|
18
|
+
* 迁移后目标会话的 goal 自主轮次上限。
|
|
19
|
+
*
|
|
20
|
+
* 上游 `goal.create` 的部署默认是 256,且 `dsh-goal-round-driver` 会在 agent
|
|
21
|
+
* 空闲时把目标渲染成 `<goal_round>` 提示反复跑——不显式设值,一次迁移等于给新
|
|
22
|
+
* 会话开了最多 256 轮自主循环。交接只需要一轮,之后交回用户。
|
|
23
|
+
*/
|
|
24
|
+
goalRounds?: number;
|
|
25
|
+
/** 摘要注入方式:prompt 不挂目标;goal / both 会挂目标。只要发 kickoff,摘要始终随 prompt 注入以防失忆。 */
|
|
26
|
+
inject?: 'goal' | 'prompt' | 'both';
|
|
27
|
+
/** 摘要语言,auto 表示跟着会话内容走。 */
|
|
28
|
+
lang?: 'zh' | 'en' | 'auto';
|
|
29
|
+
/** 直接指定压缩模型,跳过档位推断(换 provider 的部署用)。 */
|
|
30
|
+
workerProvider?: string;
|
|
31
|
+
workerModel?: string;
|
|
32
|
+
/** `/bridge <preset>` 等压缩工人的上限(毫秒)。 */
|
|
33
|
+
previewTimeoutMs?: number;
|
|
34
|
+
}
|
|
35
|
+
export declare const Config: Schema<Config>;
|
|
36
|
+
/** 把 Config 解析成命令层要的形状(Schema 已经填过默认值,这里只兜底)。 */
|
|
37
|
+
export declare function commandConfigOf(config?: Config): {
|
|
38
|
+
readonly modelTier: "current" | "flash" | "pro";
|
|
39
|
+
readonly sourceCharBudget: number;
|
|
40
|
+
readonly summaryCharBudget: number;
|
|
41
|
+
readonly goalRounds: number;
|
|
42
|
+
readonly inject: "both" | "goal" | "prompt";
|
|
43
|
+
readonly lang: "auto" | "en" | "zh";
|
|
44
|
+
readonly previewTimeoutMs: number;
|
|
45
|
+
readonly workerProvider?: string | undefined;
|
|
46
|
+
readonly workerModel?: string | undefined;
|
|
47
|
+
};
|
|
48
|
+
export declare function apply(ctx: Context, config?: Config): void;
|
|
49
|
+
declare const _default: {
|
|
50
|
+
name: string;
|
|
51
|
+
inject: string[];
|
|
52
|
+
Config: Schema<Config>;
|
|
53
|
+
apply: typeof apply;
|
|
54
|
+
};
|
|
55
|
+
export default _default;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-bridge:跨 preset 会话迁移。
|
|
3
|
+
*
|
|
4
|
+
* 形态是一个**普通的 dsh 插件**——`dsh plugin add` 装上、重启,输入框里就有
|
|
5
|
+
* `/bridge`;`dsh plugin remove` 卸掉,命令随 fiber 一起消失。不注册技能、
|
|
6
|
+
* 不依赖模型主动做什么、不需要 bash 或环境变量。
|
|
7
|
+
*
|
|
8
|
+
* 入口形状对齐上游 `dsh-plan-mode`(`/plan` 命令 + 工具):命令由 UI 直接派发,
|
|
9
|
+
* 不经过模型,结果也不进模型历史。执行引擎是进程内的 `ctx.apiProxy`
|
|
10
|
+
* (web bundle 的 `api-gateway` 行提供),所以整条链路不出进程。
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
16
|
+
import { createApiProxyRpc, probeApiProxy } from './api-rpc.js';
|
|
17
|
+
import { createBridgeCommand } from './command.js';
|
|
18
|
+
import { SOURCE_CHAR_BUDGET, SUMMARY_CHAR_BUDGET } from './compression.js';
|
|
19
|
+
export const name = 'dsh-plugin-bridge';
|
|
20
|
+
/**
|
|
21
|
+
* `commands` 是入口,`apiProxy` 是引擎——两个都是硬依赖:
|
|
22
|
+
* 缺哪个这个插件都无事可做,与其静默半挂,不如让 cordis 挂起等待。
|
|
23
|
+
* 两者在官方 `web` profile 里都在(base 挂 commands,web-app 挂 api-gateway)。
|
|
24
|
+
*/
|
|
25
|
+
export const inject = ['commands', 'apiProxy'];
|
|
26
|
+
/** 命令是同步返回的,等压缩工人不能等太久。 */
|
|
27
|
+
const DEFAULT_PREVIEW_TIMEOUT_MS = 180_000;
|
|
28
|
+
export const Config = Schema.object({
|
|
29
|
+
modelTier: Schema.union(['flash', 'current', 'pro']).default('pro'),
|
|
30
|
+
sourceCharBudget: Schema.number().default(SOURCE_CHAR_BUDGET),
|
|
31
|
+
summaryCharBudget: Schema.number().default(SUMMARY_CHAR_BUDGET),
|
|
32
|
+
goalRounds: Schema.number().default(1),
|
|
33
|
+
inject: Schema.union(['goal', 'prompt', 'both']).default('both'),
|
|
34
|
+
lang: Schema.union(['zh', 'en', 'auto']).default('auto'),
|
|
35
|
+
workerProvider: Schema.string(),
|
|
36
|
+
workerModel: Schema.string(),
|
|
37
|
+
previewTimeoutMs: Schema.number().default(DEFAULT_PREVIEW_TIMEOUT_MS),
|
|
38
|
+
});
|
|
39
|
+
/** 摘要落盘,供「改完再执行」那条路用。失败不该让迁移失败,所以吞掉异常。 */
|
|
40
|
+
function writeSummaryFile(sessionId, summary) {
|
|
41
|
+
try {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-bridge-'));
|
|
43
|
+
const file = join(dir, `summary-${sessionId.slice(0, 12)}.md`);
|
|
44
|
+
writeFileSync(file, summary, 'utf8');
|
|
45
|
+
return file;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** 把 Config 解析成命令层要的形状(Schema 已经填过默认值,这里只兜底)。 */
|
|
52
|
+
export function commandConfigOf(config = {}) {
|
|
53
|
+
return {
|
|
54
|
+
modelTier: config.modelTier ?? 'pro',
|
|
55
|
+
sourceCharBudget: config.sourceCharBudget ?? SOURCE_CHAR_BUDGET,
|
|
56
|
+
summaryCharBudget: config.summaryCharBudget ?? SUMMARY_CHAR_BUDGET,
|
|
57
|
+
goalRounds: config.goalRounds ?? 1,
|
|
58
|
+
inject: config.inject ?? 'both',
|
|
59
|
+
lang: config.lang ?? 'auto',
|
|
60
|
+
previewTimeoutMs: config.previewTimeoutMs ?? DEFAULT_PREVIEW_TIMEOUT_MS,
|
|
61
|
+
...(config.workerProvider ? { workerProvider: config.workerProvider } : {}),
|
|
62
|
+
...(config.workerModel ? { workerModel: config.workerModel } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function apply(ctx, config = {}) {
|
|
66
|
+
const apiProxyOf = () => ctx.apiProxy;
|
|
67
|
+
const command = createBridgeCommand({
|
|
68
|
+
rpcFor: (signal) => createApiProxyRpc(apiProxyOf(), signal),
|
|
69
|
+
probe: () => probeApiProxy(apiProxyOf()),
|
|
70
|
+
config: commandConfigOf(config),
|
|
71
|
+
writeSummary: writeSummaryFile,
|
|
72
|
+
readSummary: (path) => readFileSync(path, 'utf8'),
|
|
73
|
+
});
|
|
74
|
+
ctx.commands.register(command);
|
|
75
|
+
}
|
|
76
|
+
export default { name, inject, Config, apply };
|