wave-agent-sdk 1.2.0 → 1.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 (175) hide show
  1. package/dist/agent.d.ts +58 -4
  2. package/dist/agent.js +91 -19
  3. package/dist/builtin/index.js +2 -0
  4. package/dist/builtin/skills/settings.js +1 -12
  5. package/dist/builtin/skills/wave-daemon.d.ts +1 -0
  6. package/dist/builtin/skills/wave-daemon.js +194 -0
  7. package/dist/constants/images.d.ts +26 -0
  8. package/dist/constants/images.js +26 -0
  9. package/dist/constants/index.d.ts +16 -0
  10. package/dist/constants/index.js +16 -0
  11. package/dist/constants/memory.d.ts +26 -0
  12. package/dist/constants/memory.js +34 -0
  13. package/dist/constants/messages.d.ts +11 -0
  14. package/dist/constants/messages.js +11 -0
  15. package/dist/constants/plugins.d.ts +8 -0
  16. package/dist/constants/plugins.js +8 -0
  17. package/dist/constants/tools.d.ts +1 -0
  18. package/dist/constants/tools.js +1 -0
  19. package/dist/core/plugin.d.ts +53 -13
  20. package/dist/core/plugin.js +134 -26
  21. package/dist/core/session.d.ts +1 -1
  22. package/dist/core/session.js +1 -1
  23. package/dist/exec/catalog.d.ts +140 -0
  24. package/dist/exec/catalog.js +470 -0
  25. package/dist/exec/catalogAnnouncement.d.ts +89 -0
  26. package/dist/exec/catalogAnnouncement.js +293 -0
  27. package/dist/exec/constants.d.ts +51 -0
  28. package/dist/exec/constants.js +51 -0
  29. package/dist/exec/execRuntime.d.ts +55 -0
  30. package/dist/exec/execRuntime.js +217 -0
  31. package/dist/exec/workerSource.d.ts +28 -0
  32. package/dist/exec/workerSource.js +299 -0
  33. package/dist/host/index.d.ts +23 -0
  34. package/dist/host/index.js +23 -0
  35. package/dist/index.d.ts +5 -0
  36. package/dist/index.js +6 -0
  37. package/dist/managers/MemoryRuleManager.d.ts +6 -0
  38. package/dist/managers/MemoryRuleManager.js +12 -0
  39. package/dist/managers/aiManager.d.ts +35 -1
  40. package/dist/managers/aiManager.js +190 -21
  41. package/dist/managers/backgroundTaskManager.js +14 -0
  42. package/dist/managers/hookManager.d.ts +13 -0
  43. package/dist/managers/hookManager.js +31 -4
  44. package/dist/managers/liveConfigManager.d.ts +33 -0
  45. package/dist/managers/liveConfigManager.js +103 -8
  46. package/dist/managers/lspManager.d.ts +9 -0
  47. package/dist/managers/lspManager.js +47 -18
  48. package/dist/managers/mcpManager.d.ts +45 -10
  49. package/dist/managers/mcpManager.js +103 -1
  50. package/dist/managers/messageManager.d.ts +48 -5
  51. package/dist/managers/messageManager.js +107 -21
  52. package/dist/managers/permissionManager.d.ts +40 -0
  53. package/dist/managers/permissionManager.js +63 -8
  54. package/dist/managers/pluginManager.d.ts +46 -2
  55. package/dist/managers/pluginManager.js +117 -11
  56. package/dist/managers/pluginScopeManager.d.ts +15 -2
  57. package/dist/managers/pluginScopeManager.js +20 -1
  58. package/dist/managers/skillManager.d.ts +19 -0
  59. package/dist/managers/skillManager.js +44 -0
  60. package/dist/managers/slashCommandManager.d.ts +10 -0
  61. package/dist/managers/slashCommandManager.js +35 -3
  62. package/dist/managers/subagentManager.d.ts +8 -0
  63. package/dist/managers/subagentManager.js +20 -0
  64. package/dist/managers/toolManager.d.ts +29 -3
  65. package/dist/managers/toolManager.js +87 -13
  66. package/dist/prompts/autoMemory.d.ts +9 -0
  67. package/dist/prompts/autoMemory.js +30 -31
  68. package/dist/prompts/autoMemoryExtraction.d.ts +4 -0
  69. package/dist/prompts/autoMemoryExtraction.js +8 -111
  70. package/dist/prompts/memoryTypes.d.ts +63 -0
  71. package/dist/prompts/memoryTypes.js +191 -0
  72. package/dist/services/GitService.d.ts +7 -0
  73. package/dist/services/GitService.js +23 -0
  74. package/dist/services/MarketplaceService.d.ts +101 -17
  75. package/dist/services/MarketplaceService.js +318 -119
  76. package/dist/services/artifactContent.d.ts +84 -0
  77. package/dist/services/artifactContent.js +204 -0
  78. package/dist/services/artifactSession.d.ts +6 -0
  79. package/dist/services/artifactSession.js +17 -0
  80. package/dist/services/autoMemoryService.js +5 -13
  81. package/dist/services/configurationService.d.ts +60 -9
  82. package/dist/services/configurationService.js +129 -54
  83. package/dist/services/contentSummarizer.d.ts +15 -0
  84. package/dist/services/contentSummarizer.js +45 -0
  85. package/dist/services/execAvailability.d.ts +9 -0
  86. package/dist/services/execAvailability.js +32 -0
  87. package/dist/services/fileWatcher.js +61 -6
  88. package/dist/services/initializationService.js +19 -15
  89. package/dist/services/interactionService.d.ts +9 -1
  90. package/dist/services/interactionService.js +28 -8
  91. package/dist/services/jsonlHandler.d.ts +84 -0
  92. package/dist/services/jsonlHandler.js +209 -14
  93. package/dist/services/memory.d.ts +3 -1
  94. package/dist/services/memory.js +13 -9
  95. package/dist/services/officialMarketplaceMirror.js +3 -2
  96. package/dist/services/pluginLoader.d.ts +12 -4
  97. package/dist/services/pluginLoader.js +38 -7
  98. package/dist/services/remoteSettingsService.js +16 -2
  99. package/dist/services/session.d.ts +74 -0
  100. package/dist/services/session.js +144 -3
  101. package/dist/services/sessionEntries.d.ts +2 -0
  102. package/dist/services/sessionEntries.js +20 -0
  103. package/dist/stdio/index.d.ts +3 -1
  104. package/dist/stdio/index.js +3 -1
  105. package/dist/stdio/notificationRouter.js +1 -0
  106. package/dist/stdio/stdioAgent.d.ts +14 -7
  107. package/dist/stdio/stdioAgent.js +19 -0
  108. package/dist/tools/artifactTool.js +406 -273
  109. package/dist/tools/bashTool.js +8 -6
  110. package/dist/tools/editTool.js +6 -3
  111. package/dist/tools/execTool.d.ts +2 -0
  112. package/dist/tools/execTool.js +165 -0
  113. package/dist/tools/grepTool.js +7 -1
  114. package/dist/tools/readTool.js +30 -2
  115. package/dist/tools/types.d.ts +34 -8
  116. package/dist/tools/webFetchTool.js +15 -166
  117. package/dist/tools/workflowTool.js +40 -8
  118. package/dist/tools/writeTool.js +6 -3
  119. package/dist/types/agent.d.ts +20 -5
  120. package/dist/types/configuration.d.ts +39 -1
  121. package/dist/types/marketplace.d.ts +40 -2
  122. package/dist/types/mcp.d.ts +39 -0
  123. package/dist/types/permissions.d.ts +22 -0
  124. package/dist/types/permissions.js +17 -0
  125. package/dist/types/plugins.d.ts +26 -2
  126. package/dist/types/skills.d.ts +15 -0
  127. package/dist/utils/constants.d.ts +10 -0
  128. package/dist/utils/constants.js +10 -0
  129. package/dist/utils/containerSetup.js +43 -0
  130. package/dist/utils/convertMessagesForAPI.d.ts +7 -1
  131. package/dist/utils/convertMessagesForAPI.js +64 -14
  132. package/dist/utils/fileChangeReminder.d.ts +20 -0
  133. package/dist/utils/fileChangeReminder.js +153 -0
  134. package/dist/utils/fileSearch.js +4 -3
  135. package/dist/utils/fileUtils.d.ts +33 -0
  136. package/dist/utils/fileUtils.js +81 -0
  137. package/dist/utils/frontmatterYaml.d.ts +33 -0
  138. package/dist/utils/frontmatterYaml.js +192 -0
  139. package/dist/utils/imageBudget.d.ts +85 -0
  140. package/dist/utils/imageBudget.js +109 -0
  141. package/dist/utils/imageDimensions.d.ts +83 -0
  142. package/dist/utils/imageDimensions.js +232 -0
  143. package/dist/utils/imageProcessor.d.ts +66 -0
  144. package/dist/utils/imageProcessor.js +84 -0
  145. package/dist/utils/imageRewrite.d.ts +29 -0
  146. package/dist/utils/imageRewrite.js +251 -0
  147. package/dist/utils/markdownParser.d.ts +5 -1
  148. package/dist/utils/markdownParser.js +9 -51
  149. package/dist/utils/mcpInstructions.d.ts +61 -0
  150. package/dist/utils/mcpInstructions.js +126 -0
  151. package/dist/utils/mcpUtils.d.ts +7 -0
  152. package/dist/utils/mcpUtils.js +11 -2
  153. package/dist/utils/memoryAge.d.ts +32 -0
  154. package/dist/utils/memoryAge.js +47 -0
  155. package/dist/utils/memoryEntrypoint.d.ts +20 -0
  156. package/dist/utils/memoryEntrypoint.js +49 -0
  157. package/dist/utils/memoryIndex.d.ts +30 -0
  158. package/dist/utils/memoryIndex.js +76 -0
  159. package/dist/utils/messageOperations.d.ts +6 -2
  160. package/dist/utils/messageOperations.js +40 -29
  161. package/dist/utils/nestedMemory.d.ts +22 -0
  162. package/dist/utils/nestedMemory.js +61 -0
  163. package/dist/utils/npmTarball.d.ts +19 -0
  164. package/dist/utils/npmTarball.js +92 -0
  165. package/dist/utils/pluginSource.d.ts +37 -0
  166. package/dist/utils/pluginSource.js +73 -0
  167. package/dist/utils/ripgrep.d.ts +18 -4
  168. package/dist/utils/ripgrep.js +56 -4
  169. package/dist/utils/runtimeDeps.d.ts +35 -0
  170. package/dist/utils/runtimeDeps.js +426 -0
  171. package/dist/utils/skillParser.js +22 -52
  172. package/dist/utils/subagentParser.js +39 -43
  173. package/dist/utils/userSettings.d.ts +90 -0
  174. package/dist/utils/userSettings.js +291 -0
  175. package/package.json +10 -7
