skyloom 1.13.6 → 1.13.8

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 (193) hide show
  1. package/.github/workflows/ci.yml +36 -36
  2. package/README.md +220 -159
  3. package/config/providers.yaml +39 -39
  4. package/config/skills/api_integrator/SKILL.md +15 -15
  5. package/config/skills/arch_designer/SKILL.md +13 -13
  6. package/config/skills/ci_cd_manager/SKILL.md +14 -14
  7. package/config/skills/code_analysis/SKILL.md +13 -13
  8. package/config/skills/code_generator/SKILL.md +12 -12
  9. package/config/skills/code_reviewer/SKILL.md +13 -13
  10. package/config/skills/content_writer/SKILL.md +14 -14
  11. package/config/skills/data_transformer/SKILL.md +15 -15
  12. package/config/skills/document_analysis/SKILL.md +13 -13
  13. package/config/skills/emotional_companion/SKILL.md +15 -15
  14. package/config/skills/performance_checker/SKILL.md +14 -14
  15. package/config/skills/security_auditor/SKILL.md +14 -14
  16. package/config/skills/self_evolve/SKILL.md +13 -13
  17. package/config/skills/sys_operator/SKILL.md +15 -15
  18. package/config/skills/task_planner/SKILL.md +14 -14
  19. package/config/skills/web_research/SKILL.md +14 -14
  20. package/config/skills/workflow_designer/SKILL.md +13 -13
  21. package/dist/agents/dew.js +52 -52
  22. package/dist/agents/fair.js +84 -84
  23. package/dist/agents/fog.js +30 -30
  24. package/dist/agents/frost.js +32 -32
  25. package/dist/agents/rain.js +32 -32
  26. package/dist/agents/snow.js +68 -68
  27. package/dist/cli/commands_md.d.ts +41 -0
  28. package/dist/cli/commands_md.d.ts.map +1 -0
  29. package/dist/cli/commands_md.js +140 -0
  30. package/dist/cli/commands_md.js.map +1 -0
  31. package/dist/cli/input_macros.d.ts +28 -0
  32. package/dist/cli/input_macros.d.ts.map +1 -0
  33. package/dist/cli/input_macros.js +120 -0
  34. package/dist/cli/input_macros.js.map +1 -0
  35. package/dist/cli/loom.d.ts +220 -0
  36. package/dist/cli/loom.d.ts.map +1 -0
  37. package/dist/cli/loom.js +1094 -0
  38. package/dist/cli/loom.js.map +1 -0
  39. package/dist/cli/loom_chat.d.ts +20 -0
  40. package/dist/cli/loom_chat.d.ts.map +1 -0
  41. package/dist/cli/loom_chat.js +685 -0
  42. package/dist/cli/loom_chat.js.map +1 -0
  43. package/dist/cli/main.js +310 -14
  44. package/dist/cli/main.js.map +1 -1
  45. package/dist/cli/tui.d.ts.map +1 -1
  46. package/dist/cli/tui.js +7 -1
  47. package/dist/cli/tui.js.map +1 -1
  48. package/dist/core/agent.d.ts +20 -0
  49. package/dist/core/agent.d.ts.map +1 -1
  50. package/dist/core/agent.js +199 -16
  51. package/dist/core/agent.js.map +1 -1
  52. package/dist/core/factory.d.ts.map +1 -1
  53. package/dist/core/factory.js +34 -2
  54. package/dist/core/factory.js.map +1 -1
  55. package/dist/core/file_checkpoint.d.ts +57 -0
  56. package/dist/core/file_checkpoint.d.ts.map +1 -0
  57. package/dist/core/file_checkpoint.js +162 -0
  58. package/dist/core/file_checkpoint.js.map +1 -0
  59. package/dist/core/hooks.d.ts +43 -0
  60. package/dist/core/hooks.d.ts.map +1 -0
  61. package/dist/core/hooks.js +110 -0
  62. package/dist/core/hooks.js.map +1 -0
  63. package/dist/core/llm.d.ts.map +1 -1
  64. package/dist/core/llm.js +15 -9
  65. package/dist/core/llm.js.map +1 -1
  66. package/dist/core/longdoc.js +5 -5
  67. package/dist/core/mcp.d.ts +16 -0
  68. package/dist/core/mcp.d.ts.map +1 -1
  69. package/dist/core/mcp.js +55 -0
  70. package/dist/core/mcp.js.map +1 -1
  71. package/dist/core/model_config.d.ts +40 -0
  72. package/dist/core/model_config.d.ts.map +1 -0
  73. package/dist/core/model_config.js +191 -0
  74. package/dist/core/model_config.js.map +1 -0
  75. package/dist/core/skill.d.ts +7 -0
  76. package/dist/core/skill.d.ts.map +1 -1
  77. package/dist/core/skill.js +47 -0
  78. package/dist/core/skill.js.map +1 -1
  79. package/dist/core/skymd.d.ts +39 -0
  80. package/dist/core/skymd.d.ts.map +1 -0
  81. package/dist/core/skymd.js +177 -0
  82. package/dist/core/skymd.js.map +1 -0
  83. package/dist/core/tool.d.ts +12 -0
  84. package/dist/core/tool.d.ts.map +1 -1
  85. package/dist/core/tool.js +30 -0
  86. package/dist/core/tool.js.map +1 -1
  87. package/dist/core/verify.d.ts +27 -0
  88. package/dist/core/verify.d.ts.map +1 -0
  89. package/dist/core/verify.js +62 -0
  90. package/dist/core/verify.js.map +1 -0
  91. package/dist/skills/loader.d.ts +22 -2
  92. package/dist/skills/loader.d.ts.map +1 -1
  93. package/dist/skills/loader.js +45 -15
  94. package/dist/skills/loader.js.map +1 -1
  95. package/dist/tools/builtin.d.ts.map +1 -1
  96. package/dist/tools/builtin.js +13 -3
  97. package/dist/tools/builtin.js.map +1 -1
  98. package/dist/tools/model_tool.d.ts +11 -0
  99. package/dist/tools/model_tool.d.ts.map +1 -0
  100. package/dist/tools/model_tool.js +71 -0
  101. package/dist/tools/model_tool.js.map +1 -0
  102. package/dist/tools/todo.d.ts +30 -0
  103. package/dist/tools/todo.d.ts.map +1 -0
  104. package/dist/tools/todo.js +78 -0
  105. package/dist/tools/todo.js.map +1 -0
  106. package/docs/AESTHETIC_DESIGN.md +152 -144
  107. package/docs/OPTIMIZATION_PLAN.md +178 -178
  108. package/package.json +68 -68
  109. package/scripts/install.js +48 -48
  110. package/scripts/link.js +10 -10
  111. package/setup.bat +79 -79
  112. package/skill-test-ty2fOA/test.md +10 -10
  113. package/src/agents/dew.ts +70 -70
  114. package/src/agents/fair.ts +102 -102
  115. package/src/agents/fog.ts +48 -48
  116. package/src/agents/frost.ts +50 -50
  117. package/src/agents/rain.ts +50 -50
  118. package/src/agents/snow.ts +239 -239
  119. package/src/cli/commands_md.ts +112 -0
  120. package/src/cli/input_macros.ts +83 -0
  121. package/src/cli/loom.ts +982 -0
  122. package/src/cli/loom_chat.ts +598 -0
  123. package/src/cli/main.ts +255 -9
  124. package/src/cli/mode.ts +58 -58
  125. package/src/cli/tui.ts +228 -222
  126. package/src/core/agent/guard.ts +134 -134
  127. package/src/core/agent/task.ts +100 -100
  128. package/src/core/agent.ts +195 -16
  129. package/src/core/arbitrate.ts +162 -162
  130. package/src/core/catalog.ts +178 -178
  131. package/src/core/checkpoint.ts +94 -94
  132. package/src/core/estimate.ts +104 -104
  133. package/src/core/evolve.ts +191 -191
  134. package/src/core/factory.ts +31 -2
  135. package/src/core/file_checkpoint.ts +136 -0
  136. package/src/core/filter.ts +103 -103
  137. package/src/core/graph.ts +156 -156
  138. package/src/core/hooks.ts +126 -0
  139. package/src/core/icons.ts +53 -53
  140. package/src/core/index.ts +37 -37
  141. package/src/core/learn.ts +146 -146
  142. package/src/core/llm.ts +15 -9
  143. package/src/core/longdoc.ts +155 -155
  144. package/src/core/mcp.ts +48 -0
  145. package/src/core/mcp_server.ts +176 -176
  146. package/src/core/model_config.ts +157 -0
  147. package/src/core/profile.ts +255 -255
  148. package/src/core/router.ts +124 -124
  149. package/src/core/sandbox.ts +142 -142
  150. package/src/core/security.ts +243 -243
  151. package/src/core/skill.ts +42 -0
  152. package/src/core/skymd.ts +143 -0
  153. package/src/core/theme.ts +65 -65
  154. package/src/core/tool.ts +30 -0
  155. package/src/core/tool_router.ts +193 -193
  156. package/src/core/vector.ts +152 -152
  157. package/src/core/verify.ts +71 -0
  158. package/src/core/workspace.ts +150 -150
  159. package/src/plugins/loader.ts +66 -66
  160. package/src/skills/loader.ts +45 -16
  161. package/src/sql.js.d.ts +29 -29
  162. package/src/tools/builtin.ts +13 -3
  163. package/src/tools/computer.ts +269 -269
  164. package/src/tools/delegate.ts +49 -49
  165. package/src/tools/model_tool.ts +74 -0
  166. package/src/tools/todo.ts +76 -0
  167. package/src/web/tts.ts +93 -93
  168. package/tests/agent.test.ts +159 -159
  169. package/tests/agent_helpers.test.ts +48 -48
  170. package/tests/bus.test.ts +121 -121
  171. package/tests/catalog.test.ts +86 -86
  172. package/tests/checkpoint_commands.test.ts +124 -0
  173. package/tests/claude_compat.test.ts +110 -0
  174. package/tests/config.test.ts +41 -41
  175. package/tests/guard.test.ts +75 -75
  176. package/tests/icons.test.ts +45 -45
  177. package/tests/loom.test.ts +248 -0
  178. package/tests/memory.test.ts +170 -170
  179. package/tests/model_config.test.ts +109 -0
  180. package/tests/router.test.ts +86 -86
  181. package/tests/schemas.test.ts +51 -51
  182. package/tests/semantic.test.ts +83 -83
  183. package/tests/setup.ts +10 -10
  184. package/tests/skill.test.ts +172 -172
  185. package/tests/skymd.test.ts +146 -0
  186. package/tests/task.test.ts +60 -60
  187. package/tests/todo_toolstats.test.ts +94 -0
  188. package/tests/tool.test.ts +108 -108
  189. package/tests/tool_router.test.ts +71 -71
  190. package/tests/tui.test.ts +67 -67
  191. package/vitest.config.ts +17 -17
  192. package/=12 +0 -0
  193. package/=8 +0 -0
