dsh-plugin-tool-management 0.18.0 → 0.19.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.
@@ -67,6 +67,19 @@ export const DOMAIN_FRAME = {
67
67
  supersede: SUPERSEDE_NOTE,
68
68
  },
69
69
  };
70
+ /**
71
+ * `how` 行里**点名过的工具**(六个域里只有 skills / subagents 两个会点名)。
72
+ *
73
+ * 为什么要有这份名单:一条 `how` 行是否成立,取决于它点名的工具在**这个 agent 手里**还在不在。
74
+ * 名单放在这里而不是调用方 —— 与上面那两条 `how` 相邻,改一处不会漏另一处。
75
+ *
76
+ * "不在手里"有两条路,都要算:
77
+ * ① 兼容页「模型工具表」把它关掉(用户全局开关,名单在 index.ts 的 `hiddenTools` 里);
78
+ * ② **人设的工具限制**把它砍掉(`decideToolFilter` → 官方 `tools.restrict`,只作用于那份
79
+ * 人设启动的子代理)。②此前没人管:目录照注、`how` 行照样点名一个模型手里没有的工具,
80
+ * 等于诱导它去调一个不存在的名字(2026-10-07 修)。
81
+ */
82
+ export const HOW_NAMED_TOOLS = ['skill_manager_read', 'subagent_manager_run'];
70
83
  /** 域声明里没登记的 key(理论上到不了这里):给一个不出错的通用框架。 */