@@ -0,0 +1,470 @@
1
+ import { EXEC_RESERVED_NAMESPACE } from "./constants.js";
2
+ /**
3
+ * Recursion ceiling for signature rendering. Object, array and union recursion all
4
+ * increment depth, so this bounds every path: a pathological or structurally cyclic
5
+ * schema degrades to `unknown` instead of overflowing the stack. Rendering must
6
+ * never throw.
7
+ *
8
+ * This is the *only* silent reduction left in the renderer — there is deliberately
9
+ * no cap on properties per level or on enum variants. A wide schema or a long enum
10
+ * is decision-relevant text, and truncating it is how the model ends up guessing
11
+ * which parameter or which variant to use. Depth 8 and `unknown` are opencode's
12
+ * values (`tool-schema.ts`, `MAX_RENDER_DEPTH`).
13
+ */
14
+ const MAX_SIGNATURE_DEPTH = 8;
15
+ /** Cap a rendered *tool* description at one line of this length. */
16
+ const MAX_DESCRIPTION_CHARS = 120;
17
+ /** A property name that can be written bare in TypeScript. */
18
+ const IDENTIFIER_SEGMENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
19
+ /**
20
+ * Budget accounting for the catalog, in estimated tokens.
21
+ *
22
+ * Applied to a whole catalog entry, which is a multi-line block: its newlines
23
+ * and indentation are part of `text.length`, so `chars / 4` already covers them.
24
+ *
25
+ * A plain `chars / 4`, same basis as opencode's `catalogBudget`. Deliberately
26
+ * not the CJK-aware `utils/tokenEstimate`: MCP tool descriptions are
27
+ * overwhelmingly English, so telling CJK from Latin text would not move the
28
+ * result in practice.
29
+ */
30
+ const estimateCatalogTokens = (text) => Math.round(text.length / 4);
31
+ /**
32
+ * Every MCP tool the current agent may call, in catalog form.
33
+ *
34
+ * Derived from `getMcpToolsConfig()` — the exact source that produces the flat
35
+ * `tools[]` declarations when the pool is not collapsed, and the one whose
36
+ * connected/reconnecting filtering already keeps a transient disconnect from
37
+ * churning the tool list. So "the catalog equals what would have been declared"
38
+ * holds structurally, not by parallel construction.
39
+ */
40
+ export function buildExecPool(mcpManager, permissionManager) {
41
+ const outputSchemas = mcpManager.getMcpToolOutputSchemas();
42
+ const entries = [];
43
+ for (const tool of mcpManager.getMcpToolsConfig()) {
44
+ if (permissionManager?.isToolDenied(tool.function.name))
45
+ continue;
46
+ entries.push({
47
+ name: tool.function.name,
48
+ description: tool.function.description,
49
+ inputSchema: tool.function.parameters,
50
+ outputSchema: outputSchemas.get(tool.function.name),
51
+ });
52
+ }
53
+ return entries;
54
+ }
55
+ /** Render an object key, quoting names that are not valid identifiers. */
56
+ function renderKey(name) {
57
+ return IDENTIFIER_SEGMENT.test(name) ? name : JSON.stringify(name);
58
+ }
59
+ /**
60
+ * The property path the sandbox exposes a tool under. Non-identifier names use
61
+ * bracket form because `tools.mcp__my-srv__x` would parse as a subtraction.
62
+ */
63
+ function toolExpression(name) {
64
+ return IDENTIFIER_SEGMENT.test(name)
65
+ ? `tools.${name}`
66
+ : `tools[${JSON.stringify(name)}]`;
67
+ }
68
+ /**
69
+ * Trim a *tool* description to its first line and cap its length.
70
+ *
71
+ * Only tool-level prose is compressed here: it is padding that the model does not
72
+ * need to act, and the full text stays reachable through search. Field descriptions
73
+ * are never clamped (see `jsdoc`) — those carry the decision guidance.
74
+ */
75
+ function clampDescription(text) {
76
+ const first = text.split("\n")[0].trim();
77
+ return first.length > MAX_DESCRIPTION_CHARS
78
+ ? `${first.slice(0, MAX_DESCRIPTION_CHARS - 3)}...`
79
+ : first;
80
+ }
81
+ /**
82
+ * JSDoc tags for a field. Only keywords that survive `cleanSchema` are
83
+ * supported; anything else (`minimum`, `pattern`, `title`, ...) is dropped.
84
+ */
85
+ function docTags(schema) {
86
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
87
+ return [];
88
+ const typed = schema;
89
+ const tags = [];
90
+ if (typed.deprecated === true)
91
+ tags.push("@deprecated");
92
+ if (typed.default !== undefined) {
93
+ const rendered = JSON.stringify(typed.default);
94
+ if (rendered !== undefined)
95
+ tags.push(`@default ${rendered}`);
96
+ }
97
+ if (typeof typed.format === "string")
98
+ tags.push(`@format ${typed.format}`);
99
+ if (typeof typed.minItems === "number") {
100
+ tags.push(`@minItems ${typed.minItems}`);
101
+ }
102
+ if (typeof typed.maxItems === "number") {
103
+ tags.push(`@maxItems ${typed.maxItems}`);
104
+ }
105
+ return tags;
106
+ }
107
+ /**
108
+ * The JSDoc block rendered above one field, indented to `pad`. Emits nothing
109
+ * when the field has neither a description nor a tag, so plain fields stay
110
+ * unadorned.
111
+ *
112
+ * The description is kept verbatim — multi-line included, no width cap. A field
113
+ * description says what to put in the field ("which of these variants, and why"),
114
+ * so cutting it at a fixed column cuts the guidance and keeps the preamble. Only
115
+ * the tool's own description is clamped (see `clampDescription`); when a model
116
+ * needs that one in full it searches the pool by name.
117
+ */
118
+ function jsdoc(schema, pad) {
119
+ const description = schema && typeof schema === "object" && !Array.isArray(schema)
120
+ ? schema.description
121
+ : undefined;
122
+ const raw = [
123
+ ...(typeof description === "string" ? description.split("\n") : []),
124
+ ...docTags(schema),
125
+ ]
126
+ // A `*/` inside third-party text would close the comment early. Split/join
127
+ // rather than `replaceAll`: these packages compile against lib ES2020.
128
+ .map((line) => line.split("*/").join("* /").replace(/\s+$/, ""));
129
+ while (raw.length > 0 && raw[0].trim() === "")
130
+ raw.shift();
131
+ while (raw.length > 0 && raw[raw.length - 1].trim() === "")
132
+ raw.pop();
133
+ if (raw.length === 0)
134
+ return "";
135
+ if (raw.length === 1)
136
+ return `${pad}/** ${raw[0]} */\n`;
137
+ const body = raw
138
+ .map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`)
139
+ .join("\n");
140
+ return `${pad}/**\n${body}\n${pad} */\n`;
141
+ }
142
+ /**
143
+ * Render a JSON Schema as TypeScript: a multi-line block whose fields each carry
144
+ * their own JSDoc. Only recursion depth is capped; properties and enum variants
145
+ * are rendered in full.
146
+ */
147
+ function renderType(schema, depth) {
148
+ if (depth > MAX_SIGNATURE_DEPTH)
149
+ return "unknown";
150
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
151
+ return "unknown";
152
+ }
153
+ const typed = schema;
154
+ if (Array.isArray(typed.enum)) {
155
+ return typed.enum
156
+ .map((value) => JSON.stringify(value) ?? "null")
157
+ .join(" | ");
158
+ }
159
+ const union = Array.isArray(typed.anyOf)
160
+ ? typed.anyOf
161
+ : Array.isArray(typed.oneOf)
162
+ ? typed.oneOf
163
+ : undefined;
164
+ if (union) {
165
+ // No cap on branches, for the reason there is none on properties or enum
166
+ // variants: a union is a list of choices, and showing the first few is how
167
+ // the model ends up using a branch that does not exist. opencode's renderer,
168
+ // whose depth ceiling this one borrows, renders every member too.
169
+ return union.map((variant) => renderType(variant, depth + 1)).join(" | ");
170
+ }
171
+ if (Array.isArray(typed.type)) {
172
+ const names = typed.type.filter((value) => typeof value === "string");
173
+ if (names.length > 0)
174
+ return names.join(" | ");
175
+ return "unknown";
176
+ }
177
+ if (typed.type === "array" || typed.items) {
178
+ return `Array<${renderType(typed.items, depth + 1)}>`;
179
+ }
180
+ if (typed.type === "object" || typed.properties) {
181
+ const properties = (typed.properties ?? {});
182
+ const required = new Set(Array.isArray(typed.required) ? typed.required : []);
183
+ const keys = Object.keys(properties);
184
+ if (keys.length === 0)
185
+ return "{}";
186
+ const pad = " ".repeat(depth + 1);
187
+ const close = " ".repeat(depth);
188
+ const lines = keys.map((key) => `${jsdoc(properties[key], pad)}${pad}${renderKey(key)}${required.has(key) ? "" : "?"}: ${renderType(properties[key], depth + 1)},`);
189
+ return `{\n${lines.join("\n")}\n${close}}`;
190
+ }
191
+ return typeof typed.type === "string" ? typed.type : "unknown";
192
+ }
193
+ /**
194
+ * The callable signature for one tool: parameters and return type. The catalog and
195
+ * search results share it, so the model can copy either one verbatim.
196
+ *
197
+ * The return type comes from the schema the server declared for its output, and a
198
+ * tool that declared none still gets `Promise<unknown>` rather than no return type
199
+ * at all: the sandbox resolves every call to the tool's output (its structured
200
+ * content, else its text, else `null`), and a signature ending at the parameters
201
+ * would read as "calls this, get nothing". Rendering it from the same rule the
202
+ * runtime applies is what keeps the catalog from teaching a shape the host will
203
+ * not deliver.
204
+ */
205
+ export function renderToolSignature(entry) {
206
+ return `${toolExpression(entry.name)}(${renderType(entry.inputSchema, 0)}): Promise<${renderType(entry.outputSchema, 0)}>`;
207
+ }
208
+ /**
209
+ * The path the sandbox exposes search under. Built from the reserved namespace
210
+ * (the sandbox builds it the same way), so prose and runtime cannot drift.
211
+ */
212
+ const SEARCH_EXPRESSION = `tools[${JSON.stringify(EXEC_RESERVED_NAMESPACE)}].search`;
213
+ /**
214
+ * Input schema of the sandbox's `search` entry point.
215
+ *
216
+ * Load-bearing: the call form in the tool description, the call form in error
217
+ * messages and the validation `resolveSearchQuery` runs all come from this object.
218
+ * The description asked for the object form while the host only accepted a
219
+ * positional string, and nothing in the code tied the two together — one object
220
+ * makes that class of drift impossible rather than unlikely.
221
+ */
222
+ const SEARCH_INPUT_SCHEMA = {
223
+ type: "object",
224
+ properties: {
225
+ query: {
226
+ type: "string",
227
+ description: "Substring matched against tool names and descriptions, case-insensitively. Omit it (or pass an empty string) to list the entire pool.",
228
+ },
229
+ },
230
+ };
231
+ /**
232
+ * Output schema of the sandbox's `search` entry point.
233
+ *
234
+ * The same role `SEARCH_INPUT_SCHEMA` plays at the other end of the call: the
235
+ * return type in the rendered signature and the value the host actually hands back
236
+ * both come from this one object, so "what a hit looks like" cannot drift between
237
+ * the prose and the runtime. It also keeps the sandbox API uniform — every call
238
+ * resolves to its output, so nothing hands back a JSON *string* to parse.
239
+ */
240
+ const SEARCH_OUTPUT_SCHEMA = {
241
+ type: "array",
242
+ items: {
243
+ type: "object",
244
+ properties: {
245
+ name: { type: "string" },
246
+ description: { type: "string" },
247
+ signature: { type: "string" },
248
+ },
249
+ required: ["name", "signature"],
250
+ },
251
+ };
252
+ const SEARCH_INPUT_KEYS = Object.keys(SEARCH_INPUT_SCHEMA.properties);
253
+ /** A type-shaped placeholder for a field, used by the one-line call form. */
254
+ function placeholderFor(schema) {
255
+ const type = schema && typeof schema === "object" && !Array.isArray(schema)
256
+ ? schema.type
257
+ : undefined;
258
+ switch (type) {
259
+ case "string":
260
+ return '"..."';
261
+ case "number":
262
+ case "integer":
263
+ return "0";
264
+ case "boolean":
265
+ return "false";
266
+ case "array":
267
+ return "[]";
268
+ case "object":
269
+ return "{}";
270
+ default:
271
+ return "...";
272
+ }
273
+ }
274
+ /**
275
+ * The callable signature of search, rendered by the very function that renders
276
+ * every catalog entry, so its shape cannot drift from what the host accepts or
277
+ * returns. Multi-line, for the sandbox API blurb where there is room to show the
278
+ * field docs.
279
+ */
280
+ export function renderSearchSignature() {
281
+ return `${SEARCH_EXPRESSION}(${renderType(SEARCH_INPUT_SCHEMA, 0)}): Promise<${renderType(SEARCH_OUTPUT_SCHEMA, 0)}>`;
282
+ }
283
+ /**
284
+ * The one-line call form of search: the path plus a placeholder per field, all
285
+ * derived from `SEARCH_INPUT_SCHEMA`. For prose that cannot afford the multi-line
286
+ * block — the catalog's `PARTIAL` notice and error messages, which must still name
287
+ * a shape the host accepts.
288
+ */
289
+ export function renderSearchCallForm() {
290
+ const properties = SEARCH_INPUT_SCHEMA.properties;
291
+ const fields = Object.keys(properties)
292
+ .map((key) => `${renderKey(key)}: ${placeholderFor(properties[key])}`)
293
+ .join(", ");
294
+ return `${SEARCH_EXPRESSION}({ ${fields} })`;
295
+ }
296
+ /**
297
+ * Validate a search call's arguments against `SEARCH_INPUT_SCHEMA` and return the
298
+ * query to match on.
299
+ *
300
+ * The sandbox only guarantees a plain object (see `callHost`); it knows nothing
301
+ * about this schema, so a call naming a field the schema does not have must fail
302
+ * loudly here. Treating `{ q: "..." }` as an empty query would answer "here is the
303
+ * whole pool" and dress a typo up as a successful search.
304
+ */
305
+ export function resolveSearchQuery(args) {
306
+ const unexpected = Object.keys(args).find((key) => !SEARCH_INPUT_KEYS.includes(key));
307
+ if (unexpected !== undefined) {
308
+ throw new Error(`search() does not take "${unexpected}". Expected ${renderSearchCallForm()}`);
309
+ }
310
+ const query = args.query;
311
+ if (query === undefined)
312
+ return "";
313
+ if (typeof query !== "string") {
314
+ throw new Error(`search() expects "query" to be a string, got ${typeof query}. Expected ${renderSearchCallForm()}`);
315
+ }
316
+ return query.trim().toLowerCase();
317
+ }
318
+ /**
319
+ * A single catalog entry: its signature (possibly several lines) followed by the
320
+ * first line of its description.
321
+ */
322
+ export function renderCatalogEntry(entry) {
323
+ const signature = renderToolSignature(entry);
324
+ const description = clampDescription(entry.description ?? "");
325
+ return description ? `${signature} // ${description}` : signature;
326
+ }
327
+ /**
328
+ * The one-line index of a server: `- mcp__github (40 tools, 13 shown)`. The tail
329
+ * is dropped when the server is fully shown, and reads `none shown` when it got
330
+ * no seat at all.
331
+ *
332
+ * The prefix is the flattened name's, not the routing key alone, so the line is a
333
+ * substring of every tool name it covers (and of what search matches on). For a
334
+ * server whose name itself contains `__` the key is only the first segment — the
335
+ * same pre-existing ambiguity the grouping key has (see `execNamespace`).
336
+ *
337
+ * Exported for the announcement's per-namespace delta lines: the same renderer for
338
+ * the same counts, so a summary line and a delta line cannot drift apart.
339
+ */
340
+ export function summarizeNamespace(name, count, shownCount) {
341
+ const label = `${count} tool${count === 1 ? "" : "s"}`;
342
+ const detail = shownCount === count
343
+ ? ""
344
+ : shownCount === 0
345
+ ? ", none shown"
346
+ : `, ${shownCount} shown`;
347
+ return `- mcp__${name} (${label}${detail})`;
348
+ }
349
+ /**
350
+ * The server a flat MCP tool name routes to. Mirrors the split `executeMcpTool`
351
+ * does on the way back (`parts[1]`), so a group key always agrees with where a
352
+ * call would actually go — including its behaviour for server names that
353
+ * themselves contain `__`.
354
+ */
355
+ function execNamespace(name) {
356
+ return name.split("__")[1] ?? name;
357
+ }
358
+ /**
359
+ * Cheapest rendered block first, ties broken by tool name.
360
+ *
361
+ * The rotation takes each server's next unshown entry every round, so this order
362
+ * *is* the seat order: cheapest-first means one budget buys the most entries, and
363
+ * the tie-break keeps the result a pure function of the entries themselves — the
364
+ * order `tools/list` happened to return is not a reason to seat one tool first.
365
+ * opencode ranks its listings the same way (`rankListings`: cost, then path).
366
+ */
367
+ function orderByCost(group) {
368
+ return [...group].sort((left, right) => {
369
+ const delta = estimateCatalogTokens(renderCatalogEntry(left)) -
370
+ estimateCatalogTokens(renderCatalogEntry(right));
371
+ if (delta !== 0)
372
+ return delta;
373
+ return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
374
+ });
375
+ }
376
+ /** Groups in order of first appearance; within a group, cheapest block first. */
377
+ function groupByNamespace(entries) {
378
+ const groups = new Map();
379
+ for (const entry of entries) {
380
+ const namespace = execNamespace(entry.name);
381
+ const group = groups.get(namespace);
382
+ if (group) {
383
+ group.push(entry);
384
+ }
385
+ else {
386
+ groups.set(namespace, [entry]);
387
+ }
388
+ }
389
+ return [...groups.values()].map(orderByCost);
390
+ }
391
+ /**
392
+ * Render the catalog within an estimated-token budget.
393
+ *
394
+ * Selection round-robins across servers: one entry per server per round, so
395
+ * every server gets a seat before any server gets its second. A plain first-N
396
+ * cut would drop whole servers that happen to be connected later, and the model
397
+ * reads a missing server as "those tools do not exist" — the failure mode this
398
+ * whole feature has to avoid.
399
+ *
400
+ * An entry is a whole multi-line block and is budgeted and placed atomically: a
401
+ * server either gets the block this round or sits the round out, never half a
402
+ * signature.
403
+ *
404
+ * A truncated catalog additionally prints one summary line per server (see
405
+ * `summarizeNamespace`), before that server's blocks and outside the budget.
406
+ *
407
+ * The truncation notice deliberately carries no budget number: the budget is a
408
+ * tuning knob, and rendering it would make an unchanged tool pool produce
409
+ * different model-visible text after a config change.
410
+ */
411
+ export function renderCatalog(entries, budgetTokens) {
412
+ const groups = groupByNamespace(entries);
413
+ const picked = groups.map(() => []);
414
+ let used = 0;
415
+ let shown = 0;
416
+ let active = groups.map((_, index) => index);
417
+ while (active.length > 0) {
418
+ const stillActive = [];
419
+ for (const index of active) {
420
+ const group = groups[index];
421
+ const entry = group[picked[index].length];
422
+ if (entry === undefined)
423
+ continue;
424
+ const block = renderCatalogEntry(entry);
425
+ const cost = estimateCatalogTokens(block) + 1;
426
+ // The very first entry is always shown even if it alone is over budget:
427
+ // an empty catalog would be worse than an over-budget one.
428
+ if (shown > 0 && used + cost > budgetTokens)
429
+ continue;
430
+ picked[index].push(block);
431
+ used += cost;
432
+ shown += 1;
433
+ if (picked[index].length < group.length)
434
+ stillActive.push(index);
435
+ }
436
+ active = stillActive;
437
+ }
438
+ const truncated = shown < entries.length;
439
+ // Rotation alone cannot promise any server a seat — a group whose block does
440
+ // not fit the remaining budget sits the round out for good — so a truncated
441
+ // catalog also names every server, one line each, outside the budget. Without
442
+ // that line a server nobody picked is invisible, and "not in the catalog" is
443
+ // read as "that capability does not exist". An untruncated catalog skips them:
444
+ // the entries are already the index.
445
+ const lines = [];
446
+ for (let index = 0; index < groups.length; index += 1) {
447
+ if (truncated) {
448
+ const group = groups[index];
449
+ lines.push(summarizeNamespace(execNamespace(group[0].name), group.length, picked[index].length));
450
+ }
451
+ lines.push(...picked[index]);
452
+ }
453
+ if (truncated) {
454
+ // Counts only: the call form that reaches the rest is taught once, in the
455
+ // announcement's search section, which exists in exactly this state. Writing it
456
+ // here as well would give the same call two spellings to drift between.
457
+ lines.push(`PARTIAL — ${shown} of ${entries.length} tools shown.`);
458
+ }
459
+ return {
460
+ text: lines.join("\n"),
461
+ shown,
462
+ total: entries.length,
463
+ truncated,
464
+ namespaces: groups.map((group, index) => ({
465
+ name: execNamespace(group[0].name),
466
+ count: group.length,
467
+ shown: picked[index].length,
468
+ })),
469
+ };
470
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The MCP catalog, announced in the conversation instead of the `Exec` tool
3
+ * description. opencode only made this move on its v2 line (v1 shipped the catalog
4
+ * inside `execute`'s description, which is what this repo did until now).
5
+ *
6
+ * Why it cannot stay in the description: `tools[]` sits inside the cached prefix,
7
+ * and the catalog is a function of the pool. Every server that connects, drops, or
8
+ * changes its `tools/list` would rewrite the declaration and take the whole cached
9
+ * prefix with it. The description is a pool-independent constant now; the catalog is
10
+ * appended at the tail, where an addition invalidates nothing that already went out.
11
+ *
12
+ * State lives in the history, not in the process (same rule as
13
+ * `utils/mcpInstructions.ts`): each announcement leads with a marker line carrying
14
+ * the hash of a full catalog body. Three consequences, all deliberate:
15
+ * - Once the marker is gone (compaction, rewind), the next turn announces the full
16
+ * catalog again. A duplicate is better than a loss.
17
+ * - A delta is only emitted while the full catalog it is relative to is still in
18
+ * the history. The marker carries that baseline's hash (`bh`) so the check stays
19
+ * local: a delta is relative, and without its basis it says nothing.
20
+ * - The hash covers rendered text, so editing the renderer re-announces the catalog
21
+ * once for existing sessions. That is what buys the invariant "same hash ⇒ the
22
+ * same bytes the model read". It is also why a full catalog carries no "this
23
+ * supersedes the previous one" preamble: such a line would depend on history, and
24
+ * a history-dependent body can never compare equal to a freshly rendered one —
25
+ * the session would re-announce on every single turn.
26
+ */
27
+ import type { Message } from "../types/messaging.js";
28
+ import type { RenderedCatalog } from "./catalog.js";
29
+ /** Marks an announcement so the history scan can find it. */
30
+ export declare const EXEC_CATALOG_MARKER_PREFIX = "<!-- exec-catalog ";
31
+ export declare const EXEC_CATALOG_MARKER_SUFFIX = " -->";
32
+ /** Which body a marker leads. */
33
+ type ExecCatalogAnnouncementKind = "full" | "delta" | "removed";
34
+ /**
35
+ * A marker's payload: what a later turn needs to *compare* against, never the body
36
+ * itself. The body is the message the marker leads.
37
+ */
38
+ export interface ExecCatalogMarker {
39
+ kind: ExecCatalogAnnouncementKind;
40
+ /**
41
+ * Hash of the full catalog body as of this announcement — for a delta too, where
42
+ * it is the hash of what a full catalog would say right now. That is what lets the
43
+ * next turn (whose pool has not moved on since) recognise the delta as current and
44
+ * stay silent.
45
+ */
46
+ hash?: string;
47
+ /** 1 when the catalog was truncated at that moment. */
48
+ truncated?: number;
49
+ /** Namespace → how many tools it held. The only material a delta can be built from. */
50
+ namespaces?: Record<string, number>;
51
+ /** Delta only: the hash of the full catalog this delta is relative to. */
52
+ base?: string;
53
+ }
54
+ /** What the history says: its last announcement, and every full catalog hash in it. */
55
+ export interface ExecCatalogState {
56
+ last: ExecCatalogMarker | null;
57
+ fullHashes: Set<string>;
58
+ }
59
+ /**
60
+ * A full catalog body: the header, the rendered entries, and — only when the budget
61
+ * truncated them — how to reach the rest.
62
+ *
63
+ * The search section is the single place the call form is taught (the entry point
64
+ * stays registered either way, it is merely not advertised). Making it conditional
65
+ * is what keeps the two statements from drifting: while the catalog is complete,
66
+ * nothing anywhere claims a search is needed.
67
+ */
68
+ export declare function renderFullCatalogNote(catalog: RenderedCatalog): string;
69
+ /**
70
+ * Read the announcement state out of the history.
71
+ *
72
+ * Only this module's own messages are read: a marker quoted anywhere else — a reply
73
+ * explaining the mechanism, a hook echoing one — is prose *about* a marker, not a
74
+ * statement about the pool. Counting it would invent an announcement, and the
75
+ * invented state then misleads every later turn. A marker that cannot be parsed is
76
+ * skipped for the same reason (worst case: one duplicate announcement).
77
+ */
78
+ export declare function collectExecCatalogState(messages: Message[]): ExecCatalogState;
79
+ /**
80
+ * The next announcement for this turn, or null when there is nothing to say.
81
+ *
82
+ * The decision table, in order: a channel that was never open says nothing; a closed
83
+ * channel is announced once; a session that has announced nothing (or whose last word
84
+ * was the closing notice) gets the full catalog; an unchanged hash says nothing; a
85
+ * changed pool with its basis still present may go out as a namespace-level delta;
86
+ * anything else is the full catalog.
87
+ */
88
+ export declare function buildExecCatalogAnnouncement(state: ExecCatalogState, current: RenderedCatalog | undefined): string | null;
89
+ export {};