@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
@@ -0,0 +1,55 @@
1
+ 'use strict'
2
+ // A problem report: what AIPictury needs to understand a problem — versions, settings, the
3
+ // last lines of its log, your description — with keys, tokens and passwords taken out, saved
4
+ // on this machine (~/.aipictury/feedback/). Sent to your AIPictury server only when you say so
5
+ // (/bug, /feedback, or AIPictury: Report a Problem).
6
+ const fs = require('fs')
7
+ const os = require('os')
8
+ const path = require('path')
9
+ const { home } = require('./sessions')
10
+
11
+ // Anything that looks like a secret. Checked by test/feedback-check.mjs.
12
+ const SECRETS = [
13
+ /tvly-(?:dev|prod)-[\w-]{8,}/g, /hf_[A-Za-z0-9]{16,}/g, /sk-[A-Za-z0-9_-]{16,}/g, /gh[pousr]_[A-Za-z0-9]{20,}/g,
14
+ /xox[abprs]-[\w-]{10,}/g, /AKIA[0-9A-Z]{16}/g, /eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}/g,
15
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
16
+ ]
17
+ // The token after "Bearer", then the value of a field named like a secret — never the word
18
+ // "Bearer" itself, which would leave the token behind it.
19
+ const BEARER = /(Bearer\s+)(?!\[redacted\])[^\s"',}]{4,}/gi
20
+ const NAMED = /((?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|passwd|authorization)["']?\s*[:=]\s*["']?)(?!Bearer\b|\[redacted\])([^\s"',}]{4,})/gi
21
+
22
+ function redact(text) {
23
+ let out = String(text ?? '')
24
+ for (const re of SECRETS) out = out.replace(re, '[redacted]')
25
+ out = out.replace(BEARER, (_, key) => `${key}[redacted]`)
26
+ return out.replace(NAMED, (_, key) => `${key}[redacted]`)
27
+ }
28
+
29
+ function redactDeep(value) {
30
+ if (typeof value === 'string') return redact(value)
31
+ if (Array.isArray(value)) return value.map(redactDeep)
32
+ if (value && typeof value === 'object') {
33
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => (
34
+ /key|token|secret|password|authorization|headers|env/i.test(k) && typeof v !== 'boolean' ? [k, '[redacted]'] : [k, redactDeep(v)])))
35
+ }
36
+ return value
37
+ }
38
+
39
+ const dir = () => path.join(home(), 'feedback')
40
+
41
+ /** Writes a report: kind "bug" or "feedback". */
42
+ function save({ kind = 'report', description = '', versions = {}, settings = {}, log = [], extra = {} }) {
43
+ fs.mkdirSync(dir(), { recursive: true })
44
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
45
+ const file = path.join(dir(), `${stamp}-${kind}.json`)
46
+ const body = redactDeep({
47
+ kind, created: new Date().toISOString(), description,
48
+ versions: { ...versions, platform: `${os.platform()} ${os.release()}`, node: process.version },
49
+ settings, log, ...extra,
50
+ })
51
+ fs.writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 })
52
+ return { file, body }
53
+ }
54
+
55
+ module.exports = { redact, redactDeep, save, dir }
package/ext/files.js ADDED
@@ -0,0 +1,565 @@
1
+ 'use strict'
2
+ // The file tools, shaped after Claude Code's Read, Write, Edit, Grep, Glob and NotebookEdit.
3
+ //
4
+ // What they guard against, each learnt the hard way somewhere:
5
+ // - The user's unsaved typing. A file open in the editor with changes not yet saved is
6
+ // saved before the agent reads or writes it, so the agent never works from a stale copy
7
+ // and never writes over what the user just typed.
8
+ // - Editing blind. A file must be read before it is changed, and if it changed on disk
9
+ // since (the user, a formatter, a command), the edit is refused until it is read again.
10
+ // - Whitespace. Local models retype tabs as spaces and drop \r: an edit that matches
11
+ // only once indentation and line endings are set aside is applied in the file's own
12
+ // style rather than refused, and one that does not match at all is answered with the
13
+ // closest passage, so the next try lands.
14
+
15
+ const vscode = require('vscode')
16
+ const fs = require('fs')
17
+ const path = require('path')
18
+ const { execFile } = require('child_process')
19
+ const { resolve, root, rel } = require('./paths')
20
+ const { safeSlice } = require('./text')
21
+
22
+ // ignoreBOM: keep a byte-order mark in the text, so an edit can put it back (the default
23
+ // decoder drops it silently, and every edited file lost its BOM).
24
+ const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
25
+ const encoder = new TextEncoder()
26
+
27
+ const PAGE_LINES = 2000 // lines per read, as Claude Code
28
+ const MAX_LINE = 2000 // characters per line shown
29
+ const MAX_READ_CHARS = 120_000 // one page never more than this
30
+ const GLOB_CAP = 100
31
+ const GREP_OUTPUT_CAP = 30_000
32
+
33
+ // ---- the editor's own copy ----
34
+
35
+ /** The open document for a file, if any. */
36
+ function openDocument(uri) {
37
+ return vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString())
38
+ }
39
+
40
+ /** Saves the user's unsaved changes to a file before the agent touches it. With the
41
+ * autosave setting off, the unsaved text is read instead and writes are refused. */
42
+ const SAVE_LIMIT = 15_000
43
+
44
+ async function settle(uri) {
45
+ const doc = openDocument(uri)
46
+ if (!doc || !doc.isDirty) return { dirty: false }
47
+ const autosave = vscode.workspace.getConfiguration('aipictury').get('autosave', true)
48
+ // Saving runs format-on-save and other participants; one that hangs must not hold the
49
+ // agent: past SAVE_LIMIT the editor's own text is used, as with autosave off.
50
+ if (autosave) {
51
+ let timer
52
+ const late = new Promise((resolve) => { timer = setTimeout(() => resolve(false), SAVE_LIMIT) })
53
+ const saved = await Promise.race([Promise.resolve(doc.save()).catch(() => false), late])
54
+ clearTimeout(timer)
55
+ if (saved) return { dirty: false, saved: true }
56
+ }
57
+ return { dirty: true, text: doc.getText() }
58
+ }
59
+
60
+ async function stat(uri) {
61
+ try { return await vscode.workspace.fs.stat(uri) } catch { return null }
62
+ }
63
+
64
+ // What the agent last saw of each file, per conversation: its modification time and
65
+ // size. An edit is allowed only against what was seen.
66
+ function remember(session, uri, info) {
67
+ if (!session) return
68
+ session.seen ||= new Map()
69
+ session.seen.set(uri.toString(), { mtime: info.mtime, size: info.size })
70
+ }
71
+
72
+ async function checkFresh(session, uri, relPath) {
73
+ if (!session) return
74
+ const seen = session.seen?.get(uri.toString())
75
+ if (!seen) {
76
+ throw new Error(`${relPath} has not been read in this conversation — read it with read_file first, `
77
+ + 'so the change is made against what the file really holds')
78
+ }
79
+ const now = await stat(uri)
80
+ if (now && (now.mtime !== seen.mtime || now.size !== seen.size)) {
81
+ throw new Error(`${relPath} changed since you read it (the user, a formatter or a command edited it) — `
82
+ + 'read it again before changing it')
83
+ }
84
+ }
85
+
86
+ const BOM = String.fromCharCode(0xfeff)
87
+ /** A file's text with what must be kept aside: a byte-order mark and CRLF line endings. */
88
+ function parseText(raw) {
89
+ const bom = raw.startsWith(BOM)
90
+ let text = bom ? raw.slice(1) : raw
91
+ const crlf = /\r\n/.test(text) && !/(^|[^\r])\n/.test(text.replace(/\r\n/g, ''))
92
+ if (/\r\n/.test(text)) text = text.replace(/\r\n/g, '\n')
93
+ return { text, bom, crlf }
94
+ }
95
+ const unparse = ({ bom, crlf }, text) => (bom ? BOM : '') + (crlf ? text.replace(/\n/g, '\r\n') : text)
96
+
97
+ const looksBinary = (bytes) => bytes.subarray(0, 8000).includes(0)
98
+
99
+ // ---- read ----
100
+
101
+ async function readFile(args, ctx = {}) {
102
+ const uri = resolve(args.path)
103
+ const info = await stat(uri)
104
+ if (!info) throw new Error(`${args.path} does not exist — use glob or list_files to find the right path`)
105
+ if (info.type & vscode.FileType.Directory) throw new Error(`${args.path} is a directory — use list_files or glob`)
106
+ const ext = path.extname(uri.fsPath).toLowerCase()
107
+ if (ext === '.ipynb') return readNotebook(uri, args, ctx, info)
108
+ if (ctx.readSpecial && ['.pdf', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'].includes(ext)) {
109
+ remember(ctx.session, uri, info)
110
+ return ctx.readSpecial(uri, args)
111
+ }
112
+ const unsaved = await settle(uri)
113
+ const bytes = unsaved.dirty ? encoder.encode(unsaved.text) : await vscode.workspace.fs.readFile(uri)
114
+ remember(ctx.session, uri, await stat(uri) || info)
115
+ if (looksBinary(bytes)) return `${args.path} is a binary file (${bytes.length} bytes) — it cannot be read as text`
116
+ const { text } = parseText(decoder.decode(bytes))
117
+ if (!text.length) return `${args.path} exists but is empty`
118
+ const lines = text.split('\n')
119
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
120
+ const offset = Math.max(1, Math.floor(Number(args.offset) || 1))
121
+ if (offset > lines.length) return `${args.path} has only ${lines.length} lines — offset ${offset} is past the end`
122
+ const limit = Math.max(1, Math.floor(Number(args.limit) || PAGE_LINES))
123
+ const out = []
124
+ let chars = 0
125
+ let last = offset - 1
126
+ for (let i = offset - 1; i < Math.min(lines.length, offset - 1 + limit); i += 1) {
127
+ let line = lines[i]
128
+ if (line.length > MAX_LINE) line = `${safeSlice(line, 0, MAX_LINE)} … [line cut at ${MAX_LINE} characters]`
129
+ const row = `${i + 1}\t${line}`
130
+ if (chars + row.length > MAX_READ_CHARS && out.length) break
131
+ out.push(row); chars += row.length + 1; last = i + 1
132
+ }
133
+ let note = ''
134
+ if (last < lines.length) {
135
+ note = `\n\n[showing lines ${offset}–${last} of ${lines.length}. Call read_file with offset=${last + 1} for more, `
136
+ + 'or grep for the part you need.]'
137
+ }
138
+ return (unsaved.dirty ? '[the editor holds unsaved changes to this file; this is the unsaved text]\n' : '')
139
+ + out.join('\n') + note
140
+ }
141
+
142
+ async function readNotebook(uri, args, ctx, info) {
143
+ const nb = JSON.parse(decoder.decode(await vscode.workspace.fs.readFile(uri)))
144
+ remember(ctx.session, uri, info)
145
+ const cells = (nb.cells || []).map((c, i) => {
146
+ const src = Array.isArray(c.source) ? c.source.join('') : String(c.source || '')
147
+ const outputs = (c.outputs || []).map((o) => {
148
+ const t = o.text || o.data?.['text/plain'] || (o.ename ? `${o.ename}: ${o.evalue}` : '')
149
+ const s = Array.isArray(t) ? t.join('') : String(t || '')
150
+ return o.data?.['image/png'] ? '[image output]' : safeSlice(s, 0, 2000)
151
+ }).filter(Boolean)
152
+ return `<cell index=${i} id=${c.id || i} type=${c.cell_type}>\n${src}\n</cell>`
153
+ + (outputs.length ? `\n<outputs>\n${outputs.join('\n')}\n</outputs>` : '')
154
+ })
155
+ return `${args.path}: ${cells.length} cells (edit them with notebook_edit)\n\n${cells.join('\n\n')}`
156
+ }
157
+
158
+ // ---- write ----
159
+
160
+ async function writeFile(args, ctx = {}) {
161
+ const uri = resolve(args.path)
162
+ const content = String(args.content ?? '')
163
+ const existing = await stat(uri)
164
+ let style = { bom: false, crlf: false }
165
+ if (existing) {
166
+ const unsaved = await settle(uri)
167
+ if (unsaved.dirty) throw new Error(`${args.path} has unsaved changes in the editor — ask the user to save or discard them`)
168
+ await checkFresh(ctx.session, uri, args.path)
169
+ style = parseText(decoder.decode(await vscode.workspace.fs.readFile(uri)))
170
+ }
171
+ await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(uri, '..'))
172
+ await vscode.workspace.fs.writeFile(uri, encoder.encode(unparse(style, content.replace(/\r\n/g, '\n'))))
173
+ remember(ctx.session, uri, await stat(uri))
174
+ const lines = content ? content.split('\n').length : 0
175
+ return `${existing ? 'rewrote' : 'created'} ${args.path} (${lines} lines)`
176
+ }
177
+
178
+ // ---- edit ----
179
+
180
+ const lead = (line) => line.match(/^[ \t]*/)[0]
181
+
182
+ /** Every place `needle` occurs in `hay`, as offsets. */
183
+ function occurrences(hay, needle) {
184
+ const found = []
185
+ for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + Math.max(1, needle.length))) found.push(i)
186
+ return found
187
+ }
188
+
189
+ const lineOf = (text, offset) => text.slice(0, offset).split('\n').length
190
+
191
+ /** A match that ignores indentation and trailing spaces, line by line. Returns the line
192
+ * ranges in the file whose trimmed lines equal the needle's trimmed lines. */
193
+ function looseMatches(fileLines, needleLines) {
194
+ const want = needleLines.map((l) => l.trim())
195
+ while (want.length && !want[0]) want.shift()
196
+ while (want.length && !want[want.length - 1]) want.pop()
197
+ if (!want.length) return []
198
+ const hits = []
199
+ for (let i = 0; i + want.length <= fileLines.length; i += 1) {
200
+ let ok = true
201
+ for (let k = 0; k < want.length && ok; k += 1) ok = fileLines[i + k].trim() === want[k]
202
+ if (ok) hits.push({ start: i, end: i + want.length })
203
+ }
204
+ return hits
205
+ }
206
+
207
+ /** Re-indents the model's replacement in the file's style: the model's indentation of its
208
+ * first line is mapped onto the file's, and tabs or spaces follow the file. */
209
+ function reindent(newText, modelFirst, fileFirst) {
210
+ const from = lead(modelFirst)
211
+ const to = lead(fileFirst)
212
+ const tabs = to.includes('\t') && !from.includes('\t')
213
+ const unit = tabs && to.length ? Math.max(1, Math.round(from.length / to.length)) || 4 : 0
214
+ return newText.split('\n').map((line) => {
215
+ if (!line.trim()) return line
216
+ let l = lead(line)
217
+ let body = line.slice(l.length)
218
+ if (tabs) {
219
+ const spaces = l.replace(/\t/g, ' '.repeat(unit)).length
220
+ return '\t'.repeat(Math.floor(spaces / unit)) + ' '.repeat(spaces % unit) + body
221
+ }
222
+ if (l.startsWith(from)) return to + l.slice(from.length) + body
223
+ return line
224
+ }).join('\n')
225
+ }
226
+
227
+ /** The passage of the file most like the one asked for, to show the model on a miss. */
228
+ function closest(fileLines, needleLines) {
229
+ const want = needleLines.map((l) => l.trim()).filter(Boolean)
230
+ if (!want.length) return null
231
+ let best = null
232
+ for (let i = 0; i < fileLines.length; i += 1) {
233
+ let score = 0
234
+ for (let k = 0; k < want.length && i + k < fileLines.length; k += 1) {
235
+ const a = fileLines[i + k].trim()
236
+ if (a === want[k]) score += 1
237
+ else if (a && want[k] && (a.includes(want[k]) || want[k].includes(a))) score += 0.5
238
+ }
239
+ if (!best || score > best.score) best = { i, score }
240
+ }
241
+ if (!best || best.score === 0) return null
242
+ const from = Math.max(0, best.i - 1)
243
+ const to = Math.min(fileLines.length, best.i + want.length + 1)
244
+ return { from: from + 1, to, text: fileLines.slice(from, to).map((l, k) => `${from + k + 1}\t${l}`).join('\n') }
245
+ }
246
+
247
+ /** Applies one replacement to `text`. Throws with guidance when it cannot. */
248
+ function replaceOnce(text, oldText, newText, replaceAll, where) {
249
+ const oldN = String(oldText ?? '').replace(/\r\n/g, '\n')
250
+ const newN = String(newText ?? '').replace(/\r\n/g, '\n')
251
+ if (!oldN) throw new Error(`${where}old_text is empty — to create a file use write_file`)
252
+ if (oldN === newN) throw new Error(`${where}old_text and new_text are the same — nothing would change`)
253
+ const exact = occurrences(text, oldN)
254
+ if (exact.length === 1 || (exact.length > 1 && replaceAll)) {
255
+ return { text: replaceAll ? text.split(oldN).join(newN) : text.slice(0, exact[0]) + newN + text.slice(exact[0] + oldN.length),
256
+ count: replaceAll ? exact.length : 1, loose: false }
257
+ }
258
+ if (exact.length > 1) {
259
+ throw new Error(`${where}old_text appears ${exact.length} times (lines ${exact.map((o) => lineOf(text, o)).join(', ')}). `
260
+ + 'Include more surrounding lines to make it unique, or set replace_all to change every one')
261
+ }
262
+ // Not there as typed: try again setting indentation and trailing spaces aside.
263
+ const lines = text.split('\n')
264
+ const needle = oldN.split('\n')
265
+ const loose = looseMatches(lines, needle)
266
+ if (loose.length === 1 || (loose.length > 1 && replaceAll)) {
267
+ const firstModel = needle.find((l) => l.trim()) || ''
268
+ let out = lines
269
+ let shift = 0
270
+ for (const hit of loose) {
271
+ const replacement = reindent(newN, firstModel, out[hit.start + shift]).split('\n')
272
+ out = [...out.slice(0, hit.start + shift), ...replacement, ...out.slice(hit.end + shift)]
273
+ shift += replacement.length - (hit.end - hit.start)
274
+ if (!replaceAll) break
275
+ }
276
+ return { text: out.join('\n'), count: replaceAll ? loose.length : 1, loose: true }
277
+ }
278
+ if (loose.length > 1) {
279
+ throw new Error(`${where}old_text matches ${loose.length} places once indentation is ignored (lines `
280
+ + `${loose.map((h) => h.start + 1).join(', ')}). Include more surrounding lines to make it unique`)
281
+ }
282
+ const near = closest(lines, needle)
283
+ throw new Error(`${where}old_text was not found in the file`
284
+ + (near ? `. The closest passage is lines ${near.from}–${near.to}:\n${near.text}\nCopy old_text exactly from it `
285
+ + '(without the line numbers) and try again' : ' — read the file again and copy the passage exactly'))
286
+ }
287
+
288
+ async function editFile(args, ctx = {}) {
289
+ const uri = resolve(args.path)
290
+ if (!(await stat(uri))) throw new Error(`${args.path} does not exist — create it with write_file`)
291
+ const unsaved = await settle(uri)
292
+ if (unsaved.dirty) throw new Error(`${args.path} has unsaved changes in the editor — ask the user to save or discard them`)
293
+ await checkFresh(ctx.session, uri, args.path)
294
+ const raw = decoder.decode(await vscode.workspace.fs.readFile(uri))
295
+ const style = parseText(raw)
296
+ const edits = Array.isArray(args.edits) && args.edits.length
297
+ ? args.edits : [{ old_text: args.old_text, new_text: args.new_text, replace_all: args.replace_all }]
298
+ let text = style.text
299
+ let count = 0
300
+ let loose = false
301
+ edits.forEach((e, i) => {
302
+ const r = replaceOnce(text, e.old_text, e.new_text, !!e.replace_all, edits.length > 1 ? `edit ${i + 1}: ` : '')
303
+ text = r.text; count += r.count; loose ||= r.loose
304
+ })
305
+ await vscode.workspace.fs.writeFile(uri, encoder.encode(unparse(style, text)))
306
+ remember(ctx.session, uri, await stat(uri))
307
+ return `edited ${args.path}: ${count} replacement${count === 1 ? '' : 's'}`
308
+ + (loose ? ' (matched ignoring indentation; the new text was indented the way the file is)' : '')
309
+ }
310
+
311
+ // ---- after a change: what the editor now reports ----
312
+
313
+ /** Errors the language tools report for a file shortly after it changed — the way Claude
314
+ * Code reports type errors after each edit. Waits up to `ms` for fresh diagnostics. */
315
+ async function diagnosticsAfter(uri, ms = 2500) {
316
+ try {
317
+ const doc = await vscode.workspace.openTextDocument(uri) // wakes the language server for it
318
+ if (!doc) return ''
319
+ } catch { return '' }
320
+ await new Promise((done) => {
321
+ const timer = setTimeout(finish, ms)
322
+ const sub = vscode.languages.onDidChangeDiagnostics((e) => {
323
+ if (e.uris.some((u) => u.toString() === uri.toString())) { clearTimeout(timer); setTimeout(finish, 300) }
324
+ })
325
+ function finish() { sub.dispose(); done() }
326
+ })
327
+ const errors = vscode.languages.getDiagnostics(uri).filter((d) => d.severity === vscode.DiagnosticSeverity.Error)
328
+ if (!errors.length) return ''
329
+ return `\n\nThe editor now reports ${errors.length} error${errors.length === 1 ? '' : 's'} in this file:\n`
330
+ + errors.slice(0, 10).map((d) => ` line ${d.range.start.line + 1}: ${d.message.split('\n')[0]}`).join('\n')
331
+ + (errors.length > 10 ? `\n … ${errors.length - 10} more` : '')
332
+ }
333
+
334
+ // ---- search: ripgrep, the one VS Code ships ----
335
+
336
+ let rgPath
337
+ function ripgrep() {
338
+ if (rgPath !== undefined) return rgPath
339
+ const app = vscode.env.appRoot
340
+ const arch = `${process.platform}-${process.arch}`
341
+ const candidates = [
342
+ path.join(app, 'node_modules.asar.unpacked', '@vscode', 'ripgrep-universal', 'bin', arch, 'rg'),
343
+ path.join(app, 'node_modules.asar.unpacked', '@vscode', 'ripgrep', 'bin', 'rg'),
344
+ path.join(app, 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg'),
345
+ ].map((p) => (process.platform === 'win32' ? `${p}.exe` : p))
346
+ // In the terminal (cli/) without VS Code installed: a ripgrep on the PATH.
347
+ const onPath = String(process.env.PATH || '').split(path.delimiter).filter(Boolean)
348
+ .map((dir) => path.join(dir, process.platform === 'win32' ? 'rg.exe' : 'rg'))
349
+ rgPath = [...candidates, ...onPath].find((p) => { try { return fs.statSync(p).isFile() } catch { return false } }) || null
350
+ return rgPath
351
+ }
352
+
353
+ function rg(args, cwd, signal) {
354
+ return new Promise((done, fail) => {
355
+ execFile(ripgrep(), args, { cwd, maxBuffer: 20 * 1024 * 1024, timeout: 30_000, signal },
356
+ (error, stdout, stderr) => {
357
+ // Exit 1 is ripgrep's "nothing matched"; 2 is an error (a bad pattern, usually).
358
+ if (error && error.code !== 1) {
359
+ if (error.name === 'AbortError') return fail(error)
360
+ return fail(new Error((stderr || error.message).trim().split('\n').slice(0, 4).join('\n')))
361
+ }
362
+ done(stdout)
363
+ })
364
+ })
365
+ }
366
+
367
+ const SKIP = ['!.git', '!node_modules', '!.venv', '!__pycache__']
368
+
369
+ /** The files ripgrep would search under `dir`: .gitignore respected unless `ignored`. */
370
+ async function listAll(dir, ignored, signal) {
371
+ const flags = ['--files', '--hidden', ...SKIP.flatMap((g) => ['-g', g])]
372
+ if (ignored) flags.push('--no-ignore')
373
+ flags.push('--', dir)
374
+ return (await rg(flags, root().fsPath, signal)).replace(/\r/g, '').split('\n').filter(Boolean).map((l) => l.replace(/^\.\//, ''))
375
+ }
376
+
377
+ /** A glob as a test on paths: ** any folders, * and ? within a name, {a,b} alternatives,
378
+ * [abc] classes. A pattern without a slash matches the file name anywhere (as in
379
+ * .gitignore); one with a slash matches the path from the root or from `base`. */
380
+ function globRegExp(glob) {
381
+ let re = ''
382
+ for (let i = 0; i < glob.length; i += 1) {
383
+ const c = glob[i]
384
+ if (c === '*' && glob[i + 1] === '*') {
385
+ if (glob[i + 2] === '/') { re += '(?:.*/)?'; i += 2 } else { re += '.*'; i += 1 }
386
+ } else if (c === '*') re += '[^/]*'
387
+ else if (c === '?') re += '[^/]'
388
+ else if (c === '{') {
389
+ const end = glob.indexOf('}', i)
390
+ if (end === -1) { re += '\\{'; continue }
391
+ re += `(?:${glob.slice(i + 1, end).split(',').map((alt) => globRegExp(alt).source.slice(1, -1)).join('|')})`
392
+ i = end
393
+ } else if (c === '[') {
394
+ const end = glob.indexOf(']', i)
395
+ if (end === -1) { re += '\\['; continue }
396
+ re += glob.slice(i, end + 1).replace(/^\[!/, '[^')
397
+ i = end
398
+ } else re += /[.+^$()|\\]/.test(c) ? `\\${c}` : c
399
+ }
400
+ return new RegExp(`^${re}$`)
401
+ }
402
+
403
+ function matcher(pattern, base) {
404
+ const re = globRegExp(pattern.replace(/^\.\//, ''))
405
+ const bare = !pattern.includes('/')
406
+ const prefix = base && base !== '.' ? `${base.replace(/\/$/, '')}/` : ''
407
+ return (file) => (bare ? re.test(path.posix.basename(file))
408
+ : re.test(file) || (prefix && file.startsWith(prefix) && re.test(file.slice(prefix.length))))
409
+ }
410
+
411
+ /** The directory or file a search starts from, relative to the root, and whether it is a file. */
412
+ async function scope(given) {
413
+ const uri = resolve(given || '.')
414
+ const info = await stat(uri)
415
+ if (!info) throw new Error(`${given} does not exist`)
416
+ return { uri, rel: path.relative(root().fsPath, uri.fsPath) || '.', file: !(info.type & vscode.FileType.Directory) }
417
+ }
418
+
419
+ async function grep(args, ctx = {}) {
420
+ const pattern = String(args.pattern ?? args.query ?? '')
421
+ if (!pattern) throw new Error('pattern is required')
422
+ const where = await scope(args.path)
423
+ const mode = ['content', 'count', 'files_with_matches'].includes(args.output_mode) ? args.output_mode
424
+ : (args.query !== undefined && args.output_mode === undefined ? 'content' : 'files_with_matches')
425
+ if (!ripgrep()) return grepFallback(pattern, where, args, mode)
426
+ const flags = ['--color', 'never', '--hidden', '--max-columns', '400', '--max-columns-preview']
427
+ for (const g of SKIP) flags.push('-g', g)
428
+ if (args.case_insensitive || args['-i']) flags.push('-i')
429
+ if (args.fixed_strings || args.query !== undefined) flags.push('-F')
430
+ if (args.multiline) flags.push('-U', '--multiline-dotall')
431
+ // rg's -g overrides .gitignore; the glob is applied to what it would search instead.
432
+ let allowed = null
433
+ if (args.glob) {
434
+ const keep = matcher(String(args.glob), where.file ? '' : where.rel)
435
+ const list = where.file ? [where.rel] : (await listAll(where.rel, false, ctx.signal)).filter(keep)
436
+ if (!list.length) return `no files match ${args.glob}${where.rel !== '.' ? ` in ${where.rel}` : ''}`
437
+ allowed = list
438
+ }
439
+ if (args.type) flags.push('-t', String(args.type))
440
+ if (mode === 'files_with_matches') flags.push('-l', '--sortr', 'modified')
441
+ else if (mode === 'count') flags.push('-c')
442
+ else {
443
+ flags.push('-n', '--no-heading')
444
+ const c = (k) => Math.min(20, Math.max(0, Number(args[k]) || 0))
445
+ if (c('context')) flags.push('-C', String(c('context')))
446
+ if (c('before')) flags.push('-B', String(c('before')))
447
+ if (c('after')) flags.push('-A', String(c('after')))
448
+ }
449
+ flags.push('-e', pattern, '--', ...(allowed ? allowed.slice(0, 4000) : [where.rel]))
450
+ const out = (await rg(flags, root().fsPath, ctx.signal)).replace(/\r/g, '')
451
+ let rows = out.split('\n').filter(Boolean).map((l) => l.replace(/^\.\//, ''))
452
+ if (!rows.length) return `no match for ${JSON.stringify(pattern)}${where.rel !== '.' ? ` in ${where.rel}` : ''}`
453
+ const total = rows.length
454
+ const offset = Math.max(0, Number(args.offset) || 0)
455
+ const limit = Math.max(1, Number(args.head_limit) || (mode === 'content' ? 250 : 100))
456
+ rows = rows.slice(offset, offset + limit)
457
+ if (!rows.length) return `no entries at offset ${offset} (there are ${total})`
458
+ let text = rows.join('\n')
459
+ if (text.length > GREP_OUTPUT_CAP) text = `${safeSlice(text, 0, GREP_OUTPUT_CAP)}\n…`
460
+ const more = total - offset - rows.length
461
+ const head = mode === 'files_with_matches' ? `${total} file${total === 1 ? '' : 's'} (newest first)`
462
+ : mode === 'count' ? `${total} file${total === 1 ? '' : 's'} with matches` : `${total} line${total === 1 ? '' : 's'}`
463
+ return `${head}:\n${text}${more > 0 ? `\n\n[${more} more — call again with offset=${offset + rows.length}, or narrow with path/glob]` : ''}`
464
+ }
465
+
466
+ /** Without ripgrep (it should always be there): a plain scan, regex supported. */
467
+ async function grepFallback(pattern, where, args, mode) {
468
+ let re
469
+ try { re = new RegExp(pattern, args.case_insensitive ? 'i' : '') } catch (e) { throw new Error(`bad pattern: ${e.message}`, { cause: e }) }
470
+ const files = where.file ? [where.uri]
471
+ : await vscode.workspace.findFiles(new vscode.RelativePattern(where.uri, args.glob || '**/*'),
472
+ '**/{node_modules,.git,.venv,__pycache__}/**', 5000)
473
+ const rows = []
474
+ for (const file of files) {
475
+ let text
476
+ try { const b = await vscode.workspace.fs.readFile(file); if (looksBinary(b)) continue; text = decoder.decode(b) } catch { continue }
477
+ const lines = text.split('\n')
478
+ const hits = lines.map((l, i) => (re.test(l) ? i : -1)).filter((i) => i >= 0)
479
+ if (!hits.length) continue
480
+ const r = rel(file)
481
+ if (mode === 'files_with_matches') rows.push(r)
482
+ else if (mode === 'count') rows.push(`${r}:${hits.length}`)
483
+ else hits.forEach((i) => rows.push(`${r}:${i + 1}:${lines[i].slice(0, 400)}`))
484
+ if (rows.length > 2000) break
485
+ }
486
+ return rows.length ? rows.slice(0, 250).join('\n') : `no match for ${JSON.stringify(pattern)}`
487
+ }
488
+
489
+ async function glob(args, ctx = {}) {
490
+ const pattern = String(args.pattern || '')
491
+ if (!pattern) throw new Error('pattern is required, e.g. **/*.ts or src/**/*.test.js')
492
+ const where = await scope(args.path)
493
+ if (where.file) throw new Error(`${args.path} is a file; glob searches a directory`)
494
+ let files
495
+ if (ripgrep()) {
496
+ // Every file ripgrep would search (so .gitignore applies), then the pattern — rg's own
497
+ // -g would override .gitignore and list ignored files too.
498
+ files = (await listAll(where.rel, !!args.include_ignored, ctx.signal)).filter(matcher(pattern, where.rel))
499
+ } else {
500
+ files = (await vscode.workspace.findFiles(new vscode.RelativePattern(where.uri, pattern), '**/{node_modules,.git}/**', 5000)).map(rel)
501
+ }
502
+ if (!files.length) return `no files match ${pattern}${where.rel !== '.' ? ` in ${where.rel}` : ''}`
503
+ const dated = files.map((f) => {
504
+ let m = 0
505
+ try { m = fs.statSync(path.join(root().fsPath, f)).mtimeMs } catch { /* gone */ }
506
+ return { f, m }
507
+ }).sort((a, b) => b.m - a.m)
508
+ const shown = dated.slice(0, GLOB_CAP).map((d) => d.f)
509
+ return shown.join('\n') + (files.length > GLOB_CAP
510
+ ? `\n\n[${files.length} files match; the ${GLOB_CAP} most recently changed are shown — narrow the pattern or path]` : '')
511
+ }
512
+
513
+ async function listFiles(args) {
514
+ const dir = resolve(args.path || '.')
515
+ const entries = await vscode.workspace.fs.readDirectory(dir)
516
+ const lines = entries
517
+ .filter(([name]) => !['.git', 'node_modules', '.DS_Store'].includes(name))
518
+ .map(([name, kind]) => (kind & vscode.FileType.Directory ? `${name}/` : name))
519
+ .sort()
520
+ if (!lines.length) return '(empty directory)'
521
+ return lines.slice(0, 600).join('\n') + (lines.length > 600 ? `\n… ${lines.length - 600} more` : '')
522
+ }
523
+
524
+ // ---- notebooks ----
525
+
526
+ async function notebookEdit(args, ctx = {}) {
527
+ const uri = resolve(args.path)
528
+ if (!uri.fsPath.endsWith('.ipynb')) throw new Error('notebook_edit is for .ipynb files; use edit_file for others')
529
+ await checkFresh(ctx.session, uri, args.path)
530
+ const mode = ['replace', 'insert', 'delete'].includes(args.edit_mode) ? args.edit_mode : 'replace'
531
+ const nb = JSON.parse(decoder.decode(await vscode.workspace.fs.readFile(uri)))
532
+ nb.cells ||= []
533
+ const find = () => {
534
+ if (args.cell_id !== undefined && args.cell_id !== null && args.cell_id !== '') {
535
+ const byId = nb.cells.findIndex((c) => String(c.id) === String(args.cell_id))
536
+ if (byId !== -1) return byId
537
+ const n = Number(args.cell_id)
538
+ if (Number.isInteger(n) && n >= 0 && n < nb.cells.length) return n
539
+ throw new Error(`no cell with id ${args.cell_id} — read the notebook to see the cell ids`)
540
+ }
541
+ return mode === 'insert' ? -1 : (() => { throw new Error('cell_id is required') })()
542
+ }
543
+ const at = find()
544
+ const source = String(args.new_source ?? '').split(/(?<=\n)/)
545
+ const type = args.cell_type === 'markdown' ? 'markdown' : 'code'
546
+ if (mode === 'delete') nb.cells.splice(at, 1)
547
+ else if (mode === 'insert') {
548
+ const cell = { cell_type: type, metadata: {}, source, id: Math.random().toString(36).slice(2, 10) }
549
+ if (type === 'code') Object.assign(cell, { outputs: [], execution_count: null })
550
+ nb.cells.splice(at + 1, 0, cell)
551
+ } else {
552
+ const cell = nb.cells[at]
553
+ cell.source = source
554
+ if (args.cell_type) cell.cell_type = type
555
+ if (cell.cell_type === 'code') { cell.outputs = []; cell.execution_count = null }
556
+ }
557
+ const indent = /\n( +)"/.exec(decoder.decode(await vscode.workspace.fs.readFile(uri)))?.[1]?.length || 1
558
+ await vscode.workspace.fs.writeFile(uri, encoder.encode(`${JSON.stringify(nb, null, indent)}\n`))
559
+ remember(ctx.session, uri, await stat(uri))
560
+ return `${mode === 'delete' ? 'deleted' : mode === 'insert' ? 'inserted' : 'replaced'} a cell in ${args.path} `
561
+ + `(${nb.cells.length} cells now)`
562
+ }
563
+
564
+ module.exports = { globRegExp, matcher, readFile, writeFile, editFile, grep, glob, listFiles, notebookEdit, diagnosticsAfter,
565
+ replaceOnce, parseText, settle, checkFresh, remember, stat, ripgrep, reindent, looseMatches }