@davait/aipictury 1.9.2

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 (54) hide show
  1. package/LICENSE.md +77 -0
  2. package/README.md +39 -0
  3. package/ext/about.js +209 -0
  4. package/ext/account.js +242 -0
  5. package/ext/accountUi.js +287 -0
  6. package/ext/agent.js +1929 -0
  7. package/ext/browser.js +263 -0
  8. package/ext/cloud.js +385 -0
  9. package/ext/context.js +241 -0
  10. package/ext/deps.js +142 -0
  11. package/ext/dialogs.js +803 -0
  12. package/ext/diff.js +150 -0
  13. package/ext/extension.js +2483 -0
  14. package/ext/feedback.js +55 -0
  15. package/ext/files.js +565 -0
  16. package/ext/firestore.js +131 -0
  17. package/ext/gpuRun.js +77 -0
  18. package/ext/gpuServer.js +76 -0
  19. package/ext/hooks.js +153 -0
  20. package/ext/log.js +29 -0
  21. package/ext/lsp.js +122 -0
  22. package/ext/mcp.js +469 -0
  23. package/ext/mcpAuth.js +128 -0
  24. package/ext/media/vendor/firebase-config.json +9 -0
  25. package/ext/media/vendor/signin.js +1 -0
  26. package/ext/mediagen.js +174 -0
  27. package/ext/memory.js +117 -0
  28. package/ext/notebook.js +82 -0
  29. package/ext/package.json +1234 -0
  30. package/ext/paths.js +50 -0
  31. package/ext/permissions.js +229 -0
  32. package/ext/plans.js +110 -0
  33. package/ext/plugins.js +243 -0
  34. package/ext/prefs.js +157 -0
  35. package/ext/remote.js +170 -0
  36. package/ext/review.js +126 -0
  37. package/ext/sandbox.js +79 -0
  38. package/ext/scheduler.js +87 -0
  39. package/ext/sessions.js +228 -0
  40. package/ext/sessionsView.js +449 -0
  41. package/ext/shell.js +357 -0
  42. package/ext/storage.js +80 -0
  43. package/ext/tasklist.js +112 -0
  44. package/ext/text.js +31 -0
  45. package/ext/tools.js +781 -0
  46. package/ext/usage.js +77 -0
  47. package/ext/voice.js +135 -0
  48. package/ext/workbench.js +162 -0
  49. package/ext/workflow.js +51 -0
  50. package/ext/worktree.js +83 -0
  51. package/main.js +758 -0
  52. package/package.json +43 -0
  53. package/terminal.js +124 -0
  54. package/vscode-shim.js +231 -0
