@lampdotdev/lamp 0.36.2-nightly.32 → 0.36.2-nightly.36

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. 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. |
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. |
@@ -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";
@@ -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
- {"instance":"AF4C7F06-9ECB-4B36-9D41-BD860268DDF1","platform":"mac","name":"Zqx App Name","exe":"ZqxAppName"}
1
+ {"platform":"mac","instance":"5BDE295C-EF64-496B-BF2A-EAA787B28BB6","name":"Zqx App Name","exe":"ZqxAppName"}
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lampdotdev/lamp",
3
- "version": "0.36.2-nightly.32",
3
+ "version": "0.36.2-nightly.36",
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": {