@davait/aipictury 1.19.5 → 1.20.1
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/ext/about.js +2 -2
- package/ext/accountUi.js +83 -21
- package/ext/agent.js +84 -11
- package/ext/attachments.js +55 -0
- package/ext/cloud.js +165 -32
- package/ext/dialogs.js +9 -5
- package/ext/extension.js +281 -42
- package/ext/feedback.js +78 -6
- package/ext/firestore.js +37 -6
- package/ext/gpuRun.js +31 -7
- package/ext/keeping.js +171 -0
- package/ext/mediagen.js +186 -20
- package/ext/memory.js +17 -7
- package/ext/package.json +1 -1
- package/ext/plans.js +4 -1
- package/ext/remote.js +72 -12
- package/ext/voice.js +62 -11
- package/ext/workbench.js +9 -2
- package/main.js +96 -27
- package/package.json +1 -1
- package/vscode-shim.js +3 -0
package/ext/about.js
CHANGED
|
@@ -26,7 +26,7 @@ function render({ server, version, about, error, config, account = {}, access =
|
|
|
26
26
|
const roles = about?.roles || {}
|
|
27
27
|
const gpus = about?.gpus || []
|
|
28
28
|
const generation = about?.generation || []
|
|
29
|
-
const roleName = { agent: 'coding agent',
|
|
29
|
+
const roleName = { agent: 'coding agent', commit: 'commit messages', vision: 'images' }
|
|
30
30
|
const modelRows = models.map((m) => `
|
|
31
31
|
<li class="model${m.roles?.length ? ' used' : ''}">
|
|
32
32
|
<div class="line">${esc(modelLine(m))}</div>
|
|
@@ -103,7 +103,7 @@ ${online ? `<tr><td>AIPictury</td><td id="server-version">${esc(s.version)}</td>
|
|
|
103
103
|
</table>
|
|
104
104
|
${online ? `
|
|
105
105
|
<h2>Models</h2>
|
|
106
|
-
<details id="models" open><summary>Language models on this server (${models.length})</summary><ul>${modelRows ||
|
|
106
|
+
<details id="models" open><summary>Language models on this server (${models.length})</summary><ul>${modelRows || `<li class="sub">${esc(about?.models_error || 'none installed')}</li>`}</ul></details>
|
|
107
107
|
<details id="generation" open><summary>Pictures and video (${generation.length})</summary><ul>${genRows || '<li class="sub">none</li>'}</ul></details>
|
|
108
108
|
<h2>GPUs</h2>
|
|
109
109
|
<ul id="gpus">${gpuRows || '<li class="sub">no GPU reported</li>'}</ul>` : ''}
|
package/ext/accountUi.js
CHANGED
|
@@ -13,11 +13,14 @@ const gpuServer = require('./gpuServer')
|
|
|
13
13
|
const { Cloud, PREFERENCES } = require('./cloud')
|
|
14
14
|
const sessions = require('./sessions')
|
|
15
15
|
const memory = require('./memory')
|
|
16
|
+
const keeping = require('./keeping')
|
|
16
17
|
const usage = require('./usage')
|
|
17
18
|
const prefs = require('./prefs')
|
|
18
19
|
const log = require('./log')
|
|
19
20
|
|
|
20
21
|
const SECRET = 'aipictury.account'
|
|
22
|
+
// A "delete everything" under way on this machine: { uid, server, at }, until all of it is done.
|
|
23
|
+
const CLEARING = 'aipictury.clearing'
|
|
21
24
|
// VS Code settings that follow you (a subset of cloud.PREFERENCES; the rest come from settings.json).
|
|
22
25
|
const VSCODE_PREFS = ['effort', 'approval', 'showThinking', 'focusView', 'autoMemory', 'notifications', 'useCtrlEnterToSend', 'browser.headless']
|
|
23
26
|
const config = () => vscode.workspace.getConfiguration('aipictury')
|
|
@@ -65,10 +68,11 @@ function register(context, { registry, sessionsView, serverUrl }) {
|
|
|
65
68
|
// ---- keeping things in step ----
|
|
66
69
|
context.subscriptions.push(sessions.onWrite((e) => {
|
|
67
70
|
if (!e.folder || !keeps('conversations')) return
|
|
68
|
-
if (e.type === 'remove') { cloud.deleteConversation(e.folder, e.id).catch((err) => log.warn(`cloud: ${e.id} not deleted: ${err.message}`)); return }
|
|
71
|
+
if (e.type === 'remove') { cloud.cancel(e.id); cloud.deleteConversation(e.folder, e.id).catch((err) => log.warn(`cloud: ${e.id} not deleted: ${err.message}`)); return }
|
|
69
72
|
cloud.schedule(e.folder, e.entry, e.type === 'patch' ? 500 : 5000).catch(() => {})
|
|
70
73
|
}))
|
|
71
|
-
memory.useAccountRules({ active: async () => keeps('memory'), get: () => cloud.getRules(),
|
|
74
|
+
memory.useAccountRules({ active: async () => keeps('memory'), get: () => cloud.getRules(),
|
|
75
|
+
add: (rule) => cloud.addRules([rule]), remove: (id) => cloud.removeRule(id) })
|
|
72
76
|
const pushProjectRules = debounce(async () => {
|
|
73
77
|
if (!keeps('memory') || !folder()) return
|
|
74
78
|
try { await cloud.setProjectMemory(folder(), await projectRulesText()) } catch (err) { log.warn(`cloud: project rules not pushed: ${err.message}`) }
|
|
@@ -113,7 +117,7 @@ function register(context, { registry, sessionsView, serverUrl }) {
|
|
|
113
117
|
try {
|
|
114
118
|
const r = await fetch(`${server}/api/memory`, { signal: AbortSignal.timeout(8000) })
|
|
115
119
|
const old = r.ok ? (await r.json()).rules || [] : []
|
|
116
|
-
if (old.length) await cloud.
|
|
120
|
+
if (old.length) await cloud.addRules(old.slice(0, 500).map((x) => ({ id: String(x.id), text: x.text, created: Date.now() })))
|
|
117
121
|
} catch { /* the server's list stays where it is */ }
|
|
118
122
|
}
|
|
119
123
|
// The project's rules: brought back when this checkout has none, kept up to date otherwise.
|
|
@@ -144,6 +148,8 @@ function register(context, { registry, sessionsView, serverUrl }) {
|
|
|
144
148
|
if (user) {
|
|
145
149
|
findServer()
|
|
146
150
|
settle().catch((err) => log.warn(`account: not settled: ${err.message}`))
|
|
151
|
+
offerToFinishClearing().catch((err) => log.warn(`account: unfinished delete not offered: ${err.message}`))
|
|
152
|
+
keepRenders() // what an earlier session left halfway
|
|
147
153
|
} else gpuServer.adopt(null) // signed out: the account's server goes with it
|
|
148
154
|
require('./about').refreshOpen()
|
|
149
155
|
}
|
|
@@ -157,18 +163,41 @@ function register(context, { registry, sessionsView, serverUrl }) {
|
|
|
157
163
|
if (user) await gpuServer.adopt(prefs?.values?.gpuServer)
|
|
158
164
|
} catch (err) { log.warn(`account: GPU server not read: ${err.message}`) }
|
|
159
165
|
}
|
|
160
|
-
// Every render the agent makes here is kept with the account (Renders, on the web too)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
// Every render the agent makes here is kept with the account (Renders, on the web too): noted in
|
|
167
|
+
// the outbox first (keeping.js), so a reload or an exit halfway only delays it; its card follows.
|
|
168
|
+
context.subscriptions.push(require('./mediagen').onRendered((server, jobId) => {
|
|
169
|
+
if (!user || !jobId) return
|
|
170
|
+
keeping.add(server, jobId, user.uid)
|
|
171
|
+
keepRenders()
|
|
165
172
|
}))
|
|
173
|
+
function keepRenders() {
|
|
174
|
+
keeping.drain(cloud, { onState: (jobId, state) => {
|
|
175
|
+
for (const p of registry.all()) p?.keepState?.(jobId, state)
|
|
176
|
+
if (state.stage === 'kept') log.info(`render ${jobId} kept with the account`)
|
|
177
|
+
if (state.stage === 'failed') log.warn(`render ${jobId} not kept with the account${state.again ? ' (tried again later)' : ''}: ${state.error}`)
|
|
178
|
+
} }).catch((err) => log.warn(`renders not kept with the account: ${err.message}`))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Where keeping a render of an earlier session stands, for its card when the conversation is
|
|
182
|
+
* reopened: still to do (it carries on), kept (with its figures), or null. */
|
|
183
|
+
async function keptState(jobId) {
|
|
184
|
+
if (keeping.pending().some((j) => j.job === jobId)) return { stage: 'waiting' }
|
|
185
|
+
if (!user) return null
|
|
186
|
+
const entry = await cloud.db.get(`${await cloud.base()}/renders/${jobId}`).catch(() => null)
|
|
187
|
+
if (!entry) return null
|
|
188
|
+
const stats = (entry.assets || []).find((a) => a.stats)?.stats
|
|
189
|
+
return stats ? { stage: 'kept', stats } : { stage: 'kept' }
|
|
190
|
+
}
|
|
166
191
|
// Asked from the web ("control it from here"): this window, when it has the conversation, shares
|
|
167
192
|
// it by remote control. One long request at a time, while signed in; an older server: later.
|
|
168
193
|
let listening = true
|
|
169
194
|
context.subscriptions.push({ dispose: () => { listening = false } })
|
|
170
195
|
;(async function listenForAsks() {
|
|
171
|
-
|
|
196
|
+
// 0: the server answers every ask of its last minute and gives its own clock back, so an ask
|
|
197
|
+
// made while this window reloaded is answered, and no client clock is compared with its own.
|
|
198
|
+
// One already acted on (kept here) is not acted on again after a reload (1.20.0).
|
|
199
|
+
let since = 0
|
|
200
|
+
const ASKED = 'aipictury.remoteAsked'
|
|
172
201
|
while (listening) {
|
|
173
202
|
const server = serverUrl()
|
|
174
203
|
if (!user || !server) { await new Promise((r) => setTimeout(r, 10_000)); continue }
|
|
@@ -177,11 +206,15 @@ function register(context, { registry, sessionsView, serverUrl }) {
|
|
|
177
206
|
if (!r.ok) { await new Promise((res) => setTimeout(res, r.status === 404 ? 120_000 : 15_000)); continue }
|
|
178
207
|
const body = await r.json()
|
|
179
208
|
since = body.now || since
|
|
180
|
-
for (const ask of body.asks || [])
|
|
209
|
+
for (const ask of body.asks || []) {
|
|
210
|
+
if ((ask.at || 0) <= (context.workspaceState.get(ASKED) || 0)) continue
|
|
211
|
+
await context.workspaceState.update(ASKED, ask.at || 0)
|
|
212
|
+
await registry.shareAsked?.(ask.conversation).catch((err) => log.warn(`remote control not started: ${err.message}`))
|
|
213
|
+
}
|
|
181
214
|
} catch { await new Promise((res) => setTimeout(res, 5000)) }
|
|
182
215
|
}
|
|
183
216
|
})()
|
|
184
|
-
const recheck = setInterval(() => { if (user) findServer() }, 10 * 60_000)
|
|
217
|
+
const recheck = setInterval(() => { if (user) { findServer(); keepRenders() } }, 10 * 60_000)
|
|
185
218
|
context.subscriptions.push({ dispose: () => clearInterval(recheck) })
|
|
186
219
|
context.subscriptions.push(gpuServer.onChange((url) => {
|
|
187
220
|
log.info(`GPU server: ${url || 'none set'} (${gpuServer.source() || 'nowhere'})`)
|
|
@@ -218,25 +251,54 @@ function register(context, { registry, sessionsView, serverUrl }) {
|
|
|
218
251
|
+ 'What is on this machine stays, and is not sent back up by itself: only what you do from now on is kept again (while '
|
|
219
252
|
+ 'its switch is on).' }, yes)
|
|
220
253
|
if (ok !== yes) return null
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
254
|
+
return clearEverything(gpuServer.url()) // the server read first: the delete must find it
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The delete itself, its steps in an order that survives being cut (cloud.deleteEverything),
|
|
258
|
+
* and noted here until every one is done: VS Code closed or reloaded halfway, the next start
|
|
259
|
+
* offers to finish it rather than leave half the account deleted (1.20.0). */
|
|
260
|
+
async function clearEverything(server) {
|
|
261
|
+
await context.globalState.update(CLEARING, { uid: user?.uid, server: server || null, at: Date.now() })
|
|
262
|
+
// A server that keeps renders deletes the account's cloud too, and finishes it whatever happens here.
|
|
263
|
+
const byServer = !!server && await cloud.serverKeeps(server)
|
|
264
|
+
const n = await cloud.deleteEverything({ byServer })
|
|
265
|
+
const there = await deleteOnServer('all', server)
|
|
266
|
+
if (there.ok) await context.globalState.update(CLEARING, undefined)
|
|
267
|
+
if (byServer) return `Deleting what your account keeps: the server finishes it${there.said}. Your account stays — sign out or keep using it.`
|
|
268
|
+
return `Deleted (${n} records and files)${there.said}. Your account stays — sign out or keep using it.`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** At start, signed in: a delete of everything this machine began and did not finish. */
|
|
272
|
+
async function offerToFinishClearing() {
|
|
273
|
+
const left = context.globalState.get(CLEARING)
|
|
274
|
+
if (!left || !user) return
|
|
275
|
+
if (left.uid && left.uid !== user.uid) { await context.globalState.update(CLEARING, undefined); return }
|
|
276
|
+
const finish = 'Finish deleting'
|
|
277
|
+
const choice = await vscode.window.showWarningMessage(`AIPictury: deleting everything kept with your account did not finish `
|
|
278
|
+
+ `(${new Date(left.at).toLocaleString()}). Finish it now?`, { modal: false }, finish, 'Leave it')
|
|
279
|
+
if (choice === 'Leave it') { await context.globalState.update(CLEARING, undefined); return }
|
|
280
|
+
if (choice !== finish) return // asked again at the next start
|
|
281
|
+
try {
|
|
282
|
+
vscode.window.showInformationMessage(`AIPictury: ${await clearEverything(left.server || gpuServer.url())}`)
|
|
283
|
+
} catch (error) { vscode.window.showErrorMessage(`AIPictury: not deleted — ${error.message}`) }
|
|
225
284
|
}
|
|
226
285
|
|
|
227
286
|
/** The GPU server's own copies of the account's work go too — renders, chats, attached files,
|
|
228
287
|
* its copy of the rules: the web app listed them (Renders) and copied them back. Said, not
|
|
229
288
|
* hidden, when the server cannot be reached. */
|
|
230
|
-
async function forgetOnServer(what, server = gpuServer.url()) {
|
|
231
|
-
|
|
289
|
+
async function forgetOnServer(what, server = gpuServer.url()) { return (await deleteOnServer(what, server)).said }
|
|
290
|
+
|
|
291
|
+
/** forgetOnServer, and whether it was done ({ ok, said }). */
|
|
292
|
+
async function deleteOnServer(what, server) {
|
|
293
|
+
if (!server) return { ok: true, said: '; no GPU server is set, so nothing there was touched' }
|
|
232
294
|
try {
|
|
233
295
|
const r = await fetch(`${server}/api/account/data?what=${what}`, { method: 'DELETE', signal: AbortSignal.timeout(120_000) })
|
|
234
296
|
if (!r.ok) throw new Error(`it answered ${r.status}`)
|
|
235
297
|
const d = (await r.json()).deleted || {}
|
|
236
|
-
return what === 'chats' ? `, and ${d.chats || 0} chats on the GPU server`
|
|
237
|
-
: `, and on the GPU server ${d.renders || 0} renders, ${d.chats || 0} chats and ${d.files || 0} files`
|
|
298
|
+
return { ok: true, said: what === 'chats' ? `, and ${d.chats || 0} chats on the GPU server`
|
|
299
|
+
: `, and on the GPU server ${d.renders || 0} renders, ${d.chats || 0} chats and ${d.files || 0} files` }
|
|
238
300
|
} catch (error) {
|
|
239
|
-
return `. The GPU server could not be reached (${error.message}): its copies stay — delete again once it answers`
|
|
301
|
+
return { ok: false, said: `. The GPU server could not be reached (${error.message}): its copies stay — delete again once it answers` }
|
|
240
302
|
}
|
|
241
303
|
}
|
|
242
304
|
|
|
@@ -271,7 +333,7 @@ function register(context, { registry, sessionsView, serverUrl }) {
|
|
|
271
333
|
context.subscriptions.push({ dispose: () => { globalThis.fetch = plainFetch } })
|
|
272
334
|
|
|
273
335
|
account.user().then(changed).catch(() => changed(null))
|
|
274
|
-
return { account, cloud, signedIn, signIn, signOut, deleteCloudData, deleteKind, forgetOnServer, keeps, user: () => user }
|
|
336
|
+
return { account, cloud, signedIn, signIn, signOut, deleteCloudData, deleteKind, forgetOnServer, keeps, keptState, user: () => user }
|
|
275
337
|
}
|
|
276
338
|
|
|
277
339
|
async function projectRulesText() {
|
package/ext/agent.js
CHANGED
|
@@ -161,11 +161,19 @@ Your answers are rendered as Markdown: fenced code blocks tagged with their lang
|
|
|
161
161
|
tables where they help, and \`backticks\` for paths and identifiers. Keep prose short —
|
|
162
162
|
this is a side panel, not an essay.`
|
|
163
163
|
|
|
164
|
-
/** One streamed turn from the server. Returns what the model said and wants done.
|
|
165
|
-
|
|
164
|
+
/** One streamed turn from the server. Returns what the model said and wants done. The run's
|
|
165
|
+
* hold on the GPU is kept alive while it streams, and only then (gpuRun.using): a long local
|
|
166
|
+
* step between turns no longer keeps every other app off an idle GPU (1.20.0). */
|
|
167
|
+
async function turn(server, messages, options = {}) {
|
|
168
|
+
const done = require('./gpuRun').using()
|
|
169
|
+
try { return await streamTurn(server, messages, options) } finally { done() }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function streamTurn(server, messages, { role = 'agent', tools, model, think = true, effort, signal, onDelta, onThinking,
|
|
166
173
|
onWaiting, onSpeed, watchReasoning = false, watchLoops = false, steer,
|
|
167
174
|
thinkBudget = 0 } = {}) {
|
|
168
|
-
|
|
175
|
+
// A render's job id (deliverPending) is the editor's own note, never the model's.
|
|
176
|
+
const sent = compact(messages).map((m) => { if (!m.pending) return m; const rest = { ...m }; delete rest.pending; return rest })
|
|
169
177
|
// Our own switch for cutting the reasoning short, tied to the caller's Stop as well.
|
|
170
178
|
const cut = new AbortController()
|
|
171
179
|
const onStop = () => cut.abort()
|
|
@@ -208,6 +216,13 @@ async function turn(server, messages, { role = 'agent', tools, model, think = tr
|
|
|
208
216
|
throw failure('a generation is using the GPU right now, so the agent waits for it '
|
|
209
217
|
+ 'to finish', { busy: true })
|
|
210
218
|
}
|
|
219
|
+
// 423: another app took the GPU while this run did no GPU work (a long local step let its
|
|
220
|
+
// hold lapse, gpuRun.js). Waited for as a render is, never the end of the run (1.20.0).
|
|
221
|
+
if (response.status === 423) {
|
|
222
|
+
let said = ''
|
|
223
|
+
try { said = JSON.parse(detail).detail || '' } catch { /* not JSON */ }
|
|
224
|
+
throw failure(`${said || 'the GPU is in use by another app'} — the agent waits for it`, { busy: true })
|
|
225
|
+
}
|
|
211
226
|
throw failure(`the server answered ${response.status}: ${detail.slice(0, 300)}`,
|
|
212
227
|
{ transient: response.status >= 500 || response.status === 429 })
|
|
213
228
|
}
|
|
@@ -529,7 +544,7 @@ const proposedProvider = {
|
|
|
529
544
|
}
|
|
530
545
|
|
|
531
546
|
/** The model's question, answered. Blocks on the panel's chooser — except in Auto mode,
|
|
532
|
-
* where
|
|
547
|
+
* where every question, important ones too, takes its recommended option at once and is
|
|
533
548
|
* kept as a decision the user can change when the run ends. */
|
|
534
549
|
async function askUser(args, report, signal, hooks) {
|
|
535
550
|
let questions
|
|
@@ -579,11 +594,14 @@ async function makeMedia(call, session, report, signal, server, hooks = {}) {
|
|
|
579
594
|
return 'Plan mode: nothing was generated. Describe the picture in your plan instead.'
|
|
580
595
|
}
|
|
581
596
|
// Writing a file needs the same yes as any write: asked for in Manual only.
|
|
582
|
-
const ask = async (question) => {
|
|
597
|
+
const ask = async (question, { path } = {}) => {
|
|
583
598
|
if (unattended(mode) || mode === 'edits' || session.allowAll) return true
|
|
584
599
|
report({ tool: 'generate_media', args, status: 'asking' })
|
|
585
600
|
if (hooks.approve) {
|
|
586
|
-
|
|
601
|
+
// One shape every client draws: the question (what, where, the prompt), the file it goes to
|
|
602
|
+
// — the CLI said "Use undefined {}" when it got only a kind and a path the agent had not given.
|
|
603
|
+
const request = { kind: 'media', tool: 'generate_media', question, path: path || args.save_to, args }
|
|
604
|
+
const answer = await Promise.race([hooks.approve(request, signal), stopped(signal)])
|
|
587
605
|
if (answer === STOPPED || !answer || answer.decision === 'deny') return false
|
|
588
606
|
if (answer.decision === 'allow_all') session.allowAll = true
|
|
589
607
|
return true
|
|
@@ -596,8 +614,10 @@ async function makeMedia(call, session, report, signal, server, hooks = {}) {
|
|
|
596
614
|
}
|
|
597
615
|
try {
|
|
598
616
|
const result = await mediagen.generate(server, args, {
|
|
599
|
-
ask, signal, report: (update) => report({ tool: 'generate_media', args, ...update })
|
|
617
|
+
ask, signal, report: (update) => report({ tool: 'generate_media', args, ...update }),
|
|
618
|
+
onJob: (job) => hooks.onMediaJob?.({ ...job, args }) })
|
|
600
619
|
if (result.refused) report({ tool: 'generate_media', args, status: 'refused' })
|
|
620
|
+
if (result.stopped) report({ tool: 'generate_media', args, status: 'failed', result: result.text })
|
|
601
621
|
return result.text
|
|
602
622
|
} catch (error) {
|
|
603
623
|
if (error.name === 'AbortError' || signal?.aborted) return 'stopped by the user'
|
|
@@ -606,6 +626,30 @@ async function makeMedia(call, session, report, signal, server, hooks = {}) {
|
|
|
606
626
|
}
|
|
607
627
|
}
|
|
608
628
|
|
|
629
|
+
/**
|
|
630
|
+
* generate_media calls whose render was still going when their run was cut off — a reload, an
|
|
631
|
+
* exit, a lost link: each one's result in the conversation is a placeholder holding its job id
|
|
632
|
+
* (`pending`). Each is followed by that id to the end, written where it was asked for, and its
|
|
633
|
+
* real result takes the placeholder's place, in place — so the model is never left without one,
|
|
634
|
+
* and never asks for the same picture twice (1.20.0). `onTool` updates its row in the panel.
|
|
635
|
+
* Returns how many were settled; one the server cannot be reached for stays pending.
|
|
636
|
+
*/
|
|
637
|
+
async function deliverPending(server, messages, { onTool, onSettled, signal } = {}) {
|
|
638
|
+
let settled = 0
|
|
639
|
+
for (const m of messages) {
|
|
640
|
+
if (m?.role !== 'tool' || !m.pending) continue
|
|
641
|
+
const p = m.pending
|
|
642
|
+
const text = await mediagen.resume(server, p, { signal,
|
|
643
|
+
report: (update) => onTool?.({ tool: 'generate_media', args: p.args || {}, ...update, id: p.row }) })
|
|
644
|
+
if (text == null) break
|
|
645
|
+
m.content = capResult(text)
|
|
646
|
+
delete m.pending
|
|
647
|
+
settled += 1
|
|
648
|
+
onSettled?.()
|
|
649
|
+
}
|
|
650
|
+
return settled
|
|
651
|
+
}
|
|
652
|
+
|
|
609
653
|
/** The to-do list the model keeps, cleaned: at most 30 items, a known status each. */
|
|
610
654
|
function normalizeTodos(todos) {
|
|
611
655
|
let list = todos
|
|
@@ -660,7 +704,19 @@ async function execute(call, session, report, signal, server, hooks = {}) {
|
|
|
660
704
|
// The tools that take a path: in a worktree, their paths are the worktree's.
|
|
661
705
|
const PATH_TOOLS = new Set(['read_file', 'write_file', 'edit_file', 'notebook_edit', 'list_files', 'grep', 'glob', 'code_intel', 'search'])
|
|
662
706
|
|
|
707
|
+
/** One tool call, whatever its tool. A handler that throws is a failed call the model reads,
|
|
708
|
+
* never the end of the run: 1.20.0 — push_notification threw a TypeError in the CLI (its
|
|
709
|
+
* stand-in for VS Code had no window.state), and that one exception ended the whole run. */
|
|
663
710
|
async function executeTool(call, session, report, signal, server, hooks = {}) {
|
|
711
|
+
try {
|
|
712
|
+
return await callTool(call, session, report, signal, server, hooks)
|
|
713
|
+
} catch (error) {
|
|
714
|
+
report({ tool: call.function.name, args: parsedArgs(call), status: 'failed', result: error.message })
|
|
715
|
+
return `error: ${error.message}`
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function callTool(call, session, report, signal, server, hooks = {}) {
|
|
664
720
|
const name = call.function.name
|
|
665
721
|
let args = call.function.arguments || {}
|
|
666
722
|
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { args = {} } }
|
|
@@ -825,7 +881,7 @@ async function executeTool(call, session, report, signal, server, hooks = {}) {
|
|
|
825
881
|
const title = String(args.title || '').trim().slice(0, 60) || 'AIPictury'
|
|
826
882
|
const sent = workbench.notify(title, message)
|
|
827
883
|
report({ tool: name, args: { title, message }, status: 'done', result: sent ? 'sent' : 'not sent' })
|
|
828
|
-
return sent ? 'The user was notified.' : 'Not sent: the user is looking at VS Code right now, or notifications are off — say it in your reply.'
|
|
884
|
+
return sent ? 'The user was notified.' : 'Not sent: the user is looking at VS Code right now, or notifications are off here — say it in your reply.'
|
|
829
885
|
}
|
|
830
886
|
|
|
831
887
|
if (name === 'report_findings') {
|
|
@@ -1722,6 +1778,12 @@ async function work({ server, model, history, session, signal,
|
|
|
1722
1778
|
const rules = await memory.block(server)
|
|
1723
1779
|
await mcp.ensure({ trust: trustMcp }).catch(() => [])
|
|
1724
1780
|
messages.push({ role: 'system', content: systemPrompt(modeNow(), rules, effortOf(chosenEffort || settings().get('effort', 'high'))) }, ...history)
|
|
1781
|
+
// A picture or clip an earlier run left rendering is delivered before the model is asked
|
|
1782
|
+
// anything: it then reads the real result, not a gap it would fill with a second render.
|
|
1783
|
+
if (history.some((m) => m.pending)) {
|
|
1784
|
+
await deliverPending(server, messages, { onTool, signal, onSettled: () => onMessages?.(messages.slice(1)) })
|
|
1785
|
+
if (signal?.aborted) return { messages: messages.slice(1), stopped: true }
|
|
1786
|
+
}
|
|
1725
1787
|
// What the user wrote, as opposed to the notes the loop adds in their voice.
|
|
1726
1788
|
const fromUser = new Set(history.filter((m) => m.role === 'user'))
|
|
1727
1789
|
let known = null // the dependencies the workspace declares, read when first needed
|
|
@@ -1927,12 +1989,23 @@ async function work({ server, model, history, session, signal,
|
|
|
1927
1989
|
continue
|
|
1928
1990
|
}
|
|
1929
1991
|
}
|
|
1930
|
-
|
|
1992
|
+
// A render, once queued: its job id stands as this call's result and is saved at once, so a
|
|
1993
|
+
// run cut off mid-render delivers it on the next run (deliverPending) instead of losing it.
|
|
1994
|
+
let placeholder = null
|
|
1995
|
+
const onMediaJob = (job) => {
|
|
1996
|
+
placeholder = { role: 'tool', tool_name: call.function.name, pending: { ...job, row: callId },
|
|
1997
|
+
content: `[rendering on the server as job ${job.job}: the result comes when it is done]` }
|
|
1998
|
+
messages.push(placeholder)
|
|
1999
|
+
onMessages?.(messages.slice(1), { now: true })
|
|
2000
|
+
}
|
|
2001
|
+
const result = await execute(call, session, (event) => onTool?.({ ...event, id: callId }), signal, server,
|
|
2002
|
+
call.function.name === 'generate_media' ? { ...hooks, onMediaJob } : hooks)
|
|
1931
2003
|
if (WRITES.has(call.function.name) || call.function.name === 'generate_media') changed = true
|
|
1932
2004
|
const content = typeof result === 'object' ? result.text : result
|
|
1933
2005
|
const toolMessage = { role: 'tool', tool_name: call.function.name, content: capResult(content) }
|
|
1934
2006
|
if (typeof result === 'object' && result.images?.length) toolMessage.images = result.images
|
|
1935
|
-
messages.
|
|
2007
|
+
if (placeholder && messages.includes(placeholder)) messages.splice(messages.indexOf(placeholder), 1, toolMessage)
|
|
2008
|
+
else messages.push(toolMessage)
|
|
1936
2009
|
onMessages?.(messages.slice(1))
|
|
1937
2010
|
if (session.hookStop) {
|
|
1938
2011
|
onNudge?.('hook', { text: `a hook stopped the run: ${session.hookStop}` })
|
|
@@ -1945,5 +2018,5 @@ async function work({ server, model, history, session, signal,
|
|
|
1945
2018
|
}
|
|
1946
2019
|
}
|
|
1947
2020
|
|
|
1948
|
-
module.exports = { appendSystem, helpersOf, findHelper, listHelpers, modeNow, setWindow, targetTokens, sideQuestion, run, turn, steadyTurn, execute, runHelper, normalizeTodos, commandPrefix, previewChange, approve, EFFORT, effortOf, systemPrompt, estimateTokens, summaryPlan, plain, commandEnv, AUTO_NOTE, thinkingLoops, distill, summarizeEarlier, runCommand, openSideBySide, stopBackground, compact, capResult, looksLikeCode, onlyQuotes, askedForChange, overthinking, calibrate, budget, SYSTEM, MODES,
|
|
2021
|
+
module.exports = { appendSystem, helpersOf, findHelper, listHelpers, modeNow, setWindow, targetTokens, sideQuestion, run, turn, steadyTurn, execute, runHelper, deliverPending, normalizeTodos, commandPrefix, previewChange, approve, EFFORT, effortOf, systemPrompt, estimateTokens, summaryPlan, plain, commandEnv, AUTO_NOTE, thinkingLoops, distill, summarizeEarlier, runCommand, openSideBySide, stopBackground, compact, capResult, looksLikeCode, onlyQuotes, askedForChange, overthinking, calibrate, budget, SYSTEM, MODES,
|
|
1949
2022
|
TARGET_TOKENS, proposedProvider, proposedContents }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Files attached to a message that is not sent yet, kept on this machine as the server converted
|
|
3
|
+
// them (a picture as itself or described, a PDF as its text) — ~/.aipictury/attachments/<project>/
|
|
4
|
+
// <slot>.json, private — until the message goes. They lived only in the panel's memory: a window
|
|
5
|
+
// reloaded, or VS Code restarted, between attaching a PDF and pressing Send lost it, and its chip
|
|
6
|
+
// with it (1.20.0). A slot is one panel: the side bar's, or a tab's (whose webview state carries
|
|
7
|
+
// its slot across a reload). Nothing here is sent anywhere.
|
|
8
|
+
const fs = require('fs')
|
|
9
|
+
const path = require('path')
|
|
10
|
+
const crypto = require('crypto')
|
|
11
|
+
const { home, projectKey } = require('./sessions')
|
|
12
|
+
|
|
13
|
+
const KEEP_FOR = 7 * 24 * 3600_000 // a draft's files left longer than this are not brought back
|
|
14
|
+
const KINDS = new Set(['text', 'image']) // what a converted attachment is ('loading' is not kept)
|
|
15
|
+
|
|
16
|
+
const dir = (folder) => path.join(home(), 'attachments', projectKey(folder || 'no-folder'))
|
|
17
|
+
const fileOf = (folder, slot) => path.join(dir(folder), `${String(slot || 'none').replace(/[^\w-]/g, '').slice(0, 60) || 'none'}.json`)
|
|
18
|
+
|
|
19
|
+
/** What waits to go with this panel's next message, replacing what was kept: none, nothing kept. */
|
|
20
|
+
function save(folder, slot, items) {
|
|
21
|
+
const file = fileOf(folder, slot)
|
|
22
|
+
const ready = (items || []).filter((a) => a && KINDS.has(a.kind))
|
|
23
|
+
try {
|
|
24
|
+
if (!ready.length) { fs.rmSync(file, { force: true }); return }
|
|
25
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
26
|
+
// Written whole or not at all: a reload in the middle never leaves half a list.
|
|
27
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
|
|
28
|
+
fs.writeFileSync(tmp, JSON.stringify({ saved: Date.now(), items: ready }), { mode: 0o600 })
|
|
29
|
+
fs.renameSync(tmp, file)
|
|
30
|
+
} catch { /* not kept: the files still go with the message if the window stays */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What this panel had attached and not sent, as it was kept: [{ id, name, kind, text | image, images }].
|
|
34
|
+
* Lists that no panel took back within KEEP_FOR (a tab closed with files on it) are removed. */
|
|
35
|
+
function load(folder, slot, now = Date.now()) {
|
|
36
|
+
prune(folder, now)
|
|
37
|
+
try {
|
|
38
|
+
const kept = JSON.parse(fs.readFileSync(fileOf(folder, slot), 'utf8'))
|
|
39
|
+
return (kept.items || []).filter((a) => a && a.id && a.name && KINDS.has(a.kind))
|
|
40
|
+
} catch { return [] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function prune(folder, now = Date.now()) {
|
|
44
|
+
let names
|
|
45
|
+
try { names = fs.readdirSync(dir(folder)) } catch { return }
|
|
46
|
+
for (const name of names) {
|
|
47
|
+
const file = path.join(dir(folder), name)
|
|
48
|
+
try { if (now - fs.statSync(file).mtimeMs > KEEP_FOR) fs.rmSync(file, { force: true }) } catch { /* gone */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A slot for a new tab: its own, so two tabs' files never mix. */
|
|
53
|
+
const newSlot = () => `tab-${crypto.randomBytes(6).toString('hex')}`
|
|
54
|
+
|
|
55
|
+
module.exports = { save, load, newSlot, fileOf, KEEP_FOR }
|