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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ Mac app, `projectOf`), or any folder with an `index.html`; without one, the fold
19
19
  | `lamp init [--update]` | Puts Lamp's game runtime beside a page that's already there, changing none of the page's own files. `--update` writes again the Lamp files the folder has (`lamp.js` too), adding the game's only to a page that's a game already. |
20
20
  | `lamp unpack <file.lampapp> [folder]` | Unpacks a Lamp app file into a new folder beside it, named for it. A file carries none of Lamp's own files: a page gets them beside it as it's unpacked (the runtime for a game, lamp.js, the libraries its import map names), and a Mac app gets Package.swift, build.sh and LampSupport.swift from Lamp's starting files as it's first built (`writeMacStarter`), where it has none. One with a widget, a share extension or a 3D scene is left to Lamp's app, which writes the files they're built with. |
21
21
  | `lamp export [--to file] [--anyway]` | Makes the app's Lamp app file as Lamp's Share… does (`Sharing.export`, followed by `cli/lampapp.mjs`): its own files as its kind travels, Assets/, APP.md, what it declares, its credits with the app added to the chain, the licenses they name, its icon's design and lamp-app.json, never Lamp's own files, nor what the folder's `.lampignore` names. Refused when a license its credits name is gone or something in it is shaped like a key; `--anyway` sends work its credits give no license for, and lamp keeps that the person agreed (in its state), so it asks again only for work new to the app. A source credited `"live": true` (fetched while the app runs, nothing of it in the app) is never asked about. The shapes of keys, the names a license goes by and the sizes another Lamp opens are in `Resources/share-rules.json`, which Lamp's app reads too (`ShareRules`). |
