@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 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', edit: 'quick edits', commit: 'commit messages', vision: 'images' }
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 || '<li class="sub">none installed</li>'}</ul></details>
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(), set: (rules) => cloud.setRules(rules) })
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.setRules(old.map((x) => ({ id: String(x.id), text: x.text, created: Date.now() })))
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
- context.subscriptions.push(require('./mediagen').onRendered((server, jobId, keep = () => {}) => {
162
- if (!user) return
163
- cloud.keepRender(server, jobId, (_, state) => keep(state)).then((kept) => kept && log.info(`render ${jobId} kept with the account`))
164
- .catch((err) => { keep({ stage: 'failed', error: err.message }); log.warn(`render ${jobId} not kept with the account: ${err.message}`) })
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
- let since = Date.now() / 1000
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 || []) await registry.shareAsked?.(ask.conversation).catch((err) => log.warn(`remote control not started: ${err.message}`))
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
- const server = gpuServer.url() // read first: the delete must find it
222
- const n = await cloud.deleteEverything()
223
- const there = await forgetOnServer('all', server)
224
- return `Deleted (${n} records and files)${there}. Your account stays — sign out or keep using it.`
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
- if (!server) return '; no GPU server is set, so nothing there was touched'
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
- async function turn(server, messages, { role = 'agent', tools, model, think = true, effort, signal, onDelta, onThinking,
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
- const sent = compact(messages)
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 a question not marked important takes the recommended options at once and is
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
- const answer = await Promise.race([hooks.approve({ kind: 'media', question, path: args.save_to }, signal), stopped(signal)])
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
- const result = await execute(call, session, (event) => onTool?.({ ...event, id: callId }), signal, server, hooks)
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.push(toolMessage)
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 }