@lampdotdev/lamp 0.35.0 → 0.36.2-nightly.16

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.
@@ -113,10 +113,11 @@
113
113
  image: function (name) { return loadImage(LampAssets.url(name)); }
114
114
  };
115
115
 
116
- function Failure(message, needsSetup) {
116
+ function Failure(message, needsSetup, help) {
117
117
  this.name = 'LampAI.Failure';
118
118
  this.message = message;
119
119
  this.needsSetup = needsSetup || null;
120
+ this.help = help || null;
120
121
  this.stack = (new Error(message)).stack;
121
122
  }
122
123
  Failure.prototype = Object.create(Error.prototype);
@@ -190,6 +191,8 @@
190
191
  }
191
192
 
192
193
  // Why a copy Lamp didn't put on a device has no AI: a page on the web (its published page) is away from Lamp.
194
+ // Where someone with no Lamp finds how to turn AI on, for a page away from Lamp.
195
+ var AI_HELP = 'https://lamp.dev';
193
196
  function unplaced() {
194
197
  return here.protocol === 'https:'
195
198
  ? 'This page is on the web, away from Lamp, so it has no AI here. Open it in Lamp for that.'
@@ -206,7 +209,7 @@
206
209
  var ipv4 = /^http:\/\/\d+\.\d+\.\d+\.\d+:/;
207
210
  list = list.filter(function (r) { return ipv4.test(r); }).concat(list.filter(function (r) { return !ipv4.test(r); }));
208
211
  }
209
- if (!list.length) return Promise.reject(new Failure(unplaced()));
212
+ if (!list.length) return Promise.reject(new Failure(unplaced(), null, AI_HELP));
210
213
  // With a deadline (LampAI.act's turn), each try gets only the time left, and none starts after it.
211
214
  function left() { return deadline ? deadline - Date.now() : timeout; }