@@ -1,193 +1,193 @@
1
- /**
2
- * Tool-subset selection for LLM calls.
3
- *
4
- * Without filtering, every chat turn ships ~50 tool schemas (built-ins + MCP +
5
- * skill-required + delegation) to the model. That dilutes attention (the LLM
6
- * picks plausible-but-wrong tools more often) and burns 8-15k input tokens per
7
- * turn. This module narrows the active tool set to ~12 by lightweight scoring
8
- * against the user's latest message.
9
- *
10
- * The router intentionally avoids embeddings / LLM calls — it must run in <1ms
11
- * on every turn, before the real LLM call. A coarse keyword/substring score is
12
- * good enough to keep the right tools in and bad enough to be cheap.
13
- */
14
-
15
- import type { ToolDefinition, ToolRegistry } from './tool';
16
-
17
- // Infrastructure tools that are useful on most turns.
18
- const INFRA_TOOLS: ReadonlySet<string> = new Set([
19
- 'delegate_to',
20
- 'list_skills',
21
- 'use_skill',
22
- 'recall_facts',
23
- 'remember_fact',
24
- ]);
25
-
26
- const MUTATING_TOOLS: ReadonlySet<string> = new Set([
27
- 'write_file',
28
- 'edit_file',
29
- 'move_file',
30
- 'copy_file',
31
- 'delete_file',
32
- 'git_add',
33
- 'git_commit',
34
- 'git_checkout',
35
- 'shell_exec',
36
- 'http_post',
37
- 'mcp_add_server',
38
- 'mcp_remove_server',
39
- 'mcp_scaffold_server',
40
- 'kill_process',
41
- 'package_manager',
42
- 'service_control',
43
- ]);
44
-
45
- const TOKEN_RE = /[A-Za-z][A-Za-z0-9_]*|[一-鿿]+/g;
46
-
47
- const STOPWORDS: ReadonlySet<string> = new Set([
48
- 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'and', 'or', 'but',
49
- 'to', 'for', 'of', 'in', 'on', 'at', 'with', 'by', 'do', 'did',
50
- 'does', 'i', 'me', 'my', 'you', 'your', 'it', 'this', 'that',
51
- 'what', 'how', 'can', 'could', 'would', 'should', 'please',
52
- 'tell', 'show', 'help', 'ok', 'yes', 'no',
53
- '好', '的', '是', '我', '你', '他', '她', '它', '这', '那',
54
- '什么', '怎么', '请', '帮', '麻烦',
55
- ]);
56
-
57
- function tokenize(text: string): Set<string> {
58
- const tokens = new Set<string>();
59
- const matches = text?.toLowerCase().match(TOKEN_RE) || [];
60
- for (const t of matches) {
61
- if (t.length >= 2 && !STOPWORDS.has(t)) {
62
- tokens.add(t);
63
- }
64
- }
65
- return tokens;
66
- }
67
-
68
- function scoreTool(tool: ToolDefinition, queryTokens: Set<string>, queryLc: string): number {
69
- if (queryTokens.size === 0) return 0;
70
- let score = 0;
71
-
72
- // Tool name tokens carry the strongest signal
73
- const nameTokens = tokenize(tool.name.replace(/_/g, ' '));
74
- for (const qt of queryTokens) {
75
- if (nameTokens.has(qt)) {
76
- score += 5;
77
- } else if (tool.name.toLowerCase().includes(qt)) {
78
- score += 3;
79
- }
80
- }
81
-
82
- // Description tokens are weaker
83
- const descTokens = tokenize(tool.description);
84
- for (const qt of queryTokens) {
85
- if (descTokens.has(qt)) {
86
- score += 1;
87
- }
88
- }
89
-
90
- // Intent boosts for common Chinese/English tasks
91
- const name = tool.name;
92
- if (['read_file', 'list_directory', 'tree', 'file_search', 'code_search', 'grep'].includes(name) &&
93
- ['read', 'file', 'inspect', 'search', 'grep', '看', '读', '查', '搜索', '文件', '代码'].some(k => queryLc.includes(k))) {
94
- score += 4;
95
- }
96
- if (['write_file', 'edit_file', 'move_file', 'copy_file', 'delete_file'].includes(name) &&
97
- ['write', 'edit', 'modify', 'fix', 'save', '生成', '写', '改', '修复', '保存', '删除'].some(k => queryLc.includes(k))) {
98
- score += 4;
99
- }
100
- if (name.startsWith('git_') &&
101
- ['git', 'commit', 'diff', 'branch', '提交', '分支', '差异'].some(k => queryLc.includes(k))) {
102
- score += 4;
103
- }
104
- if (['web_search', 'fetch_page', 'http_get'].includes(name) &&
105
- ['web', 'url', 'http', 'research', '搜索', '网页', '联网', '资料'].some(k => queryLc.includes(k))) {
106
- score += 4;
107
- }
108
- if (['list_skills', 'use_skill'].includes(name) &&
109
- ['skill', '能力', '技能', 'ppt', 'pdf', 'excel', 'xlsx', 'docx'].some(k => queryLc.includes(k))) {
110
- score += 4;
111
- }
112
-
113
- return score;
114
- }
115
-
116
- /**
117
- * Return up to ~topK tool names ordered by relevance to the query.
118
- *
119
- * Always-included infrastructure tools and mustInclude (e.g. active
120
- * skill required_tools) are appended regardless of score. When the candidate
121
- * set is already small (<= topK + |mustInclude|), no filtering is applied.
122
- *
123
- * A short or empty query means we have no signal to filter — returning the
124
- * full candidate set is correct in that case.
125
- */
126
- export function selectRelevantTools(
127
- registry: ToolRegistry,
128
- candidateNames: string[],
129
- query: string,
130
- options?: {
131
- topK?: number;
132
- mustInclude?: Set<string>;
133
- }
134
- ): string[] {
135
- const topK = options?.topK ?? 12;
136
- const explicitMust = new Set(options?.mustInclude ?? []);
137
-
138
- const infraPresent = candidateNames.filter(n => INFRA_TOOLS.has(n) && !explicitMust.has(n));
139
- const mustPresent = candidateNames.filter(n => explicitMust.has(n));
140
- const remaining = candidateNames.filter(n => !explicitMust.has(n) && !INFRA_TOOLS.has(n));
141
-
142
- const queryTokens = tokenize(query);
143
- const queryLc = (query || '').toLowerCase();
144
- const smallSurface = candidateNames.length <= topK + mustPresent.length + infraPresent.length;
145
- const lowSignal = queryTokens.size < 2 && queryLc.length < 8;
146
-
147
- // No filtering when the set is already small or the query is too short
148
- if (smallSurface || lowSignal) {
149
- return [...mustPresent, ...infraPresent, ...remaining];
150
- }
151
-
152
- const scored: Array<{ name: string; score: number; penalty: number }> = [];
153
- const allScoredNames = [...infraPresent, ...remaining];
154
-
155
- for (const name of allScoredNames) {
156
- const tool = registry.get(name);
157
- if (!tool) continue;
158
- const score = scoreTool(tool, queryTokens, queryLc);
159
- const penalty = MUTATING_TOOLS.has(name) && score === 0 ? 1 : 0;
160
- scored.push({ name, score, penalty });
161
- }
162
-
163
- // Stable sort by descending score; zero-score tools only fill spare slots
164
- scored.sort((a, b) => {
165
- if (a.score !== b.score) return b.score - a.score;
166
- return a.penalty - b.penalty;
167
- });
168
-
169
- const budget = Math.max(0, topK - mustPresent.length);
170
- const picked: string[] = [];
171
- const pickedSet = new Set<string>();
172
-
173
- for (const item of scored) {
174
- if (picked.length >= budget) break;
175
- if (item.score > 0) {
176
- picked.push(item.name);
177
- pickedSet.add(item.name);
178
- }
179
- }
180
-
181
- // Fill remaining slots with zero-score tools
182
- if (picked.length < budget) {
183
- for (const item of scored) {
184
- if (picked.length >= budget) break;
185
- if (!pickedSet.has(item.name)) {
186
- picked.push(item.name);
187
- pickedSet.add(item.name);
188
- }
189
- }
190
- }
191
-
192
- return [...mustPresent, ...picked];
193
- }
1
+ /**
2
+ * Tool-subset selection for LLM calls.
3
+ *
4
+ * Without filtering, every chat turn ships ~50 tool schemas (built-ins + MCP +
5
+ * skill-required + delegation) to the model. That dilutes attention (the LLM
6
+ * picks plausible-but-wrong tools more often) and burns 8-15k input tokens per
7
+ * turn. This module narrows the active tool set to ~12 by lightweight scoring
8
+ * against the user's latest message.
9
+ *
10
+ * The router intentionally avoids embeddings / LLM calls — it must run in <1ms
11
+ * on every turn, before the real LLM call. A coarse keyword/substring score is
12
+ * good enough to keep the right tools in and bad enough to be cheap.
13
+ */
14
+
15
+ import type { ToolDefinition, ToolRegistry } from './tool';
16
+
17
+ // Infrastructure tools that are useful on most turns.
18
+ const INFRA_TOOLS: ReadonlySet<string> = new Set([
19
+ 'delegate_to',
20
+ 'list_skills',
21
+ 'use_skill',
22
+ 'recall_facts',
23
+ 'remember_fact',
24
+ ]);
25
+
26
+ const MUTATING_TOOLS: ReadonlySet<string> = new Set([
27
+ 'write_file',
28
+ 'edit_file',
29
+ 'move_file',
30
+ 'copy_file',
31
+ 'delete_file',
32
+ 'git_add',
33
+ 'git_commit',
34
+ 'git_checkout',
35
+ 'shell_exec',
36
+ 'http_post',
37
+ 'mcp_add_server',
38
+ 'mcp_remove_server',
39
+ 'mcp_scaffold_server',
40
+ 'kill_process',
41
+ 'package_manager',
42
+ 'service_control',
43
+ ]);
44
+
45
+ const TOKEN_RE = /[A-Za-z][A-Za-z0-9_]*|[一-鿿]+/g;
46
+
47
+ const STOPWORDS: ReadonlySet<string> = new Set([
48
+ 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'and', 'or', 'but',
49
+ 'to', 'for', 'of', 'in', 'on', 'at', 'with', 'by', 'do', 'did',
50
+ 'does', 'i', 'me', 'my', 'you', 'your', 'it', 'this', 'that',
51
+ 'what', 'how', 'can', 'could', 'would', 'should', 'please',
52
+ 'tell', 'show', 'help', 'ok', 'yes', 'no',
53
+ '好', '的', '是', '我', '你', '他', '她', '它', '这', '那',
54
+ '什么', '怎么', '请', '帮', '麻烦',
55
+ ]);
56
+
57
+ function tokenize(text: string): Set<string> {
58
+ const tokens = new Set<string>();
59
+ const matches = text?.toLowerCase().match(TOKEN_RE) || [];
60
+ for (const t of matches) {
61
+ if (t.length >= 2 && !STOPWORDS.has(t)) {
62
+ tokens.add(t);
63
+ }
64
+ }
65
+ return tokens;
66
+ }
67
+
68
+ function scoreTool(tool: ToolDefinition, queryTokens: Set<string>, queryLc: string): number {
69
+ if (queryTokens.size === 0) return 0;
70
+ let score = 0;
71
+
72
+ // Tool name tokens carry the strongest signal
73
+ const nameTokens = tokenize(tool.name.replace(/_/g, ' '));
74
+ for (const qt of queryTokens) {
75
+ if (nameTokens.has(qt)) {
76
+ score += 5;
77
+ } else if (tool.name.toLowerCase().includes(qt)) {
78
+ score += 3;
79
+ }
80
+ }
81
+
82
+ // Description tokens are weaker
83
+ const descTokens = tokenize(tool.description);
84
+ for (const qt of queryTokens) {
85
+ if (descTokens.has(qt)) {
86
+ score += 1;
87
+ }
88
+ }
89
+
90
+ // Intent boosts for common Chinese/English tasks
91
+ const name = tool.name;
92
+ if (['read_file', 'list_directory', 'tree', 'file_search', 'code_search', 'grep'].includes(name) &&
93
+ ['read', 'file', 'inspect', 'search', 'grep', '看', '读', '查', '搜索', '文件', '代码'].some(k => queryLc.includes(k))) {
94
+ score += 4;
95
+ }
96
+ if (['write_file', 'edit_file', 'move_file', 'copy_file', 'delete_file'].includes(name) &&
97
+ ['write', 'edit', 'modify', 'fix', 'save', '生成', '写', '改', '修复', '保存', '删除'].some(k => queryLc.includes(k))) {
98
+ score += 4;
99
+ }
100
+ if (name.startsWith('git_') &&
101
+ ['git', 'commit', 'diff', 'branch', '提交', '分支', '差异'].some(k => queryLc.includes(k))) {
102
+ score += 4;
103
+ }
104
+ if (['web_search', 'fetch_page', 'http_get'].includes(name) &&
105
+ ['web', 'url', 'http', 'research', '搜索', '网页', '联网', '资料'].some(k => queryLc.includes(k))) {
106
+ score += 4;
107
+ }
108
+ if (['list_skills', 'use_skill'].includes(name) &&
109
+ ['skill', '能力', '技能', 'ppt', 'pdf', 'excel', 'xlsx', 'docx'].some(k => queryLc.includes(k))) {
110
+ score += 4;
111
+ }
112
+
113
+ return score;
114
+ }
115
+
116
+ /**
117
+ * Return up to ~topK tool names ordered by relevance to the query.
118
+ *
119
+ * Always-included infrastructure tools and mustInclude (e.g. active
120
+ * skill required_tools) are appended regardless of score. When the candidate
121
+ * set is already small (<= topK + |mustInclude|), no filtering is applied.
122
+ *
123
+ * A short or empty query means we have no signal to filter — returning the
124
+ * full candidate set is correct in that case.
125
+ */
126
+ export function selectRelevantTools(
127
+ registry: ToolRegistry,
128
+ candidateNames: string[],
129
+ query: string,
130
+ options?: {
131
+ topK?: number;
132
+ mustInclude?: Set<string>;
133
+ }
134
+ ): string[] {
135
+ const topK = options?.topK ?? 12;
136
+ const explicitMust = new Set(options?.mustInclude ?? []);
137
+
138
+ const infraPresent = candidateNames.filter(n => INFRA_TOOLS.has(n) && !explicitMust.has(n));
139
+ const mustPresent = candidateNames.filter(n => explicitMust.has(n));
140
+ const remaining = candidateNames.filter(n => !explicitMust.has(n) && !INFRA_TOOLS.has(n));
141
+
142
+ const queryTokens = tokenize(query);
143
+ const queryLc = (query || '').toLowerCase();
144
+ const smallSurface = candidateNames.length <= topK + mustPresent.length + infraPresent.length;
145
+ const lowSignal = queryTokens.size < 2 && queryLc.length < 8;
146
+
147
+ // No filtering when the set is already small or the query is too short
148
+ if (smallSurface || lowSignal) {
149
+ return [...mustPresent, ...infraPresent, ...remaining];
150
+ }
151
+
152
+ const scored: Array<{ name: string; score: number; penalty: number }> = [];
153
+ const allScoredNames = [...infraPresent, ...remaining];
154
+
155
+ for (const name of allScoredNames) {
156
+ const tool = registry.get(name);
157
+ if (!tool) continue;
158
+ const score = scoreTool(tool, queryTokens, queryLc);
159
+ const penalty = MUTATING_TOOLS.has(name) && score === 0 ? 1 : 0;
160
+ scored.push({ name, score, penalty });
161
+ }
162
+
163
+ // Stable sort by descending score; zero-score tools only fill spare slots
164
+ scored.sort((a, b) => {
165
+ if (a.score !== b.score) return b.score - a.score;
166
+ return a.penalty - b.penalty;
167
+ });
168
+
169
+ const budget = Math.max(0, topK - mustPresent.length);
170
+ const picked: string[] = [];
171
+ const pickedSet = new Set<string>();
172
+
173
+ for (const item of scored) {
174
+ if (picked.length >= budget) break;
175
+ if (item.score > 0) {
176
+ picked.push(item.name);
177
+ pickedSet.add(item.name);
178
+ }
179
+ }
180
+
181
+ // Fill remaining slots with zero-score tools
182
+ if (picked.length < budget) {
183
+ for (const item of scored) {
184
+ if (picked.length >= budget) break;
185
+ if (!pickedSet.has(item.name)) {
186
+ picked.push(item.name);
187
+ pickedSet.add(item.name);
188
+ }
189
+ }
190
+ }
191
+
192
+ return [...mustPresent, ...picked];
193
+ }