22
- | `lamp dev [--open] [--lan] [--port N]` | Runs the page and says how each change went. Each save goes into every open copy of it, with the card Lamp's app puts on an app taking a change (`page.js`, left out of lamp's pictures; `lamp changing "<words>"` says on it what's changing). `--open` opens a window for it; `--lan` lets other devices on the network open it. |
22
+ | `lamp dev [--open] [--lan] [--port N]` | Runs the page and says how each change went. Each save of a file a page has asked for goes into every open copy of it (a file no page has loaded, such as renders or logs a page's tests write, opens no page again), with the card Lamp's app puts on an app taking a change (`page.js`, left out of lamp's pictures; `lamp changing "<words>"` says on it what's changing). `--open` opens a window for it; `--lan` lets other devices on the network open it. |
23
23
  | `lamp open` | Shows the page in a window, running it in the background if it isn't running, and returns at once. |
24
24
  | `lamp open --in-lamp` | Gives a Mac app to Lamp's app (`cli/outside.mjs`), over the socket the person's own coding agent uses (`OutsideServer`, `OutsideWork`): `open_project` takes the folder on, with Lamp's review the first time, and from then on `look`, `reload` and `dev` call `apply_change`, so a change goes into the open app as Lamp's app puts it there (`LiveReload`) and its outcome is said: live, held, reopened, not_built, crashed. `use` calls `use_app`, in plain sight in the app Lamp's app has open, and `stop` calls `close_app`. A folder Lamp's app no longer has (its refusal says `taken_on: false`) is lamp's own again, said once. `lamp open --here` has lamp run its own copy again. `LAMP_APP` names another Lamp by its bundle id (a Lamp Dev instance). A Mac app `lamp new --mac` makes has the `lamp-app.json` Lamp's app takes a folder on by. |
25
25
  | `lamp look [--reopen] [--wait s] [--json]` | How the changes since the last look went, a picture of the page, its screen as text, new errors and the log. `--reopen` opens the page again from its beginning first. |
@@ -23,7 +23,10 @@ opening comment for the whole list. What it gives:
23
23
  heard of Lamp finds out how. Where it answers `connect: true` (a page on lamp.dev that hasn't reached this computer
24
24
  yet), also show a small "Connect AI" button that calls `LampAI.connect(capability)` with the same kind of AI you asked
25
25
  `status` about (`LampAI.connect('pictures')` for pictures) and use the answer it gives as `status`'s;
26
- never call `connect()` as the page loads, since it makes the browser ask the person.
26
+ never call `connect()` as the page loads, since it makes the browser ask the person. For JSON the app reads, give
27
+ `ask` a `schema` (a JSON Schema: every property in `required`, `additionalProperties: false`), which holds the AI to
28
+ it, rather than only describing the shape. A long `ask` the person can stop takes a `signal` (an `AbortController`'s),
29
+ and its failure then has `stopped`, which needs no error shown.
27
30
  - **LampAI.act**: the person asks the app to do something in their own words, and the app's own reads and writes do
28
31
  it; "Doing things for the person" below, with **LampVoice** and **LampAI.hear** for saying it.
29
32
  - **LampStore**: what the app saves, as JSON on the device. Once something is saved, only ever add to it: a new field
@@ -102,6 +105,8 @@ whichever AI the person set up for apps' text, so don't tie it to voice: typing
102
105
  and take only the first answer, a button, what was heard or what was typed. With none, it's answered by voice or
103
106
  typing; never guess choices from the question's wording.
104
107
  - Keep what each write changed for Undo, and undo only what's still as the write left it; say which ones it left.
108
+ - Where the app draws something the AI builds (a scene, a layout, a chart), give it a read that returns
109
+ `LampAI.picture(canvas)` in its result, so it can look at its work and fix it in the same turn.
105
110
  - Show each step while it runs (`onStep`), and cancel the turn (`turn.cancel()`) when the person closes it or the page
106
111
  is hidden.
107
112
  - `about` says what the app is and its rules for asking back: which person, which day and time, which of two things
package/cli/ai.mjs CHANGED
@@ -56,10 +56,11 @@ export class EngineTrouble extends Error {
56
56
  * computer is read or run whatever the prompt asks. Pictures (`images`, base64 with their `media_type`) go with the
57
57
  * request itself: to Claude Code in its input, to Codex as attachments, never through a tool that reads files.
58
58
  * Lamp's app runs it this way too (`engineAnswer` in calls.mjs, `AppAI.viaEngine`), naming the program (`tool`) and
59
- * what it needs set (`env`); on its own lamp finds the person's own on the PATH.
59
+ * what it needs set (`env`); on its own lamp finds the person's own on the PATH. `spent` hears what a call through
60
+ * OpenRouter cost; one through the person's own sign-in costs nothing through Lamp.
60
61
  */
61
- export function complete({ system, prompt, schema, seconds = 60, which = engine(), tool, env = {}, model = process.env.LAMP_AI_MODEL || "", images = [], signal } = {}) {
62
- if (which === "openrouter") return viaOpenRouter({ system, prompt, schema, seconds, model, images, signal });
62
+ export function complete({ system, prompt, schema, seconds = 60, which = engine(), tool, env = {}, model = process.env.LAMP_AI_MODEL || "", images = [], signal, spent } = {}) {
63
+ if (which === "openrouter") return viaOpenRouter({ system, prompt, schema, seconds, model, images, signal, spent });
63
64
  if (which !== "claude" && which !== "codex") return Promise.reject(new EngineTrouble("missing", missing));
64
65
  const folder = mkdtempSync(join(tmpdir(), "lamp-ai-"));
65
66
  const sayer = system || "You are a helpful assistant inside an app.";
@@ -156,31 +157,68 @@ function codexFailure(event) {
156
157
  return nested || words;
157
158
  }
158
159
 
160
+ // MARK: The OpenRouter key
161
+
162
+ const KEY_REFUSED = "OpenRouter didn't accept the key in OPENROUTER_API_KEY: it may have been revoked. Set a new one from openrouter.ai/keys.";
163
+ const KEY_EMPTY = "The OpenRouter key in OPENROUTER_API_KEY has no credit left. Add credit at openrouter.ai.";
164
+ let keyAsked = { key: "", at: 0, problem: null };
165
+
166
+ /**
167
+ * What's wrong with the key in OPENROUTER_API_KEY, as OpenRouter says (as Lamp's app asks it, `openRouterVerdict`), or
168
+ * null: a key that's there isn't one that works (revoked, or out of credit), and `status` says so rather than offer
169
+ * what every call would then refuse. Asked at most every five minutes, and quickly, since a page waits on its status; when
170
+ * OpenRouter can't be reached in time, the key may be good.
171
+ */
172
+ export async function keyProblem(fetcher = fetch) {
173
+ const key = process.env.OPENROUTER_API_KEY;
174
+ if (!key) return null;
175
+ if (keyAsked.key === key && Date.now() - keyAsked.at < 5 * 60_000) return keyAsked.problem;
176
+ let problem = null;
177
+ try {
178
+ const answer = await fetcher("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(4000) });
179
+ if (answer.status === 401 || answer.status === 403) problem = KEY_REFUSED;
180
+ else if (answer.status === 200) {
181
+ const left = (await answer.json().catch(() => ({})))?.data?.limit_remaining;
182
+ if (typeof left === "number" && left <= 0) problem = KEY_EMPTY;
183
+ } else return null;
184
+ } catch { return null; }
185
+ keyAsked = { key, at: Date.now(), problem };
186
+ return problem;
187
+ }
188
+
189
+ /** What an OpenRouter failure says to the page: the key's trouble as the setting to fix, else what OpenRouter said. */
190
+ function openRouterWords(error) {
191
+ if (error?.status === 401 || error?.status === 403) return KEY_REFUSED;
192
+ if (error?.status === 402) return KEY_EMPTY;
193
+ const words = error?.reply?.error?.message;
194
+ return words ? String(words) : String(error?.message || "OpenRouter didn't answer.");
195
+ }
196
+
159
197
  /** An answer from OpenRouter with the person's key (openrouter.mjs, as Lamp's app sends it), its troubles said as an engine's. */
160
- async function viaOpenRouter({ system, prompt, schema, seconds, model, images, signal }) {
198
+ async function viaOpenRouter({ system, prompt, schema, seconds, model, images, signal, spent }) {
161
199
  try {
162
- return (await openRouterText({ key: process.env.OPENROUTER_API_KEY, prompt, images, system: system || "You are a helpful assistant inside an app.",
163
- model: model || DEFAULT_MODEL, json: !!schema, schema, seconds, signal })).text;
200
+ const { text, cost } = await openRouterText({ key: process.env.OPENROUTER_API_KEY, prompt, images, system: system || "You are a helpful assistant inside an app.",
201
+ model: model || DEFAULT_MODEL, json: !!schema, schema, seconds, signal });
202
+ spent?.(cost);
203
+ return text;
164
204
  } catch (error) {
165
205
  if (!(error instanceof OpenRouterTrouble)) throw error;
166
- const words = error.reply?.error?.message;
167
- throw new EngineTrouble(error.kind === "refused" ? "failed" : error.kind, words ? String(words) : error.message);
206
+ spent?.(error.cost);
207
+ throw new EngineTrouble(error.kind === "refused" ? "failed" : error.kind, error.kind === "refused" ? openRouterWords(error) : error.message);
168
208
  }
169
209
  }
170
210
 
171
211
  // MARK: Status and asking
172
212
 
173
- /** `/ai/status`: what can answer here. Text, decisions and actions with an engine; pictures as `pictureRoute` says. */
174
- export function status(page) {
175
- const which = engine();
176
- const route = which ? named(which) : null;
177
- const available = (kind) => route ? { available: true, route, where: "remote" } : { available: false, reason: missing, setup: "lamp" };
178
- const drawer = pictureRoute(page);
179
- return {
180
- text: !!route, decisions: !!route, pictures: !!drawer, act: !!route,
181
- details: { text: available("text"), decisions: available("decisions"), act: available("act"),
182
- pictures: drawer ? { available: true, route: named(drawer), where: "remote" } : { available: false, reason: noPictures(page), setup: "lamp" } },
183
- };
213
+ /** `/ai/status`: what can answer here. Text, decisions and actions with an engine; pictures as `pictureRoute` says.
214
+ * What goes through OpenRouter answers only while it takes the key (`keyProblem`). */
215
+ export async function status(page, problem = keyProblem) {
216
+ const which = engine(), drawer = pictureRoute(page);
217
+ const broken = which === "openrouter" || drawer === "openrouter" ? await problem() : null;
218
+ const one = (by, reason) => !by ? { available: false, reason, setup: "lamp" }
219
+ : by === "openrouter" && broken ? { available: false, reason: broken, setup: "lamp" } : { available: true, route: named(by), where: "remote" };
220
+ const details = { text: one(which, missing), decisions: one(which, missing), act: one(which, missing), pictures: one(drawer, noPictures(page)) };
221
+ return { text: details.text.available, decisions: details.decisions.available, pictures: details.pictures.available, act: details.act.available, details };
184
222
  }
185
223
 
186
224
  // MARK: Models (`LampAI.models`, and `model` on `ask` and `act`)
@@ -281,16 +319,35 @@ const GUARD_SECONDS = 600;
281
319
  const secondsFor = (body) => (typeof body?.seconds === "number" && body.seconds > 0 ? Math.min(body.seconds, GUARD_SECONDS) : GUARD_SECONDS);
282
320
 
283
321
  /** `/ai`: a text answer, or JSON when asked for it. */
284
- export async function ask(body, here = engines()) {
322
+ export async function ask(body, here = engines(), signal) {
285
323
  const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : "";
286
324
  if (!prompt) return [400, { error: "Missing prompt." }];
325
+ // A JSON Schema the answer must fit (`LampAI.ask`'s `schema`): every engine holds the model to it.
326
+ const schema = body.schema && typeof body.schema === "object" && !Array.isArray(body.schema) ? body.schema : undefined;
327
+ const json = !!body.json || !!schema;
287
328
  let system = typeof body.system === "string" ? body.system : "";
288
- if (body.json) system += (system ? "\n\n" : "") + "Answer with JSON only, nothing around it.";
329
+ if (json) system += (system ? "\n\n" : "") + "Answer with JSON only, nothing around it.";
289
330
  const { which, model } = routeFor(body.model, here);
290
- try { return [200, { text: await complete({ system, prompt, images: body.images, which, model, seconds: secondsFor(body) }) }]; }
291
- catch (error) { return [502, { error: error.message }]; }
331
+ let cost = 0;
332
+ const spent = (more) => { cost += Number(more) || 0; };
333
+ // One time for the whole request, its second try included, as Lamp's app gives it (`AppAI.complete`).
334
+ const deadline = Date.now() + secondsFor(body) * 1000;
335
+ const once = (asked) => complete({ system, prompt: asked, schema, images: body.images, which, model, seconds: Math.max(1, (deadline - Date.now()) / 1000), signal, spent });
336
+ try {
337
+ let text = await once(prompt);
338
+ // JSON that doesn't read (cut off, or with words around it) is no use to the page: asked once more, saying so.
339
+ if (json && !readsAsJSON(text)) {
340
+ if (deadline - Date.now() < 5000) return [502, { error: "The AI's reply wasn't valid JSON, and there was no time left to ask again.", cost }];
341
+ text = await once(`${prompt}\n\nYour previous reply was not valid JSON (it may have been cut off). Reply with the complete JSON only.`);
342
+ if (!readsAsJSON(text)) return [502, { error: "The AI's reply wasn't valid JSON, even on a second try. Ask for less at once.", cost }];
343
+ }
344
+ return [200, { text: json ? unfenced(text) : text, cost }];
345
+ } catch (error) { return [502, { error: error.message, cost }]; }
292
346
  }
293
347
 
348
+ const unfenced = (text) => String(text).trim().replace(/^```(?:json)?\s*|\s*```$/g, "").trim();
349
+ function readsAsJSON(text) { try { JSON.parse(unfenced(text)); return true; } catch { return false; } }
350
+
294
351
  // MARK: Decisions
295
352
 
296
353
  /** `/ai/decide`: each question answered by a text model, told and read as Lamp's app does it (app-ai.mjs). */
@@ -311,7 +368,11 @@ export async function decide(body) {
311
368
  /** `/ai/act`: one step of the page's agent loop, told and checked as Lamp's app does it (app-ai.mjs). */
312
369
  export async function act(body, here = engines()) {
313
370
  const first = here[0] ?? null;
314
- if (body?.probe === true) return [200, { act: !!first, route: first || "", why: first ? "" : missing }];
371
+ if (body?.probe === true) {
372
+ // A key OpenRouter refuses can't answer a step, as `/ai/status` says too.
373
+ const refused = first === "openrouter" ? await keyProblem() : null;
374
+ return [200, { act: !!first && !refused, route: first || "", why: refused || (first ? "" : missing) }];
375
+ }
315
376
  if (!first) return [501, { error: missing }];
316
377
  let r;
317
378
  try { r = readStep(JSON.stringify(body ?? null)); } catch (refused) { return [refused.status ?? 400, { error: refused.message }]; }
@@ -321,26 +382,29 @@ export async function act(body, here = engines()) {
321
382
  const seconds = 600;
322
383
  const deadline = Date.now() + Math.min(r.seconds, seconds) * 1000;
323
384
  const tag = "outside-text-" + randomBytes(4).toString("hex");
324
- let retry = null;
385
+ let retry = null, cost = 0;
325
386
  for (let attempt = 0; attempt < 2; attempt++) {
326
387
  const left = (deadline - Date.now()) / 1000;
327
- if (left < 1) return [504, { error: "This step ran out of time." }];
388
+ if (left < 1) return [504, { error: "This step ran out of time.", cost }];
328
389
  let read;
329
390
  try {
330
391
  if (which === "openrouter") {
331
392
  const body = chatBody(r, tag, retry, model);
332
- read = readMessage((await openRouterChat({ key: process.env.OPENROUTER_API_KEY, body, seconds: left })).message, r);
393
+ const answered = await openRouterChat({ key: process.env.OPENROUTER_API_KEY, body, seconds: left });
394
+ cost += answered.cost;
395
+ read = readMessage(answered.message, r);
333
396
  } else {
334
- const { system, prompt } = planPrompt(r, tag, retry);
335
- read = readPlan(await complete({ system, prompt, schema: planSchema(r), seconds: left, which, model }), r);
397
+ const { system, prompt, images } = planPrompt(r, tag, retry);
398
+ read = readPlan(await complete({ system, prompt, images, schema: planSchema(r), seconds: left, which, model }), r);
336
399
  }
337
400
  } catch (error) {
338
- const words = error?.reply?.error?.message || error.message;
339
- return Date.now() >= deadline || error?.kind === "timeout" ? [504, { error: "This step ran out of time." }] : [502, { error: String(words) }];
401
+ cost += Number(error?.cost) || 0;
402
+ const words = error instanceof OpenRouterTrouble && error.kind === "refused" ? openRouterWords(error) : error?.reply?.error?.message || error.message;
403
+ return Date.now() >= deadline || error?.kind === "timeout" ? [504, { error: "This step ran out of time.", cost }] : [502, { error: String(words), cost }];
340
404
  }
341
- if (read.answer) return [200, { ...read.answer, route: which, seconds }];
405
+ if (read.answer) return [200, { ...read.answer, route: which, seconds, cost }];
342
406
  if (attempt === 0) { retry = read.problem; continue; }
343
- return [200, { reply: "I couldn't work out how to do that.", ask: false, route: which, seconds, trouble: read.problem }];
407
+ return [200, { reply: "I couldn't work out how to do that.", ask: false, route: which, seconds, trouble: read.problem, cost }];
344
408
  }
345
409
  return [502, { error: "The AI didn't answer." }];
346
410
  }
@@ -404,7 +468,7 @@ export async function makeImage(body, page, app = {}) {
404
468
  }
405
469
  } catch (error) {
406
470
  if (error?.kind === "timeout") return [504, { error: "The picture took too long to make." }];
407
- return [502, { error: String(error?.reply?.error?.message || error.message) }];
471
+ return [502, { error: openRouterWords(error) }];
408
472
  }
409
473
  const type = pictureType(made);
410
474
  if (!type) return [502, { error: route === "codex" ? "Codex didn't make the picture. Check it's signed in." : "The image model didn't return a picture." }];
@@ -483,11 +547,11 @@ export function codexImage({ prompt, references = [], aspectRatio, seconds = PIC
483
547
  }
484
548
 
485
549
  /** A page's AI request, by its route, as Lamp's app answers it: [status, JSON]. `app` is what the app chose for itself
486
- * (`imageModel`), when that's known. */
487
- export async function answer(path, body, page, app = {}) {
488
- if (path === "/ai/status") return [200, status(page)];
550
+ * (`imageModel`), when that's known; `signal` stops an answer the page no longer waits for. */
551
+ export async function answer(path, body, page, app = {}, signal) {
552
+ if (path === "/ai/status") return [200, await status(page)];
489
553
  if (path === "/ai/models") return [200, models(enginesFor(page))];
490
- if (path === "/ai") return ask(body, enginesFor(page));
554
+ if (path === "/ai") return ask(body, enginesFor(page), signal);
491
555
  if (path === "/ai/act") return act(body, enginesFor(page));
492
556
  if (path === "/ai/decide") return decide(body);
493
557
  if (path === "/ai/hear") return body?.probe === true ? [200, { hear: false, why: "Hearing needs Lamp's app." }] : [501, { error: "Hearing needs Lamp's app: open the app in Lamp." }];
package/cli/app-ai.mjs CHANGED
@@ -17,7 +17,11 @@
17
17
  // `MAX_STEPS` is the most steps an app may raise a turn to (`ACT_LIMITS_MAX` in lamp.js, `LampActSession.mostSteps`):
18
18
  // room to build something real a few calls at a time, while a turn that goes round in circles still ends. The most
19
19
  // writes follows from it, since each step holds at most `MAX_CALLS`.
20
- export const MAX_TOOLS = 24, MAX_CALLS = 8, MAX_STEPS = 40, MAX_BODY = 2 * 1024 * 1024, MAX_CHOICES = 4, MAX_CHOICE = 24;
20
+ export const MAX_TOOLS = 24, MAX_CALLS = 8, MAX_STEPS = 40, MAX_BODY = 3.5 * 1024 * 1024, MAX_CHOICES = 4, MAX_CHOICE = 24;
21
+ /** The most pictures a step carries from its tools' results (`LampAI.picture`), the newest; lamp.js sends no more, and
22
+ * keeps them small enough (`ACT_PICTURE_BYTES`) for a step with its text results to stay under `MAX_BODY`. */
23
+ export const MAX_PICTURES = 6;
24
+ const PICTURE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
21
25
  /** The longest a step may take, whatever its route, before its call is taken to have stopped answering (`ACT_STEP_SECONDS`
22
26
  * in lamp.js, `AppAct.turnSeconds`). A turn has no time of its own unless the app gives it one. */
23
27
  export const MAX_TURN_SECONDS = 600;
@@ -69,7 +73,7 @@ function parseArg(raw, path) {
69
73
  * steps, route, seconds, final, model?}`, each tool's `args` as [name, shape] pairs by name. Throws `Refused`.
70
74
  */
71
75
  export function readStep(text) {
72
- if (Buffer.byteLength(text) > MAX_BODY) throw new Refused(413, "A step can carry at most 2 MB.");
76
+ if (Buffer.byteLength(text) > MAX_BODY) throw new Refused(413, "A step can carry at most 3.5 MB.");
73
77
  let body;
74
78
  try { body = JSON.parse(text); } catch {}
75
79
  if (!body || typeof body !== "object" || Array.isArray(body)) throw new Refused(400, "A step needs a JSON object.");
@@ -95,6 +99,8 @@ export function readStep(text) {
95
99
  const rawSteps = Array.isArray(body.steps) ? body.steps.filter((s) => s && typeof s === "object") : [];
96
100
  if (rawSteps.length >= MAX_STEPS) throw new Refused(400, `A turn has at most ${MAX_STEPS} steps.`);
97
101
  const seen = new Set(), steps = [];
102
+ // The pictures the results carry, numbered in the order the turn made them; only the newest MAX_PICTURES are shown.
103
+ const pictures = [];
98
104
  for (const raw of rawSteps) {
99
105
  const calls = [];
100
106
  for (const call of Array.isArray(raw.calls) ? raw.calls.filter((c) => c && typeof c === "object") : []) {
@@ -104,10 +110,30 @@ export function readStep(text) {
104
110
  calls.push({ id: call.id, name: call.name, args: call.args && typeof call.args === "object" && !Array.isArray(call.args) ? call.args : {} });
105
111
  }
106
112
  if (!calls.length || calls.length > MAX_CALLS) throw new Refused(400, `Every earlier step has 1 to ${MAX_CALLS} calls.`);
107
- const results = {};
108
- for (const result of Array.isArray(raw.results) ? raw.results : []) if (typeof result?.id === "string") results[result.id] = result;
109
- steps.push({ calls, results });
113
+ const results = {}, shown = [];
114
+ for (const result of Array.isArray(raw.results) ? raw.results : []) {
115
+ if (typeof result?.id !== "string") continue;
116
+ const { pictures: given, ...rest } = result;
117
+ const call = calls.find((c) => c.id === result.id);
118
+ // A result's pictures go to the model beside it as pictures, never as text: the result names each one in its place.
119
+ for (const picture of Array.isArray(given) ? given : []) {
120
+ // `data` null is one the page no longer sends, which the model has seen.
121
+ if (!call || !PICTURE_TYPES.includes(picture?.media_type) || (picture.data !== null && (typeof picture.data !== "string" || !/^[A-Za-z0-9+/]+=*$/.test(picture.data)))) {
122
+ throw new Refused(400, "A result's pictures are each a `media_type` (JPEG, PNG, WebP or GIF) and its `data` in base64.");
123
+ }
124
+ // Numbered by the page, which names it so in the result ("[picture 2]"); a number that isn't one of its own is replaced.
125
+ const taken = new Set(pictures.map((p) => p.number));
126
+ const number = Number.isInteger(picture.number) && picture.number > 0 && !taken.has(picture.number) ? picture.number : Math.max(0, ...taken) + 1;
127
+ const one = { number, call: call.name, media_type: picture.media_type, data: picture.data };
128
+ pictures.push(one);
129
+ shown.push(one);
130
+ }
131
+ results[result.id] = rest;
132
+ }
133
+ steps.push({ calls, results, pictures: shown });
110
134
  }
135
+ // Older pictures than the newest few are left out: the model has seen them, and the step says they're no longer shown.
136
+ for (const old of [...pictures].sort((a, b) => a.number - b.number).slice(0, -MAX_PICTURES)) old.data = null;
111
137
  const history = (Array.isArray(body.history) ? body.history : []).filter((h) => h && typeof h === "object").slice(-3)
112
138
  .map((h) => ({ said: typeof h.said === "string" ? h.said : "", heard: typeof h.heard === "string" ? h.heard : "" }));
113
139
  const seconds = typeof body.seconds === "number" ? body.seconds : 60;
@@ -336,6 +362,16 @@ export function chatBody(r, tag, retry, model) {
336
362
  for (const step of r.steps) {
337
363
  messages.push({ role: "assistant", content: "", tool_calls: step.calls.map((call) => ({ id: call.id, type: "function", function: { name: call.name, arguments: json(call.args) } })) });
338
364
  for (const call of step.calls) messages.push({ role: "tool", tool_call_id: call.id, content: fence(tag, json(step.results[call.id] ?? skipped)) });
365
+ // A tool's result can't hold a picture on every provider, so the step's pictures follow its results as the person's
366
+ // message, each named as its result names it.
367
+ if (step.pictures.length) {
368
+ const content = [{ type: "text", text: picturesSaid(step.pictures) }];
369
+ for (const picture of step.pictures.filter((p) => p.data)) {
370
+ content.push({ type: "text", text: `[picture ${picture.number}], from ${picture.call}:` });
371
+ content.push({ type: "image_url", image_url: { url: `data:${picture.media_type};base64,${picture.data}` } });
372
+ }
373
+ messages.push({ role: "user", content });
374
+ }
339
375
  }
340
376
  if (retry) messages.push({ role: "user", content: `Your last answer couldn't be used: ${retry} Answer again.` });
341
377
  const tools = r.tools.map((tool) => ({ type: "function", function: { name: tool.name,
@@ -381,12 +417,24 @@ export function planPrompt(r, tag, retry) {
381
417
  system += "\n\n" + PLAN;
382
418
  if (r.final) system += '\nOnly a reply is possible now: answer {"reply": {...}}.';
383
419
  else system += "\n\nThe app's tools:\n" + r.tools.map((tool) => `- ${tool.name}: ${tool.reads ? "Looks up, changes nothing. " : "Changes the app's data. "}${tool.about}\n Arguments: ${json(toolSchema(tool))}`).join("\n");
420
+ const images = [];
384
421
  r.steps.forEach((step, i) => {
385
422
  prompt += `\n\nYour answer ${i + 1} called:\n` + json(step.calls.map((c) => ({ name: c.name, args: c.args })));
386
423
  prompt += "\nWhat happened, in that order:\n" + fence(tag, json(step.calls.map((c) => ({ ...(step.results[c.id] ?? skipped), name: c.name }))));
424
+ if (step.pictures.length) prompt += "\n" + picturesSaid(step.pictures, true);
425
+ for (const picture of step.pictures) if (picture.data) images.push({ media_type: picture.media_type, data: picture.data });
387
426
  });
388
427
  if (retry) prompt += `\n\nYour last answer couldn't be used: ${retry} Answer again.`;
389
- return { system, prompt };
428
+ return { system, prompt, images };
429
+ }
430
+
431
+ /** What's said of a step's pictures: which come with the message, in order, and which are no longer shown. */
432
+ function picturesSaid(pictures, attached = false) {
433
+ const named = (list) => list.map((p) => `[picture ${p.number}] (from ${p.call})`).join(", ");
434
+ const sent = pictures.filter((p) => p.data), gone = pictures.filter((p) => !p.data);
435
+ let said = sent.length ? `${attached ? "Attached to this message, in this order" : "The pictures those results name"}: ${named(sent)}. Look at them before you answer.` : "";
436
+ if (gone.length) said += `${said ? " " : ""}No longer shown, as newer pictures came since: ${named(gone)}.`;
437
+ return said;
390
438
  }
391
439
 
392
440
  // MARK: Decisions (`LampAI.decide`) answered by a text model
package/cli/dev.mjs CHANGED
@@ -180,7 +180,9 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
180
180
  if (now === null) { known.delete(path); removed.push(path); }
181
181
  else { known.set(path, now); changed.push(path); }
182
182
  }
183
- if (changed.length || removed.length) broadcast(changed.sort(), removed.sort());
183
+ // A file no page has asked for (renders or logs a page's tests write beside it, say) is nothing an open page shows,
184
+ // so it opens no page again: a page that asks for it later gets it as it is then.
185
+ if ([...changed, ...removed].some((path) => served.has(path))) broadcast(changed.sort(), removed.sort());
184
186
  }
185
187
  // Saves are gathered for a moment, so a change of several files goes to the pages as one.
186
188
  let pending = new Set(), timer = null;
@@ -691,7 +693,11 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
691
693
  // The app's own choice of image model, which Lamp's app keeps for it (`AppModels`), read at each request as Lamp's app reads it.
692
694
  let chosen = {};
693
695
  try { chosen = JSON.parse(readFileSync(join(folder, ".lamp", "models.json"), "utf8")) ?? {}; } catch {}
694
- const [status, reply] = await answerAI(path, given, undefined, { imageModel: chosen.image });
696
+ // A page that stopped waiting (`signal` on `LampAI.ask`) ends the request, and the answer with it.
697
+ const gone = new AbortController();
698
+ response.on("close", () => { if (!response.writableFinished) gone.abort(); });
699
+ const [status, reply] = await answerAI(path, given, undefined, { imageModel: chosen.image }, gone.signal);
700
+ if (gone.signal.aborted) return;
695
701
  return json(200, { status, body: JSON.stringify(reply) });
696
702
  }
697
703
  if (request.method === "POST" && url.pathname.startsWith("/__lamp/")) {