@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.
package/cli/calls.mjs CHANGED
@@ -24,11 +24,28 @@ import { fileURLToPath } from "node:url";
24
24
  import { Away, OffNetwork, Refused, Rejected, Runner, StillWorking, answersOn, bonjour, pinsAt, searchNetwork, send, ssdp } from "./devices.mjs";
25
25
  import { adbCommand, connectTo, deviceListing, deviceProperties, displaySizeOf, dumpIn, installApk, installTools, launchApp, makeEmulator, pairWith, processIn, processOf,
26
26
  rowsOf, saveState, screenItems, screenshotOf, tellApp, toolsThere, useLamps } from "./android.mjs";
27
- import { Unreachable, ideasOf, latestOf, publishLink, removeFile, removeIdea, removeLink, sendFile } from "./link.mjs";
27
+ import { accountFor, keepAccount } from "./share.mjs";
28
+ import { Unreachable, accountName, askAccount, ideasOf, latestOf, pageUp, makerOf, publishLink, removeFile, removeIdea, removeLink, replyIdea, sendFile } from "./link.mjs";
28
29
  import { defaultRoute, homeNetwork, neighbours, samsungAt, subnetOn, tvsIn } from "./network.mjs";
29
30
  import { PUBLISHER, identityName, keptSigner, namesOf, packageOf, versionAfter } from "./msix.mjs";
30
31
  import { tile } from "./png.mjs";
31
32
  import { Problem } from "./state.mjs";
33
+ import { allowed as pagesAllowed, forgetPage, serveLocalAI } from "./local-ai.mjs";
34
+ import { EngineTrouble, complete } from "./ai.mjs";
35
+ import { OpenRouterTrouble, chat as openRouterChat, decide as openRouterDecide, image as openRouterImage, text as openRouterText } from "./openrouter.mjs";
36
+
37
+ /** OpenRouter's answer, or what kept it from one, as Lamp's app reads it. */
38
+ async function openRouter(asking) {
39
+ try { return await asking(); } catch (error) {
40
+ if (!(error instanceof OpenRouterTrouble)) throw error;
41
+ return { trouble: { kind: error.kind, message: error.message, status: error.status, reply: error.reply, cost: error.cost } };
42
+ }
43
+ }
44
+ import { Refused as StepRefused, chatBody, decidePrompt, planPrompt, planSchema, readDecision, readMessage, readPlan, readStep } from "./app-ai.mjs";
45
+
46
+ // Pages shared on lamp.dev asking for their AI, answered by Lamp's app (`sharedPages`): what each is waiting on from it.
47
+ const sharedPagesWaiting = new Map();
48
+ let sharedPagesAsked = 0;
32
49
 
33
50
  /** A number of seconds a call was given, within what it may take. */
34
51
  const seconds = (given, standard, most = 10) => Math.min(Math.max(Number(given) > 0 ? Number(given) : standard, 0.5), most);
