@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/LICENSE.md ADDED
@@ -0,0 +1,77 @@
1
+ # AIPictury — License
2
+
3
+ Copyright © 2026 Davait, Inc. All rights reserved.
4
+
5
+ AIPictury — the GPU server, the web app at aipictury.com, the AIPictury extension for Visual
6
+ Studio Code and the `aipictury` command-line tool, with their source code, documentation, designs
7
+ and assets (together, the "Software") — is the property of Davait, Inc. ("Davait") and is
8
+ protected by copyright and other intellectual-property laws.
9
+
10
+ ## 1. What you may do
11
+
12
+ Subject to this License and to any other terms Davait agrees with you in writing, Davait grants
13
+ you a personal, non-exclusive, non-transferable, revocable license to install and use the
14
+ Software, in the form Davait distributes it, to use the AIPictury service with your own AIPictury
15
+ account and your own GPU server.
16
+
17
+ ## 2. What you may not do
18
+
19
+ Unless Davait allows it in writing, you may not:
20
+
21
+ - copy, modify, translate or create derivative works of the Software, except as needed to use it
22
+ under section 1;
23
+ - distribute, sublicense, sell, rent, lease, lend, host for others or otherwise make the Software
24
+ available to third parties;
25
+ - reverse-engineer, decompile or disassemble the Software, except where applicable law expressly
26
+ allows it despite this restriction;
27
+ - remove, alter or obscure any copyright, trademark or other notice in the Software;
28
+ - use the Software to build a competing product or service, or in breach of any law or of the
29
+ AIPictury acceptable-use policy.
30
+
31
+ ## 3. Ownership
32
+
33
+ The Software is licensed, not sold. Davait and its licensors keep all right, title and interest
34
+ in it, including every copy and every improvement. "AIPictury" and "Davait" are names and marks of
35
+ Davait, Inc. No right to them is granted beyond naming the Software truthfully.
36
+
37
+ ## 4. Third-party components
38
+
39
+ The Software works with components owned by others — among them the models it runs (for example
40
+ LTX-Video, FLUX, Qwen, Gemma and Whisper), ffmpeg, ComfyUI, Ollama, llama.cpp, Firebase and the
41
+ open-source libraries listed in its package manifests (`package.json`, `requirements.txt`). Each
42
+ is licensed to you by its owner under its own terms, which this License does not change. Your use
43
+ of those components, and of what they produce, is subject to their licenses.
44
+
45
+ ## 5. Your content
46
+
47
+ What you create or upload with the Software — prompts, conversations, pictures, clips and files —
48
+ is yours. Davait keeps it only as needed to provide the service to your account, as described in
49
+ its privacy policy.
50
+
51
+ ## 6. No warranty
52
+
53
+ THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
54
+ IMPLIED, INCLUDING THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND
55
+ NON-INFRINGEMENT. Content produced by models can be wrong, offensive or similar to existing works;
56
+ you are responsible for checking it and for how you use it.
57
+
58
+ ## 7. Limitation of liability
59
+
60
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, DAVAIT AND ITS LICENSORS ARE NOT LIABLE FOR ANY INDIRECT,
61
+ INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, OR FOR ANY LOSS OF DATA, PROFITS, REVENUE
62
+ OR GOODWILL, ARISING OUT OF OR RELATED TO THE SOFTWARE, EVEN IF ADVISED OF THEIR POSSIBILITY.
63
+ THEIR TOTAL LIABILITY IS LIMITED TO THE AMOUNT YOU PAID DAVAIT FOR THE SOFTWARE IN THE TWELVE
64
+ MONTHS BEFORE THE CLAIM.
65
+
66
+ ## 8. Termination
67
+
68
+ This License ends automatically if you breach it. Davait may end it on notice. When it ends you
69
+ must stop using the Software and delete your copies. Sections 2 to 7 and 9 survive it.
70
+
71
+ ## 9. General
72
+
73
+ This License is the whole agreement between you and Davait about the Software, unless another
74
+ written agreement with Davait says otherwise. If a provision is unenforceable, the rest stays in
75
+ force. Davait may update this License; the version shipped with each release applies to it.
76
+
77
+ Questions about this License: Davait, Inc.
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # @davait/aipictury
2
+
3
+ The AIPictury agent in a terminal: the same agent as the AIPictury extension for VS Code — its
4
+ tools, approvals, Plan mode, MCP servers, skills and conversations — on your own GPU server,
5
+ signed in to your AIPictury account.
6
+
7
+ ```
8
+ npm install -g @davait/aipictury
9
+ aipictury login # your AIPictury account, in the browser
10
+ aipictury # a conversation in this folder
11
+ aipictury -p "question" # one answer, printed (pipes and scripts)
12
+ aipictury --help # everything else
13
+ ```
14
+
15
+ Your GPU server is the one chosen with your account at https://aipictury.com (or `--server`).
16
+
17
+ ## In a conversation
18
+
19
+ - `/` for commands — `/help` lists them, Tab completes: `/compact`, `/btw`, `/resume`,
20
+ `/rename`, `/export`, `/todos`, `/model`, `/effort`, `/mode`, `/permissions`, `/mcp`,
21
+ `/status`, `/usage`, `/init`, `/review`, and the project's own commands and skills.
22
+ - `@path` mentions a file (Tab completes it); it is read and sent with the message.
23
+ - `!command` runs it in the folder; its output goes with your next message.
24
+ - A line ending in `\` continues on the next; a paste of many lines is one message.
25
+ - Esc or Ctrl+C stops a run; Ctrl+C twice at the prompt quits. Up and down bring back what you
26
+ typed, across runs.
27
+
28
+ ## Scripts
29
+
30
+ ```
31
+ aipictury -p "why does it fail?" < log.txt
32
+ aipictury -p "…" --output-format json # or stream-json
33
+ aipictury -p "…" --allowedTools "Bash(npm test:*),Edit" --disallowedTools "WebFetch"
34
+ aipictury -p "…" --mcp-config servers.json --add-dir ../shared --append-system-prompt "Be brief."
35
+ ```
36
+
37
+ ## License
38
+
39
+ Copyright © 2026 Davait, Inc. All rights reserved. Proprietary software: see LICENSE.md.
package/ext/about.js ADDED
@@ -0,0 +1,209 @@
1
+ 'use strict'
2
+ // About AIPictury agent: the agent's own version, and the server it is connected to as that
3
+ // server describes itself — every model it holds (type, parameters), what makes pictures
4
+ // and video, and its GPUs. Drawn from the connected server, so it changes when the server
5
+ // does: switching servers redraws it on its own.
6
+
7
+ const vscode = require('vscode')
8
+
9
+ let panel = null
10
+ let lastServer = ''
11
+
12
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
13
+ const gb = (mb) => (mb == null ? '—' : `${(mb / 1024).toFixed(1)} GB`)
14
+
15
+ /** A model as one line: name (type, parameters). */
16
+ function modelLine(m) {
17
+ const kinds = (m.kinds || []).filter((k) => k !== 'text')
18
+ const type = ['text', ...kinds].join(' · ')
19
+ const params = [m.parameters, m.quantization].filter(Boolean).join(', ')
20
+ return `${m.name} (${type}${params ? `, ${params}` : ''})`
21
+ }
22
+
23
+ function render({ server, version, about, error, config, account = {}, access = {} }) {
24
+ const s = about?.server || {}
25
+ const models = about?.models || []
26
+ const roles = about?.roles || {}
27
+ const gpus = about?.gpus || []
28
+ const generation = about?.generation || []
29
+ const roleName = { agent: 'coding agent', edit: 'quick edits', commit: 'commit messages', vision: 'images' }
30
+ const modelRows = models.map((m) => `
31
+ <li class="model${m.roles?.length ? ' used' : ''}">
32
+ <div class="line">${esc(modelLine(m))}</div>
33
+ <div class="sub">${[m.engine && `<b>${esc(m.engine)}</b>`, m.family && `family ${esc(m.family)}`, m.size_gb && `${m.size_gb} GB on disk`,
34
+ m.context && `context ${Number(m.context).toLocaleString()} tokens`,
35
+ m.roles?.length && `<b>${m.roles.map((r) => esc(roleName[r] || r)).join(', ')}</b>`,
36
+ m.same_as?.length && `also named ${esc(m.same_as.join(', '))}`].filter(Boolean).join(' · ')}</div>
37
+ </li>`).join('')
38
+ const genRows = generation.map((g) => `
39
+ <li class="model${g.available ? '' : ' off'}"><div class="line">${esc(g.label)} (${esc(g.kind)}${g.params ? `, ${esc(g.params)}` : ''})</div>
40
+ <div class="sub">${g.available ? 'available' : 'not installed on this server'}</div></li>`).join('')
41
+ const gpuRows = gpus.map((g) => {
42
+ const pct = g.vram_total_mb ? Math.round((100 * (g.vram_used_mb || 0)) / g.vram_total_mb) : 0
43
+ const facts = [
44
+ `VRAM ${gb(g.vram_used_mb)} of ${gb(g.vram_total_mb)} used`,
45
+ g.utilization != null && `${g.utilization}% busy`,
46
+ g.temperature != null && `${g.temperature} °C`,
47
+ g.power_draw != null && `${Math.round(g.power_draw)} W of ${Math.round(g.power_limit)} W`,
48
+ g.driver && `driver ${esc(g.driver)}`, g.pcie && `PCIe ${esc(g.pcie)}`,
49
+ ].filter(Boolean).join(' · ')
50
+ return `<li class="gpu"><div class="line">GPU ${esc(g.index)} — ${esc(g.name)}</div>
51
+ <div class="bar"><span style="width:${pct}%"></span></div><div class="sub">${facts}</div></li>`
52
+ }).join('')
53
+ const online = !!about && !error
54
+ return `<!doctype html><html><head><meta charset="utf-8">
55
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';">
56
+ <style>
57
+ body { font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); color: var(--vscode-foreground);
58
+ background: var(--vscode-editor-background); padding: 18px 22px; max-width: 900px; line-height: 1.5; }
59
+ h1 { font-size: 1.4em; margin: 0 0 2px; } h2 { font-size: 1.05em; margin: 22px 0 6px; }
60
+ .muted, .sub { color: var(--vscode-descriptionForeground); } .sub { font-size: 0.92em; }
61
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; background: var(--vscode-charts-red); }
62
+ .dot.on { background: var(--vscode-charts-green); }
63
+ details { border: 1px solid var(--vscode-panel-border); border-radius: 6px; padding: 4px 10px; margin: 6px 0; }
64
+ summary { cursor: pointer; padding: 4px 0; font-weight: 600; }
65
+ ul { list-style: none; margin: 4px 0; padding: 0; }
66
+ li { padding: 5px 0; border-top: 1px solid var(--vscode-panel-border); } li:first-child { border-top: 0; }
67
+ .line { font-family: var(--vscode-editor-font-family); } li.used .line { font-weight: 600; } li.off { opacity: 0.55; }
68
+ .bar { height: 6px; border-radius: 3px; background: var(--vscode-panel-border); margin: 4px 0; overflow: hidden; }
69
+ .bar span { display: block; height: 100%; background: var(--vscode-progressBar-background, #0e70c0); }
70
+ table { border-collapse: collapse; } td { padding: 2px 14px 2px 0; vertical-align: top; }
71
+ .actions { margin-top: 18px; display: flex; gap: 8px; flex-wrap: wrap; }
72
+ button { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); border: 0;
73
+ border-radius: 4px; padding: 5px 12px; cursor: pointer; } button.primary { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
74
+ .err { color: var(--vscode-errorForeground); }
75
+ </style></head><body>
76
+ <h1>About AIPictury agent</h1>
77
+ <div class="muted">A coding agent on your own GPU.</div>
78
+ <div class="muted" id="copyright">Copyright © 2026 Davait, Inc. All rights reserved.</div>
79
+ <h2>The agent</h2>
80
+ <table>
81
+ <tr><td>Version</td><td><b id="agent-version">${esc(version)}</b></td></tr>
82
+ <tr><td>VS Code</td><td>${esc(vscode.version)}</td></tr>
83
+ <tr><td>Mode · effort</td><td>${esc(config.mode)} · ${esc(config.effort)}</td></tr>
84
+ <tr><td>Model for this editor</td><td>${esc(config.model || roles.agent || '—')}${config.model ? '' : ' (the server\'s agent role)'}</td></tr>
85
+ </table>
86
+ <h2>Your account</h2>
87
+ <table id="account">
88
+ ${account.user ? `<tr><td>Signed in as</td><td><b>${esc(account.user.email || account.user.displayName)}</b></td></tr>
89
+ <tr><td>Kept with it</td><td>${esc(account.keeps || '—')}</td></tr>`
90
+ : '<tr><td></td><td class="err">Not signed in — the agent needs your AIPictury account</td></tr>'}
91
+ </table>
92
+ <h2>GPU server</h2>
93
+ <table>
94
+ <tr><td>Address</td><td><span class="dot${online ? ' on' : ''}"></span><span id="server">${esc(server || 'none set')}</span></td></tr>
95
+ <tr><td>From</td><td id="server-source">${esc(access.source || '—')}</td></tr>
96
+ ${access.auth ? `<tr><td>Who may use it</td><td id="server-auth">${esc(access.auth)}</td></tr>` : ''}
97
+ ${access.yours ? `<tr><td>Your account</td><td id="server-yours"${access.refused ? ' class="err"' : ''}>${esc(access.yours)}</td></tr>` : ''}
98
+ ${online && s.version && s.version !== version ? `<tr><td></td><td class="err" id="version-skew">The server runs ${esc(s.version)} and this agent ${esc(version)}: update the older one</td></tr>` : ''}
99
+ ${online ? `<tr><td>AIPictury</td><td id="server-version">${esc(s.version)}</td></tr>
100
+ <tr><td>Host</td><td>${esc(s.host)}</td></tr>
101
+ <tr><td>Context window</td><td>${Number(s.context_window || 0).toLocaleString()} tokens</td></tr>
102
+ <tr><td>Web app's chat model</td><td>${esc(s.chat_model)}</td></tr>` : `<tr><td></td><td class="err" id="error">${esc(error || 'not answering')}</td></tr>`}
103
+ </table>
104
+ ${online ? `
105
+ <h2>Models</h2>
106
+ <details id="models" open><summary>Language models on this server (${models.length})</summary><ul>${modelRows || '<li class="sub">none installed</li>'}</ul></details>
107
+ <details id="generation" open><summary>Pictures and video (${generation.length})</summary><ul>${genRows || '<li class="sub">none</li>'}</ul></details>
108
+ <h2>GPUs</h2>
109
+ <ul id="gpus">${gpuRows || '<li class="sub">no GPU reported</li>'}</ul>` : ''}
110
+ <div class="actions">
111
+ <button class="primary" data-cmd="refresh">Refresh</button>
112
+ ${account.user ? '<button data-cmd="aipictury.account">Account</button>' : '<button class="primary" data-cmd="aipictury.signIn">Sign in</button>'}
113
+ <button data-cmd="aipictury.setServer">Change server</button>
114
+ <button data-cmd="aipictury.openSettings">Choose models</button>
115
+ <button data-cmd="aipictury.openStudio">Open aipictury.com</button>
116
+ </div>
117
+ <h2>How to use it</h2>
118
+ <div class="sub">@ mentions a file (@src/app.ts#10-20, @folder/, @terminal:name) · / lists the commands (/plan, /rewind,
119
+ /btw, /compact, /export, /status…) · # remembers a rule for this project, ## for you everywhere · ↑ ↓ earlier prompts ·
120
+ Shift+Tab the mode · Esc stops. Keys: ⌘Esc / Ctrl+Esc to the agent and back · ⌘⇧Esc a new tab · ⌥K mention the
121
+ selection · Ctrl+O every reasoning fold. AIPICTURY.md, AGENTS.md or CLAUDE.md is read into every conversation; MCP
122
+ servers come from .mcp.json.</div>
123
+ <script>
124
+ const vscode = acquireVsCodeApi()
125
+ document.querySelectorAll('button[data-cmd]').forEach((b) => b.addEventListener('click', () => vscode.postMessage({ cmd: b.dataset.cmd })))
126
+ </script>
127
+ </body></html>`
128
+ }
129
+
130
+ /** Whether the server takes this account: what it asks for (health) and its answer to /api/me. */
131
+ async function fetchAccess(server) {
132
+ const access = {}
133
+ if (!server) return access
134
+ try {
135
+ const health = await (await fetch(`${server}/api/health`, { signal: AbortSignal.timeout(10_000) })).json()
136
+ access.auth = { required: health.owned ? 'its owner\'s AIPictury accounts only' : 'AIPictury accounts — but it has no owner set, so it refuses everyone',
137
+ optional: 'anyone; signed-in calls are kept per account', off: 'anyone (no accounts)' }[health.auth] || health.auth
138
+ if (health.auth && health.auth !== 'off') {
139
+ const me = await fetch(`${server}/api/me`, { signal: AbortSignal.timeout(10_000) })
140
+ if (me.ok) access.yours = (await me.json()).account ? 'accepted' : 'not signed in here (anonymous)'
141
+ else if (me.status === 401) { access.refused = true; access.yours = `refused: ${(await me.json().catch(() => ({}))).detail || 'no reason given'}` }
142
+ else if (me.status === 404) access.yours = 'not asked (the server predates 1.9.0 — update it)'
143
+ }
144
+ } catch { /* the address does not answer: said by the About call */ }
145
+ return access
146
+ }
147
+
148
+ /** The server's own account of itself, or why it could not be had. */
149
+ async function fetchAbout(server) {
150
+ if (!server) return { error: 'no GPU server is set: choose it at aipictury.com (it is kept with your account), or "Change server" for this machine' }
151
+ try {
152
+ const response = await fetch(`${server}/api/about`, { signal: AbortSignal.timeout(30_000) })
153
+ if (!response.ok) {
154
+ const detail = response.status === 401 ? (await response.json().catch(() => ({}))).detail : ''
155
+ throw new Error(`the server answered ${response.status}${detail ? `: ${detail}` : ''}${response.status === 404 ? ' (it predates 1.6.0 — update it)' : ''}`)
156
+ }
157
+ return { about: await response.json() }
158
+ } catch (error) {
159
+ return { error: error.cause?.code ? `not reachable (${error.cause.code})` : error.message }
160
+ }
161
+ }
162
+
163
+ async function refresh(context, state) {
164
+ if (!panel) return
165
+ const server = require('./gpuServer').url()
166
+ lastServer = server
167
+ const version = require('./package.json').version
168
+ const cfg = vscode.workspace.getConfiguration('aipictury')
169
+ const MODES = { ask: 'Manual', edits: 'Edit Automatically', plan: 'Plan', never: 'Auto' }
170
+ const config = { mode: MODES[cfg.get('approval', 'ask')] || cfg.get('approval', 'ask'), effort: cfg.get('effort', 'high'), model: state?.model?.() || null }
171
+ const gpu = require('./gpuServer')
172
+ const ctl = state?.account?.()
173
+ const kinds = ['conversations', 'memory', 'settings', 'usage'].filter((k) => ctl?.keeps?.(k))
174
+ const account = { user: ctl?.user?.() || null, keeps: [...kinds, 'renders'].join(', ') }
175
+ const source = gpu.source() === 'setting' ? 'this machine (the aipictury.server setting)'
176
+ : gpu.source() === 'account' ? `your account (chosen at aipictury.com) — its ${gpu.route() === 'lan' ? 'LAN' : 'WAN'} address` : 'nowhere yet'
177
+ panel.webview.html = render({ server, version, about: null, error: 'asking the server…', config, account, access: { source } })
178
+ const [got, access] = await Promise.all([fetchAbout(server), fetchAccess(server)])
179
+ if (!panel || server !== lastServer) return // the server changed meanwhile
180
+ panel.webview.html = render({ server, version, ...got, config, account, access: { source, ...access } })
181
+ }
182
+
183
+ let opened = null
184
+ /** Draws it again when it is open (the account or the GPU server changed). */
185
+ function refreshOpen() { if (panel && opened) refresh(opened.context, opened.state) }
186
+
187
+ /** Opens (or brings forward) the About view. `state.model()` says the model chosen here,
188
+ * `state.account()` the account controller (accountUi). */
189
+ function showAbout(context, state = {}) {
190
+ opened = { context, state }
191
+ if (panel) { panel.reveal(); refresh(context, state); return panel }
192
+ panel = vscode.window.createWebviewPanel('aipictury.about', 'About AIPictury agent', vscode.ViewColumn.Active,
193
+ { enableScripts: true })
194
+ panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'icon.svg')
195
+ panel.webview.onDidReceiveMessage((m) => {
196
+ if (m.cmd === 'refresh') refresh(context, state)
197
+ else if (String(m.cmd).startsWith('aipictury.')) vscode.commands.executeCommand(m.cmd)
198
+ })
199
+ // A different server is a different machine: its models and GPUs are read again.
200
+ const sub = vscode.workspace.onDidChangeConfiguration((e) => {
201
+ if (e.affectsConfiguration('aipictury.server') || e.affectsConfiguration('aipictury.approval')
202
+ || e.affectsConfiguration('aipictury.effort')) refresh(context, state)
203
+ })
204
+ panel.onDidDispose(() => { sub.dispose(); panel = null })
205
+ refresh(context, state)
206
+ return panel
207
+ }
208
+
209
+ module.exports = { showAbout, refreshOpen, render, modelLine, fetchAbout, fetchAccess, current: () => panel }
package/ext/account.js ADDED
@@ -0,0 +1,242 @@
1
+ 'use strict'
2
+ // The AIPictury account for the editor and the CLI (Firebase Authentication). Signing in happens
3
+ // in the browser, as the gcloud and firebase CLIs do it: a page served here at
4
+ // http://localhost:<random port> (localhost is an authorized domain) signs in with Firebase's own
5
+ // SDK — email and password, or Google — and hands the session back to this server once, with a
6
+ // one-time key only this page was given. What is kept is the refresh token, in the caller's
7
+ // storage (VS Code's secret storage; the CLI's credentials file); ID tokens are minted from it
8
+ // when needed. AIPICTURY_FIREBASE_EMULATOR="auth host:port,firestore host:port" (tests) points
9
+ // everything at the local emulators. No VS Code here.
10
+ const http = require('http')
11
+ const crypto = require('crypto')
12
+ const fs = require('fs')
13
+ const path = require('path')
14
+
15
+ const SIGNIN_TIMEOUT = 10 * 60_000
16
+
17
+ function emulatorHosts(env = process.env) {
18
+ const [auth, firestore, storage] = String(env.AIPICTURY_FIREBASE_EMULATOR || '').split(',').map((s) => s.trim()).filter(Boolean)
19
+ return { auth: auth || null, firestore: firestore || null, storage: storage || null }
20
+ }
21
+
22
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
23
+
24
+ function page({ config, emulator, nonce, client, script }) {
25
+ const boot = JSON.stringify({ config, emulator, nonce, client }).replace(/</g, '\\u003c')
26
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
27
+ <title>Sign in to AIPictury</title>
28
+ <style>
29
+ :root{color-scheme:light dark;--bg:#0a0a0c;--panel:#131316;--line:#26262b;--text:#e9e9ec;--muted:#8b8b95;--accent:#7c5cff;--err:#fca5a5;--ok:#34d399}
30
+ @media (prefers-color-scheme:light){:root{--bg:#f6f6f8;--panel:#fff;--line:#dedee3;--text:#18181b;--muted:#5f5f6b;--err:#b42318;--ok:#067647}}
31
+ *{box-sizing:border-box}body{margin:0;min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:16px;background:var(--bg);color:var(--text);font:15px/1.5 system-ui,sans-serif}
32
+ main{width:100%;max-width:400px;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:22px;display:flex;flex-direction:column;gap:14px}
33
+ h1{margin:0;font-size:19px}p{margin:0;color:var(--muted);font-size:13px}
34
+ .tabs{display:flex;gap:4px;padding:3px;border:1px solid var(--line);border-radius:10px}.tabs button{flex:1;padding:7px;border:0;border-radius:8px;background:none;color:var(--muted);cursor:pointer;font:inherit}
35
+ .tabs button[aria-selected=true]{background:var(--accent);color:#fff}
36
+ button.primary,button.google{width:100%;padding:10px;border-radius:10px;font:inherit;cursor:pointer}
37
+ button.primary{border:0;background:var(--accent);color:#fff;font-weight:600}button.google{border:1px solid var(--line);background:none;color:var(--text);display:flex;gap:10px;align-items:center;justify-content:center}
38
+ button:disabled{opacity:.5;cursor:default}
39
+ .or{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:12px}.or::before,.or::after{content:'';flex:1;height:1px;background:var(--line)}
40
+ label{display:block;font-size:13px;color:var(--muted);margin-bottom:4px}input{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:none;color:var(--text);font:inherit}
41
+ .row{display:flex;gap:6px}.row input{flex:1;min-width:0}.row button{padding:0 10px;border:1px solid var(--line);border-radius:8px;background:none;color:var(--muted);cursor:pointer}
42
+ form{display:flex;flex-direction:column;gap:10px}.link{align-self:flex-start;padding:0;border:0;background:none;color:var(--accent);cursor:pointer;font:inherit;font-size:13px}
43
+ .note{padding:8px 10px;border-radius:8px;font-size:13px}.note.err{color:var(--err)}.note.ok{color:var(--ok)}
44
+ .done-view{display:none}body.done .form-view{display:none}body.done .done-view{display:flex;flex-direction:column;gap:10px}
45
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
46
+ </style></head><body><main>
47
+ <div class="form-view" style="display:contents">
48
+ <h1>Sign in to AIPictury</h1>
49
+ <p>For ${esc(client)}: your projects, conversations and rules, kept with your account.</p>
50
+ <div class="tabs" role="tablist"><button role="tab" data-mode="signin" aria-selected="true">Sign in</button><button role="tab" data-mode="register" aria-selected="false">Create account</button></div>
51
+ <button class="google" id="google" type="button"><svg viewBox="0 0 48 48" width="18" height="18" aria-hidden="true"><path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3C33.7 32.7 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.4-.4-3.5z"/><path fill="#FF3D00" d="m6.3 14.7 6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.3-11.3-8l-6.5 5C9.5 39.6 16.2 44 24 44z"/><path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.2-4.1 5.6l6.2 5.2C37 39.2 44 34 44 24c0-1.3-.1-2.4-.4-3.5z"/></svg>Continue with Google</button>
52
+ <div class="or" id="or">or with email</div>
53
+ <form id="form" novalidate>
54
+ <div id="name-field" hidden><label for="name">Name (optional)</label><input id="name" autocomplete="name"></div>
55
+ <div><label for="email">Email</label><input id="email" type="email" autocomplete="email" required></div>
56
+ <div id="password-field"><label for="password">Password</label><div class="row"><input id="password" type="password" autocomplete="current-password"><button type="button" id="show" aria-pressed="false" aria-label="Show the password">Show</button></div></div>
57
+ <div id="note" class="note" role="status" hidden></div>
58
+ <button class="primary" id="submit" type="submit">Sign in</button>
59
+ </form>
60
+ <button class="link" id="forgot" type="button">Forgot your password?</button>
61
+ </div>
62
+ <div class="done-view" role="status"><h1>You are signed in</h1><p>As <strong id="done-who"></strong>, in <span id="done-client"></span>. You can close this tab.</p></div>
63
+ <p class="copy">© 2026 Davait, Inc. All rights reserved.</p>
64
+ </main>
65
+ <script>window.AIPICTURY_SIGNIN=${boot}</script><script type="module">${script}</script></body></html>`
66
+ }
67
+
68
+ class Account {
69
+ /**
70
+ * `store`: { get: async () => saved | undefined, set: async (saved) => {}, delete: async () => {} }
71
+ * `config`: the Firebase web config. `client`: what to call ourselves on the page ("VS Code").
72
+ */
73
+ constructor({ store, config, client = 'VS Code', script = null, env = process.env, fetchImpl = globalThis.fetch }) {
74
+ this.store = store
75
+ this.config = config
76
+ this.client = client
77
+ this.script = script // the page's bundle (media/vendor/signin.js)
78
+ this.emulator = emulatorHosts(env)
79
+ this.fetch = fetchImpl
80
+ this.cache = null // { idToken, expires }
81
+ this.listeners = new Set()
82
+ }
83
+
84
+ onChange(fn) { this.listeners.add(fn); return { dispose: () => this.listeners.delete(fn) } }
85
+ emit(user) { for (const fn of this.listeners) { try { fn(user) } catch { /* a listener's own problem */ } } }
86
+
87
+ /** Who is signed in ({ uid, email, displayName, photoURL }), or null. */
88
+ async user() {
89
+ const s = await this.store.get()
90
+ return s ? { uid: s.uid, email: s.email, displayName: s.displayName, photoURL: s.photoURL } : null
91
+ }
92
+
93
+ /**
94
+ * Signs in through the browser. `open(url)` opens the page (the system browser; a test's).
95
+ * Resolves to the user once the page has handed the session over; rejects on a timeout or
96
+ * when `signal` aborts.
97
+ */
98
+ signIn({ open, signal, timeoutMs = SIGNIN_TIMEOUT } = {}) {
99
+ const nonce = crypto.randomBytes(24).toString('base64url')
100
+ const script = this.script ?? fs.readFileSync(path.join(__dirname, 'media', 'vendor', 'signin.js'), 'utf8')
101
+ return new Promise((resolve, reject) => {
102
+ let settled = false
103
+ let origin = ''
104
+ const server = http.createServer((req, res) => {
105
+ const url = new URL(req.url, 'http://localhost')
106
+ const security = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' }
107
+ if (req.method === 'GET' && url.pathname === '/' && url.searchParams.get('n') === nonce) {
108
+ res.writeHead(200, { ...security, 'Content-Type': 'text/html; charset=utf-8',
109
+ // The page talks to Firebase (and Google's sign-in) and to this server only.
110
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' https://apis.google.com https://*.gstatic.com; "
111
+ + "connect-src 'self' https://*.googleapis.com https://*.firebaseapp.com http://127.0.0.1:* http://localhost:*; "
112
+ + "frame-src https://*.firebaseapp.com https://accounts.google.com http://127.0.0.1:* http://localhost:*; img-src 'self' data: https:; style-src 'unsafe-inline'" })
113
+ res.end(page({ config: this.config, emulator: this.emulator.auth, nonce, client: this.client, script }))
114
+ return
115
+ }
116
+ if (req.method === 'POST' && url.pathname === '/done' && url.searchParams.get('n') === nonce && !settled
117
+ && req.headers.origin === origin) {
118
+ let body = ''
119
+ req.on('data', (c) => { body += c; if (body.length > 64 * 1024) req.destroy() })
120
+ req.on('end', async () => {
121
+ try {
122
+ const got = JSON.parse(body)
123
+ if (!got.uid || !got.refreshToken) throw new Error('the page sent no session')
124
+ await this.store.set({ uid: got.uid, email: got.email || null, displayName: got.displayName || null,
125
+ photoURL: got.photoURL || null, refreshToken: got.refreshToken })
126
+ this.cache = got.idToken ? { idToken: got.idToken, expires: Date.now() + 50 * 60_000 } : null
127
+ res.writeHead(200, { ...security, 'Content-Type': 'application/json' })
128
+ res.end('{"ok":true}')
129
+ done(null, await this.user())
130
+ } catch (error) {
131
+ res.writeHead(400, { ...security, 'Content-Type': 'application/json' })
132
+ res.end(JSON.stringify({ error: error.message }))
133
+ }
134
+ })
135
+ return
136
+ }
137
+ res.writeHead(404, security)
138
+ res.end()
139
+ })
140
+ const timer = setTimeout(() => done(new Error('signing in took too long — try again')), timeoutMs)
141
+ const abort = () => done(new Error('signing in was cancelled'))
142
+ signal?.addEventListener('abort', abort, { once: true })
143
+ function done(error, user) {
144
+ if (settled) return
145
+ settled = true
146
+ clearTimeout(timer)
147
+ signal?.removeEventListener('abort', abort)
148
+ server.close()
149
+ server.closeAllConnections?.()
150
+ if (error) reject(error)
151
+ else resolve(user)
152
+ }
153
+ server.listen(0, '127.0.0.1', async () => {
154
+ origin = `http://localhost:${server.address().port}`
155
+ try { await open(`${origin}/?n=${nonce}`) } catch (error) { done(error) }
156
+ })
157
+ // A promise of a user: listeners are told once it is settled.
158
+ }).then((user) => { this.emit(user); return user })
159
+ }
160
+
161
+ async signOut() {
162
+ await this.store.delete()
163
+ this.cache = null
164
+ this.emit(null)
165
+ }
166
+
167
+ /** A valid ID token for the signed-in user, refreshed when it is about to expire; null when signed out. */
168
+ async idToken() {
169
+ if (this.cache && this.cache.expires > Date.now() + 60_000) return this.cache.idToken
170
+ const saved = await this.store.get()
171
+ if (!saved?.refreshToken) return null
172
+ const base = this.emulator.auth ? `http://${this.emulator.auth}/securetoken.googleapis.com` : 'https://securetoken.googleapis.com'
173
+ const ask = () => this.fetch(`${base}/v1/token?key=${encodeURIComponent(this.config.apiKey)}`, {
174
+ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
175
+ body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: saved.refreshToken }).toString(),
176
+ signal: AbortSignal.timeout(20_000),
177
+ })
178
+ // A connection kept open from an earlier refresh may have been closed by the other end while
179
+ // idle (ECONNRESET: "fetch failed"): a refresh is safe to ask again, once, on a new one.
180
+ const response = await ask().catch((error) => (error?.name === 'TypeError' ? ask() : Promise.reject(error)))
181
+ const data = await response.json().catch(() => ({}))
182
+ if (!response.ok) {
183
+ // A revoked or expired session (the password changed, the account deleted): signed out.
184
+ if (/TOKEN_EXPIRED|USER_DISABLED|USER_NOT_FOUND|INVALID_REFRESH_TOKEN/.test(data?.error?.message || '')) await this.signOut()
185
+ throw new Error(`the account could not be reached: ${data?.error?.message || response.status}`)
186
+ }
187
+ if (data.refresh_token && data.refresh_token !== saved.refreshToken) await this.store.set({ ...saved, refreshToken: data.refresh_token })
188
+ this.cache = { idToken: data.id_token, expires: Date.now() + (Number(data.expires_in) || 3600) * 1000 }
189
+ return data.id_token
190
+ }
191
+ }
192
+
193
+ /** The CLI's store: ~/.aipictury/credentials.json, readable by its owner only. */
194
+ function fileStore(file) {
195
+ return {
196
+ async get() { try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return undefined } },
197
+ async set(value) {
198
+ fs.mkdirSync(path.dirname(file), { recursive: true })
199
+ const tmp = `${file}.${process.pid}.tmp`
200
+ fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 })
201
+ fs.renameSync(tmp, file)
202
+ fs.chmodSync(file, 0o600)
203
+ },
204
+ async delete() { fs.rmSync(file, { force: true }) },
205
+ }
206
+ }
207
+
208
+ /**
209
+ * fetch, with the account's token on every call to the GPU server — and only those, never another
210
+ * site. `server()`: its address; `signedIn()`: whether to send one. A server that answers "verify
211
+ * your email" is asked once more with a fresh token: the cached one predates the verification.
212
+ */
213
+ function serverFetch({ fetch: plainFetch, account, server, signedIn = () => true }) {
214
+ const withToken = (input, init, token) => {
215
+ const headers = new Headers(init.headers || (typeof input === 'object' ? input.headers : undefined))
216
+ if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`)
217
+ // The GPU run this call is part of (gpuRun.js), so the server holds the GPU for it.
218
+ for (const [k, v] of Object.entries(require('./gpuRun').headers())) if (!headers.has(k)) headers.set(k, v)
219
+ return { ...init, headers }
220
+ }
221
+ return async (input, init = {}) => {
222
+ let sent = init
223
+ let token = null
224
+ try {
225
+ const url = typeof input === 'string' ? input : input?.url || String(input)
226
+ const base = String(server() || '').replace(/\/+$/, '')
227
+ if (base && (url === base || url.startsWith(`${base}/`)) && signedIn()) token = await account.idToken().catch(() => null)
228
+ if (token) sent = withToken(input, init, token)
229
+ } catch { token = null; sent = init }
230
+ const ours = Boolean(token)
231
+ const response = await plainFetch(input, sent)
232
+ const resendable = !(init.body && typeof init.body === 'object' && typeof init.body.getReader === 'function')
233
+ if (!ours || response.status !== 401 || !resendable) return response
234
+ const detail = await response.clone().text().catch(() => '')
235
+ if (!/verify your email/i.test(detail)) return response
236
+ account.cache = null
237
+ const fresh = await account.idToken().catch(() => null)
238
+ return fresh ? plainFetch(input, withToken(input, init, fresh)) : response
239
+ }
240
+ }
241
+
242
+ module.exports = { Account, fileStore, emulatorHosts, page, serverFetch }