dsh-tool-folder 0.1.3 → 0.1.6

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/README.md CHANGED
@@ -20,6 +20,18 @@ replaced object is authoritative (model-selection plugin does the same).
20
20
  Execution is NOT gated on the current turn's tools — folded tools still
21
21
  resolve through the full registry — so folding never removes capability.
22
22
 
23
+ Feedback and the firewall bind to the *execution* events, not the prompt
24
+ assembly:
25
+ - `tools/pre-execute` — listener receives `(exec, next)`. Return
26
+ `{ kind: "deny", reason }` to refuse the call, or call `next()` to allow.
27
+ `exec = { callId, name, arguments, agent, signal }` (there is no `.deny()`
28
+ method on exec — the contract is the returned decision object).
29
+ - `tools/post-execute` — listener receives `(exec, result, next)` and fires
30
+ once per executed tool; `exec.name` / `exec.arguments` feed heat counting,
31
+ exfil-chain detection, coverage recording and session persistence.
32
+ (`agent/pre-step` carries `{ messages, position, signal }` only — no
33
+ toolCalls — so it cannot drive feedback; verified against dsh-agent-loop.)
34
+
23
35
  ## Six layers — all implemented
24
36
 
25
37
  | Layer | Mechanism | Status |
@@ -35,12 +47,18 @@ resolve through the full registry — so folding never removes capability.
35
47
  | Segment | Content | Cost |
36
48
  |---|---|---|
37
49
  | core | per-agent core + heat-promoted tools, full schema | small, constant |
38
- | dyn | BM25 top-K for the current query + CN-alias server tools | small, per request |
50
+ | dyn | BM25 top-K for the current query + CN-alias server top-K | small, per request |
39
51
  | folded | everything else — dropped from the schema | zero |
40
52
 
41
- Optionally (`catalogEnabled: true`) folded tools are listed as a one-line
42
- catalog section so the model still knows they exist and can call them by name
43
- (execution-side fallback still works).
53
+ An alias hit no longer pulls the whole server in: the matched server's tools
54
+ are ranked by BM25 (with the matched alias keywords bridged into the query to
55
+ cross the CN/EN gap) and the top `min(3, serverSize)` are loaded. When the
56
+ query yields no lexical signal at all, a stable name-order subset is loaded so
57
+ the alias still helps.
58
+
59
+ Optionally (`catalogEnabled: true`, default) folded tools are listed as a
60
+ one-line catalog section so the model still knows they exist and can call them
61
+ by name (execution-side fallback still works).
44
62
 
45
63
  ## Install
46
64
 
@@ -77,26 +95,144 @@ were never unregistered, so nothing else changes.
77
95
  | enabled | true | master switch |
78
96
  | perAgent | {} | `{agentId: {core: [...]}}` overrides |
79
97
  | core | [] | always-loaded tools (fallback for unknown agents) |
98
+ | deny | [] | never inject + refuse execution: exact name, or `prefix*` matches a whole server. Deny wins over core, include and the catalog too. |
80
99
  | topK | 6 | BM25 dynamic segment size |
81
100
  | hotThreshold | 3 | folded tool called N times → auto-promote to core |
82
- | catalogEnabled | false | append one-line catalog section for folded tools |
101
+ | catalogEnabled | true | append one-line catalog section for folded tools (fold safety net) |
102
+ | schemaToolEnabled | true | register the `tools_schema` meta tool (full parameter schema of one tool by name) |
103
+ | compressLevel | off | L1 compression tier: `off` \| `light` \| `standard` \| `aggressive`. `standard` trims descriptions to 200 chars + param descriptions to 120; `aggressive` additionally drops optional parameters (required ⊆ properties guaranteed constructively). Applies to dyn segment only — core/hot never trimmed. |
104
+ | compressEnabled | false | deprecated compat: `compressLevel: off` + `compressEnabled: true` is treated as `standard` |
105
+ | normalizeDescriptions | false | dyn-segment description hygiene: injection-marker removal + whitespace normalization + first-sentence keep + 300-char cap |
106
+ | category | {} | intent routing `{ "记忆/回忆/remember": ["mcp__viking"], ... }`. Key words are `/`-separated (OR). Category wins over aliases; each matched server loads per-server top-3. |
107
+ | include | [] | whitelist: exact name or `prefix*`. Non-empty → only matching tools are injected (meta tools exempt; deny still wins). Execution is NOT gated by include. |
108
+ | toonifyResults | false | compact long JSON results after execution (drop empty fields + truncate strings; only when the text block is >2000 chars and parses as JSON) |
83
109
  | maxFoldMs | 50 | hard cap; beyond this keep the full list |
84
110
  | feedbackFile | '' | feedback JSON path (default `$DSH_HOME/logs/tool-folder/`) |
85
- | aliases | built-in | CN-intent keyword → server prefix table |
111
+ | aliases | built-in | CN-intent keyword → server prefix table (per-server top-K) |
86
112
 
87
113
  ## Files
88
114
 
89
115
  - `lib/bm25.js` — zero-dep BM25 (k1=1.2, b=0.75; CN bigrams + EN tokens)
116
+ - `lib/schema.js` — L1 tiered compression (`compressLevel`) + description normalization + lossless-JSON sanitizer
117
+ - `lib/category.js` — P1-1 intent-category routing (pure function, unit-testable)
118
+ - `lib/toonify.js` — P2-2 long JSON result compaction (pure function)
90
119
  - `lib/index.js` — assemble hook, three-segment fold, heat feedback, safety
91
120
  - `cordis.patch.yml` — bundle insert declaration
92
121
  - `test.js` — simulated-cordis harness: `node test.js`
93
122
 
94
123
  ## Known limits
95
124
 
