@davait/aipictury 1.19.4 → 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/cloud.js CHANGED
@@ -8,10 +8,21 @@ const crypto = require('crypto')
8
8
  const zlib = require('zlib')
9
9
  const path = require('path')
10
10
  const { execFile } = require('child_process')
11
- const { Firestore } = require('./firestore')
11
+ const { Firestore, fieldPath } = require('./firestore')
12
12
  const { Storage } = require('./storage')
13
13
 
14
14
  const CHUNK = 700_000 // characters of base64 a chunk (the rules allow 900,000)
15
+ // A conversation saved at every step is pushed at least this often, not only once it goes quiet:
16
+ // a run killed in its tenth minute had pushed nothing since it started (1.20.0).
17
+ const MAX_WAIT = 30_000
18
+ // A conversation shared by remote control is listed while its window hosts it: the window writes
19
+ // the listing again every REMOTE_BEAT, and a listing older than REMOTE_FRESH is no longer live —
20
+ // its window crashed or was killed, and nothing deleted it (1.20.0: such a listing was offered on
21
+ // aipictury.com for good).
22
+ const REMOTE_BEAT = 60_000
23
+ const REMOTE_FRESH = 150_000
24
+ /** Whether a remote-control listing is still kept up by its window. */
25
+ const remoteLive = (entry, now = Date.now()) => now - (Number(entry?.updated) || 0) <= REMOTE_FRESH
15
26
  const MAX_CHUNKS = 64 // ~45 MB of base64: a conversation larger than that stays local
16
27
  // Preferences that follow you. Nothing else is sent: never env, headers, tokens or MCP config.
