dsh-context-actions 0.1.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/lib/index.js ADDED
@@ -0,0 +1,1021 @@
1
+ /**
2
+ * dsh-context-actions · 宿主半侧(Host half)
3
+ *
4
+ * 职责:把「交接文档」写进系统临时目录。
5
+ * 浏览器半侧(lib/client.js)在上下文占用面板里新增两个按钮,并通过
6
+ * 本模块注册的 /handover 命令与宿主通信:
7
+ *
8
+ * /handover 生成交接文档(自动命名),返回路径
9
+ * /handover --where 返回交接目录与当前生成方式(JSON)
10
+ * /handover --write <绝对路径> 在指定路径生成交接文档
11
+ * /handover --llm | --mechanical 本次调用强制指定生成方式
12
+ *
13
+ * 生成方式(config.handover.mode):
14
+ * mechanical(默认):把持久日志的派生历史按固定规则折叠成 Markdown,零模型调用。
15
+ * llm:先按同一规则生成「纪要材料」,再让模型把它总结成交接文档;
16
+ * 摘要写入正文,纪要、文件/命令清单作为附录;失败可自动回退 mechanical。
17
+ */
18
+ import { mkdir, readdir, writeFile } from 'node:fs/promises'
19
+ import { homedir, tmpdir } from 'node:os'
20
+ import path from 'node:path'
21
+
22
+ /** Cordis 插件名;与 package.json 的包名保持一致。 */
23
+ export const name = 'dsh-context-actions'
24
+
25
+ /** 依赖的宿主服务:命令注册表 + 会话存储(读取当前会话日志)。 */
26
+ export const inject = ['commands', 'sessions']
27
+
28
+ /** 设置 namespace 名(与浏览器卡片 settings.plugin.item 的 key 必须一致)。 */
29
+ const SETTINGS_NAMESPACE = 'dsh-context-actions'
30
+
31
+ /** 交接文档目录:<系统临时目录>/dsh-handover。 */
32
+ const HANDOVER_DIR = path.join(tmpdir(), 'dsh-handover')
33
+
34
+ /** 单份交接文档的字符上限;超出预算时优先保留最近的内容。 */
35
+ const MAX_DOC_CHARS = 60000
36
+ /** mechanical 模式下单条消息正文 / 工具结果的截断上限。 */
37
+ const MESSAGE_TEXT_LIMIT = 1200
38
+ const TOOL_RESULT_LIMIT = 600
39
+ /** 「涉及的文件」「执行过的命令」两节的条目上限。 */
40
+ const FILE_LIST_LIMIT = 60
41
+ const COMMAND_LIST_LIMIT = 40
42
+
43
+ const USAGE =
44
+ '用法:/handover [--llm|--mechanical] | /handover --where | /handover --write <绝对路径>'
45
+
46
+ /** 内置默认交接提示词;设置页里留空即用这份(也可以一键填入后再改)。 */
47
+ const DEFAULT_SUMMARY_PROMPT = [
48
+ '你是资深工程助理。下面是一整段会话的完整记录:用户需求、助手回复、工具调用与结果,可能还包含较早的压缩摘要。',
49
+ '请把它整理成一份交接文档,交给一个完全没有这段记忆的新会话,让对方读完就能接着干活。',
50
+ '',
51
+ '用 Markdown 输出,结构固定如下(没有内容的写「无」):',
52
+ '',
53
+ '# 交接文档:<一句话任务名>',
54
+ '',
55
+ '## 任务目标',
56
+ '- 用户要什么、验收标准是什么;引用用户原话里的关键措辞。',
57
+ '- 边界与限制:明确不做的事、必须遵守的约束。',
58
+ '',
59
+ '## 当前状态',
60
+ '- 已完成:做到哪一步、结果如何(含关键命令/测试的结论)。',
61
+ '- 进行中:正在做什么、卡在哪、最后停在哪一步。',
62
+ '',
63
+ '## 关键决策与约定',
64
+ '- 已经定下来的技术选型、命名、接口与目录约定,以及为什么这么定。',
65
+ '- 用户纠正过的地方:原样保留关键措辞,避免新会话重犯。',
66
+ '',
67
+ '## 涉及的文件与命令',
68
+ '- 关键文件/路径(精确到大小写)、各自作用、最近一次改动。',
69
+ '- 复现与验证命令(原样保留),以及当前是否通过。',
70
+ '- 依赖环境:服务地址、端口、环境变量、数据目录等。',
71
+ '',
72
+ '## 待办与下一步',
73
+ '- 按优先级列出下一步该做什么,尽量写成可执行的小步骤。',
74
+ '- 已知的坑与注意事项。',
75
+ '',
76
+ '## 风险与未解决的问题',
77
+ '- 不确定、未验证、可能有副作用的地方。',
78
+ '- 需要向用户确认的问题。',
79
+ '',
80
+ '## 接手建议',
81
+ '- 新会话开头建议先做的 1-3 件事(例如先跑某个测试确认基线、先读某个文件)。',
82
+ '',
83
+ '硬性规则:',
84
+ '- 只写记录里出现过的事实;不确定就写「未知」,禁止编造文件、接口、命令或结论。',
85
+ '- 路径、命令、标识符、错误原文、版本号、数值、函数签名必须原样保留,不要改写、不要翻译。',
86
+ '- 记录里的压缩摘要/检查点属于可信背景:仍然成立的事实要带上,已被后续推翻的不要带。',
87
+ '- 长工具输出只取结论与关键片段,不要整段照抄。',
88
+ '- 篇幅控制在 1-2 页,分点写,宁短勿虚。',
89
+ '- 不要输出开场白、结语,也不要提到本次总结请求。',
90
+ '- 使用记录主要使用的语言(记录以中文为主就用中文)。',
91
+ ].join('\n')
92
+
93
+ /** 设置页卡片暴露的全部配置项;loader 行 config.handover 提供 base 默认值。 */
94
+ const DEFAULTS = {
95
+ mode: 'mechanical',
96
+ provider: '',
97
+ model: '',
98
+ // 默认值就是内置提示词:设置页的输入框会直接显示它,用户改了才成为用户层。
99
+ prompt: DEFAULT_SUMMARY_PROMPT,
100
+ maxOutputTokens: 2000,
101
+ }
102
+
103
+ /** 以下策略固定,刻意不做成配置项(保持卡片只有 5 个字段)。 */
104
+ const SUMMARY_TIMEOUT_MS = 180000
105
+ /** 解析不到模型上下文窗口时的兜底(tokens)。 */
106
+ const DEFAULT_CONTEXT_TOKENS = 131072
107
+ /** tokens → 字符的保守估算系数(按 CJK 偏保守取 1.5)。 */
108
+ const CHARS_PER_TOKEN = 1.5
109
+
110
+ /**
111
+ * 注册 /handover 命令。
112
+ * @param ctx - 宿主 Cordis 上下文。
113
+ * @param rawConfig - loader 行配置(可含 handover 段)。
114
+ */
115
+ export function apply(ctx, rawConfig) {
116
+ // 组合层:loader 行 config.handover,就是「设置 → 插件」卡片里的默认值(base)。
117
+ const composition = normalizeConfig(rawConfig)
118
+ const runtime = { scope: undefined }
119
+
120
+ // 运行时设置:宿主注册同名 namespace 后,浏览器卡片才能被派发;
121
+ // 部署里没有 settings 提供方时这段不会跑,一切照组合配置。
122
+ ctx.inject(['settings'], (settingsCtx) => {
123
+ void (async () => {
124
+ try {
125
+ const schemastery = await import('@deepseek-ai/schemastery')
126
+ const z = schemastery.default ?? schemastery
127
+ runtime.scope = settingsCtx.settings.register(SETTINGS_NAMESPACE, handoverSchema(z), { base: composition })
128
+ const resolved = resolveSettings(runtime, composition)
129
+ console.log(
130
+ '[dsh-context-actions] settings namespace registered: ' +
131
+ SETTINGS_NAMESPACE +
132
+ ' | handover.mode=' +
133
+ resolved.mode +
134
+ (resolved.provider === '' ? '' : ' | provider=' + resolved.provider + '/' + resolved.model),
135
+ )
136
+ } catch (error) {
137
+ console.warn('[dsh-context-actions] settings namespace 注册失败,继续使用组合配置:' + describeError(error))
138
+ }
139
+ })()
140
+ })
141
+
142
+ ctx.commands.register({
143
+ name: 'handover',
144
+ description: '生成交接文档到临时目录,供新会话接续(脚本节选 / 模型总结,见 设置→插件)',
145
+ handler: async ({ agent, rawInput, signal }) => {
146
+ const parsed = parseArgs(typeof rawInput === 'string' ? rawInput.trim() : '')
147
+ const settings = resolveSettings(runtime, composition)
148
+ const mode = parsed.mode ?? settings.mode
149
+ try {
150
+ if (parsed.text === '--where') {
151
+ await mkdir(HANDOVER_DIR, { recursive: true })
152
+ return { kind: 'success', text: JSON.stringify({ dir: HANDOVER_DIR, mode }) }
153
+ }
154
+
155
+ const explicit = /^--write\s+([\s\S]+)$/.exec(parsed.text)
156
+ let target
157
+ if (explicit !== null) {
158
+ target = path.resolve(stripQuotes(explicit[1].trim()))
159
+ if (!isInside(HANDOVER_DIR, target)) {
160
+ return { kind: 'error', text: '拒绝写入:交接文档必须位于 ' + HANDOVER_DIR + ' 之内。' }
161
+ }
162
+ } else if (parsed.text === '') {
163
+ await mkdir(HANDOVER_DIR, { recursive: true })
164
+ target = path.join(HANDOVER_DIR, autoFileName(agent.id))
165
+ } else {
166
+ return { kind: 'error', text: USAGE }
167
+ }
168
+
169
+ return { kind: 'success', text: await writeDocument(ctx, agent, target, { mode, settings, signal }) }
170
+ } catch (error) {
171
+ return { kind: 'error', text: '交接文档生成失败:' + describeError(error) }
172
+ }
173
+ },
174
+ })
175
+ }
176
+
177
+ /* ------------------------------------------------------------------ *
178
+ * 参数与配置
179
+ * ------------------------------------------------------------------ */
180
+
181
+ /**
182
+ * 取出 --llm / --mechanical 开关,返回剩余的命令行文本。
183
+ * @param input - 命令名之后的原始输入(已 trim)。
184
+ * @returns 本次调用的模式覆盖(可能为 undefined)与剩余文本。
185
+ */
186
+ function parseArgs(input) {
187
+ let mode
188
+ let text = input
189
+ if (/(?:^|\s)--llm(?=\s|$)/.test(text)) {
190
+ mode = 'llm'
191
+ text = text.replace(/(?:^|\s)--llm(?=\s|$)/, ' ').trim()
192
+ }
193
+ if (/(?:^|\s)--mechanical(?=\s|$)/.test(text)) {
194
+ mode = 'mechanical'
195
+ text = text.replace(/(?:^|\s)--mechanical(?=\s|$)/, ' ').trim()
196
+ }
197
+ return { mode, text }
198
+ }
199
+
200
+ /**
201
+ * 归一化配置:只接受已知字段,非法值退回默认。
202
+ * @param rawConfig - loader 传入的配置对象。
203
+ * @returns 完整配置。
204
+ */
205
+ function normalizeConfig(rawConfig) {
206
+ const root = rawConfig !== null && typeof rawConfig === 'object' ? rawConfig : {}
207
+ const input = root.handover !== null && typeof root.handover === 'object' ? root.handover : {}
208
+ const string = (value, fallback) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback)
209
+ const number = (value, fallback) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback)
210
+ const bool = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
211
+ const mode = string(input.mode, DEFAULTS.mode)
212
+ return {
213
+ mode: mode === 'llm' ? 'llm' : 'mechanical',
214
+ provider: string(input.provider, DEFAULTS.provider),
215
+ model: string(input.model, DEFAULTS.model),
216
+ // 提示词按原文保留(含换行),只有全空白才算「没写」。
217
+ prompt: typeof input.prompt === 'string' && input.prompt.trim() !== '' ? input.prompt : DEFAULTS.prompt,
218
+ maxOutputTokens: number(input.maxOutputTokens, DEFAULTS.maxOutputTokens),
219
+ }
220
+ }
221
+
222
+ /**
223
+ * 设置 namespace 的 schema:与浏览器卡片字段一一对应。
224
+ * 每个字段都有默认值,所以「未设置」等价于组合层默认。
225
+ * @param z - schemastery 模块。
226
+ * @returns namespace schema。
227
+ */
228
+ function handoverSchema(z) {
229
+ return z.object({
230
+ mode: z.union(['mechanical', 'llm']).default('mechanical'),
231
+ provider: z.string().default(''),
232
+ model: z.string().default(''),
233
+ prompt: z.string().default(DEFAULT_SUMMARY_PROMPT),
234
+ maxOutputTokens: z.number().default(2000),
235
+
236
+ })
237
+ }
238
+
239
+ /**
240
+ * 读取当前生效配置:运行时设置(设置页保存的值)优先,读不到就退回组合配置。
241
+ * 每次命令调用都读一次,所以设置页保存后无需重启。
242
+ * @param runtime - 保存 settings scope 的容器。
243
+ * @param fallback - 组合层配置。
244
+ * @returns 归一化后的配置。
245
+ */
246
+ function resolveSettings(runtime, fallback) {
247
+ const scope = runtime === undefined ? undefined : runtime.scope
248
+ if (scope === undefined || typeof scope.get !== 'function') return fallback
249
+ try {
250
+ const value = scope.get()
251
+ if (value === null || typeof value !== 'object') return fallback
252
+ return normalizeConfig({ handover: value })
253
+ } catch (error) {
254
+ console.warn('[dsh-context-actions] 读取设置失败,使用组合配置:' + describeError(error))
255
+ return fallback
256
+ }
257
+ }
258
+
259
+ /** 自动文件名:handover-<会话短id>-<YYYYMMDD-HHmmss>.md。 */
260
+ function autoFileName(sessionId) {
261
+ return 'handover-' + shortId(sessionId) + '-' + stamp(new Date()) + '.md'
262
+ }
263
+
264
+ /* ------------------------------------------------------------------ *
265
+ * 文档生成
266
+ * ------------------------------------------------------------------ */
267
+
268
+ /**
269
+ * 生成并落盘一份交接文档。
270
+ * @param ctx - 宿主 Cordis 上下文。
271
+ * @param agent - 触发命令的 agent(其 id 即会话 id)。
272
+ * @param target - 目标绝对路径。
273
+ * @param plan - 模式、配置与调用方取消信号。
274
+ * @returns 面向用户的结果说明。
275
+ */
276
+ async function writeDocument(ctx, agent, target, plan) {
277
+ const built = await buildDocument(ctx, agent, plan)
278
+ await mkdir(path.dirname(target), { recursive: true })
279
+ await writeFile(target, built.markdown, 'utf8')
280
+ const parts = [
281
+ '交接文档已生成(' + describeGeneration(built) + '):' + target,
282
+ '\n(来源会话 ' +
283
+ agent.id +
284
+ ',' +
285
+ built.stats.messages +
286
+ ' 条消息 / ' +
287
+ built.stats.toolCalls +
288
+ ' 次工具调用 / ' +
289
+ built.stats.chars +
290
+ ' 字符',
291
+ ]
292
+ if (built.summary !== undefined) {
293
+ parts.push(
294
+ ',总结模型 ' +
295
+ built.summary.route +
296
+ ',喂入 ' +
297
+ built.stats.fedChars +
298
+ ' 字符' +
299
+ (built.stats.omittedBlocks > 0 ? '(省略最早 ' + built.stats.omittedBlocks + ' 条)' : '(完整上下文)') +
300
+ ',输入 ' +
301
+ tokenText(built.summary.usage, 'inputTokens') +
302
+ ' / 输出 ' +
303
+ tokenText(built.summary.usage, 'outputTokens') +
304
+ ' tokens,耗时 ' +
305
+ Math.round(built.summary.ms / 1000) +
306
+ 's',
307
+ )
308
+ }
309
+ if (built.warning !== undefined) parts.push(';模型总结失败,已回退脚本节选:' + built.warning)
310
+ parts.push(')')
311
+ return parts.join('')
312
+ }
313
+
314
+ /** 生成方式的人话描述。 */
315
+ function describeGeneration(built) {
316
+ if (built.summary !== undefined) return built.summary.truncated ? '模型总结,输出被 maxTokens 截断' : '模型总结'
317
+ if (built.mode === 'llm') return '脚本节选(模型总结回退)'
318
+ return '脚本节选'
319
+ }
320
+
321
+ /**
322
+ * 构建交接文档:机械折叠 + 可选的模型总结。
323
+ * @param ctx - 宿主 Cordis 上下文。
324
+ * @param agent - 触发命令的 agent。
325
+ * @param plan - 模式、配置与取消信号。
326
+ * @returns Markdown 正文、统计、总结结果与回退告警。
327
+ */
328
+ async function buildDocument(ctx, agent, plan) {
329
+ const session = ctx.sessions.get(agent.id)
330
+ const messages = session === undefined ? [] : session.deriveMessages()
331
+ const gathered = collect(messages)
332
+ const files = [...gathered.fileHits.entries()]
333
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
334
+ .slice(0, FILE_LIST_LIMIT)
335
+ const commands = dedupe(gathered.commands).slice(-COMMAND_LIST_LIMIT)
336
+ const header = session === undefined ? undefined : session.header
337
+ const route = session === undefined ? undefined : session.requestContext()
338
+ const meta = {
339
+ sessionId: agent.id,
340
+ messageCount: messages.length,
341
+ cwd: header === undefined ? undefined : header.cwd,
342
+ preset: header === undefined ? undefined : header.agentPreset,
343
+ createdAt: header === undefined ? undefined : header.createdAt,
344
+ model: route === undefined ? undefined : route.provider + '/' + route.model,
345
+ logPath: await findSessionLog(agent.id),
346
+ }
347
+
348
+ let summary
349
+ let warning
350
+ let fed
351
+ if (plan.mode === 'llm') {
352
+ try {
353
+ fed = buildTranscript(meta, messages, resolveInputBudget(session, plan.settings))
354
+ summary = await summarize(ctx, agent, session, fed.text, plan.settings, plan.signal)
355
+ } catch (error) {
356
+ warning = describeError(error)
357
+ console.warn('[dsh-context-actions] 模型总结失败,回退脚本节选:' + warning)
358
+ }
359
+ }
360
+
361
+ const markdown = render(meta, gathered, files, commands, new Date(), {
362
+ mode: plan.mode,
363
+ summary,
364
+ warning,
365
+ fed,
366
+ includeDigest: true,
367
+ })
368
+ return {
369
+ mode: plan.mode,
370
+ markdown,
371
+ summary,
372
+ warning,
373
+ fed,
374
+ stats: {
375
+ messages: messages.length,
376
+ toolCalls: gathered.toolCalls,
377
+ chars: markdown.length,
378
+ fedChars: fed === undefined ? 0 : fed.chars,
379
+ omittedBlocks: fed === undefined ? 0 : fed.omitted,
380
+ },
381
+ }
382
+ }
383
+
384
+ /**
385
+ * 构建喂给模型的「纪要材料」:会话元信息 + 从最近端截断的对话纪要。
386
+ * @param meta - 会话元信息。
387
+ * @param messages - 派生历史。
388
+ * @param settings - 归一化配置。
389
+ * @returns 纯文本材料。
390
+ */
391
+ /**
392
+ * 计算喂给模型的字符预算:用该会话最近一次请求的上下文窗口,扣掉输出上限与余量后换算字符(保守估算)。
393
+ * @param session - 当前会话(可能为 undefined)。
394
+ * @param settings - 归一化配置。
395
+ * @returns 字符预算。
396
+ */
397
+ function resolveInputBudget(session, settings) {
398
+ const context = session === undefined ? undefined : session.requestContext()
399
+ const window =
400
+ context !== undefined && typeof context.contextWindow === 'number' && context.contextWindow > 0
401
+ ? context.contextWindow
402
+ : DEFAULT_CONTEXT_TOKENS
403
+ const usableTokens = Math.max(2048, window - settings.maxOutputTokens - 1024)
404
+ // 下限只防「预算算成 0」;小窗口时宁可多裁,也不要发出超窗请求。
405
+ return Math.max(2000, Math.floor(usableTokens * CHARS_PER_TOKEN))
406
+ }
407
+
408
+ /**
409
+ * 把整段会话记录渲染成喂给模型的纯文本:单条不做截断;
410
+ * 只有总量超过预算时,才从最早的记录开始丢,必要时再截断最早的一条。
411
+ * @param meta - 会话元信息。
412
+ * @param messages - 派生历史(整段)。
413
+ * @param budgetChars - 字符预算。
414
+ * @returns 文本、字符数、省略条数与是否发生过截断。
415
+ */
416
+ function buildTranscript(meta, messages, budgetChars) {
417
+ const gathered = collect(messages, {
418
+ messageText: Number.MAX_SAFE_INTEGER,
419
+ toolResult: Number.MAX_SAFE_INTEGER,
420
+ })
421
+ const head = [
422
+ '会话元信息:',
423
+ '- 会话 id:' + meta.sessionId,
424
+ '- 工作目录:' + (typeof meta.cwd === 'string' ? meta.cwd : '未知'),
425
+ '- 模型路由:' + (typeof meta.model === 'string' ? meta.model : '未知'),
426
+ '- Agent 预设:' + (typeof meta.preset === 'string' ? meta.preset : '未知'),
427
+ '- 派生消息数:' + meta.messageCount + ',用户轮次:' + gathered.userTurns + ',工具调用:' + gathered.toolCalls,
428
+ '',
429
+ ].join('\n')
430
+
431
+ const budget = budgetChars - head.length
432
+ const kept = []
433
+ let used = 0
434
+ let omitted = 0
435
+ let truncated = false
436
+ for (let index = gathered.blocks.length - 1; index >= 0; index -= 1) {
437
+ const block = gathered.blocks[index]
438
+ const text = '### ' + block.title + '\n\n' + block.body + '\n\n'
439
+ const remaining = budget - used
440
+ if (remaining <= 400) {
441
+ omitted = index + 1
442
+ break
443
+ }
444
+ if (text.length > remaining) {
445
+ kept.push('### ' + block.title + '\n\n' + cut(block.body, Math.max(200, remaining - 40)) + '\n\n')
446
+ used = budget
447
+ omitted = index
448
+ truncated = true
449
+ break
450
+ }
451
+ used += text.length
452
+ kept.push(text)
453
+ }
454
+ kept.reverse()
455
+
456
+ const parts = [head]
457
+ if (omitted > 0) {
458
+ parts.push('(已省略最早的 ' + omitted + ' 条记录' + (truncated ? ',并截断了紧随其后的一条' : '') + ':超出本次模型的输入预算)\n')
459
+ }
460
+ parts.push(kept.length === 0 ? '(本会话还没有可总结的对话记录)' : kept.join(''))
461
+ const text = parts.join('\n')
462
+ return { text: text, chars: text.length, omitted: omitted, truncated: truncated }
463
+ }
464
+
465
+ /**
466
+ * 调用模型把纪要材料总结成交接文档正文。
467
+ * @param ctx - 宿主 Cordis 上下文。
468
+ * @param agent - 触发命令的 agent(提供会话 id 供路由/计量)。
469
+ * @param session - 当前会话(提供最近使用的模型路由)。
470
+ * @param material - 纪要材料。
471
+ * @param settings - 归一化配置。
472
+ * @param signal - 调用方取消信号(命令 RPC 的 signal)。
473
+ * @returns 摘要文本、路由、用量、耗时与截断标记。
474
+ */
475
+ async function summarize(ctx, agent, session, material, settings, signal) {
476
+ const llm = typeof ctx.get === 'function' ? ctx.get('llm') : undefined
477
+ if (llm === undefined || typeof llm.stream !== 'function') {
478
+ throw new Error('宿主没有可用的 llm 服务,无法做模型总结')
479
+ }
480
+ const target = resolveRoute(session, settings)
481
+ if (target === undefined) {
482
+ throw new Error(
483
+ '无法确定总结用的模型路由:请先在该会话发一次消息,或在插件配置里设置 handover.provider 与 handover.model',
484
+ )
485
+ }
486
+ const module = await loadLlmModule()
487
+
488
+ const controller = new AbortController()
489
+ const timer = setTimeout(
490
+ () => controller.abort(new Error('模型总结超时(' + SUMMARY_TIMEOUT_MS + 'ms)')),
491
+ SUMMARY_TIMEOUT_MS,
492
+ )
493
+ const forward = () => controller.abort(signal === undefined ? undefined : signal.reason)
494
+ if (signal !== undefined) {
495
+ if (signal.aborted) forward()
496
+ else signal.addEventListener('abort', forward)
497
+ }
498
+
499
+ const started = Date.now()
500
+ try {
501
+ const assembler = new module.BlockAssembler()
502
+ const question = module.createUserMessage({
503
+ content: [{ type: 'text', text: '以下是这次会话的完整记录:\n\n' + material }],
504
+ source: { kind: 'plugin', plugin: 'dsh-context-actions' },
505
+ })
506
+ const options = {
507
+ provider: target.provider,
508
+ model: target.model,
509
+ system: settings.prompt.trim() === '' ? DEFAULT_SUMMARY_PROMPT : settings.prompt,
510
+ messages: [question],
511
+ maxTokens: settings.maxOutputTokens,
512
+ sessionId: agent.id,
513
+ signal: controller.signal,
514
+ }
515
+ if (typeof target.reasoningEffort === 'string' && target.reasoningEffort !== '') {
516
+ options.reasoningEffort = target.reasoningEffort
517
+ }
518
+
519
+ for await (const chunk of llm.stream(options)) assembler.push(chunk)
520
+
521
+ const finish = assembler.finish
522
+ if (finish.kind === 'error') {
523
+ throw new Error('模型调用失败:' + describeFailure(finish.failure))
524
+ }
525
+ if (finish.kind === 'aborted') {
526
+ throw new Error('模型调用被取消:' + describeFailure(finish.failure))
527
+ }
528
+ const text = textOf(assembler.blocks()).trim()
529
+ if (text === '') throw new Error('模型没有输出任何文本内容')
530
+
531
+ console.log(
532
+ '[dsh-context-actions] 模型总结完成:' +
533
+ target.provider +
534
+ '/' +
535
+ target.model +
536
+ ',耗时 ' +
537
+ Math.round((Date.now() - started) / 1000) +
538
+ 's',
539
+ )
540
+ return {
541
+ text,
542
+ route: target.provider + '/' + target.model,
543
+ usage: assembler.usage,
544
+ ms: Date.now() - started,
545
+ truncated: finish.kind === 'max-tokens',
546
+ }
547
+ } finally {
548
+ clearTimeout(timer)
549
+ if (signal !== undefined) signal.removeEventListener('abort', forward)
550
+ }
551
+ }
552
+
553
+ /**
554
+ * 解析总结用的模型路由:配置优先,其次会话最近一次请求的 header / context。
555
+ * @param session - 当前会话(可能为 undefined)。
556
+ * @param settings - 归一化配置。
557
+ * @returns provider/model/reasoningEffort,或 undefined。
558
+ */
559
+ function resolveRoute(session, settings) {
560
+ // 推理档位沿用会话最近一次请求的设置(不再单独配置)。
561
+ const effort = undefined
562
+ if (settings.provider !== '' && settings.model !== '') {
563
+ return { provider: settings.provider, model: settings.model, reasoningEffort: effort }
564
+ }
565
+ if (session !== undefined) {
566
+ const header = session.requestHeader()
567
+ const config = header === undefined ? undefined : header.config
568
+ if (config !== undefined && typeof config.provider === 'string' && typeof config.model === 'string') {
569
+ return {
570
+ provider: config.provider,
571
+ model: config.model,
572
+ reasoningEffort: effort ?? (typeof config.reasoningEffort === 'string' ? config.reasoningEffort : undefined),
573
+ }
574
+ }
575
+ const context = session.requestContext()
576
+ if (context !== undefined) {
577
+ return { provider: context.provider, model: context.model, reasoningEffort: effort }
578
+ }
579
+ }
580
+ return undefined
581
+ }
582
+
583
+ /** 懒加载 dsh-llm:缺包时给出可读错误,而不是让整个插件加载失败。 */
584
+ async function loadLlmModule() {
585
+ try {
586
+ return await import('@deepseek-ai/dsh-llm')
587
+ } catch (error) {
588
+ throw new Error('无法加载 @deepseek-ai/dsh-llm(需要 dsh 自带的宿主 LLM 包):' + describeError(error))
589
+ }
590
+ }
591
+
592
+ /** 用量字段的可读文本。 */
593
+ function tokenText(usage, key) {
594
+ if (usage === null || typeof usage !== 'object') return '?'
595
+ const value = usage[key]
596
+ return typeof value === 'number' && Number.isFinite(value) ? String(value) : '?'
597
+ }
598
+
599
+ /** 提供方失败对象的可读文本。 */
600
+ function describeFailure(failure) {
601
+ if (failure === null || typeof failure !== 'object') return 'unknown'
602
+ if (typeof failure.message === 'string' && failure.message !== '') return failure.message
603
+ if (typeof failure.code === 'string' && failure.code !== '') return failure.code
604
+ return 'unknown'
605
+ }
606
+
607
+ /* ------------------------------------------------------------------ *
608
+ * 折叠规则
609
+ * ------------------------------------------------------------------ */
610
+
611
+ /**
612
+ * 把会话消息折叠成可读的段落、文件清单与命令清单。
613
+ * @param messages - session.deriveMessages() 的模型历史。
614
+ * @param limits - 可选的单条截断覆盖(LLM 模式用更大的限额)。
615
+ * @returns 折叠结果。
616
+ */
617
+ function collect(messages, limits) {
618
+ const messageLimit =
619
+ limits !== undefined && typeof limits.messageText === 'number' ? limits.messageText : MESSAGE_TEXT_LIMIT
620
+ const toolLimit =
621
+ limits !== undefined && typeof limits.toolResult === 'number' ? limits.toolResult : TOOL_RESULT_LIMIT
622
+
623
+ const blocks = []
624
+ const fileHits = new Map()
625
+ const commands = []
626
+ const callNames = new Map()
627
+ let toolCalls = 0
628
+ let userTurns = 0
629
+
630
+ for (const message of messages) {
631
+ const content = Array.isArray(message === null || message === void 0 ? void 0 : message.content)
632
+ ? message.content
633
+ : []
634
+ const source = (message === null || message === void 0 ? void 0 : message.source) ?? {}
635
+
636
+ if (message.role === 'system') continue
637
+
638
+ if (message.role === 'assistant') {
639
+ const lines = []
640
+ const text = textOf(content)
641
+ if (text !== '') lines.push(cut(collapse(text), messageLimit))
642
+ for (const block of content) {
643
+ if (block === null || typeof block !== 'object' || block.type !== 'tool-call') continue
644
+ toolCalls += 1
645
+ if (typeof block.id === 'string') callNames.set(block.id, block.name)
646
+ const args = parseJson(block.arguments)
647
+ for (const file of collectFiles(args)) {
648
+ fileHits.set(file, (fileHits.get(file) ?? 0) + 1)
649
+ }
650
+ const command = collectCommand(block.name, args)
651
+ if (command !== undefined) commands.push(command)
652
+ lines.push('- 调用工具 `' + block.name + '`' + describeArgs(args))
653
+ }
654
+ if (lines.length > 0) blocks.push({ title: '助手', body: lines.join('\n') })
655
+ continue
656
+ }
657
+
658
+ if (source.kind === 'tool') {
659
+ const result = content.find((b) => b !== null && typeof b === 'object' && b.type === 'tool-result')
660
+ const inner = result !== undefined && Array.isArray(result.content) ? result.content : content
661
+ const name = result !== undefined ? callNames.get(result.toolCallId) : undefined
662
+ const text = collapse(textOf(inner))
663
+ if (text === '') continue
664
+ blocks.push({
665
+ title:
666
+ '工具结果 · ' +
667
+ (name ?? '未知工具') +
668
+ (result !== undefined && result.isError === true ? '(失败)' : ''),
669
+ body: cut(text, toolLimit),
670
+ })
671
+ continue
672
+ }
673
+
674
+ // 插件注入的上下文:可再生的样板(指令、目录、快照)跳过,压缩检查点必须保留。
675
+ if (source.kind === 'plugin' && source.plugin !== 'compact') {
676
+ if (source.form === 'instructions' || source.form === 'catalog' || source.form === 'snapshot') continue
677
+ }
678
+
679
+ const text = textOf(content)
680
+ if (text === '') continue
681
+ userTurns += 1
682
+ const title =
683
+ source.kind === 'plugin'
684
+ ? source.plugin === 'compact'
685
+ ? '压缩检查点(上一轮压缩后的摘要)'
686
+ : '注入上下文 · ' + String(source.plugin ?? 'plugin')
687
+ : '用户'
688
+ blocks.push({ title, body: cut(collapse(text), messageLimit) })
689
+ }
690
+
691
+ return { blocks, fileHits, commands, toolCalls, userTurns }
692
+ }
693
+
694
+ /* ------------------------------------------------------------------ *
695
+ * 渲染
696
+ * ------------------------------------------------------------------ */
697
+
698
+ /**
699
+ * 渲染 Markdown 文档;超出预算时从最早的对话开始省略。
700
+ * @param meta - 会话元信息。
701
+ * @param gathered - collect() 的结果。
702
+ * @param files - 已排序的文件清单。
703
+ * @param commands - 已去重的命令清单。
704
+ * @param now - 生成时间。
705
+ * @param extra - 生成方式、模型总结、回退告警与是否附纪要。
706
+ * @returns 完整 Markdown 正文。
707
+ */
708
+ function render(meta, gathered, files, commands, now, extra) {
709
+ const summary = extra === undefined ? undefined : extra.summary
710
+ const warning = extra === undefined ? undefined : extra.warning
711
+ const fed = extra === undefined ? undefined : extra.fed
712
+ const mode = extra === undefined ? 'mechanical' : extra.mode
713
+ const includeDigest = extra === undefined ? true : extra.includeDigest !== false
714
+
715
+ const head = []
716
+ head.push('# 会话交接文档')
717
+ head.push('')
718
+ head.push(
719
+ summary !== undefined
720
+ ? '> 由 `dsh-context-actions` 插件自动生成:正文为模型对会话纪要的总结,附录为脚本节选。'
721
+ : '> 由 `dsh-context-actions` 插件自动生成:内容来自上一会话的持久事件日志,未调用模型。',
722
+ )
723
+ if (warning !== undefined) {
724
+ head.push('>')
725
+ head.push('> 注意:模型总结失败(' + warning + '),本文档为脚本节选。')
726
+ }
727
+ head.push('')
728
+ head.push('| 项 | 值 |')
729
+ head.push('| --- | --- |')
730
+ head.push('| 来源会话 | `' + meta.sessionId + '` |')
731
+ head.push('| 生成方式 | ' + describeGeneration({ mode, summary }) + ' |')
732
+ head.push('| 工作目录 | ' + code(meta.cwd) + ' |')
733
+ head.push('| 模型 | ' + code(meta.model) + ' |')
734
+ head.push('| Agent 预设 | ' + code(meta.preset) + ' |')
735
+ head.push('| 会话创建时间 | ' + code(meta.createdAt === undefined ? undefined : new Date(meta.createdAt).toISOString()) + ' |')
736
+ head.push(
737
+ '| 会话消息 / 用户轮次 / 工具调用 | ' +
738
+ meta.messageCount +
739
+ ' / ' +
740
+ gathered.userTurns +
741
+ ' / ' +
742
+ gathered.toolCalls +
743
+ ' |',
744
+ )
745
+ if (summary !== undefined) {
746
+ head.push(
747
+ '| 总结模型 / 用量 / 耗时 | ' +
748
+ code(summary.route) +
749
+ ' / 输入 ' +
750
+ tokenText(summary.usage, 'inputTokens') +
751
+ ' + 输出 ' +
752
+ tokenText(summary.usage, 'outputTokens') +
753
+ ' tokens / ' +
754
+ Math.round(summary.ms / 1000) +
755
+ 's |',
756
+ )
757
+ }
758
+ if (fed !== undefined) {
759
+ head.push(
760
+ '| 喂给模型 | ' +
761
+ fed.chars +
762
+ ' 字符' +
763
+ (fed.omitted > 0 ? '(省略最早 ' + fed.omitted + ' 条记录)' : '(整段上下文)') +
764
+ ' |',
765
+ )
766
+ }
767
+ head.push('| 交接文档生成时间 | ' + new Date(now.getTime()).toISOString() + ' |')
768
+ head.push('| 原始事件日志 | ' + code(meta.logPath) + ' |')
769
+ head.push('')
770
+
771
+ const tail = []
772
+ tail.push('## 涉及的文件')
773
+ tail.push('')
774
+ if (files.length === 0) {
775
+ tail.push('(本次会话没有记录到文件路径参数)')
776
+ } else {
777
+ for (const entry of files) tail.push('- `' + entry[0] + '` ×' + entry[1])
778
+ }
779
+ tail.push('')
780
+ tail.push('## 执行过的命令')
781
+ tail.push('')
782
+ if (commands.length === 0) {
783
+ tail.push('(本次会话没有记录到 shell 命令)')
784
+ } else {
785
+ for (const command of commands) tail.push('- `' + cut(command, 200) + '`')
786
+ }
787
+ tail.push('')
788
+ tail.push('## 交给接手会话')
789
+ tail.push('')
790
+ tail.push('你正在接手上面这个会话。请:')
791
+ tail.push('')
792
+ if (summary !== undefined) {
793
+ tail.push('1. 先通读上面的交接摘要;需要原始细节时,看附录纪要或按上表路径解压原始日志检索。')
794
+ } else {
795
+ tail.push('1. 先通读本文档;如需细节,可按上表路径解压原始日志检索。')
796
+ }
797
+ tail.push('2. 用 3-5 行复述你对当前任务状态的理解。')
798
+ tail.push('3. 给出下一步计划,并直接继续推进工作。')
799
+ tail.push('')
800
+
801
+ const summarySection =
802
+ summary === undefined ? '' : '## 交接摘要(模型生成)\n\n' + summary.text.trim() + '\n'
803
+ const headText = head.join('\n')
804
+ const tailText = tail.join('\n')
805
+ let budget = MAX_DOC_CHARS - headText.length - tailText.length - summarySection.length
806
+ if (budget < 1000) budget = 1000
807
+
808
+ const kept = []
809
+ let used = 0
810
+ let omitted = 0
811
+ for (let index = gathered.blocks.length - 1; index >= 0; index -= 1) {
812
+ const block = gathered.blocks[index]
813
+ const text = '### ' + block.title + '\n\n' + block.body + '\n\n'
814
+ if (used + text.length > budget && kept.length > 0) {
815
+ omitted = index + 1
816
+ break
817
+ }
818
+ used += text.length
819
+ kept.push(text)
820
+ }
821
+ kept.reverse()
822
+
823
+ const body = []
824
+ body.push(summarySection)
825
+ if (includeDigest) {
826
+ body.push(summary === undefined ? '## 对话记录(按时间顺序,越靠后越新)' : '## 附:对话纪要(脚本节选,按时间顺序)')
827
+ body.push('')
828
+ if (omitted > 0) {
829
+ body.push('> 本文档已省略最早的 ' + omitted + ' 条记录(超出体积预算);完整历史见原始事件日志。')
830
+ body.push('')
831
+ }
832
+ if (kept.length === 0) {
833
+ body.push('(本次会话还没有可交接的对话记录)')
834
+ body.push('')
835
+ } else {
836
+ body.push(kept.join(''))
837
+ }
838
+ } else {
839
+ body.push('')
840
+ }
841
+
842
+ return headText + '\n' + body.join('\n') + '\n' + tailText
843
+ }
844
+
845
+ /* ------------------------------------------------------------------ *
846
+ * 小工具
847
+ * ------------------------------------------------------------------ */
848
+
849
+ /** 取内容块里的纯文本。 */
850
+ function textOf(content) {
851
+ const parts = []
852
+ for (const block of content) {
853
+ if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
854
+ parts.push(block.text)
855
+ }
856
+ }
857
+ return parts.join('\n').trim()
858
+ }
859
+
860
+ /** 压掉多余空白,便于阅读。 */
861
+ function collapse(text) {
862
+ return String(text)
863
+ .replace(/[ \t]+/g, ' ')
864
+ .replace(/\n{3,}/g, '\n\n')
865
+ .trim()
866
+ }
867
+
868
+ /** 截断长文本。 */
869
+ function cut(text, limit) {
870
+ const value = String(text)
871
+ return value.length <= limit ? value : value.slice(0, limit) + ' …(已截断)'
872
+ }
873
+
874
+ /** 容错解析工具参数 JSON。 */
875
+ function parseJson(raw) {
876
+ if (typeof raw !== 'string' || raw.trim() === '') return undefined
877
+ try {
878
+ return JSON.parse(raw)
879
+ } catch {
880
+ return undefined
881
+ }
882
+ }
883
+
884
+ /** 从工具参数里挑出文件/目录路径。 */
885
+ function collectFiles(args) {
886
+ const found = new Set()
887
+ const keys = [
888
+ 'file_path',
889
+ 'filePath',
890
+ 'path',
891
+ 'paths',
892
+ 'target_file',
893
+ 'targetFile',
894
+ 'notebook_path',
895
+ 'absolute_path',
896
+ 'abspath',
897
+ 'cwd',
898
+ 'dir',
899
+ 'directory',
900
+ 'root',
901
+ ]
902
+ if (args === null || typeof args !== 'object') return found
903
+ for (const key of keys) {
904
+ const value = args[key]
905
+ if (typeof value === 'string' && value.trim() !== '') found.add(value.trim())
906
+ else if (Array.isArray(value)) {
907
+ for (const item of value) if (typeof item === 'string' && item.trim() !== '') found.add(item.trim())
908
+ }
909
+ }
910
+ return found
911
+ }
912
+
913
+ /** 从工具调用里挑出真正的 shell 命令。 */
914
+ function collectCommand(toolName, args) {
915
+ if (typeof toolName !== 'string' || !/bash|pwsh|powershell|shell|terminal|exec/i.test(toolName)) return undefined
916
+ if (args === null || typeof args !== 'object') return undefined
917
+ const command = args.command ?? args.cmd ?? args.script
918
+ return typeof command === 'string' && command.trim() !== '' ? collapse(command) : undefined
919
+ }
920
+
921
+ /** 给工具调用补一句参数摘要。 */
922
+ function describeArgs(args) {
923
+ if (args === null || typeof args !== 'object') return ''
924
+ const keys = collectFiles(args)
925
+ const first = [...keys][0]
926
+ const command = args.command ?? args.cmd ?? args.script
927
+ if (typeof command === 'string' && command.trim() !== '') return ':`' + cut(collapse(command), 160) + '`'
928
+ if (first !== undefined) return ':`' + first + '`'
929
+ const pattern = args.pattern ?? args.query
930
+ if (typeof pattern === 'string' && pattern.trim() !== '') return ':`' + cut(collapse(pattern), 120) + '`'
931
+ return ''
932
+ }
933
+
934
+ /** 数组去重(保持原顺序)。 */
935
+ function dedupe(values) {
936
+ return [...new Set(values)]
937
+ }
938
+
939
+ /** Markdown 行内代码;缺值时给出占位符。 */
940
+ function code(value) {
941
+ return typeof value === 'string' && value !== '' ? '`' + value + '`' : '—'
942
+ }
943
+
944
+ /** 去掉用户输入两端可能带的引号。 */
945
+ function stripQuotes(value) {
946
+ const match = /^(["'])([\s\S]*)\1$/.exec(value)
947
+ return match === null ? value : match[2]
948
+ }
949
+
950
+ /** 判断 target 是否落在 dir 之内。 */
951
+ function isInside(dir, target) {
952
+ const relative = path.relative(path.resolve(dir), path.resolve(target))
953
+ if (relative === '') return true
954
+ if (relative.startsWith('..')) return false
955
+ return !path.isAbsolute(relative)
956
+ }
957
+
958
+ /** 会话 id 的短标识(取 UUID 前 8 位)。 */
959
+ function shortId(sessionId) {
960
+ const match = /([0-9a-f]{8})-[0-9a-f]{4}-/i.exec(String(sessionId))
961
+ return match === null ? String(sessionId).slice(-8) : match[1]
962
+ }
963
+
964
+ /** 文件名用时间戳:YYYYMMDD-HHmmss。 */
965
+ function stamp(date) {
966
+ const pad = (value) => String(value).padStart(2, '0')
967
+ return (
968
+ String(date.getFullYear()) +
969
+ pad(date.getMonth() + 1) +
970
+ pad(date.getDate()) +
971
+ '-' +
972
+ pad(date.getHours()) +
973
+ pad(date.getMinutes()) +
974
+ pad(date.getSeconds())
975
+ )
976
+ }
977
+
978
+ /** 定位会话的持久化事件日志(尽力而为:找不到就返回 undefined)。 */
979
+ async function findSessionLog(sessionId) {
980
+ try {
981
+ const home = await resolveHome()
982
+ const root = path.join(home, 'sessions')
983
+ const entries = await readdir(root, { withFileTypes: true })
984
+ for (const entry of entries) {
985
+ if (!entry.isDirectory()) continue
986
+ const dir = path.join(root, entry.name, sessionId)
987
+ let files
988
+ try {
989
+ files = await readdir(dir)
990
+ } catch {
991
+ continue
992
+ }
993
+ const hit = files.find(
994
+ (file) => file.startsWith('session.') && (file.endsWith('.jsonl') || file.endsWith('.jsonl.zstd')),
995
+ )
996
+ if (hit !== undefined) return path.join(dir, hit)
997
+ }
998
+ } catch {
999
+ // 日志路径只是加分项,失败不影响交接。
1000
+ }
1001
+ return undefined
1002
+ }
1003
+
1004
+ /** 解析 DSH home(优先官方 helper,其次 $DSH_HOME,最后 ~/.dsh)。 */
1005
+ async function resolveHome() {
1006
+ try {
1007
+ const mod = await import('@deepseek-ai/dsh-home-paths')
1008
+ if (typeof mod.resolveDshHome === 'function') return mod.resolveDshHome()
1009
+ } catch {
1010
+ // 框架包不可用时退回环境变量。
1011
+ }
1012
+ const env = process.env.DSH_HOME
1013
+ if (typeof env === 'string' && env.trim() !== '') return env.trim()
1014
+ return path.join(homedir(), '.dsh')
1015
+ }
1016
+
1017
+ /** 稳定的错误描述。 */
1018
+ function describeError(error) {
1019
+ if (error instanceof Error && error.message !== '') return error.message
1020
+ return String(error)
1021
+ }