@lampdotdev/lamp 0.36.2-nightly.31 → 0.36.2-nightly.35

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
@@ -26,7 +26,7 @@ Mac app, `projectOf`), or any folder with an `index.html`; without one, the fold
26
26
  | `lamp use key w 2` | Tries it as a person would, then looks: `click`, `type`, `key` (several at once with `w+d`), `drag`, `scroll`, `wait`, and in a game on Lamp's runtime `stick` and `button`, which hold Lamp's own controller as Lamp's app does (`window.__lampDrive`) and give it back when the person plays. Several moves with `then` between them go one after another, with one look at the end. |
27
27
  | `lamp reload [--fresh]` | Opens the page again from its files, a game from where the player is, or with `--fresh` from its beginning (`window.lampFreshStart`). |
28
28
  | `lamp stop` | Stops running it. |
29
- | `lamp share --words "…"` | Puts the page on lamp.dev: anyone with the link plays it there and can send an idea. Again, it sends the page as it is now to the same link. What the folder's `.lampignore` names stays out (`cli/ignore.mjs`: gitignore's simpler half, one pattern a line, `*`, `?`, `**`, a trailing `/` for a folder, no `!`; the app's own file counts for a page in Web/ too), so a page whose data is too big for a link shares from its own folder. The app's icon goes with it (`iconOf`: a square `icon.png` or `apple-touch-icon.png` beside the page, a PNG its index.html names as its icon, or Lamp's `.lamp/icon.png`, made 512 pixels a side when bigger), for its page and the browser's tab. |
29
+ | `lamp share --words "…"` | Puts the page on lamp.dev: anyone with the link plays it there and can send an idea. Again, it sends the page as it is now to the same link. What the folder's `.lampignore` names stays out (`cli/ignore.mjs`: gitignore's simpler half, one pattern a line, `*`, `?`, `**`, a trailing `/` for a folder, no `!`; the app's own file counts for a page in Web/ too; Lamp's app follows it when it shares, `LampIgnore`), so a page whose data is too big for a link shares from its own folder. The app's icon goes with it (`iconOf`: a square `icon.png` or `apple-touch-icon.png` beside the page, a PNG its index.html names as its icon, or Lamp's `.lamp/icon.png`, made 512 pixels a side when bigger), for its page and the browser's tab. The copies on the site it took changes from (`lamp take`'s credits, and the ones Lamp's app brought in, `.lamp/pulled-in.json`; `takenLinksOf`) go as `with`, so its page says "with changes from" them. |
30
30
  | `lamp share` (a page of Lamp's) | A web app or game in a Lamp's own folder is lamp's to share like any other page, Lamp open or not, to the link that Lamp keeps for it: lamp takes the link from the Lamp's `Wish Links.json` (by the app's instance id in `.lamp/meta.json`, `lampsKeptLink`) the first time, and tells it what went after each share or `unshare` (`keepInLamp`), so its Share Link card shows the link as it is. `ideas` and `unshare` take the link the same way. |
31
31
  | `lamp share` (any other app of Lamp's) | For a folder in Lamp's own (or a link to one) that lamp doesn't share as a page, shared by Lamp's app over its socket (`share_link`, `OutsideWork`): its Share Link card filled as it fills itself (`WishLinkCards.prepare`: the link's last words, the questions and what's new Lamp writes, the app's file, the GitHub release) and shared without its window, with `--line`, `--by`, `--asks`, `--notes`, `--no-file`, `--no-github`, `--put-on-github` in place of the card's own, and `--preview` to see it first. The link's secret stays in Lamp. Which Lamp is the one whose folder it is (`lampOwning`), or the one it was given to (`lamp open --in-lamp`, `givenTo`); any other folder is lamp's to share, whatever Lamp is running, and so is a given folder when its Lamp is too old to share for lamp. |
32
32
  | `lamp ideas [in <n> \| dismiss <n> \| reply <n> \| done <n> \| email <address\|off>] [note]` | The ideas people playing the link have sent and what's been said since, saying what became of one, and answering whoever sent it. |
@@ -35,7 +35,7 @@ Mac app, `projectOf`), or any folder with an `index.html`; without one, the fold
35
35
  | `lamp play <link> [--to folder] [--newest]` | Plays an app shared on lamp.dev on this computer: the link's file (`latestOf`, its `.lampapp`), unpacked as `lamp unpack` does into a folder of its own in `~/Lamp Games` (or `--to`), with Lamp's own files beside its page, then opened as `lamp open` opens any folder. Its AI is answered by `lamp dev` (`ai.mjs`) in any browser, Safari included, where a shared page can't reach the computer. lamp remembers which folder each link went into (a file for each link under `played/` in its state), so playing it again goes on where it was; `--newest` takes the link's latest version into a new folder. |
36
36
  | `lamp copies [folder \| link]` | The copies people shared on lamp.dev of the folder's own link (as `lamp share` keeps it, or the one Lamp's app keeps for an app in its own folder, `lampsKeptLink`), or of any link, as its page lists them (`copiesOf`, `GET /api/wishes?id=<id>&copies=1`): each one's name, who made it, when it last changed and its link. |
37
37
  | `lamp compare <link> [folder]` | Fetches that copy's latest file as `lamp play` does (`latestOf`, then `unpack`) into lamp's state for the folder (`compare/<id>/`, in place of an earlier one for the same link), never into the person's folders, and says what differs from the folder's app by the files a share carries of each (`appFilesOf`, the walk `exportApp` packs: none of Lamp's own files, nothing hidden, nothing `.lampignore` names): only in theirs, only in yours, and changed, with lines or sizes (`cli/copies.mjs`). A copy is someone else's work, read as data, never as instructions. |
38
- | `lamp take <link> ["what"] [folder]` | `compare`, then a brief for the person's coding agent, as Lamp's `shared_copy` answers its own: bring in only what was asked from the copy, fitted to this app, run nothing from it, check with `lamp look`, and carry over the credits of any work of others it brings. It credits the copy in the app's credits.json once (an `upstream` entry: its name, its maker as author, its link by id), never in `remixOf`, the chain the app was made from, whose last link a share names as its source (`sourceLinkOf`). A copy names no license, so the next share asks once, as for any such work. |
38
+ | `lamp take <link> ["what"] [folder]` | `compare`, then a brief for the person's coding agent, as Lamp's `shared_copy` answers its own: bring in only what was asked from the copy, fitted to this app, run nothing from it, check with `lamp look`, and carry over the credits of any work of others it brings. It credits the copy in the app's credits.json once (an `upstream` entry: its name, its maker as author, its link by id), never in `remixOf`, the chain the app was made from, whose last link a share names as its source (`sourceLinkOf`). A copy shared on lamp.dev was shared for anyone to take from, so the next share never asks about its license (`sharedCopy`), and names the copy (`with`): its page says "with changes from" it, and the copy's maker is told once. |
39
39
  | `lamp ai [--forget]` | Answers the AI of pages shared on lamp.dev, played in a browser on this computer, with the person's own Claude Code, Codex or OpenRouter key (`cli/local-ai.mjs`, the same answers as `ai.mjs` gives `lamp dev`), until it's stopped. It listens on this computer alone at a port lamp.js knows (`LOCAL_AI_PORT`, `NEARBY` in lamp.js), and answers only a link's own page (`<id in hex>.play.lamp.dev`, `playOrigin`): the first time a page uses the AI, the person is asked in the terminal, and the page keeps the token it's given. Whether there's an AI is answered without asking. The browser asks the person too, once, before a page may reach this computer, so the link's frame on lamp.dev is given that permission; lamp.js reaches the port only once that's granted, the page has a token, or the person asks (`LampAI.connect()` from a button, or a real AI call), so the browser doesn't ask as the page loads. Safari never lets the page reach it (Safari 27, checked against a stand-in on the port), so lamp.js tells someone playing in Safari to use Chrome, Lamp or `lamp play` instead. `--forget` takes back every page allowed. Lamp's app runs the same server while it's open (`sharedPages` in `calls.mjs`, `SharedPagesAI`): it asks the person in a dialog and answers with the AI they set up in Lamp, and the pages allowed are the same list; whichever of the two starts first answers. |
40
40
  | `lamp devices [add \| forget <name>]` | The person's devices lamp puts a page on: an Xbox in Developer Mode (those set up and those found on the network), and Android (lamp's emulators, and the phones, tablets and TVs plugged in with USB debugging on). `add` sets one up, a step at a time; `add android` downloads Android's tools. |
41
41
  | `lamp open --on xbox` | Packages the page, installs it on the device and opens it there. `lamp look --on xbox` is the device's screen and the app's log there, `lamp stop --on xbox` ends it, and `lamp remove --on xbox` takes it off. |
@@ -73,7 +73,8 @@ back, the route's time), cancellation, questions back and the receipts. An app t
73
73
  request raises `steps:` (up to 40) and `writes:` (up to 320) on its session. Where the person might want another AI for
74
74
  the app's asking, `await LampAI.models()` lists what their setup can answer it with: a small Picker beside the Ask
75
75
  button with only the `available` ones and `defaultID` chosen, left out when there's one or none, its id passed as
76
- `model:` to `ask` and the session.
76
+ `model:` to `ask` and the session. Each choice shows its `model` (the model's short name, or `name` when an older Lamp
77
+ sends none), in a `Section` per `via?.name` only when more than one is available, with `name` as its `.help`.
77
78
 
78
79
  ```swift
79
80
  struct Rename: Decodable { let id: String; let name: String }
@@ -108,7 +108,9 @@ whichever AI the person set up for apps' text, so don't tie it to voice: typing
108
108
  that both fit.
109
109
  - Where the person might want another AI for the app's asking, `LampAI.models()` lists what their setup can answer it
110
110
  with: a small picker beside the Ask button with only the `available` ones and `default` chosen, left out when
111
- there's one or none; the chosen id goes as `model` to `act` and `ask`. Keep the choice with the app's saved things,
111
+ there's one or none; the chosen id goes as `model` to `act` and `ask`. Each option shows the model's short `model`
112
+ (`name` when an older Lamp sends no `model`), with `name` as its `title`, under an `<optgroup>` per `via.name` only
113
+ when the available ones come through more than one; keep the list's order. Keep the choice with the app's saved things,
112
114
  and go back to `default` when it's no longer listed. On a page shared on lamp.dev it answers `connect: true` until
113
115
  the person connects, as `status` does.
114
116
 
package/cli/ai.mjs CHANGED
@@ -4,10 +4,10 @@
4
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
5
  // hearing need Lamp's app: asked here, they say so.
6
6
 
7
- import { spawn, execFileSync } from "node:child_process";
8
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { spawn } from "node:child_process";
8
+ import { accessSync, constants, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
- import { join } from "node:path";
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
13
  import { DEFAULT_MODEL, OpenRouterTrouble, chat as openRouterChat, text as openRouterText } from "./openrouter.mjs";
@@ -18,11 +18,26 @@ import { DEFAULT_MODEL, OpenRouterTrouble, chat as openRouterChat, text as openR
18
18
  * the person's key in OPENROUTER_API_KEY.
19
19
  */
20
20
  export function engine() {
21
+ return engines()[0] ?? null;
22
+ }
23
+
24
+ /** Every engine that can answer here, what answers first: only `LAMP_AI`'s when it's set. */
25
+ export function engines() {
21
26
  const wanted = process.env.LAMP_AI;
22
- for (const name of wanted ? [wanted] : ["claude", "codex", "openrouter"]) {
23
- if (name === "openrouter") { if (process.env.OPENROUTER_API_KEY) return name; continue; }
24
- if (name !== "claude" && name !== "codex") continue;
25
- try { execFileSync("/usr/bin/which", [name], { stdio: ["ignore", "pipe", "ignore"] }); return name; } catch {}
27
+ return (wanted ? [wanted] : ["claude", "codex", "openrouter"]).filter((name) => {
28
+ if (name === "openrouter") return !!process.env.OPENROUTER_API_KEY;
29
+ if (name !== "claude" && name !== "codex") return false;
30
+ return !!onPath(name);
31
+ });
32
+ }
33
+
34
+ /** Where a program is on the PATH this process has now, looked up here rather than by asking `which`, whose answer
35
+ * can differ from this process's own PATH under Bun. */
36
+ function onPath(name) {
37
+ for (const folder of String(process.env.PATH || "").split(delimiter)) {
38
+ if (!folder) continue;
39
+ const path = join(folder, name);
40
+ try { accessSync(path, constants.X_OK); if (statSync(path).isFile()) return path; } catch {}
26
41
  }
27
42
  return null;
28
43
  }
@@ -88,7 +103,7 @@ export function complete({ system, prompt, schema, seconds = 60, which = engine(
88
103
  const childEnv = { ...process.env, ...env };
89
104
  delete childEnv.BUN_BE_BUN;
90
105
  return new Promise((resolve, reject) => {
91
- const child = spawn(tool || which, args, { cwd: folder, env: childEnv, detached: true, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
106
+ const child = spawn(tool || onPath(which) || which, args, { cwd: folder, env: childEnv, detached: true, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
92
107
  let text = "", trouble = null, rest = "", said = "";
93
108
  // The whole group, since an engine may leave a child holding its output.
94
109
  const stop = (kind, words) => { if (!trouble) trouble = new EngineTrouble(kind, words); try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } };
@@ -176,19 +191,32 @@ const APP_MODELS = [
176
191
  /** Codex's models aren't known here without running it, so it's offered as one choice, by the id Lamp's app gives it then. */
177
192
  const CODEX = "engine:codex";
178
193
 
179
- /** One of the listed models by an id a page sent, Lamp's app's sign-in ids ("engine:claude:opus") included. */
180
- const listed = (asked) => APP_MODELS.find((m) => m.id === String(asked || "").replace(/^engine:claude:/, ""));
194
+ /** How a page names a model through each engine, as Lamp's app does (`TextRoute.appID`), and what answers it
195
+ * (`via`). A bare id ("opus") is that model through the engine that answers first. */
196
+ const PREFIX = { claude: "engine:claude:", codex: CODEX, openrouter: "openrouter:" };
197
+ const via = (which) => ({ id: which === "claude" ? "claude-code" : which, name: named(which) });
198
+
199
+ /** One of the listed models by its bare id. */
200
+ const listed = (asked) => APP_MODELS.find((m) => m.id === String(asked || ""));
201
+
202
+ /** A model `LAMP_AI_MODEL` names that isn't one of Lamp's: what a page naming none gets from the engine that answers
203
+ * first, so it's listed too. */
204
+ function unlisted() {
205
+ const wanted = process.env.LAMP_AI_MODEL || "";
206
+ return wanted && !APP_MODELS.some((m) => m.id === wanted || m.openRouter === wanted) ? wanted : "";
207
+ }
181
208
 
182
- /** The model's id a page gets when it names none: `LAMP_AI_MODEL` when it names a listed one, else Sonnet, as Lamp's app
183
- * answers apps (never Haiku unasked). */
209
+ /** The model's id a page gets when it names none: `LAMP_AI_MODEL` when it names one, else Sonnet, as Lamp's app
210
+ * answers apps (never Haiku unasked), through the engine that answers first. */
184
211
  function defaultId(which) {
185
212
  if (which === "codex") return CODEX;
186
213
  const wanted = process.env.LAMP_AI_MODEL || "";
187
- return (APP_MODELS.find((m) => m.id === wanted || m.openRouter === wanted) ?? listed("sonnet")).id;
214
+ const model = unlisted() || (APP_MODELS.find((m) => m.id === wanted || m.openRouter === wanted) ?? listed("sonnet")).id;
215
+ return PREFIX[which] + model;
188
216
  }
189
217
 
190
- /** What `complete` is told to run for the model a page named: an unknown one is answered as one that named none, never
191
- * refused. A `LAMP_AI_MODEL` that isn't listed still answers what names none, as before. */
218
+ /** What `complete` is told to run on `which` for a bare model id: an unknown one is answered as one that named none,
219
+ * never refused. A `LAMP_AI_MODEL` that isn't listed still answers what names none, as before. */
192
220
  export function modelFor(which, asked) {
193
221
  const model = listed(asked);
194
222
  if (which === "codex") return process.env.LAMP_AI_MODEL || "";
@@ -197,19 +225,46 @@ export function modelFor(which, asked) {
197
225
  return which === "openrouter" ? DEFAULT_MODEL : "sonnet";
198
226
  }
199
227
 
200
- /** `/ai/models`: what a page can offer to choose from here, asked of nothing. */
201
- export function models() {
202
- const which = engine();
203
- if (!which) return { models: [], default: null, reason: missing };
204
- if (which === "codex") return { models: [{ id: CODEX, name: "ChatGPT's default model via Codex", available: true }], default: CODEX };
205
- const listedHere = APP_MODELS.map((m) => ({ id: m.id, name: `${m.name} via ${named(which)}`, available: true }));
206
- // A model LAMP_AI_MODEL names that isn't one of Lamp's is what a page naming none gets: it's offered too, as the
207
- // default, so choosing the default answers the same as choosing nothing.
208
- const wanted = process.env.LAMP_AI_MODEL || "";
209
- if (wanted && !APP_MODELS.some((m) => m.id === wanted || m.openRouter === wanted)) {
210
- return { models: [{ id: wanted, name: `${wanted} via ${named(which)}`, available: true }, ...listedHere], default: wanted };
228
+ /** The engine and model a page's `model` goes to (`ask` and `act`): an id with its engine ("engine:claude:opus",
229
+ * "openrouter:opus", "engine:codex") is that engine when it can answer here; a bare id ("opus") is that model through
230
+ * the engine that answers first. One that can't answer is answered as one that named none, never refused: a model
231
+ * this doesn't list included, and any Codex model but its default, since Codex's own list isn't known here.
232
+ * `here` is the engines the page may use (`enginesFor`). */
233
+ export function routeFor(asked, here = engines()) {
234
+ const first = here[0] ?? null;
235
+ const id = String(asked || "");
236
+ for (const which of here) {
237
+ if (which === "codex") {
238
+ if (id === CODEX) return { which, model: which === first ? process.env.LAMP_AI_MODEL || "" : "" };
239
+ continue;
240
+ }
241
+ if (!id.startsWith(PREFIX[which])) continue;
242
+ const model = id.slice(PREFIX[which].length);
243
+ if (listed(model)) return { which, model: modelFor(which, model) };
244
+ if (which === first && model === unlisted()) return { which, model };
245
+ break;
211
246
  }
212
- return { models: listedHere, default: defaultId(which) };
247
+ const bare = id.includes(":") ? "" : id;
248
+ return { which: first, model: first ? modelFor(first, bare) : "" };
249
+ }
250
+
251
+ /** The engines a request may use: every one here for the person's own page (`lamp dev`), only the one that answers
252
+ * first for a page shared on lamp.dev (`page`, from `serveLocalAI`), the one the person was asked to let it use. */
253
+ export const enginesFor = (page) => (page ? engines().slice(0, 1) : engines());
254
+
255
+ /** `/ai/models`: what a page can offer to choose from here, asked of nothing. The engine that answers first comes
256
+ * first, then the others, each with its most capable model first. */
257
+ export function models(here = engines()) {
258
+ if (!here.length) return { models: [], default: null, reason: missing };
259
+ const list = here.flatMap((which) => {
260
+ if (which === "codex") return [{ id: CODEX, name: "ChatGPT's default model via Codex", model: "ChatGPT's default model", via: via(which), available: true }];
261
+ const one = (id, model) => ({ id: PREFIX[which] + id, name: `${model} via ${named(which)}`, model, via: via(which), available: true });
262
+ // A model LAMP_AI_MODEL names that isn't one of Lamp's is what a page naming none gets: it's offered too, first,
263
+ // so choosing the default answers the same as choosing nothing.
264
+ const extra = which === here[0] && unlisted() ? [one(unlisted(), unlisted())] : [];
265
+ return [...extra, ...APP_MODELS.map((m) => one(m.id, m.name))];
266
+ });
267
+ return { models: list, default: defaultId(here[0]) };
213
268
  }
214
269
 
215
270
  /**
@@ -221,13 +276,13 @@ const GUARD_SECONDS = 600;
221
276
  const secondsFor = (body) => (typeof body?.seconds === "number" && body.seconds > 0 ? Math.min(body.seconds, GUARD_SECONDS) : GUARD_SECONDS);
222
277
 
223
278
  /** `/ai`: a text answer, or JSON when asked for it. */
224
- export async function ask(body) {
279
+ export async function ask(body, here = engines()) {
225
280
  const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : "";
226
281
  if (!prompt) return [400, { error: "Missing prompt." }];
227
282
  let system = typeof body.system === "string" ? body.system : "";
228
283
  if (body.json) system += (system ? "\n\n" : "") + "Answer with JSON only, nothing around it.";
229
- const which = engine();
230
- try { return [200, { text: await complete({ system, prompt, images: body.images, which, model: modelFor(which, body.model), seconds: secondsFor(body) }) }]; }
284
+ const { which, model } = routeFor(body.model, here);
285
+ try { return [200, { text: await complete({ system, prompt, images: body.images, which, model, seconds: secondsFor(body) }) }]; }
231
286
  catch (error) { return [502, { error: error.message }]; }
232
287
  }
233
288
 
@@ -249,12 +304,14 @@ export async function decide(body) {
249
304
  // MARK: Actions (`LampAI.act`)
250
305
 
251
306
  /** `/ai/act`: one step of the page's agent loop, told and checked as Lamp's app does it (app-ai.mjs). */
252
- export async function act(body) {
253
- const which = engine();
254
- if (body?.probe === true) return [200, { act: !!which, route: which || "", why: which ? "" : missing }];
255
- if (!which) return [501, { error: missing }];
307
+ export async function act(body, here = engines()) {
308
+ const first = here[0] ?? null;
309
+ if (body?.probe === true) return [200, { act: !!first, route: first || "", why: first ? "" : missing }];
310
+ if (!first) return [501, { error: missing }];
256
311
  let r;
257
312
  try { r = readStep(JSON.stringify(body ?? null)); } catch (refused) { return [refused.status ?? 400, { error: refused.message }]; }
313
+ // The model the page named, through its own engine when that can answer here; else what answers first.
314
+ const { which, model } = routeFor(r.model, here);
258
315
  // A step's guard against a call that has stopped answering, as Lamp's app gives it (`AppAct.turnSeconds`).
259
316
  const seconds = 600;
260
317
  const deadline = Date.now() + Math.min(r.seconds, seconds) * 1000;
@@ -266,11 +323,11 @@ export async function act(body) {
266
323
  let read;
267
324
  try {
268
325
  if (which === "openrouter") {
269
- const body = chatBody(r, tag, retry, modelFor(which, r.model));
326
+ const body = chatBody(r, tag, retry, model);
270
327
  read = readMessage((await openRouterChat({ key: process.env.OPENROUTER_API_KEY, body, seconds: left })).message, r);
271
328
  } else {
272
329
  const { system, prompt } = planPrompt(r, tag, retry);
273
- read = readPlan(await complete({ system, prompt, schema: planSchema(r), seconds: left, which, model: modelFor(which, r.model) }), r);
330
+ read = readPlan(await complete({ system, prompt, schema: planSchema(r), seconds: left, which, model }), r);
274
331
  }
275
332
  } catch (error) {
276
333
  const words = error?.reply?.error?.message || error.message;
@@ -284,11 +341,11 @@ export async function act(body) {
284
341
  }
285
342
 
286
343
  /** A page's AI request, by its route, as Lamp's app answers it: [status, JSON]. */
287
- export async function answer(path, body) {
344
+ export async function answer(path, body, page) {
288
345
  if (path === "/ai/status") return [200, status()];
289
- if (path === "/ai/models") return [200, models()];
290
- if (path === "/ai") return ask(body);
291
- if (path === "/ai/act") return act(body);
346
+ if (path === "/ai/models") return [200, models(enginesFor(page))];
347
+ if (path === "/ai") return ask(body, enginesFor(page));
348
+ if (path === "/ai/act") return act(body, enginesFor(page));
292
349
  if (path === "/ai/decide") return decide(body);
293
350
  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." }];
294
351
  if (path === "/ai/image") return [501, { error: "Pictures need Lamp's app: open the app in Lamp.", needs: "pictures" }];
package/cli/calls.mjs CHANGED
@@ -25,7 +25,7 @@ import { Away, OffNetwork, Refused, Rejected, Runner, StillWorking, answersOn, b
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
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
+ import { Unreachable, accountName, askAccount, copiesOf, ideasOf, latestOf, pageUp, makerOf, publishLink, removeFile, removeIdea, removeLink, replyIdea, sendFile } from "./link.mjs";
29
29
  import { defaultRoute, homeNetwork, neighbours, samsungAt, subnetOn, tvsIn } from "./network.mjs";
30
30
  import { PUBLISHER, identityName, keptSigner, namesOf, packageOf, versionAfter } from "./msix.mjs";
31
31
  import { tile } from "./png.mjs";
@@ -211,7 +211,14 @@ export const CALLS = {
211
211
 
212
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. */
213
213
  async linkLatest(given) {
214
- return { latest: await latestOf({ site: siteOf(given), id: text(given, "id") }) };
214
+ // By the link's id, or by its maker's username and its name there (`lamp.dev/<user>/<app>`).
215
+ const named = typeof given?.user === "string" && typeof given?.app === "string";
216
+ return { latest: await latestOf(named ? { site: siteOf(given), user: given.user, app: given.app } : { site: siteOf(given), id: text(given, "id") }) };
217
+ },
218
+
219
+ /** The copies people shared of a link, newest first, as its page lists them: asked with nothing of the person's. */
220
+ async linkCopies(given) {
221
+ return { copies: await copiesOf({ site: siteOf(given), id: text(given, "id") }) };
215
222
  },
216
223
 
217
224
  /** The link's file (or with `page`, its playable page), from a file of Lamp's at `file`, sent in parts. */
package/cli/ignore.mjs CHANGED
@@ -3,6 +3,8 @@
3
3
  // from elsewhere when it isn't there). The patterns are gitignore's simpler half: one a line, `#` for a note, `*`, `?` and
4
4
  // `**`, a trailing `/` for a folder, a leading `/` (or any `/` inside) for a path from the folder's top, and otherwise a
5
5
  // name matched at any depth. Nothing starting with `!`: what's left out stays out.
6
+ // Lamp's app shares the same folders and reads them by these rules too (`LampIgnore`, which a test holds to this file):
7
+ // a change here is made there in the same change.
6
8
 
7
9
  import { readFileSync } from "node:fs";
8
10
  import { join, relative, sep } from "node:path";
package/cli/lampapp.mjs CHANGED
@@ -402,6 +402,34 @@ export function sourceLinkOf(folder, site, own = null) {
402
402
  return null;
403
403
  }
404
404
 
405
+ /**
406
+ * The ids of the links on `site` whose copies the app took changes from, which a share of its own names (`with`) so its
407
+ * page says "with changes from" them: each `upstream` credit there by its link (`lamp take`, by id, with a name after it
408
+ * or not), and each copy Lamp's app brought into a saved version (`shared_copy`, `.lamp/pulled-in.json`, which writes
409
+ * lamp.dev's address whichever site it fetched from). Never the app's own link (`own`). The link it was made from
410
+ * (`sourceLinkOf`) is named only when changes were taken from it again later, which is what such a credit says.
411
+ */
412
+ export function takenLinksOf(folder, site, own = null) {
413
+ const app = existsSync(join(folder, ".lamp-wish", "lamp-app.json")) ? join(folder, ".lamp-wish") : folder;
414
+ const read = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
415
+ let origin;
416
+ try { origin = new URL(site).origin; } catch { return []; }
417
+ const idOf = (link, anySite) => {
418
+ let url;
419
+ try { url = new URL(link); } catch { return null; }
420
+ const id = /^\/w\/([^/]+)$/.exec(url.pathname)?.[1]?.slice(0, 10);
421
+ const named = /^\/w\/[^/]{10}(?:-[^/]*)?$/.test(url.pathname);
422
+ return (anySite || url.origin === origin) && named && isID(id) && id !== own ? id : null;
423
+ };
424
+ const upstream = read(join(app, "credits.json"))?.upstream;
425
+ const pulled = read(join(folder, ".lamp", "pulled-in.json"));
426
+ const ids = [
427
+ ...(Array.isArray(upstream) ? upstream.map((source) => idOf(source?.url, false)) : []),
428
+ ...(Array.isArray(pulled) ? pulled.filter((credit) => typeof credit?.version === "string").map((credit) => idOf(credit?.link, true)) : []),
429
+ ];
430
+ return [...new Set(ids.filter(Boolean))].slice(0, LIMITS.with);
431
+ }
432
+
405
433
  /** An object with its keys in order, at every level, as Lamp's own encoder writes its files. */
406
434
  function sortedKeys(value) {
407
435
  if (Array.isArray(value)) return value.map(sortedKeys);
@@ -857,18 +857,34 @@
857
857
  /** Whether this kind of AI can answer now, as `status` says. */
858
858
  isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
859
859
 
860
- /** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name,
861
- * available, reason }], default, reason, help, connect }. `default` is the id that answers when a call names no
862
- * `model`; the ids are what `ask` and `act` take as `model`, and one that can't answer when it's sent is
863
- * answered as no choice, never refused. With none to offer, `models` is empty and `reason` says why; on a page
864
- * shared on lamp.dev that hasn't reached this computer yet it asks nothing and answers `connect: true`, as
865
- * `status` does: ask again once `connect` has. Keep the person's choice with the app's saved things, and go back
866
- * to `default` when it's no longer listed as available.
860
+ /** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name, model,
861
+ * via: { id, name }, available, reason }], default, reason, help, connect }. `name` says the model with what
862
+ * answers it ("Claude Sonnet via Claude Code"); `model` is the model's own short name ("Claude Sonnet") and `via`
863
+ * what answers it ("claude-code" / "Claude Code", "codex" / "Codex", "openrouter" / "OpenRouter"), both left out
864
+ * by an older Lamp. They come with what answers when a call names no `model` first, then by what else answers,
865
+ * each with its most capable model first. Every id is the model through one harness: "engine:claude:opus",
866
+ * "openrouter:opus", "engine:codex"; a bare "opus" is Opus through whatever answers by default. `default` is the
867
+ * id that answers when a call names no `model`; the ids are what `ask` and `act` take as `model`, and one that
868
+ * can't answer when it's sent is answered as no choice, never refused. With none to offer, `models` is empty and
869
+ * `reason` says why; on a page shared on lamp.dev that hasn't reached this computer yet it asks nothing and
870
+ * answers `connect: true`, as `status` does: ask again once `connect` has. Keep the person's choice with the
871
+ * app's saved things, and go back to `default` when it's no longer listed as available.
867
872
  *
868
873
  * LampAI.models().then(function (m) {
869
874
  * var usable = m.models.filter(function (x) { return x.available; });
875
+ * var harnesses = usable.map(function (x) { return x.via ? x.via.name : ''; })
876
+ * .filter(function (v, i, all) { return all.indexOf(v) === i; });
870
877
  * picker.hidden = usable.length < 2;
871
- * picker.innerHTML = usable.map(function (x) { return '<option value="' + x.id + '">' + x.name + '</option>'; }).join('');
878
+ * picker.replaceChildren();
879
+ * usable.forEach(function (x) {
880
+ * var where = picker, label = x.via ? x.via.name : '';
881
+ * if (harnesses.length > 1) {
882
+ * where = [].filter.call(picker.children, function (g) { return g.label === label; })[0];
883
+ * if (!where) { where = document.createElement('optgroup'); where.label = label; picker.appendChild(where); }
884
+ * }
885
+ * where.appendChild(new Option(x.model || x.name, x.id));
886
+ * where.lastChild.title = x.name;
887
+ * });
872
888
  * picker.value = m.default;
873
889
  * });
874
890
  * LampAI.ask(question, { model: picker.value });
@@ -893,6 +909,9 @@
893
909
  return m && typeof m.id === 'string' && m.id;
894
910
  }).map(function (m) {
895
911
  var one = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id, available: m.available === true };
912
+ // What answers it, as its own fields: left out by a Lamp from before they were sent.
913
+ if (typeof m.model === 'string' && m.model) one.model = m.model;
914
+ if (m.via && typeof m.via.id === 'string' && m.via.id && typeof m.via.name === 'string' && m.via.name) one.via = { id: m.via.id, name: m.via.name };
896
915
  if (!one.available && typeof m.reason === 'string') one.reason = m.reason;
897
916
  return one;
898
917
  });
@@ -909,7 +928,8 @@
909
928
 
910
929
  /** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
911
930
  * system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
912
- * model (an id `LampAI.models()` lists, such as "sonnet", "opus", "haiku", "claude-fable-5-2"). */
931
+ * model (an id `LampAI.models()` lists, such as "engine:claude:opus" or "openrouter:sonnet"; a bare "opus" is
932
+ * Opus through whatever answers by default). */
913
933
  ask: function (prompt, options) { return call(prompt, options); },
914
934
 
915
935
  /**
package/cli/lamps/lamp.js CHANGED
@@ -857,18 +857,34 @@
857
857
  /** Whether this kind of AI can answer now, as `status` says. */
858
858
  isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
859
859
 
860
- /** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name,
861
- * available, reason }], default, reason, help, connect }. `default` is the id that answers when a call names no
862
- * `model`; the ids are what `ask` and `act` take as `model`, and one that can't answer when it's sent is
863
- * answered as no choice, never refused. With none to offer, `models` is empty and `reason` says why; on a page
864
- * shared on lamp.dev that hasn't reached this computer yet it asks nothing and answers `connect: true`, as
865
- * `status` does: ask again once `connect` has. Keep the person's choice with the app's saved things, and go back
866
- * to `default` when it's no longer listed as available.
860
+ /** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name, model,
861
+ * via: { id, name }, available, reason }], default, reason, help, connect }. `name` says the model with what
862
+ * answers it ("Claude Sonnet via Claude Code"); `model` is the model's own short name ("Claude Sonnet") and `via`
863
+ * what answers it ("claude-code" / "Claude Code", "codex" / "Codex", "openrouter" / "OpenRouter"), both left out
864
+ * by an older Lamp. They come with what answers when a call names no `model` first, then by what else answers,
865
+ * each with its most capable model first. Every id is the model through one harness: "engine:claude:opus",
866
+ * "openrouter:opus", "engine:codex"; a bare "opus" is Opus through whatever answers by default. `default` is the
867
+ * id that answers when a call names no `model`; the ids are what `ask` and `act` take as `model`, and one that
868
+ * can't answer when it's sent is answered as no choice, never refused. With none to offer, `models` is empty and
869
+ * `reason` says why; on a page shared on lamp.dev that hasn't reached this computer yet it asks nothing and
870
+ * answers `connect: true`, as `status` does: ask again once `connect` has. Keep the person's choice with the
871
+ * app's saved things, and go back to `default` when it's no longer listed as available.
867
872
  *
868
873
  * LampAI.models().then(function (m) {
869
874
  * var usable = m.models.filter(function (x) { return x.available; });
875
+ * var harnesses = usable.map(function (x) { return x.via ? x.via.name : ''; })
876
+ * .filter(function (v, i, all) { return all.indexOf(v) === i; });
870
877
  * picker.hidden = usable.length < 2;
871
- * picker.innerHTML = usable.map(function (x) { return '<option value="' + x.id + '">' + x.name + '</option>'; }).join('');
878
+ * picker.replaceChildren();
879
+ * usable.forEach(function (x) {
880
+ * var where = picker, label = x.via ? x.via.name : '';
881
+ * if (harnesses.length > 1) {
882
+ * where = [].filter.call(picker.children, function (g) { return g.label === label; })[0];
883
+ * if (!where) { where = document.createElement('optgroup'); where.label = label; picker.appendChild(where); }
884
+ * }
885
+ * where.appendChild(new Option(x.model || x.name, x.id));
886
+ * where.lastChild.title = x.name;
887
+ * });
872
888
  * picker.value = m.default;
873
889
  * });
874
890
  * LampAI.ask(question, { model: picker.value });
@@ -893,6 +909,9 @@
893
909
  return m && typeof m.id === 'string' && m.id;
894
910
  }).map(function (m) {
895
911
  var one = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id, available: m.available === true };
912
+ // What answers it, as its own fields: left out by a Lamp from before they were sent.
913
+ if (typeof m.model === 'string' && m.model) one.model = m.model;
914
+ if (m.via && typeof m.via.id === 'string' && m.via.id && typeof m.via.name === 'string' && m.via.name) one.via = { id: m.via.id, name: m.via.name };
896
915
  if (!one.available && typeof m.reason === 'string') one.reason = m.reason;
897
916
  return one;
898
917
  });
@@ -909,7 +928,8 @@
909
928
 
910
929
  /** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
911
930
  * system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
912
- * model (an id `LampAI.models()` lists, such as "sonnet", "opus", "haiku", "claude-fable-5-2"). */
931
+ * model (an id `LampAI.models()` lists, such as "engine:claude:opus" or "openrouter:sonnet"; a bare "opus" is
932
+ * Opus through whatever answers by default). */
913
933
  ask: function (prompt, options) { return call(prompt, options); },
914
934
 
915
935
  /**
@@ -1 +1 @@
1
- {"exe":"ZqxAppName","platform":"mac","name":"Zqx App Name","instance":"BF71B34B-7CF6-4426-A493-F71ADA568A80"}
1
+ {"name":"Zqx App Name","instance":"2C4D485D-3F9A-4E45-8A4A-59D2AD6F176E","exe":"ZqxAppName","platform":"mac"}
@@ -11224,8 +11224,9 @@
11224
11224
  }
11225
11225
 
11226
11226
  /// `data` is text from outside the app (an email, a web page, a file) for the AI to work on, not obey.
11227
- /// `model` overrides the app's text model for one call, by an id `LampAI.models()` lists ("sonnet", "opus", "haiku",
11228
- /// "claude-fable-5-2", …); one that can't answer now is answered as no choice, never refused.
11227
+ /// `model` overrides the app's text model for one call, by an id `LampAI.models()` lists ("engine:claude:opus",
11228
+ /// "openrouter:sonnet", …; a bare "opus" is Opus through whatever answers by default); one that can't answer
11229
+ /// now is answered as no choice, never refused.
11229
11230
  static func ask(_ prompt: String, data: String? = nil, images: [LampImage] = [],
11230
11231
  system: String? = nil, model: String? = nil) async throws -> String {
11231
11232
  try await call(prompt: prompt, data: data, images: images, system: system, json: false, model: model)
@@ -11688,10 +11689,20 @@
11688
11689
 
11689
11690
  // MARK: - Doing things for the person
11690
11691
 
11691
- /// A model an app may offer the person (`LampAI.models()`): `id` is what `ask(model:)` takes, `name` what to show.
11692
+ /// A model an app may offer the person (`LampAI.models()`): `id` is what `ask(model:)` takes, `name` the model with
11693
+ /// what answers it ("Claude Sonnet via Claude Code").
11692
11694
  struct LampAIModel: Identifiable, Equatable {
11695
+ /// What answers a model: `id` is "claude-code", "codex" or "openrouter", `name` what to show ("Claude Code").
11696
+ struct Via: Equatable {
11697
+ let id: String
11698
+ let name: String
11699
+ }
11693
11700
  let id: String
11694
11701
  let name: String
11702
+ /// The model's own short name ("Claude Sonnet"); nil from a Lamp from before it was sent, so show `name` then.
11703
+ var model: String? = nil
11704
+ /// What answers it; nil from a Lamp from before it was sent.
11705
+ var via: Via? = nil
11695
11706
  /// Whether it can answer now; one that can't isn't worth offering.
11696
11707
  let available: Bool
11697
11708
  /// Why it can't, when it can't.
@@ -11765,12 +11776,18 @@
11765
11776
  }
11766
11777
 
11767
11778
  /// The models the person's setup can answer this app with now, asked of no model, so it's cheap to call as a
11768
- /// view appears. The ids are what `ask(model:)` and `LampActSession(model:)` take. Keep the person's choice with
11769
- /// the app's saved things, and go back to `defaultID` when it's no longer listed as available.
11779
+ /// view appears. The ids are what `ask(model:)` and `LampActSession(model:)` take, each the model through one
11780
+ /// harness ("engine:claude:opus", "openrouter:opus", "engine:codex"; a bare "opus" is Opus through whatever
11781
+ /// answers by default). They come with what answers a call naming no model first, then by what else answers,
11782
+ /// each with its most capable model first. Show `model` (the model's own short name) in a picker, under `via`'s
11783
+ /// name when more than one harness is available, and `name` where `model` is nil (an older Lamp). Keep the
11784
+ /// person's choice with the app's saved things, and go back to `defaultID` when it's no longer listed as available.
11770
11785
  ///
11771
11786
  /// let choices = await LampAI.models()
11772
11787
  /// if choices.available.count > 1 {
11773
- /// Picker("AI", selection: $model) { ForEach(choices.available) { Text($0.name).tag(Optional($0.id)) } }
11788
+ /// Picker("AI", selection: $model) {
11789
+ /// ForEach(choices.available) { Text($0.model ?? $0.name).help($0.name).tag(Optional($0.id)) }
11790
+ /// }
11774
11791
  /// }
11775
11792
  /// // model starts as choices.defaultID; then: try await LampAI.ask(question, model: model)
11776
11793
  static func models() async -> LampAIModels {
@@ -11784,8 +11801,13 @@
11784
11801
  let models = (reply["models"] as? [[String: Any]] ?? []).compactMap { m -> LampAIModel? in
11785
11802
  guard let id = m["id"] as? String, !id.isEmpty else { return nil }
11786
11803
  let available = m["available"] as? Bool ?? false
11787
- return LampAIModel(id: id, name: (m["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? id, available: available,
11788
- reason: available ? nil : m["reason"] as? String)
11804
+ let via = (m["via"] as? [String: Any]).flatMap { v -> LampAIModel.Via? in
11805
+ guard let id = v["id"] as? String, !id.isEmpty, let name = v["name"] as? String, !name.isEmpty else { return nil }
11806
+ return LampAIModel.Via(id: id, name: name)
11807
+ }
11808
+ return LampAIModel(id: id, name: (m["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? id,
11809
+ model: (m["model"] as? String).flatMap { $0.isEmpty ? nil : $0 }, via: via,
11810
+ available: available, reason: available ? nil : m["reason"] as? String)
11789
11811
  }
11790
11812
  let fallback = reply["default"] as? String
11791
11813
  return LampAIModels(models: models, defaultID: models.contains { $0.id == fallback && $0.available } ? fallback : nil,
@@ -857,18 +857,34 @@
857
857
  /** Whether this kind of AI can answer now, as `status` says. */
858
858
  isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
859
859
 
860
- /** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name,
861
- * available, reason }], default, reason, help, connect }. `default` is the id that answers when a call names no
862
- * `model`; the ids are what `ask` and `act` take as `model`, and one that can't answer when it's sent is
863
- * answered as no choice, never refused. With none to offer, `models` is empty and `reason` says why; on a page
864
- * shared on lamp.dev that hasn't reached this computer yet it asks nothing and answers `connect: true`, as
865
- * `status` does: ask again once `connect` has. Keep the person's choice with the app's saved things, and go back
866
- * to `default` when it's no longer listed as available.
860
+ /** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name, model,
861
+ * via: { id, name }, available, reason }], default, reason, help, connect }. `name` says the model with what
862
+ * answers it ("Claude Sonnet via Claude Code"); `model` is the model's own short name ("Claude Sonnet") and `via`
863
+ * what answers it ("claude-code" / "Claude Code", "codex" / "Codex", "openrouter" / "OpenRouter"), both left out
864
+ * by an older Lamp. They come with what answers when a call names no `model` first, then by what else answers,
865
+ * each with its most capable model first. Every id is the model through one harness: "engine:claude:opus",
866
+ * "openrouter:opus", "engine:codex"; a bare "opus" is Opus through whatever answers by default. `default` is the
867
+ * id that answers when a call names no `model`; the ids are what `ask` and `act` take as `model`, and one that
868
+ * can't answer when it's sent is answered as no choice, never refused. With none to offer, `models` is empty and
869
+ * `reason` says why; on a page shared on lamp.dev that hasn't reached this computer yet it asks nothing and
870
+ * answers `connect: true`, as `status` does: ask again once `connect` has. Keep the person's choice with the
871
+ * app's saved things, and go back to `default` when it's no longer listed as available.
867
872
  *
868
873
  * LampAI.models().then(function (m) {
869
874
  * var usable = m.models.filter(function (x) { return x.available; });
875
+ * var harnesses = usable.map(function (x) { return x.via ? x.via.name : ''; })
876
+ * .filter(function (v, i, all) { return all.indexOf(v) === i; });
870
877
  * picker.hidden = usable.length < 2;
871
- * picker.innerHTML = usable.map(function (x) { return '<option value="' + x.id + '">' + x.name + '</option>'; }).join('');
878
+ * picker.replaceChildren();
879
+ * usable.forEach(function (x) {
880
+ * var where = picker, label = x.via ? x.via.name : '';
881
+ * if (harnesses.length > 1) {
882
+ * where = [].filter.call(picker.children, function (g) { return g.label === label; })[0];
883
+ * if (!where) { where = document.createElement('optgroup'); where.label = label; picker.appendChild(where); }
884
+ * }
885
+ * where.appendChild(new Option(x.model || x.name, x.id));
886
+ * where.lastChild.title = x.name;
887
+ * });
872
888
  * picker.value = m.default;
873
889
  * });
874
890
  * LampAI.ask(question, { model: picker.value });
@@ -893,6 +909,9 @@
893
909
  return m && typeof m.id === 'string' && m.id;
894
910
  }).map(function (m) {
895
911
  var one = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id, available: m.available === true };
912
+ // What answers it, as its own fields: left out by a Lamp from before they were sent.
913
+ if (typeof m.model === 'string' && m.model) one.model = m.model;
914
+ if (m.via && typeof m.via.id === 'string' && m.via.id && typeof m.via.name === 'string' && m.via.name) one.via = { id: m.via.id, name: m.via.name };
896
915
  if (!one.available && typeof m.reason === 'string') one.reason = m.reason;
897
916
  return one;
898
917
  });
@@ -909,7 +928,8 @@
909
928
 
910
929
  /** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
911
930
  * system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
912
- * model (an id `LampAI.models()` lists, such as "sonnet", "opus", "haiku", "claude-fable-5-2"). */
931
+ * model (an id `LampAI.models()` lists, such as "engine:claude:opus" or "openrouter:sonnet"; a bare "opus" is
932
+ * Opus through whatever answers by default). */
913
933
  ask: function (prompt, options) { return call(prompt, options); },
914
934
 
915
935
  /**
package/cli/share.mjs CHANGED
@@ -15,7 +15,7 @@ import { NOTES_FILE, Problem, stateOf } from "./state.mjs";
15
15
  import { ANY_BROWSER, CONTROLLERS, CONTROLLERS_FILE, DEVICE_FILE, lampJS, LIBRARIES } from "./dev.mjs";
16
16
  import { LIMITS, FILE_LIMITS, PAGE_LIMITS } from "../site/lib/shared-wishes.js";
17
17
  import { ideasOf, makerOf, pageUp, publishLink, removeFile, removeIdea, removeLink, replyIdea, sendFile, siteOrigin } from "./link.mjs";
18
- import { exportApp, sourceLinkOf } from "./lampapp.mjs";
18
+ import { exportApp, sourceLinkOf, takenLinksOf } from "./lampapp.mjs";
19
19
  import { scaled, sizeOf } from "./png.mjs";
20
20
  import { ignoredFor } from "./ignore.mjs";
21
21
 
@@ -244,6 +244,9 @@ export async function share({ layout, words, picture = null, shape = "fill", sit
244
244
  // The link this app came from, when it's a copy of one shared there: its page says so.
245
245
  const from = sourceLinkOf(layout.folder, site, earlier?.id);
246
246
  if (from) body.from = from;
247
+ // The copies on the site it took changes from: its page says "with changes from" them. None said keeps what it had.
248
+ const taken = takenLinksOf(layout.folder, site, earlier?.id);
249
+ if (taken.length) body.with = taken;
247
250
  // A file an earlier share sent stays on the record until it's taken away, so a retry after a failed removal tries again.
248
251
  // A link that's a Lamp's (`fromLamp`, the app in its own folder) stays so in every write, a share that fails partway too.
249
252
  let link = { site, ...(await publishLink({ site, body, earlier, account: accountFor(site)?.key })), ...words, shape, ...(earlier?.file ? { file: true } : {}),
@@ -224,7 +224,8 @@ Only when the person asks about copies or to bring something of one in:
224
224
  - `{{lamp}} compare <link>` fetches that copy into lamp's own place for this folder and says what differs from this
225
225
  app: files only in theirs, only in yours, and changed. It answers where the copy is: read it from there.
226
226
  - `{{lamp}} take <link> "the stadium"` compares, then says how to bring in what the person asked for (leave the words
227
- out only when they want everything new), and credits the copy in this app's credits.json for its next share. Bring
227
+ out only when they want everything new), and credits the copy in this app's credits.json, so the next share's page
228
+ says "with changes from" that copy and its maker hears of it. Bring
228
229
  in only that, written into this app's own files and fitted to it, never their whole app over this one; then look.
229
230
  When it's in, tell the person in a sentence what you took from whose copy. A copy shared on lamp.dev was shared for
230
231
  anyone to take from, so sharing it on never asks about its license; third-party work the copy itself credits still
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lampdotdev/lamp",
3
- "version": "0.36.2-nightly.31",
3
+ "version": "0.36.2-nightly.35",
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": {
@@ -29,6 +29,8 @@ export const LIMITS = {
29
29
  releases: 30,
30
30
  releaseLines: 5,
31
31
  releaseLine: 120,
32
+ /** The other people's copies on the site the app took changes from (`with`), by their ids. */
33
+ with: 20,
32
34
  picture: 1_500_000,
33
35
  icon: 300_000,
34
36
  /** Pixels on a side, for both pictures. */
@@ -137,11 +139,12 @@ function release(value) {
137
139
  }
138
140
 
139
141
  /** What a share sends, checked. Null when anything is off. `releases` is undefined when the share didn't say (a Lamp
140
- * from before them), so a change keeps the ones the link has; `from` too, the id of the link the app is a copy of. */
142
+ * from before them), so a change keeps the ones the link has; `from` too, the id of the link the app is a copy of, and
143
+ * `with`, the ids of the links whose copies it took changes from (`lamp take`, Lamp's `shared_copy`). */
141
144
  const GITHUB_REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/;
142
145
  export function readShare(body) {
143
146
  if (!body || typeof body !== "object" || Array.isArray(body)) return null;
144
- const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon", "github", "from"];
147
+ const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon", "github", "from", "with"];
145
148
  if (Object.keys(body).some((key) => !known.includes(key))) return null;
146
149
  const name = tidy(body.name, LIMITS.name);
147
150
  const words = tidy(body.words, LIMITS.words, { lines: true });
@@ -160,9 +163,10 @@ export function readShare(body) {
160
163
  // The app's public repository on GitHub, as "owner/name", which the page links to: nothing else is taken.
161
164
  const github = body.github == null || body.github === "" ? null : GITHUB_REPO.test(body.github) ? body.github : undefined;
162
165
  const from = body.from == null ? undefined : isID(body.from) ? body.from : null;
163
- if (!name || !words || line == null || by == null || ideas.includes(null) || asks.includes(null) || shape === undefined || releases?.includes(null) || !shot || !icon || github === undefined || from === null) return null;
166
+ const taken = body.with == null ? undefined : Array.isArray(body.with) && body.with.length <= LIMITS.with && body.with.every(isID) ? [...new Set(body.with)] : null;
167
+ if (!name || !words || line == null || by == null || ideas.includes(null) || asks.includes(null) || shape === undefined || releases?.includes(null) || !shot || !icon || github === undefined || from === null || taken === null) return null;
164
168
  return { name, by, line, words, ideas, asks, shape, releases: releases?.sort((a, b) => b.date.localeCompare(a.date)),
165
- picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null, github, from };
169
+ picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null, github, from, with: taken };
166
170
  }
167
171
 
168
172
  /** What anyone may see of a shared wish: never its secret. */
@@ -745,6 +749,33 @@ async function copied(kept, from, copy, origin, { env, fetcher, first }) {
745
749
  } catch {}
746
750
  }
747
751
 
752
+ /**
753
+ * A link started naming copies it took changes from (`with`): each copy's maker told by email, once (the send key), at
754
+ * the address that confirmed their account (or the copy's own maker address), as `copied` tells a source's. Never fails
755
+ * the share.
756
+ */
757
+ async function tookFrom(kept, ids, link, origin, { env, fetcher }) {
758
+ const one = async (id) => {
759
+ try {
760
+ const copy = await kept.get(id);
761
+ if (!copy) return;
762
+ const account = copy.owner ? await kept.account(copy.owner) : null;
763
+ const maker = account ? null : await kept.maker(id);
764
+ const to = account?.email ?? (maker?.confirmed ? maker.email : null);
765
+ if (!to) return;
766
+ const who = link.owner || link.by || "Someone";
767
+ await sendMail(env, fetcher, { to: [to], subject: `${who} took changes from your ${copy.name}`,
768
+ text: `${who} took changes from your ${copy.name} into ${link.name}:\n\n${linkOf(origin, link)}\n` }, `with-${link.id}-${id}`);
769
+ } catch {}
770
+ };
771
+ // All at once, and the share waits for them a few seconds at most: it has to answer well within its function's time,
772
+ // with the link's secret, whatever the mail service does. A send cut short here is only an email not sent.
773
+ await Promise.race([Promise.all(ids.map(one)), new Promise((done) => setTimeout(done, NOTIFY_MS))]);
774
+ }
775
+
776
+ /** The longest a share waits on the emails it sends its copies' makers, well within the function's time. */
777
+ const NOTIFY_MS = 6000;
778
+
748
779
  /** The copies of a link as its page lists them, newest first; one taken down since is let go. */
749
780
  export async function copiesOf(kept, id, origin) {
750
781
  const found = [];
@@ -834,8 +865,12 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
834
865
  // The link the app is a copy of, kept only while there's such a link and it isn't this one: a share that doesn't
835
866
  // say, or names one that's gone, keeps what the record had.
836
867
  const from = share.from && share.from !== wishID && (await kept.get(share.from)) ? share.from : null;
868
+ // The copies it took changes from, kept only while each is there and isn't this link: a share that doesn't say
869
+ // keeps what the record had, and one that says none clears it.
870
+ const taken = share.with && (await kept.getMany(share.with.filter((one) => one !== wishID)))
871
+ .flatMap((record) => (record ? [record.id] : []));
837
872
  const words = { name: share.name, by: share.by, line: share.line, words: share.words, ideas: share.ideas, asks: share.asks, shape: share.shape, github: share.github,
838
- ...(share.releases ? { releases: share.releases } : {}), ...(from ? { from } : {}), picture: picture || null, icon: icon || null };
873
+ ...(share.releases ? { releases: share.releases } : {}), ...(from ? { from } : {}), ...(taken ? { with: taken } : {}), picture: picture || null, icon: icon || null };
839
874
  // Its name under the account stays what it was first given, whatever the app is called later.
840
875
  const named = earlier?.owner ? { owner: earlier.owner, app: earlier.app }
841
876
  : owner ? { owner, app: await claimAppName(kept, owner, words.name, wishID) } : {};
@@ -845,6 +880,7 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
845
880
  const record = { ...earlier, ...words, ...named, updated: time };
846
881
  await kept.replace(record);
847
882
  if (record.from) await copied(kept, record.from, record, url.origin, { env, fetcher, first: !!from && earlier?.from !== from });
883
+ if (taken) await tookFrom(kept, taken.filter((one) => !(earlier?.with ?? []).includes(one)), record, url.origin, { env, fetcher });
848
884
  // Kept: the name is the record's now, whatever the cleanup after it does.
849
885
  uploaded = [];
850
886
  claimed = null;
@@ -860,6 +896,7 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
860
896
  uploaded = [];
861
897
  claimed = null;
862
898
  if (from) await copied(kept, from, record, url.origin, { env, fetcher, first: true });
899
+ if (taken) await tookFrom(kept, taken, record, url.origin, { env, fetcher });
863
900
  return reply(201, { id: wishID, url: linkOf(url.origin, record), secret });
864
901
  } catch {
865
902
  try { await kept.removeImages(uploaded); } catch {}
@@ -1365,6 +1402,14 @@ export async function findWish(id, { env = process.env, fetcher = fetch, store,
1365
1402
  if (source) wish.from = { url: linkOf(origin, source), label: source.owner && source.app ? `${source.owner}/${source.app}` : source.name };
1366
1403
  } catch {}
1367
1404
  }
1405
+ // The copies it took changes from, as each is known now, `with` on its page, in one round trip; one taken down since,
1406
+ // or the source already named as `from`, is left out.
1407
+ if (wish && origin && Array.isArray(record.with) && record.with.length) {
1408
+ try {
1409
+ const records = await kept.getMany(record.with.filter((one) => isID(one) && one !== id && one !== record.from));
1410
+ wish.with = records.filter(Boolean).map((copy) => ({ url: linkOf(origin, copy), label: copy.owner && copy.app ? `${copy.owner}/${copy.app}` : copy.name }));
1411
+ } catch {}
1412
+ }
1368
1413
  // How many copies of it people shared, for the page's "copies" (the list itself is asked for when opened).
1369
1414
  if (wish && origin) { try { wish.copies = await kept.copyCount(id); } catch {} }
1370
1415
  return wish;