@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/mediagen.js CHANGED
@@ -3,16 +3,20 @@
3
3
  // into the workspace — to add one, or to replace one the code already uses. A replaced
4
4
  // file keeps its size and format, so whatever references it still fits.
5
5
 
6
+ const crypto = require('crypto')
6
7
  const vscode = require('vscode')
7
8
  const { resolve, root } = require('./tools')
8
9
 
9
10
  const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'])
10
11
  const VIDEO_EXT = new Set(['mp4', 'webm', 'mov', 'm4v'])
11
- /** fn(server, jobId, keep) after a render the agent asked for is done (the account keeps it;
12
- * keep(state) shows its progress and figures on the card). */
12
+ /** fn(server, jobId) after a render the agent asked for is done: the account keeps it (keeping.js),
13
+ * its card following by the job id in its media. */
13
14
  const rendered = new Set()
14
15
  function onRendered(fn) { rendered.add(fn); return { dispose: () => rendered.delete(fn) } }
15
16
  const STALL = 120_000 // the server repeats its progress every 10s; this is a lost link
17
+ // A render followed by its job id (its stream lost): looked at every `poll` ms; the server silent
18
+ // for `unreachable` ms meanwhile, it is left pending for next time. (A check shortens them.)
19
+ const timing = { poll: 5000, unreachable: 10 * 60_000 }
16
20
 
17
21
  /** The width and height of a PNG, JPEG, GIF or WebP, read from its header; null if unknown. */
