@twinsearth/dsh-master 3.2.9 → 3.3.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.
Files changed (102) hide show
  1. package/README.md +39 -6
  2. package/client.js +51 -1
  3. package/dist-host/cost/attribution.js +183 -0
  4. package/dist-host/cost/audit.js +163 -0
  5. package/dist-host/cost/budget.js +211 -0
  6. package/dist-host/cost/bus.js +95 -0
  7. package/dist-host/cost/cert-chain.js +100 -0
  8. package/dist-host/cost/experiment.js +132 -0
  9. package/dist-host/cost/gate.js +189 -0
  10. package/dist-host/cost/host-surface.js +80 -0
  11. package/dist-host/cost/index.js +312 -0
  12. package/dist-host/cost/ledger-report.js +193 -0
  13. package/dist-host/cost/ledger.js +401 -0
  14. package/dist-host/cost/metrics.js +198 -0
  15. package/dist-host/cost/milestones.js +171 -0
  16. package/dist-host/cost/modules/index.js +5 -0
  17. package/dist-host/cost/modules/manifests.js +130 -0
  18. package/dist-host/cost/policy.js +276 -0
  19. package/dist-host/cost/report.js +156 -0
  20. package/dist-host/cost/reuse-report.js +128 -0
  21. package/dist-host/cost/rsi.js +367 -0
  22. package/dist-host/cost/toolface.js +147 -0
  23. package/dist-host/cost/types.js +31 -0
  24. package/dist-host/kernel.js +38 -11
  25. package/dist-host/plugins/cost-optimizer.js +277 -27
  26. package/dist-host/plugins/superagent-router.js +89 -2
  27. package/dist-host/plugins/superagent-worker.js +1 -1
  28. package/dist-host/superagent/router.js +1 -1
  29. package/dist-host/superagent/types.js +8 -0
  30. package/dist-worker/agent-loop.js +449 -7
  31. package/dist-worker/baseline-fit.js +66 -0
  32. package/dist-worker/budget-guard.js +93 -0
  33. package/dist-worker/ccs.js +99 -0
  34. package/dist-worker/cost-hooks.js +115 -0
  35. package/dist-worker/cost-modules.js +330 -0
  36. package/dist-worker/cost-spi.js +3 -0
  37. package/dist-worker/edg.js +372 -0
  38. package/dist-worker/gdr.js +159 -0
  39. package/dist-worker/index.js +80 -2
  40. package/dist-worker/llm-http.js +20 -0
  41. package/dist-worker/sink-http.js +1 -0
  42. package/dist-worker/spill.js +115 -0
  43. package/dist-worker/tool-batch.js +66 -0
  44. package/dist-worker/tool-compact.js +275 -0
  45. package/dist-worker/tool-disclosure.js +90 -0
  46. package/dist-worker/tools.js +16 -0
  47. package/docs/ARCHITECTURE.md +32 -11
  48. package/docs/CHANGELOG.md +60 -0
  49. package/docs/DESIGN-v3.3.0-cost-optimization.md +1053 -0
  50. package/docs/EXPERIMENT-M5-real-ab.md +82 -0
  51. package/docs/RELEASE-v3.2.10.md +32 -0
  52. package/docs/ROADMAP.md +3 -3
  53. package/docs/SECURITY.md +9 -9
  54. package/docs/SUPERAGENT.md +5 -5
  55. package/docs/THIRD-PARTY.md +87 -0
  56. package/docs/WORKER-AGENT-LOOP.md +1 -1
  57. package/package.json +15 -3
  58. package/src/cost/attribution.ts +186 -0
  59. package/src/cost/audit.ts +170 -0
  60. package/src/cost/budget.ts +226 -0
  61. package/src/cost/bus.ts +97 -0
  62. package/src/cost/cert-chain.ts +102 -0
  63. package/src/cost/experiment.ts +136 -0
  64. package/src/cost/gate.ts +191 -0
  65. package/src/cost/host-surface.ts +97 -0
  66. package/src/cost/index.ts +316 -0
  67. package/src/cost/ledger-report.ts +186 -0
  68. package/src/cost/ledger.ts +385 -0
  69. package/src/cost/metrics.ts +193 -0
  70. package/src/cost/milestones.ts +170 -0
  71. package/src/cost/modules/index.ts +13 -0
  72. package/src/cost/modules/manifests.ts +129 -0
  73. package/src/cost/policy.ts +276 -0
  74. package/src/cost/report.ts +158 -0
  75. package/src/cost/reuse-report.ts +131 -0
  76. package/src/cost/rsi.ts +364 -0
  77. package/src/cost/toolface.ts +151 -0
  78. package/src/cost/types.ts +298 -0
  79. package/src/kernel.ts +37 -11
  80. package/src/plugins/cost-optimizer.ts +271 -26
  81. package/src/plugins/superagent-router.ts +83 -2
  82. package/src/plugins/superagent-worker.ts +1 -1
  83. package/src/superagent/router.ts +1 -1
  84. package/src/superagent/types.ts +70 -0
  85. package/worker/agent-loop.ts +444 -7
  86. package/worker/baseline-fit.ts +80 -0
  87. package/worker/budget-guard.ts +156 -0
  88. package/worker/ccs.ts +202 -0
  89. package/worker/cost-hooks.ts +133 -0
  90. package/worker/cost-modules.ts +356 -0
  91. package/worker/cost-spi.ts +283 -0
  92. package/worker/edg.ts +424 -0
  93. package/worker/gdr.ts +233 -0
  94. package/worker/index.ts +84 -3
  95. package/worker/llm-http.ts +18 -0
  96. package/worker/sink-http.ts +5 -2
  97. package/worker/spill.ts +133 -0
  98. package/worker/tool-batch.ts +124 -0
  99. package/worker/tool-compact.ts +317 -0
  100. package/worker/tool-disclosure.ts +131 -0
  101. package/worker/tools.ts +553 -531
  102. package/worker/types.ts +191 -0
