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

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
@@ -118,7 +118,7 @@ What a game's folder lacks all the same, the server gives it:
118
118
  | `lamp-device.js`, `lamp-build.js` | Made by the server: the device is a browser on any device, as a page published to the web is given, and the build is named for the files as they are. |
119
119
  | `lamp-controllers.js` | `cli/lamps/lamp-controllers.js`, the list of controllers as Lamp writes it (`Controllers.pageJS`). `CLIFilesTests` fails when the two differ, and writes the copy again when run with `LAMP_WRITE_CLI_FILES=1`. The game runtime can't load without it. |
120
120
  | `three/`, `phaser/`, `rapier/` | Lamp's copies (`Resources/WebLibraries`), for a library the page asks for and the folder has none of. |
121
- | `lamp.js` | Lamp's building blocks for a page (saved data, the app's files, AI through Lamp's app), as Lamp's own code writes them: `scripts/cli-files.sh` writes the file out (`cli/lamps/lamp.js`, and `lamp-for-games.js` as a game is given it, without a TV's remote; not committed, since they change often), and without it a stand-in that holds nothing. Its AI (`LampAI`: status, ask, act, decide) is answered by `lamp dev` itself (`ai.mjs`, through `lampGatewayHost` in `page.js`, `/__lamp/ai`, only for the page on this Mac) with the person's own Claude Code or Codex, or OpenRouter with their key in `OPENROUTER_API_KEY` (`LAMP_AI` picks one: claude, codex or openrouter; `LAMP_AI_MODEL` its model), the engines sealed as Lamp's app seals it (the same run, `complete` in ai.mjs); making pictures and hearing need Lamp's app. On a page shared on lamp.dev, Lamp's app or `lamp ai` answers it instead. |
121
+ | `lamp.js` | Lamp's building blocks for a page (saved data, the app's files, AI through Lamp's app), as Lamp's own code writes them: `scripts/cli-files.sh` writes the file out (`cli/lamps/lamp.js`, and `lamp-for-games.js` as a game is given it, without a TV's remote; not committed, since they change often), and without it a stand-in that holds nothing. Its AI (`LampAI`: status, ask, act, decide) is answered by `lamp dev` itself (`ai.mjs`, through `lampGatewayHost` in `page.js`, `/__lamp/ai`, only for the page on this Mac) with the person's own Claude Code or Codex, or OpenRouter with their key in `OPENROUTER_API_KEY` (`LAMP_AI` picks one: claude, codex or openrouter; `LAMP_AI_MODEL` its model), the engines sealed as Lamp's app seals it (the same run, `complete` in ai.mjs). Its pictures (`makeImage`, `editImage`, `editImages`) are made with the OpenRouter key when there is one (the app's own image model from `.lamp/models.json`, else Lamp's first), else by Codex's own image tool when Codex is one of the engines (`makeImage` in ai.mjs, as Lamp's app makes them); Claude Code can't make them, and a shared page's are made only with the OpenRouter key it was let use. Hearing needs Lamp's app. On a page shared on lamp.dev, Lamp's app or `lamp ai` answers it instead. |
122
122
 
123
123
  A file the page asks for that neither the folder nor Lamp has is logged as an error, since a module that imports one
124
124
  never runs and the page can't say so itself.
package/cli/ai.mjs CHANGED
@@ -1,16 +1,16 @@
1
1
  // The AI a page asks for with LampAI (lamp.js), answered by lamp itself for the pages it serves, with the person's own
2
2
  // Claude Code or Codex on this Mac: their subscription, through the program they signed in to, never a key in the page.
3
- // The same routes and answers Lamp's app gives (`/ai/status`, `/ai/models`, `/ai`, `/ai/act`, `/ai/decide`), told and checked by the
4
- // same code (app-ai.mjs, which Lamp's app has do it too), so a page can't tell which answered it. Pictures and
5
- // hearing need Lamp's app: asked here, they say so.
3
+ // The same routes and answers Lamp's app gives (`/ai/status`, `/ai/models`, `/ai`, `/ai/act`, `/ai/decide`, `/ai/image`),
4
+ // told and checked by the same code (app-ai.mjs, which Lamp's app has do it too), so a page can't tell which answered
5
+ // it. Hearing needs Lamp's app: asked here, it says so.
6
6
 
7
7
  import { spawn } from "node:child_process";
8
- import { accessSync, constants, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { accessSync, closeSync, constants, fstatSync, mkdtempSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { delimiter, join } from "node:path";
11
11
  import { randomBytes } from "node:crypto";
12
12
  import { chatBody, decidePrompt, planPrompt, planSchema, readDecision, readMessage, readPlan, readStep } from "./app-ai.mjs";
13
- import { DEFAULT_MODEL, OpenRouterTrouble, chat as openRouterChat, text as openRouterText } from "./openrouter.mjs";
13
+ import { DEFAULT_MODEL, IMAGE_MODELS, OpenRouterTrouble, chat as openRouterChat, image as openRouterImage, text as openRouterText } from "./openrouter.mjs";
14
14
 
15
15
 
16
16
  /**
@@ -115,13 +115,7 @@ export function complete({ system, prompt, schema, seconds = 60, which = engine(
115
115
  try { event = JSON.parse(raw); } catch { return; }
116
116
  if (which === "codex") {
117
117
  if (event.type === "item.completed" && event.item?.type === "agent_message" && typeof event.item.text === "string") text = event.item.text;
118
- else if (event.type === "turn.failed" || event.type === "error") {
119
- // Codex nests the service's error as JSON text: its message is what's said.
120
- const words = String(event.message || event.error?.message || "Codex couldn't answer.");
121
- let nested;
122
- try { nested = JSON.parse(words)?.error?.message; } catch {}
123
- trouble ??= new EngineTrouble("failed", nested || words);
124
- }
118
+ else if (codexFailure(event)) trouble ??= new EngineTrouble("failed", codexFailure(event));
125
119
  return;
126
120
  }
127
121
  if (event.type !== "result") return;
@@ -152,6 +146,16 @@ export function complete({ system, prompt, schema, seconds = 60, which = engine(
152
146
  });
153
147
  }
154
148
 
149
+ /** What Codex said went wrong in one of its events, or nothing. It nests the service's error as JSON text: its message
150
+ * is what's said. */
151
+ function codexFailure(event) {
152
+ if (event?.type !== "turn.failed" && event?.type !== "error") return "";
153
+ const words = String(event.message || event.error?.message || "Codex couldn't answer.");
154
+ let nested;
155
+ try { nested = JSON.parse(words)?.error?.message; } catch {}
156
+ return nested || words;
157
+ }
158
+
155
159
  /** An answer from OpenRouter with the person's key (openrouter.mjs, as Lamp's app sends it), its troubles said as an engine's. */
156
160
  async function viaOpenRouter({ system, prompt, schema, seconds, model, images, signal }) {
157
161
  try {
@@ -166,15 +170,16 @@ async function viaOpenRouter({ system, prompt, schema, seconds, model, images, s
166
170
 
167
171
  // MARK: Status and asking
168
172
 
169
- /** `/ai/status`: what can answer here. Text, decisions and actions with an engine; pictures only in Lamp's app. */
170
- export function status() {
173
+ /** `/ai/status`: what can answer here. Text, decisions and actions with an engine; pictures as `pictureRoute` says. */
174
+ export function status(page) {
171
175
  const which = engine();
172
176
  const route = which ? named(which) : null;
173
177
  const available = (kind) => route ? { available: true, route, where: "remote" } : { available: false, reason: missing, setup: "lamp" };
178
+ const drawer = pictureRoute(page);
174
179
  return {
175
- text: !!route, decisions: !!route, pictures: false, act: !!route,
180
+ text: !!route, decisions: !!route, pictures: !!drawer, act: !!route,
176
181
  details: { text: available("text"), decisions: available("decisions"), act: available("act"),
177
- pictures: { available: false, reason: "Pictures need Lamp's app: open the app in Lamp.", setup: "lamp" } },
182
+ pictures: drawer ? { available: true, route: named(drawer), where: "remote" } : { available: false, reason: noPictures(page), setup: "lamp" } },
178
183
  };
179
184
  }
180
185
 
@@ -340,14 +345,152 @@ export async function act(body, here = engines()) {
340
345
  return [502, { error: "The AI didn't answer." }];
341
346
  }
342
347
 
343
- /** A page's AI request, by its route, as Lamp's app answers it: [status, JSON]. */
344
- export async function answer(path, body, page) {
345
- if (path === "/ai/status") return [200, status()];
348
+ // MARK: Pictures (`LampAI.makeImage`, `editImage`, `editImages`)
349
+
350
+ /**
351
+ * What makes a page's pictures here, as Lamp's app chooses (`AppAI.pictureRoute`): OpenRouter with the person's key,
352
+ * else Codex's own image tool when Codex is one of the engines here (Lamp's app spends Codex on pictures only when it
353
+ * was chosen), else nothing: Claude Code can't make pictures. A page shared on lamp.dev gets only the engine the person
354
+ * let it use (`enginesFor`), and never Codex, which makes a picture by running its own tools in a folder.
355
+ */
356
+ export function pictureRoute(page) {
357
+ const here = enginesFor(page);
358
+ if (process.env.OPENROUTER_API_KEY && (!page || here[0] === "openrouter")) return "openrouter";
359
+ if (!page && here.includes("codex")) return "codex";
360
+ return null;
361
+ }
362
+
363
+ /** Why nothing makes pictures here, saying what would. */
364
+ const noPictures = (page) => page
365
+ ? "Pictures for a page shared on lamp.dev need an OpenRouter key here, in OPENROUTER_API_KEY with LAMP_AI=openrouter. Or open it with Lamp."
366
+ : "Making pictures here needs an OpenRouter key in OPENROUTER_API_KEY, or Codex on this computer, signed in. Or open the app in Lamp.";
367
+
368
+ /** How long a picture may take: as long as lamp.js waits for one, and what Lamp's app gives OpenRouter (calls.mjs). */
369
+ const PICTURE_SECONDS = 240;
370
+
371
+ /** The image model a picture is asked of: the app's own choice when it's one of Lamp's, else Lamp's first, as Lamp's app does. */
372
+ export const imageModelFor = (choice) => (IMAGE_MODELS.includes(choice) ? choice : IMAGE_MODELS[0]);
373
+
374
+ /** A picture's type by its first bytes, or nothing when it isn't one: what a model hands back is checked as a picture
375
+ * before a page is given it, as Lamp's app does. */
376
+ function pictureType(bytes) {
377
+ if (bytes.length > 8 && bytes[0] === 0x89 && bytes.toString("latin1", 1, 4) === "PNG") return "image/png";
378
+ if (bytes.length > 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
379
+ if (bytes.length > 12 && bytes.toString("latin1", 0, 4) === "RIFF" && bytes.toString("latin1", 8, 12) === "WEBP") return "image/webp";
380
+ return "";
381
+ }
382
+
383
+ /**
384
+ * `/ai/image`: a picture made, or edited from the pictures the page gave (`images`, base64 with their `media_type`),
385
+ * answered as Lamp's app answers it: `{image, media_type}`, or an error, with `needs: "pictures"` when nothing here can
386
+ * make one. `app.imageModel` is the app's own choice of model (its .lamp/models.json). The request's size is the
387
+ * server's to cap, as every AI request's is.
388
+ */
389
+ export async function makeImage(body, page, app = {}) {
390
+ const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : "";
391
+ if (!prompt) return [400, { error: "Missing prompt." }];
392
+ const route = pictureRoute(page);
393
+ if (!route) return [501, { error: noPictures(page), needs: "pictures" }];
394
+ const references = (Array.isArray(body.images) ? body.images : [])
395
+ .filter((image) => typeof image?.data === "string" && image.data && typeof image.media_type === "string");
396
+ const aspectRatio = typeof body.aspect_ratio === "string" && body.aspect_ratio ? body.aspect_ratio : undefined;
397
+ let made;
398
+ try {
399
+ if (route === "openrouter") {
400
+ made = Buffer.from((await openRouterImage({ key: process.env.OPENROUTER_API_KEY, model: imageModelFor(app.imageModel), prompt,
401
+ references, aspectRatio, seconds: PICTURE_SECONDS })).image, "base64");
402
+ } else {
403
+ made = await codexImage({ prompt, references, aspectRatio });
404
+ }
405
+ } catch (error) {
406
+ 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) }];
408
+ }
409
+ const type = pictureType(made);
410
+ 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." }];
411
+ return [200, { image: made.toString("base64"), media_type: type }];
412
+ }
413
+
414
+ /**
415
+ * What Codex may touch while it makes a picture: the scratch folder, and the little of the system its tools need to
416
+ * start, nothing of the person's and no network for its commands. Its image tool needs its code tools on, and the page
417
+ * writes the request, so the request is never trusted with more than the folder the picture comes back from.
418
+ */
419
+ export function picturePermissions(folder) {
420
+ const filesystem = [[":root", "deny"], [":minimal", "read"], [folder, "write"]].map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`);
421
+ return `permissions.lamp={filesystem={${filesystem.join(",")}},network={enabled=false}}`;
422
+ }
423
+
424
+ /**
425
+ * A picture from Codex's built-in image tool, asked for as Lamp's app asks (`viaCodexImage`): run in a scratch folder
426
+ * it may write in and nothing outside it may be read, with none of the person's config, rules or hooks, told to save image.png there; the pictures to
427
+ * build on are attached with `-i` and named in the request. The folder goes when it's done. Takes about a minute.
428
+ */
429
+ export function codexImage({ prompt, references = [], aspectRatio, seconds = PICTURE_SECONDS, tool = onPath("codex") || "codex" }) {
430
+ // By its real path: the profile below names it, and Codex refuses a folder reached through a link (/var is one).
431
+ const folder = realpathSync(mkdtempSync(join(tmpdir(), "lamp-image-")));
432
+ const files = references.map((image, index) => {
433
+ const name = `reference${index + 1}.${image.media_type.endsWith("png") ? "png" : "jpg"}`;
434
+ writeFileSync(join(folder, name), Buffer.from(image.data, "base64"));
435
+ return name;
436
+ });
437
+ let request = `Create an image and save it as image.png in the current folder, then reply with just: done.\n\nThe image: ${prompt}`;
438
+ if (aspectRatio) request += `\n\nAspect ratio ${aspectRatio}.`;
439
+ if (files.length) {
440
+ request += `\n\nUse the attached ${files.length === 1 ? "image" : "images"} (${files.join(", ")}) as `
441
+ + (files.length === 1 ? "the reference: edit or build on it as the description says." : "references.");
442
+ }
443
+ const args = ["exec", "--json", "--ignore-user-config", "--ignore-rules", "--skip-git-repo-check", "--ephemeral",
444
+ "-c", picturePermissions(folder), "-c", 'default_permissions="lamp"', "-c", 'approval_policy="never"', "-c", "hooks={}", "-C", folder, ...files.flatMap((name) => ["-i", name]), "--", request];
445
+ // As `complete` runs it: never told to be Bun, which lamp itself is told.
446
+ const env = { ...process.env };
447
+ delete env.BUN_BE_BUN;
448
+ return new Promise((resolve, reject) => {
449
+ const child = spawn(tool, args, { cwd: folder, env, detached: true, stdio: ["ignore", "pipe", "ignore"] });
450
+ let trouble = null, rest = "";
451
+ // The whole group, since Codex may leave a child of its own running.
452
+ const timer = setTimeout(() => {
453
+ trouble = new EngineTrouble("timeout", "The picture took too long to make.");
454
+ try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); }
455
+ }, seconds * 1000);
456
+ const line = (raw) => {
457
+ let event;
458
+ try { event = JSON.parse(raw); } catch { return; }
459
+ if (codexFailure(event)) trouble ??= new EngineTrouble("failed", codexFailure(event));
460
+ };
461
+ child.stdout.setEncoding("utf8");
462
+ child.stdout.on("data", (chunk) => { rest += chunk; const lines = rest.split("\n"); rest = lines.pop(); lines.forEach(line); });
463
+ const finish = () => {
464
+ clearTimeout(timer);
465
+ let made = null;
466
+ // Never through a link: Codex may make image.png a link to a file it couldn't read itself, which lamp could.
467
+ let fd = null;
468
+ try {
469
+ fd = openSync(join(folder, "image.png"), constants.O_RDONLY | constants.O_NOFOLLOW);
470
+ if (fstatSync(fd).isFile()) made = readFileSync(fd);
471
+ } catch {} finally { if (fd !== null) closeSync(fd); }
472
+ rmSync(folder, { recursive: true, force: true });
473
+ return made;
474
+ };
475
+ child.on("error", () => { finish(); reject(new EngineTrouble("missing", "Codex isn't on this computer.")); });
476
+ child.on("close", () => {
477
+ if (rest) line(rest);
478
+ const made = finish();
479
+ if (trouble) return reject(trouble);
480
+ resolve(made ?? Buffer.alloc(0));
481
+ });
482
+ });
483
+ }
484
+
485
+ /** 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)];
346
489
  if (path === "/ai/models") return [200, models(enginesFor(page))];
347
490
  if (path === "/ai") return ask(body, enginesFor(page));
348
491
  if (path === "/ai/act") return act(body, enginesFor(page));
349
492
  if (path === "/ai/decide") return decide(body);
350
493
  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." }];
351
- if (path === "/ai/image") return [501, { error: "Pictures need Lamp's app: open the app in Lamp.", needs: "pictures" }];
494
+ if (path === "/ai/image") return makeImage(body, page, app);
352
495
  return [404, { error: "Not found." }];
353
496
  }
package/cli/dev.mjs CHANGED
@@ -688,7 +688,10 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
688
688
  const path = typeof asked?.path === "string" ? asked.path : "";
689
689
  let given = {};
690
690
  try { given = typeof asked?.body === "string" ? JSON.parse(asked.body) : asked?.body ?? {}; } catch {}
691
- const [status, reply] = await answerAI(path, given);
691
+ // 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
+ let chosen = {};
693
+ try { chosen = JSON.parse(readFileSync(join(folder, ".lamp", "models.json"), "utf8")) ?? {}; } catch {}
694
+ const [status, reply] = await answerAI(path, given, undefined, { imageModel: chosen.image });
692
695
  return json(200, { status, body: JSON.stringify(reply) });
693
696
  }
694
697
  if (request.method === "POST" && url.pathname.startsWith("/__lamp/")) {
@@ -1 +1 @@
1
- {"platform":"mac","exe":"ZqxAppName","name":"Zqx App Name","instance":"67179AAC-BEE4-45E7-8C8D-C5BC3D6CB101"}
1
+ {"name":"Zqx App Name","instance":"81942121-46A7-4886-9515-F2C5D44CC566","exe":"ZqxAppName","platform":"mac"}
@@ -6,7 +6,18 @@
6
6
  const ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
7
7
  /** The model lamp answers with when none is named: the one Lamp's app offers first for apps' text. */
8
8
  export const DEFAULT_MODEL = "anthropic/claude-sonnet-5.5";
9
- const FILTERED = "The AI provider's content filter cut the reply off. Try rewording the request.";
9
+ /** The image models an app may choose for its pictures, by OpenRouter's ids, first the one used when it chose none:
10
+ * Lamp's app's list (`Icons.artModels`), kept the same by hand. */
11
+ export const IMAGE_MODELS = [
12
+ "google/gemini-3.1-flash-image",
13
+ "google/gemini-nano-banana-2.1",
14
+ "google/gemini-3-pro-image",
15
+ "openai/gpt-image-2.5-sunburst",
16
+ "openai/gpt-image-2.5-flare",
17
+ "openai/gpt-image-2",
18
+ "google/gemini-3.1-flash-lite-image",
19
+ ];
20
+ const FILTERED ="The AI provider's content filter cut the reply off. Try rewording the request.";
10
21
 
11
22
  /** Why OpenRouter gave no answer: `kind` "refused" (with its `status` and `reply`), "filtered", "timeout" or "stopped". */
12
23
  export class OpenRouterTrouble extends Error {
@@ -36,7 +36,7 @@ looked earlier, `--reopen` puts it right.
36
36
  a page (saved data with `LampStore`, the app's files and more). Read Lamp's `web-app` skill before writing it, and
37
37
  the comments in `lamp.js` for how each block is used; never edit `lamp.js`. Its AI (`LampAI`: ask, act, decide) is
38
38
  answered by lamp itself on this Mac, with the person's own Claude Code or Codex (or OpenRouter, with their key in `OPENROUTER_API_KEY`), in every page lamp runs here (`dev`,
39
- `open`, `look`, `use`); pictures and hearing need Lamp's app. Shared on lamp.dev, it's answered on the computer the page is played on while Lamp is open there or `{{lamp}} ai` runs there in a terminal, once the person allows that page. Never put a key or a model provider's address in the page. A page that wants none of Lamp's needs no starting files: write its
39
+ `open`, `look`, `use`). Pictures (`LampAI.makeImage` and its edits) are made there with the OpenRouter key, or with Codex; not with Claude Code alone. Hearing needs Lamp's app. Shared on lamp.dev, it's answered on the computer the page is played on while Lamp is open there or `{{lamp}} ai` runs there in a terminal, once the person allows that page. Never put a key or a model provider's address in the page. A page that wants none of Lamp's needs no starting files: write its
40
40
  `index.html` in an empty folder.
41
41
 
42
42
  ## A Mac app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lampdotdev/lamp",
3
- "version": "0.36.2-nightly.39",
3
+ "version": "0.36.2-nightly.41",
4
4
  "description": "Runs a web app or game from its folder and puts each saved change into the open page.",
5
5
  "type": "module",
6
6
  "bin": {