17
28
  const PREFERENCES = ['effort', 'approval', 'showThinking', 'focusView', 'autoMemory', 'notifications',
@@ -82,6 +93,16 @@ async function compactOn(server, assetId, onStage) {
82
93
  return stats
83
94
  }
84
95
 
96
+ // As frontend/src/rules.js: a list that would not fit is refused with the reason, never cut short
97
+ // without a word (slice dropped the newest rules, 1.20.0; firestore.rules refuses past 500).
98
+ function rulesFit(rules) {
99
+ if (rules.length > 500) throw new Error(`your account keeps 500 rules at most and this would make ${rules.length}`)
100
+ if (Buffer.byteLength(JSON.stringify(rules)) > 900_000) throw new Error('your rules are as long as can be kept together: remove or shorten one first')
101
+ }
102
+
103
+ const KEEP_POLL = 3000 // a render the server keeps: looked at this often
104
+ const KEEP_WATCH = 30 * 60_000 // and this long, before the outbox takes it up later
105
+
85
106
  class Cloud {
86
107
  /**
87
108
  * `account`: account.js (who, and ID tokens). `device`: { id, name, app, platform, version }.
@@ -95,6 +116,10 @@ class Cloud {
95
116
  this.storage = bucket ? new Storage({ bucket, emulator: storageEmulator, getToken: () => account.idToken(), fetchImpl }) : null
96
117
  this.pushed = new Map() // conversation path → hash last pushed (in this process)
97
118
  this.timers = new Map()
119
+ // Scheduled and not pushed yet, by conversation id: { folder, entry, owner, since } — kept
120
+ // here, so closing pushes them (flush) instead of dropping the timers' work (1.20.0).
121
+ this.pending = new Map()
122
+ this.scheduling = new Set() // schedule() calls still finding out who is signed in
98
123
  // Signing out, or another account signing in, leaves nothing of the last session behind.
99
124
  this.subscription = account.onChange?.(() => this.reset())
100
125
  }
@@ -104,6 +129,7 @@ class Cloud {
104
129
  reset() {
105
130
  for (const t of this.timers.values()) clearTimeout(t)
106
131
  this.timers.clear()
132
+ this.pending.clear()
107
133
  this.pushed.clear()
108
134
  }
109
135
 
@@ -150,6 +176,15 @@ class Cloud {
150
176
  if (this.pushed.get(doc) === whole) return 'unchanged'
151
177
  const before = await this.db.get(doc)
152
178
  if (before?.hash === whole) { this.pushed.set(doc, whole); return 'unchanged' }
179
+ if (!before) {
180
+ // A new conversation: its project and a summary that says it is being saved go first, so
181
+ // its chunks always have a parent to be found by. A first save cut between the chunks and
182
+ // the summary left chunks under no conversation, which no delete ever listed (1.20.0).
183
+ // (No contentHash: a reader takes it for a conversation being saved, as it is.)
184
+ await this.touchProject(project, folder)
185
+ await this.db.set(doc, { ...summary, created: entry.created || Date.now(), updated: 0, chunks: 0,
186
+ origin: entry.origin || this.device.app, device: this.device.id })
187
+ }
153
188
  const stale = []
154
189
  if (before?.contentHash !== packed.hash) {
155
190
  // The chunks first, the summary last: a reader that sees the summary finds every chunk.
@@ -173,26 +208,55 @@ class Cloud {
173
208
  conversations: current?.conversations ?? 0 })
174
209
  }
175
210
 
176
- /** Pushes a few seconds after the last save, so a busy conversation is sent once, not at every step. */
177
- async schedule(folder, entry, delayMs = 5000) {
211
+ /** Pushes a few seconds after the last save, so a busy conversation is sent once, not at every
212
+ * step — and at least every MAX_WAIT while it keeps being saved. */
213
+ schedule(folder, entry, delayMs = 5000) {
214
+ // Known at once, so a flush right after a save (closing) waits for it rather than missing it.
215
+ const scheduling = this.scheduleAfterWho(folder, entry, delayMs)
216
+ this.scheduling.add(scheduling)
217
+ return scheduling.finally(() => this.scheduling.delete(scheduling))
218
+ }
219
+
220
+ async scheduleAfterWho(folder, entry, delayMs) {
178
221
  // The push belongs to the account signed in now: if another one is by the time it runs, it is dropped.
179
222
  const owner = await this.uid()
180
223
  if (!owner) return
224
+ const was = this.pending.get(entry.id)
225
+ const since = was?.owner === owner ? was.since : Date.now()
226
+ this.pending.set(entry.id, { folder, entry, owner, since })
181
227
  clearTimeout(this.timers.get(entry.id))
182
- const timer = setTimeout(async () => {
228
+ const timer = setTimeout(() => {
183
229
  this.timers.delete(entry.id)
184
- if ((await this.uid()) !== owner) return
185
- try { await this.pushConversation(folder, entry) } catch (error) { this.log(`cloud: ${entry.id} not pushed: ${error.message}`) }
186
- }, delayMs)
230
+ this.pushPending(entry.id)
231
+ }, Math.max(0, Math.min(delayMs, since + MAX_WAIT - Date.now())))
187
232
  timer.unref?.()
188
233
  this.timers.set(entry.id, timer)
189
234
  }
190
235
 
191
- /** Waits for what is scheduled (on closing). */
192
- async flush(pending) {
236
+ /** The scheduled push of one conversation, now; never throws. */
237
+ async pushPending(id) {
238
+ const p = this.pending.get(id)
239
+ if (!p) return
240
+ this.pending.delete(id)
241
+ if ((await this.uid().catch(() => null)) !== p.owner) return
242
+ if ((p.entry.updated || 0) <= (this.clearedHere || 0)) return // saved before "Delete everything"
243
+ try { await this.pushConversation(p.folder, p.entry) } catch (error) { this.log(`cloud: ${id} not pushed: ${error.message}`) }
244
+ }
245
+
246
+ /** A conversation deleted here: a push still scheduled for it would bring it back. */
247
+ cancel(id) {
248
+ clearTimeout(this.timers.get(id))
249
+ this.timers.delete(id)
250
+ this.pending.delete(id)
251
+ }
252
+
253
+ /** On closing: everything scheduled is pushed now, not dropped with its timer (1.20.0: VS Code
254
+ * never called this, and the last few seconds of every conversation stayed off the account). */
255
+ async flush() {
256
+ await Promise.allSettled([...this.scheduling])
193
257
  for (const t of this.timers.values()) clearTimeout(t)
194
258
  this.timers.clear()
195
- for (const [folder, entry] of pending || []) await this.pushConversation(folder, entry).catch(() => {})
259
+ for (const id of [...this.pending.keys()]) await this.pushPending(id)
196
260
  }
197
261
 
198
262
  /** A project's conversations, by its id (the web app's chats are the project "web"). */
@@ -206,6 +270,7 @@ class Cloud {
206
270
  const doc = `${await this.base()}/projects/${projectId}/conversations/${id}`
207
271
  const meta = await this.db.get(doc)
208
272
  if (!meta) throw new Error('that conversation is not in your history any more')
273
+ if (!meta.contentHash) throw new Error('it is being saved right now — try again in a moment')
209
274
  const parts = []
210
275
  for (let i = 0; i < meta.chunks; i += 1) {
211
276
  const c = await this.db.get(`${doc}/chunks/${i}`)
@@ -261,6 +326,7 @@ class Cloud {
261
326
  const doc = `${project.path}/conversations/${id}`
262
327
  const meta = await this.db.get(doc)
263
328
  if (!meta) throw new Error('that conversation is not in the cloud')
329
+ if (!meta.contentHash) throw new Error('the cloud copy is being written — try again in a moment')
264
330
  const parts = []
265
331
  for (let i = 0; i < meta.chunks; i += 1) {
266
332
  const c = await this.db.get(`${doc}/chunks/${i}`)
@@ -276,9 +342,10 @@ class Cloud {
276
342
  async deleteConversation(folder, id) {
277
343
  const project = await this.projectPath(folder)
278
344
  const doc = `${project.path}/conversations/${id}`
279
- const meta = await this.db.get(doc)
280
345
  const writes = []
281
- for (let i = 0; i < (meta?.chunks || 0); i += 1) writes.push({ delete: `${doc}/chunks/${i}` })
346
+ // Every chunk there is, listed, not as many as the summary says: a save cut short leaves
347
+ // chunks with no summary, or more than it counts (1.20.0).
348
+ for (const k of await this.db.list(`${doc}/chunks`)) writes.push({ delete: `${doc}/chunks/${k.id}` })
282
349
  writes.push({ delete: doc })
283
350
  await this.db.batch(writes)
284
351
  this.pushed.delete(doc)
@@ -293,7 +360,21 @@ class Cloud {
293
360
 
294
361
  /** Your rules: [{ id, text, created }]. */
295
362
  async getRules() { return (await this.db.get(`${await this.base()}/memory/rules`))?.rules || [] }
296
- async setRules(rules) { await this.db.set(`${await this.base()}/memory/rules`, { rules: rules.slice(0, 500), updated: Date.now() }) }
363
+ async setRules(rules) { rulesFit(rules); await this.db.set(`${await this.base()}/memory/rules`, { rules, updated: Date.now() }) }
364
+ /** Rules added, and one taken out, on the server in one step each: a rule saved on another
365
+ * machine (or the web) at the same moment stays. Reading the list, changing it and writing it
366
+ * back lost one of the two (1.20.0). A rule is never edited, only added or removed, so taking
367
+ * out the very rules that carry this id removes it whatever was added meanwhile. */
368
+ async addRules(rules) {
369
+ if (!rules.length) return
370
+ rulesFit([...await this.getRules(), ...rules])
371
+ await this.db.change(`${await this.base()}/memory/rules`, { set: { updated: Date.now() }, append: { rules } })
372
+ }
373
+
374
+ async removeRule(id) {
375
+ const gone = (await this.getRules()).filter((r) => String(r.id) === String(id))
376
+ await this.db.change(`${await this.base()}/memory/rules`, { set: { updated: Date.now() }, remove: { rules: gone } })
377
+ }
297
378
 
298
379
  async getPreferences() { return (await this.db.get(`${await this.base()}/settings/preferences`)) || null }
299
380
  /** This device's preferences, merged into the account's: what other apps keep there (the web
@@ -305,27 +386,57 @@ class Cloud {
305
386
  await this.db.set(path, { values: { ...current, ...allowed }, updated: Date.now(), device: this.device.id })
306
387
  }
307
388
 
308
- /** Adds one turn to the day's totals. */
389
+ /** Adds one turn to the day's totals — added on the server, so two machines' turns at the same
390
+ * moment both count (a read, a sum and a write back kept only one: 1.20.0). */
309
391
  async addUsage(day, turn) {
310
392
  const doc = `${await this.base()}/usage/${day}`
311
- const now = (await this.db.get(doc)) || { turns: 0, prompt: 0, output: 0, genSeconds: 0, tools: {}, models: {} }
312
- for (const t of turn.tools || []) now.tools[t] = (now.tools[t] || 0) + 1
313
- for (const m of turn.models || []) now.models[m] = (now.models[m] || 0) + 1
314
- await this.db.set(doc, { turns: now.turns + 1, prompt: now.prompt + (turn.context || 0), output: now.output + (turn.output || 0),
315
- genSeconds: Math.round((now.genSeconds + (turn.genSeconds || 0)) * 10) / 10, tools: now.tools, models: now.models, updated: Date.now() })
393
+ const increment = { turns: 1, prompt: turn.context || 0, output: turn.output || 0, genSeconds: Math.round((turn.genSeconds || 0) * 10) / 10 }
394
+ for (const t of turn.tools || []) increment[fieldPath('tools', t)] = (increment[fieldPath('tools', t)] || 0) + 1
395
+ for (const m of turn.models || []) increment[fieldPath('models', m)] = (increment[fieldPath('models', m)] || 0) + 1
396
+ await this.db.change(doc, { set: { updated: Date.now() }, increment })
316
397
  }
317
398
 
318
- /** Everything of yours in the cloud, deleted. The local files stay. */
319
399
  /**
320
400
  * A render the GPU server finished (its job), kept with the account as the web app keeps it:
321
401
  * each file and a clip's poster in Storage (media/), its entry in renders/. Once per job;
322
- * false when it was kept already or there is nothing to keep.
402
+ * false when it was kept already or there is nothing to keep (the job gone from the server).
403
+ * Safe to run again after a cut: the same files are written again, the entry still last.
323
404
  */
405
+ /** Whether this GPU server keeps renders with accounts by itself (GET /api/health `keeps`). */
406
+ async serverKeeps(server) {
407
+ const r = await fetch(`${server}/api/health`, { signal: AbortSignal.timeout(10_000) }).catch(() => null)
408
+ return r?.ok ? !!(await r.json()).keeps : false
409
+ }
410
+
411
+ /** A render the server is keeping: how it goes, on its card. True once its entry is there; a
412
+ * failure throws, so the outbox looks again later. The app can go at any moment: the server goes on. */
413
+ async followKeeping(server, jobId, path, onStage) {
414
+ const until = Date.now() + KEEP_WATCH
415
+ while (Date.now() < until) {
416
+ const r = await fetch(`${server}/api/keeping/${jobId}`, { signal: AbortSignal.timeout(20_000) })
417
+ if (!r.ok) throw new Error(`the server could not say how keeping it goes (${r.status})`)
418
+ const now = await r.json()
419
+ if (now.state === 'unknown') return false
420
+ if (now.state === 'done') {
421
+ const entry = await this.db.get(path)
422
+ const stats = (entry?.assets || []).find((a) => a.stats)?.stats
423
+ onStage(now.asset, stats ? { stage: 'kept', stats } : { stage: 'kept' })
424
+ return !!entry
425
+ }
426
+ if (now.state === 'failed' || now.state === 'retry') throw new Error(now.error || 'the server could not keep it')
427
+ if (now.stage) onStage(now.asset, { stage: now.stage, progress: now.progress })
428
+ await new Promise((resolve) => setTimeout(resolve, KEEP_POLL))
429
+ }
430
+ throw new Error('the server is still keeping it')
431
+ }
432
+
324
433
  async keepRender(server, jobId, onStage = () => {}) {
325
434
  if (!this.storage || !jobId) return false
326
435
  const path = `${await this.base()}/renders/${jobId}`
327
436
  if (await this.db.get(path)) return false
437
+ if (await this.serverKeeps(server)) return this.followKeeping(server, jobId, path, onStage)
328
438
  const r = await fetch(`${server}/api/jobs/${jobId}`, { signal: AbortSignal.timeout(20_000) })
439
+ if (r.status === 404) return false
329
440
  if (!r.ok) throw new Error(`the job could not be read (${r.status})`)
330
441
  const job = await r.json()
331
442
  if (job.status !== 'done') return false
@@ -365,13 +476,17 @@ class Cloud {
365
476
  }
366
477
 
367
478
  /** A conversation shared by remote control, listed with the account (aipictury.com offers to
368
- * control it): its link, title, machine; gone when sharing stops. */
369
- async putRemote(id, { url, title, device, conversation = '' }) {
370
- await this.db.set(`${await this.base()}/remote/${id}`, { url, title, device, conversation, started: Date.now(), updated: Date.now() })
479
+ * control it): its link, title, machine; gone when sharing stops. Written again every
480
+ * REMOTE_BEAT while it is hosted (`started` kept): `updated` says it is live. */
481
+ async putRemote(id, { url, title, device, conversation = '', started = Date.now() }) {
482
+ await this.db.set(`${await this.base()}/remote/${id}`, { url, title, device, conversation, started, updated: Date.now() })
371
483
  }
372
484
 
373
485
  async dropRemote(id) { await this.db.delete(`${await this.base()}/remote/${id}`) }
374
486
 
487
+ /** The shared conversations listed with the account: [{ id, url, title, device, conversation, … }]. */
488
+ async listRemote() { return (await this.db.list(`${await this.base()}/remote`)).map((d) => ({ ...d.data, id: d.id })) }
489
+
375
490
  /** One kind of what is kept, deleted from every machine's cloud copy (its switch turned off):
376
491
  * 'conversations' (with their chunks), 'memory' (your rules and each project's), 'settings'
377
492
  * (preferences) or 'usage'. Returns how many records went. */
@@ -380,6 +495,8 @@ class Cloud {
380
495
  const writes = []
381
496
  let kept = null
382
497
  if (kind === 'conversations' || kind === 'memory') {
498
+ // A save writes the project and a summary before any chunk (pushConversation), so what a
499
+ // cut-short save left is listed here, with its chunks.
383
500
  for (const p of await this.db.list(`${base}/projects`)) {
384
501
  if (kind === 'memory') { writes.push({ delete: `${base}/projects/${p.id}/memory/current` }); continue }
385
502
  for (const c of await this.db.list(`${base}/projects/${p.id}/conversations`)) {
@@ -413,10 +530,29 @@ class Cloud {
413
530
  await this.db.set(`${await this.base()}/settings/preferences`, { values: { gpuServer }, updated: Date.now(), device: this.device.id })
414
531
  }
415
532
 
416
- async deleteEverything() {
533
+ /**
534
+ * Everything kept with the account, deleted (the local files stay). The "cleared" marker goes
535
+ * FIRST, with the server choice, in one commit: it used to be written last, so a delete cut
536
+ * between its commits or in the Storage step left no marker, and another machine's bringUp
537
+ * carried the deleted conversations straight back (1.20.0). Each step is safe to run again.
538
+ */
539
+ async deleteEverything({ byServer = false } = {}) {
417
540
  const base = await this.base()
418
541
  const writes = []
419
542
  const server = await this.serverChoice()
543
+ const cleared = Date.now()
544
+ // What was saved here in the last few seconds and is still scheduled is from before the delete:
545
+ // its push came after it and put the conversation back — the marker only stops bringUp (1.20.0).
546
+ // Cancelled now, and a push already on its way stops at pushPending.
547
+ this.reset()
548
+ this.clearedHere = cleared
549
+ // The preferences are replaced by the server choice alone, never deleted then put back.
550
+ await this.db.batch([{ set: [base, { cleared }] }, server
551
+ ? { set: [`${base}/settings/preferences`, { values: { gpuServer: server }, updated: cleared, device: this.device.id }] }
552
+ : { delete: `${base}/settings/preferences` }])
553
+ // The server deletes the rest when it is asked, and carries on if this app goes (keeper.py): it
554
+ // also reaches what a signed-in app cannot list. The marker above already stops every device.
555
+ if (byServer) { this.pushed.clear(); return 0 }
420
556
  for (const p of await this.db.list(`${base}/projects`)) {
421
557
  for (const c of await this.db.list(`${base}/projects/${p.id}/conversations`)) {
422
558
  // Every chunk there is, not as many as the summary says: one left by a save cut short stays otherwise.
@@ -428,16 +564,13 @@ class Cloud {
428
564
  writes.push({ delete: `${base}/projects/${p.id}/memory/current` }, { delete: `${base}/projects/${p.id}` })
429
565
  }
430
566
  for (const col of ['usage', 'devices', 'renders', 'remote']) for (const d of await this.db.list(`${base}/${col}`)) writes.push({ delete: `${base}/${col}/${d.id}` })
431
- writes.push({ delete: `${base}/memory/rules` }, { delete: `${base}/settings/preferences` }, { delete: base })
567
+ writes.push({ delete: `${base}/memory/rules` })
568
+ this.pushed.clear()
432
569
  await this.db.batch(writes)
433
570
  // The media kept with the account (pictures, clips, attachments) go too.
434
571
  if (this.storage) for (const o of await this.storage.list(`${base}/media/`)) { await this.storage.delete(o.name); writes.push(o) }
435
- // The moment it was cleared: no app carries back up what it still holds from before.
436
- await this.db.set(base, { cleared: Date.now() })
437
- if (server) await this.keepServerChoice(server)
438
- this.pushed.clear()
439
- return writes.length
572
+ return writes.length + 2
440
573
  }
441
574
  }
442
575
 
443
- module.exports = { Cloud, projectOf, normalizeRemote, pack, unpack, PREFERENCES, CHUNK, MAX_CHUNKS }
576
+ module.exports = { Cloud, projectOf, normalizeRemote, pack, unpack, remoteLive, PREFERENCES, CHUNK, MAX_CHUNKS, MAX_WAIT, REMOTE_BEAT, REMOTE_FRESH }
package/ext/dialogs.js CHANGED
@@ -750,10 +750,10 @@ async function sendReport(server, body) {
750
750
  const response = await fetch(`${server}/api/feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
751
751
  body: JSON.stringify(body), signal: controller.signal })
752
752
  const data = await response.json().catch(() => ({}))
753
- if (!response.ok) throw new Error(data.detail || `the server answered ${response.status}`)
753
+ if (!response.ok) throw Object.assign(new Error(data.detail || `the server answered ${response.status}`), { status: response.status })
754
754
  return data.id
755
755
  } catch (error) {
756
- throw new Error(error.name === 'AbortError' ? 'the server did not answer in 10 s' : error.message, { cause: error })
756
+ throw Object.assign(new Error(error.name === 'AbortError' ? 'the server did not answer in 10 s' : error.message, { cause: error }), { status: error.status })
757
757
  } finally { clearTimeout(timer) }
758
758
  }
759
759
 
@@ -767,12 +767,16 @@ function reportHandler({ server, version, send = sendReport }) {
767
767
  if (!description) throw new Error('say what happened')
768
768
  const kind = f.kind === 'feedback' ? 'feedback' : 'bug'
769
769
  saved = feedback.save({ kind, description, versions: { extension: version, vscode: vscode.version },
770
- settings: settingsSnapshot(), log: log.tail() })
770
+ settings: settingsSnapshot(), log: log.tail(), send: f.send !== 'local' })
771
771
  log.info(`problem report saved: ${saved.file}`)
772
772
  let sent = 'Kept only on this machine.'
773
773
  if (f.send !== 'local') {
774
- try { sentId = await send(server, saved.body); sent = `Sent to your AIPictury server as ${sentId}.` } catch (error) {
775
- sent = `Not sent: ${error.message}. It is saved here — send it later from this folder.`
774
+ try {
775
+ sentId = await feedback.sendOne(server, saved.file, saved.body, send) // never twice with the outbox's retry
776
+ sent = `Sent to your AIPictury server as ${sentId}.`
777
+ } catch (error) {
778
+ // Not lost: it waits in the reports folder and goes by itself once the server answers.
779
+ sent = `Not sent yet: ${error.message}. It is saved here, and sent by itself once your server answers.`
776
780
  log.warn('problem report not sent', error)
777
781
  }
778
782
  }