@davait/aipictury 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE.md +77 -0
  2. package/README.md +39 -0
  3. package/ext/about.js +209 -0
  4. package/ext/account.js +242 -0
  5. package/ext/accountUi.js +287 -0
  6. package/ext/agent.js +1929 -0
  7. package/ext/browser.js +263 -0
  8. package/ext/cloud.js +385 -0
  9. package/ext/context.js +241 -0
  10. package/ext/deps.js +142 -0
  11. package/ext/dialogs.js +803 -0
  12. package/ext/diff.js +150 -0
  13. package/ext/extension.js +2483 -0
  14. package/ext/feedback.js +55 -0
  15. package/ext/files.js +565 -0
  16. package/ext/firestore.js +131 -0
  17. package/ext/gpuRun.js +77 -0
  18. package/ext/gpuServer.js +76 -0
  19. package/ext/hooks.js +153 -0
  20. package/ext/log.js +29 -0
  21. package/ext/lsp.js +122 -0
  22. package/ext/mcp.js +469 -0
  23. package/ext/mcpAuth.js +128 -0
  24. package/ext/media/vendor/firebase-config.json +9 -0
  25. package/ext/media/vendor/signin.js +1 -0
  26. package/ext/mediagen.js +174 -0
  27. package/ext/memory.js +117 -0
  28. package/ext/notebook.js +82 -0
  29. package/ext/package.json +1234 -0
  30. package/ext/paths.js +50 -0
  31. package/ext/permissions.js +229 -0
  32. package/ext/plans.js +110 -0
  33. package/ext/plugins.js +243 -0
  34. package/ext/prefs.js +157 -0
  35. package/ext/remote.js +170 -0
  36. package/ext/review.js +126 -0
  37. package/ext/sandbox.js +79 -0
  38. package/ext/scheduler.js +87 -0
  39. package/ext/sessions.js +228 -0
  40. package/ext/sessionsView.js +449 -0
  41. package/ext/shell.js +357 -0
  42. package/ext/storage.js +80 -0
  43. package/ext/tasklist.js +112 -0
  44. package/ext/text.js +31 -0
  45. package/ext/tools.js +781 -0
  46. package/ext/usage.js +77 -0
  47. package/ext/voice.js +135 -0
  48. package/ext/workbench.js +162 -0
  49. package/ext/workflow.js +51 -0
  50. package/ext/worktree.js +83 -0
  51. package/main.js +758 -0
  52. package/package.json +43 -0
  53. package/terminal.js +124 -0
  54. package/vscode-shim.js +231 -0