package/ext/tools.js ADDED
@@ -0,0 +1,781 @@
1
+ 'use strict'
2
+ // What the agent is allowed to do to the workspace, and the guards around it.
3
+ //
4
+ // Everything here runs in the editor, never on the server: file access belongs where
5
+ // the user's trust already is. Each tool states its shape for the model and returns a
6
+ // string, because that is what goes back into the conversation.
7
+
8
+ const vscode = require('vscode')
9
+ const gpuServer = require('./gpuServer')
10
+ const { resolve, root, rel } = require('./paths')
11
+ const files = require('./files')
12
+ const { codeIntel, OPS } = require('./lsp')
13
+ const shell = require('./shell')
14
+ const browser = require('./browser')
15
+ const context = require('./context')
16
+
17
+ const MAX_MATCHES = 80
18
+ const decoder = new TextDecoder()
19
+ const encoder = new TextEncoder()
20
+
21
+ async function listOpen() {
22
+ const open = vscode.window.visibleTextEditors.map((e) => vscode.workspace.asRelativePath(e.document.uri))
23
+ const active = vscode.window.activeTextEditor
24
+ const selection = active && !active.selection.isEmpty
25
+ ? active.document.getText(active.selection).slice(0, 8000) : ''
26
+ return JSON.stringify({
27
+ open,
28
+ active: active ? vscode.workspace.asRelativePath(active.document.uri) : null,
29
+ selection,
30
+ selectionLines: active && !active.selection.isEmpty ? [active.selection.start.line + 1, active.selection.end.line + 1] : null,
31
+ }, null, 1)
32
+ }
33
+
34
+ /** Diagnostics are what the editor already knows is wrong — the agent should use it. */
35
+ async function problems(args) {
36
+ const all = vscode.languages.getDiagnostics()
37
+ const wanted = args.path ? resolve(args.path).fsPath : null
38
+ const lines = []
39
+ for (const [uri, list] of all) {
40
+ if (wanted && uri.fsPath !== wanted) continue
41
+ for (const d of list) {
42
+ if (lines.length >= MAX_MATCHES) break
43
+ const severity = ['error', 'warning', 'info', 'hint'][d.severity] || 'info'
44
+ lines.push(`${vscode.workspace.asRelativePath(uri)}:${d.range.start.line + 1}: ${severity}: ${d.message}`)
45
+ }
46
+ }
47
+ return lines.length ? lines.join('\n') : 'no problems reported'
48
+ }
49
+
50
+ // ---- the web ----
51
+ // Research online, the way Claude Code does: search for pages, then read one. Both are
52
+ // read-only (they touch nothing in the workspace), so they need no approval and are
53
+ // available even in Plan mode. Network calls are bounded, and a failure is returned as
54
+ // text for the model to reason about rather than thrown.
55
+
56
+ const https = require('https')
57
+
58
+ /** A bounded GET that follows a couple of redirects and returns the body as text. */
59
+ function getUrl(url, { timeout = 15000, maxBytes = 2_000_000, redirects = 3 } = {}) {
60
+ return new Promise((resolve, reject) => {
61
+ const request = https.get(url, {
62
+ headers: {
63
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
64
+ + 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36',
65
+ 'Accept': 'text/html,application/xhtml+xml',
66
+ },
67
+ }, (res) => {
68
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && redirects > 0) {
69
+ res.resume()
70
+ const next = new URL(res.headers.location, url).href
71
+ resolve(getUrl(next, { timeout, maxBytes, redirects: redirects - 1 }))
72
+ return
73
+ }
74
+ let data = ''
75
+ res.on('data', (chunk) => {
76
+ data += chunk
77
+ if (data.length > maxBytes) { data = data.slice(0, maxBytes); request.destroy() }
78
+ })
79
+ res.on('end', () => resolve({ status: res.statusCode, body: data,
80
+ type: res.headers['content-type'] || '' }))
81
+ })
82
+ request.on('error', reject)
83
+ request.setTimeout(timeout, () => request.destroy(new Error(`timed out after ${timeout} ms`)))
84
+ })
85
+ }
86
+
87
+ const unentity = (t) => t
88
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
89
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
90
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
91
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, ' ')
92
+
93
+ // Search results are cached briefly, so the same query in the same minute — a retry, a
94
+ // test, the model looking twice — does not hit the engine again and risk a rate-limit.
95
+ const searchCache = new Map()
96
+ const CACHE_MS = 5 * 60 * 1000
97
+
98
+ const CHALLENGE = /captcha|unfortunately, bots|confirm this search was made by a human/i
99
+
100
+ /** DuckDuckGo's HTML endpoint. Fast and detailed, but it challenges heavy use. */
101
+ function parseDuckDuckGo(bodyText) {
102
+ if (CHALLENGE.test(bodyText)) return { blocked: true, results: [] }
103
+ const links = [...bodyText.matchAll(/result__a[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/gs)]
104
+ const snippets = [...bodyText.matchAll(/result__snippet[^>]*>(.*?)<\/a>/gs)].map((m) => m[1])
105
+ const results = links.map((m, i) => {
106
+ let href = m[1]
107
+ const wrapped = href.match(/[?&]uddg=([^&]+)/)
108
+ if (wrapped) href = decodeURIComponent(wrapped[1])
109
+ else if (href.startsWith('//')) href = `https:${href}`
110
+ return { url: href, title: unentity(m[2].replace(/<[^>]+>/g, '')).trim(),
111
+ snippet: unentity((snippets[i] || '').replace(/<[^>]+>/g, '')).trim() }
112
+ })
113
+ return { blocked: false, results }
114
+ }
115
+
116
+ /** Bing's HTML, as a fallback. Its result links are base64 redirects. */
117
+ function parseBing(bodyText) {
118
+ if (CHALLENGE.test(bodyText)) return { blocked: true, results: [] }
119
+ const results = []
120
+ for (const block of bodyText.split(/<li class="b_algo"/).slice(1)) {
121
+ const link = block.match(/<h2[^>]*>\s*<a[^>]*href="([^"]+)"/)
122
+ if (!link) continue
123
+ let url = unentity(link[1])
124
+ const u = url.match(/[?&]u=a1([A-Za-z0-9_-]+)/) // the real url, base64url after "a1"
125
+ if (u) {
126
+ try { url = Buffer.from(u[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8') } catch { /* keep redirect */ }
127
+ }
128
+ const title = block.match(/<h2[^>]*>\s*<a[^>]*>(.*?)<\/a>/s)
129
+ const snip = block.match(/<p[^>]*>(.*?)<\/p>/s)
130
+ results.push({ url,
131
+ title: title ? unentity(title[1].replace(/<[^>]+>/g, '')).trim() : url,
132
+ snippet: snip ? unentity(snip[1].replace(/<[^>]+>/g, '')).trim() : '' })
133
+ }
134
+ return { blocked: false, results }
135
+ }
136
+
137
+ const ENGINES = [
138
+ { name: 'DuckDuckGo', url: (q) => `https://html.duckduckgo.com/html/?q=${encodeURIComponent(q)}`, parse: parseDuckDuckGo },
139
+ { name: 'Bing', url: (q) => `https://www.bing.com/search?q=${encodeURIComponent(q)}`, parse: parseBing },
140
+ ]
141
+
142
+ // ---- web search and page reading through the server (Tavily) ----
143
+ // The server holds the Tavily key; the extension never does. Its answers are shaped to
144
+ // keep the model's context small: ranked results with only their relevant passage, and a
145
+ // page read against a question returning only the passages that answer it.
146
+ const serverUrl = () => gpuServer.url()
147
+ const list = (v) => (Array.isArray(v) ? v : String(v || '').split(',')).map((d) => String(d).trim()).filter(Boolean).slice(0, 10)
148
+
149
+ async function viaServer(path, body) {
150
+ const server = serverUrl()
151
+ if (!server) throw new Error('no AIPictury server configured')
152
+ const response = await fetch(`${server}/api${path}`, {
153
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
154
+ signal: AbortSignal.timeout(60_000) })
155
+ if (!response.ok) {
156
+ let detail = response.statusText
157
+ try { detail = (await response.json()).detail || detail } catch { /* not JSON */ }
158
+ throw new Error(`${response.status} ${detail}`)
159
+ }
160
+ return response.json()
161
+ }
162
+
163
+ /** What the model reads from a search: compact, and pointing at the next step. */
164
+ function resultsForModel(query, answer, results, weak = false) {
165
+ // Each result as the link to cite it with: a numbered list invites "[1]" footnotes.
166
+ const lines = results.map((r) =>
167
+ `- [${linkText(r.title)}](${r.url})${r.published ? ` (${String(r.published).slice(0, 10)})` : ''}`
168
+ + `${r.snippet ? `\n ${r.snippet.slice(0, 400)}` : ''}`)
169
+ return `Results for ${JSON.stringify(query)}:\n\n${weak ? '(only weak matches — consider other words or a wider time range)\n\n' : ''}`
170
+ + `${answer ? `In short: ${answer}\n\n` : ''}${lines.join('\n\n')}\n\n`
171
+ + 'To read a result, call fetch_url with its url and a query saying what you need from it. '
172
+ + 'Cite what you use with these links, inline.'
173
+ }
174
+
175
+ /** A page title that stays one Markdown link text. */
176
+ function linkText(title) {
177
+ return String(title || 'page').replace(/\[/g, '(').replace(/\]/g, ')').replace(/\s+/g, ' ').trim().slice(0, 160)
178
+ }
179
+
180
+ async function webSearch(args) {
181
+ const query = String(args.query || '').trim()
182
+ if (!query) throw new Error('query is required')
183
+ try {
184
+ const data = await viaServer('/search', {
185
+ query, max_results: Math.min(8, Number(args.max_results) || 5),
186
+ topic: args.topic === 'news' ? 'news' : 'general', depth: args.depth === 'advanced' ? 'advanced' : 'basic',
187
+ time_range: ['day', 'week', 'month', 'year'].includes(args.time_range) ? args.time_range : undefined,
188
+ include_domains: list(args.include_domains), exclude_domains: list(args.exclude_domains) })
189
+ const text = data.results.length || data.answer
190
+ ? resultsForModel(query, data.answer, data.results, data.weak)
191
+ : `no results for ${JSON.stringify(query)} — try other words, or a wider time range`
192
+ return { text, web: { engine: data.cached ? 'Tavily (cached)' : 'Tavily', seconds: data.seconds,
193
+ answer: data.answer, results: data.results.map(({ title, url, score }) => ({ title, url, score })) } }
194
+ } catch (error) {
195
+ // No key on the server, Tavily down or out of credits: search the web from here.
196
+ const text = await searchDirectly(query)
197
+ return { text, web: { engine: 'DuckDuckGo/Bing (Tavily unavailable)', note: error.message, results: [] } }
198
+ }
199
+ }
200
+
201
+ async function searchDirectly(query) {
202
+ const cached = searchCache.get(query)
203
+ if (cached && Date.now() - cached.at < CACHE_MS) return cached.text
204
+
205
+ let blocked = false
206
+ let results = []
207
+ for (const engine of ENGINES) {
208
+ try {
209
+ const page = await getUrl(engine.url(query))
210
+ const parsed = engine.parse(page.body)
211
+ blocked = blocked || parsed.blocked
212
+ if (parsed.results.length) { results = parsed.results; break }
213
+ } catch { /* try the next engine */ }
214
+ }
215
+ if (!results.length) {
216
+ return blocked
217
+ ? `the search engines are rate-limiting automated queries right now for ${JSON.stringify(query)}; `
218
+ + 'try again in a few minutes, or open the page yourself'
219
+ : `no results for ${JSON.stringify(query)}`
220
+ }
221
+
222
+ const out = results.slice(0, 8).map((r) =>
223
+ `- [${linkText(r.title)}](${r.url})\n ${r.snippet.slice(0, 300)}`)
224
+ const text = `Results for ${JSON.stringify(query)}:\n\n${out.join('\n\n')}\n\n`
225
+ + 'Use fetch_url on the most relevant link to read it in full. Cite what you use with these links, inline.'
226
+ searchCache.set(query, { at: Date.now(), text })
227
+ return text
228
+ }
229
+
230
+ /** A web page for the model: with a query, only the passages that answer it; without
231
+ * one, the page as Markdown. Through Tavily on the server, else fetched from here. */
232
+ async function fetchUrl(args) {
233
+ let target = String(args.url || '').trim()
234
+ if (!target) throw new Error('url is required')
235
+ if (!/^https?:\/\//i.test(target)) target = `https://${target}`
236
+ const query = String(args.query || '').trim()
237
+ try {
238
+ const data = await viaServer('/extract', { url: target, query: query || undefined, chunks: 3 })
239
+ const body = data.content || '(the page has no readable text)'
240
+ const text = query
241
+ ? `From ${data.url}, the passages about ${JSON.stringify(query)}:\n\n${body}`
242
+ : `${data.url}\n\n${body}${data.truncated ? '\n\n… page truncated; call again with a query to get the part you need' : ''}`
243
+ return { text, web: { engine: data.cached ? 'Tavily (cached)' : 'Tavily', seconds: data.seconds, url: data.url,
244
+ focused: data.focused, chars: data.chars, sent: body.length, preview: body.slice(0, 1500) } }
245
+ } catch (error) {
246
+ // Tavily could not read it (or is not there): fetch it from here — and when a query was
247
+ // given, keep to it here too, so the model still gets passages, not a whole page.
248
+ const page = await fetchDirectly(target)
249
+ const text = query ? passages(page, query, target) : page
250
+ return { text, web: { engine: 'direct (Tavily unavailable)', note: error.message, url: target,
251
+ focused: !!query && text !== page, chars: page.length, sent: text.length, preview: text.slice(0, 1500) } }
252
+ }
253
+ }
254
+
255
+ // Words that say nothing about what is being looked for (English and Spanish).
256
+ const STOP = new Set(('the and for are was were has have had how what which who why when where does did '
257
+ + 'can could would should will with from into about this that these those its their there than then '
258
+ + 'much many more most some any all not but you your our they them his her el la los las un una unos '
259
+ + 'que qué como cómo cual cuál del por para con sin sobre este esta estos estas ese esa hay tiene '
260
+ + 'tienen es son fue ser muy más menos').split(' '))
261
+
262
+ /** The paragraphs of a page that best match a query — by the query's words they contain —
263
+ * in page order, up to ~1.8k characters: what Tavily's reranking does, done plainly. */
264
+ function passages(page, query, url, limit = 1800) {
265
+ const words = [...new Set(String(query).toLowerCase().match(/[\p{L}\p{N}]{2,}/gu) || [])].filter((w) => !STOP.has(w))
266
+ const parts = String(page).split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 40)
267
+ if (!words.length || !parts.length) return page.slice(0, limit)
268
+ const scored = parts.map((text, i) => {
269
+ const lower = text.toLowerCase()
270
+ const hits = words.reduce((n, w) => n + (lower.includes(w) ? 1 : 0), 0)
271
+ return { i, text: text.slice(0, 700), score: hits / words.length }
272
+ }).filter((p) => p.score > 0).sort((a, b) => b.score - a.score)
273
+ // Only paragraphs about as relevant as the best: a single shared word is not a match.
274
+ const best = scored[0]?.score || 0
275
+ scored.splice(0, scored.length, ...scored.filter((p) => p.score >= best * 0.5))
276
+ let used = 0
277
+ const chosen = []
278
+ for (const p of scored) {
279
+ if (used + p.text.length > limit) break
280
+ chosen.push(p); used += p.text.length
281
+ }
282
+ if (!chosen.length) return `From ${url}: nothing on the page matched ${JSON.stringify(query)}.\n\n${page.slice(0, 600)}`
283
+ return `From ${url}, the passages about ${JSON.stringify(query)}:\n\n`
284
+ + chosen.sort((a, b) => a.i - b.i).map((p) => p.text).join('\n\n')
285
+ }
286
+
287
+ /** A web page reduced to readable text: scripts, styles and tags removed. */
288
+ async function fetchDirectly(target) {
289
+ let page
290
+ try {
291
+ page = await getUrl(target)
292
+ } catch (err) {
293
+ return `could not fetch ${target} (${err.message})`
294
+ }
295
+ if (page.status && page.status >= 400) return `${target} returned HTTP ${page.status}`
296
+ if (!/html|text|xml|json/i.test(page.type)) {
297
+ return `${target} is ${page.type || 'not text'}; nothing to read`
298
+ }
299
+ const text = unentity(page.body
300
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
301
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
302
+ .replace(/<!--[\s\S]*?-->/g, ' ')
303
+ .replace(/<\/(p|div|h[1-6]|li|tr|section|article|br)>/gi, '\n')
304
+ .replace(/<[^>]+>/g, ' '))
305
+ .replace(/[ \t]+/g, ' ')
306
+ .replace(/\n\s*\n\s*\n+/g, '\n\n')
307
+ .trim()
308
+ const title = (page.body.match(/<title[^>]*>(.*?)<\/title>/is) || [])[1]
309
+ const head = title ? `# ${unentity(title.replace(/<[^>]+>/g, '')).trim()}\n\n` : ''
310
+ const capped = text.slice(0, 12_000)
311
+ return head + capped + (text.length > capped.length ? '\n\n… page truncated' : '')
312
+ }
313
+
314
+ /** The shapes the model is told about. Kept beside the implementations on purpose.
315
+ * [name, description, properties, required]; a property is [type, hint] or a full schema. */
316
+ const SCHEMA = [
317
+ ['read_file', 'Read a file. Lines come numbered (number, tab, line) so you can quote locations; do not '
318
+ + 'copy the numbers into edit_file. Long files come a page (2000 lines) at a time — use offset and limit '
319
+ + 'for the part you need. Also reads Jupyter notebooks (with their cells and outputs), PDFs (by pages) and pictures. '
320
+ + 'Read a file before you change it.',
321
+ { path: ['string', 'file path relative to the workspace root'],
322
+ offset: ['integer', 'optional: the line to start from (1-based)'],
323
+ limit: ['integer', 'optional: how many lines to read'],
324
+ pages: ['string', 'optional, PDFs only: which pages — "3", "1-10", or "12-" to the end (40 at a time)'] }, ['path']],
325
+ ['write_file', 'Create a file, or replace one completely. For a change to an existing file prefer '
326
+ + 'edit_file. An existing file must have been read first.',
327
+ { path: ['string', 'file path relative to the workspace root'],
328
+ content: ['string', 'the complete new contents'] }, ['path', 'content']],
329
+ ['edit_file', 'Replace text in a file you have read: old_text must appear exactly once (include enough '
330
+ + 'surrounding lines to make it unique), or set replace_all to change every occurrence. Copy old_text '
331
+ + 'from the file exactly, indentation included, without line numbers. For several changes to one '
332
+ + 'file, pass them together in edits.',
333
+ { path: ['string', 'file path relative to the workspace root'],
334
+ old_text: ['string', 'the exact text to replace'],
335
+ new_text: ['string', 'what to put in its place'],
336
+ replace_all: ['boolean', 'optional: replace every occurrence of old_text (e.g. renaming a variable)'],
337
+ edits: { type: 'array', description: 'optional: several replacements applied in order, all or none',
338
+ items: { type: 'object', properties: { old_text: { type: 'string' }, new_text: { type: 'string' },
339
+ replace_all: { type: 'boolean' } }, required: ['old_text', 'new_text'] } } }, ['path']],
340
+ ['grep', 'Search file contents with a regular expression (ripgrep syntax), respecting .gitignore. '
341
+ + 'Use it instead of grep or rg in run_command. output_mode: "files_with_matches" (default, newest '
342
+ + 'first), "content" (matching lines with numbers; add context for surrounding lines) or "count".',
343
+ { pattern: ['string', 'the regular expression, e.g. "function\\s+handle\\w+" or "TODO"'],
344
+ path: ['string', 'optional: a directory or file to search (default: the whole workspace)'],
345
+ glob: ['string', 'optional: only files matching this glob, e.g. "*.ts" or "src/**/*.{js,jsx}"'],
346
+ type: ['string', 'optional: a file type, e.g. "py", "js", "rust"'],
347
+ output_mode: ['string', '"files_with_matches", "content" or "count"'],
348
+ case_insensitive: ['boolean', 'optional: ignore case'],
349
+ context: ['integer', 'optional, content mode: lines before and after each match'],
350
+ multiline: ['boolean', 'optional: let the pattern span lines (. matches newlines)'],
351
+ head_limit: ['integer', 'optional: at most this many results'],
352
+ offset: ['integer', 'optional: skip this many results (for paging)'] }, ['pattern']],
353
+ ['glob', 'Find files by name pattern, newest first (at most 100), e.g. "**/*.test.js" or '
354
+ + '"src/components/**/*.tsx". Respects .gitignore.',
355
+ { pattern: ['string', 'the glob pattern'],
356
+ path: ['string', 'optional: the directory to search in (default: the workspace root)'],
357
+ include_ignored: ['boolean', 'optional: include files .gitignore excludes'] }, ['pattern']],
358
+ ['list_files', 'List the files and folders in one directory of the workspace.',
359
+ { path: ['string', 'directory, relative to the workspace root; defaults to the root'] }, []],
360
+ ['code_intel', 'Ask the language server: where a symbol is defined, every reference to it, its type '
361
+ + 'and docs (hover), what a file declares, symbols across the workspace, implementations, and who calls '
362
+ + 'or is called by a function. Faster and more precise than searching text for code questions. Give the '
363
+ + 'line and the symbol\'s name on it.',
364
+ { operation: ['string', OPS.join(' | ')],
365
+ path: ['string', 'the file (not needed for workspace_symbols)'],
366
+ line: ['integer', 'the line the symbol is on (1-based)'],
367
+ symbol: ['string', 'the symbol\'s name on that line'],
368
+ query: ['string', 'for workspace_symbols: the name to look for'] }, ['operation']],
369
+ ['editor_state', 'What the user is looking at: open files, the active file, and the current selection.',
370
+ {}, []],
371
+ ['problems', 'Errors and warnings the editor has already reported.',
372
+ { path: ['string', 'optional file to limit the report to'] }, []],
373
+ ['run_command', 'Run a bash command in the workspace and return its output and exit code. The directory '
374
+ + 'a cd leaves you in carries over to the next command. Default timeout 120 s (at most 600); a command '
375
+ + 'still running then is left running in the background. Use run_in_background for servers and '
376
+ + 'watchers, then bash_output to read them. Do not use it to read, search or edit files — read_file, '
377
+ + 'grep, glob and edit_file do that better.',
378
+ { command: ['string', 'the command line to run'],
379
+ timeout: ['integer', 'optional: seconds to wait, up to 600'],
380
+ run_in_background: ['boolean', 'optional: start it and return at once (dev servers, watchers, long builds)'],
381
+ description: ['string', 'optional: what it does, in 5-10 words'],
382
+ outside_sandbox: ['boolean', 'optional: run it outside the sandbox — only when the sandbox stopped it and it '
383
+ + 'really needs to write outside the workspace or reach the network; the user is asked first'] }, ['command']],
384
+ ['powershell', 'Run a PowerShell command in the workspace and return its output (Windows, or wherever PowerShell 7 '
385
+ + 'is installed). The same rules as run_command: asked first, a timeout, run_in_background.',
386
+ { command: ['string', 'the PowerShell command'], timeout: ['integer', 'optional: seconds to wait, up to 600'],
387
+ run_in_background: ['boolean', 'optional: start it and return at once'], description: ['string', 'optional: what it does, in 5-10 words'] }, ['command']],
388
+ ['monitor', 'Start a command that keeps running — a log being written, a dev server, a watcher — and be told '
389
+ + 'of each new output line: the lines reach you as events, between your steps, or as a new turn once you have '
390
+ + 'finished. For waiting on something to happen ("tell me when the build fails"). Stop it with kill_shell.',
391
+ { command: ['string', 'the command, e.g. "tail -f logs/app.log" or "npm run dev"'],
392
+ description: ['string', 'what you are watching for, in a few words'],
393
+ timeout_minutes: ['integer', 'optional: stop it after this many minutes (default 30, at most 120)'] }, ['command']],
394
+ ['notebook_run_cell', 'Run a code cell of a Jupyter notebook with the kernel the user chose for it, and read its '
395
+ + 'outputs (text, results, errors). The user is asked first. Write or change the cell with notebook_edit, then run it.',
396
+ { path: ['string', 'the .ipynb notebook'], cell_id: ['string', 'the cell\'s id, or its position (0 is the first)'],
397
+ timeout: ['integer', 'optional: seconds to wait, 5 to 600 (default 120)'] }, ['path', 'cell_id']],
398
+ ['browser', 'Use a web browser (the user\'s Chrome or Edge, in a profile of its own, in a window they can watch): open pages, '
399
+ + 'read them, click, type, take screenshots, read the console — to test a web app on localhost, check a page, or do '
400
+ + 'something on a site. Every page comes back as a snapshot whose elements have refs ("[ref=e12]"): act on an '
401
+ + 'element by giving its ref. Acting on a site other than localhost is asked once per site. Never type passwords or '
402
+ + 'payment details: ask the user to do that in the window.',
403
+ { action: ['string', 'navigate (url), back, snapshot, click (ref), type (ref, text, submit?), press (key), select (ref, values), '
404
+ + 'hover (ref), wait (text or seconds), screenshot (ref? full_page?), console, evaluate (script), tabs (index? to switch), '
405
+ + 'new_tab (url?), close_tab, close'],
406
+ url: ['string', 'for navigate and new_tab: the whole address, e.g. http://localhost:5173'],
407
+ ref: ['string', 'the element\'s ref from the latest snapshot, e.g. "e12"'],
408
+ element: ['string', 'what the element is, in a few words (e.g. "the Sign up button") — shown to the user'],
409
+ text: ['string', 'for type: the text; for wait: text to wait for'],
410
+ submit: ['boolean', 'for type: press Enter after typing'],
411
+ key: ['string', 'for press: a key or chord, e.g. "Enter", "PageDown", "Control+A"'],
412
+ values: { type: 'array', items: { type: 'string' }, description: 'for select: the option(s) to choose' },
413
+ seconds: ['number', 'for wait: how long (at most 30), or the time limit when waiting for text'],
414
+ full_page: ['boolean', 'for screenshot: the whole page, not just what the window shows'],
415
+ script: ['string', 'for evaluate: a JavaScript expression run in the page, e.g. "document.title"'],
416
+ index: ['number', 'for tabs: the tab to switch to'] }, ['action']],
417
+ ['publish_page', 'Publish a page for the user — a report, a summary, a small HTML app — on their AIPictury server, '
418
+ + 'at a private address (only those given the link can open it). Give Markdown, or a whole HTML page. Say the '
419
+ + 'link in your reply.',
420
+ { title: ['string', 'the page\'s title'], markdown: ['string', 'its content as Markdown (or give html)'],
421
+ html: ['string', 'optional: a whole HTML page instead — it is served sandboxed'] }, ['title']],
422
+ ['send_user_file', 'Give the user a file from the workspace as a card in the conversation, to open, save a copy of, '
423
+ + 'or show in its folder — a report you wrote, an export, a picture.',
424
+ { path: ['string', 'the file, relative to the workspace'], message: ['string', 'optional: one line about it'] }, ['path']],
425
+ ['enter_worktree', 'Work in a git worktree: a checkout of its own on a new branch (inside the workspace, at '
426
+ + '.aipictury/worktrees/<name>), so your changes do not touch the user\'s checkout until they want them. From '
427
+ + 'then on, paths are relative to the worktree and commands run in it. Leave it with exit_worktree.',
428
+ { name: ['string', 'optional: a short name, e.g. "fix-login" (the branch is aipictury/<name>)'] }, []],
429
+ ['exit_worktree', 'Leave the worktree: "keep" leaves its folder and branch for the user (say where), "remove" '
430
+ + 'deletes them — refused if it holds work unless discard_changes is true.',
431
+ { action: ['string', '"keep" or "remove"'], discard_changes: ['boolean', 'optional: remove even with commits or changes in it'] }, ['action']],
432
+ ['cron_create', 'Schedule a prompt in this conversation: at a cron time ("0 9 * * 1-5") or every interval ("30m", '
433
+ + '"2h"), recurring or once. When it is due it arrives as a new message to you. For "every morning…", "check this '
434
+ + 'every hour".',
435
+ { schedule: ['string', 'a 5-field cron expression (local time) or an interval like "15m", "2h", "1d" (at least a minute)'],
436
+ prompt: ['string', 'what to do then, as you would be asked it'],
437
+ recurring: ['boolean', 'optional: false for once (default true)'] }, ['schedule', 'prompt']],
438
+ ['cron_list', 'The prompts scheduled in this conversation, with when each comes next.', {}, []],
439
+ ['cron_delete', 'Remove a scheduled prompt.', { id: ['string', 'its id, e.g. "job_1"'] }, ['id']],
440
+ ['schedule_wakeup', 'Come back to this later: after `delay_minutes` you are given `prompt` as a new message. For '
441
+ + 'waiting on something outside (a build, a deploy): choose the delay from how long it takes.',
442
+ { delay_minutes: ['integer', '1 to 1440'], prompt: ['string', 'what to do then'],
443
+ reason: ['string', 'why that delay, in a few words'] }, ['delay_minutes', 'prompt']],
444
+ ['bash_output', 'Read the new output of a command running in the background, and whether it has finished.',
445
+ { bash_id: ['string', 'the id it was given, e.g. "bash_1"'],
446
+ filter: ['string', 'optional: a regular expression; only matching lines are returned'] }, ['bash_id']],
447
+ ['kill_shell', 'Stop a command running in the background.',
448
+ { shell_id: ['string', 'its id, e.g. "bash_1"'] }, ['shell_id']],
449
+ ['todo_write', 'Keep a to-do list for a task of three or more steps, shown to the user as you work. '
450
+ + 'Send the whole list each time. Exactly one item is in_progress while you work on it; mark it '
451
+ + 'completed as soon as it is done, not in a batch at the end. Skip it for small, one-step tasks.',
452
+ { todos: { type: 'array', description: 'the whole list, in order',
453
+ items: { type: 'object', properties: {
454
+ content: { type: 'string', description: 'what to do, imperative: "Add the login route"' },
455
+ status: { type: 'string', description: '"pending", "in_progress" or "completed"' },
456
+ activeForm: { type: 'string', description: 'while doing it: "Adding the login route"' } },
457
+ required: ['content', 'status'] } } }, ['todos']],
458
+ ['task_create', 'Add a task to your task list (the same list as todo_write, shown to the user). Give the '
459
+ + 'tasks it waits for in blockedBy: it cannot start until they are completed. Use it for work with steps '
460
+ + 'that depend on each other; todo_write stays the quick way to set a simple list at once.',
461
+ { subject: ['string', 'what to do, imperative: "Add the orders table"'],
462
+ description: ['string', 'optional: details, what done means'],
463
+ activeForm: ['string', 'optional: while doing it: "Adding the orders table"'],
464
+ blockedBy: { type: 'array', items: { type: 'string' }, description: 'optional: ids of the tasks it waits for, e.g. ["1"]' } }, ['subject']],
465
+ ['task_update', 'Change a task: its status (pending, in_progress, completed, or deleted), subject, description, '
466
+ + 'what it waits for (addBlockedBy) or what waits for it (addBlocks). A task waiting for unfinished ones cannot '
467
+ + 'be set in_progress. Mark a task completed as soon as it is done: the reply says which tasks it freed.',
468
+ { taskId: ['string', 'the task\'s id, e.g. "2"'], status: ['string', 'optional: pending, in_progress, completed or deleted'],
469
+ subject: ['string', 'optional'], description: ['string', 'optional'], activeForm: ['string', 'optional'],
470
+ addBlockedBy: { type: 'array', items: { type: 'string' }, description: 'optional: ids it now also waits for' },
471
+ addBlocks: { type: 'array', items: { type: 'string' }, description: 'optional: ids that now wait for it' } }, ['taskId']],
472
+ ['task_list', 'Your task list: each task\'s id, status and what it still waits for.', {}, []],
473
+ ['task_get', 'One task in full: its description, what it waits for and what waits for it.',
474
+ { taskId: ['string', 'the task\'s id'] }, ['taskId']],
475
+ ['task', 'Hand a self-contained piece of work to a helper agent with a fresh context: it works on its '
476
+ + 'own and returns only its final report, which keeps your context small. subagent_type "explore" '
477
+ + '(read-only: find where things are, how something works, answer a question about the code — the '
478
+ + 'most common use) or "general" (may also change files and run commands). Say exactly what to find '
479
+ + 'or do and what to report back.',
480
+ { description: ['string', 'a 3-5 word summary, e.g. "Find the auth middleware"'],
481
+ prompt: ['string', 'the full task: what to do, where to look, what to return'],
482
+ subagent_type: ['string', '"explore" or "general"'],
483
+ isolation: ['string', 'optional: "worktree" — the helper works in a git worktree of its own; its changes are left there for you'],
484
+ name: ['string', 'optional: a name, to send_message to it later (it keeps its context)'],
485
+ run_in_background: ['boolean', 'optional: start it and carry on; its report reaches you as an event when it is done'] }, ['description', 'prompt']],
486
+ ['workflow', 'Run a piece of work as a workflow: steps carried out by helpers, in the order their dependencies give '
487
+ + '(one at a time), each told what the steps it depends on found; you get one report of all of them. For substantial '
488
+ + 'work in parts — research, then changes, then checks.',
489
+ { goal: ['string', 'what the whole workflow is for'],
490
+ steps: { type: 'array', description: 'the steps, 1 to 12', items: { type: 'object', properties: {
491
+ name: { type: 'string', description: 'a short name, e.g. "find callers"' },
492
+ prompt: { type: 'string', description: 'what this step does and what it reports' },
493
+ after: { type: 'array', items: { type: 'string' }, description: 'optional: names of the steps it needs first' },
494
+ subagent_type: { type: 'string', description: 'optional: "explore" (read-only) or "general" (default)' } }, required: ['name', 'prompt'] } } }, ['steps']],
495
+ ['send_message', 'Send a message to a helper you started (by its name or id): while it works it reads it before its '
496
+ + 'next step; once it has reported, it carries on from where it was, with all it knows, and reports again.',
497
+ { to: ['string', 'its name or id, e.g. "researcher" or "agent_2"'], message: ['string', 'what to tell or ask it'] }, ['to', 'message']],
498
+ ['list_agents', 'The helpers of this conversation: their status, steps, tokens and time.', {}, []],
499
+ ['task_stop', 'Stop a helper running in the background, or a background command.', { id: ['string', 'the helper\'s name or id, or a command\'s id (bash_1)'] }, ['id']],
500
+ ['notebook_edit', 'Change a Jupyter notebook cell: replace its source, insert a new cell after it, or '
501
+ + 'delete it. Read the notebook first to see the cell ids.',
502
+ { path: ['string', 'the .ipynb file'],
503
+ cell_id: ['string', 'the cell to change, or to insert after (omit to insert at the start)'],
504
+ new_source: ['string', 'the cell\'s new source'],
505
+ cell_type: ['string', '"code" or "markdown"'],
506
+ edit_mode: ['string', '"replace" (default), "insert" or "delete"'] }, ['path']],
507
+ ['remember', 'Save a lasting rule or preference the user has stated, so it applies in every '
508
+ + 'later turn and conversation. Use it when the user says remember, rule, always, never, from '
509
+ + 'now on (recordá, regla, siempre, nunca, de ahora en más), or states a convention to keep.',
510
+ { rule: ['string', 'the rule in one clear, self-contained sentence'],
511
+ scope: ['string', '"project" for this codebase, "user" for a personal preference that applies everywhere'] },
512
+ ['rule']],
513
+ ['generate_media', 'Make a picture or a short video on the AIPictury GPU and save it into '
514
+ + 'the project. Use it whenever the user wants an image, photo, illustration, icon, background '
515
+ + 'or clip — to add one, or to replace one the code already uses. To replace, set save_to to '
516
+ + 'the existing file: the new one gets its size and format. To change an existing picture '
517
+ + '(recolour, restyle, remove a background), pass it as source and describe the change. '
518
+ + 'kind=video with a source animates that picture.',
519
+ { kind: ['string', '"image" or "video"'],
520
+ prompt: ['string', 'a full visual description: subject, setting, light, style, composition, '
521
+ + 'colours — or, with a source, the change to make'],
522
+ save_to: ['string', 'the workspace path to write, e.g. public/images/hero.jpg; its extension '
523
+ + 'sets the format. Omit to only show it to the user'],
524
+ source: ['string', 'optional: a picture in the workspace to edit, or to animate into a video'],
525
+ aspect: ['string', '"landscape", "portrait" or "square", when there is no file to match'],
526
+ width: ['integer', 'optional exact width in pixels (default: the replaced file\'s)'],
527
+ height: ['integer', 'optional exact height in pixels'],
528
+ seconds: ['number', 'video length, 2 to 10'] },
529
+ ['kind', 'prompt']],
530
+ ['web_search', 'Search the web: ranked results, each with its most relevant passage, and a '
531
+ + 'short answer drawn from them. Use it when the task needs current or external facts. One '
532
+ + `topic per query (split a complex question into several searches); today is ${new Date().toISOString().slice(0, 10)}, `
533
+ + 'so put the year in queries about recent things. Then read only the one or two best results '
534
+ + 'with fetch_url, giving it a query.',
535
+ { query: ['string', 'what to search for, specific and short (under 400 characters)'],
536
+ time_range: ['string', 'optional: "day", "week", "month" or "year" for recent results'],
537
+ topic: ['string', 'optional: "news" for current events'],
538
+ depth: ['string', 'optional: "advanced" for niche or hard questions (slower); default basic'],
539
+ include_domains: ['string', 'optional: comma-separated domains to search only, e.g. "nodejs.org,github.com"'],
540
+ exclude_domains: ['string', 'optional: comma-separated domains to leave out'] }, ['query']],
541
+ ['fetch_url', 'Read a web page. With a query, returns only the passages of the page that answer '
542
+ + 'it — use this almost always, it keeps your context small. Without a query, returns the whole '
543
+ + 'page as Markdown (long; only when you truly need all of it).',
544
+ { url: ['string', 'the page to read'],
545
+ query: ['string', 'what you need from the page, e.g. "the latest LTS version and its end-of-life date"'] }, ['url']],
546
+ ['push_notification', 'Tell the user something needs them when they may not be watching: a long task '
547
+ + 'finished, or you are blocked on something only they can do. Shown as a system notification when '
548
+ + 'VS Code is not in front, and not sent when they are already looking. Use it rarely — once at the '
549
+ + 'end of long work, never for progress.',
550
+ { message: ['string', 'one short line: what is done or what you need'],
551
+ title: ['string', 'optional: a few words, e.g. "Tests fixed"'] }, ['message']],
552
+ ]
553
+
554
+ // Only in Plan mode: how a plan is handed to the user for approval.
555
+ const EXIT_PLAN = ['exit_plan_mode', 'When your plan is ready, present it with this for the user to approve. '
556
+ + 'Write it in Markdown: the goal, the files you will change and how, and anything uncertain. Only after '
557
+ + 'they approve can you make changes; if they ask for changes, revise the plan and call this again.',
558
+ { plan: ['string', 'the plan, in Markdown'] }, ['plan']]
559
+
560
+ // Outside Plan mode: the agent may ask to switch to it, for work worth agreeing on first.
561
+ const ENTER_PLAN = ['enter_plan_mode', 'Ask the user to switch to Plan mode before starting work that '
562
+ + 'is large, risky or ambiguous — many files, a design choice, anything costly to undo. In Plan mode '
563
+ + 'you investigate read-only and present a plan for approval with exit_plan_mode. Not for small or '
564
+ + 'clear tasks: do those directly.',
565
+ { reason: ['string', 'one line: why this is worth planning first'] }, ['reason']]
566
+
567
+ function skillSchema() {
568
+ // Each skill as /skills shows it: with its description, by its name only, or not at all.
569
+ const list = context.visibleSkills()
570
+ if (!list.length) return null
571
+ return ['skill', 'Use a skill: a set of instructions (and files) the project provides for a kind of task. '
572
+ + 'When a task matches one, call this first and follow what it says. Available:\n'
573
+ + list.map((s) => (s.visibility === 'name' ? `- ${s.name}` : `- ${s.name}: ${s.description}`)).join('\n'),
574
+ { name: ['string', 'the skill\'s name'], arguments: ['string', 'optional: what to use it on'] }, ['name']]
575
+ }
576
+
577
+ // Asking the user to decide, as Claude Code's AskUserQuestion does: the model states the
578
+ // choice and its options, the panel shows them in place of the input, and the answers
579
+ // come back as this tool's result. Its parameters nest, so it is written out in full.
580
+ const ASK_USER = {
581
+ type: 'function',
582
+ function: {
583
+ name: 'ask_user',
584
+ description: 'Ask the user to decide something that is theirs to decide, instead of choosing '
585
+ + 'yourself: which library or stack, the scope, naming or structure they will live with, a '
586
+ + 'trade-off between real options, anything costly to undo. Give 2 to 4 concrete options, your '
587
+ + 'recommended one first. The user can always write their own answer instead. Up to 4 related '
588
+ + 'questions in one call. Do not ask what you can find out by reading the code.',
589
+ parameters: {
590
+ type: 'object',
591
+ properties: {
592
+ questions: {
593
+ type: 'array',
594
+ description: '1 to 4 decisions, asked together',
595
+ items: {
596
+ type: 'object',
597
+ properties: {
598
+ header: { type: 'string', description: 'a very short tab label, at most 12 characters, e.g. "Database"' },
599
+ question: { type: 'string', description: 'the full question, ending with a question mark' },
600
+ options: {
601
+ type: 'array',
602
+ description: '2 to 4 choices, your recommended one first',
603
+ items: {
604
+ type: 'object',
605
+ properties: {
606
+ label: { type: 'string', description: '1 to 5 words' },
607
+ description: { type: 'string', description: 'what choosing it means: its trade-off, in one line' },
608
+ },
609
+ required: ['label'],
610
+ },
611
+ },
612
+ multiSelect: { type: 'boolean', description: 'true when several options can be chosen together' },
613
+ },
614
+ required: ['header', 'question', 'options'],
615
+ },
616
+ },
617
+ important: { type: 'boolean', description: 'true when the decision is costly to undo. In Auto mode only '
618
+ + 'important questions stop and wait; others take your recommended option and the user can change it at the end.' },
619
+ },
620
+ required: ['questions'],
621
+ },
622
+ },
623
+ }
624
+
625
+ // Findings of a review, as Claude Code's ReportFindings: a list the panel shows as a card, each
626
+ // finding opening its file at its line.
627
+ const REPORT_FINDINGS = {
628
+ type: 'function',
629
+ function: {
630
+ name: 'report_findings',
631
+ description: 'Report the findings of a review or an investigation (bugs, risks, problems found in '
632
+ + 'code) as a list the user can click through: each opens its file at its line. Most severe first. '
633
+ + 'Use it when you were asked to review, audit or look for problems; then summarize in a sentence.',
634
+ parameters: {
635
+ type: 'object',
636
+ properties: {
637
+ findings: {
638
+ type: 'array',
639
+ description: 'what you found, most severe first (an empty list when nothing was found)',
640
+ items: {
641
+ type: 'object',
642
+ properties: {
643
+ file: { type: 'string', description: 'the file, relative to the workspace' },
644
+ line: { type: 'integer', description: 'the line it is at (1-based) — always give it: the user opens the file there' },
645
+ summary: { type: 'string', description: 'the problem, in one sentence' },
646
+ severity: { type: 'string', enum: ['high', 'medium', 'low'], description: 'how much it matters' },
647
+ category: { type: 'string', description: 'optional: a short kind, e.g. "correctness", "security"' },
648
+ detail: { type: 'string', description: 'optional: how it fails — the inputs and what goes wrong' },
649
+ },
650
+ required: ['file', 'line', 'summary'],
651
+ },
652
+ },
653
+ },
654
+ required: ['findings'],
655
+ },
656
+ },
657
+ }
658
+
659
+ const SEVERITIES = ['high', 'medium', 'low']
660
+
661
+ /** What the model reported, made safe to show: at most 50 findings, each with a file and a
662
+ * summary, a line when it is a number, a known severity. */
663
+ function normalizeFindings(args) {
664
+ let list = args?.findings
665
+ if (typeof list === 'string') { try { list = JSON.parse(list) } catch { list = [] } }
666
+ if (list && !Array.isArray(list)) list = [list]
667
+ return (list || []).map((f) => ({
668
+ file: String(f?.file || f?.path || '').trim().replace(/^\.\//, ''),
669
+ line: Number.isInteger(Number(f?.line)) && Number(f.line) > 0 ? Number(f.line) : undefined,
670
+ summary: String(f?.summary || '').trim().slice(0, 400),
671
+ severity: SEVERITIES.includes(String(f?.severity).toLowerCase()) ? String(f.severity).toLowerCase() : 'medium',
672
+ category: String(f?.category || '').trim().slice(0, 40) || undefined,
673
+ detail: String(f?.detail || '').trim().slice(0, 1200) || undefined,
674
+ })).filter((f) => f.file && f.summary)
675
+ .sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity))
676
+ .slice(0, 50)
677
+ }
678
+
679
+ const toProps = (props) => Object.fromEntries(Object.entries(props)
680
+ .map(([key, v]) => [key, Array.isArray(v) ? { type: v[0], description: v[1] } : v]))
681
+
682
+ const shape = ([name, description, props, required]) => ({
683
+ type: 'function', function: { name, description, parameters: { type: 'object', properties: toProps(props), required } } })
684
+
685
+ /** The tools offered to the model. In Plan mode only those that look (and a command only
686
+ * when it is read-only, checked when it runs), plus exit_plan_mode; outside it,
687
+ * enter_plan_mode. A helper agent gets no ask_user, no task of its own, and nothing that
688
+ * speaks to the user (a notification, findings): its report goes to the agent that asked.
689
+ * With autoMemory off there is no remember. */
690
+ function toolSchema({ readOnly = false, helper = false, extra = [], autoMemory = true } = {}) {
691
+ const skill = skillSchema()
692
+ const roles = context.agents()
693
+ const schemaList = roles.length ? SCHEMA.map((entry) => (entry[0] !== 'task' ? entry : [entry[0],
694
+ `${entry[1]} Or one of these helpers with a role (give its name as subagent_type):\n${roles.map((a) => `- ${a.name}: ${a.description}`).join('\n')}`.slice(0, 6000),
695
+ entry[2], entry[3]])) : SCHEMA
696
+ const list = [...schemaList, ...(skill ? [skill] : []), ...(helper ? [] : readOnly ? [EXIT_PLAN] : [ENTER_PLAN])]
697
+ .filter(([name]) => !readOnly || READ_ONLY.has(name) || name === 'run_command' || name === 'exit_plan_mode')
698
+ .filter(([name]) => !helper || !['task', 'todo_write', 'exit_plan_mode', 'remember', 'push_notification', 'task_create', 'task_update', 'task_list', 'task_get', 'cron_create', 'cron_list', 'cron_delete', 'schedule_wakeup', 'enter_worktree', 'exit_worktree', 'send_message', 'list_agents', 'task_stop', 'publish_page', 'send_user_file', 'workflow'].includes(name))
699
+ .filter(([name]) => autoMemory || name !== 'remember')
700
+ .filter(([name]) => name !== 'powershell' || shell.powershellPath()) // only where there is PowerShell
701
+ .filter(([name]) => name !== 'browser' || (vscode.workspace.getConfiguration('aipictury').get('browser.enabled', true) && browser.available()))
702
+ .filter(([name]) => !vscode.cli || !EDITOR_ONLY.has(name)) // the terminal (cli/) has no editor
703
+ // Asking and reporting change nothing: every mode has them.
704
+ return [...list.map(shape), ...extra, ...(helper ? [] : [ASK_USER, REPORT_FINDINGS])]
705
+ }
706
+
707
+ // Words a question starts with that say nothing about its subject.
708
+ const FILLER = new Set(('which what how should do does would could can you we i the a an to for of '
709
+ + 'want like use prefer is are will be it this that your our my in on with there any '
710
+ + 'qué que cuál cual cuáles cómo como querés quieres preferís prefieres usamos usar el la los las un una de del para con en se te tu vos lo le hay').split(' '))
711
+
712
+ /** A tab label when the model gave none: the question's first word that is not filler —
713
+ * "Which database should store the orders?" → "Database". */
714
+ function keyWord(question) {
715
+ const word = String(question).toLowerCase().match(/[\p{L}\p{N}-]+/gu)?.find((w) => !FILLER.has(w))
716
+ return word ? word[0].toUpperCase() + word.slice(1) : 'Question'
717
+ }
718
+
719
+ /** What the model asked, made safe to show: 1 to 4 questions of 2 to 4 options, each
720
+ * with a short header. A model sends options as strings, headers too long, a question
721
+ * as a lone object — all of it is accepted and shaped. Throws when nothing usable is left. */
722
+ function normalizeQuestions(args) {
723
+ let list = args?.questions
724
+ if (typeof list === 'string') { try { list = JSON.parse(list) } catch { list = [] } }
725
+ if (list && !Array.isArray(list)) list = [list]
726
+ const questions = (list || []).slice(0, 4).map((q) => {
727
+ const question = String(q?.question || '').trim()
728
+ const options = (Array.isArray(q?.options) ? q.options : []).map((o) => (
729
+ typeof o === 'string' ? { label: o.trim(), description: '' }
730
+ : { label: String(o?.label || '').trim(), description: String(o?.description || '').trim() }))
731
+ .filter((o) => o.label)
732
+ .filter((o, i, all) => all.findIndex((x) => x.label.toLowerCase() === o.label.toLowerCase()) === i)
733
+ .filter((o) => !/^other\b/i.test(o.label)) // "Other" is always offered by the panel
734
+ .slice(0, 4)
735
+ const header = (String(q?.header || '').trim() || keyWord(question)).slice(0, 12)
736
+ return { header: header || 'Question', question, options, multiSelect: !!q?.multiSelect }
737
+ }).filter((q) => q.question && q.options.length >= 2)
738
+ if (!questions.length) {
739
+ throw new Error('ask_user needs 1 to 4 questions, each with a question and 2 to 4 options')
740
+ }
741
+ return questions
742
+ }
743
+
744
+ // Which tools change something. These are the ones that need a yes.
745
+ const WRITES = new Set(['write_file', 'edit_file', 'notebook_edit'])
746
+ const RUNS = new Set(['run_command', 'monitor', 'powershell'])
747
+ // The tools that only look. Plan mode is given these (and run_command, which then runs only
748
+ // read-only commands), so it can investigate thoroughly but cannot touch the workspace.
749
+ // What only an editor can do: not offered in the terminal (cli/).
750
+ const EDITOR_ONLY = new Set(['code_intel', 'editor_state', 'problems', 'notebook_run_cell'])
751
+ const READ_ONLY = new Set(['list_files', 'read_file', 'grep', 'glob', 'search', 'editor_state', 'problems',
752
+ 'code_intel', 'web_search', 'fetch_url', 'remember', 'bash_output', 'task', 'skill', 'todo_write',
753
+ 'push_notification', 'report_findings', 'tool_search', 'list_mcp_resources', 'read_mcp_resource',
754
+ 'task_create', 'task_update', 'task_list', 'task_get', 'cron_list', 'list_agents', 'send_user_file', 'workflow', 'browser'])
755
+
756
+ // search: the old name, kept for conversations saved before grep existed.
757
+ const search = (args, ctx) => files.grep({ ...args, pattern: args.query ?? args.pattern, fixed_strings: true,
758
+ output_mode: 'content' }, ctx)
759
+
760
+ const IMPLEMENTATIONS = {
761
+ list_files: files.listFiles,
762
+ read_file: files.readFile,
763
+ write_file: files.writeFile,
764
+ edit_file: files.editFile,
765
+ notebook_edit: files.notebookEdit,
766
+ grep: files.grep,
767
+ glob: files.glob,
768
+ search,
769
+ code_intel: codeIntel,
770
+ editor_state: listOpen,
771
+ problems,
772
+ bash_output: shell.bashOutput,
773
+ kill_shell: shell.killShell,
774
+ skill: context.useSkill,
775
+ web_search: webSearch,
776
+ fetch_url: fetchUrl,
777
+ browser: (args) => browser.run(args, { headless: vscode.workspace.getConfiguration('aipictury').get('browser.headless', false),
778
+ prefer: vscode.workspace.getConfiguration('aipictury').get('browser.channel', 'auto') }),
779
+ }
780
+
781
+ module.exports = { passages, toolSchema, normalizeQuestions, normalizeFindings, IMPLEMENTATIONS, WRITES, RUNS, READ_ONLY, resolve, root, rel, decoder, encoder, SCHEMA }