@lampdotdev/lamp 0.35.0 → 0.36.2-nightly.17

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/link.mjs CHANGED
@@ -22,6 +22,9 @@ const REFUSALS = {
22
22
  rate_limited: "the site has taken as many as it takes for now: try again in a while",
23
23
  service_unavailable: "the site can't keep it just now: try again in a moment",
24
24
  not_found: "the link is no longer there",
25
+ invalid_username: "a username is 2 to 30 lowercase letters, digits and dashes, starting and ending with a letter or digit, and not one of the site's own words",
26
+ username_taken: "that username is someone else's: choose another",
27
+ invalid_email: "that isn't an email address the site can write to",
25
28
  };
26
29
 
27
30
  /** The site couldn't be reached at all: offline, or the site is down. */
@@ -32,14 +35,15 @@ export class Unreachable extends Problem {}
32
35
  * a little later each time, when the site can't be reached or can't answer just now; any other is sent once. A refusal
33
36
  * carries the status the site gave it. No redirect is followed: a link's secret goes to the site and nowhere else.
34
37
  */
35
- export async function call(method, url, { secret, json, bytes, tries = 1 } = {}) {
38
+ export async function call(method, url, { secret, account, json, bytes, tries = 1, seconds = 120 } = {}) {
36
39
  const headers = {
37
40
  ...(secret ? { authorization: `Bearer ${secret}` } : {}),
41
+ ...(account ? { "x-lamp-account": account } : {}),
38
42
  ...(json ? { "content-type": "application/json" } : bytes ? { "content-type": "application/octet-stream" } : {}),
39
43
  };
40
44
  for (let attempt = 0; ; attempt++) {
41
45
  let answer = null;
42
- try { answer = await fetch(url, { method, headers, body: json ? JSON.stringify(json) : bytes, redirect: "manual", signal: AbortSignal.timeout(120_000) }); } catch {}
46
+ try { answer = await fetch(url, { method, headers, body: json ? JSON.stringify(json) : bytes, redirect: "manual", signal: AbortSignal.timeout(seconds * 1000) }); } catch {}
43
47
  if (answer && answer.status < 500) {
44
48
  const said = await answer.json().catch(() => null);
45
49
  if (answer.ok) return { status: answer.status, said: said ?? {} };
@@ -65,33 +69,65 @@ function onSite(site, address) {
65
69
  * name, line, words, who it's by, the questions its page asks, its shape, picture and icon). A link the site no longer
66
70
  * has is made again. Answers its id, its address and its secret.
67
71
  */
68
- export async function publishLink({ site = siteOrigin(), body, earlier }) {
72
+ export async function publishLink({ site = siteOrigin(), body, earlier, account }) {
69
73
  if (earlier) {
70
74
  try {
71
- const { said } = await call("PUT", `${site}/api/wishes?id=${encodeURIComponent(earlier.id)}`, { secret: earlier.secret, json: body });
75
+ const { said } = await call("PUT", `${site}/api/wishes?id=${encodeURIComponent(earlier.id)}`, { secret: earlier.secret, account, json: body });
72
76
  // A change keeps the link; its address follows the name, so a rename shows in it (the old one still works).
73
77
  return { id: earlier.id, url: onSite(site, said.url) ?? earlier.url ?? null, secret: earlier.secret };
74
78
  } catch (error) { if (error.status !== 404) throw error; }
75
79
  }
76
- const { said } = await call("POST", `${site}/api/wishes`, { json: body });
80
+ const { said } = await call("POST", `${site}/api/wishes`, { account, json: body });
77
81
  const url = onSite(site, said.url);
78
82
  if (typeof said.id !== "string" || typeof said.secret !== "string" || !url) throw new Problem("the site's answer couldn't be read");
79
83
  return { id: said.id, url, secret: said.secret };
80
84
  }
81
85
 
86
+ /**
87
+ * Asks for a maker's account (`username` and `email`), or with an address that has one, to sign in to it: answers
88
+ * `{ username, key, existing, sent }`. The key is the account's once the email's link is opened.
89
+ */
90
+ export async function askAccount({ site = siteOrigin(), username, email }) {
91
+ const { said } = await call("POST", `${site}/api/accounts`, { json: { ...(username ? { username } : {}), email } });
92
+ if (typeof said.key !== "string" || typeof said.username !== "string") throw new Problem("the site's answer couldn't be read");
93
+ return said;
94
+ }
95
+
96
+ /** The username a key is an account's, once the email's link was opened; null before. */
97
+ export async function accountName({ site = siteOrigin(), key }) {
98
+ const { said } = await call("GET", `${site}/api/accounts`, { secret: key, seconds: 20 });
99
+ return typeof said.username === "string" ? said.username : null;
100
+ }
101
+
82
102
  /**
83
103
  * What a followed link has now: its name, who it's by (null for nobody), its file's version (0 with no file), and the
84
104
  * file's address, only from where the site keeps them. Null when it can't be asked, or is gone. Asks with nothing of
85
105
  * the person's.
86
106
  */
87
- export async function latestOf({ site = siteOrigin(), id }) {
107
+ export async function latestOf({ site = siteOrigin(), id, user, app }) {
88
108
  let said;
89
- try { ({ said } = await call("GET", `${site}/api/wishes?id=${encodeURIComponent(id)}`)); } catch { return null; }
109
+ const asked = id ? `id=${encodeURIComponent(id)}` : `user=${encodeURIComponent(user)}&app=${encodeURIComponent(app)}`;
110
+ try { ({ said } = await call("GET", `${site}/api/wishes?${asked}`)); } catch { return null; }
90
111
  if (said.format !== 1 || !Number.isInteger(said.version)) return null;
91
112
  let file = null;
92
113
  try { const url = new URL(said.file); if (url.protocol === "https:" || (url.protocol === "data:" && new URL(site).protocol === "http:")) file = url.href; } catch {}
93
114
  const by = typeof said.by === "string" ? said.by.trim().slice(0, LIMITS.by) : "";
94
- return { name: typeof said.name === "string" ? said.name : null, by: by || null, version: said.version, file };
115
+ return { id: typeof said.id === "string" ? said.id : id ?? null, name: typeof said.name === "string" ? said.name : null, by: by || null, version: said.version, file,
116
+ // Absent from a site from before that doesn't say; null while a link's first page is still being stored.
117
+ page: !("page" in said) ? undefined : Number.isInteger(said.page) ? said.page : null };
118
+ }
119
+
120
+ /**
121
+ * The site stores a page's files after it answers its last part, and the link shows the page before until they're in:
122
+ * waits until it shows `version` (a minute at most; a site from before that doesn't say is taken at its word).
123
+ */
124
+ export async function pageUp({ site = siteOrigin(), id, version, said = () => {} }) {
125
+ for (let waited = 0; waited < 60; waited += 2) {
126
+ const now = await latestOf({ site, id });
127
+ if (!now || now.page === undefined || (Number.isInteger(now.page) && now.page >= version)) return;
128
+ if (waited === 0) said("Waiting for lamp.dev to put the page up…");
129
+ await sleep(2000);
130
+ }
95
131
  }
96
132
 
97
133
  const fileAddress = (site, id, more = "") => `${site}/api/wish-file?id=${encodeURIComponent(id)}${more}`;
@@ -108,7 +144,8 @@ export async function sendFile({ site = siteOrigin(), link, bytes, page = false,
108
144
  said(`Sending ${page ? "the page" : "the file"}: part ${n + 1} of ${count}`);
109
145
  await call("PUT", fileAddress(site, link.id, `${what}&part=${n}&of=${count}`), { secret: link.secret, bytes: bytes.subarray(n * FILE_LIMITS.part, (n + 1) * FILE_LIMITS.part), tries: 4 });
110
146
  }
111
- return (await call("POST", fileAddress(site, link.id, what), { secret: link.secret })).said.version ?? null;
147
+ // Putting a page together stores each of its files, which for a big game's takes minutes (the site gives it five).
148
+ return (await call("POST", fileAddress(site, link.id, what), { secret: link.secret, seconds: 330 })).said.version ?? null;
112
149
  }
113
150
 
114
151
  /** Takes the link's file away, which keeps showing the idea, and its page with it; with `page` only the page. */
@@ -118,19 +155,46 @@ export async function removeFile({ site = siteOrigin(), link, page = false }) {
118
155
  }
119
156
 
120
157
  /** The ideas sent to the link that are still open, oldest first: each with its number. Only the link's secret reads them. */
121
- export async function ideasOf({ site = siteOrigin(), link }) {
122
- try { return (await call("GET", `${site}/api/wish-ideas?id=${encodeURIComponent(link.id)}`, { secret: link.secret })).said.ideas ?? []; }
158
+ export async function ideasOf({ site = siteOrigin(), link, earlier = false }) {
159
+ try { return (await call("GET", `${site}/api/wish-ideas?id=${encodeURIComponent(link.id)}${earlier ? "&earlier=1" : ""}`, { secret: link.secret })).said.ideas ?? []; }
123
160
  catch (error) { if (error.status === 404) return []; throw error; }
124
161
  }
125
162
 
126
163
  /** Lets an idea go: with `built` it's in, and the site first writes to whoever asked to hear. Whether it did. */
127
- export async function removeIdea({ site = siteOrigin(), link, n, built = false }) {
164
+ export async function removeIdea({ site = siteOrigin(), link, n, built = false, note }) {
128
165
  try {
129
- const { said } = await call("DELETE", `${site}/api/wish-ideas?id=${encodeURIComponent(link.id)}&n=${Number(n)}${built ? "&built=1" : ""}`, { secret: link.secret });
166
+ const { said } = await call("DELETE", `${site}/api/wish-ideas?id=${encodeURIComponent(link.id)}&n=${Number(n)}${built ? "&built=1" : ""}`,
167
+ { secret: link.secret, ...(note ? { json: { note } } : {}) });
130
168
  return { notified: said.notified === true };
131
169
  } catch (error) { if (error.status === 404) return { notified: false }; throw error; }
132
170
  }
133
171
 
172
+ /**
173
+ * Says something more about an idea, its sender emailed if they gave an address, and answers whether they were;
174
+ * without a note, only that the maker has seen to their answer.
175
+ */
176
+ export async function replyIdea({ site = siteOrigin(), link, n, note }) {
177
+ try {
178
+ const { said } = await call("PATCH", `${site}/api/wish-ideas?id=${encodeURIComponent(link.id)}&n=${Number(n)}`,
179
+ { secret: link.secret, ...(note ? { json: { note } } : {}) });
180
+ return { notified: said.notified === true };
181
+ } catch (error) {
182
+ if (error.status === 404) throw new Problem(`there's no idea ${Number(n)} on this link`);
183
+ if (error.status === 429) throw new Problem(`idea ${Number(n)} has as many messages as it can keep`);
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * The maker's own address for the link's ideas and replies, as `{ email, confirmed }`; with `email`, given ("" takes it
190
+ * away), and the site emails it to confirm first.
191
+ */
192
+ export async function makerOf({ site = siteOrigin(), link, email }) {
193
+ const { said } = await call(email === undefined ? "GET" : "PUT", `${site}/api/wish-ideas?id=${encodeURIComponent(link.id)}&maker=1`,
194
+ { secret: link.secret, ...(email === undefined ? {} : { json: { email } }) });
195
+ return { email: typeof said.email === "string" ? said.email : null, confirmed: said.confirmed === true, ...(email ? { sent: said.sent === true } : {}) };
196
+ }
197
+
134
198
  /** Takes the link down, with its page and the ideas sent to it. A link the site no longer has is down already. */
135
199
  export async function removeLink({ site = siteOrigin(), link }) {
136
200
  try { await call("DELETE", `${site}/api/wishes?id=${encodeURIComponent(link.id)}`, { secret: link.secret }); }
@@ -0,0 +1,193 @@
1
+ // `lamp ai`: the AI for a page shared on lamp.dev, answered on this computer with the person's own Claude Code or
2
+ // Codex (ai.mjs), the way `lamp dev` answers a page it serves. A shared page is away from Lamp, so lamp.js asks here,
3
+ // at a port of this computer's own (127.0.0.1), where a browser lets an https page call plain http.
4
+ //
5
+ // Only the person decides which pages spend their subscription. Each shared link plays from an origin of its own
6
+ // (`playOrigin` in site/lib/shared-wishes.js: <the link's id in hex>.play.lamp.dev), which a browser always names in
7
+ // the request and a page can't change, so an approval is for one link's page: the first time it asks, the person is
8
+ // asked in the terminal, and the page is given a token it keeps and sends from then on. Nothing else is answered:
9
+ // another site, another link's page, or a request naming this computer by another name (a site that rebinds its own
10
+ // name to 127.0.0.1) is refused. Whether there's an AI here (`/ai/status` and the probes) is answered without one, so a
11
+ // page can say what it can do before anyone is asked.
12
+
13
+ import { createServer } from "node:http";
14
+ import { randomBytes, timingSafeEqual } from "node:crypto";
15
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { createInterface } from "node:readline";
19
+ import { answer as answerAI, engine } from "./ai.mjs";
20
+
21
+ /** The port lamp.js on a shared page asks (`LOCAL_AI` there): one, fixed, since the page can't be told another. */
22
+ export const LOCAL_AI_PORT = 47423;
23
+ const PLAY_HOST = "play.lamp.dev";
24
+ const MOST = 2_000_000, AT_ONCE = 3, ASKING_FOR = 120_000;
25
+ const ROUTES = new Set(["/ai/status", "/ai", "/ai/act", "/ai/decide", "/ai/hear", "/ai/image"]);
26
+
27
+ const keptFile = () => join(process.env.LAMP_DEV_HOME || join(homedir(), ".lamp", "dev"), "shared-pages-ai.json");
28
+
29
+ /** The pages the person has let use their AI, by origin: `{ token, id, name, at }`. */
30
+ export function allowed() {
31
+ try { const kept = JSON.parse(readFileSync(keptFile(), "utf8")); return kept && typeof kept === "object" ? kept : {}; } catch { return {}; }
32
+ }
33
+ function keep(all) {
34
+ const file = keptFile();
35
+ mkdirSync(dirname(file), { recursive: true });
36
+ writeFileSync(file + ".new", JSON.stringify(all, null, 2), { mode: 0o600 });
37
+ renameSync(file + ".new", file);
38
+ }
39
+ /** Forgets every page let in: each asks again. */
40
+ export function forgetAllowed() { if (existsSync(keptFile())) rmSync(keptFile()); }
41
+
42
+ /** Forgets one page let in, by its origin: it asks again the next time it uses the AI. */
43
+ export function forgetPage(origin) {
44
+ const all = allowed();
45
+ if (!(origin in all)) return;
46
+ delete all[origin];
47
+ if (Object.keys(all).length) keep(all); else forgetAllowed();
48
+ }
49
+
50
+ /** The link a shared page's origin plays, by its id, or null for any other origin. */
51
+ export function linkOf(origin) {
52
+ let url;
53
+ try { url = new URL(origin); } catch { return null; }
54
+ if (url.protocol !== "https:" || url.port || !url.host.endsWith("." + PLAY_HOST)) return null;
55
+ const hex = url.host.slice(0, -PLAY_HOST.length - 1);
56
+ if (!/^(?:[0-9a-f]{2}){10}$/.test(hex)) return null;
57
+ const id = Buffer.from(hex, "hex").toString();
58
+ return /^[A-Za-z0-9]{10}$/.test(id) ? id : null;
59
+ }
60
+
61
+ const same = (a, b) => {
62
+ if (typeof a !== "string" || typeof b !== "string") return false;
63
+ const x = Buffer.from(a), y = Buffer.from(b);
64
+ return x.length === y.length && timingSafeEqual(x, y);
65
+ };
66
+
67
+ /**
68
+ * Answers shared pages' AI until it's closed. `ask(question, page)` puts a yes-or-no to the person and resolves true
69
+ * for yes; by default it's the terminal's, and without a terminal nothing is let in. `answer(path, body, page)` is the
70
+ * AI's [status, reply]: the person's Claude Code, Codex or OpenRouter key by default, Lamp's own AI when Lamp's app runs this
71
+ * (`sharedPages` in calls.mjs). Resolves to the server once it listens.
72
+ */
73
+ export async function serveLocalAI({ port = LOCAL_AI_PORT, ask = askInTerminal(), answer = answerAI, said = console.log } = {}) {
74
+ const busy = new Map(), queued = new Map(), refused = new Set(), waiting = new Map();
75
+ // A few of a page's requests run at once and the rest wait their turn, since lamp.js asks many at a time
76
+ // (`LampAI.decideEach`) and takes a refusal as that item's answer.
77
+ const turn = (origin) => {
78
+ if ((busy.get(origin) || 0) < AT_ONCE) { busy.set(origin, (busy.get(origin) || 0) + 1); return Promise.resolve(); }
79
+ return new Promise((go) => queued.set(origin, [...(queued.get(origin) || []), go]));
80
+ };
81
+ const done = (origin) => {
82
+ const next = queued.get(origin)?.shift();
83
+ if (next) next();
84
+ else busy.set(origin, busy.get(origin) - 1);
85
+ };
86
+ // One question at a time in the terminal; a page that asks again while it's waiting gets the same answer, and one
87
+ // the person said no to isn't asked about again while this runs.
88
+ let asking = Promise.resolve();
89
+ const askAbout = (origin, id, name) => {
90
+ if (waiting.has(origin)) return waiting.get(origin);
91
+ const asked = asking.then(async () => {
92
+ const kept = allowed()[origin];
93
+ if (kept) return kept.token;
94
+ const titled = name ? `"${name}" ` : "A page ";
95
+ const yes = await ask(`${titled}shared at lamp.dev/w/${id} wants to use your ${engineName()} here. Allow it?`, { id, name });
96
+ if (!yes) { refused.add(origin); return null; }
97
+ const token = randomBytes(32).toString("base64url");
98
+ keep({ ...allowed(), [origin]: { token, id, name: name || "", at: new Date().toISOString() } });
99
+ said(`Allowed ${name ? `"${name}"` : `lamp.dev/w/${id}`}. lamp ai --forget takes it back.`);
100
+ return token;
101
+ });
102
+ asking = asked.catch(() => {});
103
+ waiting.set(origin, asked);
104
+ asked.finally(() => waiting.delete(origin));
105
+ return asked;
106
+ };
107
+
108
+ const server = createServer(async (request, response) => {
109
+ const origin = request.headers.origin || "";
110
+ const id = linkOf(origin);
111
+ const reply = (status, body) => {
112
+ const headers = { "Content-Type": "application/json", "Cache-Control": "no-store", Vary: "Origin" };
113
+ if (id) headers["Access-Control-Allow-Origin"] = origin;
114
+ response.writeHead(status, headers);
115
+ response.end(JSON.stringify(body));
116
+ };
117
+ // Only this computer's own name for itself: a site that points a name of its own at 127.0.0.1 is someone else.
118
+ const host = String(request.headers.host || "").replace(/:\d+$/, "");
119
+ if (host !== "127.0.0.1" && host !== "localhost") return reply(403, { error: "Not here." });
120
+ if (!id) return reply(403, { error: "Only a page shared on lamp.dev can ask here." });
121
+ const url = new URL(request.url, "http://127.0.0.1");
122
+ if (request.method === "OPTIONS") {
123
+ response.writeHead(204, {
124
+ "Access-Control-Allow-Origin": origin,
125
+ "Access-Control-Allow-Methods": "POST",
126
+ "Access-Control-Allow-Headers": "Authorization, Content-Type",
127
+ "Access-Control-Allow-Private-Network": "true",
128
+ "Access-Control-Max-Age": "600",
129
+ Vary: "Origin",
130
+ });
131
+ return response.end();
132
+ }
133
+ if (request.method !== "POST") return reply(405, { error: "Method not allowed." });
134
+
135
+ let body = {};
136
+ try {
137
+ const chunks = [];
138
+ let size = 0;
139
+ for await (const chunk of request) {
140
+ size += chunk.length;
141
+ if (size > MOST) return reply(413, { error: "That's too much to ask at once." });
142
+ chunks.push(chunk);
143
+ }
144
+ const text = Buffer.concat(chunks).toString();
145
+ body = text ? JSON.parse(text) : {};
146
+ } catch { return reply(400, { error: "That wasn't JSON." }); }
147
+
148
+ if (url.pathname === "/pair") {
149
+ if (refused.has(origin)) return reply(403, { error: "Not allowed on this computer." });
150
+ const name = typeof body?.name === "string" ? body.name.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 60) : "";
151
+ const token = await askAbout(origin, id, name);
152
+ return token ? reply(200, { token }) : reply(403, { error: "Not allowed on this computer." });
153
+ }
154
+ if (!ROUTES.has(url.pathname)) return reply(404, { error: "Not found." });
155
+ // What there is to answer with, asked before anyone is let in: only the routes whose probe asks no model.
156
+ const asksNothing = url.pathname === "/ai/status" || (body?.probe === true && (url.pathname === "/ai/act" || url.pathname === "/ai/hear"));
157
+ if (asksNothing) {
158
+ const [status, answered] = await answer(url.pathname, url.pathname === "/ai/status" ? {} : { probe: true }, { id });
159
+ return reply(status, answered);
160
+ }
161
+ const kept = allowed()[origin];
162
+ const given = String(request.headers.authorization || "").replace(/^Bearer\s+/i, "");
163
+ if (!kept || !same(given, kept.token)) return reply(401, { error: "This page isn't allowed to use the AI here yet." });
164
+ await turn(origin);
165
+ try {
166
+ const [status, answered] = await answer(url.pathname, body, { id });
167
+ reply(status, answered);
168
+ } catch (error) {
169
+ reply(502, { error: error?.message || "The AI didn't answer." });
170
+ } finally {
171
+ done(origin);
172
+ }
173
+ });
174
+ await new Promise((done, fail) => { server.once("error", fail); server.listen(port, "127.0.0.1", () => { server.off("error", fail); done(); }); });
175
+ return server;
176
+ }
177
+
178
+ const engineName = () => ({ codex: "Codex", openrouter: "OpenRouter key" })[engine()] ?? "Claude Code";
179
+
180
+ /** A yes-or-no in the terminal, or always no without one. */
181
+ export function askInTerminal({ input = process.stdin, output = process.stdout } = {}) {
182
+ return (question) => {
183
+ if (!input.isTTY) {
184
+ output.write(`${question} No: lamp ai can only ask in a terminal.\n`);
185
+ return Promise.resolve(false);
186
+ }
187
+ const lines = createInterface({ input, output });
188
+ return new Promise((done) => {
189
+ const timer = setTimeout(() => { output.write("\nNo answer, so not allowed.\n"); lines.close(); done(false); }, ASKING_FOR);
190
+ lines.question(`${question} [y/N] `, (said) => { clearTimeout(timer); lines.close(); done(/^y(es)?$/i.test(said.trim())); });
191
+ });
192
+ };
193
+ }