package/ext/browser.js ADDED
@@ -0,0 +1,263 @@
1
+ 'use strict'
2
+ // A browser for the agent, as Claude Code's Chrome integration: it opens pages, reads them
3
+ // (an accessibility snapshot whose elements carry refs — "[ref=e12]" — to act on), clicks,
4
+ // types, takes screenshots for the vision model, reads the console. It drives the user's own
5
+ // Chrome (or Edge, or Chromium) through Playwright, in a profile of its own
6
+ // (~/.aipictury/browser) so sign-ins persist and the user's everyday profile is never touched;
7
+ // the window is visible unless aipictury.browser.headless says otherwise.
8
+ // No VS Code here: the agent decides what is asked, this only does it.
9
+ const fs = require('fs')
10
+ const path = require('path')
11
+ const os = require('os')
12
+ const { home } = require('./sessions')
13
+
14
+ // What only looks, and what acts on a site (asked per site, not in Plan mode).
15
+ const LOOKS = new Set(['snapshot', 'screenshot', 'console', 'tabs', 'wait'])
16
+ const ACTS = new Set(['navigate', 'back', 'click', 'type', 'press', 'select', 'hover', 'evaluate', 'new_tab', 'close_tab', 'close'])
17
+ const ACTIONS = [...LOOKS, ...ACTS]
18
+ const SNAPSHOT_CAP = 24_000 // characters of snapshot sent to the model
19
+ const CONSOLE_KEEP = 200
20
+
21
+ const state = { context: null, page: null, console: [], launching: null, channel: null, tmp: null }
22
+
23
+ /** The site a call is about: the page it goes to, or the page it acts on. */
24
+ function targetUrl(args) {
25
+ if (args.action === 'navigate' || args.action === 'new_tab') return String(args.url || '')
26
+ try { return state.page && !state.page.isClosed() ? state.page.url() : '' } catch { return '' }
27
+ }
28
+
29
+ /** Sites the agent may use without asking: the user's own machine. */
30
+ function isLocal(url) {
31
+ try {
32
+ const { hostname, protocol } = new URL(url)
33
+ if (protocol === 'file:' || protocol === 'about:' || protocol === 'data:') return true
34
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname.endsWith('.localhost')
35
+ } catch { return false }
36
+ }
37
+
38
+ function hostOf(url) { try { return new URL(url).hostname.toLowerCase() } catch { return '' } }
39
+
40
+ function profileDir() { return path.join(home(), 'browser') }
41
+
42
+ /** Where Chrome, Edge or Chromium is installed here, as Playwright launches it: { channel } for
43
+ * Chrome and Edge, { executablePath } for a Chromium. */
44
+ function installs(platform = process.platform, env = process.env) {
45
+ const local = env.LOCALAPPDATA || ''
46
+ const pf = env.PROGRAMFILES || 'C:\\Program Files'
47
+ const pf86 = env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)'
48
+ const at = {
49
+ darwin: [['chrome', '/Applications/Google Chrome.app'], ['chrome', path.join(os.homedir(), 'Applications/Google Chrome.app')],
50
+ ['msedge', '/Applications/Microsoft Edge.app'], ['chromium', '/Applications/Chromium.app/Contents/MacOS/Chromium']],
51
+ linux: [['chrome', '/opt/google/chrome/chrome'], ['msedge', '/opt/microsoft/msedge/msedge'],
52
+ ['chromium', '/usr/bin/chromium'], ['chromium', '/usr/bin/chromium-browser'], ['chromium', '/snap/bin/chromium']],
53
+ win32: [['chrome', path.join(pf, 'Google/Chrome/Application/chrome.exe')], ['chrome', path.join(pf86, 'Google/Chrome/Application/chrome.exe')],
54
+ ['chrome', path.join(local, 'Google/Chrome/Application/chrome.exe')], ['msedge', path.join(pf86, 'Microsoft/Edge/Application/msedge.exe')],
55
+ ['msedge', path.join(pf, 'Microsoft/Edge/Application/msedge.exe')]],
56
+ }[platform] || []
57
+ return at.filter(([, p]) => p && fs.existsSync(p)).map(([kind, p]) => (kind === 'chromium' ? { kind, executablePath: p } : { kind, channel: kind }))
58
+ }
59
+
60
+ let found
61
+ /** Is there a browser to drive (and Playwright to drive it)? Looked up once. */
62
+ function available() {
63
+ if (found === undefined) {
64
+ let playwright = true
65
+ try { require.resolve('playwright-core') } catch { playwright = false }
66
+ found = playwright ? installs() : []
67
+ }
68
+ return found.length > 0
69
+ }
70
+
71
+ function track(page) {
72
+ const push = (line) => { state.console.push(line); if (state.console.length > CONSOLE_KEEP) state.console.shift() }
73
+ page.on('console', (m) => push(`${m.type()}: ${m.text()}`))
74
+ page.on('pageerror', (e) => push(`error: ${e.message}`))
75
+ }
76
+
77
+ /** The browser, opened on first use: the first channel found of Chrome, Edge, Chromium. */
78
+ async function open({ headless = false, prefer = 'auto' } = {}) {
79
+ if (state.context) return state.page && !state.page.isClosed() ? state.page : newPage()
80
+ if (state.launching) return state.launching
81
+ state.launching = (async () => {
82
+ let chromium
83
+ try { ({ chromium } = require('playwright-core')) } catch { throw new Error('the browser needs Playwright, which is missing from this install of the extension') }
84
+ fs.mkdirSync(profileDir(), { recursive: true })
85
+ // Chrome on macOS drops a file of its own into the temp folder on each start; its temp
86
+ // files go to a folder of this browser instead, removed when it closes.
87
+ state.tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aipictury-browser-'))
88
+ const errors = []
89
+ const all = installs()
90
+ const order = prefer === 'auto' ? all : [...all.filter((b) => b.kind === prefer), ...all.filter((b) => b.kind !== prefer)]
91
+ for (const b of order) {
92
+ try {
93
+ state.context = await chromium.launchPersistentContext(profileDir(), { channel: b.channel, executablePath: b.executablePath, headless,
94
+ viewport: { width: 1280, height: 860 }, env: { ...process.env, MAC_CHROMIUM_TMPDIR: state.tmp } })
95
+ state.channel = b.kind
96
+ break
97
+ } catch (error) { errors.push(`${b.kind}: ${String(error.message).split('\n')[0]}`) }
98
+ }
99
+ if (!state.context) {
100
+ fs.rmSync(state.tmp, { recursive: true, force: true })
101
+ throw new Error(`no browser could be opened — install Google Chrome or Microsoft Edge${errors.length ? ` (${errors.join('; ')})` : ''}`)
102
+ }
103
+ state.context.on('close', () => reset())
104
+ state.context.on('page', (p) => { track(p); state.page = p }) // a link that opens a tab: work there
105
+ for (const p of state.context.pages()) track(p)
106
+ state.page = state.context.pages()[0] || await state.context.newPage()
107
+ return state.page
108
+ })()
109
+ try { return await state.launching } finally { state.launching = null }
110
+ }
111
+
112
+ async function newPage() {
113
+ state.page = await state.context.newPage()
114
+ return state.page
115
+ }
116
+
117
+ function reset() {
118
+ if (state.tmp) fs.rmSync(state.tmp, { recursive: true, force: true })
119
+ Object.assign(state, { context: null, page: null, console: [], launching: null, channel: null, tmp: null })
120
+ }
121
+
122
+ async function close() {
123
+ const context = state.context
124
+ if (context) await context.close().catch(() => {})
125
+ reset()
126
+ }
127
+
128
+ /** The page as the model reads it: title, address, and its snapshot with refs. */
129
+ async function snapshot(page) {
130
+ await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {})
131
+ let tree = await page.ariaSnapshot({ mode: 'ai', timeout: 15_000 })
132
+ if (tree.length > SNAPSHOT_CAP) tree = `${tree.slice(0, SNAPSHOT_CAP)}\n… (the page goes on — act on what is shown, or scroll with press "PageDown")`
133
+ const url = page.url()
134
+ return `Page: ${await page.title()} — ${url.length > 200 ? `${url.slice(0, 200)}…` : url}\n\n${tree}`
135
+ }
136
+
137
+ function locate(page, args) {
138
+ const ref = String(args.ref || '').replace(/^\[?ref=/, '').replace(/\]$/, '').trim()
139
+ if (!ref) throw new Error(`${args.action} needs the ref of an element from the snapshot (e.g. "e12")`)
140
+ return page.locator(`aria-ref=${ref}`)
141
+ }
142
+
143
+ /** After an action: the page settles a moment, and the model gets the page as it is now. */
144
+ async function settled(page, said) {
145
+ await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => {})
146
+ await page.waitForTimeout(300)
147
+ const current = state.page && !state.page.isClosed() ? state.page : page
148
+ return `${said}\n\n${await snapshot(current)}`
149
+ }
150
+
151
+ /**
152
+ * One browser action. Resolves to text for the model, or { text, images } for a screenshot.
153
+ * `options`: { headless, prefer: 'auto' | 'chrome' | 'msedge' | 'chromium' }.
154
+ */
155
+ async function run(args, options = {}) {
156
+ const action = String(args.action || '')
157
+ if (!ACTIONS.includes(action)) throw new Error(`unknown action "${action}" — one of: ${ACTIONS.join(', ')}`)
158
+ if (action === 'close') { await close(); return 'The browser is closed.' }
159
+ const page = await open(options)
160
+ const timeout = 15_000
161
+ switch (action) {
162
+ case 'navigate': {
163
+ const url = String(args.url || '').trim()
164
+ if (!/^(https?|file|about|data):/i.test(url)) throw new Error('navigate needs a whole address, e.g. https://example.com')
165
+ const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 })
166
+ return `${response && !response.ok() ? `The server answered ${response.status()}.\n\n` : ''}${await snapshot(page)}`
167
+ }
168
+ case 'back': await page.goBack({ waitUntil: 'domcontentloaded', timeout }); return snapshot(page)
169
+ case 'snapshot': return snapshot(page)
170
+ case 'click': {
171
+ await locate(page, args).click({ timeout, button: args.button === 'right' ? 'right' : 'left', clickCount: args.double ? 2 : 1 })
172
+ return settled(page, `Clicked ${args.element || args.ref}.`)
173
+ }
174
+ case 'hover': await locate(page, args).hover({ timeout }); return settled(page, `Hovering over ${args.element || args.ref}.`)
175
+ case 'type': {
176
+ const target = locate(page, args)
177
+ await target.fill(String(args.text ?? ''), { timeout })
178
+ if (args.submit) await target.press('Enter', { timeout })
179
+ return settled(page, `Typed into ${args.element || args.ref}${args.submit ? ' and pressed Enter' : ''}.`)
180
+ }
181
+ case 'select': {
182
+ const values = Array.isArray(args.values) ? args.values.map(String) : [String(args.values ?? args.text ?? '')]
183
+ await locate(page, args).selectOption(values, { timeout })
184
+ return settled(page, `Selected ${values.join(', ')} in ${args.element || args.ref}.`)
185
+ }
186
+ case 'press': {
187
+ const key = String(args.key || '').trim()
188
+ if (!key) throw new Error('press needs a key, e.g. "Enter", "PageDown", "Control+A"')
189
+ await page.keyboard.press(key)
190
+ return settled(page, `Pressed ${key}.`)
191
+ }
192
+ case 'wait': {
193
+ if (args.text) {
194
+ await page.getByText(String(args.text)).first().waitFor({ timeout: Math.min(60, Number(args.seconds) || 20) * 1000 })
195
+ return `"${args.text}" is on the page.\n\n${await snapshot(page)}`
196
+ }
197
+ await page.waitForTimeout(Math.max(0.1, Math.min(30, Number(args.seconds) || 2)) * 1000)
198
+ return snapshot(page)
199
+ }
200
+ case 'screenshot': {
201
+ const png = await (args.ref ? locate(page, args).screenshot({ timeout }) : page.screenshot({ fullPage: !!args.full_page, timeout }))
202
+ return { text: `A screenshot of ${args.ref ? args.element || args.ref : `${await page.title()} — ${page.url()}`} (${png.length} bytes) — look at it.`,
203
+ images: [png.toString('base64')] }
204
+ }
205
+ case 'console': {
206
+ const lines = state.console.slice(-Math.max(1, Math.min(CONSOLE_KEEP, Number(args.limit) || 50)))
207
+ return lines.length ? `The console (oldest first):\n${lines.join('\n')}` : 'The console is empty.'
208
+ }
209
+ case 'evaluate': {
210
+ const script = String(args.script || '').trim()
211
+ if (!script) throw new Error('evaluate needs a script: a JavaScript expression, e.g. "document.title"')
212
+ const value = await page.evaluate(script)
213
+ const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
214
+ return `Result: ${String(text ?? 'undefined').slice(0, 8000)}`
215
+ }
216
+ case 'tabs': {
217
+ const pages = state.context.pages()
218
+ if (args.index !== undefined) {
219
+ const chosen = pages[Number(args.index)]
220
+ if (!chosen) throw new Error(`there is no tab ${args.index} (there are ${pages.length})`)
221
+ state.page = chosen
222
+ await chosen.bringToFront()
223
+ return snapshot(chosen)
224
+ }
225
+ const titles = await Promise.all(pages.map(async (p, i) => `${i}${p === state.page ? ' (current)' : ''}: ${await p.title().catch(() => '')} — ${p.url()}`))
226
+ return `Tabs:\n${titles.join('\n')}`
227
+ }
228
+ case 'new_tab': {
229
+ const fresh = await newPage()
230
+ if (args.url) await fresh.goto(String(args.url), { waitUntil: 'domcontentloaded', timeout: 30_000 })
231
+ return snapshot(fresh)
232
+ }
233
+ case 'close_tab': {
234
+ await page.close()
235
+ const rest = state.context.pages()
236
+ state.page = rest.at(-1) || await newPage()
237
+ return `Tab closed.\n\n${await snapshot(state.page)}`
238
+ }
239
+ default: throw new Error(`unknown action "${action}"`)
240
+ }
241
+ }
242
+
243
+ /** One line for the panel's row. */
244
+ function describe(args) {
245
+ const a = args.action
246
+ if (a === 'navigate' || a === 'new_tab') return `${a} ${args.url || ''}`.trim()
247
+ if (a === 'type') return `type into ${args.element || args.ref}`
248
+ if (a === 'press') return `press ${args.key || ''}`
249
+ if (['click', 'hover', 'select'].includes(a)) return `${a} ${args.element || args.ref || ''}`
250
+ return a || ''
251
+ }
252
+
253
+ /** A browser action that is always asked, whatever the mode: what goes into a password,
254
+ * card or similar field — the agent never enters them unasked. Returns why, or null. */
255
+ function sensitive(args) {
256
+ if (args.action !== 'type' && args.action !== 'select') return null
257
+ const about = `${args.element || ''} ${args.ref || ''}`
258
+ if (/pass(word|code|phrase)|contraseña|\bpin\b|one.?time|\botp\b|2fa|mfa|security code/i.test(about)) return 'it would type into a password or security-code field'
259
+ if (/card|cvv|cvc|iban|account number|routing|expir|ssn|social security|tax id/i.test(about)) return 'it would enter payment or identity details'
260
+ return null
261
+ }
262
+
263
+ module.exports = { run, close, open, targetUrl, isLocal, hostOf, describe, sensitive, available, installs, LOOKS, ACTS, ACTIONS, state, profileDir }
package/ext/cloud.js ADDED
@@ -0,0 +1,385 @@
1
+ 'use strict'
2
+ // The cloud copy of what is yours, in Firestore under users/{uid} (docs/accounts-plan.md): the
3
+ // projects and their conversations, project memory, your rules, your preferences, each day's
4
+ // usage. Local-first: the editor and the CLI work from their own files and keep this copy in
5
+ // step — a conversation is pushed when its content changed, conversations from another machine
6
+ // are listed and downloaded when opened. Shared by the extension and the CLI; no VS Code here.
7
+ const crypto = require('crypto')
8
+ const zlib = require('zlib')
9
+ const path = require('path')
10
+ const { execFile } = require('child_process')
11
+ const { Firestore } = require('./firestore')
12
+ const { Storage } = require('./storage')
13
+
14
+ const CHUNK = 700_000 // characters of base64 a chunk (the rules allow 900,000)
15
+ const MAX_CHUNKS = 64 // ~45 MB of base64: a conversation larger than that stays local
16
+ // Preferences that follow you. Nothing else is sent: never env, headers, tokens or MCP config.
17
+ const PREFERENCES = ['effort', 'approval', 'showThinking', 'focusView', 'autoMemory', 'notifications',
18
+ 'useCtrlEnterToSend', 'browser.headless', 'outputStyle', 'skillVisibility', 'enabledPlugins', 'permissions']
19
+
20
+ const sha = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 40)
21
+
22
+ /** A git remote as one string for the same repository everywhere: no scheme, user, credentials or .git. */
23
+ function normalizeRemote(url) {
24
+ let u = String(url || '').trim()
25
+ if (!u) return ''
26
+ u = u.replace(/^[a-z+]+:\/\//i, '') // https://, ssh://, git+ssh://
27
+ .replace(/^[^@/]*@/, '') // user@ or user:token@
28
+ .replace(/^([^/:]+):(?!\d+\/)/, '$1/') // git@host:owner/repo → host/owner/repo
29
+ .replace(/:\d+\//, '/') // a port
30
+ .replace(/\.git\/?$/, '').replace(/\/+$/, '')
31
+ const [host, ...rest] = u.split('/')
32
+ return [host.toLowerCase(), ...rest].join('/')
33
+ }
34
+
35
+ function gitRemote(folder) {
36
+ return new Promise((resolve) => {
37
+ execFile('git', ['config', '--get', 'remote.origin.url'], { cwd: folder, timeout: 5000 }, (error, out) => resolve(error ? '' : out.trim()))
38
+ })
39
+ }
40
+
41
+ /** The project as the cloud knows it: { id, name, remote }. The same id on every machine for a git repository. */
42
+ async function projectOf(folder) {
43
+ const remote = normalizeRemote(await gitRemote(folder))
44
+ const name = path.basename(folder || 'no-folder')
45
+ const id = remote ? `g-${sha(remote).slice(0, 24)}` : `l-${sha(path.resolve(folder || '/')).slice(0, 24)}`
46
+ return { id, name: remote ? remote.split('/').pop() : name, remote }
47
+ }
48
+
49
+ function pack(value) {
50
+ const json = JSON.stringify(value)
51
+ const data = zlib.gzipSync(json, { level: 9 }).toString('base64')
52
+ const chunks = []
53
+ for (let i = 0; i < data.length; i += CHUNK) chunks.push(data.slice(i, i + CHUNK))
54
+ return { hash: sha(json), chunks, bytes: json.length }
55
+ }
56
+
57
+ function unpack(chunks) {
58
+ return JSON.parse(zlib.gunzipSync(Buffer.from(chunks.join(''), 'base64')).toString('utf8'))
59
+ }
60
+
61
+ /** The GPU server compresses a clip to keep (media.compact): its progress through onStage, then
62
+ * the figures — { original, size, saving, ssim, seconds, kept, why }. */
63
+ async function compactOn(server, assetId, onStage) {
64
+ const r = await fetch(`${server}/api/assets/${assetId}/compact`, { method: 'POST', signal: AbortSignal.timeout(1_800_000) })
65
+ if (!r.ok) throw new Error(`the server could not compress it (${r.status})`)
66
+ let buffer = ''
67
+ let stats = null
68
+ for await (const chunk of r.body) {
69
+ buffer += Buffer.from(chunk).toString('utf8')
70
+ let at
71
+ while ((at = buffer.indexOf('\n')) >= 0) {
72
+ const line = buffer.slice(0, at).trim()
73
+ buffer = buffer.slice(at + 1)
74
+ if (!line) continue
75
+ const event = JSON.parse(line)
76
+ if (event.error) throw new Error(event.error)
77
+ if (event.phase === 'compress') onStage({ stage: 'compress', progress: event.progress })
78
+ if (event.done) stats = { original: event.original, size: event.size, saving: event.saving, ssim: event.ssim, seconds: event.seconds, kept: event.kept, why: event.why || '' }
79
+ }
80
+ }
81
+ if (!stats) throw new Error('the server stopped before it was compressed')
82
+ return stats
83
+ }
84
+
85
+ class Cloud {
86
+ /**
87
+ * `account`: account.js (who, and ID tokens). `device`: { id, name, app, platform, version }.
88
+ * `emulator`: "host:port" of the Firestore emulator (tests).
89
+ */
90
+ constructor({ account, projectId, device, emulator = null, bucket = null, storageEmulator = null, log = () => {}, fetchImpl }) {
91
+ this.account = account
92
+ this.device = device
93
+ this.log = log
94
+ this.db = new Firestore({ projectId, emulator, getToken: () => account.idToken(), fetchImpl })
95
+ this.storage = bucket ? new Storage({ bucket, emulator: storageEmulator, getToken: () => account.idToken(), fetchImpl }) : null
96
+ this.pushed = new Map() // conversation path → hash last pushed (in this process)
97
+ this.timers = new Map()
98
+ // Signing out, or another account signing in, leaves nothing of the last session behind.
99
+ this.subscription = account.onChange?.(() => this.reset())
100
+ }
101
+
102
+ /** On sign-out (or another account signing in): nothing of the last session stays in memory —
103
+ * scheduled pushes are cancelled, what was pushed is forgotten. */
104
+ reset() {
105
+ for (const t of this.timers.values()) clearTimeout(t)
106
+ this.timers.clear()
107
+ this.pushed.clear()
108
+ }
109
+
110
+ async uid() { return (await this.account.user())?.uid || null }
111
+ async base() {
112
+ const uid = await this.uid()
113
+ if (!uid) throw new Error('not signed in')
114
+ return `users/${uid}`
115
+ }
116
+
117
+ /** Says who this is and which device: called after signing in and now and then. */
118
+ async hello() {
119
+ const user = await this.account.user()
120
+ if (!user) return
121
+ const base = `users/${user.uid}`
122
+ const now = Date.now()
123
+ const existing = await this.db.get(base)
124
+ await this.db.set(base, { email: user.email || '', displayName: user.displayName || '', photoURL: user.photoURL || '',
125
+ created: existing?.created || now, lastSeen: now })
126
+ await this.db.set(`${base}/devices/${this.device.id}`, { name: this.device.name, app: this.device.app,
127
+ platform: this.device.platform, version: this.device.version, lastSeen: now })
128
+ }
129
+
130
+ // ---- conversations ----
131
+
132
+ async projectPath(folder) {
133
+ const p = await projectOf(folder)
134
+ return { ...p, path: `${await this.base()}/projects/${p.id}` }
135
+ }
136
+
137
+ /** Pushes one conversation (the entry the session store saves), when its content changed. */
138
+ async pushConversation(folder, entry) {
139
+ const project = await this.projectPath(folder)
140
+ const doc = `${project.path}/conversations/${entry.id}`
141
+ const content = { history: entry.history || [], transcript: entry.transcript || [], todos: entry.todos || [],
142
+ jobs: entry.jobs, btw: entry.btw }
143
+ const packed = pack(content)
144
+ if (packed.chunks.length > MAX_CHUNKS) { this.log(`cloud: ${entry.id} is too large to keep in the cloud (${packed.bytes} bytes)`); return 'too-large' }
145
+ // Two hashes: the content's (its chunks), and the whole one (with the title, group, archived),
146
+ // so renaming or archiving rewrites the summary only.
147
+ const summary = { title: String(entry.title || 'Conversation').slice(0, 300), renamed: !!entry.renamed,
148
+ aiTitle: entry.aiTitle ? String(entry.aiTitle).slice(0, 300) : '', group: entry.group || '', archived: !!entry.archived }
149
+ const whole = sha(packed.hash + JSON.stringify(summary))
150
+ if (this.pushed.get(doc) === whole) return 'unchanged'
151
+ const before = await this.db.get(doc)
152
+ if (before?.hash === whole) { this.pushed.set(doc, whole); return 'unchanged' }
153
+ const stale = []
154
+ if (before?.contentHash !== packed.hash) {
155
+ // The chunks first, the summary last: a reader that sees the summary finds every chunk.
156
+ for (const [i, data] of packed.chunks.entries()) await this.db.set(`${doc}/chunks/${i}`, { data, hash: packed.hash })
157
+ for (let i = packed.chunks.length; i < (before?.chunks || 0); i += 1) stale.push({ delete: `${doc}/chunks/${i}` })
158
+ }
159
+ await this.db.batch([...stale, { set: [doc, { ...summary,
160
+ created: entry.created || Date.now(), updated: entry.updated || Date.now(), model: entry.model || '',
161
+ turns: entry.usage?.turns || 0, usage: entry.usage || {},
162
+ origin: entry.origin || this.device.app, device: this.device.id, hash: whole, contentHash: packed.hash, chunks: packed.chunks.length, bytes: packed.bytes,
163
+ }] }])
164
+ await this.touchProject(project, folder)
165
+ this.pushed.set(doc, whole)
166
+ return before?.contentHash === packed.hash ? 'summary' : 'pushed'
167
+ }
168
+
169
+ async touchProject(project, folder) {
170
+ const current = await this.db.get(project.path)
171
+ const paths = [...new Set([...(current?.paths || []), path.resolve(folder)])].slice(-50)
172
+ await this.db.set(project.path, { name: project.name, remote: project.remote, paths, updated: Date.now(),
173
+ conversations: current?.conversations ?? 0 })
174
+ }
175
+
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) {
178
+ // The push belongs to the account signed in now: if another one is by the time it runs, it is dropped.
179
+ const owner = await this.uid()
180
+ if (!owner) return
181
+ clearTimeout(this.timers.get(entry.id))
182
+ const timer = setTimeout(async () => {
183
+ 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)
187
+ timer.unref?.()
188
+ this.timers.set(entry.id, timer)
189
+ }
190
+
191
+ /** Waits for what is scheduled (on closing). */
192
+ async flush(pending) {
193
+ for (const t of this.timers.values()) clearTimeout(t)
194
+ this.timers.clear()
195
+ for (const [folder, entry] of pending || []) await this.pushConversation(folder, entry).catch(() => {})
196
+ }
197
+
198
+ /** A project's conversations, by its id (the web app's chats are the project "web"). */
199
+ async listProject(projectId) {
200
+ return (await this.db.list(`${await this.base()}/projects/${projectId}/conversations`)).map((d) => ({ id: d.id, ...d.data }))
201
+ .sort((a, b) => (b.updated || 0) - (a.updated || 0))
202
+ }
203
+
204
+ /** One conversation of a project, by the project's id: { meta, history, transcript, … }. */
205
+ async pullFrom(projectId, id) {
206
+ const doc = `${await this.base()}/projects/${projectId}/conversations/${id}`
207
+ const meta = await this.db.get(doc)
208
+ if (!meta) throw new Error('that conversation is not in your history any more')
209
+ const parts = []
210
+ for (let i = 0; i < meta.chunks; i += 1) {
211
+ const c = await this.db.get(`${doc}/chunks/${i}`)
212
+ if (!c || c.hash !== meta.contentHash) throw new Error('it is being saved right now — try again in a moment')
213
+ parts.push(c.data)
214
+ }
215
+ return { meta, ...unpack(parts) }
216
+ }
217
+
218
+ /** The cloud's summary of one of this project's conversations, or null. */
219
+ async summaryOf(folder, id) {
220
+ return this.db.get(`${(await this.projectPath(folder)).path}/conversations/${id}`)
221
+ }
222
+
223
+ /** This project's conversations in the cloud: [{ id, title, updated, origin, device, … }]. */
224
+ async listConversations(folder) {
225
+ const project = await this.projectPath(folder)
226
+ return (await this.db.list(`${project.path}/conversations`)).map((d) => ({ id: d.id, ...d.data }))
227
+ .sort((a, b) => (b.updated || 0) - (a.updated || 0))
228
+ }
229
+
230
+ /** The conversations of `folder` kept on this machine (`store`: sessions.js) that the account
231
+ * lacks or has an older copy of, carried up; how many. `still()`: false stops (signed out). */
232
+ async bringUp(folder, store, { still = () => true } = {}) {
233
+ const remote = new Map((await this.listConversations(folder)).map((c) => [c.id, c]))
234
+ let up = 0
235
+ for (const meta of store.list().slice(0, 500)) {
236
+ if (!still()) break
237
+ const there = remote.get(meta.id)
238
+ if (there && (there.updated || 0) >= (meta.updated || 0)) continue
239
+ const entry = store.get(meta.id)
240
+ if (!entry) continue
241
+ try {
242
+ if (['pushed', 'summary'].includes(await this.pushConversation(folder, entry))) up += 1
243
+ } catch (err) { this.log(`cloud: ${meta.id} not carried up: ${err.message}`) }
244
+ }
245
+ return up
246
+ }
247
+
248
+ /** One conversation from the cloud, as the session store keeps it. */
249
+ async pullConversation(folder, id) {
250
+ const project = await this.projectPath(folder)
251
+ const doc = `${project.path}/conversations/${id}`
252
+ const meta = await this.db.get(doc)
253
+ if (!meta) throw new Error('that conversation is not in the cloud')
254
+ const parts = []
255
+ for (let i = 0; i < meta.chunks; i += 1) {
256
+ const c = await this.db.get(`${doc}/chunks/${i}`)
257
+ if (!c || c.hash !== meta.contentHash) throw new Error('the cloud copy is being written — try again in a moment')
258
+ parts.push(c.data)
259
+ }
260
+ const content = unpack(parts)
261
+ this.pushed.set(doc, meta.hash)
262
+ return { id, title: meta.title, renamed: meta.renamed, aiTitle: meta.aiTitle || undefined, created: meta.created, updated: meta.updated,
263
+ model: meta.model || null, usage: meta.usage, group: meta.group || undefined, archived: meta.archived || undefined, origin: meta.origin, ...content }
264
+ }
265
+
266
+ async deleteConversation(folder, id) {
267
+ const project = await this.projectPath(folder)
268
+ const doc = `${project.path}/conversations/${id}`
269
+ const meta = await this.db.get(doc)
270
+ const writes = []
271
+ for (let i = 0; i < (meta?.chunks || 0); i += 1) writes.push({ delete: `${doc}/chunks/${i}` })
272
+ writes.push({ delete: doc })
273
+ await this.db.batch(writes)
274
+ this.pushed.delete(doc)
275
+ }
276
+
277
+ // ---- memory, rules, preferences, usage ----
278
+
279
+ async getProjectMemory(folder) { return (await this.db.get(`${(await this.projectPath(folder)).path}/memory/current`)) || null }
280
+ async setProjectMemory(folder, text) {
281
+ await this.db.set(`${(await this.projectPath(folder)).path}/memory/current`, { text: String(text).slice(0, 200_000), updated: Date.now(), device: this.device.id })
282
+ }
283
+
284
+ /** Your rules: [{ id, text, created }]. */
285
+ async getRules() { return (await this.db.get(`${await this.base()}/memory/rules`))?.rules || [] }
286
+ async setRules(rules) { await this.db.set(`${await this.base()}/memory/rules`, { rules: rules.slice(0, 500), updated: Date.now() }) }
287
+
288
+ async getPreferences() { return (await this.db.get(`${await this.base()}/settings/preferences`)) || null }
289
+ /** This device's preferences, merged into the account's: what other apps keep there (the web
290
+ * app's GPU server choice) stays. */
291
+ async setPreferences(values) {
292
+ const allowed = Object.fromEntries(Object.entries(values).filter(([k, v]) => PREFERENCES.includes(k) && v !== undefined))
293
+ const path = `${await this.base()}/settings/preferences`
294
+ const current = (await this.db.get(path))?.values || {}
295
+ await this.db.set(path, { values: { ...current, ...allowed }, updated: Date.now(), device: this.device.id })
296
+ }
297
+
298
+ /** Adds one turn to the day's totals. */
299
+ async addUsage(day, turn) {
300
+ const doc = `${await this.base()}/usage/${day}`
301
+ const now = (await this.db.get(doc)) || { turns: 0, prompt: 0, output: 0, genSeconds: 0, tools: {}, models: {} }
302
+ for (const t of turn.tools || []) now.tools[t] = (now.tools[t] || 0) + 1
303
+ for (const m of turn.models || []) now.models[m] = (now.models[m] || 0) + 1
304
+ await this.db.set(doc, { turns: now.turns + 1, prompt: now.prompt + (turn.context || 0), output: now.output + (turn.output || 0),
305
+ genSeconds: Math.round((now.genSeconds + (turn.genSeconds || 0)) * 10) / 10, tools: now.tools, models: now.models, updated: Date.now() })
306
+ }
307
+
308
+ /** Everything of yours in the cloud, deleted. The local files stay. */
309
+ /**
310
+ * A render the GPU server finished (its job), kept with the account as the web app keeps it:
311
+ * each file and a clip's poster in Storage (media/), its entry in renders/. Once per job;
312
+ * false when it was kept already or there is nothing to keep.
313
+ */
314
+ async keepRender(server, jobId, onStage = () => {}) {
315
+ if (!this.storage || !jobId) return false
316
+ const path = `${await this.base()}/renders/${jobId}`
317
+ if (await this.db.get(path)) return false
318
+ const r = await fetch(`${server}/api/jobs/${jobId}`, { signal: AbortSignal.timeout(20_000) })
319
+ if (!r.ok) throw new Error(`the job could not be read (${r.status})`)
320
+ const job = await r.json()
321
+ if (job.status !== 'done') return false
322
+ const media = `${await this.base()}/media`
323
+ const assets = []
324
+ for (const a of (job.assets_detail || []).slice(0, 8)) {
325
+ let stats = null
326
+ let stored
327
+ if (a.kind === 'video') {
328
+ // A clip: compressed on the GPU server first (MP4, H.264, never worse than as made).
329
+ stats = await compactOn(server, a.id, (s) => onStage(a.id, s))
330
+ const file = await fetch(`${server}/api/assets/${a.id}/compact`, { signal: AbortSignal.timeout(300_000) })
331
+ if (!file.ok) throw new Error(`the compressed clip could not be read (${file.status})`)
332
+ stored = `${media}/${a.id}.mp4`
333
+ onStage(a.id, { stage: 'upload', progress: 0 })
334
+ await this.storage.upload(stored, Buffer.from(await file.arrayBuffer()), 'video/mp4')
335
+ onStage(a.id, { stage: 'kept', stats })
336
+ } else {
337
+ const ext = (/\.[A-Za-z0-9]{1,5}$/.exec(a.filename || '') || ['.png'])[0]
338
+ const file = await fetch(`${server}/api/assets/${a.id}`, { signal: AbortSignal.timeout(300_000) })
339
+ if (!file.ok) throw new Error(`its file could not be read (${file.status})`)
340
+ stored = `${media}/${a.id}${ext}`
341
+ await this.storage.upload(stored, Buffer.from(await file.arrayBuffer()), file.headers.get('content-type') || 'image/png')
342
+ }
343
+ let poster = ''
344
+ if (a.kind === 'video') {
345
+ const p = await fetch(`${server}/api/assets/${a.id}/poster`, { signal: AbortSignal.timeout(30_000) }).catch(() => null)
346
+ if (p?.ok) { poster = `${media}/${a.id}-poster.jpg`; await this.storage.upload(poster, Buffer.from(await p.arrayBuffer()), p.headers.get('content-type') || 'image/jpeg') }
347
+ }
348
+ assets.push({ id: a.id, kind: a.kind === 'video' ? 'video' : 'image', path: stored, poster,
349
+ width: Number(a.width) || 0, height: Number(a.height) || 0, duration: Number(a.duration) || 0, ...(stats ? { stats } : {}) })
350
+ }
351
+ if (!assets.length) return false
352
+ await this.db.set(path, { kind: String(job.kind || '').slice(0, 40), prompt: String(job.params?.prompt || '').slice(0, 4000),
353
+ created: Math.round((Number(job.finished_at || job.created_at) || Date.now() / 1000) * 1000), assets, device: this.device.id })
354
+ return true
355
+ }
356
+
357
+ /** A conversation shared by remote control, listed with the account (aipictury.com offers to
358
+ * control it): its link, title, machine; gone when sharing stops. */
359
+ async putRemote(id, { url, title, device, conversation = '' }) {
360
+ await this.db.set(`${await this.base()}/remote/${id}`, { url, title, device, conversation, started: Date.now(), updated: Date.now() })
361
+ }
362
+
363
+ async dropRemote(id) { await this.db.delete(`${await this.base()}/remote/${id}`) }
364
+
365
+ async deleteEverything() {
366
+ const base = await this.base()
367
+ const writes = []
368
+ for (const p of await this.db.list(`${base}/projects`)) {
369
+ for (const c of await this.db.list(`${base}/projects/${p.id}/conversations`)) {
370
+ for (let i = 0; i < (c.data.chunks || 0); i += 1) writes.push({ delete: `${base}/projects/${p.id}/conversations/${c.id}/chunks/${i}` })
371
+ writes.push({ delete: `${base}/projects/${p.id}/conversations/${c.id}` })
372
+ }
373
+ writes.push({ delete: `${base}/projects/${p.id}/memory/current` }, { delete: `${base}/projects/${p.id}` })
374
+ }
375
+ 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}` })
376
+ writes.push({ delete: `${base}/memory/rules` }, { delete: `${base}/settings/preferences` }, { delete: base })
377
+ await this.db.batch(writes)
378
+ // The media kept with the account (pictures, clips, attachments) go too.
379
+ if (this.storage) for (const o of await this.storage.list(`${base}/media/`)) { await this.storage.delete(o.name); writes.push(o) }
380
+ this.pushed.clear()
381
+ return writes.length
382
+ }
383
+ }
384
+
385
+ module.exports = { Cloud, projectOf, normalizeRemote, pack, unpack, PREFERENCES, CHUNK, MAX_CHUNKS }