96
- - BM25 cannot cross the CN-query / EN-description language gap; the alias
97
- table covers common CN intents. A future query-rewrite leg (ARK LLM, proven
98
- in the GPT Researcher smart retriever) removes this entirely.
99
- - `catalogEnabled` render behavior needs one runtime verification pass.
125
+ - BM25 cannot cross the CN-query / EN-description language gap on its own; the
126
+ alias table covers common CN intents and now bridges its EN keywords into the
127
+ per-server BM25 scoring. A future query-rewrite leg (ARK LLM, proven in the
128
+ GPT Researcher smart retriever) removes this entirely.
129
+ - Alias hits load the server's top-3 relevant tools, not the whole server. A
130
+ server whose most relevant tool ranks low on a bridged CN query may miss it —
131
+ the `tools_search` meta-tool and heat promotion are the recovery paths.
132
+ - `catalogEnabled` render behavior needs one runtime verification pass
133
+ (section render is verified; the catalog text itself is standard).
134
+ - `toonifyResults` mutation of the result object in the host's post-execute
135
+ waterfall is not yet proven end-to-end (the listener's return is a gate, not
136
+ the result body) — the pure function + unit tests are in; runtime propagation
137
+ needs one verification pass. Default off until then.
138
+ - `aggressive` compression hides optional parameters from the model, so a
139
+ model that would have used them can't (execution still validates against the
140
+ registry's original schema — C6). `standard` is the safe default tier;
141
+ `aggressive` is opt-in.
142
+
143
+ ## Changelog
144
+
145
+ ### v0.1.6 — settings UI schema (2026-08-25)
146
+ 1. **`Config` schema exported (schemastery)**: every toggle now renders as a
147
+ native form in the DSH settings UI — no more hand-editing YAML. The schema
148
+ mirrors `DEFAULTS` one-to-one (18 fields: enabled / core / deny / topK /
149
+ hotThreshold / catalogEnabled / compressLevel / compressEnabled /
150
+ schemaToolEnabled / normalizeDescriptions / category / include /
151
+ toonifyResults / toolSearchEnabled / firewallEnabled / maxFoldMs /
152
+ feedbackFile / aliases), with per-field Chinese descriptions and range
153
+ constraints (topK 0-50, hotThreshold ≥1, maxFoldMs 0-1000). Invalid values
154
+ are rejected with precise messages ("expected off | light | standard |
155
+ aggressive but got X"). Same pattern as @deepseek-ai/dsh-tool-todo
156
+ (`z.object` + `.default()`, schemastery `~standard` bridge).
157
+ 2. **Dependencies**: `@deepseek-ai/schemastery` + `zod` (both `dependencies`,
158
+ matching the official plugin pattern — the zod adapter is required by the
159
+ `~standard` bridge).
160
+
161
+ ### v0.1.5 — tools_schema + tiered compression + intent routing (2026-08-25)
162
+ 1. **P0-1 `tools_schema` meta tool**: model-driven full-schema discovery for one
163
+ tool by name (complement to `tools_search`). Output is `{schema:{type:"json"}}`
164
+ (host contract C3 — a strict object schema would reject arbitrary tool
165
+ schemas with "is not a declared property"). Its return value is deep-washed
166
+ by `sanitizeLossless` (C4: `const:-0`/NaN can never leak). Meta tools are now
167
+ managed by a shared `META_TOOLS` block that keeps both `tools_search` and
168
+ `tools_schema` always visible.
169
+ 2. **P0-2 `compressLevel` tiers**: `light`/`standard`/`aggressive` replace the
170
+ single conservative trim. `aggressive` drops optional parameters and rebuilds
171
+ `properties` from `required`, so `required ⊆ properties` holds constructively
172
+ (C8). Required properties are never dropped; `$ref`/`oneOf`/`items` deep
173
+ structure is never touched; non object-root parameter schemas get
174
+ description-only trimming. `compressEnabled: true` remains as a deprecated
175
+ compat alias for `standard`.
176
+ 3. **P1-1 `category` intent routing**: `{ "记忆/回忆": ["mcp__viking"] }` routes a
177
+ query to explicit server prefixes (per-server top-3, BM25-bridged) and wins
178
+ over the alias table. Default `{}` = zero behavior change.
179
+ 4. **P1-2 `normalizeDescriptions`**: dyn-segment descriptions are sanitized
180
+ (prompt-injection markers removed), whitespace-collapsed, first-sentence
181
+ kept, and capped at 300 chars. Core/hot descriptions are never touched.
182
+ 5. **P2-1 `include` whitelist**: exact name or `prefix*`; non-empty → only
183
+ matching tools enter the injection pool. Meta tools are exempt (a whitelist
184
+ can never hide the discovery tools), deny still wins, and execution is never
185
+ gated by include (C5).
186
+ 6. **P2-2 `toonifyResults`** (default off): post-execute compaction of long JSON
187
+ text blocks (>2000 chars) — drop empty fields, truncate long strings. Any
188
+ parse/compact failure leaves the original result untouched. Pure function in
189
+ `lib/toonify.js`; host waterfall propagation still to be confirmed at runtime
190
+ (see Known limits).
191
+ 7. **Tests**: 33 → comprehensive suite (~50+ checks) covering every new feature
192
+ plus regression guards for the red lines ($ref untouched, core untouched,
193
+ required ⊆ properties, lossless JSON, deny-wins-over-include).
194
+
195
+ ### v0.1.4 — audit fixes (2026-08-25)
196
+ 1. **P0 firewall contract fixed** (`tools/pre-execute`): the listener now
197
+ returns `{ kind: "deny", reason }` to refuse (host contract verified:
198
+ `prepareExecution` reads `gate.kind` / `gate.reason`; `exec` has
199
+ `{ callId, name, arguments, agent, signal }` — the old `payload.deny()`
200
+ call and `toolName/argv` fields never existed, so high-risk blocks silently
201
+ never fired).
202
+ 2. **P0 feedback loop revived** (`tools/post-execute`): heat counting,
203
+ exfil-chain recording, coverage and session persistence moved off
204
+ `agent/pre-step` (whose payload has no toolCalls) onto `tools/post-execute`
205
+ (fires per executed tool with `exec.name`/`exec.arguments`). Feedback now
206
+ actually persists and heat-promotes across restarts.
207
+ 3. **P0 ESM crash removed**: `require("node:fs")` under `"type": "module"`
208
+ threw ReferenceError and silently killed all feedback persistence; replaced
209
+ with top-level `import fs` / `import path`. Also fixed a parameter-shadow
210
+ bug (`path` param hid the `node:path` module in `persistFeedback`).
211
+ 4. **P1 alias whole-server pull fixed**: alias hits now load a per-server
212
+ BM25 top-K (`min(3, serverSize)`) with alias keywords bridged into the
213
+ query, instead of dragging in every tool of a matched server.
214
+ 5. **P1 deny config added**: `deny: []` (exact name or `prefix*`) removes a
215
+ tool from the injected surface AND refuses its execution at
216
+ `tools/pre-execute`. Deny wins over core and the catalog.
217
+ 6. **P2 stopword over-removal fixed**: `list/get/set/make/use(ing)` removed
218
+ from the EN stopword set — they are tool-name/description high-frequency
219
+ terms (`list_sessions`, `get_config`) and were eroding retrieval signal.
220
+ 7. **P2 -0 defense**: metrics now normalize `-0` → `0` (DSH lossless JSON
221
+ rejects `-0`).
222
+ 8. **P2 default flip**: `catalogEnabled` defaults to `true` (fold safety net —
223
+ the model can see that hidden tools exist). `compressEnabled` stays off
224
+ (L1 not fully verified).
225
+ 9. **Tests**: suite extended 15 → 29 checks, covering the deny-return
226
+ contract, `tools/post-execute` receiving `exec.name`, deny config
227
+ (inject + execution), alias per-server top-K, no-`require` ESM hygiene, and
228
+ no-`-0` metric outputs.
229
+ 10. **NEW-1 (QA finding, LOW)**: three backslash-sensitive firewall regexes
230
+ (`HKLM\SAM`, `\system32\config`, `\Run`) silently never matched when argv
231
+ reached `verdict()` JSON-encoded (single backslash → `\\`). Changed `\\`
232
+ to `\\+` so both raw-string and JSON-encoded paths are blocked. Added 4
233
+ regression checks (suite now 33).
234
+
235
+ ### v0.1.3 — initial release (2026-08-24)
100
236
 
101
237
  ## Quality check (2026-08-24, real 45-tool set)
102
238
 
@@ -106,16 +242,15 @@ Full-pipeline test with the REAL tool list (converted to DSH public names
106
242
  | query | result | verdict |
107
243
  |---|---|---|
108
244
  | delegate a coding task to openhands | 7 openhands tools | ✅ |
109
- | 帮我记住这个事实 / 查记忆 记住 回忆 | all 15 viking tools (alias) | ✅ |
245
+ | 帮我记住这个事实 / 查记忆 记住 回忆 | top-3 viking tools (alias, per-server top-K) | ✅ |
110
246
  | fetch library documentation | 2 context7 tools | ✅ |
111
- | start a learning session | all 13 deeptutor tools (alias) | ✅ |
247
+ | start a learning session | top-3 deeptutor tools (alias, per-server top-K) | ✅ |
112
248
  | 复杂推理任务 深度思考 | deeptutor deep_* + reasonix + openhands | ✅ |
113
249
  | 搜索代码 semantic search | viking grep/glob + reasonix code | ✅ |
114
250
 
115
251
  - Fold ratio: 45 tools → 2-18 visible per query (avg ~8) ≈ **-82% schema tokens**
116
- - Known trade-off: an alias hit pulls the WHOLE server in. Fine while servers
117
- stay ≤15 tools; a large server (e.g. GitHub MCP ~60) needs the v2 per-server
118
- top-K refinement.
252
+ - v0.1.4: alias hits are capped at the server's top-3 by relevance instead of
253
+ pulling the whole server in (the v2 per-server top-K refinement is now live).
119
254
  - BM25 shows minor lexical noise on some queries (e.g. `cancel_watch` on an
120
255
  openhands query) — the model filters this during synthesis.
121
256
 
package/cordis.patch.yml CHANGED
@@ -36,6 +36,20 @@
36
36
  topK: 6
37
37
  # Folded tool called this many times auto-promotes into core.
38
38
  hotThreshold: 3
39
+ # v0.1.5 — meta tool: full parameter schema of one tool by name.
40
+ schemaToolEnabled: true
41
+ # L1 compression tier: off | light | standard | aggressive.
42
+ compressLevel: off
43
+ # Deprecated compat: compressLevel "off" + compressEnabled true → "standard".
44
+ compressEnabled: false
45
+ # Normalize dyn descriptions (injection sanitize + first sentence + 300 chars).
46
+ normalizeDescriptions: false
47
+ # Intent routing: { "记忆/回忆": ["mcp__viking"] } — category wins over aliases.
48
+ category: {}
49
+ # Whitelist: ["mcp__openhands*", "mcp__viking__remember"]; empty = all (minus deny).
50
+ include: []
51
+ # Compact long JSON tool results (drop empty fields + truncate long strings).
52
+ toonifyResults: false
39
53
  # One-line catalog section for folded tools (model knows they exist).
40
54
  # Off until section render behavior is verified at runtime.
41
55
  catalogEnabled: false
package/lib/bm25.js CHANGED
@@ -22,7 +22,6 @@ const EN_STOP = new Set([
22
22
  "it", "this", "that", "these", "those", "i", "you", "he", "she", "we",
23
23
  "they", "do", "does", "did", "have", "has", "had", "not", "no", "yes",
24
24
  "will", "would", "can", "could", "should", "may", "might", "must",
25
- "use", "used", "using", "get", "gets", "got", "set", "make", "list",
26
25
  "your", "my", "our", "their", "its", "all", "any", "some", "each",
27
26
  ]);
28
27
 
@@ -0,0 +1,34 @@
1
+ /**
2
+ * category.js — P1-1 intent-category routing for the dynamic tool segment.
3
+ *
4
+ * Operator-configurable `category` map: `{ "记忆/回忆/remember": ["mcp__viking"], ... }`
5
+ * The key is a `/`-separated list of intent words (OR semantics — any word
6
+ * contained in the query matches). The value is a list of server prefixes to
7
+ * load per-server top-K tools from.
8
+ *
9
+ * Pure function, zero dependencies, unit-testable without the plugin context.
10
+ */
11
+
12
+ /**
13
+ * Match a lowercased query against the category map.
14
+ * @param {string} queryLower pre-lowercased user query
15
+ * @param {object} category `{ "word1/word2": ["serverPrefix", ...], ... }`
16
+ * @returns {{matched: boolean, matches: Array<{server: string, keywords: string[]}>}}
17
+ * `matches` entries carry the server prefix and the hit keywords (used to
18
+ * bridge CN intent words into the per-server BM25 scoring).
19
+ */
20
+ export function matchCategory(queryLower, category) {
21
+ const matches = [];
22
+ for (const [key, servers] of Object.entries(category || {})) {
23
+ const keywords = String(key)
24
+ .split(/[/,,、]/)
25
+ .map((s) => s.trim().toLowerCase())
26
+ .filter(Boolean);
27
+ const hit = keywords.filter((k) => queryLower.includes(k));
28
+ if (hit.length === 0 || !Array.isArray(servers)) continue;
29
+ for (const server of servers) {
30
+ matches.push({ server: String(server), keywords: hit });
31
+ }
32
+ }
33
+ return { matched: matches.length > 0, matches };
34
+ }
package/lib/chainguard.js CHANGED
@@ -17,10 +17,14 @@ const HIGH_RISK_PATTERNS = [
17
17
  [/(wget|curl|powershell|pwsh).{0,80}(\||;|&&).{0,30}(sh|bash|powershell|pwsh|cmd|start)\b/i, "download-pipe-execute"],
18
18
  [/invoke-expression|iex\s*\(|iwr\s+.*\|.*iex/i, "powershell download-exec"],
19
19
  // credential / exfil
20
- [/(net\s+user|whoami\s+\/all|reg\s+query\s+HKLM\\SAM)/i, "credential harvest"],
21
- [/copy\s+.*\\system32\\config|type\s+.*\\(ntuser\.dat|SAM|SYSTEM)/i, "system file exfil"],
20
+ // NOTE: `\\+` (one-or-more literal backslashes) instead of `\\` — argv text
21
+ // reaches verdict() both as a raw string (single backslash) and as
22
+ // JSON.stringify(object) (backslash escaped to `\\`). A bare `\\` only
23
+ // matches the raw path, silently missing JSON-encoded calls (NEW-1).
24
+ [/(net\s+user|whoami\s+\/all|reg\s+query\s+HKLM\\+SAM)/i, "credential harvest"],
25
+ [/copy\s+.*\\+system32\\+config|type\s+.*\\+(ntuser\.dat|SAM|SYSTEM)/i, "system file exfil"],
22
26
  // shadow / persistence
23
- [/schtasks\s+\/create|reg\s+add\s+.*\\run/i, "persistence install"],
27
+ [/schtasks\s+\/create|reg\s+add\s+.*\\+run/i, "persistence install"],
24
28
  ];
25
29
 
26
30
  // Tools that READ sensitive local state (file system, memory, secrets).
package/lib/index.js CHANGED
@@ -17,27 +17,77 @@
17
17
  * buildRequest uses assembly.tools as the ONLY source of the API tools field.
18
18
  * Execution is NOT gated on the current turn's tools, so folding never
19
19
  * removes capability.
20
+ * `tools/pre-execute` — listener receives (exec, next); return
21
+ * { kind: "deny", reason } to refuse, or call next() to allow.
22
+ * exec = { callId, name, arguments, agent, signal } (no .deny() method).
23
+ * `tools/post-execute` — listener receives (exec, result, next); fires once
24
+ * per executed tool. exec.name/exec.arguments feed the L3/L4 feedback
25
+ * loop. (`agent/pre-step` carries { messages, position, signal } only —
26
+ * no toolCalls — so it is useless for feedback.)
20
27
  *
21
28
  * Safety: any error falls back to the untouched assembly. Performance: BM25
22
29
  * in-memory + sync; a maxFoldMs hard cap keeps the full list if we overrun.
23
30
  */
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+ import z from "@deepseek-ai/schemastery";
24
34
  import { buildIndex, search } from "./bm25.js";
25
- import { compressTool } from "./schema.js";
35
+ import { compressTool, normalizeDescription, sanitizeLossless } from "./schema.js";
26
36
  import { selectionCoverage } from "./metrics.js";
27
37
  import { ChainGuard, verdict } from "./chainguard.js";
38
+ import { matchCategory } from "./category.js";
39
+ import { toonifyValue } from "./toonify.js";
28
40
  import { defineTool } from "@deepseek-ai/dsh-tools";
29
41
 
30
42
  export const name = "tool-folder";
31
43
  export const inject = ["systemPrompt", "agents", "tools"];
32
44
 
45
+ /**
46
+ * Schemastery configuration schema — renders the plugin's settings form in the
47
+ * DSH settings UI (per cordis `runtime.Config["~standard"]`). Mirrors DEFAULTS
48
+ * below; the UI exposes every toggle/knob this plugin has.
49
+ * Same pattern as @deepseek-ai/dsh-tool-todo (z.object + .default()).
50
+ */
51
+ const COMPRESS_LEVELS = ["off", "light", "standard", "aggressive"];
52
+ export const Config = z.object({
53
+ enabled: z.boolean().default(true).description("总开关:启用工具折叠"),
54
+ core: z.array(z.string()).default([]).description("每轮恒显示的工具(平台核心,永不折叠)"),
55
+ perAgent: z.dict(z.object({
56
+ core: z.array(z.string()).default([]).description("该 agent 的常驻工具覆盖(未列出 agent 回退到全局 core)"),
57
+ })).default({}).description("按 agent 覆盖常驻工具:{\"standard\": {\"core\": [...]}}"),
58
+ deny: z.array(z.string()).default([]).description("禁用工具:精确名或 prefix* 前缀,命中不注入+拒绝执行"),
59
+ topK: z.number().min(0).max(50).default(6).description("BM25 动态检索每轮最多注入的工具数"),
60
+ hotThreshold: z.number().min(1).default(3).description("被折叠工具调用几次后自动晋升为常驻"),
61
+ catalogEnabled: z.boolean().default(true).description("折叠安全网:把被藏工具列表写进提示词,模型知道它们存在"),
62
+ compressLevel: z.union(COMPRESS_LEVELS).default("off").description("L1 schema 压缩级别:off 不压缩 / light 裁描述 / standard 裁描述+参数说明 / aggressive 删可选参数"),
63
+ compressEnabled: z.boolean().default(false).description("兼容开关:compressLevel=off 且此开=true 时按 standard 处理"),
64
+ schemaToolEnabled: z.boolean().default(true).description("注册 tools_schema 元工具:按名称展开任意工具的完整参数 schema"),
65
+ normalizeDescriptions: z.boolean().default(false).description("描述标准化:注入消毒+首句提取+300 字符(只作用于动态段)"),
66
+ category: z.dict(z.array(z.string())).default({}).description("意图路由:{\"记忆/回忆\": [\"mcp__viking\"]},优先级 category > aliases > BM25"),
67
+ include: z.array(z.string()).default([]).description("白名单:[\"mcp__openhands*\"],空=全部(deny 仍优先,meta 工具豁免)"),
68
+ toonifyResults: z.boolean().default(false).description("长 JSON 结果紧凑化(删空字段/截长串,默认关)"),
69
+ toolSearchEnabled: z.boolean().default(true).description("注册 tools_search 元工具:检索被折叠的工具目录"),
70
+ firewallEnabled: z.boolean().default(true).description("ChainGuard 防火墙:高危命令硬拦截+外泄链检测"),
71
+ maxFoldMs: z.number().min(0).max(1000).default(50).description("折叠超时上限(ms),超时回退完整工具列表"),
72
+ feedbackFile: z.string().default("").description("反馈数据落盘路径(空=DSH_HOME/logs/tool-folder/)"),
73
+ aliases: z.dict(z.array(z.string())).default({}).description("中文意图→server 别名表"),
74
+ });
75
+
33
76
  const DEFAULTS = {
34
77
  enabled: true,
35
78
  perAgent: {},
36
79
  core: [],
80
+ deny: [], // never inject + refuse execution (exact name or "prefix*")
37
81
  topK: 6,
38
82
  hotThreshold: 3,
39
- catalogEnabled: false,
40
- compressEnabled: false,
83
+ catalogEnabled: true, // safety net: folded tools stay discoverable by name
84
+ compressEnabled: false, // deprecated compat: compressLevel "off" + true → "standard"
85
+ compressLevel: "off", // "off" | "light" | "standard" | "aggressive"
86
+ schemaToolEnabled: true, // P0-1: register the tools_schema meta tool
87
+ normalizeDescriptions: false, // P1-2: sanitize+first-sentence+300 dyn descriptions
88
+ category: {}, // P1-1: { "记忆/回忆": ["mcp__viking"], ... } intent routing
89
+ include: [], // P2-1: whitelist ("prefix*" or exact name); empty = all (minus deny)
90
+ toonifyResults: false, // P2-2: compact long JSON results (default off)
41
91
  toolSearchEnabled: true,
42
92
  firewallEnabled: true, // ChainGuard: high-risk hard block + exfil chain warn
43
93
  maxFoldMs: 50,
@@ -66,26 +116,35 @@ function feedbackPath(cfg) {
66
116
  }
67
117
  }
68
118
 
69
- function loadFeedback(path, log) {
119
+ function loadFeedback(file, log) {
70
120
  try {
71
- const fs = require("node:fs");
72
- if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8"));
121
+ if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, "utf8"));
73
122
  } catch (e) {
74
123
  log?.warn?.("feedback load failed (%s) — starting empty", e?.message);
75
124
  }
76
125
  return { calls: {}, sessions: [] };
77
126
  }
78
127
 
79
- function persistFeedback(path, fb, log) {
128
+ function persistFeedback(file, fb, log) {
80
129
  try {
81
- const fs = require("node:fs");
82
- require("node:fs").mkdirSync(require("node:path").dirname(path), { recursive: true });
83
- fs.writeFileSync(path, JSON.stringify(fb, null, 2), "utf8");
130
+ fs.mkdirSync(path.dirname(file), { recursive: true });
131
+ fs.writeFileSync(file, JSON.stringify(fb, null, 2), "utf8");
84
132
  } catch (e) {
85
133
  log?.warn?.("feedback persist failed (%s) — non-fatal", e?.message);
86
134
  }
87
135
  }
88
136
 
137
+ /** Serialize an exec.arguments payload (object) into text for firewall verdicts. */
138
+ function execArgumentsText(args) {
139
+ if (args == null) return "";
140
+ if (typeof args === "string") return args;
141
+ try {
142
+ return JSON.stringify(args);
143
+ } catch {
144
+ return String(args);
145
+ }
146
+ }
147
+
89
148
  function extractQuery(context) {
90
149
  const signal = context && context.signal;
91
150
  if (!signal) return "";
@@ -118,6 +177,19 @@ function catalogText(tools) {
118
177
 
119
178
  export function apply(ctx, config) {
120
179
  const cfg = { ...DEFAULTS, ...(config || {}) };
180
+
181
+ // T01: normalize the v0.1.5 config surface right after the DEFAULTS merge.
182
+ // - compressLevel accepts only the four valid tiers; anything else → "off".
183
+ // - compressEnabled is the deprecated compat switch: "off" + true → "standard".
184
+ // - include patterns are trimmed/non-empty; empty list = include-all.
185
+ // - category must be a plain object; malformed values are ignored.
186
+ const VALID_LEVELS = ["off", "light", "standard", "aggressive"];
187
+ const compressLevel = VALID_LEVELS.includes(cfg.compressLevel) ? cfg.compressLevel : "off";
188
+ const effectiveLevel = compressLevel !== "off" ? compressLevel : (cfg.compressEnabled ? "standard" : "off");
189
+ const includePatterns = (cfg.include || []).map((n) => String(n).trim()).filter(Boolean);
190
+ const includeAll = includePatterns.length === 0;
191
+ const categoryCfg = (cfg.category && typeof cfg.category === "object") ? cfg.category : {};
192
+
121
193
  const logger = ctx.logger("tool-folder");
122
194
  if (!cfg.enabled) {
123
195
  logger.info("disabled by config");
@@ -133,20 +205,79 @@ export function apply(ctx, config) {
133
205
  const disposers = [];
134
206
  const chain = new ChainGuard({ window: 30 }); // firewall: exfil-chain + high-risk
135
207
 
136
- const serverOf = (name) => name.split("__")[0];
208
+ // "mcp__viking__remember" → "mcp__viking"; plain tools ("tool-bash") stay
209
+ // as themselves (single segment survives slice(0,2)).
210
+ const serverOf = (name) => String(name).split("__").slice(0, 2).join("__");
211
+
212
+ // Pattern matching shared by deny (P0) and include (P2-1): exact name or
213
+ // "prefix*" matches any tool starting with that prefix.
214
+ function matchesAnyPattern(name, patterns) {
215
+ if (!name || !patterns || patterns.length === 0) return false;
216
+ const n = String(name);
217
+ for (const p of patterns) {
218
+ if (p.endsWith("*")) {
219
+ if (n.startsWith(p.slice(0, -1))) return true;
220
+ } else if (n === p) {
221
+ return true;
222
+ }
223
+ }
224
+ return false;
225
+ }
226
+ // deny config: exact name, or "prefix*" matches any tool starting with it.
227
+ // Deny wins over core, include and the catalog (a denied tool is not listed).
228
+ const denyPatterns = (cfg.deny || []).map((n) => String(n).trim()).filter(Boolean);
229
+ const isDenied = (n) => matchesAnyPattern(n, denyPatterns);
230
+
231
+ // Per-server top-K: rank the matched server's tools by BM25 relevance with
232
+ // the matched intent keywords bridged into the query. When there is no
233
+ // lexical signal at all, fall back to a stable name-order subset so the
234
+ // intent route still helps. Used by both aliases and category routing.
235
+ function addPerServerTopK(dyn, tools, serverPrefix, bridgedQuery, maxK) {
236
+ const serverIndices = [];
237
+ for (let i = 0; i < tools.length; i++) {
238
+ if (tools[i].name.startsWith(serverPrefix)) serverIndices.push(i);
239
+ }
240
+ if (serverIndices.length === 0) return;
241
+ const k = Math.min(maxK, serverIndices.length);
242
+ let ranked = [];
243
+ if (index) {
244
+ ranked = search(index, bridgedQuery, serverIndices.length, {
245
+ filter: (i) => tools[i].name.startsWith(serverPrefix),
246
+ });
247
+ }
248
+ if (ranked.length === 0) {
249
+ // No lexical signal at all → stable name order so the route still helps.
250
+ serverIndices.sort((a, b) => (tools[a].name < tools[b].name ? -1 : 1));
251
+ for (const i of serverIndices.slice(0, k)) dyn.add(tools[i].name);
252
+ } else {
253
+ for (const i of ranked.slice(0, k)) dyn.add(tools[i].name);
254
+ }
255
+ }
137
256
 
138
257
  function buildDynamicSet(tools, query) {
139
258
  const dyn = new Set();
140
- if (query && cfg.topK > 0 && index) {
141
- for (const i of search(index, query, cfg.topK)) dyn.add(tools[i].name);
142
- }
143
- if (query) {
259
+ if (!query) return dyn;
260
+ const q = query.toLowerCase();
261
+ // P1-1: category wins over aliases. When a category key matches, only the
262
+ // category's servers are routed per-server top-K; the aliases branch is
263
+ // skipped. The generic BM25 leg is still merged as a union afterwards.
264
+ const cat = matchCategory(q, categoryCfg);
265
+ if (cat.matched) {
266
+ for (const m of cat.matches) {
267
+ addPerServerTopK(dyn, tools, m.server, `${query} ${m.keywords.join(" ")}`, 3);
268
+ }
269
+ } else {
270
+ // aliases fallback (existing behavior preserved verbatim)
144
271
  for (const [prefix, keywords] of Object.entries(cfg.aliases || {})) {
145
- if (keywords.some((k) => query.toLowerCase().includes(k.toLowerCase()))) {
146
- for (const t of tools) if (t.name.startsWith(prefix)) dyn.add(t.name);
147
- }
272
+ const matched = keywords.filter((k) => q.includes(String(k).toLowerCase()));
273
+ if (matched.length === 0) continue;
274
+ addPerServerTopK(dyn, tools, prefix, `${query} ${matched.join(" ")}`, 3);
148
275
  }
149
276
  }
277
+ // Generic BM25 last as a union (result set unchanged vs. previous order).
278
+ if (cfg.topK > 0 && index) {
279
+ for (const i of search(index, query, cfg.topK)) dyn.add(tools[i].name);
280
+ }
150
281
  return dyn;
151
282
  }
152
283
 
@@ -215,13 +346,79 @@ export function apply(ctx, config) {
215
346
  }
216
347
  }
217
348
 
349
+ // L4: `tools_schema` — model-driven full-schema discovery for one tool.
350
+ // Complementary to tools_search: search narrows candidates, this returns the
351
+ // exact parameter schema + full description of one named tool.
352
+ // RED LINE (C3): output MUST be `{ schema: { type: "json" } }` — the return
353
+ // value is an arbitrary tool schema, and a strict object schema with
354
+ // additionalProperties:false would fail the host's "declared property"
355
+ // validation (ToolOutputError). The `type:"json"` annotation schema only
356
+ // checks lossless JSON (C4).
357
+ if (cfg.schemaToolEnabled) {
358
+ const schemaTool = defineTool({
359
+ name: "tools_schema",
360
+ description:
361
+ "Get the complete parameter schema of one tool by name. Use after " +
362
+ "tools_search narrows candidates, or before calling a tool whose exact " +
363
+ "arguments you need. Returns the full JSON schema of parameters plus the " +
364
+ "full description.",
365
+ parameters: {
366
+ name: {
367
+ type: "string",
368
+ required: true,
369
+ description: "Exact tool name, e.g. 'mcp__viking__remember'.",
370
+ },
371
+ },
372
+ output: {
373
+ schema: { type: "json" },
374
+ render: (_a, v) => [{ type: "text", text: JSON.stringify(v, null, 2) }],
375
+ },
376
+ isConcurrencySafe: () => true,
377
+ async execute(args) {
378
+ const name = String(args?.name || "");
379
+ const t = allTools.find((x) => x.name === name);
380
+ if (!t) return { name, found: false, reason: "unknown tool" }; // never throw
381
+ return {
382
+ name: t.name,
383
+ found: true,
384
+ server: serverOf(t.name),
385
+ description: t.description || "",
386
+ // C4 defense: even a schema containing `const: -0` must come back as
387
+ // lossless JSON.
388
+ parameters: sanitizeLossless(t.parameters ?? {}),
389
+ required: Array.isArray(t.parameters?.required) ? t.parameters.required : [],
390
+ };
391
+ },
392
+ });
393
+ try {
394
+ ctx.tools.register(schemaTool);
395
+ logger.info("tools_schema registered");
396
+ } catch (e) {
397
+ logger.warn("tools_schema register failed: %s", e?.message);
398
+ }
399
+ }
400
+
218
401
  disposers.push(
219
402
  ctx.on("system-prompt/assemble", async (assembly, context, next) => {
220
403
  const t0 = Date.now();
221
404
  try {
222
405
  const assembled = await next();
223
- const tools = assembled.tools || [];
224
- if (!Array.isArray(tools) || tools.length === 0) return assembled;
406
+ const assembledTools = assembled.tools || [];
407
+ if (!Array.isArray(assembledTools)) return assembled;
408
+ // P2-1 include whitelist + deny config remove tools from the injected
409
+ // surface BEFORE folding. Semantics: include non-empty → whitelist mode,
410
+ // only matching tools enter the pool (injection surface + catalog +
411
+ // tools_search/tools_schema search scope); execution is NOT gated here
412
+ // (C5 — pre-execute only checks deny + firewall). Deny still wins.
413
+ // Meta tools are exempt from include so a whitelist can never hide the
414
+ // discovery tools themselves (that would break the always-visible loop).
415
+ const isMetaTool = (n) => n === "tools_search" || n === "tools_schema";
416
+ const tools = assembledTools.filter(
417
+ (t) =>
418
+ !isDenied(t?.name) &&
419
+ (isMetaTool(t?.name) || includeAll || matchesAnyPattern(t?.name, includePatterns)),
420
+ );
421
+ if (tools.length === 0) return { ...assembled, tools };
225
422
 
226
423
  const agentId = context?.agent?.id || "default";
227
424
  const coreNames = new Set(cfg.perAgent?.[agentId]?.core || cfg.core || []);
@@ -247,19 +444,46 @@ export function apply(ctx, config) {
247
444
  // L5: stable ordering — core by config order, hot by name, dyn by name.
248
445
  // A deterministic byte order keeps the prompt prefix cacheable.
249
446
  const coreTools = tools.filter((t) => coreNames.has(t.name));
250
- const hotTools = tools.filter((t) => !coreNames.has(t.name) && hotNames.has(t.name)).sort((a, b) => (a.name < b.name ? -1 : 1));
251
- const dynTools = tools.filter((t) => !coreNames.has(t.name) && !hotNames.has(t.name) && dynNames.has(t.name)).sort((a, b) => (a.name < b.name ? -1 : 1));
252
-
253
- // L1: conservative schema trim on the dynamic segment only.
254
- const trimmed = cfg.compressEnabled ? dynTools.map((t) => compressTool(t)) : dynTools;
447
+ const hotTools = tools
448
+ .filter((t) => !coreNames.has(t.name) && hotNames.has(t.name))
449
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
450
+ const dynTools = tools
451
+ .filter(
452
+ (t) =>
453
+ !coreNames.has(t.name) &&
454
+ !hotNames.has(t.name) &&
455
+ dynNames.has(t.name),
456
+ )
457
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
458
+
459
+ // L1: schema compression + description normalization on the dynamic
460
+ // segment only. core/hot never pass through this pipe (P0 red line).
461
+ const trimmed =
462
+ effectiveLevel !== "off" || cfg.normalizeDescriptions
463
+ ? dynTools.map((t) => {
464
+ let out = t;
465
+ if (cfg.normalizeDescriptions && typeof t.description === "string") {
466
+ out = { ...out, description: normalizeDescription(t.description) };
467
+ }
468
+ return effectiveLevel !== "off"
469
+ ? compressTool(out, { level: effectiveLevel })
470
+ : out;
471
+ })
472
+ : dynTools;
255
473
 
256
474
  const visible = [...coreTools, ...hotTools, ...trimmed];
257
475
 
258
- // The meta-tool must always stay visible or the model can never ask
259
- // for folded tools (it would be hidden by its own fold).
260
- if (cfg.toolSearchEnabled && !visible.some((t) => t.name === "tools_search")) {
261
- const meta = tools.find((t) => t.name === "tools_search");
262
- if (meta) visible.push(meta);
476
+ // The meta-tools must always stay visible or the model can never ask
477
+ // for folded tools (they would be hidden by their own fold).
478
+ const META_TOOLS = [
479
+ { name: "tools_search", enabled: cfg.toolSearchEnabled },
480
+ { name: "tools_schema", enabled: cfg.schemaToolEnabled },
481
+ ];
482
+ for (const m of META_TOOLS) {
483
+ if (m.enabled && !visible.some((t) => t.name === m.name)) {
484
+ const meta = tools.find((t) => t.name === m.name);
485
+ if (meta) visible.push(meta);
486
+ }
263
487
  }
264
488
 
265
489
  const visibleNames = visible.map((t) => t.name);
@@ -268,7 +492,11 @@ export function apply(ctx, config) {
268
492
  let sections = assembled.sections;
269
493
  if (cfg.catalogEnabled) {
270
494
  const folded = tools.filter(
271
- (t) => !coreNames.has(t.name) && !hotNames.has(t.name) && !dynNames.has(t.name),
495
+ (t) =>
496
+ !isMetaTool(t.name) &&
497
+ !coreNames.has(t.name) &&
498
+ !hotNames.has(t.name) &&
499
+ !dynNames.has(t.name),
272
500
  );
273
501
  if (folded.length > 0) {
274
502
  sections = [...(assembled.sections || []), {
@@ -296,23 +524,49 @@ export function apply(ctx, config) {
296
524
  );
297
525
 
298
526
  // L3 + L4: heat feedback, coverage recording, and folded-call detection.
527
+ // Bound to `tools/post-execute` — payload = (exec, result), exec.name /
528
+ // exec.arguments are available. (`agent/pre-step` was the original hook but
529
+ // its payload is { messages, ...position, signal } — no toolCalls — so the
530
+ // feedback loop had no data. Verified against dsh-agent-loop preStep().)
531
+ // This listener is deliberately side-effect only: it always returns next()
532
+ // and never throws, so a feedback bug can never turn a tool result into
533
+ // an isError (postExecute wraps a throwing listener as failure).
299
534
  disposers.push(
300
- ctx.on("agent/pre-step", (payload, next) => {
535
+ ctx.on("tools/post-execute", (exec, _result, next) => {
301
536
  try {
302
- const calls = payload?.step?.toolCalls ?? payload?.toolCalls;
303
- if (!Array.isArray(calls) || calls.length === 0) return next?.();
304
- const names = calls.map((c) => (typeof c === "string" ? c : c?.name)).filter(Boolean);
537
+ const name = exec?.name;
538
+ if (!name) return next();
539
+
540
+ // P2-2 toonify (opt-in, default off): compact long JSON text blocks in
541
+ // the result (drop empty fields + truncate long strings). Only fires
542
+ // when the text block is >2000 chars AND parses as JSON; any failure
543
+ // leaves the original block untouched — a compaction bug can never
544
+ // corrupt a tool result.
545
+ if (cfg.toonifyResults && _result && Array.isArray(_result.content)) {
546
+ _result.content = _result.content.map((block) => {
547
+ if (
548
+ block?.type !== "text" ||
549
+ typeof block.text !== "string" ||
550
+ block.text.length <= 2000
551
+ ) {
552
+ return block;
553
+ }
554
+ try {
555
+ const v = JSON.parse(block.text);
556
+ const compact = JSON.stringify(toonifyValue(v), null, 2);
557
+ return compact.length < block.text.length ? { ...block, text: compact } : block;
558
+ } catch {
559
+ return block;
560
+ }
561
+ });
562
+ }
305
563
 
306
564
  // Heat counting (L4 feedback).
307
- let changed = false;
308
- for (const n of names) {
309
- feedback.calls[n] = (feedback.calls[n] || 0) + 1;
310
- changed = true;
311
- }
565
+ feedback.calls[name] = (feedback.calls[name] || 0) + 1;
312
566
 
313
567
  // Firewall: record the call sequence and flag exfil chains.
314
568
  if (cfg.firewallEnabled !== false) {
315
- for (const n of names) chain.record(n);
569
+ chain.record(name, execArgumentsText(exec?.arguments));
316
570
  const exfil = chain.checkChain();
317
571
  if (exfil) {
318
572
  logger.warn(
@@ -328,7 +582,7 @@ export function apply(ctx, config) {
328
582
  }
329
583
 
330
584
  // Folded-call detection: did the model call something we hid?
331
- const cov = selectionCoverage(lastVisible.names, names);
585
+ const cov = selectionCoverage(lastVisible.names, [name]);
332
586
  if (cov.uncovered.length > 0) {
333
587
  logger.warn(
334
588
  "folded tools called (coverage=%.2f): %s — promoting via heat",
@@ -341,43 +595,49 @@ export function apply(ctx, config) {
341
595
  ts: Date.now(),
342
596
  agent: lastVisible.agentId,
343
597
  visible: lastVisible.names,
344
- calls: names,
598
+ calls: [name],
345
599
  coverage: cov.coverage,
346
600
  uncovered: cov.uncovered,
347
601
  });
348
602
  if (feedback.sessions.length > 50) feedback.sessions.splice(0, feedback.sessions.length - 50);
349
603
 
350
- if (changed) persistFeedback(fbPath, feedback, logger);
604
+ persistFeedback(fbPath, feedback, logger);
351
605
  } catch (e) {
352
- logger.warn("pre-step hook error (%s) — best-effort", e?.message);
606
+ logger.warn("post-execute hook error (%s) — best-effort", e?.message);
353
607
  }
354
- return next?.();
608
+ return next();
355
609
  }),
356
610
  );
357
611
 
358
- // Firewall hard-block: deny high-risk command patterns before execution.
359
- if (cfg.firewallEnabled !== false) {
360
- disposers.push(
361
- ctx.on("tools/pre-execute", (payload, next) => {
362
- try {
363
- const toolName = payload?.toolName;
364
- const argv = payload?.argv;
365
- const argvText = Array.isArray(argv) ? argv.join(" ") : String(argv || "");
612
+ // Firewall hard-block + deny config: refuse execution at tools/pre-execute.
613
+ // Host contract (dsh-tools prepareExecution): the waterfall's gate is the
614
+ // last value; return { kind: "deny", reason } to refuse, or next() to allow.
615
+ // exec carries { callId, name, arguments, agent, signal } — no .deny().
616
+ disposers.push(
617
+ ctx.on("tools/pre-execute", (exec, next) => {
618
+ try {
619
+ const name = exec?.name;
620
+
621
+ // deny config: refuse execution regardless of firewallEnabled.
622
+ if (isDenied(name)) {
623
+ logger.warn("[deny] tool disabled by config: %s", name);
624
+ return { kind: "deny", reason: `[tool-folder] 工具 ${name} 已被禁用(deny 配置)。` };
625
+ }
626
+
627
+ if (cfg.firewallEnabled !== false) {
628
+ const argvText = execArgumentsText(exec?.arguments);
366
629
  const v = verdict(argvText);
367
- if (v?.blocked && typeof payload?.deny === "function") {
368
- logger.warn("[firewall] BLOCKED %s: %s", toolName, v.label);
369
- payload.deny(
370
- `[tool-folder firewall] 高危命令已拦截(${v.label})。如需执行请人工确认。`,
371
- );
372
- return; // deny() aborts this execution
630
+ if (v?.blocked) {
631
+ logger.warn("[firewall] BLOCKED %s: %s", name, v.label);
632
+ return { kind: "deny", reason: `[tool-folder firewall] 高危命令已拦截(${v.label})。如需执行请人工确认。` };
373
633
  }
374
- } catch (e) {
375
- logger.warn("firewall hook error (%s) — best-effort", e?.message);
376
634
  }
377
- return next?.();
378
- }),
379
- );
380
- }
635
+ } catch (e) {
636
+ logger.warn("pre-execute hook error (%s) — best-effort", e?.message);
637
+ }
638
+ return next();
639
+ }),
640
+ );
381
641
 
382
642
  // Cleanup on plugin unload: no leaked listeners.
383
643
  return () => {
package/lib/metrics.js CHANGED
@@ -16,6 +16,16 @@ function norm(s) {
16
16
  return String(s || "").toLowerCase().replace(/[?#].*$/, "");
17
17
  }
18
18
 
19
+ /**
20
+ * Round to 3 decimals and normalize -0 to +0. DSH's lossless JSON validation
21
+ * rejects -0 (a value that survives JSON round-trips as "0" but fails a
22
+ * strict Object.is check), so every public metric must never emit -0.
23
+ */
24
+ function round3(x) {
25
+ const r = Math.round(x * 1000) / 1000;
26
+ return r === 0 ? 0 : r;
27
+ }
28
+
19
29
  export function retrievalMetrics(relevant, retrieved, k = null) {
20
30
  const top = k ? retrieved.slice(0, k) : retrieved;
21
31
  const rel = relevant.map(norm).filter(Boolean);
@@ -31,10 +41,10 @@ export function retrievalMetrics(relevant, retrieved, k = null) {
31
41
  const recall = tp / rel.length;
32
42
  const f1 = precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : 0;
33
43
  return {
34
- precision: Math.round(precision * 1000) / 1000,
35
- recall: Math.round(recall * 1000) / 1000,
44
+ precision: round3(precision),
45
+ recall: round3(recall),
36
46
  hit: tp > 0,
37
- f1: Math.round(f1 * 1000) / 1000,
47
+ f1: round3(f1),
38
48
  n: top.length,
39
49
  };
40
50
  }
@@ -45,7 +55,7 @@ export function selectionCoverage(visibleNames, calledNames) {
45
55
  if (calls.length === 0) return { coverage: 1, uncovered: [] };
46
56
  const uncovered = calls.filter((c) => !vis.has(c));
47
57
  return {
48
- coverage: Math.round(((calls.length - uncovered.length) / calls.length) * 1000) / 1000,
58
+ coverage: round3((calls.length - uncovered.length) / calls.length),
49
59
  uncovered,
50
60
  };
51
61
  }
package/lib/schema.js CHANGED
@@ -1,38 +1,129 @@
1
1
  /**
2
- * schema.js — L1 conservative schema compression.
2
+ * schema.js — L1 schema compression + description normalization.
3
3
  *
4
4
  * DO NOT deduplicate with JSON $ref: OpenAI/ARK function-calling providers do
5
5
  * not reliably resolve $ref in tool parameters, so a broken schema would
6
6
  * silently kill a tool. Instead we trim what is provably redundant:
7
- * - tool description beyond `descLimit` chars
8
- * - parameter description beyond `paramDescLimit` chars
9
- * Both limits are conservative (500/120) so the selection signal survives —
10
- * this is the TSCG "conservative tier" idea, not aggressive compression.
7
+ * - tool description beyond the level's `descLimit`
8
+ * - parameter description beyond the level's `paramDescLimit`
9
+ * - `aggressive` additionally drops OPTIONAL parameters — properties are
10
+ * rebuilt from the required list, so `required ⊆ properties` holds
11
+ * constructively (host contract C8: providers reject a schema whose
12
+ * required names are not all declared in properties).
11
13
  *
12
- * Applied ONLY to dynamically-loaded tools; core tools are operator-chosen
13
- * and left untouched. Off by default (`compressEnabled: false`).
14
+ * Compression applies ONLY to dynamically-loaded tools; core tools are
15
+ * operator-chosen and left untouched. Off by default (`compressLevel: "off"`).
16
+ *
17
+ * normalizeDescription() sanitizes injection markers, normalizes whitespace,
18
+ * keeps the first sentence, and truncates at 300 chars. It also applies only
19
+ * to the dynamic segment (core/hot descriptions are never rewritten).
14
20
  */
15
21
 
16
- export function compressTool(tool, opts = {}) {
17
- const descLimit = opts.descLimit ?? 500;
18
- const paramDescLimit = opts.paramDescLimit ?? 120;
22
+ const LEVELS = {
23
+ light: { descLimit: 500, paramDescLimit: Infinity, dropOptional: false },
24
+ standard: { descLimit: 200, paramDescLimit: 120, dropOptional: false },
25
+ aggressive: { descLimit: 120, paramDescLimit: 60, dropOptional: true },
26
+ };
27
+
28
+ /**
29
+ * Compress one tool's schema at a given level.
30
+ * @param {object} tool tool definition {name, description, parameters, ...}
31
+ * @param {{level?: "off"|"light"|"standard"|"aggressive"}} [opts]
32
+ * @returns {object} a shallow copy of the tool; the original is never mutated.
33
+ */
34
+ export function compressTool(tool, { level = "off" } = {}) {
35
+ if (level === "off") return tool;
36
+ const t = LEVELS[level] ?? LEVELS.standard;
19
37
  const out = { ...tool };
20
38
 
21
- if (typeof out.description === "string" && out.description.length > descLimit) {
22
- out.description = out.description.slice(0, descLimit) + " …";
39
+ if (typeof out.description === "string" && out.description.length > t.descLimit) {
40
+ out.description = out.description.slice(0, t.descLimit) + " …";
23
41
  }
24
42
 
25
43
  const params = out.parameters;
26
- if (params && typeof params === "object" && params.properties) {
27
- const props = {};
28
- for (const [key, raw] of Object.entries(params.properties)) {
29
- const p = { ...raw };
30
- if (typeof p.description === "string" && p.description.length > paramDescLimit) {
31
- p.description = p.description.slice(0, paramDescLimit) + " …";
32
- }
33
- props[key] = p;
44
+ if (
45
+ !params ||
46
+ typeof params !== "object" ||
47
+ !params.properties ||
48
+ typeof params.properties !== "object"
49
+ ) {
50
+ // Non object-root (array-root / no properties): trim the description only.
51
+ return out;
52
+ }
53
+
54
+ const required = Array.isArray(params.required)
55
+ ? params.required.filter((n) => typeof n === "string")
56
+ : [];
57
+ // Constructive guarantee (C8): properties are rebuilt from the required list
58
+ // under `dropOptional`, so `required ⊆ properties` holds by construction.
59
+ // Required properties are NEVER dropped (missing required → ToolArgsError).
60
+ const keep = new Set(t.dropOptional ? required : Object.keys(params.properties));
61
+ const props = {};
62
+ for (const [key, raw] of Object.entries(params.properties)) {
63
+ if (!keep.has(key)) continue;
64
+ const p = { ...raw }; // never touch $ref/oneOf/items/properties deep structure
65
+ if (typeof p.description === "string" && p.description.length > t.paramDescLimit) {
66
+ p.description = p.description.slice(0, t.paramDescLimit) + " …";
34
67
  }
35
- out.parameters = { ...params, properties: props };
68
+ props[key] = p;
69
+ }
70
+ out.parameters = { ...params, properties: props };
71
+ if (t.dropOptional) {
72
+ // C3d / C8: malformed inputs may list `required` names that name no
73
+ // property at all (JSON Schema allows it, e.g. required:["ghost"] with
74
+ // properties:{real:{...}}). After the aggressive rebuild those names are
75
+ // gone from properties, so an unfiltered required would violate
76
+ // `required ⊆ properties` and providers (ARK/OpenAI) could reject the
77
+ // tool. Filter the emitted required list down to the kept property names
78
+ // so the guarantee holds for ANY input, well-formed or not.
79
+ out.parameters.required = required.filter((n) => Object.hasOwn(props, n));
36
80
  }
37
81
  return out;
38
82
  }
83
+
84
+ /** Injection / prompt-leak markers removed from tool descriptions. */
85
+ const INJECTION_MARKERS = [
86
+ /\bignore\s+(all\s+)?(previous|prior|above)\b/i,
87
+ /\bsystem\s*:/,
88
+ /\[\s*\/?\s*system\s*\]/i,
89
+ /^\s*(你|you)\s*(现在|现在开始|are)\b/i,
90
+ ];
91
+
92
+ /**
93
+ * Normalize a tool description: strip injection markers, collapse whitespace,
94
+ * keep the first sentence, truncate at 300 chars.
95
+ * @param {string} desc
96
+ * @param {{firstSentence?: boolean}} [opts]
97
+ * @returns {string}
98
+ */
99
+ export function normalizeDescription(desc, { firstSentence = true } = {}) {
100
+ let s = String(desc || "");
101
+ for (const re of INJECTION_MARKERS) s = s.replace(re, " "); // injection sanitize
102
+ s = s.replace(/\s+/g, " ").replace(/[。..!!]{2,}/g, "。").trim();
103
+ if (firstSentence) {
104
+ const m = s.match(/^.{1,120}?[。.!?;;]/);
105
+ if (m) s = m[0];
106
+ }
107
+ return s.length > 300 ? s.slice(0, 300) + " …" : s;
108
+ }
109
+
110
+ /**
111
+ * Deep-sanitize a value into lossless JSON (host contract C4: -0/NaN/Infinity
112
+ * are rejected by the host's lossless JSON validation). Numbers are mapped to
113
+ * a finite non-negative-zero value; plain objects/arrays are walked; strings,
114
+ * booleans, null pass through untouched.
115
+ * @param {unknown} v
116
+ * @returns {unknown} a plain JSON-safe projection
117
+ */
118
+ export function sanitizeLossless(v) {
119
+ if (typeof v === "number") {
120
+ return Number.isFinite(v) && !Object.is(v, -0) ? v : 0;
121
+ }
122
+ if (Array.isArray(v)) return v.map(sanitizeLossless);
123
+ if (v && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype) {
124
+ const out = {};
125
+ for (const k of Object.keys(v)) out[k] = sanitizeLossless(v[k]);
126
+ return out;
127
+ }
128
+ return v; // string / boolean / null / undefined (undefined never enters a plain object walk)
129
+ }
package/lib/toonify.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * toonify.js — P2-2 long JSON result compaction (opt-in, default off).
3
+ *
4
+ * Recursively drops empty fields (null/undefined/""/[]/{}) and truncates
5
+ * strings longer than `maxStr`. Keys are NEVER shortened — renaming keys
6
+ * would break consumer semantics, so the default behavior only shrinks
7
+ * values. Output is built from plain literals, so it stays lossless JSON
8
+ * (no -0 / NaN / sparse arrays, host contract C4).
9
+ */
10
+
11
+ /**
12
+ * Compact a JSON value: drop empty fields, truncate long strings.
13
+ * @param {unknown} value
14
+ * @param {{maxStr?: number}} [opts]
15
+ * @returns {unknown} `undefined` signals "drop this field"; otherwise a
16
+ * lossless-JSON-safe projection.
17
+ */
18
+ export function toonifyValue(value, { maxStr = 200 } = {}) {
19
+ if (value === null || value === undefined) return undefined; // drop empty
20
+ if (typeof value === "string") {
21
+ const s = value.trim();
22
+ return s.length === 0 ? undefined : s.length > maxStr ? s.slice(0, maxStr) + "…" : s;
23
+ }
24
+ if (Array.isArray(value)) {
25
+ const out = value.map((v) => toonifyValue(v, { maxStr })).filter((v) => v !== undefined);
26
+ return out.length === 0 ? undefined : out;
27
+ }
28
+ if (typeof value === "object") {
29
+ const out = {};
30
+ for (const k of Object.keys(value)) {
31
+ const v = toonifyValue(value[k], { maxStr });
32
+ if (v !== undefined) out[k] = v;
33
+ }
34
+ return Object.keys(out).length === 0 ? undefined : out;
35
+ }
36
+ return value; // number / boolean unchanged (no -0 arithmetic here)
37
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tool-folder",
3
- "version": "0.1.3",
4
- "description": "Fold the DSH tool surface per request + ChainGuard firewall (high-risk block + exfil-chain detection). Shrinks schema tokens 80-90% while keeping selection accuracy.",
3
+ "version": "0.1.6",
4
+ "description": "Fold the DSH tool surface per request + ChainGuard firewall (high-risk block + exfil-chain detection). Shrinks schema tokens 80-90% while keeping selection accuracy. v0.1.6 adds a zod Config schema so every toggle renders in the DSH settings UI.",
5
5
  "keywords": [
6
6
  "dsh-plugin",
7
7
  "dsh",
@@ -27,6 +27,10 @@
27
27
  "engines": {
28
28
  "node": ">=18"
29
29
  },
30
+ "dependencies": {
31
+ "zod": "^4.4.3",
32
+ "@deepseek-ai/schemastery": "^3.18.1"
33
+ },
30
34
  "peerDependencies": {
31
35
  "@deepseek-ai/cordis": "^4.0.1",
32
36
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"