@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.
- package/LICENSE.md +77 -0
- package/README.md +39 -0
- package/ext/about.js +209 -0
- package/ext/account.js +242 -0
- package/ext/accountUi.js +287 -0
- package/ext/agent.js +1929 -0
- package/ext/browser.js +263 -0
- package/ext/cloud.js +385 -0
- package/ext/context.js +241 -0
- package/ext/deps.js +142 -0
- package/ext/dialogs.js +803 -0
- package/ext/diff.js +150 -0
- package/ext/extension.js +2483 -0
- package/ext/feedback.js +55 -0
- package/ext/files.js +565 -0
- package/ext/firestore.js +131 -0
- package/ext/gpuRun.js +77 -0
- package/ext/gpuServer.js +76 -0
- package/ext/hooks.js +153 -0
- package/ext/log.js +29 -0
- package/ext/lsp.js +122 -0
- package/ext/mcp.js +469 -0
- package/ext/mcpAuth.js +128 -0
- package/ext/media/vendor/firebase-config.json +9 -0
- package/ext/media/vendor/signin.js +1 -0
- package/ext/mediagen.js +174 -0
- package/ext/memory.js +117 -0
- package/ext/notebook.js +82 -0
- package/ext/package.json +1234 -0
- package/ext/paths.js +50 -0
- package/ext/permissions.js +229 -0
- package/ext/plans.js +110 -0
- package/ext/plugins.js +243 -0
- package/ext/prefs.js +157 -0
- package/ext/remote.js +170 -0
- package/ext/review.js +126 -0
- package/ext/sandbox.js +79 -0
- package/ext/scheduler.js +87 -0
- package/ext/sessions.js +228 -0
- package/ext/sessionsView.js +449 -0
- package/ext/shell.js +357 -0
- package/ext/storage.js +80 -0
- package/ext/tasklist.js +112 -0
- package/ext/text.js +31 -0
- package/ext/tools.js +781 -0
- package/ext/usage.js +77 -0
- package/ext/voice.js +135 -0
- package/ext/workbench.js +162 -0
- package/ext/workflow.js +51 -0
- package/ext/worktree.js +83 -0
- package/main.js +758 -0
- package/package.json +43 -0
- package/terminal.js +124 -0
- package/vscode-shim.js +231 -0
package/ext/feedback.js
ADDED
|
@@ -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 }
|