@@ -153,7 +170,43 @@ export const CALLS = {
153
170
  */
154
171
  async linkPublish(given) {
155
172
  const earlier = given.earlier && typeof given.earlier.id === "string" ? given.earlier : null;
156
- return publishLink({ site: siteOf(given), body: given.body ?? {}, earlier });
173
+ // Sent with the maker's account key, the one `lamp account` keeps, unless the call names another: the link goes
174
+ // under their username once it's confirmed.
175
+ const site = siteOf(given);
176
+ return publishLink({ site, body: given.body ?? {}, earlier, account: typeof given.account === "string" ? given.account : accountFor(site)?.key });
177
+ },
178
+
179
+ /**
180
+ * The maker's account on `site`, as lamp keeps it: `account` null with none, else its username, the address that
181
+ * confirms it, and `confirmed` (null when the site can't be asked just now).
182
+ */
183
+ async account(given) {
184
+ const site = siteOf(given);
185
+ const kept = accountFor(site);
186
+ if (!kept) return { account: null };
187
+ let name;
188
+ try { name = await accountName({ site, key: kept.key }); } catch { return { account: { username: kept.username, email: kept.email ?? null, confirmed: null } }; }
189
+ // An address signing in to its account learns the username only once it's confirmed.
190
+ if (name && name !== kept.username) keepAccount(site, { ...kept, username: name });
191
+ return { account: { username: name ?? kept.username, email: kept.email ?? null, confirmed: !!name } };
192
+ },
193
+
194
+ /**
195
+ * Asks `site` for an account (`username`, `email`), or with an address alone, to sign in to the one it has; the key is
196
+ * kept as `lamp account` keeps it, and works once the email's link is opened. Its username and whether the email went.
197
+ */
198
+ async accountAsk(given) {
199
+ const site = siteOf(given);
200
+ const email = text(given, "email");
201
+ const asked = await askAccount({ site, username: typeof given.username === "string" && given.username ? given.username : undefined, email });
202
+ keepAccount(site, { username: asked.username, key: asked.key, email });
203
+ return { username: asked.username ?? null, existing: !!asked.existing, sent: asked.sent !== false };
204
+ },
205
+
206
+ /** Forgets the maker's account on `site` here (the one waiting for a mistyped address, say). */
207
+ async accountForget(given) {
208
+ keepAccount(siteOf(given), undefined);
209
+ return {};
157
210
  },
158
211
 
159
212
  /** What a followed link has now, asked with nothing of the person's; `latest` null when it can't be asked or is gone. */
@@ -163,8 +216,10 @@ export const CALLS = {
163
216
 
164
217
  /** The link's file (or with `page`, its playable page), from a file of Lamp's at `file`, sent in parts. */
165
218
  async linkUpload(given, how = {}) {
166
- return { version: await sendFile({ site: siteOf(given), link: linkOf(given), bytes: readFileSync(text(given, "file")), page: given.page === true,
167
- said: (line) => how.event?.({ said: line }) }) };
219
+ const said = (line) => how.event?.({ said: line });
220
+ const version = await sendFile({ site: siteOf(given), link: linkOf(given), bytes: readFileSync(text(given, "file")), page: given.page === true, said });
221
+ if (given.page === true) await pageUp({ site: siteOf(given), id: linkOf(given).id, version, said });
222
+ return { version };
168
223
  },
169
224
 
170
225
  /** Takes the link's file away (with `page`, only its page). */
@@ -173,14 +228,26 @@ export const CALLS = {
173
228
  return {};
174
229
  },
175
230
 
176
- /** The ideas sent to the link that are still open. */
231
+ /** The ideas sent to the link that are still open or answered since; with `earlier`, those seen to. */
177
232
  async linkIdeas(given) {
178
- return { ideas: await ideasOf({ site: siteOf(given), link: linkOf(given) }) };
233
+ return { ideas: await ideasOf({ site: siteOf(given), link: linkOf(given), earlier: given.earlier === true }) };
179
234
  },
180
235
 
181
- /** Lets an idea go; with `built` it's in, and the site writes to whoever asked to hear. */
236
+ /** Lets an idea go; with `built` it's in, and the site writes to whoever asked to hear, with `note` if there is one. */
182
237
  async linkRemoveIdea(given) {
183
- return removeIdea({ site: siteOf(given), link: linkOf(given), n: Number(given.n), built: given.built === true });
238
+ const note = typeof given.note === "string" && given.note.trim() ? given.note.trim() : undefined;
239
+ return removeIdea({ site: siteOf(given), link: linkOf(given), n: Number(given.n), built: given.built === true, note });
240
+ },
241
+
242
+ /** Says something more about an idea, emailed to its sender if they gave an address; without `note`, it's seen to. */
243
+ async linkReplyIdea(given) {
244
+ const note = typeof given.note === "string" && given.note.trim() ? given.note.trim() : undefined;
245
+ return replyIdea({ site: siteOf(given), link: linkOf(given), n: Number(given.n), note });
246
+ },
247
+
248
+ /** The maker's own address for the link's ideas; with `email` (a string, "" for none), given, to be confirmed. */
249
+ async linkMaker(given) {
250
+ return makerOf({ site: siteOf(given), link: linkOf(given), ...(typeof given.email === "string" ? { email: given.email.trim() } : {}) });
184
251
  },
185
252
 
186
253
  /** Takes the link down. */
@@ -299,6 +366,135 @@ export const CALLS = {
299
366
  return { table: await neighbours(), gateway: route?.gateway ?? null, subnet: route ? subnetOn(route.interface) : null };
300
367
  },
301
368
 
369
+ /**
370
+ * A text answer from OpenRouter for an app's AI (`AppAI.viaOpenRouter`), with the person's `key`, given on the input
371
+ * like everything else and kept nowhere: `prompt`, `images`, `system`, `model`, `json`, `schema`. `{text, cost}`, or
372
+ * `{trouble: {kind, message, status, reply, cost}}`.
373
+ */
374
+ async openRouterText(given, { stopped } = {}) {
375
+ return openRouter(() => openRouterText({ key: text(given, "key"), prompt: text(given, "prompt"), images: given.images, system: given.system ?? "",
376
+ model: text(given, "model"), json: given.json === true, schema: given.schema ?? undefined, seconds: seconds(given.seconds, 120, 600), signal: stopped }));
377
+ },
378
+
379
+ /** One chat completion from OpenRouter, its request as `body` (`actAsk`'s): `{message, cost}`, or `{trouble}`. */
380
+ async openRouterChat(given, { stopped } = {}) {
381
+ return openRouter(() => openRouterChat({ key: text(given, "key"), body: given.body ?? {}, seconds: seconds(given.seconds, 60, 600), signal: stopped }));
382
+ },
383
+
384
+ /** Decisions from OpenRouter's decision model (`AppAI.decide`): `key`, `model`, `state`, `questions`. `{answers, cost}` or `{trouble}`. */
385
+ async openRouterDecide(given, { stopped } = {}) {
386
+ return openRouter(() => openRouterDecide({ key: text(given, "key"), model: text(given, "model"), state: given.state, questions: given.questions ?? {},
387
+ seconds: seconds(given.seconds, 60, 120), signal: stopped }));
388
+ },
389
+
390
+ /** A picture from OpenRouter (`AppAI.image`): `key`, `model`, `prompt`, `references`, `aspectRatio`. `{image, cost}` or `{trouble}`. */
391
+ async openRouterImage(given, { stopped } = {}) {
392
+ return openRouter(() => openRouterImage({ key: text(given, "key"), model: text(given, "model"), prompt: text(given, "prompt"),
393
+ references: given.references, aspectRatio: typeof given.aspectRatio === "string" ? given.aspectRatio : undefined, seconds: seconds(given.seconds, 240, 300), signal: stopped }));
394
+ },
395
+
396
+ /**
397
+ * One answer from Claude Code or Codex for an app's AI (`AppAI.viaEngine`), sealed as ai.mjs runs every one: `engine`,
398
+ * the program Lamp chose (`tool`) and what it needs set (`env`), `system`, `prompt`, optional `schema`, `model`,
399
+ * `images` and `seconds`. `{text}`, or `{trouble: {kind, message}}`; stopping the call stops the engine.
400
+ */
401
+ async engineAnswer(given, { stopped } = {}) {
402
+ try {
403
+ return { text: await complete({ which: text(given, "engine"), tool: typeof given.tool === "string" && given.tool ? given.tool : undefined,
404
+ env: given.env && typeof given.env === "object" ? given.env : {}, system: typeof given.system === "string" ? given.system : "",
405
+ prompt: text(given, "prompt"), schema: given.schema ?? undefined, model: typeof given.model === "string" ? given.model : "",
406
+ images: Array.isArray(given.images) ? given.images : [], seconds: seconds(given.seconds, 300, 600), signal: stopped }) };
407
+ } catch (error) {
408
+ if (error instanceof EngineTrouble) return { trouble: { kind: error.kind, message: error.message } };
409
+ throw error;
410
+ }
411
+ },
412
+
413
+ /**
414
+ * A step of an app's `LampAI.act` as the page sent it (`text`, its JSON), checked: `{step}`, the step as the other
415
+ * act calls take it, or `{refused: {status, error}}` for the page.
416
+ */
417
+ async actStep(given) {
418
+ try { return { step: readStep(text(given, "text")) }; } catch (error) {
419
+ if (error instanceof StepRefused) return { refused: { status: error.status, error: error.message } };
420
+ throw error;
421
+ }
422
+ },
423
+
424
+ /**
425
+ * What a step's route is asked: `way` "chat" is a chat with tools (`{body}`, OpenRouter's own when `model` names one),
426
+ * "plan" one prompt held to the step's shape (`{system, prompt, schema}`), and "words" one with the shape said in it.
427
+ */
428
+ async actAsk(given) {
429
+ const step = given.step, tag = text(given, "tag"), retry = typeof given.retry === "string" ? given.retry : null;
430
+ if (given.way === "chat") return { body: chatBody(step, tag, retry, typeof given.model === "string" ? given.model : undefined) };
431
+ return { ...planPrompt(step, tag, retry, given.way === "words"), schema: planSchema(step) };
432
+ },
433
+
434
+ /** A step's answer read and checked: `message` from a chat with tools, or `plan`'s text. `{answer}` or `{problem}`. */
435
+ async actRead(given) {
436
+ return given.message !== undefined ? readMessage(given.message, given.step) : readPlan(String(given.plan ?? ""), given.step);
437
+ },
438
+
439
+ /** A decision asked of a text model: `{system, prompt, schema}`. */
440
+ async decideAsk(given) {
441
+ return decidePrompt(given.state, given.questions ?? {});
442
+ },
443
+
444
+ /** A text model's decision read: `{answers}` or `{problem}`. */
445
+ async decideRead(given) {
446
+ return readDecision(String(given.text ?? ""), given.questions ?? {});
447
+ },
448
+
449
+ /**
450
+ * Answers the AI of pages shared on lamp.dev, played in a browser on this computer, as `lamp ai` does
451
+ * (local-ai.mjs), for as long as Lamp's app wants it: which pages are let in, and how, is lamp's alone. The person is
452
+ * asked by Lamp's app, and the AI is Lamp's: each is an event, `{ask: n, id, name}` or `{answer: n, path, body}`,
453
+ * which Lamp's app answers with `sharedPagesReply`. A problem when something else answers on this computer already.
454
+ */
455
+ async sharedPages(given, { stopped, event } = {}) {
456
+ const fromLamp = (kind, fields) => new Promise((done) => {
457
+ const n = ++sharedPagesAsked;
458
+ sharedPagesWaiting.set(n, done);
459
+ event({ [kind]: n, ...fields });
460
+ });
461
+ let server;
462
+ try {
463
+ server = await serveLocalAI({
464
+ ...(Number.isInteger(given.port) ? { port: given.port } : {}),
465
+ said: () => {},
466
+ ask: (_question, page) => fromLamp("ask", { id: page.id, name: page.name }).then((reply) => reply.yes === true),
467
+ answer: (path, body, page) => fromLamp("answer", { path, body, id: page.id }).then((reply) => [Number(reply.status) || 502, reply.reply ?? {}]),
468
+ });
469
+ } catch (error) {
470
+ if (error?.code === "EADDRINUSE") throw new Problem("lamp ai already answers shared pages' AI on this computer");
471
+ throw error;
472
+ }
473
+ await new Promise((done) => (stopped?.aborted ? done() : stopped?.addEventListener("abort", done)));
474
+ server.close();
475
+ return {};
476
+ },
477
+
478
+ /** The pages shared on lamp.dev allowed to use the AI here, for Settings: `{pages: [{origin, id, name, at}]}`, newest first. */
479
+ async sharedPagesAllowed() {
480
+ return { pages: Object.entries(pagesAllowed()).map(([origin, page]) => ({ origin, id: page.id, name: page.name, at: page.at }))
481
+ .sort((a, b) => String(b.at).localeCompare(String(a.at))) };
482
+ },
483
+
484
+ /** Takes back one page's leave to use the AI here (`origin`): it asks again the next time. */
485
+ async sharedPagesForget(given) {
486
+ forgetPage(text(given, "origin"));
487
+ return {};
488
+ },
489
+
490
+ /** Lamp's app's answer to what `sharedPages` asked it: `{n, yes}` or `{n, status, reply}`. */
491
+ async sharedPagesReply(given) {
492
+ const done = sharedPagesWaiting.get(given.n);
493
+ sharedPagesWaiting.delete(given.n);
494
+ done?.(given);
495
+ return {};
496
+ },
497
+
302
498
  /** A search of the home network for one search target, for a few seconds: every answer. */
303
499
  async search(given) {
304
500
  return { answers: await ssdp(typeof given.target === "string" ? given.target : "ssdp:all", seconds(given.seconds, 2)) };
@@ -358,11 +554,19 @@ async function once(name) {
358
554
  async function serve() {
359
555
  const say = (line) => process.stdout.write(JSON.stringify(line) + "\n");
360
556
  const under = new Map();
557
+ // A stop can overtake the call it's for (Lamp's app cancelled it as it was sent): it's kept, and the call starts stopped.
558
+ const early = new Set();
361
559
  const answer = async (line) => {
362
560
  let asked;
363
561
  try { asked = JSON.parse(line); } catch { return; }
364
- if (asked?.stop !== undefined) return void under.get(asked.stop)?.abort();
562
+ if (asked?.stop !== undefined) {
563
+ const stopping = under.get(asked.stop);
564
+ if (stopping) stopping.abort();
565
+ else { early.add(asked.stop); if (early.size > 1000) early.delete(early.values().next().value); }
566
+ return;
567
+ }
365
568
  const id = asked?.id, call = CALLS[asked?.call], stopping = new AbortController();
569
+ if (early.delete(id)) stopping.abort();
366
570
  under.set(id, stopping);
367
571
  try {
368
572
  if (!call) throw new Error(`lamp has no call named "${asked?.call ?? ""}"`);
package/cli/dev.mjs CHANGED
@@ -3,7 +3,9 @@
3
3
  //
4
4
  // A game made with Lamp's runtime (lamp-game.js beside the page) keeps the player's place: the runtime is handed the
5
5
  // change exactly as Lamp's app around the page hands it over (`window.__lampLive`), and swaps the code in, runs it
6
- // again in place, or opens the page again from where the player was. Any other page is opened again.
6
+ // again in place, or opens the page again from where the player was. A game's page that doesn't run on the runtime is
7
+ // handed it too, through lamp-live.js (given after page.js), which puts in what it safely can and holds the rest for
8
+ // Reload. Any other page is opened again.
7
9
 
8
10
  import { createServer } from "node:http";
9
11
  import { createHash, randomBytes } from "node:crypto";
@@ -13,8 +15,9 @@ import { networkInterfaces, hostname } from "node:os";
13
15
  import { join, resolve, extname, sep, relative, dirname } from "node:path";
14
16
  import { fileURLToPath } from "node:url";
15
17
  import { spawn } from "node:child_process";
16
- import { Problem, RESOURCES, layoutOf, stateOf, logFileOf, serverFileOf, firstPortOf } from "./state.mjs";
17
- import { findBrowser, launch, keyNamed } from "./browser.mjs";
18
+ import { NOTES_FILE, Problem, RESOURCES, layoutOf, stateOf, logFileOf, serverFileOf, firstPortOf } from "./state.mjs";
19
+ import { findBrowser, noBrowser, launch, keyNamed } from "./browser.mjs";
20
+ import { answer as answerAI } from "./ai.mjs";
18
21
 
19
22
  const TYPES = {
20
23
  ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
@@ -37,10 +40,13 @@ export default {"keys":{"AltLeft":"Alt / Option","AltRight":"Alt / Option","Back
37
40
  `;
38
41
  const HERE = dirname(fileURLToPath(import.meta.url));
39
42
  const PAGE_SCRIPT = readFileSync(join(HERE, "page.js"), "utf8");
43
+ // What a game's page is given after lamp's script, as Lamp's app gives it (`LampPageLive.gameScript`): a game that
44
+ // doesn't run on Lamp's runtime takes a change while it's played where it can. Read from Lamp's own copy.
45
+ const GAME_SCRIPT = (() => { try { return readFileSync(join(RESOURCES, "GameKit", "page", "lamp-live.js"), "utf8"); } catch { return ""; } })();
40
46
  // Lamp's building blocks for a page (saved data, the app's files, AI through Lamp's app): lamp.js, which Lamp's app
41
47
  // writes at each build from its own code. A page whose folder has none is given it as that code writes it, when it has
42
48
  // been written out for the command line (scripts/cli-files.sh, cli/lamps/lamp.js), and else a stand-in, so the page loads.
43
- const LAMP_JS_STAND_IN = "// lamp's stand-in for lamp.js: Lamp's building blocks for a page aren't written out for the command line yet (scripts/cli-files.sh).\n";
49
+ export const LAMP_JS_STAND_IN = "// lamp's stand-in for lamp.js: Lamp's building blocks for a page aren't written out for the command line yet (scripts/cli-files.sh).\n";
44
50
  export function lampJS(game = false) {
45
51
  // A game's is its own (`GameKit.lampJS`): without a TV's remote, which would take the keys a game reads itself.
46
52
  for (const name of game ? ["lamp-for-games.js", "lamp.js"] : ["lamp.js"]) {
@@ -109,9 +115,10 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
109
115
 
110
116
  // MARK: The files and their changes
111
117
 
112
- // What's never part of a change: hidden files and folders, what tools keep beside the code, and Lamp's own two files.
118
+ // What's never part of a change: hidden files and folders, what tools keep beside the code, Lamp's own two files,
119
+ // and the app's notes, which no page runs (other Markdown may be content a page loads, so it is a change).
113
120
  const ignored = (path) => path.split("/").some((part) => part.startsWith(".") || part === "node_modules") || path.endsWith("~")
114
- || path === DEVICE_FILE || path === BUILD_FILE;
121
+ || path === DEVICE_FILE || path === BUILD_FILE || path === NOTES_FILE;
115
122
  const filesUnder = (dir = root, prefix = "", found = []) => {
116
123
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
117
124
  const path = prefix + entry.name;
@@ -136,6 +143,9 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
136
143
  const pages = new Map(); // page id → { response, tab, at: the change it has taken, ready, since }
137
144
  const reopening = new Map(); // tab → when the page it said it's opening again is given up on
138
145
  const early = new Map(); // what a page said before its line for changes was open
146
+ // Every file of the page a page asked for, by any page since the server started: a change says them, so a game's page
147
+ // that doesn't run on the runtime tells a file it uses from one it doesn't (lamp-live.js), which no page can see itself.
148
+ const served = new Set();
139
149
  const printed = new Set();
140
150
 
141
151
  const named = (change) => {
@@ -144,12 +154,19 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
144
154
  };
145
155
  const tell = (page, event, data, id) => page.response.write(`${id === undefined ? "" : `id: ${id}\n`}event: ${event}\ndata: ${data}\n\n`);
146
156
 
157
+ // What the coding agent said it's changing (`lamp changing`), for the card each page shows as a change goes in: the
158
+ // changes saved in the few minutes after say it, then it's let go.
159
+ let changing = null;
160
+ // What's changing, as the agent last said it: it stands for every save after it, until it says something new or half an
161
+ // hour has gone by.
162
+ const changingWords = () => (changing && Date.now() - changing.at < 30 * 60_000 ? { words: changing.words } : {});
163
+
147
164
  function broadcast(changed, removed) {
148
165
  gen += 1;
149
166
  const change = { gen, changed, removed, said: new Map(), line: logCount };
150
167
  changes.push(change);
151
168
  if (changes.length > 40) changes.shift();
152
- const data = JSON.stringify({ gen, changed, removed, now: false, pill: true });
169
+ const data = JSON.stringify({ gen, changed, removed, now: false, pill: true, read: [...served], ...changingWords() });
153
170
  for (const page of pages.values()) { page.told = Date.now(); tell(page, "change", data, gen); }
154
171
  if (!pages.size) print(`${named(change)}: no page is open; it's in the next one opened`);
155
172
  }
@@ -331,6 +348,12 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
331
348
  checkAll();
332
349
  let target = null, problem = null;
333
350
  try { target = await watched(); } catch (error) { problem = error.message; }
351
+ // With no browser lamp can drive, the page is opened in one it can't, and a look straight after can come before
352
+ // the page has said it's open: the look waits for it, as long as it's given to wait.
353
+ if (!target && !problem) {
354
+ const until = Date.now() + Math.max(0, wait) * 1000;
355
+ while (!pages.size && Date.now() < until) await sleep(100);
356
+ }
334
357
  // Another size is for lamp's own copy: the person's window is the size they made it.
335
358
  if (target?.where === "copy") {
336
359
  if (size || copySized) await target.page.size(size?.[0], size?.[1]).catch(() => {});
@@ -393,14 +416,15 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
393
416
  // Any open page says what's on it; there's no picture without a browser lamp can look into.
394
417
  const ask = randomBytes(4).toString("hex"), page = [...pages.values()].pop();
395
418
  seen = await new Promise((resolve) => {
396
- lookers.set(ask, resolve);
397
- setTimeout(() => { lookers.delete(ask); resolve(null); }, 3000);
419
+ // An answer ends the wait for one, so nothing is left waiting after it.
420
+ const given = setTimeout(() => { lookers.delete(ask); resolve(null); }, 3000);
421
+ lookers.set(ask, (answer) => { clearTimeout(given); resolve(answer); });
398
422
  tell(page, "look", ask);
399
423
  });
400
424
  where = "page";
401
- problem ??= "No picture: lamp found no browser it can look into (Chrome, Edge, Brave or Chromium).";
425
+ problem ??= `No picture. ${noBrowser()}`;
402
426
  } else {
403
- problem ??= "No page is open, and lamp found no browser it can open one in (Chrome, Edge, Brave or Chromium). Open the address in a browser and look again.";
427
+ problem ??= `No page has opened${wait > 0 ? ` in ${wait} s` : ""}: open ${address} in a browser, then look again. ${noBrowser()}`;
404
428
  }
405
429
  // How each change since the last look went in the page looked at, or in any page when that one never said. A
406
430
  // change a look said was held is said again once it has gone in.
@@ -455,7 +479,7 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
455
479
  async function use(asked) {
456
480
  const moves = Array.isArray(asked?.moves) ? asked.moves : [asked];
457
481
  const target = await watched();
458
- if (!target) throw new Problem("lamp found no browser it can use the page in (Chrome, Edge, Brave or Chromium)");
482
+ if (!target) throw new Problem(`Nothing was done. ${noBrowser()}`);
459
483
  // A page still opening, or opening again after a save, would lose what's pressed before it's in: each use waits
460
484
  // for its tab, and makes no move in a page that hasn't settled by then.
461
485
  let tab = null;
@@ -608,6 +632,11 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
608
632
  if (what === "use") return json(200, await inTurn(() => use(asked)));
609
633
  if (what === "card") return json(200, await inTurn(() => card(asked)));
610
634
  if (what === "open") return json(200, { opened: await openWindow(), url: address });
635
+ if (what === "changing") {
636
+ const words = String(asked.words ?? "").replace(/\s+/g, " ").trim();
637
+ changing = words ? { words: words.length > 120 ? `${words.slice(0, 119).trimEnd()}…` : words, at: Date.now() } : null;
638
+ return json(200, { pages: pages.size });
639
+ }
611
640
  if (what === "reload") { for (const page of pages.values()) tell(page, "reload", asked.fresh ? "fresh" : "now"); return json(200, { pages: pages.size }); }
612
641
  if (what === "stop") { json(200, { stopped: true }); setTimeout(() => close().then(() => process.exit(0)), 50); return; }
613
642
  return json(404, { problem: `lamp dev doesn't know "${what}"` });
@@ -616,7 +645,7 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
616
645
  }
617
646
  }
618
647
 
619
- if (url.pathname === "/__lamp/page.js") return send(200, TYPES[".js"], `const LAMP = ${JSON.stringify({ gen, run })};\n${PAGE_SCRIPT}`);
648
+ if (url.pathname === "/__lamp/page.js") return send(200, TYPES[".js"], `const LAMP = ${JSON.stringify({ gen, run })};\n${PAGE_SCRIPT}${layout.game ? `\n${GAME_SCRIPT}` : ""}`);
620
649
  if (url.pathname === "/__lamp/events") {
621
650
  const id = url.searchParams.get("page") || randomBytes(4).toString("hex"), tab = url.searchParams.get("tab") || id;
622
651
  // The last change this page has: what its files were served at, or what it last heard when the line broke.
@@ -636,7 +665,7 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
636
665
  for (const path of change.changed) { changed.add(path); removed.delete(path); }
637
666
  for (const path of change.removed) { removed.add(path); changed.delete(path); }
638
667
  }
639
- tell(page, "change", JSON.stringify({ gen, changed: [...changed], removed: [...removed], now: false, pill: true }), gen);
668
+ tell(page, "change", JSON.stringify({ gen, changed: [...changed], removed: [...removed], now: false, pill: true, read: [...served], ...changingWords() }), gen);
640
669
  } else if (missed.length) {
641
670
  // More was saved than is remembered: the page opens again from the files as they are.
642
671
  tell(page, "reload", "now");
@@ -645,6 +674,23 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
645
674
  request.on("close", () => { clearInterval(beat); if (pages.get(id) === page) pages.delete(id); });
646
675
  return;
647
676
  }
677
+ // The page's AI (LampAI in lamp.js, through `lampGatewayHost` in page.js): answered here with the person's own
678
+ // Claude Code or Codex (ai.mjs), only for the page itself on this Mac. Never another site in the person's browser,
679
+ // which can't send this request's header from another origin, nor another device the server is open to with --lan.
680
+ if (request.method === "POST" && url.pathname === "/__lamp/ai") {
681
+ const from = request.socket.remoteAddress || "";
682
+ const local = from === "127.0.0.1" || from === "::1" || from === "::ffff:127.0.0.1";
683
+ const origin = request.headers.origin;
684
+ if (!local || request.headers["x-lamp-page"] !== "1" || (origin && new URL(origin).host !== request.headers.host)) {
685
+ return json(403, { status: 403, body: JSON.stringify({ error: "The AI answers only the page on this Mac." }) });
686
+ }
687
+ const asked = await body(request);
688
+ const path = typeof asked?.path === "string" ? asked.path : "";
689
+ let given = {};
690
+ try { given = typeof asked?.body === "string" ? JSON.parse(asked.body) : asked?.body ?? {}; } catch {}
691
+ const [status, reply] = await answerAI(path, given);
692
+ return json(200, { status, body: JSON.stringify(reply) });
693
+ }
648
694
  if (request.method === "POST" && url.pathname.startsWith("/__lamp/")) {
649
695
  const message = await body(request);
650
696
  if (message && url.pathname === "/__lamp/said") said(message);
@@ -660,6 +706,7 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
660
706
  let file = resolve(join(root, path));
661
707
  if (file !== root && !file.startsWith(root + sep)) return send(403, "text/plain", "Outside the page");
662
708
  const name = relative(root, file).split(sep).join("/");
709
+ served.add(name);
663
710
  // Hidden files (.git, .lamp, .env) are no part of the page.
664
711
  if (name.split("/").some((part) => part.startsWith("."))) return send(404, "text/plain", "Not found");
665
712
  let info;