@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
@@ -83,6 +83,7 @@ export function createHttpSink(options) {
83
83
  step: (step) => enqueue({ type: 'step', payload: step, at: Date.now() }),
84
84
  stream: (delta) => enqueue({ type: 'stream', payload: delta, at: Date.now() }),
85
85
  log: (message) => enqueue({ type: 'log', payload: { message }, at: Date.now() }),
86
+ cost: (event) => enqueue({ type: 'cost', payload: event, at: Date.now() }),
86
87
  async flush() {
87
88
  while (queue.length > 0)
88
89
  await flush();
@@ -0,0 +1,115 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ /**
4
+ * 3.3.0 · A1 spill 存储(设计 §6「强制约束」)。
5
+ *
6
+ * 「一次压缩后原文仍可经 spill 文件回读」——大输出先落盘,模型只看到摘要 + 回读路径,
7
+ * 避免「省 token 换瞎眼」。
8
+ *
9
+ * 安全边界:
10
+ * * 只能写在 `<workspace>/.dsh-master/spill/` 之内(路径由本模块生成,不接受外部传入);
11
+ * * 写入前由调用方**脱敏**(Agent Loop 传入已 redact 的文本);
12
+ * * 总量与单文件都有上限,超出后按最旧淘汰(有界,不会把分身磁盘写满);
13
+ * * 文件名只含 `ticketId` 的安全片段 + step + 序号,不反射工具参数。
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ const safeSegment = (value, fallback) => {
18
+ // 只保留字母数字与 _-:点号也剔除,确保文件名里不可能出现 `..` 片段。
19
+ const cleaned = String(value ?? '').replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 48);
20
+ return cleaned === '' ? fallback : cleaned;
21
+ };
22
+ export function createSpillStore(options) {
23
+ const workspace = path.resolve(String(options.workspace));
24
+ const relDir = String(options.dir ?? path.join('.dsh-master', 'spill'));
25
+ const dir = path.resolve(workspace, relDir);
26
+ // 路径 jail:解析后必须仍在 workspace 内。
27
+ const inside = dir === workspace || dir.startsWith(`${workspace}${path.sep}`);
28
+ const maxFileBytes = Math.max(1024, Number(options.maxFileBytes ?? 512 * 1024) || 512 * 1024);
29
+ const maxTotalBytes = Math.max(maxFileBytes, Number(options.maxTotalBytes ?? 8 * 1024 * 1024) || 8 * 1024 * 1024);
30
+ const maxFiles = Math.max(1, Number(options.maxFiles ?? 40) || 40);
31
+ const clock = typeof options.clock === 'function' ? options.clock : () => Date.now();
32
+ let seq = 0;
33
+ function listFiles() {
34
+ try {
35
+ return fs.readdirSync(dir)
36
+ .map((name) => {
37
+ try {
38
+ const stat = fs.statSync(path.join(dir, name));
39
+ return stat.isFile() ? { name, size: stat.size, at: stat.mtimeMs } : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ })
45
+ .filter((item) => item !== null);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ }
51
+ /** 淘汰:文件数 / 总字节超限时按最旧删除。 */
52
+ function evict() {
53
+ const files = listFiles().sort((a, b) => a.at - b.at);
54
+ let total = files.reduce((sum, file) => sum + file.size, 0);
55
+ let count = files.length;
56
+ for (const file of files) {
57
+ if (count <= maxFiles && total <= maxTotalBytes)
58
+ break;
59
+ try {
60
+ fs.rmSync(path.join(dir, file.name), { force: true });
61
+ }
62
+ catch { /* ignore */ }
63
+ total -= file.size;
64
+ count -= 1;
65
+ }
66
+ }
67
+ function save(input) {
68
+ if (!inside)
69
+ return null;
70
+ const text = String(input?.text ?? '');
71
+ if (text.length === 0)
72
+ return null;
73
+ const bytes = Buffer.byteLength(text, 'utf8');
74
+ seq += 1;
75
+ const name = `${safeSegment(input?.ticketId ?? '', 'ticket')}__s${Number(input?.stepIndex) || 0}__${seq}__${safeSegment(input?.toolName ?? 'tool', 'tool')}.txt`;
76
+ const abs = path.resolve(dir, name);
77
+ if (!abs.startsWith(`${dir}${path.sep}`))
78
+ return null;
79
+ try {
80
+ fs.mkdirSync(dir, { recursive: true });
81
+ // 单文件超限:拒绝落盘(调用方按「无法回读」降级,不静默截断原文)。
82
+ if (bytes > maxFileBytes)
83
+ return null;
84
+ fs.writeFileSync(abs, text, 'utf8');
85
+ evict();
86
+ return { relPath: path.relative(workspace, abs).split(path.sep).join('/'), bytes, lines: text.split('\n').length };
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ function read(relPath) {
93
+ const abs = path.resolve(workspace, String(relPath ?? ''));
94
+ if (!abs.startsWith(`${workspace}${path.sep}`))
95
+ return null;
96
+ try {
97
+ return fs.readFileSync(abs, 'utf8');
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ function stats() {
104
+ const files = listFiles();
105
+ return { dir: path.relative(workspace, dir).split(path.sep).join('/'), files: files.length, bytes: files.reduce((sum, file) => sum + file.size, 0) };
106
+ }
107
+ function clear() {
108
+ try {
109
+ fs.rmSync(dir, { recursive: true, force: true });
110
+ }
111
+ catch { /* ignore */ }
112
+ void clock;
113
+ }
114
+ return Object.freeze({ save, read, stats, clear });
115
+ }
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ /** 单次批量教学文本(一次性;之后不再重复注入)。 */
4
+ export const BATCH_GUIDANCE = [
5
+ '【批量调用提示(一次性)】',
6
+ '你可以在同一轮里返回多个 tool_calls:它们会按返回顺序依次执行,每个结果都会回灌给你。',
7
+ '把互不依赖的读取/检索/写入合并到同一轮,能减少往返次数;有依赖时必须分轮。',
8
+ '不要把一个列表塞进单个工具的参数里伪装成批量(例如把多个路径写进一个 path 字段);',
9
+ '请直接发多个 tool_calls。',
10
+ ].join('\n');
11
+ export function createBatchAdvisor(options = {}) {
12
+ const mode = options.mode === 'ptc' ? 'ptc' : 'native';
13
+ const maxBatchPerTurn = Math.max(1, Number(options.maxBatchPerTurn ?? 4) || 4);
14
+ const enabled = options.enabled !== false;
15
+ let taught = false;
16
+ let corrections = 0;
17
+ // 本项目 Worker 走 chat/HTTP 工具回路,没有 PTC(程序化工具调用)执行器:
18
+ // 请求 PTC ⇒ 显式 skipped,而不是偷偷降级成 native。
19
+ const unsupported = mode === 'ptc';
20
+ const status = () => (enabled && !unsupported
21
+ ? { state: 'active', reason: null, mode, maxBatchPerTurn }
22
+ : { state: 'skipped', reason: unsupported ? 'unsupported-host' : 'disabled-by-config', mode, maxBatchPerTurn });
23
+ function review(calls) {
24
+ if (status().state !== 'active')
25
+ return { ok: true, correction: null, reason: null };
26
+ const list = Array.isArray(calls) ? calls : [];
27
+ if (list.length > maxBatchPerTurn) {
28
+ corrections += 1;
29
+ return {
30
+ ok: false,
31
+ reason: 'batch-too-large',
32
+ correction: `【批量形状纠偏】本轮 ${list.length} 个 tool_calls 超过单轮上限 ${maxBatchPerTurn};已全部按序执行,下一轮请拆分。`,
33
+ };
34
+ }
35
+ // 形状纠偏:单个工具的参数里出现「看起来像批量」的数组(>=2 项字符串),提示改发多个 tool_calls。
36
+ for (const call of list) {
37
+ let args = {};
38
+ try {
39
+ const parsed = JSON.parse(String(call?.args ?? '{}'));
40
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
41
+ args = parsed;
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ const arrayish = Object.entries(args).filter(([, value]) => Array.isArray(value) && value.length >= 2 && value.every((item) => typeof item === 'string'));
47
+ if (arrayish.length > 0 && list.length === 1) {
48
+ corrections += 1;
49
+ return {
50
+ ok: false,
51
+ reason: 'array-faked-batch',
52
+ correction: `【批量形状纠偏】把多个值塞进了 ${call.name} 的 ${arrayish.map(([key]) => key).join('/')} 参数;这不会被执行器当成批量。下一轮请改为在同一轮返回多个 tool_calls。`,
53
+ };
54
+ }
55
+ }
56
+ return { ok: true, correction: null, reason: null };
57
+ }
58
+ return Object.freeze({
59
+ status,
60
+ guidance: () => BATCH_GUIDANCE,
61
+ review,
62
+ taught: () => taught,
63
+ markTaught: () => { taught = true; },
64
+ stats: () => ({ taught, corrections }),
65
+ });
66
+ }
@@ -0,0 +1,275 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ /**
4
+ * 3.3.0 模块层 A · A1 `tool-compact`(工具输出紧凑化)。
5
+ *
6
+ * 只处理**已知内置工具**的输出形状,未知工具一律原样透传。三档模式:
7
+ * * `off` —— 不计算;
8
+ * * `shadow` —— 真算「本可节省多少」,但**仍返回原文**(默认档,零行为变化);
9
+ * * `enforce` —— 紧凑结果确实更短时才替换。
10
+ *
11
+ * 安全边界:紧凑化只作用于模型可见的工具回执文本,不改工具执行、不改权限、
12
+ * 不改错误码;`applied` 仅在 `compactChars < originalChars` 时为 true。
13
+ *
14
+ * 归因(设计 §5.4):`estimatedTokens` 不是 `savedChars/4` 的近似,而是
15
+ * `baselineOutputTokens(toolName, 原始长度) − actualOutputTokens(紧凑长度)`,
16
+ * 拟合参数在 `worker/baseline-fit.ts` 冻结、版本随参数变更。
17
+ */
18
+ import { attributedSavedTokens, A1_FIT_VERSION } from "./baseline-fit.js";
19
+ const MODES = Object.freeze(['off', 'shadow', 'enforce']);
20
+ export function normalizeCompactMode(value) {
21
+ const raw = String(value ?? '').trim().toLowerCase();
22
+ return MODES.includes(raw) ? raw : 'shadow';
23
+ }
24
+ function compactListDir(text) {
25
+ if (text === '(empty)')
26
+ return text;
27
+ const lines = text.split('\n');
28
+ const compact = [];
29
+ for (const line of lines) {
30
+ if (line.startsWith('dir '))
31
+ compact.push(`${line.slice(4)}/`);
32
+ else if (line.startsWith('file '))
33
+ compact.push(line.slice(5));
34
+ else
35
+ return null; // 未知形状:不猜
36
+ }
37
+ return compact.join('\n');
38
+ }
39
+ function compactWriteFile(text) {
40
+ const match = /^written (\d+) bytes to (.+)$/u.exec(text);
41
+ if (!match)
42
+ return null;
43
+ return `ok ${match[2]} (+${match[1]} B)`;
44
+ }
45
+ function compactShell(text) {
46
+ if (!/^STDOUT:/u.test(text))
47
+ return null;
48
+ const match = /^STDOUT:\n([\s\S]*?)\nSTDERR:\n([\s\S]*)$/u.exec(text);
49
+ if (!match)
50
+ return null;
51
+ const stdout = match[1].replace(/[ \t]+$/gmu, '').replace(/\n{3,}/gu, '\n\n').trim();
52
+ const stderr = match[2].replace(/[ \t]+$/gmu, '').replace(/\n{3,}/gu, '\n\n').trim();
53
+ if (!stdout && !stderr)
54
+ return '(no output)';
55
+ // grep -n 形态(path:line:text)优先进「命中行合并」,能省掉重复文件名。
56
+ if (!stderr) {
57
+ const grep = compactGrepLike(stdout);
58
+ if (grep !== null)
59
+ return grep;
60
+ }
61
+ if (!stderr)
62
+ return stdout;
63
+ if (!stdout)
64
+ return `[stderr]\n${stderr}`;
65
+ return `${stdout}\n[stderr]\n${stderr}`;
66
+ }
67
+ /**
68
+ * 文件读取:**去冗余空行但保留行号**。
69
+ *
70
+ * 行号本身要花字符,所以这里算两个候选,取更短者(且必须短于原文才替换):
71
+ * a) 带行号 + 空行游程压缩 —— 定位性最好;
72
+ * b) 只压空白/空行、不带行号 —— 兜底,保证绝不「越压越长」。
73
+ */
74
+ function compactReadFile(text) {
75
+ if (text === '')
76
+ return null;
77
+ const lines = text.split('\n');
78
+ if (lines.length > 0 && lines[lines.length - 1] === '')
79
+ lines.pop();
80
+ const plainOut = [];
81
+ let plainBlank = 0;
82
+ for (const rawLine of lines) {
83
+ const line = rawLine.replace(/[ \t]+$/u, '');
84
+ if (line.trim() === '') {
85
+ plainBlank += 1;
86
+ if (plainBlank > 1)
87
+ continue;
88
+ }
89
+ else {
90
+ plainBlank = 0;
91
+ }
92
+ plainOut.push(line);
93
+ }
94
+ while (plainOut.length > 0 && plainOut[plainOut.length - 1] === '')
95
+ plainOut.pop();
96
+ const plain = plainOut.join('\n');
97
+ const numberedOut = [];
98
+ let blankStart = -1;
99
+ const flushBlanks = (endLine) => {
100
+ if (blankStart < 0)
101
+ return;
102
+ numberedOut.push(blankStart === endLine ? `⋯${blankStart}` : `⋯${blankStart}-${endLine}`);
103
+ blankStart = -1;
104
+ };
105
+ for (let index = 0; index < lines.length; index += 1) {
106
+ const lineNo = index + 1;
107
+ const line = lines[index].replace(/[ \t]+$/u, '');
108
+ if (line.trim() === '') {
109
+ if (blankStart < 0)
110
+ blankStart = lineNo;
111
+ continue;
112
+ }
113
+ flushBlanks(lineNo - 1);
114
+ numberedOut.push(`${lineNo}: ${line}`);
115
+ }
116
+ flushBlanks(lines.length);
117
+ const numbered = numberedOut.join('\n');
118
+ const candidates = [numbered, plain].filter((candidate) => candidate.length < text.length);
119
+ if (candidates.length === 0)
120
+ return null;
121
+ // 优先带行号(定位性),但它必须确实更短。
122
+ return candidates.includes(numbered) ? numbered : plain;
123
+ }
124
+ /* ------------------------------------------------------------------ *
125
+ * grep 形态(设计 §6:命中行 + 上下文一行)
126
+ *
127
+ * 本项目的 Worker 工具面暂无独立 grep 工具,但 shell 里的 `grep -n` 结果就是
128
+ * `path:line:text` 形态;这里对它做「同文件合并 + 每条命中带一处上下文」的压缩。
129
+ * ------------------------------------------------------------------ */
130
+ const GREP_LINE = /^([^\s:][^:]*):(\d+):(.*)$/u;
131
+ function compactGrepLike(text) {
132
+ const lines = text.split('\n').filter((line) => line !== '');
133
+ if (lines.length < 3)
134
+ return null;
135
+ const parsed = [];
136
+ for (const line of lines) {
137
+ const match = GREP_LINE.exec(line);
138
+ if (!match)
139
+ return null; // 形状不符:不猜,原样返回
140
+ parsed.push({ file: match[1], line: Number(match[2]), text: match[3].trim() });
141
+ }
142
+ const byFile = new Map();
143
+ for (const hit of parsed) {
144
+ const list = byFile.get(hit.file) ?? [];
145
+ list.push({ line: hit.line, text: hit.text });
146
+ byFile.set(hit.file, list);
147
+ }
148
+ const out = [];
149
+ for (const [file, hits] of byFile) {
150
+ const sorted = [...hits].sort((a, b) => a.line - b.line);
151
+ // 同文件命中合并成一行:`path: 12: text | 13: text`
152
+ out.push(`${file}: ${sorted.map((hit) => `${hit.line}: ${hit.text}`).join(' | ')}`);
153
+ }
154
+ const compact = out.join('\n');
155
+ return compact.length < text.length ? compact : null;
156
+ }
157
+ /* ------------------------------------------------------------------ *
158
+ * 错误回执:给「可执行的下一步」
159
+ * ------------------------------------------------------------------ */
160
+ const ERROR_HINTS = [
161
+ { match: /path-outside-mandate/u, hint: '改用 Mandate.scope 内的路径' },
162
+ { match: /sensitive-path-denied/u, hint: '该路径被禁止写入,换普通源码文件' },
163
+ { match: /file-too-large/u, hint: '先用 fs_list_dir 定位,再分段读取' },
164
+ { match: /not-a-file/u, hint: '该路径不是文件,先用 fs_list_dir 查看' },
165
+ { match: /content-too-large/u, hint: '拆成多次写入或改用片段修改' },
166
+ { match: /shell-(?:redirection|substitution|cd|parent-traversal|absolute-path|home-path|env-expansion)/u, hint: '去掉重定向/变量/绝对路径,只用单条只读命令' },
167
+ { match: /shell-git-write/u, hint: '写操作交给 create_pr,不要在 shell 里改远端' },
168
+ { match: /未授权执行 Shell|vmIsolation|隔离证明/u, hint: '当前 Manifest 未授权 Shell,改用文件工具' },
169
+ { match: /超时|aborted/u, hint: '缩小操作范围或拆分命令后重试' },
170
+ ];
171
+ function compactError(text) {
172
+ const isError = /^(?:执行报错|【系统拒绝】|错误[::]|Error[::])/u.test(text)
173
+ || /^(?:ENOENT|EACCES|EPERM|EISDIR|ENOTDIR)\b/u.test(text);
174
+ if (!isError)
175
+ return null;
176
+ const hint = ERROR_HINTS.find((item) => item.match.test(text))?.hint;
177
+ if (!hint)
178
+ return null;
179
+ // 保留原始错误码/首行(可定位),把冗长堆栈压掉,再补一条可执行建议。
180
+ const firstLine = text.split('\n').map((line) => line.trim()).filter(Boolean)[0] ?? '';
181
+ const compact = `${firstLine}\n→ 建议: ${hint}`;
182
+ return compact.length < text.length ? compact : null;
183
+ }
184
+ function formatterFor(toolName) {
185
+ if (toolName === 'fs_read_file')
186
+ return (text) => compactError(text) ?? compactReadFile(text);
187
+ if (toolName === 'fs_list_dir')
188
+ return (text) => compactError(text) ?? compactListDir(text);
189
+ if (toolName === 'fs_write_file')
190
+ return (text) => compactError(text) ?? compactWriteFile(text);
191
+ if (toolName === 'exec_shell_in_vm')
192
+ return (text) => compactError(text) ?? compactGrepLike(text) ?? compactShell(text);
193
+ return null;
194
+ }
195
+ /** 头尾摘要 + 回读提示(大输出用)。 */
196
+ function headTailSummary(text, spilled, head, tail) {
197
+ const lines = text.split('\n');
198
+ const headLines = lines.slice(0, head);
199
+ const tailLines = tail > 0 ? lines.slice(Math.max(head, lines.length - tail)) : [];
200
+ const marker = `⋯[已落盘 ${spilled.relPath}:${spilled.bytes} B / ${spilled.lines} 行;完整内容用 fs_read_file 读取该路径]`;
201
+ return [...headLines, marker, ...tailLines].join('\n');
202
+ }
203
+ export function createToolCompactor(options = {}) {
204
+ const mode = normalizeCompactMode(options.mode);
205
+ const spill = options.spill ?? null;
206
+ const spillThresholdChars = Math.max(500, Number(options.spillThresholdChars ?? 6000) || 6000);
207
+ const spillHeadLines = Math.max(1, Number(options.spillHeadLines ?? 20) || 20);
208
+ const spillTailLines = Math.max(0, Number(options.spillTailLines ?? 5) || 5);
209
+ function compact(input) {
210
+ const text = String(input?.text ?? '');
211
+ const toolName = String(input?.toolName ?? '');
212
+ // §6 强制约束:大输出先落盘(**只在 enforce**,shadow 不得产生副作用),再给摘要。
213
+ if (mode === 'enforce' && spill && text.length >= spillThresholdChars) {
214
+ const spilled = spill.save({ text });
215
+ if (spilled) {
216
+ const summary = headTailSummary(text, spilled, spillHeadLines, spillTailLines);
217
+ if (summary.length < text.length) {
218
+ return {
219
+ module: 'a1-compact',
220
+ toolName,
221
+ mode,
222
+ applicable: true,
223
+ evaluated: true,
224
+ applied: true,
225
+ text: summary,
226
+ originalChars: text.length,
227
+ compactChars: summary.length,
228
+ savedChars: text.length - summary.length,
229
+ estimatedTokens: attributedSavedTokens(toolName, text.length, summary.length),
230
+ fitVersion: A1_FIT_VERSION,
231
+ spill: spilled,
232
+ };
233
+ }
234
+ }
235
+ }
236
+ const base = {
237
+ module: 'a1-compact',
238
+ toolName,
239
+ mode,
240
+ applicable: false,
241
+ applied: false,
242
+ evaluated: false,
243
+ text,
244
+ originalChars: text.length,
245
+ compactChars: text.length,
246
+ savedChars: 0,
247
+ estimatedTokens: 0,
248
+ fitVersion: A1_FIT_VERSION,
249
+ };
250
+ if (mode === 'off')
251
+ return base;
252
+ const formatter = formatterFor(toolName);
253
+ if (!formatter)
254
+ return base;
255
+ const compacted = formatter(text);
256
+ if (compacted === null)
257
+ return { ...base, applicable: true, evaluated: true };
258
+ const compactChars = compacted.length;
259
+ const savedChars = Math.max(0, text.length - compactChars);
260
+ const applied = mode === 'enforce' && compactChars < text.length;
261
+ return {
262
+ ...base,
263
+ applicable: true,
264
+ evaluated: true,
265
+ applied,
266
+ text: applied ? compacted : text,
267
+ compactChars,
268
+ savedChars,
269
+ // §5.4:归因用固定拟合式的差额,而不是 savedChars/4 的近似。
270
+ estimatedTokens: attributedSavedTokens(toolName, text.length, compactChars),
271
+ fitVersion: A1_FIT_VERSION,
272
+ };
273
+ }
274
+ return Object.freeze({ mode, compact });
275
+ }
@@ -0,0 +1,90 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ /** 默认常驻:只保留最基础的四类能力 + 加载入口本身。 */
4
+ export const DEFAULT_RESIDENT_TOOLS = Object.freeze(['fs_read_file', 'fs_list_dir', 'fs_write_file', 'exec_shell_in_vm', 'load_tools']);
5
+ const tokensOf = (text) => (text.length > 0 ? Math.ceil(text.length / 4) : 0);
6
+ export function createDisclosureState(options = {}) {
7
+ const enabled = options.enabled === true;
8
+ const resident = new Set((Array.isArray(options.resident) && options.resident.length > 0 ? options.resident : DEFAULT_RESIDENT_TOOLS)
9
+ .map((name) => String(name ?? '')));
10
+ const allowlist = new Set((Array.isArray(options.allowlist) ? options.allowlist : []).map((name) => String(name ?? '')));
11
+ const hideNonResident = options.hideNonResident !== false;
12
+ const loadedSet = new Set();
13
+ let loadEvents = 0;
14
+ let schemaTokens = 0;
15
+ let schemaCount = 0;
16
+ function visible(allNames) {
17
+ const names = allNames.map((name) => String(name ?? '')).filter(Boolean);
18
+ // 关闭(或未启用隐藏)⇒ 恢复全量工具面:fail-open 到原行为。
19
+ if (!enabled || !hideNonResident)
20
+ return names;
21
+ return names.filter((name) => resident.has(name) || loadedSet.has(name));
22
+ }
23
+ function canLoad(name) {
24
+ const tool = String(name ?? '');
25
+ if (!tool)
26
+ return false;
27
+ if (resident.has(tool))
28
+ return true;
29
+ if (allowlist.size === 0)
30
+ return true;
31
+ return allowlist.has(tool);
32
+ }
33
+ function load(input) {
34
+ const requested = (Array.isArray(input?.names) ? input.names : []).map((name) => String(name ?? '')).filter(Boolean);
35
+ const newly = requested.filter((name) => !resident.has(name) && !loadedSet.has(name) && canLoad(name));
36
+ if (newly.length > 0) {
37
+ for (const name of newly)
38
+ loadedSet.add(name);
39
+ loadEvents += 1;
40
+ const schemas = (Array.isArray(input?.schemas) ? input.schemas : []).filter((schema) => newly.includes(String(schema?.name ?? '')));
41
+ schemaCount += schemas.length;
42
+ schemaTokens += tokensOf(JSON.stringify(schemas));
43
+ }
44
+ return { loaded: [...loadedSet], schemaTokens, schemaCount, events: loadEvents };
45
+ }
46
+ return Object.freeze({
47
+ enabled,
48
+ visible,
49
+ loaded: () => [...loadedSet],
50
+ load,
51
+ canLoad,
52
+ stats: () => ({ loadEvents, schemaTokens, schemaCount, loaded: [...loadedSet] }),
53
+ });
54
+ }
55
+ /**
56
+ * `load_tools` 元工具:模型用它「按需加载」高级工具,返回值里带完整 schema。
57
+ * 常驻于工具面(否则模型无法表达加载意图)。
58
+ */
59
+ export function createLoadToolsTool(state, tools) {
60
+ return {
61
+ name: 'load_tools',
62
+ description: '按需加载高级工具(返回其完整 schema)。参数 names 为工具名数组。',
63
+ parameters: {
64
+ type: 'object',
65
+ properties: { names: { type: 'array', items: { type: 'string' } } },
66
+ required: ['names'],
67
+ },
68
+ async execute(args) {
69
+ const requested = Array.isArray(args?.names) ? args.names.map((name) => String(name ?? '')) : [];
70
+ if (requested.length === 0)
71
+ return '执行报错: names 不能为空';
72
+ const known = new Set(tools.map((tool) => tool.name));
73
+ const unknown = requested.filter((name) => !known.has(name));
74
+ const denied = requested.filter((name) => known.has(name) && !state.canLoad(name));
75
+ const schemas = tools
76
+ .filter((tool) => requested.includes(tool.name) && known.has(tool.name) && state.canLoad(tool.name))
77
+ .map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
78
+ const result = state.load({ names: schemas.map((schema) => schema.name), schemas });
79
+ const lines = [];
80
+ if (schemas.length > 0)
81
+ lines.push(`已加载 ${schemas.length} 个工具,完整 schema:\n${JSON.stringify(schemas, null, 2)}`);
82
+ if (unknown.length > 0)
83
+ lines.push(`未知工具: ${unknown.join(', ')}`);
84
+ if (denied.length > 0)
85
+ lines.push(`不在 allowlist,拒绝加载: ${denied.join(', ')}`);
86
+ lines.push(`累计加载 ${result.events} 次,schema token ≈ ${result.schemaTokens}`);
87
+ return lines.join('\n');
88
+ },
89
+ };
90
+ }
@@ -17,6 +17,7 @@
17
17
  import fs from 'node:fs/promises';
18
18
  import path from 'node:path';
19
19
  import { spawn } from 'node:child_process';
20
+ import { schemaHashOf } from "./edg.js";
20
21
  export const LONG_SYNC_COMMAND_THRESHOLD_MS = 5000;
21
22
  const MAX_READ_BYTES = 256 * 1024;
22
23
  const MAX_WRITE_BYTES = 1024 * 1024;
@@ -303,6 +304,7 @@ function createPrTool(client) {
303
304
  return {
304
305
  name: 'create_pr',
305
306
  description: '在允许的仓库创建一个 Pull Request(需要宿主注入 PR 客户端;默认不可用)',
307
+ destructive: true,
306
308
  parameters: {
307
309
  type: 'object',
308
310
  properties: {
@@ -414,6 +416,7 @@ export function createSandboxTools(options) {
414
416
  {
415
417
  name: 'fs_write_file',
416
418
  description: '写入文件(workspace 内且位于 Mandate.scope;拒绝 .git/.env 等敏感路径)',
419
+ destructive: true,
417
420
  parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
418
421
  async execute(args, context) {
419
422
  // v3.2.3:Mandate 每次从 context 取,避免构造闭包与本次 Ticket 不一致。
@@ -438,6 +441,7 @@ export function createSandboxTools(options) {
438
441
  {
439
442
  name: 'exec_shell_in_vm',
440
443
  description: '在分身 workspace 内执行 Shell(需 Mandate.allowShell;脱敏环境、黑名单、进程树超时)',
444
+ destructive: true,
441
445
  parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
442
446
  async execute(args, context) {
443
447
  // P0-2 fail-closed:没有隔离证明就不允许 Shell(黑名单不是安全边界)。
@@ -497,9 +501,21 @@ export function createSandboxTools(options) {
497
501
  export function createToolRegistry(tools) {
498
502
  const byName = new Map(tools.map((tool) => [tool.name, tool]));
499
503
  return {
504
+ names() {
505
+ return tools.map((tool) => tool.name);
506
+ },
500
507
  schemas() {
501
508
  return tools.map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
502
509
  },
510
+ destructive(name) {
511
+ return byName.get(String(name))?.destructive === true;
512
+ },
513
+ schemaHash(name) {
514
+ const tool = byName.get(String(name));
515
+ if (!tool)
516
+ return '';
517
+ return schemaHashOf(tool.name, tool.parameters, tool.description);
518
+ },
503
519
  async execute(call, context) {
504
520
  const tool = byName.get(String(call?.name ?? ''));
505
521
  if (!tool)