@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/main.js CHANGED
@@ -177,6 +177,13 @@ class Conversation {
177
177
  return this.title || this.aiTitle || (first ? String(first.content).replace(/\[[^\]]*\]\n?/g, '').replace(/\s+/g, ' ').trim().slice(0, 80) : 'New conversation')
178
178
  }
179
179
 
180
+ /** One row per tool call, updated in place as it runs, kept with its output. */
181
+ toolRow(event) {
182
+ const key = event.id || `${event.tool}:${JSON.stringify(event.args)}`
183
+ const existing = this.transcript.find((t) => t.kind === 'tool' && t.key === key)
184
+ if (existing) Object.assign(existing, event); else this.transcript.push({ kind: 'tool', key, at: Date.now(), ...event })
185
+ }
186
+
180
187
  save(model) {
181
188
  if (!this.history.some((m) => m.role === 'user')) return
182
189
  this.store.save({ id: this.id, title: this.displayTitle, renamed: !!this.title, aiTitle: this.aiTitle || undefined, created: this.created,
@@ -200,9 +207,7 @@ async function runTurn(convo, text, ui, { model, maxTurns, content } = {}) {
200
207
  onDelta: (d) => ui.delta?.(d),
201
208
  onThinking: (d) => ui.thinking?.(d),
202
209
  onTool: (event) => {
203
- const key = event.id || `${event.tool}:${JSON.stringify(event.args)}`
204
- const existing = convo.transcript.find((t) => t.kind === 'tool' && t.key === key)
205
- if (existing) Object.assign(existing, event); else convo.transcript.push({ kind: 'tool', key, at: Date.now(), ...event })
210
+ convo.toolRow(event)
206
211
  ui.tool?.(event)
207
212
  },
208
213
  onNudge: (reason, detail) => ui.nudge?.(reason, detail),
@@ -230,6 +235,27 @@ async function runTurn(convo, text, ui, { model, maxTurns, content } = {}) {
230
235
  } finally { ui.onEnd?.() }
231
236
  }
232
237
 
238
+ /** What an approval asks, for a terminal: each kind the agent sends (agent.js), else the question
239
+ * it brings. A render's approval (kind 'media') fell through to "Use undefined {}" (1.20.0). */
240
+ function approvalText(request, b = (t) => t, d = (t) => t) {
241
+ if (request.kind === 'command') return `Run ${b(request.command)}`
242
+ if (request.kind === 'edit') return `${request.before == null ? 'Create' : 'Change'} ${b(request.path)}`
243
+ if (request.kind === 'plan-mode') return `Switch to Plan mode first${request.reason ? d(` — ${request.reason}`) : ''}`
244
+ if (request.tool === 'browser') return `Use the browser on ${b(request.site || 'a page')}`
245
+ if (request.question) {
246
+ // The question's first line says what and where (the verb, the file); the rest is the prompt.
247
+ const [head, ...rest] = String(request.question).split('\n')
248
+ const body = rest.join('\n').trim()
249
+ return `${b(head)}${request.path && !head.includes(request.path) ? ` ${d(`(${request.path})`)}` : ''}${body ? `\n${d(body.split('\n').map((l) => ` ${l}`).join('\n'))}` : ''}`
250
+ }
251
+ return `Use ${b(request.tool || request.kind || 'a tool')} ${d(JSON.stringify(request.args || {}).slice(0, 200))}`
252
+ }
253
+
254
+ /** The one line print mode says when it refuses what needs a yes. */
255
+ const refusedText = (request) => `not allowed in print mode (-p): ${request.kind === 'command' ? request.command
256
+ : request.kind === 'media' ? `${request.question ? String(request.question).split('\n')[0] : 'a generated picture or clip'}${request.path ? ` (${request.path})` : ''}`
257
+ : request.path || request.tool || request.kind} — allow it with --allowedTools or --permission-mode`
258
+
233
259
  // ---- print mode: one prompt, no questions ----
234
260
  async function printMode(o, convo, text) {
235
261
  const started = Date.now()
@@ -242,7 +268,7 @@ async function printMode(o, convo, text) {
242
268
  tool: (e) => { if (events && (e.status === 'done' || e.status === 'failed' || e.status === 'refused')) emit({ type: 'tool', tool: e.tool, args: e.args, status: e.status, result: typeof e.result === 'string' ? e.result.slice(0, 4000) : undefined }) },
243
269
  turn: ({ text: said }) => { if (events && String(said || '').trim()) emit({ type: 'assistant', text: said }) },
244
270
  // Nobody to ask: what needs a yes is refused, a question is left to the agent.
245
- approve: async (request) => ({ decision: 'deny', feedback: `not allowed in print mode (-p): ${request.kind === 'command' ? request.command : request.path || request.tool} — allow it with --allowedTools or --permission-mode` }),
271
+ approve: async (request) => ({ decision: 'deny', feedback: refusedText(request) }),
246
272
  askUser: async () => ({ dismissed: true }),
247
273
  // The plan is the answer: the run ends there, with no further turn.
248
274
  approvePlan: async (plan) => { planText = plan; ui.controller?.abort(); return { approved: false, feedback: 'Stop here: the plan is the answer.' } },
@@ -363,10 +389,7 @@ async function interactive(o, convo, first, cfg) {
363
389
  nudge: (reason, detail) => { if (detail?.text) say(dim(` · ${detail.text}`)) },
364
390
  todos: (todos) => say(dim(todos.map((t) => ` ${t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '▸' : '☐'} ${t.content}`).join('\n'))),
365
391
  async approve(request) {
366
- const what = request.kind === 'command' ? `Run ${bold(request.command)}`
367
- : request.kind === 'edit' ? `${request.before == null ? 'Create' : 'Change'} ${bold(request.path)}`
368
- : request.tool === 'browser' ? `Use the browser on ${bold(request.site || 'a page')}` : `Use ${bold(request.tool)} ${dim(JSON.stringify(request.args || {}).slice(0, 200))}`
369
- say(`\n${yellow('?')} ${what}${request.because ? dim(` — ${request.because}`) : ''}`)
392
+ say(`\n${yellow('?')} ${approvalText(request, bold, dim)}${request.because ? dim(` — ${request.because}`) : ''}`)
370
393
  if (request.kind === 'edit') for (const h of (request.hunks || []).slice(0, 6)) say([...h.removed.slice(0, 8).map((l) => red(` - ${l}`)), ...h.added.slice(0, 12).map((l) => green(` + ${l}`))].join('\n'))
371
394
  const answer = await ask(` ${bold('y')}es · ${bold('a')}lways${request.rule ? dim(` (${request.rule}, this conversation)`) : ' (this conversation)'} · ${bold('n')}o, or say what to do instead › `)
372
395
  spinner.start()
@@ -605,6 +628,17 @@ async function interactive(o, convo, first, cfg) {
605
628
  return lines.join('\n')
606
629
  }
607
630
 
631
+ // A picture or clip the last run left rendering (an exit or Ctrl+C cut it): delivered now by its
632
+ // job id and written where it was asked for — not at the next message, nor made again (1.20.0).
633
+ if (convo.history.some((m) => m.pending)) {
634
+ say(dim(' a render the last run left going is being delivered (Esc or Ctrl+C leaves it for later)'))
635
+ running = new AbortController()
636
+ spinner.start('rendering')
637
+ try {
638
+ await agent.deliverPending(server(), convo.history, { signal: running.signal,
639
+ onTool: (event) => { convo.toolRow(event); ui.tool(event) }, onSettled: () => convo.save(model) })
640
+ } catch (error) { say(red(` not delivered: ${error.message}`)) } finally { running = null; spinner.stop() }
641
+ }
608
642
  if (first) await turn(first)
609
643
  for (;;) {
610
644
  const message = await nextMessage()
@@ -633,7 +667,10 @@ function accountFor() {
633
667
  return new Account({ store: fileStore(path.join(HOME(), 'credentials.json')), config, client: 'the aipictury CLI' })
634
668
  }
635
669
 
636
- /** The conversations of this run, kept with the account: what was saved is pushed before exiting. */
670
+ /** The conversations of this run, kept with the account as VS Code keeps them: each save pushed a
671
+ * few seconds later (at least every half minute while a run goes on), and what is left pushed
672
+ * before exiting. It used to push at a clean exit only: a CLI killed, crashed or with its
673
+ * terminal closed left the account's copy as it was when the run began (1.20.0). */
637
674
  function cloudFor(account) {
638
675
  const { Cloud } = require(path.join(EXT, 'cloud.js'))
639
676
  const { emulatorHosts } = require(path.join(EXT, 'account.js'))
@@ -643,29 +680,54 @@ function cloudFor(account) {
643
680
  try { id = JSON.parse(fs.readFileSync(deviceFile, 'utf8')).id } catch { /* first run */ }
644
681
  if (!id) { id = require('crypto').randomUUID(); fs.mkdirSync(HOME(), { recursive: true }); fs.writeFileSync(deviceFile, JSON.stringify({ id })) }
645
682
  const cloud = new Cloud({ account, projectId: config.projectId, emulator: emulatorHosts().firestore,
646
- bucket: config.storageBucket, storageEmulator: emulatorHosts().storage,
683
+ bucket: config.storageBucket, storageEmulator: emulatorHosts().storage, log: (line) => {
684
+ require(path.join(EXT, 'log.js')).warn(line)
685
+ if (exiting) process.stderr.write(dim(` (${line.replace(/^cloud: /, '')} — not kept with your account)\n`))
686
+ },
647
687
  device: { id, name: os.hostname(), app: 'cli', platform: process.platform, version: VERSION } })
648
- const pending = new Map()
649
- const removed = []
688
+ const removing = new Set()
689
+ let exiting = false // a push failing now is said; during the run, only logged
650
690
  const on = (kind) => vscode.workspace.getConfiguration('aipictury').get(`cloud.${kind}`, true)
651
- require(path.join(EXT, 'sessions.js')).onWrite((e) => {
691
+ const sessions = require(path.join(EXT, 'sessions.js'))
692
+ sessions.onWrite((e) => {
652
693
  if (!e.folder || !on('conversations')) return
653
- if (e.type === 'remove') { pending.delete(e.id); removed.push([e.folder, e.id]); return }
654
- pending.set(e.id, [e.folder, { ...e.entry, origin: 'cli' }])
694
+ if (e.type === 'remove') {
695
+ cloud.cancel(e.id)
696
+ const gone = cloud.deleteConversation(e.folder, e.id).catch(() => {}).finally(() => removing.delete(gone))
697
+ removing.add(gone)
698
+ return
699
+ }
700
+ cloud.schedule(e.folder, { ...e.entry, origin: 'cli' }, e.type === 'patch' ? 500 : 5000).catch(() => {})
655
701
  })
656
- // Every render the agent makes is kept with the account; the run waits for it before it ends.
657
- const keeping = []
702
+ // What this folder's conversations hold that the account lacks — a run an earlier CLI was
703
+ // killed in, before its push — goes up at the start, as VS Code's sign-in does (cloud.bringUp).
704
+ let bringing = Promise.resolve()
705
+ const bringUp = () => {
706
+ if (!on('conversations')) return
707
+ bringing = cloud.bringUp(process.cwd(), sessions.storeFor(process.cwd()))
708
+ .catch((err) => process.stderr.write(dim(` (conversations not carried up to your account: ${err.message})\n`)))
709
+ }
710
+ // Every render the agent makes is kept with the account: noted in the outbox the extension shares
711
+ // (keeping.js), so Ctrl+C twice or a kill only delays it — the next start of either app finishes
712
+ // it — and what an earlier run left is taken up now. The run waits for the keeping in progress.
713
+ const keeping = require(path.join(EXT, 'keeping.js'))
714
+ const keep = () => keeping.drain(cloud, { onState: (_, state) => {
715
+ if (state.stage === 'failed') process.stderr.write(dim(` (render not kept with your account${state.again ? ' yet: tried again at the next start' : ''}: ${state.error})\n`))
716
+ } }).catch((err) => process.stderr.write(dim(` (renders not kept with your account: ${err.message})\n`)))
658
717
  require(path.join(EXT, 'mediagen.js')).onRendered((base, jobId) => {
659
- keeping.push(cloud.keepRender(base, jobId).catch((err) => process.stderr.write(dim(` (render not kept with your account: ${err.message})\n`))))
718
+ account.user().then((u) => { if (u) { keeping.add(base, jobId, u.uid); keep() } }).catch(() => {})
660
719
  })
661
720
  return {
662
721
  cloud,
722
+ keep,
723
+ bringUp,
663
724
  async flush() {
664
- await Promise.all(keeping)
725
+ await keeping.settled()
726
+ await bringing
727
+ await Promise.allSettled([...removing])
665
728
  if (!(await account.user())) return
666
- for (const [folder, entry] of pending.values()) await cloud.pushConversation(folder, entry).catch((err) => process.stderr.write(dim(` (not kept with your account: ${err.message})\n`)))
667
- for (const [folder, id] of removed) await cloud.deleteConversation(folder, id).catch(() => {})
668
- pending.clear()
729
+ exiting = true
730
+ await cloud.flush()
669
731
  },
670
732
  }
671
733
  }
@@ -725,6 +787,8 @@ async function main() {
725
787
  // Every call to the GPU server carries the account's token (the server may require it), and only those.
726
788
  const { serverFetch } = require(path.join(EXT, 'account.js'))
727
789
  globalThis.fetch = serverFetch({ fetch: globalThis.fetch, account, server })
790
+ sync.keep() // renders an earlier run (or VS Code) left halfway, with the token now
791
+ sync.bringUp() // and conversations an earlier run left unpushed
728
792
  // …and each of those calls is in the log (--debug: method, path, status, ms).
729
793
  const log = require(path.join(EXT, 'log.js'))
730
794
  log.watchFetch({ server })
@@ -774,8 +838,13 @@ async function mainRun(o) {
774
838
  return interactive(o, convo, text, cfg)
775
839
  }
776
840
 
777
- main().then((code) => {
778
- require(path.join(EXT, 'agent.js')).stopBackground?.()
779
- try { require(path.join(EXT, 'mcp.js')).closeAll() } catch { /* none */ }
780
- require(path.join(EXT, 'browser.js')).close().finally(() => process.exit(code))
781
- }, (error) => { process.stderr.write(`aipictury: ${error.stack || error.message}\n`); process.exit(1) })
841
+ // Run as the `aipictury` command; required (a check), it only hands out what can be tested alone.
842
+ if (require.main === module) {
843
+ main().then((code) => {
844
+ require(path.join(EXT, 'agent.js')).stopBackground?.()
845
+ try { require(path.join(EXT, 'mcp.js')).closeAll() } catch { /* none */ }
846
+ require(path.join(EXT, 'browser.js')).close().finally(() => process.exit(code))
847
+ }, (error) => { process.stderr.write(`aipictury: ${error.stack || error.message}\n`); process.exit(1) })
848
+ }
849
+
850
+ module.exports = { approvalText, refusedText }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davait/aipictury",
3
- "version": "1.19.4",
3
+ "version": "1.20.1",
4
4
  "description": "The AIPictury agent in a terminal: the VS Code extension's agent, on your own GPU server, signed in to your AIPictury account.",
5
5
  "bin": {
6
6
  "aipictury": "main.js"
package/vscode-shim.js CHANGED
@@ -242,6 +242,9 @@ module.exports = {
242
242
  window: {
243
243
  activeTextEditor: undefined,
244
244
  visibleTextEditors: [],
245
+ // A terminal cannot tell whether it is in front: taken as not, so push_notification is sent
246
+ // (1.20.0: without state, its TypeError ended the CLI's run).
247
+ state: { focused: false },
245
248
  createOutputChannel: outputChannel,
246
249
  showInformationMessage: async () => undefined,
247
250
  showWarningMessage: async () => undefined,