18
22
  function imageSize(bytes) {
@@ -94,12 +98,78 @@ async function follow(response, onCard, signal) {
94
98
  return last
95
99
  }
96
100
 
101
+ const pause = (ms, signal) => new Promise((done) => {
102
+ const stop = () => { clearTimeout(timer); done() }
103
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', stop); done() }, ms)
104
+ signal?.addEventListener('abort', stop, { once: true })
105
+ })
106
+
107
+ /** The render's state read back by its job id, in the stream's shape: queued or running with
108
+ * its progress, done with its asset, or an error. */
109
+ async function jobState(server, jobId, signal) {
110
+ // Stop, or 20 s without an answer, ends this one request (AbortSignal.any is not in every Node 20).
111
+ const ask = new AbortController()
112
+ const timer = setTimeout(() => ask.abort(), 20_000)
113
+ const onStop = () => ask.abort()
114
+ signal?.addEventListener('abort', onStop, { once: true })
115
+ let r
116
+ try {
117
+ r = await fetch(`${server}/api/jobs/${encodeURIComponent(jobId)}`, { signal: ask.signal })
118
+ } finally { clearTimeout(timer); signal?.removeEventListener('abort', onStop) }
119
+ if (r.status === 404) return { status: 'error', error: 'the render is no longer on the server' }
120
+ if (!r.ok) throw new Error(`the server answered ${r.status}`)
121
+ const job = await r.json()
122
+ if (job.status === 'queued' || job.status === 'running') return { status: job.status, progress: job.progress || 0 }
123
+ if (job.status === 'done') return job.assets?.[0] ? { status: 'done', asset: job.assets[0] } : { status: 'error', error: 'the render finished without a file' }
124
+ return { status: 'error', error: job.error || `the render ${job.status || 'failed'}` }
125
+ }
126
+
127
+ /** Follows a render by its job id until it ends — after its stream was lost (a dropped link, the
128
+ * 45-minute limit of the stream) or after a reload or an exit cut the run (1.20.0): the job goes
129
+ * on on the server whatever happens to the editor. A server that does not answer (restarting,
130
+ * the Wi-Fi) is waited for; null when stopped. */
131
+ async function settle(server, jobId, onCard, signal) {
132
+ let silentSince = null
133
+ while (!signal?.aborted) {
134
+ let card = null
135
+ try {
136
+ card = await jobState(server, jobId, signal)
137
+ silentSince = null
138
+ } catch (error) {
139
+ if (signal?.aborted) break
140
+ silentSince ??= Date.now()
141
+ if (Date.now() - silentSince > timing.unreachable) {
142
+ const lost = new Error(`the server could not be reached to finish the render (${error.message})`)
143
+ lost.unreachable = true
144
+ throw lost
145
+ }
146
+ }
147
+ if (card?.status === 'done' || card?.status === 'error') return card
148
+ if (card) onCard(card)
149
+ await pause(timing.poll, signal)
150
+ }
151
+ return null
152
+ }
153
+
154
+ /** Stop pressed mid-render: the GPU job is cancelled too. Only the reading of it stopped before,
155
+ * and the GPU went on making a picture nobody would collect (1.20.0). */
156
+ async function cancel(server, jobId) {
157
+ if (!jobId) return false
158
+ try {
159
+ const r = await fetch(`${server}/api/jobs/${encodeURIComponent(jobId)}/cancel`, { method: 'POST', signal: AbortSignal.timeout(10_000) })
160
+ return r.ok
161
+ } catch { return false }
162
+ }
163
+
164
+ const progressOf = (update) => Math.round((update.progress || 0) * (update.progress > 1 ? 1 : 100))
165
+
97
166
  /**
98
167
  * Makes the picture or clip and writes it into the workspace.
99
- * `ask(question)` is the approval — true to go ahead. `report(update)` keeps the panel's
100
- * card current. Returns what to tell the model.
168
+ * `ask(question, { path })` is the approval — true to go ahead. `report(update)` keeps the
169
+ * panel's card current. `onJob(pending)`, once the server has queued it: what is needed to
170
+ * deliver it later by its job id (resume), for the caller to keep. Returns what to tell the model.
101
171
  */
102
- async function generate(server, args, { ask, report, signal }) {
172
+ async function generate(server, args, { ask, report, signal, onJob }) {
103
173
  const prompt = String(args.prompt || '').trim()
104
174
  if (!prompt) throw new Error('prompt is required: describe the picture or clip in full')
105
175
  const { kind, path, format } = target(args)
@@ -120,30 +190,57 @@ async function generate(server, args, { ask, report, signal }) {
120
190
  }
121
191
 
122
192
  const verb = existing ? 'Replace' : 'Create'
123
- if (!(await ask(`${verb} ${path} with a generated ${kind}?\n\n${prompt.slice(0, 300)}`))) {
193
+ if (!(await ask(`${verb} ${path} with a generated ${kind}?\n\n${prompt.slice(0, 300)}`, { path }))) {
124
194
  return { refused: true, text: 'the user declined this' }
125
195
  }
126
196
 
127
197
  report({ status: 'running', progress: 0, path })
128
- const body = { kind, prompt, aspect: args.aspect || 'landscape', seconds: Number(args.seconds) || 5 }
198
+ // A seed of its own, sent with the request and kept with the pending call: /api/agent/media
199
+ // renders with it rather than one of its own from the clock (1.20.0: its request model dropped
200
+ // the field without a word, so the seed sent did nothing).
201
+ const seed = crypto.randomInt(1, 2 ** 31 - 1)
202
+ const body = { kind, prompt, aspect: args.aspect || 'landscape', seconds: Number(args.seconds) || 5, seed }
129
203
  if (width && height) Object.assign(body, { width, height })
130
204
  if (source) Object.assign(body, { source: Buffer.from(source).toString('base64'), source_name: args.source })
131
205
  const response = await fetch(`${server}/api/agent/media`, {
132
206
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal,
133
207
  })
134
208
  if (!response.ok) throw new Error(`the server could not start it (${response.status}): ${(await response.text()).slice(0, 200)}`)
135
- const card = await follow(response, (update) => {
209
+ // Its job id, from the first line: kept by the caller at once, so a render cut off by a reload,
210
+ // an exit or a lost link is delivered later instead of being lost or made a second time (1.20.0).
211
+ const job = { job: null, seed, path, kind, format, width, height, existing: !!existing, saved: !!args.save_to }
212
+ const onCard = (update) => {
213
+ if (update.job_id && !job.job) {
214
+ job.job = update.job_id
215
+ try { onJob?.({ ...job }) } catch { /* kept or not, the render goes on */ }
216
+ }
136
217
  if (update.status === 'queued' || update.status === 'running') {
137
- report({ status: 'running', progress: Math.round((update.progress || 0) * (update.progress > 1 ? 1 : 100)), path, queued: update.status === 'queued' })
218
+ report({ status: 'running', progress: progressOf(update), path, queued: update.status === 'queued' })
138
219
  }
139
- }, signal)
140
- if (signal?.aborted) return { stopped: true, text: 'stopped by the user' }
220
+ }
221
+ let card = null
222
+ try {
223
+ card = await follow(response, onCard, signal)
224
+ } catch (error) {
225
+ // Stopped: the job is cancelled below. The stream lost: the job goes on, followed by its id.
226
+ if (!(signal?.aborted || job.job)) throw error
227
+ }
228
+ if (job.job && !signal?.aborted && card?.status !== 'done') card = await settle(server, job.job, onCard, signal)
229
+ if (signal?.aborted) {
230
+ const cancelled = await cancel(server, job.job)
231
+ return { stopped: true, text: cancelled ? 'stopped by the user: the render was cancelled on the server' : 'stopped by the user' }
232
+ }
141
233
  if (!card || card.status !== 'done') throw new Error(card?.error || 'the render ended without a file')
234
+ return deliver(server, job, card.asset, { report, signal })
235
+ }
142
236
 
143
- // The file, at the size and in the format the project needs.
237
+ /** A render, finished, written into the workspace: the file at the size and in the format the
238
+ * project needs, a preview for the card, the account told. Returns what to tell the model. */
239
+ async function deliver(server, job, asset, { report, signal }) {
240
+ const { kind, path, format, width, height, existing } = job
144
241
  const query = new URLSearchParams({ format })
145
242
  if (width && height && kind === 'image') { query.set('width', width); query.set('height', height) }
146
- const file = await fetch(`${server}/api/agent/media/${card.asset}?${query}`, { signal })
243
+ const file = await fetch(`${server}/api/agent/media/${asset}?${query}`, { signal })
147
244
  if (!file.ok) throw new Error(`could not fetch the result (${file.status}): ${(await file.text()).slice(0, 200)}`)
148
245
  const bytes = new Uint8Array(await file.arrayBuffer())
149
246
  await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(resolve(path), '..'))
@@ -155,20 +252,89 @@ async function generate(server, args, { ask, report, signal }) {
155
252
  try {
156
253
  const w = 320
157
254
  const h = size ? Math.max(16, Math.round(w * size.height / size.width)) : 180
158
- const still = await fetch(`${server}/api/agent/media/${card.asset}?format=jpg&width=${w}&height=${h}`, { signal })
255
+ const still = await fetch(`${server}/api/agent/media/${asset}?format=jpg&width=${w}&height=${h}`, { signal })
159
256
  if (still.ok) preview = `data:image/jpeg;base64,${Buffer.from(await still.arrayBuffer()).toString('base64')}`
160
257
  } catch { /* the card shows the path without a picture */ }
161
258
 
162
259
  const described = `${size ? `${size.width}×${size.height} ` : ''}${format.toUpperCase()}, ${Math.round(bytes.length / 1024)} KB`
163
- const media = { kind, path, preview, width: size?.width, height: size?.height, bytes: bytes.length }
260
+ // Its job, so its card can follow its keeping with the account after a reload (keeping.js).
261
+ const media = { kind, path, preview, width: size?.width, height: size?.height, bytes: bytes.length, job: job.job || undefined }
164
262
  report({ status: 'done', path, media })
165
- // Kept with the account now (accountUi): its progress, then the figures, on this card.
166
- const keep = (state) => report({ status: 'done', path, media, keep: state })
167
- for (const fn of rendered) { try { fn(server, card.job_id, keep) } catch { /* the account's own problem */ } }
263
+ // Kept with the account now (keeping.js): its progress, then the figures, on this card.
264
+ for (const fn of rendered) { try { fn(server, job.job) } catch { /* the account's own problem */ } }
168
265
  return {
169
266
  text: `Saved ${path} (${described})${existing ? ', replacing the previous file' : ''}.`
170
- + (args.save_to ? '' : ' It was saved under .aipictury/media/ to show the user; move it into the project if it belongs there.'),
267
+ + (job.saved ? '' : ' It was saved under .aipictury/media/ to show the user; move it into the project if it belongs there.'),
268
+ }
269
+ }
270
+
271
+ // Renders being delivered by their job id, so the panel (on reopening) and the run (carrying on)
272
+ // asking for the same one share a single delivery: one follow, one write, every caller told.
273
+ const delivering = new Map()
274
+
275
+ /**
276
+ * A render a cut-off run left pending (what onJob gave): followed by its job id to the end and
277
+ * written where it was asked for. Resolves to what to tell the model — "Saved …", or what went
278
+ * wrong — or null when stopped or the server is out of reach (it stays pending for next time).
279
+ *
280
+ * Each caller's own signal is its own: the delivery shared with the panel was made with the
281
+ * panel's signal, so Stop in a run waiting on it did nothing until the render ended — up to ten
282
+ * minutes with the server out of reach (1.20.0). Now a caller that stops leaves at once with
283
+ * null; the follow itself stops only when the last caller has left.
284
+ */
285
+ function resume(server, pending, { report = () => {}, signal } = {}) {
286
+ if (!pending?.job) return Promise.resolve('error: the render was cut off before the server had queued it')
287
+ if (signal?.aborted) return Promise.resolve(null)
288
+ let shared = delivering.get(pending.job)
289
+ if (!shared) {
290
+ const stop = new AbortController()
291
+ const callers = new Set()
292
+ const tell = (update) => { for (const fn of callers) { try { fn(update) } catch { /* one caller's row */ } } }
293
+ const path = pending.path
294
+ const running = (async () => {
295
+ tell({ status: 'running', progress: 0, path })
296
+ let card
297
+ try {
298
+ card = await settle(server, pending.job, (update) => tell({ status: 'running', progress: progressOf(update), path, queued: update.status === 'queued' }), stop.signal)
299
+ } catch (error) {
300
+ if (error.unreachable) return null
301
+ throw error
302
+ }
303
+ if (!card) return null
304
+ if (card.status !== 'done') {
305
+ tell({ status: 'failed', path, result: card.error })
306
+ return `error: ${card.error}`
307
+ }
308
+ return (await deliver(server, pending, card.asset, { report: tell, signal: stop.signal })).text
309
+ })().catch((error) => {
310
+ if (stop.signal.aborted || error.name === 'AbortError') return null
311
+ tell({ status: 'failed', path, result: error.message })
312
+ return `error: ${error.message}`
313
+ }).finally(() => { if (delivering.get(pending.job) === shared) delivering.delete(pending.job) })
314
+ shared = { running, stop, callers }
315
+ delivering.set(pending.job, shared)
171
316
  }
317
+ const mine = (update) => report(update) // a function of its own, even for the same report twice
318
+ shared.callers.add(mine)
319
+ const joined = shared
320
+ return new Promise((resolve) => {
321
+ const leave = (value) => {
322
+ signal?.removeEventListener('abort', stopped)
323
+ joined.callers.delete(mine)
324
+ resolve(value)
325
+ }
326
+ const stopped = () => {
327
+ leave(null)
328
+ // The last one gone: nobody waits for it, so it is not followed further — and a later
329
+ // caller starts a delivery of its own rather than joining this stopping one.
330
+ if (!joined.callers.size) {
331
+ if (delivering.get(pending.job) === joined) delivering.delete(pending.job)
332
+ joined.stop.abort()
333
+ }
334
+ }
335
+ signal?.addEventListener('abort', stopped, { once: true })
336
+ joined.running.then(leave)
337
+ })
172
338
  }
173
339
 
174
- module.exports = { generate, imageSize, target, root, onRendered }
340
+ module.exports = { generate, resume, imageSize, target, root, onRendered, timing }
package/ext/memory.js CHANGED
@@ -59,8 +59,13 @@ async function request(server, path, init = {}) {
59
59
  }
60
60
 
61
61
  // Your rules live with your AIPictury account when you are signed in (accountUi.js gives this
62
- // module the account's list), else on the AIPictury server, as before.
63
- let accountRules = null // { active: async () => bool, get: async () => [...], set: async ([...]) }
62
+ // module the account's list), else on the AIPictury server, as before. A rule is added or removed
63
+ // on the account in one step (cloud.addRules / removeRule), never by writing back the list read a
64
+ // moment before: two machines saving a rule at once kept only one of them (1.20.0).
65
+ let accountRules = null // { active: async () => bool, get: async () => [...], add: async (rule), remove: async (id) }
66
+ // The account keeps 500 at most (firestore.rules): one appended past that is refused with a
67
+ // permission error, and the rule was lost with no word of why (1.20.0) — said before it is tried.
68
+ const MAX_ACCOUNT_RULES = 500
64
69
  function useAccountRules(adapter) { accountRules = adapter }
65
70
 
66
71
  async function userRules(server) {
@@ -70,10 +75,15 @@ async function userRules(server) {
70
75
 
71
76
  async function addUserRule(server, text) {
72
77
  if (await accountRules?.active()) {
78
+ const clean = String(text).trim()
79
+ // Refused with a reason, never clipped: what GET /api/memory says as max_chars (1.20.0).
80
+ if (clean.length > 2000) throw new Error(`not remembered: a rule is at most 2,000 characters and this one is ${clean.length.toLocaleString('en')}`)
73
81
  const rules = await accountRules.get()
74
- const clean = String(text).trim().slice(0, 2000)
75
- if (!rules.some((r) => r.text === clean)) rules.push({ id: `r${Date.now().toString(36)}`, text: clean, created: Date.now() })
76
- await accountRules.set(rules)
82
+ if (rules.some((r) => r.text === clean)) return 'remembered for you, in every project — kept with your AIPictury account'
83
+ if (rules.length >= MAX_ACCOUNT_RULES) {
84
+ throw new Error(`not remembered: your account keeps ${MAX_ACCOUNT_RULES} rules at most and has ${rules.length} — remove one (/memory) and add it again`)
85
+ }
86
+ await accountRules.add({ id: `r${Date.now().toString(36)}`, text: clean, created: Date.now() })
77
87
  return 'remembered for you, in every project — kept with your AIPictury account'
78
88
  }
79
89
  await request(server, '/api/memory', {
@@ -84,7 +94,7 @@ async function addUserRule(server, text) {
84
94
 
85
95
  async function deleteUserRule(server, id) {
86
96
  if (await accountRules?.active()) {
87
- await accountRules.set((await accountRules.get()).filter((r) => String(r.id) !== String(id)))
97
+ await accountRules.remove(id)
88
98
  return
89
99
  }
90
100
  await request(server, `/api/memory/${encodeURIComponent(id)}`, { method: 'DELETE' })
@@ -114,4 +124,4 @@ async function block(server) {
114
124
  }
115
125
 
116
126
  module.exports = { PROJECT_FILE, projectUri, projectRules, addProjectRule, removeProjectRule,
117
- userRules, addUserRule, deleteUserRule, block, useAccountRules, onProjectRules }
127
+ userRules, addUserRule, deleteUserRule, block, useAccountRules, onProjectRules, MAX_ACCOUNT_RULES }
package/ext/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "aipictury",
3
3
  "displayName": "AIPictury",
4
4
  "description": "A coding agent and a generation studio running on your own GPU, with uncensored local models.",
5
- "version": "1.19.5",
5
+ "version": "1.20.1",
6
6
  "publisher": "aipictury",
7
7
  "license": "SEE LICENSE IN LICENSE.md",
8
8
  "engines": {
package/ext/plans.js CHANGED
@@ -12,7 +12,10 @@ const { home, projectKey } = require('./sessions')
12
12
  const MODES = [
13
13
  { id: 'edits', label: 'Approve — and edit automatically', detail: 'File changes go ahead; commands are asked' },
14
14
  { id: 'ask', label: 'Approve — and ask before each change', detail: 'Manual mode' },
15
- { id: 'never', label: 'Approve — in Auto mode', detail: 'Routine work goes ahead; anything risky is asked' },
15
+ // Said as Auto works since 1.10.4: it asks nothing (only your own ask rules and hooks do), and
16
+ // what would have been asked is listed at the end. "Anything risky is asked" promised a question
17
+ // Auto never stops for (1.20.0).
18
+ { id: 'never', label: 'Approve — in Auto mode', detail: 'Asks nothing: what would have been asked goes ahead and is listed at the end' },
16
19
  ]
17
20
 
18
21
  function planFile(folder, id) {
package/ext/remote.js CHANGED
@@ -17,6 +17,7 @@ const TEXT_CAP = 8000 // characters of one entry
17
17
  const LIVE_CAP = 20_000
18
18
  const DEBOUNCE = 150
19
19
  const RETRY = [1000, 2000, 5000, 10_000, 30_000]
20
+ const KEEP = 10 * 60_000 // as long as the server waits for its editor (remote.py KEEP)
20
21
 
21
22
  const cut = (text, n) => { const t = String(text ?? ''); return t.length > n ? `${t.slice(0, n)}…` : t }
22
23
 
@@ -26,13 +27,16 @@ function toolLabel(t) {
26
27
  return cut(a.command || a.path || a.url || a.pattern || a.query || a.description || a.title || a.action || '', 160)
27
28
  }
28
29
 
29
- /** What an approval asks, for the remote view: kind, the command or file, why. */
30
+ /** What an approval asks, for the remote view: kind, the command or file, why — and the question
31
+ * as the editor words it, which every client can show when it does not know the kind: a render
32
+ * (kind 'media') read "Use generate_media?" on aipictury.com, with no file and no prompt (1.20.0). */
30
33
  function approvalOf(id, request = {}) {
31
34
  return {
32
35
  id, kind: request.kind,
33
36
  tool: request.tool, command: cut(request.command, 2000), path: request.path, site: request.site,
34
37
  because: request.because, description: request.description,
35
- args: request.kind === 'tool' || request.kind === 'mcp' ? cut(JSON.stringify(request.args || {}), 1200) : undefined,
38
+ question: request.question ? cut(request.question, 1200) : undefined,
39
+ args: ['tool', 'mcp', 'media'].includes(request.kind) ? cut(JSON.stringify(request.args || {}), 1200) : undefined,
36
40
  // An edit as a diff: each changed part, its removed lines (-) and added ones (+), from line n.
37
41
  diff: request.kind === 'edit' ? cut((request.hunks || []).map((h) => [`@@ line ${h.line}`, ...h.removed.map((l) => `- ${l}`),
38
42
  ...h.added.map((l) => `+ ${l}`)].join('\n')).join('\n') || request.after, 6000) : undefined,
@@ -56,18 +60,21 @@ function snapshot(panel, live) {
56
60
  question: panel.pendingAsk ? { id: panel.pendingAsk.id, questions: panel.pendingAsk.questions } : null,
57
61
  plan: panel.pendingPlan ? { id: panel.pendingPlan.id, plan: cut(panel.pendingPlan.plan, 20_000) } : null,
58
62
  queue: (panel.queue || []).map((q) => cut(q.text, 400)),
63
+ // How the last run ended: the relay tells the account's devices (remote.py), knowing a stop from a finish.
64
+ ended: panel.ended || undefined,
59
65
  }
60
66
  }
61
67
 
62
68
  class RemoteLink {
63
- /** `panel`: the AgentPanel; `server`: its http(s) address. */
64
- constructor(panel, server, { log = () => {}, onViewers = () => {}, webApp = 'https://aipictury.com', token = async () => null } = {}) {
69
+ /** `panel`: the AgentPanel; `server`: its http(s) address. `id` and `key`: a link kept from
70
+ * before a reload (SavedLinks), hosted again so the same address carries on. */
71
+ constructor(panel, server, { log = () => {}, onViewers = () => {}, webApp = 'https://aipictury.com', token = async () => null, id = null, key = null } = {}) {
65
72
  this.webApp = webApp
66
73
  this.token = token // the account's ID token: the server may ask for it
67
74
  this.panel = panel
68
75
  this.server = server.replace(/\/+$/, '')
69
- this.id = crypto.randomUUID()
70
- this.key = crypto.randomBytes(24).toString('base64url')
76
+ this.id = id || crypto.randomUUID()
77
+ this.key = key || crypto.randomBytes(24).toString('base64url')
71
78
  this.log = log
72
79
  this.onViewers = onViewers
73
80
  this.live = ''
@@ -106,7 +113,7 @@ class RemoteLink {
106
113
  this.ws = ws
107
114
  const opened = Date.now()
108
115
  logs.event('debug', 'ws.connecting', { path: '/ws/remote/host', attempt: this.tries + 1, signedIn: Boolean(token) })
109
- ws.onopen = () => { logs.event('debug', 'ws.open', { path: '/ws/remote/host', ms: Date.now() - opened }); ws.send(JSON.stringify({ type: 'hello', id: this.id, key: this.key })) }
116
+ ws.onopen = () => { logs.event('debug', 'ws.open', { path: '/ws/remote/host', ms: Date.now() - opened }); ws.send(JSON.stringify({ type: 'hello', id: this.id, key: this.key, notify: 'server', server: this.server })) }
110
117
  ws.onmessage = (event) => {
111
118
  let msg
112
119
  try { msg = JSON.parse(String(event.data)) } catch { logs.event('debug', 'ws.message', { path: '/ws/remote/host', unreadable: true }); return }
@@ -117,8 +124,10 @@ class RemoteLink {
117
124
  }
118
125
  ws.onclose = (event) => {
119
126
  this.connected = false
120
- const refused = event.code === 4403 || event.code === 4409
127
+ // 4401: not signed in, or an account this server does not let in: trying again changes nothing.
128
+ const refused = event.code === 4401 || event.code === 4403 || event.code === 4409
121
129
  logs.event(refused ? 'warn' : 'debug', 'ws.close', { path: '/ws/remote/host', code: event.code, reason: event.reason || undefined, seconds: Math.round((Date.now() - opened) / 1000), retrying: this.active && !refused })
130
+ if (refused && this.active) this.log?.(`remote control refused: ${event.reason || 'sign in, or check that this server lets your account in'}`)
122
131
  if (!this.active || refused) return
123
132
  const wait = RETRY[Math.min(this.tries, RETRY.length - 1)]
124
133
  this.tries += 1
@@ -162,15 +171,66 @@ class RemoteLink {
162
171
  this.schedule()
163
172
  }
164
173
 
165
- /** Ends the sharing: the link stops working for everyone. */
166
- stop() {
174
+ /** Ends the sharing: the link stops working for everyone. With `end: false` only this editor
175
+ * lets go (its panel closed, the window reloading): the server keeps the conversation for
176
+ * KEEP, and the same link carries on once it is hosted again. */
177
+ stop({ end = true } = {}) {
167
178
  this.active = false
168
179
  clearTimeout(this.retry)
169
180
  clearTimeout(this.pending)
170
181
  this.restorePost?.()
171
- try { if (this.ws?.readyState === 1) this.ws.send(JSON.stringify({ type: 'end' })) } catch { /* closing anyway */ }
182
+ try { if (end && this.ws?.readyState === 1) this.ws.send(JSON.stringify({ type: 'end' })) } catch { /* closing anyway */ }
172
183
  setTimeout(() => { try { this.ws?.close() } catch { /* gone */ } }, 200)
173
184
  }
174
185
  }
175
186
 
176
- module.exports = { RemoteLink, snapshot, approvalOf }
187
+ /**
188
+ * The links of conversations shared by remote control, kept so a reload or a crash no longer ends
189
+ * them for good — the id and key were never stored, and a restarted editor could not host the same
190
+ * link again (1.20.0). The id and key go in the secret store (`secrets`: VS Code's), when each was
191
+ * last hosted in `state` (the workspace's). A link hosted within KEEP is hosted again when its
192
+ * conversation is on screen; an older one is forgotten, so an old link never wakes days later.
193
+ */
194
+ class SavedLinks {
195
+ constructor({ secrets, state, now = () => Date.now() }) {
196
+ this.secrets = secrets
197
+ this.state = state
198
+ this.now = now
199
+ }
200
+
201
+ static secretName(conversation) { return `aipictury.remote.${conversation}` }
202
+
203
+ seen() { return { ...(this.state.get('aipictury.remote') || {}) } }
204
+
205
+ /** Hosting `link` for `conversation`: kept, and its time refreshed (called now and then while on). */
206
+ async keep(conversation, link) {
207
+ if (!conversation || !link) return
208
+ if (link.keptFor !== conversation) {
209
+ await this.secrets.store(SavedLinks.secretName(conversation), JSON.stringify({ id: link.id, key: link.key }))
210
+ link.keptFor = conversation
211
+ }
212
+ await this.state.update('aipictury.remote', { ...this.seen(), [conversation]: this.now() })
213
+ }
214
+
215
+ /** The link to host again for `conversation` ({ id, key }), or null: none, or hosted too long ago. */
216
+ async take(conversation) {
217
+ const at = this.seen()[conversation]
218
+ if (!at) return null
219
+ if (this.now() - at > KEEP) { await this.forget(conversation); return null }
220
+ try {
221
+ const saved = JSON.parse(await this.secrets.get(SavedLinks.secretName(conversation)) || 'null')
222
+ return saved?.id && saved?.key ? saved : null
223
+ } catch { return null }
224
+ }
225
+
226
+ /** Sharing stopped on purpose: the link is gone for good. */
227
+ async forget(conversation) {
228
+ if (!conversation) return
229
+ const seen = this.seen()
230
+ delete seen[conversation]
231
+ await this.state.update('aipictury.remote', seen)
232
+ await this.secrets.delete(SavedLinks.secretName(conversation))
233
+ }
234
+ }
235
+
236
+ module.exports = { RemoteLink, SavedLinks, snapshot, approvalOf, KEEP }
package/ext/voice.js CHANGED
@@ -119,17 +119,68 @@ class Recording {
119
119
  discard() { try { fs.rmSync(this.file, { force: true }) } catch { /* gone */ } }
120
120
  }
121
121
 
122
- /** The server writes it: { text, language, duration, seconds }. The file is removed after. */
123
- async function transcribe(server, file, { language = '', signal } = {}) {
122
+ // Writing a recording waits this long for the server (a 5-minute recording takes Whisper well
123
+ // under a minute; a server that never answers no longer keeps "writing…" on for ever), and a
124
+ // failure that may clear — no answer, a restart (5xx), a busy GPU (423, 429) — is tried again
125
+ // after each of these pauses before it is said (1.20.0).
126
+ const TRANSCRIBE_TIMEOUT = 180_000
127
+ const RETRY_WAITS = [2000, 6000]
128
+
129
+ const pause = (ms, signal) => new Promise((done) => {
130
+ const stop = () => { clearTimeout(timer); done() }
131
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', stop); done() }, ms)
132
+ signal?.addEventListener('abort', stop, { once: true })
133
+ })
134
+
135
+ /** One try: the server's answer, or an error that says whether trying again may help (`transient`). */
136
+ async function transcribeOnce(server, file, { language, signal, timeoutMs, fetchImpl }) {
137
+ const form = new FormData()
138
+ form.append('audio', new Blob([fs.readFileSync(file)], { type: 'audio/wav' }), 'dictation.wav')
139
+ if (language) form.append('language', language)
140
+ // Stop, or no answer within timeoutMs, ends this try (AbortSignal.any is not in every Node 20).
141
+ const ask = new AbortController()
142
+ const timer = setTimeout(() => ask.abort(), timeoutMs)
143
+ const onStop = () => ask.abort()
144
+ signal?.addEventListener('abort', onStop, { once: true })
145
+ let response
124
146
  try {
125
- const form = new FormData()
126
- form.append('audio', new Blob([fs.readFileSync(file)], { type: 'audio/wav' }), 'dictation.wav')
127
- if (language) form.append('language', language)
128
- const response = await fetch(`${server}/api/transcribe`, { method: 'POST', body: form, signal })
129
- const data = await response.json().catch(() => ({}))
130
- if (!response.ok) throw new Error(data.detail || `the server answered ${response.status}${response.status === 404 ? ' — it is older than dictation; update it' : ''}`)
131
- return data
132
- } finally { fs.rmSync(file, { force: true }) }
147
+ response = await fetchImpl(`${server}/api/transcribe`, { method: 'POST', body: form, signal: ask.signal })
148
+ } catch (error) {
149
+ if (signal?.aborted) throw error
150
+ throw Object.assign(new Error(ask.signal.aborted ? `the server did not answer in ${Math.round(timeoutMs / 1000)} s`
151
+ : `the server could not be reached (${error.cause?.code || error.message})`), { transient: true })
152
+ } finally { clearTimeout(timer); signal?.removeEventListener('abort', onStop) }
153
+ const data = await response.json().catch(() => ({}))
154
+ if (!response.ok) {
155
+ throw Object.assign(new Error(data.detail || `the server answered ${response.status}${response.status === 404 ? ' — it is older than dictation; update it' : ''}`),
156
+ { status: response.status, transient: response.status >= 500 || [408, 423, 429].includes(response.status) })
157
+ }
158
+ return data
159
+ }
160
+
161
+ /**
162
+ * The server writes it: { text, language, duration, seconds }. The recording is removed once it
163
+ * is written, and only then: a failure leaves it where it is — the error carries it (`file`) — to
164
+ * be written again. It was removed whatever happened, so a server restarting or out of reach lost
165
+ * what was said, with no way to try again (1.20.0).
166
+ */
167
+ async function transcribe(server, file, { language = '', signal, timeoutMs = TRANSCRIBE_TIMEOUT, waits = RETRY_WAITS, fetchImpl = fetch } = {}) {
168
+ for (let attempt = 0; ; attempt += 1) {
169
+ try {
170
+ const data = await transcribeOnce(server, file, { language, signal, timeoutMs, fetchImpl })
171
+ fs.rmSync(file, { force: true })
172
+ return data
173
+ } catch (error) {
174
+ if (signal?.aborted || !error.transient || attempt >= waits.length) {
175
+ error.file = fs.existsSync(file) ? file : null
176
+ throw error
177
+ }
178
+ await pause(waits[attempt], signal)
179
+ }
180
+ }
133
181
  }
134
182
 
135
- module.exports = { recorder, Recording, transcribe, missing, which, soundBytes, MAX_SECONDS }
183
+ /** A recording that was not written, given up on: removed. */
184
+ function discard(file) { try { if (file) fs.rmSync(file, { force: true }) } catch { /* gone */ } }
185
+
186
+ module.exports = { recorder, Recording, transcribe, discard, missing, which, soundBytes, MAX_SECONDS, TRANSCRIBE_TIMEOUT, RETRY_WAITS }
package/ext/workbench.js CHANGED
@@ -139,16 +139,23 @@ function uriHandler(registry) {
139
139
 
140
140
  // ---- telling the user, when VS Code is not in front ----
141
141
 
142
- /** A system notification — only when VS Code is not the focused window, and `notifications` is on. */
142
+ /** A system notification — only when VS Code is not the focused window, and `notifications` is on.
143
+ * In the terminal (cli/) the same notification, and the terminal's bell beside it. */
143
144
  function notify(title, body) {
144
- if (!config().get('notifications', true) || vscode.window.state.focused) return false
145
145
  const text = String(body || '').slice(0, 200)
146
146
  try {
147
+ // Inside the try, and asked with ?.: 1.20.0 — the CLI's stand-in had no window.state, and
148
+ // the TypeError here ended the agent's run.
149
+ if (!config().get('notifications', true) || vscode.window.state?.focused) return false
150
+ if (vscode.cli && process.stderr.isTTY) process.stderr.write('\x07')
147
151
  if (process.platform === 'darwin') {
148
152
  const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
149
153
  execFile('osascript', ['-e', `display notification "${esc(text)}" with title "${esc(title)}"`], { timeout: 5000 }, () => {})
150
154
  } else if (process.platform === 'linux') {
151
155
  execFile('notify-send', [title, text], { timeout: 5000 }, () => {})
156
+ } else if (vscode.cli) {
157
+ // No system notification to send from a terminal here: the bell is it, if there is one.
158
+ return !!process.stderr.isTTY
152
159
  } else {
153
160
  vscode.window.showInformationMessage(`${title}: ${text}`)
154
161
  }