212
215
  function attempt(i, round) {
@@ -229,7 +232,7 @@
229
232
  function token() {
230
233
  var kept = read('install.token', null);
231
234
  if (kept && installed && kept.app === installed.app) return Promise.resolve(kept.token);
232
- if (!installed) return Promise.reject(new Failure(unplaced()));
235
+ if (!installed) return Promise.reject(new Failure(unplaced(), null, AI_HELP));
233
236
  return send('/app/claim', { app: installed.app }, null, 10000).then(function (answer) {
234
237
  if (answer.status !== 200 || !answer.reply.token) {
235
238
  throw new Failure('This app needs Lamp to put it on this device once more before it can use AI: press Play in Lamp on your Mac.');
@@ -240,10 +243,50 @@
240
243
  });
241
244
  }
242
245
 
246
+ // A page shared on lamp.dev plays from a host of its own under play.lamp.dev, away from Lamp. The person's own
247
+ // Lamp or lamp on this computer can answer it (Lamp's `SharedPagesAI`, or `lamp ai`, at a port of the computer's
248
+ // own), once they've allowed this
249
+ // page there; the token it's given is kept with the page's saved things. Whether there's an AI is asked first,
250
+ // without one, so the person is asked only when the page uses it.
251
+ var NEARBY = 'http://127.0.0.1:47423';
252
+ function shared() { return !installed && here.protocol === 'https:' && /\.play\.lamp\.dev$/.test(here.hostname || ''); }
253
+ function safari() {
254
+ var ua = (window.navigator && navigator.userAgent) || '';
255
+ return /Safari\//.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Firefox|FxiOS/.test(ua);
256
+ }
257
+ function nearby(path, body, timeout, deadline) {
258
+ var text = JSON.stringify(body);
259
+ function go(t) { return exchange(NEARBY, path, t, text, deadline ? Math.max(1000, deadline - Date.now()) : timeout); }
260
+ function allow() {
261
+ return exchange(NEARBY, '/pair', null, JSON.stringify({ name: document.title || '' }), 130000).then(function (answer) {
262
+ if (answer.status !== 200 || !answer.reply.token) throw new Failure('This computer didn\'t allow this page to use its AI.');
263
+ write('nearby.token', answer.reply.token);
264
+ return answer.reply.token;
265
+ });
266
+ }
267
+ var kept = read('nearby.token', null);
268
+ var done = path === '/ai/status' || (body && body.probe === true) ? go(kept) : (kept ? Promise.resolve(kept) : allow()).then(function (t) {
269
+ return go(t).then(function (answer) {
270
+ if (answer.status !== 401) return answer;
271
+ // Taken back on the computer: ask once more.
272
+ forget('nearby.token');
273
+ return allow().then(go);
274
+ });
275
+ });
276
+ return done.then(null, function (e) {
277
+ if (e instanceof Failure) throw e;
278
+ // Safari never lets a page on the web reach this computer this way, so Lamp or lamp ai open here wouldn't help.
279
+ if (safari()) throw new Failure('AI here works in Chrome or in Lamp, or with Lamp\'s command line: lamp play and this page\'s link.', null, AI_HELP);
280
+ throw new Failure('AI isn\'t available here. Open Lamp on this computer, or with Lamp\'s command line, run lamp ai.', null, AI_HELP);
281
+ });
282
+ }
283
+
243
284
  function post(path, body, timeout, deadline) {
244
285
  timeout = timeout || 240000;
245
286
  var done;
246
- if (bridge) {
287
+ if (!bridge && shared()) {
288
+ done = nearby(path, body, timeout, deadline);
289
+ } else if (bridge) {
247
290
  // The TV preview on the Mac answers for Lamp itself.
248
291
  done = bridge.postMessage({ path: path, body: JSON.stringify(body), timeout: timeout }).then(function (r) {
249
292
  var reply = {};
@@ -454,8 +497,11 @@
454
497
 
455
498
  // Doing things for the person: LampAI.act
456
499
 
457
- // 60 seconds is the longest any route allows; each answer says its route's own (`seconds`), and the turn keeps to it.
458
- var ACT_LIMITS = { steps: 4, writes: 6, seconds: 60 };
500
+ // A turn has no time limit of its own: a real answer is never cut off, however long the model takes. Each step is
501
+ // given ACT_STEP_SECONDS, which only a call that has stopped answering reaches. An app that wants a quicker turn
502
+ // asks for one (`limits: { seconds }`); steps and writes it may only lower.
503
+ var ACT_LIMITS = { steps: 4, writes: 6 };
504
+ var ACT_STEP_SECONDS = 600;
459
505
  var actRan = {}; // turn id -> call key -> the result a write gave, while this copy of the page is open
460
506
  function actId() { return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
461
507
  function isList(v) { return Object.prototype.toString.call(v) === '[object Array]'; }
@@ -481,10 +527,11 @@
481
527
  function act(request, options) {
482
528
  var o = options || {}, tools = o.tools || {};
483
529
  var limits = {};
484
- ['steps', 'writes', 'seconds'].forEach(function (k) {
530
+ ['steps', 'writes'].forEach(function (k) {
485
531
  var asked = o.limits && typeof o.limits[k] === 'number' ? o.limits[k] : ACT_LIMITS[k];
486
532
  limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS[k]));
487
533
  });
534
+ var asked = o.limits && typeof o.limits.seconds === 'number' && o.limits.seconds > 0 ? o.limits.seconds : null;
488
535
  var id = o.turn || actId();
489
536
  if (!actRan[id]) {
490
537
  var kept = Object.keys(actRan);
@@ -492,7 +539,7 @@
492
539
  actRan[id] = {};
493
540
  }
494
541
  var ran = actRan[id];
495
- var started = Date.now(), deadline = started + limits.seconds * 1000;
542
+ var started = Date.now(), deadline = asked ? started + Math.max(1, asked) * 1000 : Infinity;
496
543
  var out = { turn: id, reply: '', ask: false, choices: [], done: [], failed: [], skipped: [], reads: [], stopped: null, error: '' };
497
544
  var steps = [], route = null, writes = 0, over = false, settle;
498
545
  var declared = Object.keys(tools).map(function (name) {
@@ -514,7 +561,7 @@
514
561
  return over;
515
562
  }
516
563
  var result = new Promise(function (resolve) { settle = resolve; });
517
- var timer = setTimeout(function () { stop('time'); }, limits.seconds * 1000);
564
+ var timer = asked ? setTimeout(function () { stop('time'); }, Math.max(1, asked) * 1000) : null;
518
565
  result.then(function () { clearTimeout(timer); });
519
566
 
520
567
  // Reads run together; then the writes, one after another, each checked right before it runs. A write that
@@ -590,23 +637,15 @@
590
637
  if (over) return;
591
638
  var left = deadline - Date.now();
592
639
  if (left <= 500) return stop('time');
640
+ // The step's own time: what's left of an app's turn, or the step's guard against a call that has stopped.
641
+ var stepTime = Math.min(left, ACT_STEP_SECONDS * 1000);
593
642
  var body = { request: String(request), about: o.about || '', now: present(), tools: declared, steps: steps,
594
- history: (o.history || []).slice(-3), seconds: Math.floor(left / 1000) };
643
+ history: (o.history || []).slice(-3), seconds: Math.floor(stepTime / 1000) };
595
644
  if (o.context !== undefined) body.context = jsonState(o.context);
596
645
  if (route) body.route = route;
597
646
  if (steps.length >= limits.steps - 1) body.final = true;
598
647
  if (o.model) body.model = o.model;
599
- post('/ai/act', body, left, deadline).then(function (answer) {
600
- // The route's own time for a turn (25 seconds over OpenRouter, a minute through Claude Code, Codex or a model
601
- // on the Mac) shortens the turn's deadline, counted from its start; it never lengthens it.
602
- if (!over && typeof answer.seconds === 'number' && answer.seconds > 0) {
603
- var end = started + Math.min(answer.seconds, limits.seconds) * 1000;
604
- if (end < deadline) {
605
- deadline = end;
606
- clearTimeout(timer);
607
- timer = setTimeout(function () { stop('time'); }, Math.max(0, deadline - Date.now()));
608
- }
609
- }
648
+ post('/ai/act', body, stepTime, Date.now() + stepTime).then(function (answer) {
610
649
  // Cancelled, out of time or already over: an answer that comes late runs nothing.
611
650
  if (late()) return;
612
651
  route = answer.route || route;
@@ -693,24 +732,42 @@
693
732
  * Text calls take a few seconds; decisions well under a second; image calls 10-60 seconds. Show `error.message` on
694
733
  * failure. Aspect ratios look like "1:1", "16:9", "3:4".
695
734
  *
696
- * Not every Mac has every kind of AI set up, and a device away from the Mac's Wi-Fi, or with Lamp closed, has none.
697
- * `LampAI.isAvailable('pictures')` (or 'text', 'decisions') says whether it can answer now: check it where a
698
- * feature depends on it, and show the feature with a short note ("Pictures need AI set up in Lamp") rather than
699
- * hiding it or failing at it. A call that fails for that reason has `needsSetup` set on its error.
735
+ * Not every Mac has every kind of AI set up, a device away from the Mac's Wi-Fi or with Lamp closed has none, and a
736
+ * page shared on lamp.dev has it only on a computer that answers it. `LampAI.status('pictures')` (or 'text',
737
+ * 'decisions', 'act', 'hear') says whether it can answer now and, when it can't, the line to show: check it where
738
+ * a feature depends on it, and show the feature with its `reason` as it's given rather than words of the app's
739
+ * own, or hiding it or failing at it, and where it gives `help` (a page away from Lamp), a link to it beside the
740
+ * reason: "How to turn on AI". A call that fails for that reason has `needsSetup`, or `help`, set on its error.
700
741
  */
701
742
  var LampAI = {
702
743
  Failure: Failure,
703
744
 
704
- /** Whether this kind of AI ('text', 'decisions', 'pictures' or 'act') can answer now. False when Lamp can't be reached. */
705
- isAvailable: function (capability) {
706
- // 'act' is asked of /ai/act itself: true only when this lamp.js has it, the app around the page lets the
707
- // route through (an older one answers Not found) and the AI that answers the app can take it.
708
- if (capability === 'act') return post('/ai/act', { probe: true }, 10000).then(function (reply) { return reply.act === true; }, function () { return false; });
709
- // 'hear' the same way: this lamp.js, the app around the page and the AI set up in Lamp can all take it.
710
- if (capability === 'hear') return post('/ai/hear', { probe: true }, 10000).then(function (reply) { return reply.hear === true; }, function () { return false; });
711
- return post('/ai/status', {}, 10000).then(function (reply) { return reply[capability] === true; }, function () { return false; });
745
+ /** Whether this kind of AI ('text', 'decisions', 'pictures', 'act' or 'hear') can answer now: { available, reason,
746
+ * help }, where `reason` is the short line to show when it can't, written for where the page is, and `help`, when
747
+ * it's there, the page that says how to turn AI on, to link to beside it. */
748
+ status: function (capability) {
749
+ var no = function (reason, help) {
750
+ var said = { available: false, reason: reason || 'AI isn\'t set up for this here.' };
751
+ if (help) said.help = help;
752
+ return said;
753
+ };
754
+ // 'act' is asked of /ai/act itself: available only when this lamp.js has it, the app around the page lets the
755
+ // route through (an older one answers Not found) and the AI that answers the app can take it. 'hear' the same.
756
+ if (capability === 'act' || capability === 'hear') {
757
+ return post('/ai/' + capability, { probe: true }, 10000).then(function (reply) {
758
+ return reply[capability] === true ? { available: true, reason: '' } : no(reply.reason || reply.why);
759
+ }, function (e) { return no(e && e.message, e && e.help); });
760
+ }
761
+ return post('/ai/status', {}, 10000).then(function (reply) {
762
+ if (reply[capability] === true) return { available: true, reason: '' };
763
+ var detail = reply.details && reply.details[capability];
764
+ return no(detail && detail.reason);
765
+ }, function (e) { return no(e && e.message, e && e.help); });
712
766
  },
713
767
 
768
+ /** Whether this kind of AI can answer now, as `status` says. */
769
+ isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
770
+
714
771
  /** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
715
772
  * system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
716
773
  * model (Lamp's id: "sonnet", "opus", "haiku", "claude-fable-5-2"). */
@@ -719,7 +776,7 @@
719
776
  /**
720
777
  * Does what the person asks, in their own words, with the app's own reads and writes: the model picks calls,
721
778
  * the page runs them, the model sees what happened, until it replies (at most 4 model calls and 6 writes a turn,
722
- * and 25 seconds over OpenRouter or a minute through Claude Code, Codex or a model on the Mac). Check `LampAI.isAvailable('act')` first; keep the app's buttons for everything.
779
+ * with no time limit unless the app sets one with `limits: { seconds }`; Cancel ends it at once). Check `LampAI.isAvailable('act')` first; keep the app's buttons for everything.
723
780
  *
724
781
  * var turn = LampAI.act('Add milk', {
725
782
  * about: 'A family dashboard: the calendar, chores and the shopping list.',
@@ -816,6 +873,7 @@
816
873
  * probability, choice, score and confidence. Fails only when no item could be judged.
817
874
  */
818
875
  decideEach: function (items, questions, options) {
876
+ var failed = null;
819
877
  var o = options || {}, about = o.about || function (item) { return item; };
820
878
  var limit = Math.max(1, o.concurrency || 12), total = items.length, done = 0, next = 0;
821
879
  var results = new Array(total), saved = o.remember ? read('verdicts', {}) : null, now = Date.now();
@@ -843,7 +901,7 @@
843
901
  for (var k in (reply.answers || {})) if (reply.answers.hasOwnProperty(k)) answers[k] = decision(reply.answers[k]);
844
902
  if (key) saved[key] = { answers: reply.answers, until: now + o.remember * 1000 };
845
903
  finish(i, answers);
846
- }, function (error) { finish(i, null, error.message); });
904
+ }, function (error) { if (!failed) failed = error; finish(i, null, error.message); });
847
905
  }
848
906
  function worker() {
849
907
  if (next >= total) return Promise.resolve();
@@ -859,7 +917,8 @@
859
917
  keys.slice(-2000).forEach(function (k) { kept[k] = saved[k]; });
860
918
  write('verdicts', kept);
861
919
  }
862
- if (total && results.every(function (r) { return r.error; })) throw new Failure(results[0].error);
920
+ // Every item failed: the first failure's setup and help go with it, as a single decision's would.
921
+ if (total && results.every(function (r) { return r.error; })) throw new Failure(results[0].error, failed && failed.needsSetup, failed && failed.help);
863
922
  return results;
864
923
  });
865
924
  },
package/cli/lamps/lamp.js CHANGED
@@ -113,10 +113,11 @@
113
113
  image: function (name) { return loadImage(LampAssets.url(name)); }
114
114
  };
115
115
 
116
- function Failure(message, needsSetup) {
116
+ function Failure(message, needsSetup, help) {
117
117
  this.name = 'LampAI.Failure';
118
118
  this.message = message;
119
119
  this.needsSetup = needsSetup || null;
120
+ this.help = help || null;
120
121
  this.stack = (new Error(message)).stack;
121
122
  }
122
123
  Failure.prototype = Object.create(Error.prototype);
@@ -190,6 +191,8 @@
190
191
  }
191
192
 
192
193
  // Why a copy Lamp didn't put on a device has no AI: a page on the web (its published page) is away from Lamp.
194
+ // Where someone with no Lamp finds how to turn AI on, for a page away from Lamp.
195
+ var AI_HELP = 'https://lamp.dev';
193
196
  function unplaced() {
194
197
  return here.protocol === 'https:'
195
198
  ? 'This page is on the web, away from Lamp, so it has no AI here. Open it in Lamp for that.'
@@ -206,7 +209,7 @@
206
209
  var ipv4 = /^http:\/\/\d+\.\d+\.\d+\.\d+:/;
207
210
  list = list.filter(function (r) { return ipv4.test(r); }).concat(list.filter(function (r) { return !ipv4.test(r); }));
208
211
  }
209
- if (!list.length) return Promise.reject(new Failure(unplaced()));
212
+ if (!list.length) return Promise.reject(new Failure(unplaced(), null, AI_HELP));
210
213
  // With a deadline (LampAI.act's turn), each try gets only the time left, and none starts after it.
211
214
  function left() { return deadline ? deadline - Date.now() : timeout; }
212
215
  function attempt(i, round) {
@@ -229,7 +232,7 @@
229
232
  function token() {
230
233
  var kept = read('install.token', null);
231
234
  if (kept && installed && kept.app === installed.app) return Promise.resolve(kept.token);
232
- if (!installed) return Promise.reject(new Failure(unplaced()));
235
+ if (!installed) return Promise.reject(new Failure(unplaced(), null, AI_HELP));
233
236
  return send('/app/claim', { app: installed.app }, null, 10000).then(function (answer) {
234
237
  if (answer.status !== 200 || !answer.reply.token) {
235
238
  throw new Failure('This app needs Lamp to put it on this device once more before it can use AI: press Play in Lamp on your Mac.');
@@ -240,10 +243,50 @@
240
243
  });
241
244
  }
242
245
 
246
+ // A page shared on lamp.dev plays from a host of its own under play.lamp.dev, away from Lamp. The person's own
247
+ // Lamp or lamp on this computer can answer it (Lamp's `SharedPagesAI`, or `lamp ai`, at a port of the computer's
248
+ // own), once they've allowed this
249
+ // page there; the token it's given is kept with the page's saved things. Whether there's an AI is asked first,
250
+ // without one, so the person is asked only when the page uses it.
251
+ var NEARBY = 'http://127.0.0.1:47423';
252
+ function shared() { return !installed && here.protocol === 'https:' && /\.play\.lamp\.dev$/.test(here.hostname || ''); }
253
+ function safari() {
254
+ var ua = (window.navigator && navigator.userAgent) || '';
255
+ return /Safari\//.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Firefox|FxiOS/.test(ua);
256
+ }
257
+ function nearby(path, body, timeout, deadline) {
258
+ var text = JSON.stringify(body);
259
+ function go(t) { return exchange(NEARBY, path, t, text, deadline ? Math.max(1000, deadline - Date.now()) : timeout); }
260
+ function allow() {
261
+ return exchange(NEARBY, '/pair', null, JSON.stringify({ name: document.title || '' }), 130000).then(function (answer) {
262
+ if (answer.status !== 200 || !answer.reply.token) throw new Failure('This computer didn\'t allow this page to use its AI.');
263
+ write('nearby.token', answer.reply.token);
264
+ return answer.reply.token;
265
+ });
266
+ }
267
+ var kept = read('nearby.token', null);
268
+ var done = path === '/ai/status' || (body && body.probe === true) ? go(kept) : (kept ? Promise.resolve(kept) : allow()).then(function (t) {
269
+ return go(t).then(function (answer) {
270
+ if (answer.status !== 401) return answer;
271
+ // Taken back on the computer: ask once more.
272
+ forget('nearby.token');
273
+ return allow().then(go);
274
+ });
275
+ });
276
+ return done.then(null, function (e) {
277
+ if (e instanceof Failure) throw e;
278
+ // Safari never lets a page on the web reach this computer this way, so Lamp or lamp ai open here wouldn't help.
279
+ if (safari()) throw new Failure('AI here works in Chrome or in Lamp, or with Lamp\'s command line: lamp play and this page\'s link.', null, AI_HELP);
280
+ throw new Failure('AI isn\'t available here. Open Lamp on this computer, or with Lamp\'s command line, run lamp ai.', null, AI_HELP);
281
+ });
282
+ }
283
+
243
284
  function post(path, body, timeout, deadline) {
244
285
  timeout = timeout || 240000;
245
286
  var done;
246
- if (bridge) {
287
+ if (!bridge && shared()) {
288
+ done = nearby(path, body, timeout, deadline);
289
+ } else if (bridge) {
247
290
  // The TV preview on the Mac answers for Lamp itself.
248
291
  done = bridge.postMessage({ path: path, body: JSON.stringify(body), timeout: timeout }).then(function (r) {
249
292
  var reply = {};
@@ -454,8 +497,11 @@
454
497
 
455
498
  // Doing things for the person: LampAI.act
456
499
 
457
- // 60 seconds is the longest any route allows; each answer says its route's own (`seconds`), and the turn keeps to it.
458
- var ACT_LIMITS = { steps: 4, writes: 6, seconds: 60 };
500
+ // A turn has no time limit of its own: a real answer is never cut off, however long the model takes. Each step is
501
+ // given ACT_STEP_SECONDS, which only a call that has stopped answering reaches. An app that wants a quicker turn
502
+ // asks for one (`limits: { seconds }`); steps and writes it may only lower.
503
+ var ACT_LIMITS = { steps: 4, writes: 6 };
504
+ var ACT_STEP_SECONDS = 600;
459
505
  var actRan = {}; // turn id -> call key -> the result a write gave, while this copy of the page is open
460
506
  function actId() { return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
461
507
  function isList(v) { return Object.prototype.toString.call(v) === '[object Array]'; }
@@ -481,10 +527,11 @@
481
527
  function act(request, options) {
482
528
  var o = options || {}, tools = o.tools || {};
483
529
  var limits = {};
484
- ['steps', 'writes', 'seconds'].forEach(function (k) {
530
+ ['steps', 'writes'].forEach(function (k) {
485
531
  var asked = o.limits && typeof o.limits[k] === 'number' ? o.limits[k] : ACT_LIMITS[k];
486
532
  limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS[k]));
487
533
  });
534
+ var asked = o.limits && typeof o.limits.seconds === 'number' && o.limits.seconds > 0 ? o.limits.seconds : null;
488
535
  var id = o.turn || actId();
489
536
  if (!actRan[id]) {
490
537
  var kept = Object.keys(actRan);
@@ -492,7 +539,7 @@
492
539
  actRan[id] = {};
493
540
  }
494
541
  var ran = actRan[id];
495
- var started = Date.now(), deadline = started + limits.seconds * 1000;
542
+ var started = Date.now(), deadline = asked ? started + Math.max(1, asked) * 1000 : Infinity;
496
543
  var out = { turn: id, reply: '', ask: false, choices: [], done: [], failed: [], skipped: [], reads: [], stopped: null, error: '' };
497
544
  var steps = [], route = null, writes = 0, over = false, settle;
498
545
  var declared = Object.keys(tools).map(function (name) {
@@ -514,7 +561,7 @@
514
561
  return over;
515
562
  }
516
563
  var result = new Promise(function (resolve) { settle = resolve; });
517
- var timer = setTimeout(function () { stop('time'); }, limits.seconds * 1000);
564
+ var timer = asked ? setTimeout(function () { stop('time'); }, Math.max(1, asked) * 1000) : null;
518
565
  result.then(function () { clearTimeout(timer); });
519
566
 
520
567
  // Reads run together; then the writes, one after another, each checked right before it runs. A write that
@@ -590,23 +637,15 @@
590
637
  if (over) return;
591
638
  var left = deadline - Date.now();
592
639
  if (left <= 500) return stop('time');
640
+ // The step's own time: what's left of an app's turn, or the step's guard against a call that has stopped.
641
+ var stepTime = Math.min(left, ACT_STEP_SECONDS * 1000);
593
642
  var body = { request: String(request), about: o.about || '', now: present(), tools: declared, steps: steps,
594
- history: (o.history || []).slice(-3), seconds: Math.floor(left / 1000) };
643
+ history: (o.history || []).slice(-3), seconds: Math.floor(stepTime / 1000) };
595
644
  if (o.context !== undefined) body.context = jsonState(o.context);
596
645
  if (route) body.route = route;
597
646
  if (steps.length >= limits.steps - 1) body.final = true;
598
647
  if (o.model) body.model = o.model;
599
- post('/ai/act', body, left, deadline).then(function (answer) {
600
- // The route's own time for a turn (25 seconds over OpenRouter, a minute through Claude Code, Codex or a model
601
- // on the Mac) shortens the turn's deadline, counted from its start; it never lengthens it.
602
- if (!over && typeof answer.seconds === 'number' && answer.seconds > 0) {
603
- var end = started + Math.min(answer.seconds, limits.seconds) * 1000;
604
- if (end < deadline) {
605
- deadline = end;
606
- clearTimeout(timer);
607
- timer = setTimeout(function () { stop('time'); }, Math.max(0, deadline - Date.now()));
608
- }
609
- }
648
+ post('/ai/act', body, stepTime, Date.now() + stepTime).then(function (answer) {
610
649
  // Cancelled, out of time or already over: an answer that comes late runs nothing.
611
650
  if (late()) return;
612
651
  route = answer.route || route;
@@ -693,24 +732,42 @@
693
732
  * Text calls take a few seconds; decisions well under a second; image calls 10-60 seconds. Show `error.message` on
694
733
  * failure. Aspect ratios look like "1:1", "16:9", "3:4".
695
734
  *
696
- * Not every Mac has every kind of AI set up, and a device away from the Mac's Wi-Fi, or with Lamp closed, has none.
697
- * `LampAI.isAvailable('pictures')` (or 'text', 'decisions') says whether it can answer now: check it where a
698
- * feature depends on it, and show the feature with a short note ("Pictures need AI set up in Lamp") rather than
699
- * hiding it or failing at it. A call that fails for that reason has `needsSetup` set on its error.
735
+ * Not every Mac has every kind of AI set up, a device away from the Mac's Wi-Fi or with Lamp closed has none, and a
736
+ * page shared on lamp.dev has it only on a computer that answers it. `LampAI.status('pictures')` (or 'text',
737
+ * 'decisions', 'act', 'hear') says whether it can answer now and, when it can't, the line to show: check it where
738
+ * a feature depends on it, and show the feature with its `reason` as it's given rather than words of the app's
739
+ * own, or hiding it or failing at it, and where it gives `help` (a page away from Lamp), a link to it beside the
740
+ * reason: "How to turn on AI". A call that fails for that reason has `needsSetup`, or `help`, set on its error.
700
741
  */
701
742
  var LampAI = {
702
743
  Failure: Failure,
703
744
 
704
- /** Whether this kind of AI ('text', 'decisions', 'pictures' or 'act') can answer now. False when Lamp can't be reached. */
705
- isAvailable: function (capability) {
706
- // 'act' is asked of /ai/act itself: true only when this lamp.js has it, the app around the page lets the
707
- // route through (an older one answers Not found) and the AI that answers the app can take it.
708
- if (capability === 'act') return post('/ai/act', { probe: true }, 10000).then(function (reply) { return reply.act === true; }, function () { return false; });
709
- // 'hear' the same way: this lamp.js, the app around the page and the AI set up in Lamp can all take it.
710
- if (capability === 'hear') return post('/ai/hear', { probe: true }, 10000).then(function (reply) { return reply.hear === true; }, function () { return false; });
711
- return post('/ai/status', {}, 10000).then(function (reply) { return reply[capability] === true; }, function () { return false; });
745
+ /** Whether this kind of AI ('text', 'decisions', 'pictures', 'act' or 'hear') can answer now: { available, reason,
746
+ * help }, where `reason` is the short line to show when it can't, written for where the page is, and `help`, when
747
+ * it's there, the page that says how to turn AI on, to link to beside it. */
748
+ status: function (capability) {
749
+ var no = function (reason, help) {
750
+ var said = { available: false, reason: reason || 'AI isn\'t set up for this here.' };
751
+ if (help) said.help = help;
752
+ return said;
753
+ };
754
+ // 'act' is asked of /ai/act itself: available only when this lamp.js has it, the app around the page lets the
755
+ // route through (an older one answers Not found) and the AI that answers the app can take it. 'hear' the same.
756
+ if (capability === 'act' || capability === 'hear') {
757
+ return post('/ai/' + capability, { probe: true }, 10000).then(function (reply) {
758
+ return reply[capability] === true ? { available: true, reason: '' } : no(reply.reason || reply.why);
759
+ }, function (e) { return no(e && e.message, e && e.help); });
760
+ }
761
+ return post('/ai/status', {}, 10000).then(function (reply) {
762
+ if (reply[capability] === true) return { available: true, reason: '' };
763
+ var detail = reply.details && reply.details[capability];
764
+ return no(detail && detail.reason);
765
+ }, function (e) { return no(e && e.message, e && e.help); });
712
766
  },
713
767
 
768
+ /** Whether this kind of AI can answer now, as `status` says. */
769
+ isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
770
+
714
771
  /** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
715
772
  * system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
716
773
  * model (Lamp's id: "sonnet", "opus", "haiku", "claude-fable-5-2"). */
@@ -719,7 +776,7 @@
719
776
  /**
720
777
  * Does what the person asks, in their own words, with the app's own reads and writes: the model picks calls,
721
778
  * the page runs them, the model sees what happened, until it replies (at most 4 model calls and 6 writes a turn,
722
- * and 25 seconds over OpenRouter or a minute through Claude Code, Codex or a model on the Mac). Check `LampAI.isAvailable('act')` first; keep the app's buttons for everything.
779
+ * with no time limit unless the app sets one with `limits: { seconds }`; Cancel ends it at once). Check `LampAI.isAvailable('act')` first; keep the app's buttons for everything.
723
780
  *
724
781
  * var turn = LampAI.act('Add milk', {
725
782
  * about: 'A family dashboard: the calendar, chores and the shopping list.',
@@ -816,6 +873,7 @@
816
873
  * probability, choice, score and confidence. Fails only when no item could be judged.
817
874
  */
818
875
  decideEach: function (items, questions, options) {
876
+ var failed = null;
819
877
  var o = options || {}, about = o.about || function (item) { return item; };
820
878
  var limit = Math.max(1, o.concurrency || 12), total = items.length, done = 0, next = 0;
821
879
  var results = new Array(total), saved = o.remember ? read('verdicts', {}) : null, now = Date.now();
@@ -843,7 +901,7 @@
843
901
  for (var k in (reply.answers || {})) if (reply.answers.hasOwnProperty(k)) answers[k] = decision(reply.answers[k]);
844
902
  if (key) saved[key] = { answers: reply.answers, until: now + o.remember * 1000 };
845
903
  finish(i, answers);
846
- }, function (error) { finish(i, null, error.message); });
904
+ }, function (error) { if (!failed) failed = error; finish(i, null, error.message); });
847
905
  }
848
906
  function worker() {
849
907
  if (next >= total) return Promise.resolve();
@@ -859,7 +917,8 @@
859
917
  keys.slice(-2000).forEach(function (k) { kept[k] = saved[k]; });
860
918
  write('verdicts', kept);
861
919
  }
862
- if (total && results.every(function (r) { return r.error; })) throw new Failure(results[0].error);
920
+ // Every item failed: the first failure's setup and help go with it, as a single decision's would.
921
+ if (total && results.every(function (r) { return r.error; })) throw new Failure(results[0].error, failed && failed.needsSetup, failed && failed.help);
863
922
  return results;
864
923
  });
865
924
  },
@@ -1 +1 @@
1
- {"exe":"ZqxAppName","name":"Zqx App Name","platform":"mac","instance":"3986240D-BDA8-4CAF-B8C3-9F96F9A932FB"}
1
+ {"instance":"0B0B3EAA-A528-48AA-99D7-F9045656CF78","name":"Zqx App Name","exe":"ZqxAppName","platform":"mac"}