dsh-plugin-mind-tree 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1020 @@
1
+ // dsh-plugin-mind-tree 宿主半区:基于 sessionQuery 的对话分支分析引擎。
2
+ // Copyright (c) 2026 ShOpenMind · https://github.com/ShOpenMind/dsh-plugin-mind-tree · MIT
3
+ // 只读服务:不写入任何会话,不发起任何模型调用。
4
+ // 另注册 mind_tree 工具,让终端/GUI 里的 agent 也能直接导出思维树(不依赖网页客户端)。
5
+ import { buildExport } from './format.js';
6
+ import { reduceGraph, DETAIL_LEVELS } from './reduce.js';
7
+ import { costOfTree, formatCny } from './pricing.js';
8
+
9
+ export const name = 'mindTree';
10
+ export const inject = ['sessionQuery'];
11
+
12
+ const FOLLOW_QUEUE_LIMIT = 256;
13
+ /** 工具返回内容的字符上限(超过则截断并提示改用 writeTo 写文件)。 */
14
+ const TOOL_CONTENT_LIMIT = 40000;
15
+ /** 会话查询服务中本插件依赖的方法。 */
16
+ const REQUIRED_SESSION_QUERY_METHODS = [
17
+ 'readSession', 'traceSession', 'readEvent', 'filterSessions', 'readTitle', 'readTitleSnapshots',
18
+ ];
19
+ /** 导出格式 → 视图限制(血缘/森林没有轮次结构,不支持导图/画布)。 */
20
+ const TREE_ONLY_FORMATS = new Set(['mindmap', 'canvas']);
21
+
22
+ /** 宿主侧文案(与客户端一致的关键标签,保证导出一致)。 */
23
+ const HOST_LABELS = {
24
+ kindLabel: (kind) => ({
25
+ turn: '轮次', user: '用户', assistant: '助手', tool: '工具', result: '结果', session: '会话',
26
+ retry: '重试', approval: '批准', goal: '目标', todo: '待办', plan: '计划', compaction: '压缩',
27
+ team: '团队', info: '信息',
28
+ }[kind] ?? kind),
29
+ branchLabel: (kind) => ({
30
+ replace: '替换分支', retry: '生成重试', question: '用户选择', approval: '批准决策',
31
+ spawn: '派生子代理', fork: '会话分叉', todo: '任务分解', goal: '目标变化', plan: '计划模式',
32
+ compaction: '上下文压缩', team: '团队协作', error: '出错',
33
+ }[kind] ?? kind),
34
+ };
35
+
36
+ // 会产生子会话的"派生"工具。
37
+ const SPAWN_TOOLS = new Set(['subagent', 'subagent_fork', 'workflow', 'spawn_teammate']);
38
+ // 与分支语义直接相关的工具。
39
+ const BRANCH_TOOLS = new Map([
40
+ ['ask_user_question', 'question'],
41
+ ['create_goal', 'goal'],
42
+ ['update_goal', 'goal'],
43
+ ['todo_write', 'todo'],
44
+ ['exit_plan_mode', 'plan'],
45
+ ['interrupt_agent', 'spawn'],
46
+ ['send_message', 'spawn'],
47
+ ]);
48
+
49
+ function clamp(value, min, max) {
50
+ return Math.max(min, Math.min(max, value));
51
+ }
52
+
53
+ /** 折叠空白并把文本截断到 max 个字符。 */
54
+ export function squash(text, max = 240) {
55
+ if (typeof text !== 'string') return '';
56
+ const flat = text.replace(/\s+/g, ' ').trim();
57
+ if (flat.length <= max) return flat;
58
+ return `${flat.slice(0, max - 1)}…`;
59
+ }
60
+
61
+ /** 从消息内容块提取可读文本。 */
62
+ export function messageText(message) {
63
+ const blocks = Array.isArray(message?.content) ? message.content : [];
64
+ const parts = [];
65
+ for (const block of blocks) {
66
+ if (!block || typeof block !== 'object') continue;
67
+ switch (block.type) {
68
+ case 'text': if (typeof block.text === 'string') parts.push(block.text); break;
69
+ case 'reasoning': if (typeof block.text === 'string') parts.push(block.text); break;
70
+ case 'tool-call': parts.push(`〔工具:${block.name ?? ''}〕`); break;
71
+ case 'image': parts.push('〔图片〕'); break;
72
+ case 'file': parts.push(`〔文件:${block.attachment?.name ?? ''}〕`); break;
73
+ default: break;
74
+ }
75
+ }
76
+ return parts.join('\n');
77
+ }
78
+
79
+ /** 尽力解析 JSON(失败返回 undefined)。 */
80
+ function tryJson(raw) {
81
+ if (typeof raw !== 'string') return raw;
82
+ try { return JSON.parse(raw); } catch { return undefined; }
83
+ }
84
+
85
+ /** 把任意值压缩成可读摘要。 */
86
+ function summarize(value, max = 160) {
87
+ if (value === undefined || value === null) return '';
88
+ if (typeof value === 'string') return squash(value, max);
89
+ if (typeof value !== 'object') return squash(String(value), max);
90
+ try { return squash(JSON.stringify(value), max); } catch { return ''; }
91
+ }
92
+
93
+ /** 工具调用节点的标签。 */
94
+ function toolLabel(name, argumentsRaw) {
95
+ const args = tryJson(argumentsRaw);
96
+ if (name === 'ask_user_question') {
97
+ const questions = Array.isArray(args?.questions) ? args.questions : [];
98
+ if (questions.length > 0) {
99
+ const q = questions[0];
100
+ const qlabel = typeof q?.question === 'string' ? squash(q.question, 90) : '';
101
+ const options = Array.isArray(q?.options) ? q.options.map((o) => o?.label ?? '').filter(Boolean).join(' / ') : '';
102
+ return `提问:${qlabel}${options ? ` [${squash(options, 80)}]` : ''}`;
103
+ }
104
+ }
105
+ if (SPAWN_TOOLS.has(name)) {
106
+ if (name === 'workflow' && typeof args?.meta?.name === 'string') return `工作流:${args.meta.name}`;
107
+ const prompt = typeof args?.prompt === 'string' ? squash(args.prompt, 100) : (typeof args?.description === 'string' ? squash(args.description, 100) : '');
108
+ const label = prompt || args?.label || '';
109
+ return `${name}${label ? `:${label}` : ''}`;
110
+ }
111
+ const quick = args?.file_path ?? args?.path ?? args?.pattern ?? args?.name ?? args?.title;
112
+ if (typeof quick === 'string' && quick) return `${name} ${squash(quick, 100)}`;
113
+ return name;
114
+ }
115
+
116
+ /** 问询问题的选项列表。 */
117
+ function questionOptions(argumentsRaw) {
118
+ const args = tryJson(argumentsRaw);
119
+ const questions = Array.isArray(args?.questions) ? args.questions : [];
120
+ return questions.map((q) => ({
121
+ question: typeof q?.question === 'string' ? squash(q.question, 120) : '',
122
+ options: Array.isArray(q?.options) ? q.options.map((o) => ({
123
+ label: typeof o?.label === 'string' ? squash(o.label, 60) : '',
124
+ description: typeof o?.description === 'string' ? squash(o.description, 80) : undefined,
125
+ })) : [],
126
+ multiSelect: q?.multiSelect === true,
127
+ }));
128
+ }
129
+
130
+ /** todo_write 的任务树。 */
131
+ function todoList(argumentsRaw) {
132
+ const args = tryJson(argumentsRaw);
133
+ const todos = Array.isArray(args?.todos) ? args.todos : [];
134
+ return todos.map((t) => ({
135
+ status: t?.status ?? 'pending',
136
+ content: typeof t?.content === 'string' ? squash(t.content, 120) : '',
137
+ }));
138
+ }
139
+
140
+ // ---- 轮次摘要(确定性启发式,不调用模型) ----
141
+
142
+ /** 注入型消息的开头特征(运行时上下文、系统提示等,不作为摘要来源)。 */
143
+ const INJECTED_PREFIXES = [
144
+ 'Current runtime context',
145
+ 'You are an AI agent',
146
+ 'You are a ',
147
+ 'Image "',
148
+ 'Tokens prefixed',
149
+ '<system-reminder>',
150
+ 'The user has',
151
+ 'Note:',
152
+ 'System:',
153
+ 'Developer:',
154
+ ];
155
+
156
+ /** 该文本是否像"注入的上下文"而非用户本人的请求。 */
157
+ export function looksInjected(text) {
158
+ if (typeof text !== 'string') return true;
159
+ const trimmed = text.trim();
160
+ if (trimmed === '') return true;
161
+ for (const prefix of INJECTED_PREFIXES) if (trimmed.startsWith(prefix)) return true;
162
+ // 长且几乎全英文的内容通常是注入的上下文。
163
+ const chars = [...trimmed];
164
+ if (chars.length > 60 && cjkRatio(trimmed) < 0.05) return true;
165
+ return false;
166
+ }
167
+
168
+ /** 文本中的中日韩字符占比。 */
169
+ export function cjkRatio(text) {
170
+ const chars = [...String(text ?? '')];
171
+ if (chars.length === 0) return 0;
172
+ let cjk = 0;
173
+ for (const ch of chars) {
174
+ const code = ch.codePointAt(0);
175
+ if ((code >= 0x3000 && code <= 0x9fff) || (code >= 0xf900 && code <= 0xfaff)) cjk += 1;
176
+ }
177
+ return cjk / chars.length;
178
+ }
179
+
180
+ const POLITE_PREFIXES = [
181
+ '我想要做一个', '我想要做个', '我想要', '我想做个', '我想做', '我希望', '我需要', '我想',
182
+ '能不能帮我', '能不能', '可以帮我', '可以', '帮我', '请你', '请帮忙', '请', '麻烦', '你好',
183
+ '非常不错', '太好了', '很好', '好的', '不错', '谢谢', '完美', '收到', '明白', '了解',
184
+ '现在', '接下来', '然后', '另外', '顺便', '还有', '就是', '一个', '一款', '一件',
185
+ ];
186
+
187
+ /** 去掉内容块占位标记(〔图片〕〔文件:…〕等),摘要只保留真正的文字。 */
188
+ export function summarySourceOf(text) {
189
+ return String(text ?? '')
190
+ .replace(/〔[^〕]*〕/g, ' ')
191
+ .replace(/\s+/g, ' ')
192
+ .trim();
193
+ }
194
+
195
+ /**
196
+ * 生成 6–15 字的轮次摘要:清理 markdown/代码/链接,去掉客套前缀,取首个短句。
197
+ * @param raw - 该轮首条真实用户消息(或回退的助手文本)。
198
+ * @returns 摘要文本;无法提取时返回空串。
199
+ */
200
+ export function summarizeTurn(raw) {
201
+ if (typeof raw !== 'string') return '';
202
+ let text = raw
203
+ .replace(/```[\s\S]*?```/g, ' ')
204
+ .replace(/`[^`]*`/g, ' ')
205
+ .replace(/!?\[[^\]]*\]\([^)]*\)/g, ' ')
206
+ .replace(/https?:\/\/\S+/g, ' ')
207
+ .replace(/^[#>*\-\s]+/gm, '')
208
+ .replace(/[\r\n\t]+/g, ' ')
209
+ .replace(/\s+/g, ' ')
210
+ .trim();
211
+ // 交错剥离:前导标点 → 客套前缀 → 再前导标点……直到稳定。
212
+ const LEADING_PUNCT = /^[,,、。::;;!!??\s"'“”「」【】]+/;
213
+ let changed = true;
214
+ while (changed) {
215
+ changed = false;
216
+ const before = text;
217
+ text = text.replace(LEADING_PUNCT, '').trim();
218
+ for (const prefix of POLITE_PREFIXES) {
219
+ if (text.startsWith(prefix) && [...text].length > [...prefix].length + 3) {
220
+ text = text.slice(prefix.length).trim();
221
+ break;
222
+ }
223
+ }
224
+ if (text !== before) changed = true;
225
+ }
226
+ // 首个短句(断句前需已有至少 6 字,避免切得太碎)。
227
+ const end = text.search(/[。!?!?;;::,,、"“”「」]/);
228
+ if (end >= 6) text = text.slice(0, end);
229
+ const chars = [...text];
230
+ if (chars.length === 0) return '';
231
+ const MAX = 15;
232
+ const MIN = 6;
233
+ if (chars.length <= MAX) return text;
234
+ let cut = MAX;
235
+ for (let i = MAX; i >= MIN; i -= 1) {
236
+ if (',,、。::;;!?!?))】]」”'.includes(chars[i - 1])) { cut = i; break; }
237
+ }
238
+ return `${chars.slice(0, cut).join('')}…`;
239
+ }
240
+
241
+ /** 会话记录 → 节点 DTO。 */
242
+ function sessionNode(record, title) {
243
+ const header = record.header ?? {};
244
+ return {
245
+ sessionId: String(header.id ?? record.sessionId ?? ''),
246
+ ...(title ? { title } : {}),
247
+ parentSessionId: header.parentSession ? String(header.parentSession) : null,
248
+ origin: header.origin === 'subagent' ? 'subagent' : header.parentSession ? 'fork' : null,
249
+ delegationDepth: typeof header.delegationDepth === 'number' ? header.delegationDepth : undefined,
250
+ live: record.live === true,
251
+ persisted: record.persisted === true,
252
+ createdAt: typeof header.createdAt === 'number' ? header.createdAt : 0,
253
+ ...(header.cwd ? { cwd: header.cwd } : {}),
254
+ };
255
+ }
256
+
257
+ /** 会话血统树内的边。 */
258
+ function lineageEdge(parent, child, spawn) {
259
+ const kind = child.origin === 'subagent' ? 'subagent' : 'fork';
260
+ return {
261
+ from: parent.sessionId,
262
+ to: child.sessionId,
263
+ kind,
264
+ ...(spawn ? { spawnSeq: spawn.seq, spawnLabel: spawn.label } : {}),
265
+ };
266
+ }
267
+
268
+ /** 每个"派生"工具调用与其生成的子会话的时间匹配窗口(毫秒)。 */
269
+ const SPAWN_MATCH_WINDOW_MS = 10 * 60 * 1000;
270
+ /** 单次"替换"最多逐条标记多少个旧节点;超过视为整段重写,只记录分支不逐个标记。 */
271
+ const MAX_REPLACE_TARGETS = 64;
272
+
273
+ export function apply(ctx) {
274
+ const sq = ctx.get('sessionQuery');
275
+ if (sq === undefined) throw new Error('mindTree: sessionQuery 服务不可用');
276
+
277
+ // ---- 能力探测:DSH 版本变化导致服务契约缺失时,给出明确提示而不是静默失效 ----
278
+ const warnings = [];
279
+ const missingMethods = REQUIRED_SESSION_QUERY_METHODS.filter((method) => typeof sq[method] !== 'function');
280
+ if (missingMethods.length > 0) {
281
+ warnings.push(`sessionQuery 缺少方法 ${missingMethods.join(', ')}(DSH 版本可能已变化,分析功能可能不完整)`);
282
+ }
283
+ /** 工具注册结果:供 warnings 上报(注册失败时不再静默)。 */
284
+ let toolState = 'pending';
285
+ for (const warning of warnings) {
286
+ try { (ctx.logger?.warn ?? console.warn)(`[mind-tree] ${warning}`); } catch { /* 忽略 */ }
287
+ }
288
+
289
+ const readTitleMap = async (ids, signal) => {
290
+ const map = new Map();
291
+ if (ids.length === 0) return map;
292
+ try {
293
+ const results = await sq.readTitleSnapshots(ids, signal);
294
+ for (const result of results ?? []) {
295
+ if (result?.status === 'fulfilled' && result.value?.title?.title) {
296
+ map.set(String(result.value.session?.id ?? ''), result.value.title.title);
297
+ }
298
+ }
299
+ } catch { /* 标题尽力而为 */ }
300
+ return map;
301
+ };
302
+
303
+ /** 全量会话森林。 */
304
+ const forest = async (signal) => {
305
+ const records = (await sq.listSessions(signal)) ?? [];
306
+ const titles = await readTitleMap(records.map((r) => r.header?.id).filter(Boolean), signal);
307
+ const nodes = [];
308
+ const edges = [];
309
+ for (const record of records) {
310
+ const node = sessionNode(record, titles.get(String(record.header?.id)));
311
+ nodes.push(node);
312
+ if (node.parentSessionId) edges.push(lineageEdge({ sessionId: node.parentSessionId }, node));
313
+ }
314
+ const ids = new Set(nodes.map((n) => n.sessionId));
315
+ const roots = nodes.filter((n) => !n.parentSessionId || !ids.has(n.parentSessionId)).map((n) => n.sessionId);
316
+ return { nodes, edges, roots, ...(warnings.length > 0 ? { warnings: [...warnings] } : {}) };
317
+ };
318
+
319
+ /** 单个会话的血统追踪。 */
320
+ const lineage = async (sessionId, signal) => {
321
+ const trace = await sq.traceSession(String(sessionId), signal);
322
+ const collected = [];
323
+ for (const ancestor of trace.ancestors ?? []) collected.push(ancestor);
324
+ collected.push(trace.target);
325
+ const walk = (node) => {
326
+ collected.push(node.session);
327
+ for (const child of node.descendants ?? []) walk(child);
328
+ };
329
+ for (const child of trace.descendants ?? []) walk(child);
330
+ const titles = await readTitleMap(collected.map((r) => r.header?.id).filter(Boolean), signal);
331
+ const nodes = collected.map((r) => sessionNode(r, titles.get(String(r.header?.id))));
332
+ const byId = new Map(nodes.map((n) => [n.sessionId, n]));
333
+ const edges = [];
334
+ for (const node of nodes) {
335
+ if (node.parentSessionId && byId.has(node.parentSessionId)) edges.push(lineageEdge(byId.get(node.parentSessionId), node));
336
+ }
337
+ return {
338
+ complete: trace.complete === true,
339
+ ...(trace.complete !== true && trace.unresolvedParentId ? { unresolvedParentId: String(trace.unresolvedParentId) } : {}),
340
+ target: String(trace.target.header?.id ?? sessionId),
341
+ ...(trace.complete === true && trace.root ? { root: String(trace.root.header?.id) } : {}),
342
+ nodes,
343
+ edges,
344
+ };
345
+ };
346
+
347
+ /**
348
+ * 纯函数:把事件日志遍历成思维树(不依赖外部服务,便于单独测试)。
349
+ * 返回 { nodes, edges, turnCount, spawns };spawns 为带事件时间的派生(子会话)候选节点。
350
+ */
351
+ const analyzeEvents = (events, options = {}) => {
352
+ const { title } = options;
353
+ const nodes = [];
354
+ const edges = [];
355
+ const seqNode = new Map(); // seq -> node id
356
+ let turnNodeId = null;
357
+ let prevTurnNodeId = null;
358
+ let currentTurn = null;
359
+ let lastCallNode = new Map(); // callId -> tool node id
360
+ const shadowed = new Set(); // 被替换(分支出上下文)的节点 id
361
+ const turnUserText = new Map(); // turn -> 该轮首条真实用户请求文本
362
+ const turnAssistantText = new Map(); // turn -> 该轮首条中文助手文本(回退)
363
+ const turnUsage = new Map(); // turn -> 该轮 token 消耗聚合
364
+ const turnTimes = new Map(); // turn -> {start, end}
365
+ const turnModel = new Map(); // turn -> 该轮实际使用的模型(用于成本估算)
366
+ let nodeSeqCounter = 0;
367
+
368
+ const pushNode = (node) => {
369
+ nodeSeqCounter += 1;
370
+ nodes.push(node);
371
+ if (node.seq >= 0) seqNode.set(node.seq, node.id);
372
+ return node.id;
373
+ };
374
+ const attachToTurn = (nodeId, seq) => {
375
+ if (turnNodeId) edges.push({ from: turnNodeId, to: nodeId, kind: 'step' });
376
+ };
377
+ const addTurnUsage = (turn, usage) => {
378
+ if (typeof turn !== 'number' || !usage || typeof usage !== 'object') return;
379
+ const acc = turnUsage.get(turn) ?? { input: 0, output: 0, total: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, messageCount: 0 };
380
+ acc.input += Number(usage.inputTokens) || 0;
381
+ acc.output += Number(usage.outputTokens) || 0;
382
+ acc.total += Number(usage.totalTokens) || 0;
383
+ acc.cacheRead += Number(usage.cacheReadTokens) || 0;
384
+ acc.cacheWrite += Number(usage.cacheWriteTokens) || 0;
385
+ acc.reasoning += Number(usage.reasoningTokens) || 0;
386
+ acc.messageCount += 1;
387
+ turnUsage.set(turn, acc);
388
+ };
389
+
390
+ const rootId = pushNode({ id: 'root', seq: -1, type: 'root', kind: 'info', label: title ?? '会话', surface: 'current' });
391
+
392
+ for (const event of events) {
393
+ // 单事件异常绝不中断整体分析:坏事件只跳过,不影响整棵树。
394
+ try {
395
+ const seq = typeof event?.seq === 'number' ? event.seq : -1;
396
+ const data = event?.data ?? {};
397
+ switch (event?.type) {
398
+ case 'turn/start': {
399
+ const turn = typeof data.turn === 'number' ? data.turn : (currentTurn ?? 0) + 1;
400
+ currentTurn = turn;
401
+ if (typeof event.time === 'number') turnTimes.set(turn, { start: event.time });
402
+ const id = pushNode({ id: `t${turn}`, seq, type: 'turn/start', kind: 'turn', turn, label: `第 ${turn} 轮`, surface: 'current' });
403
+ edges.push({ from: prevTurnNodeId ?? rootId, to: id, kind: 'next' });
404
+ prevTurnNodeId = id;
405
+ turnNodeId = id;
406
+ break;
407
+ }
408
+ case 'turn/end': {
409
+ if (typeof event.time === 'number' && currentTurn !== null) {
410
+ const times = turnTimes.get(currentTurn) ?? {};
411
+ times.end = event.time;
412
+ turnTimes.set(currentTurn, times);
413
+ }
414
+ if (turnNodeId && data?.reason) {
415
+ const reason = data.reason;
416
+ const node = nodes.find((n) => n.id === turnNodeId);
417
+ if (node && reason?.kind === 'forked') node.branch = { kind: 'fork', detail: { reason: 'forked' } };
418
+ }
419
+ break;
420
+ }
421
+ case 'user/message':
422
+ case 'assistant/message':
423
+ case 'system/message':
424
+ case 'developer/message': {
425
+ const role = event.type.split('/')[0];
426
+ // 助手/系统消息的 data 带 message 包装;用户消息的 data 本身就是消息。
427
+ const message = data.message ?? data;
428
+ let kind = role;
429
+ if (role === 'system' || role === 'developer') kind = 'info';
430
+ const text = messageText(message);
431
+ const interrupted = data.interrupted === true;
432
+ // 轮次摘要素材:该轮第一条真实用户请求(避开注入上下文与纯图片消息),回退用首条中文助手文本。
433
+ if (currentTurn !== null && text) {
434
+ const source = summarySourceOf(text);
435
+ if (role === 'user' && !turnUserText.has(currentTurn) && [...source].length >= 2 && !looksInjected(source)) {
436
+ turnUserText.set(currentTurn, source);
437
+ } else if (role === 'assistant' && !turnAssistantText.has(currentTurn) && [...source].length >= 2 && cjkRatio(source) >= 0.3) {
438
+ turnAssistantText.set(currentTurn, source);
439
+ }
440
+ }
441
+ if (role === 'assistant') {
442
+ addTurnUsage(currentTurn, data.usage);
443
+ // 记录该轮使用的模型(成本估算按模型单价折算)。
444
+ const modelId = message?.source?.model ?? data?.source?.model;
445
+ if (typeof modelId === 'string' && modelId && currentTurn !== null) turnModel.set(currentTurn, modelId);
446
+ }
447
+ const baseLabel = squash(text, 160) || (role === 'user' ? '用户消息' : role === 'assistant' ? '助手回复' : role);
448
+ const node = {
449
+ id: `n${seq}`, seq, type: event.type, kind,
450
+ turn: currentTurn ?? undefined,
451
+ ...(typeof data.step === 'number' ? { step: data.step } : {}),
452
+ label: interrupted ? `〔已中断〕${baseLabel}` : baseLabel,
453
+ ...(text ? { text: squash(text, 4000) } : {}),
454
+ surface: 'current',
455
+ };
456
+ // 表面操作:replace 代表该消息替换了一段旧内容 → 旧内容成为"被替代分支"。
457
+ if (event.surfaceOp && typeof event.surfaceOp === 'object' && event.surfaceOp.op === 'replace') {
458
+ const start = typeof event.surfaceOp.startSeq === 'number' ? event.surfaceOp.startSeq : -1;
459
+ const end = typeof event.surfaceOp.endSeq === 'number' ? event.surfaceOp.endSeq : start;
460
+ // 精确目标:优先使用事件自带的 sourceEventSeqs(逐条列出被替换的旧事件),
461
+ // 只有在缺失时才回退到 [startSeq, endSeq] 区间。
462
+ const explicit = Array.isArray(event.sourceEventSeqs) && event.sourceEventSeqs.length > 0
463
+ ? event.sourceEventSeqs.filter((n) => typeof n === 'number')
464
+ : null;
465
+ const rangeSeqs = [];
466
+ if (start >= 0 && end >= start) {
467
+ for (let s = start; s <= end && rangeSeqs.length <= MAX_REPLACE_TARGETS; s += 1) rangeSeqs.push(s);
468
+ }
469
+ const candidates = explicit ?? rangeSeqs;
470
+ // 真实影响条数:显式列表用其长度,区间用区间宽度(不受候选数组上限影响)。
471
+ const affected = explicit !== null
472
+ ? explicit.length
473
+ : (start >= 0 && end >= start ? end - start + 1 : candidates.length);
474
+ // 整段重写(一次替换牵扯几十上百条,例如上下文压缩后的整体重写):
475
+ // 不把大量历史节点逐个标成"已脱离上下文"(既误导又制造上千条虚线),
476
+ // 只记录一次"重写"分支并给出影响条数。
477
+ const wide = candidates.length > MAX_REPLACE_TARGETS;
478
+ node.branch = wide
479
+ ? { kind: 'replace', detail: { startSeq: start, endSeq: end, wide: true, affected } }
480
+ : { kind: 'replace', detail: { startSeq: start, endSeq: end } };
481
+ if (!wide) {
482
+ for (const targetSeq of candidates) {
483
+ const targetId = seqNode.get(targetSeq);
484
+ if (targetId === undefined || targetId === node.id) continue;
485
+ shadowed.add(targetId);
486
+ edges.push({ from: targetId, to: `n${seq}`, kind: 'replaced-by' });
487
+ }
488
+ }
489
+ }
490
+ const id = pushNode(node);
491
+ attachToTurn(id);
492
+ // 派生来源(如基于某段旧内容改写)。
493
+ if (Array.isArray(event.sourceEventSeqs)) {
494
+ for (const sourceSeq of event.sourceEventSeqs) {
495
+ const sourceId = seqNode.get(sourceSeq);
496
+ if (sourceId && sourceId !== id) edges.push({ from: sourceId, to: id, kind: 'derived' });
497
+ }
498
+ }
499
+ break;
500
+ }
501
+ case 'tool/call': {
502
+ const toolName = typeof data.name === 'string' ? data.name : 'tool';
503
+ const callId = data.callId;
504
+ const id = pushNode({
505
+ id: `n${seq}`, seq, type: 'tool/call', kind: 'tool',
506
+ turn: currentTurn ?? undefined,
507
+ ...(typeof data.step === 'number' ? { step: data.step } : {}),
508
+ label: toolLabel(toolName, data.arguments),
509
+ surface: 'current',
510
+ });
511
+ attachToTurn(id);
512
+ if (callId !== undefined) lastCallNode.set(callId, id);
513
+ const branchKind = BRANCH_TOOLS.get(toolName) ?? (SPAWN_TOOLS.has(toolName) ? 'spawn' : undefined);
514
+ if (branchKind === 'question') {
515
+ const node = nodes.find((n) => n.id === id);
516
+ node.branch = { kind: 'question', detail: { questions: questionOptions(data.arguments) } };
517
+ } else if (branchKind === 'todo') {
518
+ const node = nodes.find((n) => n.id === id);
519
+ node.branch = { kind: 'todo', detail: { todos: todoList(data.arguments) } };
520
+ } else if (branchKind === 'spawn') {
521
+ const node = nodes.find((n) => n.id === id);
522
+ node.branch = { kind: 'spawn', detail: { tool: toolName, label: toolLabel(toolName, data.arguments) } };
523
+ } else if (branchKind === 'goal' || branchKind === 'plan') {
524
+ const node = nodes.find((n) => n.id === id);
525
+ node.branch = { kind: branchKind, detail: { tool: toolName } };
526
+ }
527
+ break;
528
+ }
529
+ case 'tool/result': {
530
+ const message = data.message ?? {};
531
+ const callId = message?.toolCallId ?? data.callId;
532
+ const text = messageText(message);
533
+ const id = pushNode({
534
+ id: `n${seq}`, seq, type: 'tool/result', kind: 'result',
535
+ turn: currentTurn ?? undefined,
536
+ label: text ? squash(text, 160) : '工具结果',
537
+ ...(text ? { text: squash(text, 4000) } : {}),
538
+ surface: 'current',
539
+ error: data.error !== undefined || message?.isError === true,
540
+ });
541
+ const callNode = callId !== undefined ? lastCallNode.get(callId) : undefined;
542
+ edges.push({ from: callNode ?? turnNodeId ?? rootId, to: id, kind: callNode ? 'result' : 'step' });
543
+ break;
544
+ }
545
+ case 'llm/retry':
546
+ case 'llm/retry-started': {
547
+ const id = pushNode({
548
+ id: `n${seq}`, seq, type: event.type, kind: 'retry',
549
+ turn: currentTurn ?? undefined,
550
+ label: event.type === 'llm/retry' ? '模型重试' : '开始重试',
551
+ text: summarize(data, 400),
552
+ surface: 'log-only',
553
+ });
554
+ const node = nodes.find((n) => n.id === id);
555
+ node.branch = { kind: 'retry', detail: data };
556
+ attachToTurn(id);
557
+ break;
558
+ }
559
+ case 'approval/asked': {
560
+ const id = pushNode({
561
+ id: `n${seq}`, seq, type: 'approval/asked', kind: 'approval',
562
+ turn: currentTurn ?? undefined,
563
+ label: '等待批准',
564
+ text: summarize(data, 400),
565
+ surface: 'log-only',
566
+ });
567
+ const node = nodes.find((n) => n.id === id);
568
+ node.branch = { kind: 'approval', detail: data };
569
+ attachToTurn(id);
570
+ break;
571
+ }
572
+ case 'approval/decided': {
573
+ const decision = data?.outcome === 'approved' || data?.approved === true ? '已批准' : (data?.outcome === 'denied' || data?.denied === true ? '已拒绝' : '批准结果');
574
+ const id = pushNode({
575
+ id: `n${seq}`, seq, type: 'approval/decided', kind: 'approval',
576
+ turn: currentTurn ?? undefined,
577
+ label: decision,
578
+ text: summarize(data, 400),
579
+ surface: 'log-only',
580
+ });
581
+ attachToTurn(id);
582
+ break;
583
+ }
584
+ case 'plan/mode': {
585
+ const id = pushNode({
586
+ id: `n${seq}`, seq, type: 'plan/mode', kind: 'plan',
587
+ turn: currentTurn ?? undefined,
588
+ label: '计划模式',
589
+ text: summarize(data, 400),
590
+ surface: 'log-only',
591
+ });
592
+ const node = nodes.find((n) => n.id === id);
593
+ node.branch = { kind: 'plan', detail: data };
594
+ attachToTurn(id);
595
+ break;
596
+ }
597
+ case 'goal/change': {
598
+ const op = typeof data?.operation === 'string' ? data.operation : '';
599
+ const objective = typeof data?.goal?.objective === 'string' ? `:${squash(data.goal.objective, 120)}` : '';
600
+ const id = pushNode({
601
+ id: `n${seq}`, seq, type: 'goal/change', kind: 'goal',
602
+ turn: currentTurn ?? undefined,
603
+ label: `目标${op ? `(${op})` : '变化'}${objective}`,
604
+ text: summarize(data, 400),
605
+ surface: 'log-only',
606
+ });
607
+ const node = nodes.find((n) => n.id === id);
608
+ node.branch = { kind: 'goal', detail: data };
609
+ attachToTurn(id);
610
+ break;
611
+ }
612
+ case 'todo/write': {
613
+ const id = pushNode({
614
+ id: `n${seq}`, seq, type: 'todo/write', kind: 'todo',
615
+ turn: currentTurn ?? undefined,
616
+ label: '任务清单',
617
+ surface: 'log-only',
618
+ });
619
+ const node = nodes.find((n) => n.id === id);
620
+ node.branch = { kind: 'todo', detail: { todos: todoList(JSON.stringify(data?.todos ? { todos: data.todos } : {})) } };
621
+ attachToTurn(id);
622
+ break;
623
+ }
624
+ case 'compaction/start':
625
+ case 'compaction/summary':
626
+ case 'compaction/end':
627
+ case 'compaction/prune': {
628
+ const id = pushNode({
629
+ id: `n${seq}`, seq, type: event.type, kind: 'compaction',
630
+ turn: currentTurn ?? undefined,
631
+ label: { 'compaction/start': '压缩开始', 'compaction/summary': '压缩摘要', 'compaction/end': '压缩完成', 'compaction/prune': '压缩裁剪' }[event.type] ?? '上下文压缩',
632
+ text: summarize(data, 400),
633
+ surface: 'log-only',
634
+ });
635
+ const node = nodes.find((n) => n.id === id);
636
+ node.branch = { kind: 'compaction' };
637
+ if (Array.isArray(event.sourceEventSeqs)) {
638
+ for (const sourceSeq of event.sourceEventSeqs) {
639
+ const sourceId = seqNode.get(sourceSeq);
640
+ if (sourceId && sourceId !== id) edges.push({ from: sourceId, to: id, kind: 'derived' });
641
+ }
642
+ }
643
+ attachToTurn(id);
644
+ break;
645
+ }
646
+ case 'team/task':
647
+ case 'team/member': {
648
+ const id = pushNode({
649
+ id: `n${seq}`, seq, type: event.type, kind: 'team',
650
+ turn: currentTurn ?? undefined,
651
+ label: '团队协作',
652
+ text: summarize(data, 400),
653
+ surface: 'log-only',
654
+ });
655
+ const node = nodes.find((n) => n.id === id);
656
+ node.branch = { kind: 'team', detail: data };
657
+ attachToTurn(id);
658
+ break;
659
+ }
660
+ default:
661
+ // 其余事件(log-only)不生成节点。
662
+ break;
663
+ }
664
+ } catch { /* 单个坏事件只跳过,不影响整体分析 */ }
665
+ }
666
+
667
+ // 阴影化:被 replace 覆盖的事件节点。
668
+ for (const id of shadowed) {
669
+ const node = nodes.find((n) => n.id === id);
670
+ if (node && node.surface === 'current') node.surface = 'shadowed';
671
+ }
672
+
673
+ // 轮次摘要 + token 消耗 + 耗时。
674
+ for (const node of nodes) {
675
+ if (node.kind !== 'turn' || typeof node.turn !== 'number') continue;
676
+ const source = turnUserText.get(node.turn) ?? turnAssistantText.get(node.turn);
677
+ const summary = summarizeTurn(source);
678
+ if (summary) node.summary = summary;
679
+ const usage = turnUsage.get(node.turn);
680
+ if (usage) {
681
+ const times = turnTimes.get(node.turn);
682
+ node.usage = {
683
+ input: usage.input,
684
+ output: usage.output,
685
+ total: usage.total,
686
+ cacheRead: usage.cacheRead,
687
+ cacheWrite: usage.cacheWrite,
688
+ reasoning: usage.reasoning,
689
+ messageCount: usage.messageCount,
690
+ ...(turnModel.has(node.turn) ? { model: turnModel.get(node.turn) } : {}),
691
+ ...(times?.start !== undefined ? { startedAt: times.start } : {}),
692
+ ...(times?.start !== undefined && times?.end !== undefined && times.end > times.start
693
+ ? { durationMs: times.end - times.start }
694
+ : {}),
695
+ };
696
+ }
697
+ }
698
+
699
+ // 派生(子会话)候选:带事件时间,供调用方与子会话匹配。
700
+ const spawns = nodes.filter((n) => n.branch?.kind === 'spawn').map((n) => {
701
+ const event = events.find((e) => e.seq === n.seq);
702
+ return { node: n, time: typeof event?.time === 'number' ? event.time : undefined };
703
+ });
704
+
705
+ const turnCount = nodes.filter((n) => n.kind === 'turn').length;
706
+ return { nodes, edges, turnCount, spawns };
707
+ };
708
+
709
+ /** 会话内思维树:读取日志 + 纯函数分析 + 子会话 spawn 匹配。 */
710
+ const tree = async (sessionId, signal) => {
711
+ const snapshot = await sq.readSession(String(sessionId));
712
+ const events = snapshot.events ?? [];
713
+ let title;
714
+ try { title = (await sq.readTitle(String(sessionId), signal))?.title; } catch { /* 尽力而为 */ }
715
+
716
+ // 本会话的子会话(用于 spawn 匹配)。
717
+ let children = [];
718
+ try { children = (await sq.filterSessions([{ kind: 'parent', values: [String(sessionId)] }], signal)) ?? []; } catch { /* 尽力而为 */ }
719
+ const childTitles = await readTitleMap(children.map((r) => r.header?.id).filter(Boolean), signal);
720
+
721
+ const { nodes, edges, turnCount, spawns } = analyzeEvents(events, { title });
722
+
723
+ // 子会话节点与 spawn 边:按创建时间就近匹配派生工具调用。
724
+ const claimed = new Set();
725
+ for (const child of children) {
726
+ const childId = String(child.header?.id);
727
+ const createdAt = typeof child.header?.createdAt === 'number' ? child.header.createdAt : -1;
728
+ let best = undefined;
729
+ let bestGap = SPAWN_MATCH_WINDOW_MS;
730
+ for (const candidate of spawns) {
731
+ if (claimed.has(candidate.node.id)) continue;
732
+ if (candidate.time === undefined) continue;
733
+ const gap = Math.abs(createdAt - candidate.time);
734
+ if (gap < bestGap) { bestGap = gap; best = candidate; }
735
+ }
736
+ const childTitle = childTitles.get(childId);
737
+ const childNode = {
738
+ id: `c${childId}`, seq: best ? best.node.seq : -1, type: 'child-session', kind: 'session',
739
+ label: childTitle ?? (childId.length > 8 ? childId.slice(0, 8) : childId),
740
+ sessionId: childId,
741
+ surface: 'current',
742
+ origin: child.header?.origin === 'subagent' ? 'subagent' : 'fork',
743
+ };
744
+ nodes.push(childNode);
745
+ if (best) {
746
+ claimed.add(best.node.id);
747
+ edges.push({ from: best.node.id, to: childNode.id, kind: 'spawn' });
748
+ } else {
749
+ const lastTurnId = [...nodes].reverse().find((n) => n.kind === 'turn')?.id ?? 'root';
750
+ edges.push({ from: lastTurnId, to: childNode.id, kind: 'spawn' });
751
+ }
752
+ }
753
+
754
+ return {
755
+ sessionId: String(sessionId),
756
+ ...(title ? { title } : {}),
757
+ nodes,
758
+ edges,
759
+ turnCount,
760
+ ...(warnings.length > 0 ? { warnings: [...warnings] } : {}),
761
+ };
762
+ };
763
+
764
+ /** 事件详情窗口。 */
765
+ const detail = async (sessionId, seq, before, after, signal) => {
766
+ const window = await sq.readEvent({
767
+ sessionId: String(sessionId),
768
+ seq: Number(seq),
769
+ before: clamp(Number(before) || 0, 0, 20),
770
+ after: clamp(Number(after) || 0, 0, 20),
771
+ }, signal);
772
+ return {
773
+ sessionId: String(sessionId),
774
+ seq: Number(seq),
775
+ startSeq: typeof window.startSeq === 'number' ? window.startSeq : Number(seq),
776
+ endSeq: typeof window.endSeq === 'number' ? window.endSeq : Number(seq),
777
+ inheritedEventCount: typeof window.inheritedEventCount === 'number' ? window.inheritedEventCount : 0,
778
+ target: window.target,
779
+ events: window.events ?? [],
780
+ };
781
+ };
782
+
783
+ // ---- 实时变更流(供客户端刷新) ----
784
+ const clients = new Set();
785
+ let closed = false;
786
+ const onSessionEvent = (session, event) => {
787
+ if (closed) return;
788
+ const sessionId = session?.id;
789
+ if (typeof sessionId !== 'string' || !sessionId) return;
790
+ if (!Number.isSafeInteger(event?.seq)) return;
791
+ for (const client of [...clients]) client.push({ sessionId, seq: event.seq });
792
+ };
793
+ ctx.on('session/event', onSessionEvent, { global: true });
794
+ const watch = (signal) => {
795
+ const queue = [];
796
+ let resolveNext;
797
+ let ended = closed || signal?.aborted === true;
798
+ const finish = () => {
799
+ if (ended) return;
800
+ ended = true;
801
+ clients.delete(client);
802
+ signal?.removeEventListener('abort', finish);
803
+ queue.length = 0;
804
+ if (resolveNext) { const r = resolveNext; resolveNext = undefined; r({ done: true }); }
805
+ };
806
+ const client = {
807
+ push(frame) {
808
+ if (ended) return;
809
+ if (resolveNext) { const r = resolveNext; resolveNext = undefined; r({ done: false, value: frame }); }
810
+ else if (queue.length >= FOLLOW_QUEUE_LIMIT) queue.shift();
811
+ else queue.push(frame);
812
+ },
813
+ next() {
814
+ if (queue.length) return Promise.resolve({ done: false, value: queue.shift() });
815
+ if (ended) return Promise.resolve({ done: true });
816
+ if (resolveNext) return Promise.reject(new Error('mindTree: 同一迭代器不可并发读取'));
817
+ return new Promise((resolve) => { resolveNext = resolve; });
818
+ },
819
+ return() { finish(); return Promise.resolve({ done: true }); },
820
+ [Symbol.asyncIterator]() { return this; },
821
+ };
822
+ if (!ended) {
823
+ clients.add(client);
824
+ signal?.addEventListener('abort', finish, { once: true });
825
+ }
826
+ return client;
827
+ };
828
+ ctx.effect(() => () => {
829
+ closed = true;
830
+ for (const client of [...clients]) client.return();
831
+ }, 'mind-tree.follow');
832
+
833
+ const service = {
834
+ forest,
835
+ lineage,
836
+ tree,
837
+ detail,
838
+ follow: watch,
839
+ };
840
+ // 公开的 Cordis 服务 + 精确可见的远程协议绑定。
841
+ service.typertRemote = Object.freeze({ service, serviceKey: 'mindTree', namespace: 'mindTree' });
842
+ ctx.provide('mindTree', service);
843
+
844
+ // ---- mind_tree 工具:让终端 / GUI 里的 agent 也能直接导出(不依赖网页客户端) ----
845
+ // 注意:插件无法 import DSH 内部的 defineTool(不在可解析依赖里),
846
+ // 因此这里提供已编译好的 JSON Schema,由 tools 服务直接使用。
847
+ const asDisplayTree = (view, sessionId, data) => {
848
+ if (view === 'tree') return data;
849
+ const nodes = (data.nodes ?? []).map((node) => ({
850
+ id: node.sessionId,
851
+ seq: -1,
852
+ type: 'session',
853
+ kind: 'session',
854
+ label: node.title ?? node.sessionId,
855
+ surface: 'current',
856
+ sessionId: node.sessionId,
857
+ ...(node.origin ? { origin: node.origin } : {}),
858
+ }));
859
+ return {
860
+ sessionId: view === 'forest' ? 'forest' : String(sessionId),
861
+ ...(view === 'forest' ? { title: '全部会话森林' } : {}),
862
+ nodes,
863
+ edges: (data.edges ?? []).map((edge) => ({ from: edge.from, to: edge.to, kind: edge.kind })),
864
+ turnCount: 0,
865
+ };
866
+ };
867
+
868
+ if (typeof ctx.inject !== 'function') {
869
+ toolState = 'unavailable';
870
+ warnings.push('Cordis 上下文缺少 inject:mind_tree 工具未注册');
871
+ return;
872
+ }
873
+ ctx.inject(['tools'], (scope) => {
874
+ const tools = scope.get('tools');
875
+ if (tools === undefined || typeof tools.register !== 'function') {
876
+ toolState = 'unavailable';
877
+ const message = 'mind_tree 工具未注册:tools 服务不可用';
878
+ warnings.push(message);
879
+ try { (ctx.logger?.warn ?? console.warn)(`[mind-tree] ${message}`); } catch { /* 忽略 */ }
880
+ return;
881
+ }
882
+ try {
883
+ scope.effect(() => tools.register({
884
+ name: 'mind_tree',
885
+ description: [
886
+ '把某个会话的对话上下文分析成思维树并导出。',
887
+ 'format=mindmap:Mermaid 思维导图 Markdown(Obsidian/支持 Mermaid 的编辑器打开即见图);',
888
+ 'format=outline:全量大纲 Markdown;format=canvas:Obsidian 画布 JSON;format=json:原始数据。',
889
+ '默认分析当前会话。强烈建议配合 writeTo 写入文件(避免大内容进入上下文);',
890
+ '不写文件时返回内容,超过 4 万字符会被截断。',
891
+ ].join(' '),
892
+ parameters: {
893
+ type: 'object',
894
+ additionalProperties: false,
895
+ properties: {
896
+ sessionId: { type: 'string', description: '目标会话 id;省略则使用当前会话。' },
897
+ format: {
898
+ type: 'string',
899
+ enum: ['mindmap', 'outline', 'canvas', 'json'],
900
+ description: '导出格式,默认 mindmap。',
901
+ },
902
+ view: {
903
+ type: 'string',
904
+ enum: ['tree', 'lineage', 'forest'],
905
+ description: 'tree=会话思维树(默认);lineage=会话血缘;forest=全部会话森林(后两者仅支持 outline/json)。',
906
+ },
907
+ writeTo: {
908
+ type: 'string',
909
+ description: '绝对文件路径;提供后把导出内容写入该文件,只返回摘要。',
910
+ },
911
+ level: {
912
+ type: 'string',
913
+ enum: ['user', 'compact', 'standard', 'full'],
914
+ description: '过程信息量:user(只留轮次/你的发言/分支点,可当需求清单)、compact(默认,另加工具活动合计与每轮答复)、standard(加过程文本与工具明细)、full(全量)。',
915
+ },
916
+ },
917
+ },
918
+ output: {
919
+ // 必须是标准 JSON Schema:插件无法 import 内部的 defineTool,
920
+ // 因此不能再使用 defineTool 的属性级 required DSL。
921
+ schema: {
922
+ type: 'object',
923
+ additionalProperties: false,
924
+ required: ['summary', 'format', 'sessionId', 'fileName', 'bytes', 'turns', 'nodes'],
925
+ properties: {
926
+ summary: { type: 'string', description: '给模型看的一句话结果。' },
927
+ format: { type: 'string', description: '实际使用的导出格式。' },
928
+ sessionId: { type: 'string', description: '来源会话 id。' },
929
+ fileName: { type: 'string', description: '建议文件名。' },
930
+ path: { type: 'string', description: 'writeTo 模式下的落盘路径。' },
931
+ bytes: { type: 'integer', description: '导出内容字节数。' },
932
+ turns: { type: 'integer', description: '轮次数。' },
933
+ nodes: { type: 'integer', description: '节点数。' },
934
+ truncated: { type: 'boolean', description: '内容是否被截断。' },
935
+ content: { type: 'string', description: '导出内容(未使用 writeTo 时返回)。' },
936
+ },
937
+ },
938
+ render: (_args, value) => [{ type: 'text', text: value?.summary ?? 'mind_tree 完成' }],
939
+ },
940
+ async execute(args, exec) {
941
+ const format = ['mindmap', 'outline', 'canvas', 'json'].includes(args?.format) ? args.format : 'mindmap';
942
+ const view = ['tree', 'lineage', 'forest'].includes(args?.view) ? args.view : 'tree';
943
+ const level = DETAIL_LEVELS.includes(args?.level) ? args.level : 'compact';
944
+ const sessionId = typeof args?.sessionId === 'string' && args.sessionId.trim() !== ''
945
+ ? args.sessionId.trim()
946
+ : (exec?.agent?.id !== undefined ? String(exec.agent.id) : undefined);
947
+ if (TREE_ONLY_FORMATS.has(format) && view !== 'tree') {
948
+ throw new Error(`mind_tree: view=${view} 没有轮次结构,不支持 ${format};请改用 outline 或 json(或 view=tree)`);
949
+ }
950
+ let data;
951
+ if (view === 'tree') {
952
+ if (sessionId === undefined) throw new Error('mind_tree: 未提供 sessionId,且当前调用没有所属会话');
953
+ data = await tree(sessionId);
954
+ } else if (view === 'lineage') {
955
+ if (sessionId === undefined) throw new Error('mind_tree: lineage 视图需要 sessionId');
956
+ data = await lineage(sessionId);
957
+ } else {
958
+ data = await forest();
959
+ }
960
+ // 与 GUI 一致:大纲/JSON 按信息量档位削减过程信息;
961
+ // 导图/画布本身已经是抽稀后的骨架,不再叠加削减(否则会丢掉骨架需要的节点)。
962
+ const displayed = asDisplayTree(view, sessionId, data);
963
+ const reduced = (format === 'outline' || format === 'json') ? reduceGraph(displayed, level) : displayed;
964
+ const exported = buildExport(reduced, format, HOST_LABELS);
965
+ const turns = view === 'tree' ? (data.turnCount ?? 0) : 0;
966
+ const nodeCount = (displayed.nodes ?? []).length;
967
+ const keptCount = (reduced?.nodes ?? []).length;
968
+ const dropped = nodeCount - keptCount;
969
+ const levelNote = dropped > 0 ? `,已按 ${level} 档削减 ${dropped} 个过程节点` : '';
970
+ // 成本估算:只在"会话思维树"上有意义(轮次带 usage/model)。
971
+ const cost = view === 'tree' ? costOfTree(displayed) : null;
972
+ const costNote = cost && cost.cny > 0
973
+ ? `,估算成本 ${cost.anyUnknown ? '≈' : ''}${formatCny(cost.cny)}`
974
+ : '';
975
+ const writeTo = typeof args?.writeTo === 'string' ? args.writeTo.trim() : '';
976
+ if (writeTo !== '') {
977
+ const fs = ctx.get('fs');
978
+ if (fs === undefined || typeof fs.resolve !== 'function' || typeof fs.writeText !== 'function') {
979
+ throw new Error('mind_tree: 当前部署没有可用的 fs 服务,无法写文件;请省略 writeTo 直接取回内容');
980
+ }
981
+ const target = await fs.resolve(writeTo);
982
+ await fs.writeText(target, exported.content);
983
+ const displayPath = typeof fs.processPath === 'function' ? fs.processPath(target) : writeTo;
984
+ return {
985
+ summary: `已导出 ${exported.format} 到 ${displayPath}(${exported.content.length} 字节,${turns} 轮 / ${keptCount} 节点${levelNote}${costNote})`,
986
+ format: exported.format,
987
+ sessionId: String(sessionId ?? 'forest'),
988
+ fileName: exported.fileName,
989
+ path: displayPath,
990
+ bytes: exported.content.length,
991
+ turns,
992
+ nodes: keptCount,
993
+ };
994
+ }
995
+ const truncated = exported.content.length > TOOL_CONTENT_LIMIT;
996
+ const content = truncated
997
+ ? `${exported.content.slice(0, TOOL_CONTENT_LIMIT)}\n\n…(内容超过 ${TOOL_CONTENT_LIMIT} 字符已截断;完整内容请用 writeTo 写入文件)`
998
+ : exported.content;
999
+ return {
1000
+ summary: `已生成 ${exported.format}(${exported.content.length} 字节,${turns} 轮 / ${keptCount} 节点${levelNote}${costNote}${truncated ? ',已截断' : ''})`,
1001
+ format: exported.format,
1002
+ sessionId: String(sessionId ?? 'forest'),
1003
+ fileName: exported.fileName,
1004
+ bytes: exported.content.length,
1005
+ turns,
1006
+ nodes: keptCount,
1007
+ ...(truncated ? { truncated: true } : {}),
1008
+ content,
1009
+ };
1010
+ },
1011
+ }), 'mind-tree.tool');
1012
+ toolState = 'ready';
1013
+ } catch (error) {
1014
+ toolState = 'failed';
1015
+ const message = `mind_tree 工具注册失败: ${error?.message ?? error}`;
1016
+ warnings.push(message);
1017
+ try { (ctx.logger?.warn ?? console.warn)(`[mind-tree] ${message}`); } catch { /* 忽略 */ }
1018
+ }
1019
+ });
1020
+ }