@@ -0,0 +1,317 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+
4
+ /**
5
+ * 3.3.0 模块层 A · A1 `tool-compact`(工具输出紧凑化)。
6
+ *
7
+ * 只处理**已知内置工具**的输出形状,未知工具一律原样透传。三档模式:
8
+ * * `off` —— 不计算;
9
+ * * `shadow` —— 真算「本可节省多少」,但**仍返回原文**(默认档,零行为变化);
10
+ * * `enforce` —— 紧凑结果确实更短时才替换。
11
+ *
12
+ * 安全边界:紧凑化只作用于模型可见的工具回执文本,不改工具执行、不改权限、
13
+ * 不改错误码;`applied` 仅在 `compactChars < originalChars` 时为 true。
14
+ *
15
+ * 归因(设计 §5.4):`estimatedTokens` 不是 `savedChars/4` 的近似,而是
16
+ * `baselineOutputTokens(toolName, 原始长度) − actualOutputTokens(紧凑长度)`,
17
+ * 拟合参数在 `worker/baseline-fit.ts` 冻结、版本随参数变更。
18
+ */
19
+
20
+ import { attributedSavedTokens, A1_FIT_VERSION } from './baseline-fit.ts'
21
+
22
+ export type CompactMode = 'off' | 'shadow' | 'enforce'
23
+
24
+ export interface CompactInput {
25
+ toolName: string
26
+ args?: Record<string, unknown>
27
+ text: string
28
+ }
29
+
30
+ export interface CompactResult {
31
+ module: 'a1-compact'
32
+ toolName: string
33
+ mode: CompactMode
34
+ /** 是否是本模块能处理的工具(未知工具为 false)。 */
35
+ applicable: boolean
36
+ /** 是否真的替换了输出(仅 enforce 且更短时为 true)。 */
37
+ applied: boolean
38
+ evaluated: boolean
39
+ text: string
40
+ originalChars: number
41
+ compactChars: number
42
+ savedChars: number
43
+ /** §5.4 归因:固定拟合式给出的节省 token(baseline − actual)。 */
44
+ estimatedTokens: number
45
+ /** 拟合式版本;报表据此判断归因口径。 */
46
+ fitVersion: string
47
+ /** 大输出落盘信息(有则说明原文可回读)。 */
48
+ spill?: { relPath: string; bytes: number; lines: number }
49
+ }
50
+
51
+ const MODES: readonly CompactMode[] = Object.freeze(['off', 'shadow', 'enforce'])
52
+
53
+ export function normalizeCompactMode(value?: string): CompactMode {
54
+ const raw = String(value ?? '').trim().toLowerCase()
55
+ return (MODES as readonly string[]).includes(raw) ? (raw as CompactMode) : 'shadow'
56
+ }
57
+
58
+ function compactListDir(text: string): string | null {
59
+ if (text === '(empty)') return text
60
+ const lines = text.split('\n')
61
+ const compact = []
62
+ for (const line of lines) {
63
+ if (line.startsWith('dir ')) compact.push(`${line.slice(4)}/`)
64
+ else if (line.startsWith('file ')) compact.push(line.slice(5))
65
+ else return null // 未知形状:不猜
66
+ }
67
+ return compact.join('\n')
68
+ }
69
+
70
+ function compactWriteFile(text: string): string | null {
71
+ const match = /^written (\d+) bytes to (.+)$/u.exec(text)
72
+ if (!match) return null
73
+ return `ok ${match[2]} (+${match[1]} B)`
74
+ }
75
+
76
+ function compactShell(text: string): string | null {
77
+ if (!/^STDOUT:/u.test(text)) return null
78
+ const match = /^STDOUT:\n([\s\S]*?)\nSTDERR:\n([\s\S]*)$/u.exec(text)
79
+ if (!match) return null
80
+ const stdout = match[1].replace(/[ \t]+$/gmu, '').replace(/\n{3,}/gu, '\n\n').trim()
81
+ const stderr = match[2].replace(/[ \t]+$/gmu, '').replace(/\n{3,}/gu, '\n\n').trim()
82
+ if (!stdout && !stderr) return '(no output)'
83
+ // grep -n 形态(path:line:text)优先进「命中行合并」,能省掉重复文件名。
84
+ if (!stderr) {
85
+ const grep = compactGrepLike(stdout)
86
+ if (grep !== null) return grep
87
+ }
88
+ if (!stderr) return stdout
89
+ if (!stdout) return `[stderr]\n${stderr}`
90
+ return `${stdout}\n[stderr]\n${stderr}`
91
+ }
92
+
93
+ /**
94
+ * 文件读取:**去冗余空行但保留行号**。
95
+ *
96
+ * 行号本身要花字符,所以这里算两个候选,取更短者(且必须短于原文才替换):
97
+ * a) 带行号 + 空行游程压缩 —— 定位性最好;
98
+ * b) 只压空白/空行、不带行号 —— 兜底,保证绝不「越压越长」。
99
+ */
100
+ function compactReadFile(text: string): string | null {
101
+ if (text === '') return null
102
+ const lines = text.split('\n')
103
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
104
+
105
+ const plainOut: string[] = []
106
+ let plainBlank = 0
107
+ for (const rawLine of lines) {
108
+ const line = rawLine.replace(/[ \t]+$/u, '')
109
+ if (line.trim() === '') {
110
+ plainBlank += 1
111
+ if (plainBlank > 1) continue
112
+ } else {
113
+ plainBlank = 0
114
+ }
115
+ plainOut.push(line)
116
+ }
117
+ while (plainOut.length > 0 && plainOut[plainOut.length - 1] === '') plainOut.pop()
118
+ const plain = plainOut.join('\n')
119
+
120
+ const numberedOut: string[] = []
121
+ let blankStart = -1
122
+ const flushBlanks = (endLine: number) => {
123
+ if (blankStart < 0) return
124
+ numberedOut.push(blankStart === endLine ? `⋯${blankStart}` : `⋯${blankStart}-${endLine}`)
125
+ blankStart = -1
126
+ }
127
+ for (let index = 0; index < lines.length; index += 1) {
128
+ const lineNo = index + 1
129
+ const line = lines[index].replace(/[ \t]+$/u, '')
130
+ if (line.trim() === '') {
131
+ if (blankStart < 0) blankStart = lineNo
132
+ continue
133
+ }
134
+ flushBlanks(lineNo - 1)
135
+ numberedOut.push(`${lineNo}: ${line}`)
136
+ }
137
+ flushBlanks(lines.length)
138
+ const numbered = numberedOut.join('\n')
139
+
140
+ const candidates = [numbered, plain].filter((candidate) => candidate.length < text.length)
141
+ if (candidates.length === 0) return null
142
+ // 优先带行号(定位性),但它必须确实更短。
143
+ return candidates.includes(numbered) ? numbered : plain
144
+ }
145
+
146
+ /* ------------------------------------------------------------------ *
147
+ * grep 形态(设计 §6:命中行 + 上下文一行)
148
+ *
149
+ * 本项目的 Worker 工具面暂无独立 grep 工具,但 shell 里的 `grep -n` 结果就是
150
+ * `path:line:text` 形态;这里对它做「同文件合并 + 每条命中带一处上下文」的压缩。
151
+ * ------------------------------------------------------------------ */
152
+
153
+ const GREP_LINE = /^([^\s:][^:]*):(\d+):(.*)$/u
154
+
155
+ function compactGrepLike(text: string): string | null {
156
+ const lines = text.split('\n').filter((line) => line !== '')
157
+ if (lines.length < 3) return null
158
+ const parsed = []
159
+ for (const line of lines) {
160
+ const match = GREP_LINE.exec(line)
161
+ if (!match) return null // 形状不符:不猜,原样返回
162
+ parsed.push({ file: match[1], line: Number(match[2]), text: match[3].trim() })
163
+ }
164
+ const byFile = new Map<string, Array<{ line: number; text: string }>>()
165
+ for (const hit of parsed) {
166
+ const list = byFile.get(hit.file) ?? []
167
+ list.push({ line: hit.line, text: hit.text })
168
+ byFile.set(hit.file, list)
169
+ }
170
+ const out: string[] = []
171
+ for (const [file, hits] of byFile) {
172
+ const sorted = [...hits].sort((a, b) => a.line - b.line)
173
+ // 同文件命中合并成一行:`path: 12: text | 13: text`
174
+ out.push(`${file}: ${sorted.map((hit) => `${hit.line}: ${hit.text}`).join(' | ')}`)
175
+ }
176
+ const compact = out.join('\n')
177
+ return compact.length < text.length ? compact : null
178
+ }
179
+
180
+ /* ------------------------------------------------------------------ *
181
+ * 错误回执:给「可执行的下一步」
182
+ * ------------------------------------------------------------------ */
183
+
184
+ const ERROR_HINTS: Array<{ match: RegExp; hint: string }> = [
185
+ { match: /path-outside-mandate/u, hint: '改用 Mandate.scope 内的路径' },
186
+ { match: /sensitive-path-denied/u, hint: '该路径被禁止写入,换普通源码文件' },
187
+ { match: /file-too-large/u, hint: '先用 fs_list_dir 定位,再分段读取' },
188
+ { match: /not-a-file/u, hint: '该路径不是文件,先用 fs_list_dir 查看' },
189
+ { match: /content-too-large/u, hint: '拆成多次写入或改用片段修改' },
190
+ { match: /shell-(?:redirection|substitution|cd|parent-traversal|absolute-path|home-path|env-expansion)/u, hint: '去掉重定向/变量/绝对路径,只用单条只读命令' },
191
+ { match: /shell-git-write/u, hint: '写操作交给 create_pr,不要在 shell 里改远端' },
192
+ { match: /未授权执行 Shell|vmIsolation|隔离证明/u, hint: '当前 Manifest 未授权 Shell,改用文件工具' },
193
+ { match: /超时|aborted/u, hint: '缩小操作范围或拆分命令后重试' },
194
+ ]
195
+
196
+ function compactError(text: string): string | null {
197
+ const isError = /^(?:执行报错|【系统拒绝】|错误[::]|Error[::])/u.test(text)
198
+ || /^(?:ENOENT|EACCES|EPERM|EISDIR|ENOTDIR)\b/u.test(text)
199
+ if (!isError) return null
200
+ const hint = ERROR_HINTS.find((item) => item.match.test(text))?.hint
201
+ if (!hint) return null
202
+ // 保留原始错误码/首行(可定位),把冗长堆栈压掉,再补一条可执行建议。
203
+ const firstLine = text.split('\n').map((line) => line.trim()).filter(Boolean)[0] ?? ''
204
+ const compact = `${firstLine}\n→ 建议: ${hint}`
205
+ return compact.length < text.length ? compact : null
206
+ }
207
+
208
+ function formatterFor(toolName: string): ((text: string) => string | null) | null {
209
+ if (toolName === 'fs_read_file') return (text) => compactError(text) ?? compactReadFile(text)
210
+ if (toolName === 'fs_list_dir') return (text) => compactError(text) ?? compactListDir(text)
211
+ if (toolName === 'fs_write_file') return (text) => compactError(text) ?? compactWriteFile(text)
212
+ if (toolName === 'exec_shell_in_vm') return (text) => compactError(text) ?? compactGrepLike(text) ?? compactShell(text)
213
+ return null
214
+ }
215
+
216
+ /** 大输出落盘后再给摘要(§6 强制约束:原文仍可经 spill 文件回读)。 */
217
+ export interface SpillSink {
218
+ save(input: { text: string }): { relPath: string; bytes: number; lines: number } | null
219
+ }
220
+
221
+ export interface ToolCompactorOptions {
222
+ mode?: string
223
+ /** 提供后,超过阈值的回执先落盘再摘要(仅在 enforce 生效)。 */
224
+ spill?: SpillSink | null
225
+ /** 触发落盘的最小字符数(默认 6000)。 */
226
+ spillThresholdChars?: number
227
+ /** 摘要保留的头/尾行数。 */
228
+ spillHeadLines?: number
229
+ spillTailLines?: number
230
+ }
231
+
232
+ export interface ToolCompactor {
233
+ mode: CompactMode
234
+ compact(input: CompactInput): CompactResult
235
+ }
236
+
237
+ /** 头尾摘要 + 回读提示(大输出用)。 */
238
+ function headTailSummary(text: string, spilled: { relPath: string; bytes: number; lines: number }, head: number, tail: number): string {
239
+ const lines = text.split('\n')
240
+ const headLines = lines.slice(0, head)
241
+ const tailLines = tail > 0 ? lines.slice(Math.max(head, lines.length - tail)) : []
242
+ const marker = `⋯[已落盘 ${spilled.relPath}:${spilled.bytes} B / ${spilled.lines} 行;完整内容用 fs_read_file 读取该路径]`
243
+ return [...headLines, marker, ...tailLines].join('\n')
244
+ }
245
+
246
+ export function createToolCompactor(options: ToolCompactorOptions = {}): ToolCompactor {
247
+ const mode = normalizeCompactMode(options.mode)
248
+ const spill = options.spill ?? null
249
+ const spillThresholdChars = Math.max(500, Number(options.spillThresholdChars ?? 6000) || 6000)
250
+ const spillHeadLines = Math.max(1, Number(options.spillHeadLines ?? 20) || 20)
251
+ const spillTailLines = Math.max(0, Number(options.spillTailLines ?? 5) || 5)
252
+
253
+ function compact(input: CompactInput): CompactResult {
254
+ const text = String(input?.text ?? '')
255
+ const toolName = String(input?.toolName ?? '')
256
+ // §6 强制约束:大输出先落盘(**只在 enforce**,shadow 不得产生副作用),再给摘要。
257
+ if (mode === 'enforce' && spill && text.length >= spillThresholdChars) {
258
+ const spilled = spill.save({ text })
259
+ if (spilled) {
260
+ const summary = headTailSummary(text, spilled, spillHeadLines, spillTailLines)
261
+ if (summary.length < text.length) {
262
+ return {
263
+ module: 'a1-compact',
264
+ toolName,
265
+ mode,
266
+ applicable: true,
267
+ evaluated: true,
268
+ applied: true,
269
+ text: summary,
270
+ originalChars: text.length,
271
+ compactChars: summary.length,
272
+ savedChars: text.length - summary.length,
273
+ estimatedTokens: attributedSavedTokens(toolName, text.length, summary.length),
274
+ fitVersion: A1_FIT_VERSION,
275
+ spill: spilled,
276
+ }
277
+ }
278
+ }
279
+ }
280
+ const base: CompactResult = {
281
+ module: 'a1-compact',
282
+ toolName,
283
+ mode,
284
+ applicable: false,
285
+ applied: false,
286
+ evaluated: false,
287
+ text,
288
+ originalChars: text.length,
289
+ compactChars: text.length,
290
+ savedChars: 0,
291
+ estimatedTokens: 0,
292
+ fitVersion: A1_FIT_VERSION,
293
+ }
294
+ if (mode === 'off') return base
295
+ const formatter = formatterFor(toolName)
296
+ if (!formatter) return base
297
+ const compacted = formatter(text)
298
+ if (compacted === null) return { ...base, applicable: true, evaluated: true }
299
+ const compactChars = compacted.length
300
+ const savedChars = Math.max(0, text.length - compactChars)
301
+ const applied = mode === 'enforce' && compactChars < text.length
302
+ return {
303
+ ...base,
304
+ applicable: true,
305
+ evaluated: true,
306
+ applied,
307
+ text: applied ? compacted : text,
308
+ compactChars,
309
+ savedChars,
310
+ // §5.4:归因用固定拟合式的差额,而不是 savedChars/4 的近似。
311
+ estimatedTokens: attributedSavedTokens(toolName, text.length, compactChars),
312
+ fitVersion: A1_FIT_VERSION,
313
+ }
314
+ }
315
+
316
+ return Object.freeze({ mode, compact })
317
+ }
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+
4
+ /**
5
+ * 3.3.0 · A2 `tool-disclosure`(渐进式纰漏,设计 §6)。
6
+ *
7
+ * 目的:把**常驻工具面**收敛到有界集合(读/写/搜索/执行 + 少量必备),
8
+ * 高级工具(PDF/Word/批量改等)改为**按需加载**,避免 schema 常驻挤占 prompt。
9
+ *
10
+ * 纪律:
11
+ * * 加载动作**本身计成本**:一次加载带来的 schema token 记入 `governance` 账
12
+ * (归类:`phase:'authoring'` / `source:'authoring'` / `meta.kind:'tools.load'`),
13
+ * 报表里与 execution 节省并列展示,绝不混算成节省;
14
+ * * 开关 `enabled` + `allowlist`;**关闭时恢复全量工具面**(fail-open 到原行为);
15
+ * * 只有 `enforce` 档真的收敛工具面;`shadow` 只记录「本可收敛」。
16
+ */
17
+
18
+ import type { WorkerTool, ToolSchema } from './types.ts'
19
+
20
+ export interface DisclosureOptions {
21
+ enabled?: boolean
22
+ /** 常驻工具(有界集合)。未列出的工具视为「高级工具」,需按需加载。 */
23
+ resident?: string[]
24
+ /** allowlist:为空表示「所有非常驻工具都可按需加载」;非空则只允许加载列出的工具。 */
25
+ allowlist?: string[]
26
+ /** 常驻集合之外是否真的隐藏(false = 全量暴露,等价于关闭)。 */
27
+ hideNonResident?: boolean
28
+ }
29
+
30
+ /** 默认常驻:只保留最基础的四类能力 + 加载入口本身。 */
31
+ export const DEFAULT_RESIDENT_TOOLS = Object.freeze(['fs_read_file', 'fs_list_dir', 'fs_write_file', 'exec_shell_in_vm', 'load_tools'])
32
+
33
+ export interface DisclosureState {
34
+ readonly enabled: boolean
35
+ /** 当前可见(模型能看到 schema)的工具名。 */
36
+ visible(allNames: string[]): string[]
37
+ /** 已按需加载的工具。 */
38
+ loaded(): string[]
39
+ /** 记录一次加载;返回本次加载带来的 schema token 估算(0 表示无变化)。 */
40
+ load(input: { names: string[]; schemas: ToolSchema[] }): { loaded: string[]; schemaTokens: number; schemaCount: number; events: number }
41
+ /** 本任务累计加载成本(供 governance 记账)。 */
42
+ stats(): { loadEvents: number; schemaTokens: number; schemaCount: number; loaded: string[] }
43
+ /** 判定某工具是否允许被加载(allowlist 约束)。 */
44
+ canLoad(name: string): boolean
45
+ }
46
+
47
+ const tokensOf = (text: string): number => (text.length > 0 ? Math.ceil(text.length / 4) : 0)
48
+
49
+ export function createDisclosureState(options: DisclosureOptions = {}): DisclosureState {
50
+ const enabled = options.enabled === true
51
+ const resident = new Set(
52
+ (Array.isArray(options.resident) && options.resident.length > 0 ? options.resident : DEFAULT_RESIDENT_TOOLS)
53
+ .map((name) => String(name ?? '')),
54
+ )
55
+ const allowlist = new Set((Array.isArray(options.allowlist) ? options.allowlist : []).map((name) => String(name ?? '')))
56
+ const hideNonResident = options.hideNonResident !== false
57
+ const loadedSet = new Set<string>()
58
+ let loadEvents = 0
59
+ let schemaTokens = 0
60
+ let schemaCount = 0
61
+
62
+ function visible(allNames: string[]): string[] {
63
+ const names = allNames.map((name) => String(name ?? '')).filter(Boolean)
64
+ // 关闭(或未启用隐藏)⇒ 恢复全量工具面:fail-open 到原行为。
65
+ if (!enabled || !hideNonResident) return names
66
+ return names.filter((name) => resident.has(name) || loadedSet.has(name))
67
+ }
68
+
69
+ function canLoad(name: string): boolean {
70
+ const tool = String(name ?? '')
71
+ if (!tool) return false
72
+ if (resident.has(tool)) return true
73
+ if (allowlist.size === 0) return true
74
+ return allowlist.has(tool)
75
+ }
76
+
77
+ function load(input: { names: string[]; schemas: ToolSchema[] }): { loaded: string[]; schemaTokens: number; schemaCount: number; events: number } {
78
+ const requested = (Array.isArray(input?.names) ? input.names : []).map((name) => String(name ?? '')).filter(Boolean)
79
+ const newly = requested.filter((name) => !resident.has(name) && !loadedSet.has(name) && canLoad(name))
80
+ if (newly.length > 0) {
81
+ for (const name of newly) loadedSet.add(name)
82
+ loadEvents += 1
83
+ const schemas = (Array.isArray(input?.schemas) ? input.schemas : []).filter((schema) => newly.includes(String(schema?.name ?? '')))
84
+ schemaCount += schemas.length
85
+ schemaTokens += tokensOf(JSON.stringify(schemas))
86
+ }
87
+ return { loaded: [...loadedSet], schemaTokens, schemaCount, events: loadEvents }
88
+ }
89
+
90
+ return Object.freeze({
91
+ enabled,
92
+ visible,
93
+ loaded: () => [...loadedSet],
94
+ load,
95
+ canLoad,
96
+ stats: () => ({ loadEvents, schemaTokens, schemaCount, loaded: [...loadedSet] }),
97
+ })
98
+ }
99
+
100
+ /**
101
+ * `load_tools` 元工具:模型用它「按需加载」高级工具,返回值里带完整 schema。
102
+ * 常驻于工具面(否则模型无法表达加载意图)。
103
+ */
104
+ export function createLoadToolsTool(state: DisclosureState, tools: WorkerTool[]): WorkerTool {
105
+ return {
106
+ name: 'load_tools',
107
+ description: '按需加载高级工具(返回其完整 schema)。参数 names 为工具名数组。',
108
+ parameters: {
109
+ type: 'object',
110
+ properties: { names: { type: 'array', items: { type: 'string' } } },
111
+ required: ['names'],
112
+ },
113
+ async execute(args) {
114
+ const requested = Array.isArray(args?.names) ? args.names.map((name) => String(name ?? '')) : []
115
+ if (requested.length === 0) return '执行报错: names 不能为空'
116
+ const known = new Set(tools.map((tool) => tool.name))
117
+ const unknown = requested.filter((name) => !known.has(name))
118
+ const denied = requested.filter((name) => known.has(name) && !state.canLoad(name))
119
+ const schemas = tools
120
+ .filter((tool) => requested.includes(tool.name) && known.has(tool.name) && state.canLoad(tool.name))
121
+ .map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }))
122
+ const result = state.load({ names: schemas.map((schema) => schema.name), schemas })
123
+ const lines: string[] = []
124
+ if (schemas.length > 0) lines.push(`已加载 ${schemas.length} 个工具,完整 schema:\n${JSON.stringify(schemas, null, 2)}`)
125
+ if (unknown.length > 0) lines.push(`未知工具: ${unknown.join(', ')}`)
126
+ if (denied.length > 0) lines.push(`不在 allowlist,拒绝加载: ${denied.join(', ')}`)
127
+ lines.push(`累计加载 ${result.events} 次,schema token ≈ ${result.schemaTokens}`)
128
+ return lines.join('\n')
129
+ },
130
+ }
131
+ }