@lampdotdev/lamp 0.36.2-nightly.41 → 0.36.2-nightly.43

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.
@@ -138,8 +138,20 @@
138
138
  delete asked[m.id];
139
139
  waiting({ status: m.status, body: m.body });
140
140
  };
141
- bridge = { postMessage: function (m) {
142
- return new Promise(function (resolve) { var id = ++askId; asked[id] = resolve; gate.postMessage(JSON.stringify({ id: id, path: m.path, body: m.body, timeout: m.timeout })); });
141
+ bridge = { postMessage: function (m, signal) {
142
+ return new Promise(function (resolve) {
143
+ var id = ++askId;
144
+ asked[id] = resolve;
145
+ gate.postMessage(JSON.stringify({ id: id, path: m.path, body: m.body, timeout: m.timeout }));
146
+ // A page that stops waiting tells the host, which can stop the answer (lamp dev does), and hears nothing after.
147
+ function stop() {
148
+ if (!asked[id]) return;
149
+ delete asked[id];
150
+ gate.postMessage(JSON.stringify({ id: id, cancel: true }));
151
+ resolve({ status: 499, body: '{}' });
152
+ }
153
+ if (signal) { if (signal.aborted) stop(); else signal.addEventListener('abort', stop); }
154
+ });
143
155
  } };
144
156
  }
145
157
  var preferred = null;
@@ -305,14 +317,14 @@
305
317
  // with a little more for the answer to come back.
306
318
  function AI_WAIT(seconds) { return (typeof seconds === 'number' && seconds > 0 ? Math.min(seconds, 600) : 600) * 1000 + 15000; }
307
319
 
308
- function post(path, body, timeout, deadline) {
320
+ function post(path, body, timeout, deadline, signal) {
309
321
  timeout = timeout || 240000;
310
322
  var done;
311
323
  if (!bridge && shared()) {
312
324
  done = nearby(path, body, timeout, deadline);
313
325
  } else if (bridge) {
314
326
  // The TV preview on the Mac answers for Lamp itself.
315
- done = bridge.postMessage({ path: path, body: JSON.stringify(body), timeout: timeout }).then(function (r) {
327
+ done = bridge.postMessage({ path: path, body: JSON.stringify(body), timeout: timeout }, signal).then(function (r) {
316
328
  var reply = {};
317
329
  try { reply = JSON.parse(r.body) || {}; } catch (e) {}
318
330
  return { status: r.status, reply: reply };
@@ -328,10 +340,15 @@
328
340
  });
329
341
  }
330
342
  return done.then(function (answer) {
343
+ // Stopped by the page: no failure of the AI's, so nothing is said about it.
344
+ if (signal && signal.aborted) throw stoppedFailure();
331
345
  if (answer.status === 200) return answer.reply;
332
346
  var message = answer.reply.error || 'The AI request failed.';
333
347
  if (window.console) console.error('LampAI ' + path + ' failed: ' + message);
334
- throw new Failure(message, answer.reply.needs || null);
348
+ var failed = new Failure(message, answer.reply.needs || null);
349
+ // What was billed before it failed (a retry, a timeout), for a turn's `cost`.
350
+ if (typeof answer.reply.cost === 'number' && answer.reply.cost > 0) failed.cost = answer.reply.cost;
351
+ throw failed;
335
352
  });
336
353
  }
337
354
 
@@ -346,14 +363,14 @@
346
363
  });
347
364
  }
348
365
 
349
- /** Downscales to at most 1568px on the long side (the most the model uses) and JPEG-encodes. Takes an <img>, a
350
- * <canvas>, or a picture's address (a data: URL or a file of the app's). */
351
- function encode(images) {
366
+ /** Downscales to at most 1568px on the long side (the most the model uses), or `side`, and JPEG-encodes. Takes an
367
+ * <img>, a <canvas>, or a picture's address (a data: URL or a file of the app's). */
368
+ function encode(images, side) {
352
369
  return Promise.all((images || []).map(function (image) {
353
370
  var ready = typeof image === 'string' ? loadImage(image) : Promise.resolve(image);
354
371
  return ready.then(function (source) {
355
372
  var w = source.naturalWidth || source.width, h = source.naturalHeight || source.height;
356
- var scale = Math.min(1, 1568 / Math.max(w, h, 1));
373
+ var scale = Math.min(1, (side || 1568) / Math.max(w, h, 1));
357
374
  var canvas = document.createElement('canvas');
358
375
  canvas.width = Math.max(1, Math.round(w * scale));
359
376
  canvas.height = Math.max(1, Math.round(h * scale));
@@ -483,21 +500,44 @@
483
500
  score: function (question, levels) { return { type: 'score', instructions: question, criteria: levels }; }
484
501
  };
485
502
 
503
+ function stoppedFailure() { var f = new Failure('Stopped.'); f.stopped = true; return f; }
504
+
505
+ // A promise the page can stop waiting for with an AbortSignal: it fails at once with `stopped`, whatever comes after.
506
+ function stoppable(promise, signal) {
507
+ if (!signal) return promise;
508
+ return new Promise(function (resolve, reject) {
509
+ function stop() { reject(stoppedFailure()); }
510
+ // Settled either way, so a stopped ask's own failure is never left unhandled.
511
+ if (signal.aborted) { promise.then(null, function () {}); return stop(); }
512
+ signal.addEventListener('abort', stop);
513
+ promise.then(function (v) { signal.removeEventListener('abort', stop); if (!signal.aborted) resolve(v); },
514
+ function (e) { signal.removeEventListener('abort', stop); if (!signal.aborted) reject(e); });
515
+ });
516
+ }
517
+
486
518
  function call(prompt, options) {
487
- var o = options || {}, text = prompt, rules = [o.system || '', present()];
519
+ var o = options || {}, text = prompt, rules = [o.system || '', present()], started = Date.now(), cost = 0;
520
+ var schema = o.schema && typeof o.schema === 'object' ? o.schema : null, json = !!o.json || !!schema;
488
521
  if (o.data) { var f = fenced(prompt, o.data); text = f.prompt; rules.push(f.rules); }
489
- return encode(o.images).then(function (images) {
490
- var body = { prompt: text, system: rules.filter(function (r) { return r; }).join('\n\n'), json: !!o.json, images: images };
522
+ return stoppable(encode(o.images).then(function (images) {
523
+ // Stopped before or while its pictures were read: nothing is asked.
524
+ if (o.signal && o.signal.aborted) throw stoppedFailure();
525
+ var body = { prompt: text, system: rules.filter(function (r) { return r; }).join('\n\n'), json: json, images: images };
526
+ if (schema) body.schema = schema;
491
527
  if (o.model) body.model = o.model;
492
528
  // No limit of the page's own unless it asks for one: Lamp's guard ends only a call that stopped answering.
493
529
  if (typeof o.seconds === 'number' && o.seconds > 0) body.seconds = o.seconds;
494
- return post('/ai', body, AI_WAIT(body.seconds));
530
+ return post('/ai', body, AI_WAIT(body.seconds), null, o.signal);
495
531
  }).then(function (reply) {
496
532
  if (typeof reply.text !== 'string') throw new Failure('The AI request failed.');
497
- if (!o.json) return reply.text;
498
- try { return JSON.parse(reply.text.replace(/^\s*```(?:json)?\s*|\s*```\s*$/g, '')); }
499
- catch (e) { throw new Failure('The AI\'s answer wasn\'t in the expected format. Try again.'); }
500
- });
533
+ cost = typeof reply.cost === 'number' && reply.cost > 0 ? reply.cost : 0;
534
+ var answer = reply.text;
535
+ if (json) {
536
+ try { answer = JSON.parse(reply.text.replace(/^\s*```(?:json)?\s*|\s*```\s*$/g, '')); }
537
+ catch (e) { throw new Failure('The AI\'s answer wasn\'t in the expected format. Try again.'); }
538
+ }
539
+ return o.details ? { answer: answer, seconds: Math.round((Date.now() - started) / 100) / 10, cost: cost } : answer;
540
+ }), o.signal);
501
541
  }
502
542
 
503
543
  function image(prompt, images, aspectRatio) {
@@ -538,6 +578,12 @@
538
578
  // What a turn's reads give back in all, which every later step carries again: kept under what a step may carry
539
579
  // (`MAX_BODY` in lamp's app-ai.mjs), with room for the request, the tools and the steps' own words.
540
580
  var ACT_TURN_RESULTS = 1200000;
581
+ // The pictures a step carries from its tools' results (`LampAI.picture`): the newest few, each at most this many
582
+ // pixels on its long side, together at most this many bytes, beside the text results under the step's limit
583
+ // (`MAX_PICTURES`, `MAX_BODY` in lamp's app-ai.mjs). Older ones the model has seen go as no longer shown.
584
+ var ACT_PICTURES = 6, ACT_PICTURE_SIDE = 1024, ACT_PICTURE_BYTES = 1600000;
585
+ /** A picture in a tool's result, for the AI to look at (`LampAI.picture`). */
586
+ function ActPicture(source) { this.source = source; }
541
587
  var ACT_STEP_SECONDS = 600;
542
588
  var actRan = {}; // turn id -> call key -> the result a write gave, while this copy of the page is open
543
589
  function actId() { return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
@@ -596,8 +642,50 @@
596
642
  }
597
643
  var ran = actRan[id];
598
644
  var started = Date.now(), deadline = asked ? started + Math.max(1, asked) * 1000 : Infinity;
599
- var out = { turn: id, reply: '', ask: false, choices: [], done: [], failed: [], skipped: [], reads: [], stopped: null, error: '' };
600
- var steps = [], route = null, writes = 0, over = false, settle;
645
+ var out = { turn: id, reply: '', ask: false, choices: [], done: [], failed: [], skipped: [], reads: [], stopped: null, error: '', seconds: 0, cost: 0 };
646
+ var steps = [], route = null, writes = 0, over = false, settle, pictureCount = 0;
647
+ // A result with its pictures taken out: each one sent beside it, scaled down, and named in its place
648
+ // ("[picture 2]", numbered across the turn), so the model sees what the app drew in the middle of the turn.
649
+ function pictured(value) {
650
+ var found = [];
651
+ function walk(v, depth) {
652
+ if (v instanceof ActPicture) { found.push({ source: v.source, number: ++pictureCount }); return '[picture ' + pictureCount + ']'; }
653
+ if (!v || typeof v !== 'object' || depth > 8) return v;
654
+ if (isList(v)) return v.map(function (x) { return walk(x, depth + 1); });
655
+ var proto = Object.getPrototypeOf(v);
656
+ if (proto !== Object.prototype && proto !== null) return v;
657
+ var copy = {};
658
+ for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) copy[k] = walk(v[k], depth + 1);
659
+ return copy;
660
+ }
661
+ var kept = walk(value, 0);
662
+ if (!found.length) return Promise.resolve({ value: value });
663
+ // A picture that never loads costs the turn nothing: after a few seconds the result goes without it.
664
+ var slow = new Promise(function (resolve, reject) { browserTimeout(function () { reject(new Error('slow')); }, 10000); });
665
+ return Promise.race([encode(found.map(function (f) { return f.source; }), ACT_PICTURE_SIDE), slow]).then(function (encoded) {
666
+ return { value: kept, pictures: encoded.map(function (e, i) { return { media_type: e.media_type, data: e.data, number: found[i].number }; }) };
667
+ }, function () {
668
+ return { value: { result: kept, note: 'Its pictures couldn\'t be read, so they aren\'t shown.' } };
669
+ });
670
+ }
671
+ // The steps as sent: only the newest pictures go with their data, within the step's room for them.
672
+ function sentSteps() {
673
+ // Chosen newest first by number across every result, whatever order the results are in.
674
+ var room = ACT_PICTURE_BYTES, chosen = {}, all = [];
675
+ steps.forEach(function (s) { s.results.forEach(function (r) { (r.pictures || []).forEach(function (p) { all.push(p); }); }); });
676
+ all.sort(function (a, b) { return b.number - a.number; }).forEach(function (p) {
677
+ if (Object.keys(chosen).length < ACT_PICTURES && p.data.length <= room) { chosen[p.number] = true; room -= p.data.length; }
678
+ });
679
+ return steps.map(function (s) {
680
+ return { calls: s.calls, results: s.results.map(function (r) {
681
+ if (!r.pictures) return r;
682
+ var copy = {};
683
+ for (var k in r) if (Object.prototype.hasOwnProperty.call(r, k)) copy[k] = r[k];
684
+ copy.pictures = r.pictures.map(function (p) { return { media_type: p.media_type, number: p.number, data: chosen[p.number] ? p.data : null }; });
685
+ return copy;
686
+ }) };
687
+ });
688
+ }
601
689
  var declared = Object.keys(tools).map(function (name) {
602
690
  var t = tools[name];
603
691
  return { name: name, about: t.about || '', reads: !!t.reads, args: t.args || {} };
@@ -608,6 +696,7 @@
608
696
  over = true;
609
697
  out.stopped = reason || null;
610
698
  if (error) out.error = error;
699
+ out.seconds = Math.round((Date.now() - started) / 100) / 10;
611
700
  settle(out);
612
701
  }
613
702
  // Over, or past the deadline by the clock even if its timer hasn't run yet (a slow write can carry the turn past
@@ -637,7 +726,10 @@
637
726
  }).then(function (value) {
638
727
  if (value === NOT_RUN) return;
639
728
  out.reads.push({ name: c.name, args: c.args });
640
- results[c.id] = { id: c.id, ok: true, value: sized(value) };
729
+ return pictured(value).then(function (p) {
730
+ results[c.id] = { id: c.id, ok: true, value: sized(p.value) };
731
+ if (p.pictures) results[c.id].pictures = p.pictures;
732
+ });
641
733
  }, function (e) { results[c.id] = { id: c.id, ok: false, error: refused(e) }; });
642
734
  })).then(function () {
643
735
  var stopped = null;
@@ -675,13 +767,18 @@
675
767
  var running;
676
768
  try { running = Promise.resolve(tool.run(c.args, call)); } catch (e) { running = Promise.reject(e); }
677
769
  return running.then(function (value) {
678
- var kept = sized(value);
679
- ran[key] = kept;
770
+ // The change is done: it's recorded now, whatever happens to its pictures after.
771
+ ran[key] = { note: 'Done.' };
680
772
  var entry = { name: c.name, args: c.args, result: value };
681
773
  if (value && typeof value === 'object' && value.partly) entry.partly = String(value.partly);
682
774
  out.done.push(entry);
683
- results[c.id] = { id: c.id, ok: true, value: kept };
684
775
  tell({ kind: 'write', name: c.name, args: c.args, result: value });
776
+ return pictured(value).then(function (p) { return { p: p }; });
777
+ }).then(function (got) {
778
+ var kept = sized(got.p.value);
779
+ ran[key] = kept;
780
+ results[c.id] = { id: c.id, ok: true, value: kept };
781
+ if (got.p.pictures) results[c.id].pictures = got.p.pictures;
685
782
  return next(i + 1);
686
783
  }, function (e) { return fail(refused(e)); });
687
784
  }, function (e) { return fail(refused(e)); });
@@ -696,13 +793,15 @@
696
793
  if (left <= 500) return stop('time');
697
794
  // The step's own time: what's left of an app's turn, or the step's guard against a call that has stopped.
698
795
  var stepTime = Math.min(left, ACT_STEP_SECONDS * 1000);
699
- var body = { request: String(request), about: o.about || '', now: present(), tools: declared, steps: steps,
796
+ var body = { request: String(request), about: o.about || '', now: present(), tools: declared, steps: sentSteps(),
700
797
  history: (o.history || []).slice(-3), seconds: Math.floor(stepTime / 1000) };
701
798
  if (o.context !== undefined) body.context = jsonState(o.context);
702
799
  if (route) body.route = route;
703
800
  if (steps.length >= limits.steps - 1) body.final = true;
704
801
  if (o.model) body.model = o.model;
705
802
  post('/ai/act', body, stepTime, Date.now() + stepTime).then(function (answer) {
803
+ // What the step cost through a key, paid for whether or not its answer still runs.
804
+ if (typeof answer.cost === 'number' && answer.cost > 0) out.cost += answer.cost;
706
805
  // Cancelled, out of time or already over: an answer that comes late runs nothing.
707
806
  if (late()) return;
708
807
  route = answer.route || route;
@@ -728,6 +827,7 @@
728
827
  }) ? offered.map(function (c) { return c.trim(); }) : [];
729
828
  stop(null);
730
829
  }, function (e) {
830
+ if (e && typeof e.cost === 'number' && e.cost > 0) out.cost += e.cost;
731
831
  var message = refused(e);
732
832
  stop(/reach Lamp|Lamp isn't|wasn't put here|put it on this device/.test(message) ? 'unreachable' : 'error', message);
733
833
  });
@@ -928,8 +1028,12 @@
928
1028
 
929
1029
  /** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
930
1030
  * system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
931
- * model (an id `LampAI.models()` lists, such as "engine:claude:opus" or "openrouter:sonnet"; a bare "opus" is
932
- * Opus through whatever answers by default). */
1031
+ * schema (a JSON Schema the answer must fit, which holds every AI to it, for large JSON: implies json; each object
1032
+ * lists every property in `required` and has `additionalProperties: false`), model (an id `LampAI.models()` lists,
1033
+ * such as "engine:claude:opus" or "openrouter:sonnet"; a bare "opus" is Opus through whatever answers by
1034
+ * default), signal (an AbortSignal: aborting it fails the ask at once with `error.stopped`), details (true
1035
+ * resolves { answer, seconds, cost }: `cost` in US dollars, 0 when the person's own Claude Code or Codex
1036
+ * answered). */
933
1037
  ask: function (prompt, options) { return call(prompt, options); },
934
1038
 
935
1039
  /**
@@ -957,7 +1061,7 @@
957
1061
  * model: picker.value, // optional: an id from LampAI.models()
958
1062
  * onStep: function (s) { … } // { kind, name, args[, result] }
959
1063
  * });
960
- * turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; });
1064
+ * turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; r.seconds; r.cost; });
961
1065
  * turn.cancel();
962
1066
  *
963
1067
  * An argument's shape is { type: 'string' | 'integer' | 'number' | 'boolean' | 'date' | 'datetime' | 'list',
@@ -971,10 +1075,18 @@
971
1075
  * `call.key` is the same for the same write in the same turn: make the ids of what a write creates from it, or
972
1076
  * keep it on the change, so a write asked again (or a turn tried again with `turn: r.turn`) finds it instead of
973
1077
  * adding a second. The same write in one turn while the page is open isn't run twice; nothing promises more.
974
- * Throw `LampAI.refuse('sentence')` from `check` or `run` to say why something can't be done.
1078
+ * A tool's result can show the AI what the app drew, in the middle of the turn: put `LampAI.picture(canvas)` in it
1079
+ * (`{ rendered: [LampAI.picture(front), LampAI.picture(side)] }`), and the AI sees each picture in its place, so it
1080
+ * can build, look and fix in one turn. The newest few are shown, smaller than drawn; older ones the AI has seen.
1081
+ * Throw `LampAI.refuse('sentence')` from `check` or `run` to say why something can't be done. `seconds` is how long
1082
+ * the turn took and `cost` what it cost in US dollars (0 when the person's own Claude Code or Codex answered).
975
1083
  */
976
1084
  act: function (request, options) { return act(request, options); },
977
1085
 
1086
+ /** A picture for the AI to look at, in what a tool of `act` returns: a <canvas>, an <img>, or a picture's address
1087
+ * (a data: URL or a file of the app's). */
1088
+ picture: function (source) { return new ActPicture(source); },
1089
+
978
1090
  /**
979
1091
  * What the person said, as text: `audio` is a WAV in base64 as `LampVoice.listen` gives it (16 kHz mono 16-bit,
980
1092
  * at most 15 seconds), transcribed by Lamp on the Mac with the AI set up there (OpenRouter's Scribe), which
package/cli/lamps/lamp.js CHANGED
@@ -138,8 +138,20 @@
138
138
  delete asked[m.id];
139
139
  waiting({ status: m.status, body: m.body });
140
140
  };
141
- bridge = { postMessage: function (m) {
142
- return new Promise(function (resolve) { var id = ++askId; asked[id] = resolve; gate.postMessage(JSON.stringify({ id: id, path: m.path, body: m.body, timeout: m.timeout })); });
141
+ bridge = { postMessage: function (m, signal) {
142
+ return new Promise(function (resolve) {
143
+ var id = ++askId;
144
+ asked[id] = resolve;
145
+ gate.postMessage(JSON.stringify({ id: id, path: m.path, body: m.body, timeout: m.timeout }));
146
+ // A page that stops waiting tells the host, which can stop the answer (lamp dev does), and hears nothing after.
147
+ function stop() {
148
+ if (!asked[id]) return;
149
+ delete asked[id];
150
+ gate.postMessage(JSON.stringify({ id: id, cancel: true }));
151
+ resolve({ status: 499, body: '{}' });
152
+ }
153
+ if (signal) { if (signal.aborted) stop(); else signal.addEventListener('abort', stop); }
154
+ });
143
155
  } };
144
156
  }
145
157
  var preferred = null;
@@ -305,14 +317,14 @@
305
317
  // with a little more for the answer to come back.
306
318
  function AI_WAIT(seconds) { return (typeof seconds === 'number' && seconds > 0 ? Math.min(seconds, 600) : 600) * 1000 + 15000; }
307
319
 
308
- function post(path, body, timeout, deadline) {
320
+ function post(path, body, timeout, deadline, signal) {
309
321
  timeout = timeout || 240000;
310
322
  var done;
311
323
  if (!bridge && shared()) {
312
324
  done = nearby(path, body, timeout, deadline);
313
325
  } else if (bridge) {
314
326
  // The TV preview on the Mac answers for Lamp itself.
315
- done = bridge.postMessage({ path: path, body: JSON.stringify(body), timeout: timeout }).then(function (r) {
327
+ done = bridge.postMessage({ path: path, body: JSON.stringify(body), timeout: timeout }, signal).then(function (r) {
316
328
  var reply = {};
317
329
  try { reply = JSON.parse(r.body) || {}; } catch (e) {}
318
330
  return { status: r.status, reply: reply };
@@ -328,10 +340,15 @@
328
340
  });
329
341
  }
330
342
  return done.then(function (answer) {
343
+ // Stopped by the page: no failure of the AI's, so nothing is said about it.
344
+ if (signal && signal.aborted) throw stoppedFailure();
331
345
  if (answer.status === 200) return answer.reply;
332
346
  var message = answer.reply.error || 'The AI request failed.';
333
347
  if (window.console) console.error('LampAI ' + path + ' failed: ' + message);
334
- throw new Failure(message, answer.reply.needs || null);
348
+ var failed = new Failure(message, answer.reply.needs || null);
349
+ // What was billed before it failed (a retry, a timeout), for a turn's `cost`.
350
+ if (typeof answer.reply.cost === 'number' && answer.reply.cost > 0) failed.cost = answer.reply.cost;
351
+ throw failed;
335
352
  });
336
353
  }
337
354
 
@@ -346,14 +363,14 @@
346
363
  });
347
364
  }
348
365
 
349
- /** Downscales to at most 1568px on the long side (the most the model uses) and JPEG-encodes. Takes an <img>, a
350
- * <canvas>, or a picture's address (a data: URL or a file of the app's). */
351
- function encode(images) {
366
+ /** Downscales to at most 1568px on the long side (the most the model uses), or `side`, and JPEG-encodes. Takes an
367
+ * <img>, a <canvas>, or a picture's address (a data: URL or a file of the app's). */
368
+ function encode(images, side) {
352
369
  return Promise.all((images || []).map(function (image) {
353
370
  var ready = typeof image === 'string' ? loadImage(image) : Promise.resolve(image);
354
371
  return ready.then(function (source) {
355
372
  var w = source.naturalWidth || source.width, h = source.naturalHeight || source.height;
356
- var scale = Math.min(1, 1568 / Math.max(w, h, 1));
373
+ var scale = Math.min(1, (side || 1568) / Math.max(w, h, 1));
357
374
  var canvas = document.createElement('canvas');
358
375
  canvas.width = Math.max(1, Math.round(w * scale));
359
376
  canvas.height = Math.max(1, Math.round(h * scale));
@@ -483,21 +500,44 @@
483
500
  score: function (question, levels) { return { type: 'score', instructions: question, criteria: levels }; }
484
501
  };
485
502
 
503
+ function stoppedFailure() { var f = new Failure('Stopped.'); f.stopped = true; return f; }
504
+
505
+ // A promise the page can stop waiting for with an AbortSignal: it fails at once with `stopped`, whatever comes after.
506
+ function stoppable(promise, signal) {
507
+ if (!signal) return promise;
508
+ return new Promise(function (resolve, reject) {
509
+ function stop() { reject(stoppedFailure()); }
510
+ // Settled either way, so a stopped ask's own failure is never left unhandled.
511
+ if (signal.aborted) { promise.then(null, function () {}); return stop(); }
512
+ signal.addEventListener('abort', stop);
513
+ promise.then(function (v) { signal.removeEventListener('abort', stop); if (!signal.aborted) resolve(v); },
514
+ function (e) { signal.removeEventListener('abort', stop); if (!signal.aborted) reject(e); });
515
+ });
516
+ }
517
+
486
518
  function call(prompt, options) {
487
- var o = options || {}, text = prompt, rules = [o.system || '', present()];
519
+ var o = options || {}, text = prompt, rules = [o.system || '', present()], started = Date.now(), cost = 0;
520
+ var schema = o.schema && typeof o.schema === 'object' ? o.schema : null, json = !!o.json || !!schema;
488
521
  if (o.data) { var f = fenced(prompt, o.data); text = f.prompt; rules.push(f.rules); }
489
- return encode(o.images).then(function (images) {
490
- var body = { prompt: text, system: rules.filter(function (r) { return r; }).join('\n\n'), json: !!o.json, images: images };
522
+ return stoppable(encode(o.images).then(function (images) {
523
+ // Stopped before or while its pictures were read: nothing is asked.
524
+ if (o.signal && o.signal.aborted) throw stoppedFailure();
525
+ var body = { prompt: text, system: rules.filter(function (r) { return r; }).join('\n\n'), json: json, images: images };
526
+ if (schema) body.schema = schema;
491
527
  if (o.model) body.model = o.model;
492
528
  // No limit of the page's own unless it asks for one: Lamp's guard ends only a call that stopped answering.
493
529
  if (typeof o.seconds === 'number' && o.seconds > 0) body.seconds = o.seconds;
494
- return post('/ai', body, AI_WAIT(body.seconds));
530
+ return post('/ai', body, AI_WAIT(body.seconds), null, o.signal);
495
531
  }).then(function (reply) {
496
532
  if (typeof reply.text !== 'string') throw new Failure('The AI request failed.');
497
- if (!o.json) return reply.text;
498
- try { return JSON.parse(reply.text.replace(/^\s*```(?:json)?\s*|\s*```\s*$/g, '')); }
499
- catch (e) { throw new Failure('The AI\'s answer wasn\'t in the expected format. Try again.'); }
500
- });
533
+ cost = typeof reply.cost === 'number' && reply.cost > 0 ? reply.cost : 0;
534
+ var answer = reply.text;
535
+ if (json) {
536
+ try { answer = JSON.parse(reply.text.replace(/^\s*```(?:json)?\s*|\s*```\s*$/g, '')); }
537
+ catch (e) { throw new Failure('The AI\'s answer wasn\'t in the expected format. Try again.'); }
538
+ }
539
+ return o.details ? { answer: answer, seconds: Math.round((Date.now() - started) / 100) / 10, cost: cost } : answer;
540
+ }), o.signal);
501
541
  }
502
542
 
503
543
  function image(prompt, images, aspectRatio) {
@@ -538,6 +578,12 @@
538
578
  // What a turn's reads give back in all, which every later step carries again: kept under what a step may carry
539
579
  // (`MAX_BODY` in lamp's app-ai.mjs), with room for the request, the tools and the steps' own words.
540
580
  var ACT_TURN_RESULTS = 1200000;
581
+ // The pictures a step carries from its tools' results (`LampAI.picture`): the newest few, each at most this many
582
+ // pixels on its long side, together at most this many bytes, beside the text results under the step's limit
583
+ // (`MAX_PICTURES`, `MAX_BODY` in lamp's app-ai.mjs). Older ones the model has seen go as no longer shown.
584
+ var ACT_PICTURES = 6, ACT_PICTURE_SIDE = 1024, ACT_PICTURE_BYTES = 1600000;
585
+ /** A picture in a tool's result, for the AI to look at (`LampAI.picture`). */
586
+ function ActPicture(source) { this.source = source; }
541
587
  var ACT_STEP_SECONDS = 600;
542
588
  var actRan = {}; // turn id -> call key -> the result a write gave, while this copy of the page is open
543
589
  function actId() { return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
@@ -596,8 +642,50 @@
596
642
  }
597
643
  var ran = actRan[id];
598
644
  var started = Date.now(), deadline = asked ? started + Math.max(1, asked) * 1000 : Infinity;
599
- var out = { turn: id, reply: '', ask: false, choices: [], done: [], failed: [], skipped: [], reads: [], stopped: null, error: '' };
600
- var steps = [], route = null, writes = 0, over = false, settle;
645
+ var out = { turn: id, reply: '', ask: false, choices: [], done: [], failed: [], skipped: [], reads: [], stopped: null, error: '', seconds: 0, cost: 0 };
646
+ var steps = [], route = null, writes = 0, over = false, settle, pictureCount = 0;
647
+ // A result with its pictures taken out: each one sent beside it, scaled down, and named in its place
648
+ // ("[picture 2]", numbered across the turn), so the model sees what the app drew in the middle of the turn.
649
+ function pictured(value) {
650
+ var found = [];
651
+ function walk(v, depth) {
652
+ if (v instanceof ActPicture) { found.push({ source: v.source, number: ++pictureCount }); return '[picture ' + pictureCount + ']'; }
653
+ if (!v || typeof v !== 'object' || depth > 8) return v;
654
+ if (isList(v)) return v.map(function (x) { return walk(x, depth + 1); });
655
+ var proto = Object.getPrototypeOf(v);
656
+ if (proto !== Object.prototype && proto !== null) return v;
657
+ var copy = {};
658
+ for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) copy[k] = walk(v[k], depth + 1);
659
+ return copy;
660
+ }
661
+ var kept = walk(value, 0);
662
+ if (!found.length) return Promise.resolve({ value: value });
663
+ // A picture that never loads costs the turn nothing: after a few seconds the result goes without it.
664
+ var slow = new Promise(function (resolve, reject) { browserTimeout(function () { reject(new Error('slow')); }, 10000); });
665
+ return Promise.race([encode(found.map(function (f) { return f.source; }), ACT_PICTURE_SIDE), slow]).then(function (encoded) {
666
+ return { value: kept, pictures: encoded.map(function (e, i) { return { media_type: e.media_type, data: e.data, number: found[i].number }; }) };
667
+ }, function () {
668
+ return { value: { result: kept, note: 'Its pictures couldn\'t be read, so they aren\'t shown.' } };
669
+ });
670
+ }
671
+ // The steps as sent: only the newest pictures go with their data, within the step's room for them.
672
+ function sentSteps() {
673
+ // Chosen newest first by number across every result, whatever order the results are in.
674
+ var room = ACT_PICTURE_BYTES, chosen = {}, all = [];
675
+ steps.forEach(function (s) { s.results.forEach(function (r) { (r.pictures || []).forEach(function (p) { all.push(p); }); }); });
676
+ all.sort(function (a, b) { return b.number - a.number; }).forEach(function (p) {
677
+ if (Object.keys(chosen).length < ACT_PICTURES && p.data.length <= room) { chosen[p.number] = true; room -= p.data.length; }
678
+ });
679
+ return steps.map(function (s) {
680
+ return { calls: s.calls, results: s.results.map(function (r) {
681
+ if (!r.pictures) return r;
682
+ var copy = {};
683
+ for (var k in r) if (Object.prototype.hasOwnProperty.call(r, k)) copy[k] = r[k];
684
+ copy.pictures = r.pictures.map(function (p) { return { media_type: p.media_type, number: p.number, data: chosen[p.number] ? p.data : null }; });
685
+ return copy;
686
+ }) };
687
+ });
688
+ }
601
689
  var declared = Object.keys(tools).map(function (name) {
602
690
  var t = tools[name];
603
691
  return { name: name, about: t.about || '', reads: !!t.reads, args: t.args || {} };
@@ -608,6 +696,7 @@
608
696
  over = true;
609
697
  out.stopped = reason || null;
610
698
  if (error) out.error = error;
699
+ out.seconds = Math.round((Date.now() - started) / 100) / 10;
611
700
  settle(out);
612
701
  }
613
702
  // Over, or past the deadline by the clock even if its timer hasn't run yet (a slow write can carry the turn past
@@ -637,7 +726,10 @@
637
726
  }).then(function (value) {
638
727
  if (value === NOT_RUN) return;
639
728
  out.reads.push({ name: c.name, args: c.args });
640
- results[c.id] = { id: c.id, ok: true, value: sized(value) };
729
+ return pictured(value).then(function (p) {
730
+ results[c.id] = { id: c.id, ok: true, value: sized(p.value) };
731
+ if (p.pictures) results[c.id].pictures = p.pictures;
732
+ });
641
733
  }, function (e) { results[c.id] = { id: c.id, ok: false, error: refused(e) }; });
642
734
  })).then(function () {
643
735
  var stopped = null;
@@ -675,13 +767,18 @@
675
767
  var running;
676
768
  try { running = Promise.resolve(tool.run(c.args, call)); } catch (e) { running = Promise.reject(e); }
677
769
  return running.then(function (value) {
678
- var kept = sized(value);
679
- ran[key] = kept;
770
+ // The change is done: it's recorded now, whatever happens to its pictures after.
771
+ ran[key] = { note: 'Done.' };
680
772
  var entry = { name: c.name, args: c.args, result: value };
681
773
  if (value && typeof value === 'object' && value.partly) entry.partly = String(value.partly);
682
774
  out.done.push(entry);
683
- results[c.id] = { id: c.id, ok: true, value: kept };
684
775
  tell({ kind: 'write', name: c.name, args: c.args, result: value });
776
+ return pictured(value).then(function (p) { return { p: p }; });
777
+ }).then(function (got) {
778
+ var kept = sized(got.p.value);
779
+ ran[key] = kept;
780
+ results[c.id] = { id: c.id, ok: true, value: kept };
781
+ if (got.p.pictures) results[c.id].pictures = got.p.pictures;
685
782
  return next(i + 1);
686
783
  }, function (e) { return fail(refused(e)); });
687
784
  }, function (e) { return fail(refused(e)); });
@@ -696,13 +793,15 @@
696
793
  if (left <= 500) return stop('time');
697
794
  // The step's own time: what's left of an app's turn, or the step's guard against a call that has stopped.
698
795
  var stepTime = Math.min(left, ACT_STEP_SECONDS * 1000);
699
- var body = { request: String(request), about: o.about || '', now: present(), tools: declared, steps: steps,
796
+ var body = { request: String(request), about: o.about || '', now: present(), tools: declared, steps: sentSteps(),
700
797
  history: (o.history || []).slice(-3), seconds: Math.floor(stepTime / 1000) };
701
798
  if (o.context !== undefined) body.context = jsonState(o.context);
702
799
  if (route) body.route = route;
703
800
  if (steps.length >= limits.steps - 1) body.final = true;
704
801
  if (o.model) body.model = o.model;
705
802
  post('/ai/act', body, stepTime, Date.now() + stepTime).then(function (answer) {
803
+ // What the step cost through a key, paid for whether or not its answer still runs.
804
+ if (typeof answer.cost === 'number' && answer.cost > 0) out.cost += answer.cost;
706
805
  // Cancelled, out of time or already over: an answer that comes late runs nothing.
707
806
  if (late()) return;
708
807
  route = answer.route || route;
@@ -728,6 +827,7 @@
728
827
  }) ? offered.map(function (c) { return c.trim(); }) : [];
729
828
  stop(null);
730
829
  }, function (e) {
830
+ if (e && typeof e.cost === 'number' && e.cost > 0) out.cost += e.cost;
731
831
  var message = refused(e);
732
832
  stop(/reach Lamp|Lamp isn't|wasn't put here|put it on this device/.test(message) ? 'unreachable' : 'error', message);
733
833
  });
@@ -928,8 +1028,12 @@
928
1028
 
929
1029
  /** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
930
1030
  * system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
931
- * model (an id `LampAI.models()` lists, such as "engine:claude:opus" or "openrouter:sonnet"; a bare "opus" is
932
- * Opus through whatever answers by default). */
1031
+ * schema (a JSON Schema the answer must fit, which holds every AI to it, for large JSON: implies json; each object
1032
+ * lists every property in `required` and has `additionalProperties: false`), model (an id `LampAI.models()` lists,
1033
+ * such as "engine:claude:opus" or "openrouter:sonnet"; a bare "opus" is Opus through whatever answers by
1034
+ * default), signal (an AbortSignal: aborting it fails the ask at once with `error.stopped`), details (true
1035
+ * resolves { answer, seconds, cost }: `cost` in US dollars, 0 when the person's own Claude Code or Codex
1036
+ * answered). */
933
1037
  ask: function (prompt, options) { return call(prompt, options); },
934
1038
 
935
1039
  /**
@@ -957,7 +1061,7 @@
957
1061
  * model: picker.value, // optional: an id from LampAI.models()
958
1062
  * onStep: function (s) { … } // { kind, name, args[, result] }
959
1063
  * });
960
- * turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; });
1064
+ * turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; r.seconds; r.cost; });
961
1065
  * turn.cancel();
962
1066
  *
963
1067
  * An argument's shape is { type: 'string' | 'integer' | 'number' | 'boolean' | 'date' | 'datetime' | 'list',
@@ -971,10 +1075,18 @@
971
1075
  * `call.key` is the same for the same write in the same turn: make the ids of what a write creates from it, or
972
1076
  * keep it on the change, so a write asked again (or a turn tried again with `turn: r.turn`) finds it instead of
973
1077
  * adding a second. The same write in one turn while the page is open isn't run twice; nothing promises more.
974
- * Throw `LampAI.refuse('sentence')` from `check` or `run` to say why something can't be done.
1078
+ * A tool's result can show the AI what the app drew, in the middle of the turn: put `LampAI.picture(canvas)` in it
1079
+ * (`{ rendered: [LampAI.picture(front), LampAI.picture(side)] }`), and the AI sees each picture in its place, so it
1080
+ * can build, look and fix in one turn. The newest few are shown, smaller than drawn; older ones the AI has seen.
1081
+ * Throw `LampAI.refuse('sentence')` from `check` or `run` to say why something can't be done. `seconds` is how long
1082
+ * the turn took and `cost` what it cost in US dollars (0 when the person's own Claude Code or Codex answered).
975
1083
  */
976
1084
  act: function (request, options) { return act(request, options); },
977
1085
 
1086
+ /** A picture for the AI to look at, in what a tool of `act` returns: a <canvas>, an <img>, or a picture's address
1087
+ * (a data: URL or a file of the app's). */
1088
+ picture: function (source) { return new ActPicture(source); },
1089
+
978
1090
  /**
979
1091
  * What the person said, as text: `audio` is a WAV in base64 as `LampVoice.listen` gives it (16 kHz mono 16-bit,
980
1092
  * at most 15 seconds), transcribed by Lamp on the Mac with the AI set up there (OpenRouter's Scribe), which
@@ -1 +1 @@
1
- {"name":"Zqx App Name","instance":"81942121-46A7-4886-9515-F2C5D44CC566","exe":"ZqxAppName","platform":"mac"}
1
+ {"exe":"ZqxAppName","platform":"mac","instance":"B2538EDF-E84E-4703-8A9C-65006F6279AA","name":"Zqx App Name"}