71
84
  export const fallbackFrame = (label) => ({
72
85
  title: `本机的${label}`,
@@ -20,9 +20,10 @@ export function normalizeInjectSettings(raw) {
20
20
  * 规则(设计 2026-09-16;目录注入深度 2026-09-17):
21
21
  * - 域开关关掉的、文本为空的,都不进;
22
22
  * - **压制型预设**且没开「仍然注入」→ 一个都不进(跟随预设);
23
- * - 域声明 `applicableTo` 说不成立的域不进(当前只有人设目录:会话深度超出了人设的
24
- * `catalogDepth`,默认 1 = 只在顶层注入。**这不是"子会话不能委派"** —— 委派由官方
25
- * 决定,见 service.ts 里 `catalogDepth` 的注释);
23
+ * - 域声明 `applicableTo` 说不成立的域不进(**深度判据逐域写**:场景与记忆只在顶层
24
+ * —— 它们说的是父会话的处境;人设目录则看会话深度有没有超出人设的 `catalogDepth`,
25
+ * 默认 1 = 只在顶层注入。**这都不是"子会话不能委派"** —— 委派由官方决定,
26
+ * 见 service.ts 里 `catalogDepth` 的注释);
26
27
  * - 有官方载体的两个域(提示词 / 技能目录):预设挂得到官方那条行时不进
27
28
  * —— 官方自己会送,再注入一遍只会重复;预设事实读不到(`undefined`)时按"已承载"处理
28
29
  * (宁可与现状一致,也不制造重复);
@@ -80,7 +80,7 @@ import { kindOfMessage, LIVE_KINDS, newestDomainTexts, officialKindsToSuppress,
80
80
  // subagents/{catalog,service}.ts 与契约测试都按这个路径引)。
81
81
  export { DEFAULT_INJECT_SETTINGS, DOMAIN_TOOL_PREFIX, INJECT_DOMAIN_KEYS, INJECT_KIND_OF, domainOfTool, isSubagentSession, subagentDepthOf, } from './context-inject-contract.js';
82
82
  export { explainInjections, normalizeInjectSettings, selectInjections, } from './context-inject-select.js';
83
- export { clearedDomainText, escapeFrameBody, renderDomainText } from './context-inject-frame.js';
83
+ export { clearedDomainText, escapeFrameBody, HOW_NAMED_TOOLS, renderDomainText } from './context-inject-frame.js';
84
84
  export { officialKindsToSuppress } from './context-inject-surface.js';
85
85
  // B4:关域拦截的**顺序自检**。官方那两条注入行与本插件同挂在这条瀑布上,拦截成立的剩余前提是
86
86
  // "本插件排在它们上游"(本插件用 `{ prepend: true }` 把自己钉在钩子表最前,所以真正还依赖的
@@ -370,8 +370,9 @@ export function createContextInjector(deps) {
370
370
  const published = new Set();
371
371
  // 域声明按 key 索引:来源文件(`files`)只在真要发消息时取,所以要能从这里回查声明。
372
372
  const domainOf = new Map(domains.map((domain) => [domain.key, domain]));
373
- // 工具表开关本步读一次(同步快照):`how` 行里点名工具的那几句据此换话术。
374
- const hiddenNow = deps.hiddenTools !== undefined ? deps.hiddenTools() : null;
373
+ // `how` 行点名工具的那几句据本步的快照换话术。按 agent 取:全局开关之外,
374
+ // 人设的工具限制也会让工具不在手里,而那份名单逐 agent 不同。
375
+ const hiddenNow = deps.hiddenTools !== undefined ? deps.hiddenTools(agent) : null;
375
376
  const toolHidden = hiddenNow === null ? () => false : (name) => hiddenNow.has(name);
376
377
  for (const section of sections) {
377
378
  published.add(section.key);
@@ -0,0 +1,223 @@
1
+ // 会话转录导出:把宿主的 SessionEvent[] 提成可再导入的转录文本。
2
+ //
3
+ // 从 src/index.ts 剥出来(0.18.5),同时补上两件事:
4
+ //
5
+ // 1. **按块类型取文本**。宿主的内容块是带 type 标签的联合(dsh-llm types.d.ts:114-122):
6
+ // `text` 是用户可见正文,`reasoning` 是思考,`tool-call` 是模型发起的调用。此前导出走
7
+ // `extractText`,它只判「这个对象有没有 text 字段」—— 而 `ReasoningBlock` 恰好就是
8
+ // `{type:'reasoning', text}`,所以**思考一直混在助手正文里导出去**。同一类缺陷 0.16.x
9
+ // 已在子智能体侧用 `textOfBlocks` 修过(persona-parse.ts:275),导出侧是它的第二个出口。
10
+ // 2. **导出分三档,且是累进的**:正文 → 另加工具 → 再加思考。每档都是上一档的超集,
11
+ // 所以一个下拉就说得清(两个独立勾选框是另一种答案,用户不要)。
12
+ // 三档仍是**可读转录,不是完整备份**:每块有上限并明说截断,token 统计与压缩结构不在里面。
13
+ //
14
+ // `extractText` 本身不动:它服务的是外部文件导入,那份宽松是故意的(外来转录稿没有
15
+ // `reasoning` 词表可守)。
16
+ import { textOfBlocks } from '../subagents/persona-parse.js';
17
+ // 上限是**病态保护**:工具结果可以是一次几 MB 的文件读取,不设限的话一个会话就能
18
+ // 导出上百 MB。取最靠前的那一段 —— 工具输出的头部几乎总是最有用的那部分。
19
+ const REASONING_MAX = 6000;
20
+ const TOOL_INPUT_MAX = 1000;
21
+ const TOOL_RESULT_MAX = 2000;
22
+ /** 裁到 max 个字符并留下标记。标记固定英文:op 跑在服务端,没有客户端词典可用。 */
23
+ function cap(text, max) {
24
+ const s = String(text || '');
25
+ if (s.length <= max)
26
+ return s;
27
+ return `${s.slice(0, max)}\n[truncated: showing ${max} of ${s.length} chars]`;
28
+ }
29
+ function str(value) {
30
+ return typeof value === 'string' ? value : '';
31
+ }
32
+ function blocksOf(content) {
33
+ return Array.isArray(content) ? content : [];
34
+ }
35
+ function formatBytes(n) {
36
+ const v = Number(n);
37
+ if (!Number.isFinite(v) || v < 0)
38
+ return 'unknown size';
39
+ if (v < 1024)
40
+ return `${v} B`;
41
+ if (v < 1024 * 1024)
42
+ return `${Math.round(v / 1024)} KB`;
43
+ return `${(Math.round((v / 1024 / 1024) * 10) / 10).toFixed(1)} MB`;
44
+ }
45
+ /** 附件只报名字 / 类型 / 尺寸 / 字节数 —— `attachmentId` 官方定义为不透明串,进了转录稿只是噪声。 */
46
+ function attachmentLines(content) {
47
+ const lines = [];
48
+ for (const block of blocksOf(content)) {
49
+ const ref = (block.attachment && typeof block.attachment === 'object' ? block.attachment : {});
50
+ if (block.type === 'image') {
51
+ const media = str(ref.mediaType) || 'image';
52
+ const w = Number(ref.width);
53
+ const h = Number(ref.height);
54
+ const dims = Number.isFinite(w) && Number.isFinite(h) ? ` ${w}×${h}` : '';
55
+ const name = str(ref.name);
56
+ lines.push(`image: ${name ? name + ' — ' : ''}${media}${dims} (${formatBytes(ref.bytes)})`);
57
+ }
58
+ else if (block.type === 'file') {
59
+ lines.push(`file: ${str(ref.name) || '(未命名)'} (${formatBytes(ref.bytes)})`);
60
+ }
61
+ }
62
+ return lines.join('\n');
63
+ }
64
+ function toolLabel(name, kind, failed = false) {
65
+ return `Tool: ${name} — ${kind}${failed ? ' (failed)' : ''}`;
66
+ }
67
+ /**
68
+ * 把会话事件提成导出用的轮次。
69
+ *
70
+ * 工具的**发起**在两处都有记录:`assistant/message` 里的 `tool-call` 块(模型可见真相),
71
+ * 与 log-only 的 `tool/call` 事件(持久化记录)。两边都收、按 call id 去重 —— 只认一边
72
+ * 会在某种宿主形状下静默丢掉全部工具调用,而"导出里看不见工具调用"正是这份文件此前的毛病。
73
+ * 配不上任何调用的 `tool/result` 也照样单独成段,同样不静默丢。
74
+ */
75
+ export function extractExportTurns(events, detail) {
76
+ const wantTools = detail !== 'simple';
77
+ const wantThinking = detail === 'full';
78
+ const turns = [];
79
+ const namesByCallId = new Map();
80
+ const emittedCallIds = new Set();
81
+ let current = null;
82
+ const flush = () => {
83
+ // 空正文 + 空附加段 = 这一轮什么都没有,不占位。
84
+ if (current && (current.text || current.details.length))
85
+ turns.push(current);
86
+ current = null;
87
+ };
88
+ /** 工具细节总归属于助手轮;正文已经翻篇时就另起一轮。 */
89
+ const openAssistant = () => {
90
+ if (!current || current.role !== 'assistant') {
91
+ flush();
92
+ current = { role: 'assistant', text: '', details: [] };
93
+ }
94
+ return current;
95
+ };
96
+ for (const event of events || []) {
97
+ if (!event || typeof event !== 'object')
98
+ continue;
99
+ const e = event;
100
+ const data = (e.data && typeof e.data === 'object' ? e.data : {});
101
+ if (e.type === 'user/message') {
102
+ flush();
103
+ const turn = { role: 'user', text: textOfBlocks(data.content, 'text'), details: [] };
104
+ // 附件跟工具一起进中间档:它是「对话里出现过什么」的一部分,不是「模型想了什么」。
105
+ if (wantTools) {
106
+ const attachments = attachmentLines(data.content);
107
+ if (attachments)
108
+ turn.details.push({ label: 'Attachment', text: attachments });
109
+ }
110
+ current = turn;
111
+ continue;
112
+ }
113
+ if (e.type === 'assistant/message') {
114
+ flush();
115
+ const message = (data.message && typeof data.message === 'object' ? data.message : {});
116
+ const turn = { role: 'assistant', text: textOfBlocks(message.content, 'text'), details: [] };
117
+ // 顺序即模型产出的顺序:先思考、后调用。两档都开时(full)这个次序天然成立,
118
+ // 因为 full 必然也开 wantTools。
119
+ if (wantThinking) {
120
+ const reasoning = textOfBlocks(message.content, 'reasoning');
121
+ if (reasoning)
122
+ turn.details.push({ label: 'Thinking', text: cap(reasoning, REASONING_MAX) });
123
+ }
124
+ if (wantTools) {
125
+ for (const block of blocksOf(message.content)) {
126
+ if (block.type !== 'tool-call')
127
+ continue;
128
+ const id = str(block.id);
129
+ const name = str(block.name) || '(unnamed)';
130
+ if (id) {
131
+ namesByCallId.set(id, name);
132
+ emittedCallIds.add(id);
133
+ }
134
+ turn.details.push({ label: toolLabel(name, 'in'), text: cap(str(block.arguments), TOOL_INPUT_MAX) });
135
+ }
136
+ }
137
+ current = turn;
138
+ continue;
139
+ }
140
+ // 往下只剩工具与结果两类事件,中间档就要,只有简洁档跳过。
141
+ if (!wantTools)
142
+ continue;
143
+ if (e.type === 'tool/call') {
144
+ const id = str(data.callId);
145
+ const name = str(data.name) || namesByCallId.get(id) || '(unnamed)';
146
+ if (id)
147
+ namesByCallId.set(id, name);
148
+ // assistant 块里已经列过同一个调用,就别重复成段。
149
+ if (id && emittedCallIds.has(id))
150
+ continue;
151
+ if (id)
152
+ emittedCallIds.add(id);
153
+ openAssistant().details.push({ label: toolLabel(name, 'in'), text: cap(str(data.arguments), TOOL_INPUT_MAX) });
154
+ continue;
155
+ }
156
+ if (e.type === 'tool/result') {
157
+ const message = (data.message && typeof data.message === 'object' ? data.message : {});
158
+ const id = str(message.toolCallId);
159
+ const name = namesByCallId.get(id) || '(unknown)';
160
+ const failed = message.isError === true;
161
+ const body = textOfBlocks(message.content, 'text');
162
+ const blocks = blocksOf(message.content);
163
+ openAssistant().details.push({
164
+ label: toolLabel(name, 'out', failed),
165
+ text: cap(body || (blocks.length ? `(无文本输出,${blocks.length} 个内容块)` : '(无输出)'), TOOL_RESULT_MAX),
166
+ });
167
+ // `error` 官方注释明说它在模型可见消息之外 —— 带工具的那两档是用户唯一能看到它的地方。
168
+ const err = (data.error && typeof data.error === 'object' ? data.error : undefined);
169
+ if (err) {
170
+ const reason = str(err.reason) || str(err.code) || str(err.name);
171
+ if (reason)
172
+ openAssistant().details.push({ label: toolLabel(name, 'error'), text: cap(`${str(err.name)}/${str(err.code)} — ${reason}`, TOOL_RESULT_MAX) });
173
+ }
174
+ continue;
175
+ }
176
+ }
177
+ flush();
178
+ return turns;
179
+ }
180
+ /**
181
+ * 把载荷整段缩进 4 空格。
182
+ *
183
+ * 这是**回流保护**,不是排版:工具结果可以是任意文件内容,读进一份 markdown 转录稿就会把
184
+ * 里面的 `## User` 原样吐进助手正文,再导入时把轮次劈开。缩进同时废掉 `HEADING_RE` 与
185
+ * `BOLD_PREFIX_RE` 两个行首锚点(两者都不允许前导空白)。空行保持真空,不留尾随空格。
186
+ */
187
+ function indentBlock(text) {
188
+ return String(text || '')
189
+ .split('\n')
190
+ .map((line) => (line.trim() ? ` ${line}` : ''))
191
+ .join('\n');
192
+ }
193
+ /**
194
+ * 序列化为可再导入的转录文本:Codex 风格 Markdown 或 Claude Code 风格 JSONL。
195
+ *
196
+ * 签名与 0.18.5 之前一致 —— 附加信息全在 `turn.details` 里,所以 `details` 为空时两种输出
197
+ * 与旧版逐字节相同。`## User` / `### Assistant` 两级标题不能改:导入侧靠它切轮次与嗅探格式。
198
+ */
199
+ export function serializeTranscript(turns, format) {
200
+ const list = Array.isArray(turns) ? turns : [];
201
+ if (format === 'jsonl') {
202
+ // content 保持**字符串**:改成类型化块数组的话,导入侧的 extractText 会把 reasoning
203
+ // 又拼回正文(正是我们在修的那个泄漏),并让 tool-call 无声消失 —— 严格劣于字符串。
204
+ return list
205
+ .map((turn) => {
206
+ let content = turn.text;
207
+ for (const item of turn.details)
208
+ content += `${content ? '\n\n' : ''}[${item.label}]\n${item.text}`;
209
+ return JSON.stringify({ type: turn.role, message: { role: turn.role, content } });
210
+ })
211
+ .join('\n');
212
+ }
213
+ return list
214
+ .map((turn) => {
215
+ const head = turn.role === 'user' ? '## User' : '### Assistant';
216
+ const blocks = [turn.text ? `${head}\n${turn.text}` : head];
217
+ // 四级标题:`#{1,3}` 匹配不到,所以附加段再导入时只会并入所属轮次,不会伪装成边界。
218
+ for (const item of turn.details)
219
+ blocks.push(`#### ${item.label}\n${indentBlock(item.text)}`);
220
+ return blocks.join('\n\n');
221
+ })
222
+ .join('\n\n');
223
+ }
package/lib/index.js CHANGED
@@ -30,7 +30,7 @@ import { runStateDoctor } from './ops/state-doctor.js';
30
30
  import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
31
31
  import { createSubagentCatalog } from './subagents/catalog.js';
32
32
  import { createSkillCatalog } from './skills/catalog.js';
33
- import { createContextInjector, subagentDepthOf, } from './context-inject.js';
33
+ import { createContextInjector, HOW_NAMED_TOOLS, subagentDepthOf, } from './context-inject.js';
34
34
  import { isApprovalNever } from './approval-policy.js';
35
35
  import { DEFAULT_HIDDEN_TOOLS, FACTORY_TOOL_TABLE_PRESET, normalizeToolTableSettings, } from './tools/table.js';
36
36
  import { createToolTableSwitch } from './tool-table-switch.js';
@@ -70,41 +70,13 @@ import { buildMemoryTools } from './tools/memory.js';
70
70
  import { buildSceneTools } from './tools/scene.js';
71
71
  import { buildSubagentTools } from './tools/subagent.js';
72
72
  import { createScenePromptSync } from './scene-prompt-sync.js';
73
- import { extractText } from './imports/parsers.js';
73
+ import { extractExportTurns, serializeTranscript } from './exports/transcript.js';
74
74
  import { spawn } from 'node:child_process';
75
75
  import { mkdir, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
76
76
  import { basename, dirname, join } from 'node:path';
77
77
  // ---------------------------------------------------------------------------
78
- // Transcript export helpers — mirror the import parsers (src/imports/parsers.js)
79
- // so an exported file can be imported back losslessly. Pure Node, no ctx.
78
+ // Transcript export lives in src/exports/transcript.ts (0.18.5).
80
79
  // ---------------------------------------------------------------------------
81
- /** 从会话事件数组提取用户/助手纯文本轮次(与导入解析器对称)。 */
82
- function extractTurnsFromEvents(events) {
83
- const turns = [];
84
- for (const ev of events || []) {
85
- if (!ev || typeof ev !== 'object')
86
- continue;
87
- const e = ev;
88
- let content;
89
- if (e.type === 'user/message')
90
- content = e.data && e.data.content;
91
- else if (e.type === 'assistant/message')
92
- content = e.data && e.data.message && e.data.message.content;
93
- else
94
- continue;
95
- const text = extractText(content).trim();
96
- if (text)
97
- turns.push({ role: e.type === 'user/message' ? 'user' : 'assistant', text });
98
- }
99
- return turns;
100
- }
101
- /** 序列化为可再导入的转录文本:Codex 风格 Markdown 或 Claude Code 风格 JSONL。 */
102
- function serializeTurns(turns, format) {
103
- if (format === 'jsonl') {
104
- return turns.map((t) => JSON.stringify({ type: t.role, message: { role: t.role, content: t.text } })).join('\n');
105
- }
106
- return turns.map((t) => (t.role === 'user' ? '## User\n' : '### Assistant\n') + t.text).join('\n\n');
107
- }
108
80
  /**
109
81
  * `inject` 的服务名清单(单一来源):既声明给宿主,也是「挂载心跳」与 doctor 核对的内容。
110
82
  *
@@ -652,6 +624,35 @@ export default {
652
624
  }
653
625
  return out;
654
626
  };
627
+ /**
628
+ * `how` 行点名过的工具里,**这个 agent 手里没有**的那些(注入器每步同步问一次)。
629
+ *
630
+ * 两条路并起来,缺一条框架文本就会说谎:
631
+ * ① `toolTableHidden()` —— 兼容页「模型工具表」的全局开关。**必须留着**:它读的是设置
632
+ * 本身,与"限制层装没装上"无关(可见性的重放有 300ms 去抖,只问可见性会有一个窗口漏报)。
633
+ * ② `tools.get(name, agent)` —— 拿 agent 当 scope 问"它解析得到这个名字吗",与
634
+ * `carrierHiddenToolsFor` 同一套官方写法。这一条覆盖**人设的工具限制**:
635
+ * `decideToolFilter` 算出的 allow/deny 由官方在子代理启动时 `tools.restrict` 装到那个
636
+ * scope 上(`dsh-subagent/lib/index.js:522`),全局开关表里看不到它 —— 于是人设砍掉
637
+ * `skill_manager_read` 之后,技能目录照注、`how` 行照样点名一个模型手里没有的工具。
638
+ * 拿不到 agent 时跳过:`get(name, undefined)` 问的是**全局视图**,会得出相反结论。
639
+ */
640
+ const howHiddenToolsFor = (agent) => {
641
+ const hidden = new Set(toolTableHidden());
642
+ if (agent === null || (typeof agent !== 'object' && typeof agent !== 'function'))
643
+ return hidden;
644
+ const scope = agent;
645
+ for (const name of HOW_NAMED_TOOLS) {
646
+ if (hidden.has(name))
647
+ continue;
648
+ try {
649
+ if (tools.get(name, scope) === undefined)
650
+ hidden.add(name);
651
+ }
652
+ catch { /* 读不到 = 按还在手里算 */ }
653
+ }
654
+ return hidden;
655
+ };
655
656
  // ---------- 注入通道(场景 / 记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
656
657
  // 这些文本以前是 systemPrompt 段(persona complete 会整段压掉)。现在改走官方的
657
658
  // 「每步注入一条合成消息」通道(skill-catalog / AGENTS.md / 时间上下文同款):
@@ -701,23 +702,23 @@ export default {
701
702
  // 子智能体 → 提示词。form 是宿主语义轴:记忆是「当前状态」(snapshot,后发取代先发),
702
703
  // 三个目录是 catalog,提示词是 instructions(与官方 AGENTS.md 那条行同一形态)。
703
704
  //
704
- // `applicableTo`(2026-09-17 方案 A):子智能体域按"**目录该不该注入到这么深的
705
- // 会话**"(人设的 `catalogDepth`,默认 1 = 只在顶层注入)判断,**不是**"能不能
706
- // 委派"。委派可行性由官方决定:`dsh-tool-subagent` 默认 `maxDepth: 3`,provider
707
- // 只在传了该值时才校验,所以子代理本来就能继续嵌套(用户实测确认)。此前判据叫
708
- // "还有没有委派预算"、语义是"还能不能委派",那是错的 —— 详见 service.ts 里
709
- // `catalogDepth` 字段的注释。目录正文也按同一个判据过滤(`text(agent)`),两处同源
710
- // 所以不会分叉。
705
+ // 深度判据(`applicableTo`)**逐域写在域声明上**,三个域各一条,语义都**不是**"能不能
706
+ // 委派"。委派可行性由官方决定:`dsh-tool-subagent` 默认 `maxDepth: 3`,provider 只在
707
+ // 传了该值时才校验,所以子代理本来就能继续嵌套(用户实测确认)。此前判据叫"还有没有
708
+ // 委派预算"、语义是"还能不能委派",那是错的 —— 详见 service.ts 里 `catalogDepth` 的注释。
709
+ // · 场景与记忆:**恒为"只在顶层"**。这两段说的是**父会话的处境**(此刻在哪个场景、
710
+ // 这个场景下攒了什么),子代理要什么由父代理写进委派说明;记忆本体又是几 KB 的
711
+ // 常驻文本,子代理真有需要可以自己用 `memory_manager_*` 取。放它们进子会话,
712
+ // 子代理的上下文就不再收敛为「角色 + 任务」。(0.19.0 中途有一版把这两条放开到
713
+ // 所有深度,已回退。)
714
+ // · 子智能体目录:人设的 `catalogDepth`(默认 1 = 只在顶层)—— 它问的是"**目录该不该
715
+ // 注入到这么深的会话**",与"这个域要不要注入"是两件事。目录正文也按同一判据过滤
716
+ // (`text(agent)`),两处同源所以不会分叉。
717
+ // · MCP / 技能 / 提示词:不分深度。
711
718
  //
712
- // 场景与记忆(2026-09-17 用户裁定;2026-09-23 拆成两个域):**都只在顶层注入**。
713
- // 记忆是"父会话的现场",不是子代理完成任务所需的事实 —— 塞进一次性子会话只会与
714
- // 角色定义争注意力(实测:子代理跑审查时,上下文里同时躺着人设与整份记忆)。
715
- // 场景段同理:它说的是"父会话现在处在哪个模式",而子代理根本没有"模式"可言。
716
- // 子代理手里有 `memory_manager_list/read` 与 `scene_manager_save`,需要什么自己取;
717
- // 父代理上下文里也有,相关事实应当由它写进 `task`(子代理的上下文 = 角色 + 任务)。
718
- // 其余三域对任何深度都成立:提示词是用户规则(本插件的立身之本就是"覆盖到子代理")、
719
- // 技能目录与 MCP 状态是"操作这台机器所需的事实"(子代理手里就有 `skill` / `mcp__*`
720
- // 工具,不知道清单就只能瞎调)。
719
+ // 同一张 `domains` 表管着六个域的开关,没有按会话分流的第二张表 —— 那张
720
+ // `subagentDomains` 已删:人设身份在官方机制里取不到(descriptor 是字段白名单、
721
+ // 冷恢复只还原 provider/model/effort)。
721
722
  domains: () => [
722
723
  // 场景(**框架**):启用的场景 + 场景说明(用户的约定)。排第一 —— 先让模型知道
723
724
  // "现在在哪个场景、这个场景的约定是什么",再读下面的记忆条目。
@@ -738,7 +739,7 @@ export default {
738
739
  ],
739
740
  settings: () => injectSettingsSync(),
740
741
  // 工具表开关:`how` 行里点名工具的那几句要跟着它换话术(关掉的不点名)。
741
- hiddenTools: toolTableHidden,
742
+ hiddenTools: howHiddenToolsFor,
742
743
  factsFor: (agent) => candidates.presetFactsForAgent(agent),
743
744
  // 令牌门禁(2026-09-19,用户裁定「宿主侧硬拦截」):令牌**在生效**(`TOKEN !== ''`,
744
745
  // 关掉或没配都是空串)而本次启动还没有人验过 ⇒ 这一步不放行,宿主把 turn 收成
@@ -1427,6 +1428,11 @@ export default {
1427
1428
  // 实现在 ./scenes/candidates.ts(2026-09-19 从本闭包 10 段抽出,约 260 行)。
1428
1429
  // 放在这里而不是更早:它要 mcp(读停用表)与 toolKeyParts(416 行的 const),
1429
1430
  // 而唯一在更早处引用它的地方(注入 deps 的 factsFor)是箭头函数,调用发生在之后。
1431
+ // 本插件注册过的工具名。**唯一**可靠的"插件工具"判据:官方没有工具级归属接口
1432
+ // (`ToolSchema` 无归属字段、`ToolRuntime` 公开面无来源、`tools/change` 无载荷),
1433
+ // 但自己注册了什么自己当然知道。登记点在下面 `toolDeps.register`(2115 行那个漏斗,
1434
+ // 六个域的构建器全部经它),所以不可能漂移 —— 新增工具自动进集合,不用维护前缀表。
1435
+ const selfToolNames = new Set();
1430
1436
  const candidates = createCandidates({
1431
1437
  get: (name) => ctx.get(name),
1432
1438
  tools,
@@ -1434,6 +1440,9 @@ export default {
1434
1440
  skillsService,
1435
1441
  memoriesService,
1436
1442
  toolKeyParts,
1443
+ // 传 getter 而不是快照:登记发生在 apply 过程中(本行之后 600 余行),
1444
+ // 而这个工厂现在就要构造完。
1445
+ selfToolNames: () => selfToolNames,
1437
1446
  });
1438
1447
  // 提示词预设域的 op 表。单独提出来是为了让**模型工具**能复用同一份判定:
1439
1448
  // `prompt_manager_list` 以前直调 `promptsService.list()`,于是它报的「生效中」是
@@ -1534,8 +1543,8 @@ export default {
1534
1543
  quickPromptsDir,
1535
1544
  rulesList: (args) => memoriesService.ops['rules-list'](args),
1536
1545
  skillDetail: (args) => skillsService.ops['skill-detail'](args),
1537
- extractTurnsFromEvents,
1538
- serializeTurns,
1546
+ extractTurnsFromEvents: extractExportTurns,
1547
+ serializeTurns: serializeTranscript,
1539
1548
  }),
1540
1549
  // rules 域里要走「场景 ↔ 全局基线同步」包装的四个写 op(ops/scene-sync.ts 提供)。
1541
1550
  // 必须排在 ...memoriesService.ops 之后 —— 这里是显式覆盖同名 op,不是新增。
@@ -2117,7 +2126,11 @@ export default {
2117
2126
  defineTool,
2118
2127
  // 量体积只认**注册成功**的那些(注册失败的域工具本来就不在模型工具表里)——
2119
2128
  // 兼容页「模型工具表」块的分组与 ≈token 都取自这里,那是这份设置唯一的数字来源。
2120
- register: (def) => { recordToolSize(def); tools.register(def); },
2129
+ //
2130
+ // 同一行里登记 `selfToolNames`:六个域的构建器全部经这个漏斗,所以它就是"本插件注册了
2131
+ // 哪些工具"的权威清单。`tools.register` 抛(重名 / 保留名)时不登记 —— 那份不在表里,
2132
+ // 也不该出现在「工具限制」的候选里。
2133
+ register: (def) => { recordToolSize(def); tools.register(def); selfToolNames.add(String(def.name)); },
2121
2134
  lockedSceneGuard,
2122
2135
  syncSwitchToScene,
2123
2136
  // 错误句里点名工具前要先问一句(判据与两个目录的 `listToolVisible` 同一份缓存)。
@@ -6,7 +6,7 @@ export function buildCandidateOps(deps) {
6
6
  return {
7
7
  // 人设表单的两个候选源之一(只读,均为「按需拉取」——不进 scene-inventory,避免每次开
8
8
  // 档案弹窗都枚举预设)。
9
- // preset-tools:全体 Agent 预设工具名并集 + 各工具所属预设 + 当前会话是否可见。
9
+ // preset-tools:一行一个 Agent 预设,各带自己的工具清单与归属组(0.19.0 起不再返回全体并集)。
10
10
  // `unavailable` = 本插件自己没注册上的工具:工具列表接口是唯一按名字枚举工具的地方,
11
11
  // 注册失败只能在这里被如实回答。
12
12
  'preset-tools': async () => ({
@@ -205,9 +205,13 @@ export function buildSessionOps(deps) {
205
205
  if (!sessionId)
206
206
  return { ok: false, error: '缺少 sessionId' };
207
207
  try {
208
- const r = await registry.unarchiveSession(sessionId);
208
+ // 不接 registry 的返回值:官方 `unarchiveSession` 是 `Promise<void>`,而界面只要
209
+ // ok / error / workspace —— 每次单条恢复都把整个归档集合回传一遍纯属浪费。
210
+ // 0.18.5 之前这里读 `r.archivedSessionIds`,于是宿主恢复**成功之后**构造回执时抛
211
+ // TypeError,界面弹「操作失败」,用户以为没恢复、其实已经恢复了。
212
+ await registry.unarchiveSession(sessionId);
209
213
  const workspace = await deps.restoreWorkspaceAccounting([sessionId]);
210
- return { ok: true, archivedSessionIds: r.archivedSessionIds, ...(workspace ? { workspace } : {}) };
214
+ return { ok: true, sessionId, ...(workspace ? { workspace } : {}) };
211
215
  }
212
216
  catch (e) {
213
217
  return { ok: false, error: deps.message(e) };
@@ -278,7 +282,8 @@ export function buildSessionOps(deps) {
278
282
  try {
279
283
  const r = await registry.unarchiveSessions(parsed.target);
280
284
  const workspace = await deps.restoreWorkspaceAccounting(r.unarchivedSessionIds || []);
281
- return { ok: true, unarchivedSessionIds: r.unarchivedSessionIds, archivedSessionIds: r.archivedSessionIds, ...(workspace ? { workspace } : {}) };
285
+ // 只报「这次恢复了哪些」,不把整个归档集合快照发回界面(同上,界面不读它)。
286
+ return { ok: true, unarchivedSessionIds: r.unarchivedSessionIds, ...(workspace ? { workspace } : {}) };
282
287
  }
283
288
  catch (e) {
284
289
  return { ok: false, error: deps.message(e) };
@@ -564,6 +569,8 @@ export function buildSessionOps(deps) {
564
569
  },
565
570
  // 把选中的归档会话导出为可再导入的转录文件(Markdown / JSONL),写入指定目录。
566
571
  // 每个会话一个文件;读不到正文的冷会话列入 skipped,不中断其余导出。
572
+ // `detail` 三档**累进**:`simple`(默认)只有正文;`tools` 另加工具调用与结果、附件名;
573
+ // `full` 再加思考。
567
574
  'history-export': async (args) => {
568
575
  const rawIds = (args && args.sessionIds) || [];
569
576
  const ids = Array.isArray(rawIds) ? rawIds.map((s) => String(s).trim()).filter(Boolean) : [];
@@ -572,6 +579,11 @@ export function buildSessionOps(deps) {
572
579
  const format = String((args && args.format) || 'markdown').toLowerCase();
573
580
  if (format !== 'markdown' && format !== 'jsonl')
574
581
  return { ok: false, error: 'format 需为 markdown 或 jsonl' };
582
+ // 收到不认识的档位就出声:静默降级会让用户以为导出的是带工具的版本,而它其实只有正文。
583
+ const rawDetail = String((args && args.detail) || 'simple').toLowerCase();
584
+ if (rawDetail !== 'simple' && rawDetail !== 'tools' && rawDetail !== 'full')
585
+ return { ok: false, error: 'detail 需为 simple / tools / full' };
586
+ const detail = rawDetail;
575
587
  const outDir = String((args && args.outDir) || '').trim();
576
588
  if (!/^([A-Za-z]:[\\/]|\\\\|\/)/.test(outDir))
577
589
  return { ok: false, error: '导出目录需为绝对路径' };
@@ -627,9 +639,13 @@ export function buildSessionOps(deps) {
627
639
  const path = join(outDir, fileName);
628
640
  try {
629
641
  const events = await readEvents(sessionId);
630
- const turns = deps.extractTurnsFromEvents(events || []);
642
+ const turns = deps.extractTurnsFromEvents(events || [], detail);
631
643
  if (!turns.length) {
632
- skipped.push({ sessionId, error: events ? '会话中没有可导出的消息' : '无法读取会话内容(不在活动存储且无持久化句柄)' });
644
+ // 简洁档现在**不含思考与工具**(0.18.5 之前思考被混在正文里),所以"只有思考没正文"
645
+ // 的会话会第一次变成导不出来 —— 得说清楚是档位问题,不是内容丢了。
646
+ skipped.push({ sessionId, error: !events ? '无法读取会话内容(不在活动存储且无持久化句柄)'
647
+ : detail !== 'full' ? '会话中没有可导出的正文(思考与工具调用不计入本档 —— 换更高一档可以导出它们)'
648
+ : '会话中没有可导出的消息' });
633
649
  continue;
634
650
  }
635
651
  // `'wx'`:绝不覆盖已存在的同名文件 —— 覆盖是静默的,用户会以为导出成功