@lampdotdev/lamp 0.36.2-nightly.26 → 0.36.2-nightly.28

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
@@ -18,7 +18,7 @@ Mac app, `projectOf`), or any folder with an `index.html`; without one, the fold
18
18
  | `lamp new <folder> [--name "Its Name"] [--web \| --mac]` | Makes a new web game's folder from Lamp's starting files, or with `--web` a new web app's, or with `--mac` a new Mac app in Swift. |
19
19
  | `lamp init [--update]` | Puts Lamp's game runtime beside a page that's already there, changing none of the page's own files. `--update` writes again the Lamp files the folder has (`lamp.js` too), adding the game's only to a page that's a game already. |
20
20
  | `lamp unpack <file.lampapp> [folder]` | Unpacks a Lamp app file into a new folder beside it, named for it. A file carries none of Lamp's own files: a page gets them beside it as it's unpacked (the runtime for a game, lamp.js, the libraries its import map names), and a Mac app gets Package.swift, build.sh and LampSupport.swift from Lamp's starting files as it's first built (`writeMacStarter`), where it has none. One with a widget, a share extension or a 3D scene is left to Lamp's app, which writes the files they're built with. |
21
- | `lamp export [--to file] [--anyway]` | Makes the app's Lamp app file as Lamp's Share… does (`Sharing.export`, followed by `cli/lampapp.mjs`): its own files as its kind travels, Assets/, APP.md, what it declares, its credits with the app added to the chain, the licenses they name, its icon's design and lamp-app.json, never Lamp's own files, nor what the folder's `.lampignore` names. Refused when a license its credits name is gone or something in it is shaped like a key; `--anyway` sends work its credits give no license for. The shapes of keys, the names a license goes by and the sizes another Lamp opens are in `Resources/share-rules.json`, which Lamp's app reads too (`ShareRules`). |
21
+ | `lamp export [--to file] [--anyway]` | Makes the app's Lamp app file as Lamp's Share… does (`Sharing.export`, followed by `cli/lampapp.mjs`): its own files as its kind travels, Assets/, APP.md, what it declares, its credits with the app added to the chain, the licenses they name, its icon's design and lamp-app.json, never Lamp's own files, nor what the folder's `.lampignore` names. Refused when a license its credits name is gone or something in it is shaped like a key; `--anyway` sends work its credits give no license for, and lamp keeps that the person agreed (in its state), so it asks again only for work new to the app. A source credited `"live": true` (fetched while the app runs, nothing of it in the app) is never asked about. The shapes of keys, the names a license goes by and the sizes another Lamp opens are in `Resources/share-rules.json`, which Lamp's app reads too (`ShareRules`). |
22
22
  | `lamp dev [--open] [--lan] [--port N]` | Runs the page and says how each change went. Each save goes into every open copy of it, with the card Lamp's app puts on an app taking a change (`page.js`, left out of lamp's pictures; `lamp changing "<words>"` says on it what's changing). `--open` opens a window for it; `--lan` lets other devices on the network open it. |
23
23
  | `lamp open` | Shows the page in a window, running it in the background if it isn't running, and returns at once. |
24
24
  | `lamp open --in-lamp` | Gives a Mac app to Lamp's app (`cli/outside.mjs`), over the socket the person's own coding agent uses (`OutsideServer`, `OutsideWork`): `open_project` takes the folder on, with Lamp's review the first time, and from then on `look`, `reload` and `dev` call `apply_change`, so a change goes into the open app as Lamp's app puts it there (`LiveReload`) and its outcome is said: live, held, reopened, not_built, crashed. `use` calls `use_app`, in plain sight in the app Lamp's app has open, and `stop` calls `close_app`. A folder Lamp's app no longer has (its refusal says `taken_on: false`) is lamp's own again, said once. `lamp open --here` has lamp run its own copy again. `LAMP_APP` names another Lamp by its bundle id (a Lamp Dev instance). A Mac app `lamp new --mac` makes has the `lamp-app.json` Lamp's app takes a folder on by. |
@@ -100,7 +100,7 @@ import UIKit
100
100
  /// The page's way to Lamp: only the routes LampAI uses, and where phones join a game (`Lamp.controllers`), with the
101
101
  /// app's own token.
102
102
  final class LampPageGateway: NSObject, WKScriptMessageHandlerWithReply {
103
- private static let routes: Set<String> = ["/ai", "/ai/status", "/ai/image", "/ai/decide", "/ai/act", "/ai/hear", "/app/controller"]
103
+ private static let routes: Set<String> = ["/ai", "/ai/status", "/ai/models", "/ai/image", "/ai/decide", "/ai/act", "/ai/hear", "/app/controller"]
104
104
  private let scheme: String
105
105
  init(scheme: String) { self.scheme = scheme }
106
106
 
@@ -69,7 +69,11 @@ create or edit pictures, use `LampAI.makeImage(...)`, `LampAI.editImage(...)` or
69
69
  things up and writes that change them, each a few lines over the app's data and its ordinary commands. The AI proposes
70
70
  calls, Lamp checks their shapes, the app's `check` says whether that exact change is allowed now, and its `run` makes it
71
71
  and returns a receipt. Lamp keeps the loop, its limits (4 AI steps between questions, 6 changes a request, 3 questions
72
- back, the route's time), cancellation, questions back and the receipts.
72
+ back, the route's time), cancellation, questions back and the receipts. An app that builds something real in one
73
+ request raises `steps:` (up to 40) and `writes:` (up to 320) on its session. Where the person might want another AI for
74
+ the app's asking, `await LampAI.models()` lists what their setup can answer it with: a small Picker beside the Ask
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.
73
77
 
74
78
  ```swift
75
79
  struct Rename: Decodable { let id: String; let name: String }
@@ -71,11 +71,18 @@ So every feature that leans on Lamp keeps the app usable without it, going by wh
71
71
  for everything AI would do, example content where the person's own things would be, and a short line where something
72
72
  needs Lamp (for AI, `LampAI.status`'s `reason`), never an error or an empty screen.
73
73
 
74
+ Credit a live source the app fetches as it runs (a transit feed, a map service, an open-data API) in credits.json's
75
+ `upstream` with `"live": true`: nothing of it is in the app, so sharing passes none of it on, and it isn't asked about
76
+ as work with no license. Work copied into the app (code, models, pictures, a data file saved from a feed) is never
77
+ `live`, and its license is asked about before it's shared.
78
+
74
79
  ## Doing things for the person
75
80
 
76
81
  `LampAI.act(request, { about, context, tools, history, onStep })` runs a short loop: the AI picks calls among the
77
82
  app's tools, the page runs them, the AI sees the results, until it replies (at most 4 AI calls and 6 writes a
78
- turn, and no time limit unless the app sets one with `limits: { seconds }`, for a feature that should answer quickly; each read gives the AI up to 32 KB of its result, and an app whose state is bigger raises it with `limits: { result }`, up to 256 KB, rather than having the AI page through it). It works with
83
+ turn, and no time limit unless the app sets one with `limits: { seconds }`, for a feature that should answer quickly; each read gives the AI up to 32 KB of its result, and an app whose state is bigger raises it with `limits: { result }`, up to 256 KB, rather than having the AI page through it). An app that builds something real in one request (a model of many pieces, a week's plan) raises
84
+ `limits: { steps, writes }`, up to 40 steps and 320 writes; a tool that takes a list of items makes each write go
85
+ further. It works with
79
86
  whichever AI the person set up for apps' text, so don't tie it to voice: typing works wherever `act` does. Its comment in lamp.js has the shapes. This is the one recipe for it:
80
87
 
81
88
  - Show the feature where `LampAI.status('act')` says it's available, and keep the app's own buttons for everything:
@@ -99,6 +106,11 @@ whichever AI the person set up for apps' text, so don't tie it to voice: typing
99
106
  is hidden.
100
107
  - `about` says what the app is and its rules for asking back: which person, which day and time, which of two things
101
108
  that both fit.
109
+ - Where the person might want another AI for the app's asking, `LampAI.models()` lists what their setup can answer it
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,
112
+ and go back to `default` when it's no longer listed. On a page shared on lamp.dev it answers `connect: true` until
113
+ the person connects, as `status` does.
102
114
 
103
115
  Saying it instead of typing it: where the app around the page has a microphone (`LampVoice.status()`, which never
104
116
  prompts: the Mac, iPhone and iPad, Android and Android TV, a TV remote's microphone included) and Lamp can transcribe
package/cli/ai.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // The AI a page asks for with LampAI (lamp.js), answered by lamp itself for the pages it serves, with the person's own
2
2
  // Claude Code or Codex on this Mac: their subscription, through the program they signed in to, never a key in the page.
3
- // The same routes and answers Lamp's app gives (`/ai/status`, `/ai`, `/ai/act`, `/ai/decide`), told and checked by the
3
+ // The same routes and answers Lamp's app gives (`/ai/status`, `/ai/models`, `/ai`, `/ai/act`, `/ai/decide`), told and checked by the
4
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
 
@@ -163,13 +163,63 @@ export function status() {
163
163
  };
164
164
  }
165
165
 
166
+ // MARK: Models (`LampAI.models`, and `model` on `ask` and `act`)
167
+
168
+ /** The models a page may name here, by Lamp's ids for them (`AppAI.anthropicModels` in Lamp's app, the same strings), with
169
+ * what OpenRouter calls each. Claude Code takes the ids as they are. */
170
+ const APP_MODELS = [
171
+ { id: "claude-fable-5-2", name: "Claude Fable", openRouter: "anthropic/claude-fable-5.2" },
172
+ { id: "opus", name: "Claude Opus", openRouter: "anthropic/claude-opus-5.5" },
173
+ { id: "sonnet", name: "Claude Sonnet", openRouter: DEFAULT_MODEL },
174
+ { id: "haiku", name: "Claude Haiku", openRouter: "anthropic/claude-haiku-5.5" },
175
+ ];
176
+ /** 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
+ const CODEX = "engine:codex";
178
+
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:/, ""));
181
+
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). */
184
+ function defaultId(which) {
185
+ if (which === "codex") return CODEX;
186
+ const wanted = process.env.LAMP_AI_MODEL || "";
187
+ return (APP_MODELS.find((m) => m.id === wanted || m.openRouter === wanted) ?? listed("sonnet")).id;
188
+ }
189
+
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. */
192
+ export function modelFor(which, asked) {
193
+ const model = listed(asked);
194
+ if (which === "codex") return process.env.LAMP_AI_MODEL || "";
195
+ if (model) return which === "openrouter" ? model.openRouter : model.id;
196
+ if (process.env.LAMP_AI_MODEL) return process.env.LAMP_AI_MODEL;
197
+ return which === "openrouter" ? DEFAULT_MODEL : "sonnet";
198
+ }
199
+
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 };
211
+ }
212
+ return { models: listedHere, default: defaultId(which) };
213
+ }
214
+
166
215
  /** `/ai`: a text answer, or JSON when asked for it. */
167
216
  export async function ask(body) {
168
217
  const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : "";
169
218
  if (!prompt) return [400, { error: "Missing prompt." }];
170
219
  let system = typeof body.system === "string" ? body.system : "";
171
220
  if (body.json) system += (system ? "\n\n" : "") + "Answer with JSON only, nothing around it.";
172
- try { return [200, { text: await complete({ system, prompt, images: body.images }) }]; }
221
+ const which = engine();
222
+ try { return [200, { text: await complete({ system, prompt, images: body.images, which, model: modelFor(which, body.model) }) }]; }
173
223
  catch (error) { return [502, { error: error.message }]; }
174
224
  }
175
225
 
@@ -208,11 +258,11 @@ export async function act(body) {
208
258
  let read;
209
259
  try {
210
260
  if (which === "openrouter") {
211
- const body = chatBody(r, tag, retry, process.env.LAMP_AI_MODEL || DEFAULT_MODEL);
261
+ const body = chatBody(r, tag, retry, modelFor(which, r.model));
212
262
  read = readMessage((await openRouterChat({ key: process.env.OPENROUTER_API_KEY, body, seconds: left })).message, r);
213
263
  } else {
214
264
  const { system, prompt } = planPrompt(r, tag, retry);
215
- read = readPlan(await complete({ system, prompt, schema: planSchema(r), seconds: left, which }), r);
265
+ read = readPlan(await complete({ system, prompt, schema: planSchema(r), seconds: left, which, model: modelFor(which, r.model) }), r);
216
266
  }
217
267
  } catch (error) {
218
268
  const words = error?.reply?.error?.message || error.message;
@@ -228,6 +278,7 @@ export async function act(body) {
228
278
  /** A page's AI request, by its route, as Lamp's app answers it: [status, JSON]. */
229
279
  export async function answer(path, body) {
230
280
  if (path === "/ai/status") return [200, status()];
281
+ if (path === "/ai/models") return [200, models()];
231
282
  if (path === "/ai") return ask(body);
232
283
  if (path === "/ai/act") return act(body);
233
284
  if (path === "/ai/decide") return decide(body);
package/cli/app-ai.mjs CHANGED
@@ -7,14 +7,17 @@
7
7
  // the whole turn so far (the request, the tools, every earlier call and its result) and gets back either the calls to
8
8
  // make next or the reply. Nothing is kept between steps. What a step can hold on its own is checked here: the tools'
9
9
  // shapes, the calls the model hands back (checked against them, at most `MAX_CALLS`), the call ids, and that a turn
10
- // with `MAX_STEPS - 1` steps behind it is asked for a reply only. Limits across the turn (writes, time, cancel) are the
11
- // page's.
10
+ // with `MAX_STEPS - 1` steps behind it is asked for a reply only. A turn's own step limit is the page's (4 unless the app
11
+ // raises it, up to `MAX_STEPS`), which it keeps by sending `final` on its last step; so are its writes, time and cancel.
12
12
  //
13
13
  // Every route is asked one of two ways and read into the same calls-or-reply: a chat with OpenAI-style tools
14
14
  // (OpenRouter: `chatBody`, `readMessage`), or one prompt whose answer must fit the step's JSON shape (Claude Code, Codex:
15
15
  // `planPrompt`, `planSchema`, `readPlan`).
16
16
 
17
- export const MAX_TOOLS = 24, MAX_CALLS = 8, MAX_STEPS = 4, MAX_BODY = 2 * 1024 * 1024, MAX_CHOICES = 4, MAX_CHOICE = 24;
17
+ // `MAX_STEPS` is the most steps an app may raise a turn to (`ACT_LIMITS_MAX` in lamp.js, `LampActSession.mostSteps`):
18
+ // room to build something real a few calls at a time, while a turn that goes round in circles still ends. The most
19
+ // writes follows from it, since each step holds at most `MAX_CALLS`.
20
+ export const MAX_TOOLS = 24, MAX_CALLS = 8, MAX_STEPS = 40, MAX_BODY = 2 * 1024 * 1024, MAX_CHOICES = 4, MAX_CHOICE = 24;
18
21
  /** The longest a step may take, whatever its route, before its call is taken to have stopped answering (`ACT_STEP_SECONDS`
19
22
  * in lamp.js, `AppAct.turnSeconds`). A turn has no time of its own unless the app gives it one. */
20
23
  export const MAX_TURN_SECONDS = 600;
@@ -63,7 +66,7 @@ function parseArg(raw, path) {
63
66
 
64
67
  /**
65
68
  * A step as the page sent it (its JSON text), checked and put in order: `{request, about, now, context?, tools, history,
66
- * steps, route, seconds, final}`, each tool's `args` as [name, shape] pairs by name. Throws `Refused`.
69
+ * steps, route, seconds, final, model?}`, each tool's `args` as [name, shape] pairs by name. Throws `Refused`.
67
70
  */
68
71
  export function readStep(text) {
69
72
  if (Buffer.byteLength(text) > MAX_BODY) throw new Refused(413, "A step can carry at most 2 MB.");
@@ -112,6 +115,8 @@ export function readStep(text) {
112
115
  tools, history, steps, route: typeof body.route === "string" ? body.route : null, seconds: Math.max(1, Math.min(seconds, MAX_TURN_SECONDS)),
113
116
  final: body.final === true || rawSteps.length >= MAX_STEPS - 1 };
114
117
  if ("context" in body) step.context = body.context;
118
+ // The model the app asked for (an id `LampAI.models` lists): the answering side takes it when it can, else its default.
119
+ if (typeof body.model === "string" && body.model) step.model = body.model;
115
120
  return step;
116
121
  }
117
122
 
package/cli/lampapp.mjs CHANGED
@@ -232,9 +232,22 @@ export function exportApp(given, { anyway = false, link = null } = {}) {
232
232
  const gone = terms(source).find((path) => !existsSync(join(app, path)));
233
233
  if (gone) throw new Problem(`${name} can't be shared: the license of ${source.name} is missing from it. Put ${gone} back.`);
234
234
  }
235
- const unlicensed = upstream.filter((source) => noLicense(source.license));
236
- if (unlicensed.length && !anyway) {
237
- throw new Problem(`${name} uses work that came with no license (${unlicensed.map((source) => source.author ? `${source.name} by ${source.author}` : source.name).join(", ")}). Ask the person whether they're comfortable sharing it; --anyway shares it all the same.`);
235
+ // Work in the app that came with no license: the person is asked once, as Lamp's Share Link asks (`ShareCheck`), and
236
+ // again only when the app takes in more. What it only fetches as it runs (`live`) isn't passed on, so it isn't asked of.
237
+ const unlicensed = upstream.filter((source) => noLicense(source.license) && source.live !== true);
238
+ const named = (source) => (source.author ? `${source.name} by ${source.author}` : source.name);
239
+ // Agreed to by the work itself, as Lamp keeps it (`ShareCheck.fingerprint`): its name, address and commit, so another
240
+ // work under the same name is asked about again.
241
+ const work = (source) => [source.name, source.url ?? "", source.commit ?? ""].join("\u001f");
242
+ const agreedFile = join(stateOf(folder), "unlicensed-agreed.json");
243
+ let agreed = [];
244
+ try { agreed = JSON.parse(readFileSync(agreedFile, "utf8")); } catch {}
245
+ const asking = unlicensed.filter((source) => !agreed.includes(work(source)));
246
+ if (asking.length && !anyway) {
247
+ throw new Problem(`${name} uses work that came with no license (${asking.map(named).join(", ")}). Ask the person whether they're comfortable sharing it; --anyway shares it, and it isn't asked again for these.`);
248
+ }
249
+ if (anyway && asking.length) {
250
+ try { mkdirSync(stateOf(folder), { recursive: true }); writeFileSync(agreedFile, JSON.stringify([...new Set([...agreed, ...unlicensed.map(work)])].sort(), null, 2)); } catch {}
238
251
  }
239
252
 
240
253
  const files = new Map(), from = (path) => readFileSync(join(app, path));
@@ -11,7 +11,7 @@ import org.json.JSONObject
11
11
  import kotlin.concurrent.thread
12
12
 
13
13
  object LampPageGateway {
14
- private val routes = setOf("/ai", "/ai/status", "/ai/image", "/ai/decide", "/ai/act", "/ai/hear")
14
+ private val routes = setOf("/ai", "/ai/status", "/ai/models", "/ai/image", "/ai/decide", "/ai/act", "/ai/hear")
15
15
 
16
16
  /** Answers the page at `origin`, the page's own, in its main frame: no other page or frame reaches Lamp. */
17
17
  fun attach(web: WebView, origin: String) {
@@ -285,7 +285,7 @@
285
285
  });
286
286
  }
287
287
  var kept = read('nearby.token', null);
288
- var done = path === '/ai/status' || (body && body.probe === true) ? go(kept) : (kept ? Promise.resolve(kept) : allow()).then(function (t) {
288
+ var done = path === '/ai/status' || path === '/ai/models' || (body && body.probe === true) ? go(kept) : (kept ? Promise.resolve(kept) : allow()).then(function (t) {
289
289
  return go(t).then(function (answer) {
290
290
  if (answer.status !== 401) return answer;
291
291
  // Taken back on the computer: ask once more.
@@ -519,8 +519,12 @@
519
519
 
520
520
  // A turn has no time limit of its own: a real answer is never cut off, however long the model takes. Each step is
521
521
  // given ACT_STEP_SECONDS, which only a call that has stopped answering reaches. An app that wants a quicker turn
522
- // asks for one (`limits: { seconds }`); steps and writes it may only lower.
522
+ // asks for one (`limits: { seconds }`). Steps and writes are few unless the app says otherwise, so a request that
523
+ // goes wrong ends soon and changes little; an app that builds something real raises them (`limits: { steps,
524
+ // writes }`) up to ACT_LIMITS_MAX: 40 steps, as many as Lamp takes in one turn (`MAX_STEPS` in lamp's app-ai.mjs),
525
+ // and as many writes as 40 steps of 8 calls can hold.
523
526
  var ACT_LIMITS = { steps: 4, writes: 6 };
527
+ var ACT_LIMITS_MAX = { steps: 40, writes: 320 };
524
528
  // How much of one read goes back to the model: room for a real app's state (a 184-piece model, a week's calendar)
525
529
  // in one step, since paging through it costs steps a turn needs for its work. An app with more raises it
526
530
  // (`limits: { result }`), up to ACT_RESULT_MAX.
@@ -565,8 +569,8 @@
565
569
  var o = options || {}, tools = o.tools || {};
566
570
  var limits = {};
567
571
  ['steps', 'writes'].forEach(function (k) {
568
- var asked = o.limits && typeof o.limits[k] === 'number' ? o.limits[k] : ACT_LIMITS[k];
569
- limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS[k]));
572
+ var asked = o.limits && typeof o.limits[k] === 'number' && o.limits[k] > 0 ? Math.floor(o.limits[k]) : ACT_LIMITS[k];
573
+ limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS_MAX[k]));
570
574
  });
571
575
  var asked = o.limits && typeof o.limits.seconds === 'number' && o.limits.seconds > 0 ? o.limits.seconds : null;
572
576
  var resultMax = o.limits && typeof o.limits.result === 'number' && o.limits.result > 0
@@ -651,7 +655,8 @@
651
655
  if (bad) return fail(bad);
652
656
  var key = 'k' + hash(id + '|' + c.name + '|' + canonical(c.args));
653
657
  // The same change asked again in this turn gets its first result, and doesn't run again.
654
- if (ran.hasOwnProperty(key)) { results[c.id] = { id: c.id, ok: true, repeat: true, value: ran[key] }; return next(i + 1); }
658
+ // Its result is in an earlier step, which every request carries: pointed to, not sent again.
659
+ if (ran.hasOwnProperty(key)) { results[c.id] = { id: c.id, ok: true, repeat: true, value: { note: 'Already done in this request: its result is in an earlier step.' } }; return next(i + 1); }
655
660
  if (writes >= limits.writes) return fail('Over this turn\'s limit of ' + limits.writes + ' changes.');
656
661
  var call = { turn: id, key: key, step: step, id: c.id };
657
662
  return Promise.resolve().then(function () { return tool.check ? tool.check(c.args, call) : null; }).then(function (problem) {
@@ -788,6 +793,11 @@
788
793
  * `connect: true`: show a small "Connect AI" button beside its reason that calls `LampAI.connect(capability)` with
789
794
  * the same kind of AI (`connect('pictures')` for pictures), and use what that gives as `status`'s answer. Never
790
795
  * call `connect()` as the page loads.
796
+ *
797
+ * Which AI answers is the person's choice in Lamp. An app may offer them another for its own asking: `LampAI.models()`
798
+ * lists what their setup can answer it with now. Show a small picker beside the app's Ask button with only the
799
+ * available ones and `default` chosen, and send the chosen id as `model` to `ask` and `act`. Leave the picker out
800
+ * when there's one or none to choose from.
791
801
  */
792
802
  var LampAI = {
793
803
  Failure: Failure,
@@ -841,15 +851,70 @@
841
851
  /** Whether this kind of AI can answer now, as `status` says. */
842
852
  isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
843
853
 
854
+ /** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name,
855
+ * available, reason }], default, reason, help, connect }. `default` is the id that answers when a call names no
856
+ * `model`; the ids are what `ask` and `act` take as `model`, and one that can't answer when it's sent is
857
+ * answered as no choice, never refused. With none to offer, `models` is empty and `reason` says why; on a page
858
+ * shared on lamp.dev that hasn't reached this computer yet it asks nothing and answers `connect: true`, as
859
+ * `status` does: ask again once `connect` has. Keep the person's choice with the app's saved things, and go back
860
+ * to `default` when it's no longer listed as available.
861
+ *
862
+ * LampAI.models().then(function (m) {
863
+ * var usable = m.models.filter(function (x) { return x.available; });
864
+ * picker.hidden = usable.length < 2;
865
+ * picker.innerHTML = usable.map(function (x) { return '<option value="' + x.id + '">' + x.name + '</option>'; }).join('');
866
+ * picker.value = m.default;
867
+ * });
868
+ * LampAI.ask(question, { model: picker.value });
869
+ */
870
+ models: function () {
871
+ var none = function (reason, help) {
872
+ var said = { models: [], default: null, reason: reason || 'AI isn\'t set up for this here.' };
873
+ if (help) said.help = help;
874
+ return said;
875
+ };
876
+ if (!bridge && shared()) {
877
+ return nearbyAllowed().then(function (allowed) {
878
+ if (allowed) return listed();
879
+ if (safari()) return none(SAFARI_AI, AI_HELP);
880
+ return { models: [], default: null, connect: true, reason: 'Connect to the AI on this computer to use this.', help: AI_HELP };
881
+ });
882
+ }
883
+ return listed();
884
+ function listed() {
885
+ return post('/ai/models', {}, 10000).then(function (reply) {
886
+ var models = (isList(reply.models) ? reply.models : []).filter(function (m) {
887
+ return m && typeof m.id === 'string' && m.id;
888
+ }).map(function (m) {
889
+ var one = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id, available: m.available === true };
890
+ if (!one.available && typeof m.reason === 'string') one.reason = m.reason;
891
+ return one;
892
+ });
893
+ var fallback = models.filter(function (m) { return m.available && m.id === reply.default; })[0];
894
+ if (!models.length) return none(reply.reason);
895
+ return { models: models, default: fallback ? fallback.id : null, reason: '' };
896
+ }, function (e) {
897
+ // A Lamp from before models were listed doesn't know the route.
898
+ var words = e && e.message;
899
+ return none(/^Not found\.?$/.test(words || '') ? 'Choosing the AI needs a newer Lamp.' : words, e && e.help);
900
+ });
901
+ }
902
+ },
903
+
844
904
  /** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
845
905
  * system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
846
- * model (Lamp's id: "sonnet", "opus", "haiku", "claude-fable-5-2"). */
906
+ * model (an id `LampAI.models()` lists, such as "sonnet", "opus", "haiku", "claude-fable-5-2"). */
847
907
  ask: function (prompt, options) { return call(prompt, options); },
848
908
 
849
909
  /**
850
910
  * Does what the person asks, in their own words, with the app's own reads and writes: the model picks calls,
851
- * the page runs them, the model sees what happened, until it replies (at most 4 model calls and 6 writes a turn,
852
- * with no time limit unless the app sets one with `limits: { seconds }`; Cancel ends it at once). Check `LampAI.isAvailable('act')` first; keep the app's buttons for everything.
911
+ * the page runs them, the model sees what happened, until it replies; Cancel ends it at once. Check
912
+ * `LampAI.isAvailable('act')` first; keep the app's buttons for everything. A turn takes at most 4 model calls
913
+ * and 6 writes and has no time limit; `limits` changes that for an app that needs it: `steps` (up to 40) and
914
+ * `writes` (up to 320) for one that builds something real in a turn, many changes at a time, `seconds` for a
915
+ * feature that should answer quickly, and `result` (bytes, 32 KB unless raised, up to 256 KB) for how much of a
916
+ * read the model sees. A limit that isn't a positive number keeps its default. `model` names which AI answers,
917
+ * as `ask`'s does; the whole turn stays on it.
853
918
  *
854
919
  * var turn = LampAI.act('Add milk', {
855
920
  * about: 'A family dashboard: the calendar, chores and the shopping list.',
@@ -862,6 +927,8 @@
862
927
  * run: function (args, call) { return add(args, call.key); } }
863
928
  * },
864
929
  * history: [{ said: 'Who did it?', heard: 'Maya' }], // the last 3 exchanges, kept by the app
930
+ * limits: { steps: 20, writes: 200 }, // optional: see above
931
+ * model: picker.value, // optional: an id from LampAI.models()
865
932
  * onStep: function (s) { … } // { kind, name, args[, result] }
866
933
  * });
867
934
  * turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; });
package/cli/lamps/lamp.js CHANGED
@@ -285,7 +285,7 @@
285
285
  });
286
286
  }
287
287
  var kept = read('nearby.token', null);
288
- var done = path === '/ai/status' || (body && body.probe === true) ? go(kept) : (kept ? Promise.resolve(kept) : allow()).then(function (t) {
288
+ var done = path === '/ai/status' || path === '/ai/models' || (body && body.probe === true) ? go(kept) : (kept ? Promise.resolve(kept) : allow()).then(function (t) {
289
289
  return go(t).then(function (answer) {
290
290
  if (answer.status !== 401) return answer;
291
291
  // Taken back on the computer: ask once more.
@@ -519,8 +519,12 @@
519
519
 
520
520
  // A turn has no time limit of its own: a real answer is never cut off, however long the model takes. Each step is
521
521
  // given ACT_STEP_SECONDS, which only a call that has stopped answering reaches. An app that wants a quicker turn
522
- // asks for one (`limits: { seconds }`); steps and writes it may only lower.
522
+ // asks for one (`limits: { seconds }`). Steps and writes are few unless the app says otherwise, so a request that
523
+ // goes wrong ends soon and changes little; an app that builds something real raises them (`limits: { steps,
524
+ // writes }`) up to ACT_LIMITS_MAX: 40 steps, as many as Lamp takes in one turn (`MAX_STEPS` in lamp's app-ai.mjs),
525
+ // and as many writes as 40 steps of 8 calls can hold.
523
526
  var ACT_LIMITS = { steps: 4, writes: 6 };
527
+ var ACT_LIMITS_MAX = { steps: 40, writes: 320 };
524
528
  // How much of one read goes back to the model: room for a real app's state (a 184-piece model, a week's calendar)
525
529
  // in one step, since paging through it costs steps a turn needs for its work. An app with more raises it
526
530
  // (`limits: { result }`), up to ACT_RESULT_MAX.
@@ -565,8 +569,8 @@
565
569
  var o = options || {}, tools = o.tools || {};
566
570
  var limits = {};
567
571
  ['steps', 'writes'].forEach(function (k) {
568
- var asked = o.limits && typeof o.limits[k] === 'number' ? o.limits[k] : ACT_LIMITS[k];
569
- limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS[k]));
572
+ var asked = o.limits && typeof o.limits[k] === 'number' && o.limits[k] > 0 ? Math.floor(o.limits[k]) : ACT_LIMITS[k];
573
+ limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS_MAX[k]));
570
574
  });
571
575
  var asked = o.limits && typeof o.limits.seconds === 'number' && o.limits.seconds > 0 ? o.limits.seconds : null;
572
576
  var resultMax = o.limits && typeof o.limits.result === 'number' && o.limits.result > 0
@@ -651,7 +655,8 @@
651
655
  if (bad) return fail(bad);
652
656
  var key = 'k' + hash(id + '|' + c.name + '|' + canonical(c.args));
653
657
  // The same change asked again in this turn gets its first result, and doesn't run again.
654
- if (ran.hasOwnProperty(key)) { results[c.id] = { id: c.id, ok: true, repeat: true, value: ran[key] }; return next(i + 1); }
658
+ // Its result is in an earlier step, which every request carries: pointed to, not sent again.
659
+ if (ran.hasOwnProperty(key)) { results[c.id] = { id: c.id, ok: true, repeat: true, value: { note: 'Already done in this request: its result is in an earlier step.' } }; return next(i + 1); }
655
660
  if (writes >= limits.writes) return fail('Over this turn\'s limit of ' + limits.writes + ' changes.');
656
661
  var call = { turn: id, key: key, step: step, id: c.id };
657
662
  return Promise.resolve().then(function () { return tool.check ? tool.check(c.args, call) : null; }).then(function (problem) {
@@ -788,6 +793,11 @@
788
793
  * `connect: true`: show a small "Connect AI" button beside its reason that calls `LampAI.connect(capability)` with
789
794
  * the same kind of AI (`connect('pictures')` for pictures), and use what that gives as `status`'s answer. Never
790
795
  * call `connect()` as the page loads.
796
+ *
797
+ * Which AI answers is the person's choice in Lamp. An app may offer them another for its own asking: `LampAI.models()`
798
+ * lists what their setup can answer it with now. Show a small picker beside the app's Ask button with only the
799
+ * available ones and `default` chosen, and send the chosen id as `model` to `ask` and `act`. Leave the picker out
800
+ * when there's one or none to choose from.
791
801
  */
792
802
  var LampAI = {
793
803
  Failure: Failure,
@@ -841,15 +851,70 @@
841
851
  /** Whether this kind of AI can answer now, as `status` says. */
842
852
  isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
843
853
 
854
+ /** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name,
855
+ * available, reason }], default, reason, help, connect }. `default` is the id that answers when a call names no
856
+ * `model`; the ids are what `ask` and `act` take as `model`, and one that can't answer when it's sent is
857
+ * answered as no choice, never refused. With none to offer, `models` is empty and `reason` says why; on a page
858
+ * shared on lamp.dev that hasn't reached this computer yet it asks nothing and answers `connect: true`, as
859
+ * `status` does: ask again once `connect` has. Keep the person's choice with the app's saved things, and go back
860
+ * to `default` when it's no longer listed as available.
861
+ *
862
+ * LampAI.models().then(function (m) {
863
+ * var usable = m.models.filter(function (x) { return x.available; });
864
+ * picker.hidden = usable.length < 2;
865
+ * picker.innerHTML = usable.map(function (x) { return '<option value="' + x.id + '">' + x.name + '</option>'; }).join('');
866
+ * picker.value = m.default;
867
+ * });
868
+ * LampAI.ask(question, { model: picker.value });
869
+ */
870
+ models: function () {
871
+ var none = function (reason, help) {
872
+ var said = { models: [], default: null, reason: reason || 'AI isn\'t set up for this here.' };
873
+ if (help) said.help = help;
874
+ return said;
875
+ };
876
+ if (!bridge && shared()) {
877
+ return nearbyAllowed().then(function (allowed) {
878
+ if (allowed) return listed();
879
+ if (safari()) return none(SAFARI_AI, AI_HELP);
880
+ return { models: [], default: null, connect: true, reason: 'Connect to the AI on this computer to use this.', help: AI_HELP };
881
+ });
882
+ }
883
+ return listed();
884
+ function listed() {
885
+ return post('/ai/models', {}, 10000).then(function (reply) {
886
+ var models = (isList(reply.models) ? reply.models : []).filter(function (m) {
887
+ return m && typeof m.id === 'string' && m.id;
888
+ }).map(function (m) {
889
+ var one = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id, available: m.available === true };
890
+ if (!one.available && typeof m.reason === 'string') one.reason = m.reason;
891
+ return one;
892
+ });
893
+ var fallback = models.filter(function (m) { return m.available && m.id === reply.default; })[0];
894
+ if (!models.length) return none(reply.reason);
895
+ return { models: models, default: fallback ? fallback.id : null, reason: '' };
896
+ }, function (e) {
897
+ // A Lamp from before models were listed doesn't know the route.
898
+ var words = e && e.message;
899
+ return none(/^Not found\.?$/.test(words || '') ? 'Choosing the AI needs a newer Lamp.' : words, e && e.help);
900
+ });
901
+ }
902
+ },
903
+
844
904
  /** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
845
905
  * system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
846
- * model (Lamp's id: "sonnet", "opus", "haiku", "claude-fable-5-2"). */
906
+ * model (an id `LampAI.models()` lists, such as "sonnet", "opus", "haiku", "claude-fable-5-2"). */
847
907
  ask: function (prompt, options) { return call(prompt, options); },
848
908
 
849
909
  /**
850
910
  * Does what the person asks, in their own words, with the app's own reads and writes: the model picks calls,
851
- * the page runs them, the model sees what happened, until it replies (at most 4 model calls and 6 writes a turn,
852
- * with no time limit unless the app sets one with `limits: { seconds }`; Cancel ends it at once). Check `LampAI.isAvailable('act')` first; keep the app's buttons for everything.
911
+ * the page runs them, the model sees what happened, until it replies; Cancel ends it at once. Check
912
+ * `LampAI.isAvailable('act')` first; keep the app's buttons for everything. A turn takes at most 4 model calls
913
+ * and 6 writes and has no time limit; `limits` changes that for an app that needs it: `steps` (up to 40) and
914
+ * `writes` (up to 320) for one that builds something real in a turn, many changes at a time, `seconds` for a
915
+ * feature that should answer quickly, and `result` (bytes, 32 KB unless raised, up to 256 KB) for how much of a
916
+ * read the model sees. A limit that isn't a positive number keeps its default. `model` names which AI answers,
917
+ * as `ask`'s does; the whole turn stays on it.
853
918
  *
854
919
  * var turn = LampAI.act('Add milk', {
855
920
  * about: 'A family dashboard: the calendar, chores and the shopping list.',
@@ -862,6 +927,8 @@
862
927
  * run: function (args, call) { return add(args, call.key); } }
863
928
  * },
864
929
  * history: [{ said: 'Who did it?', heard: 'Maya' }], // the last 3 exchanges, kept by the app
930
+ * limits: { steps: 20, writes: 200 }, // optional: see above
931
+ * model: picker.value, // optional: an id from LampAI.models()
865
932
  * onStep: function (s) { … } // { kind, name, args[, result] }
866
933
  * });
867
934
  * turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; });
@@ -1 +1 @@
1
- {"exe":"ZqxAppName","name":"Zqx App Name","platform":"mac","instance":"10DC6CFC-3873-414E-B724-6AA904DC37A0"}
1
+ {"name":"Zqx App Name","instance":"FE98C883-9417-425A-8B4A-689C3EB7CC0A","exe":"ZqxAppName","platform":"mac"}
@@ -202,6 +202,10 @@
202
202
  if path == "/ai/act" {
203
203
  throw LampAI.Failure(message: "Couldn't reach Lamp, and asking the app to do things needs it. Try again on the same Wi‑Fi as the Mac.")
204
204
  }
205
+ // Away, the phone answers with the one model it carries: nothing to choose among.
206
+ if path == "/ai/models" {
207
+ return ["models": [Any](), "reason": "Choosing the AI needs Lamp: use the app on the same Wi‑Fi as the Mac."]
208
+ }
205
209
  fputs("LampAI \(path): Lamp isn't reachable, using the key kept on this phone\n", stderr)
206
210
  switch path {
207
211
  case "/ai":
@@ -11197,6 +11201,11 @@
11197
11201
  /// To have the app do what the person asks in their own words, give `LampAI.act` (or a `LampActSession`) the app's
11198
11202
  /// own reads and checked writes: the AI chooses among them, the app checks and runs each, and the result says what
11199
11203
  /// was done from the tools' receipts.
11204
+ ///
11205
+ /// Which AI answers is the person's choice in Lamp. An app may offer them another for its own asking:
11206
+ /// `await LampAI.models()` lists what their setup can answer it with now. Show a small Picker beside the app's Ask
11207
+ /// button with only the `available` ones and `defaultID` chosen, and pass the chosen id as `model:` to `ask` and to a
11208
+ /// `LampActSession`. Leave the picker out when there's one or none to choose from.
11200
11209
  enum LampAI {
11201
11210
  struct Failure: LocalizedError {
11202
11211
  let message: String
@@ -11215,7 +11224,8 @@
11215
11224
  }
11216
11225
 
11217
11226
  /// `data` is text from outside the app (an email, a web page, a file) for the AI to work on, not obey.
11218
- /// `model` overrides the app's text model for one call, by Lamp's id ("sonnet", "opus", "haiku", "claude-fable-5-2").
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.
11219
11229
  static func ask(_ prompt: String, data: String? = nil, images: [LampImage] = [],
11220
11230
  system: String? = nil, model: String? = nil) async throws -> String {
11221
11231
  try await call(prompt: prompt, data: data, images: images, system: system, json: false, model: model)
@@ -11677,6 +11687,27 @@
11677
11687
 
11678
11688
  // MARK: - Doing things for the person
11679
11689
 
11690
+ /// A model an app may offer the person (`LampAI.models()`): `id` is what `ask(model:)` takes, `name` what to show.
11691
+ struct LampAIModel: Identifiable, Equatable {
11692
+ let id: String
11693
+ let name: String
11694
+ /// Whether it can answer now; one that can't isn't worth offering.
11695
+ let available: Bool
11696
+ /// Why it can't, when it can't.
11697
+ let reason: String?
11698
+ }
11699
+
11700
+ /// What the person's setup can answer an app with now (`LampAI.models()`).
11701
+ struct LampAIModels {
11702
+ var models: [LampAIModel] = []
11703
+ /// The one that answers a call naming no model; nil when nothing can answer.
11704
+ var defaultID: String?
11705
+ /// Why there's nothing to choose from, when there isn't.
11706
+ var reason: String?
11707
+ /// The ones to show in a picker.
11708
+ var available: [LampAIModel] { models.filter(\.available) }
11709
+ }
11710
+
11680
11711
  /// How one kind of AI stands for this app now, as Lamp sees it (`LampAI.status`).
11681
11712
  struct LampAIStatus {
11682
11713
  /// Where the AI works out its answer, for telling the person where their words go. A Claude Code or Codex
@@ -11732,12 +11763,41 @@
11732
11763
  }
11733
11764
  }
11734
11765
 
11766
+ /// The models the person's setup can answer this app with now, asked of no model, so it's cheap to call as a
11767
+ /// view appears. The ids are what `ask(model:)` and `LampActSession(model:)` take. Keep the person's choice with
11768
+ /// the app's saved things, and go back to `defaultID` when it's no longer listed as available.
11769
+ ///
11770
+ /// let choices = await LampAI.models()
11771
+ /// if choices.available.count > 1 {
11772
+ /// Picker("AI", selection: $model) { ForEach(choices.available) { Text($0.name).tag(Optional($0.id)) } }
11773
+ /// }
11774
+ /// // model starts as choices.defaultID; then: try await LampAI.ask(question, model: model)
11775
+ static func models() async -> LampAIModels {
11776
+ let reply: [String: Any]
11777
+ do { reply = try await post("/ai/models", [:], timeout: 10) } catch {
11778
+ // A Lamp from before models were listed doesn't know the route.
11779
+ let words = (error as? Failure)?.message ?? ""
11780
+ return LampAIModels(reason: words.hasPrefix("Not found") ? "Choosing the AI needs a newer Lamp."
11781
+ : (words.isEmpty ? "Lamp isn't open, or can't be reached from here." : words))
11782
+ }
11783
+ let models = (reply["models"] as? [[String: Any]] ?? []).compactMap { m -> LampAIModel? in
11784
+ guard let id = m["id"] as? String, !id.isEmpty else { return nil }
11785
+ let available = m["available"] as? Bool ?? false
11786
+ return LampAIModel(id: id, name: (m["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? id, available: available,
11787
+ reason: available ? nil : m["reason"] as? String)
11788
+ }
11789
+ let fallback = reply["default"] as? String
11790
+ return LampAIModels(models: models, defaultID: models.contains { $0.id == fallback && $0.available } ? fallback : nil,
11791
+ reason: models.isEmpty ? (reply["reason"] as? String ?? "AI isn't set up in Lamp yet.") : nil)
11792
+ }
11793
+
11735
11794
  /// The person asks the app to do something in their own words, and the app's own tools do it: a few reads and
11736
11795
  /// checked writes over its data, chosen by the AI, run by the app. `LampActSession` has the details; this starts a
11737
11796
  /// request in `session`, or in a session of its own when there's none to keep (no questions back can be answered then).
11797
+ /// `model` is for that session of its own, as `LampActSession(model:)` takes it.
11738
11798
  @MainActor static func act(request: String, about: String = "", context: (any Encodable)? = nil, tools: [LampActTool],
11739
- session: LampActSession? = nil, onStep: ((LampActStep) -> Void)? = nil) -> LampActTurn {
11740
- (session ?? LampActSession(about: about, tools: tools)).ask(request, context: context, onStep: onStep)
11799
+ session: LampActSession? = nil, model: String? = nil, onStep: ((LampActStep) -> Void)? = nil) -> LampActTurn {
11800
+ (session ?? LampActSession(about: about, tools: tools, model: model)).ask(request, context: context, onStep: onStep)
11741
11801
  }
11742
11802
 
11743
11803
  fileprivate static var actPresent: String { present }
@@ -11993,13 +12053,22 @@
11993
12053
  /// its `run` makes it. Reads run first, then changes one at a time, each checked right before it runs; a change that
11994
12054
  /// fails skips the rest of that step. The same change asked twice in a request runs once. A request may take 4 AI
11995
12055
  /// steps between questions, 6 changes in all and 3 questions back, with no time limit unless the app sets one
11996
- /// (`seconds:`): a real answer is never cut off. Each step stops only when a call has stopped answering
11997
- /// (`stepSeconds`). Asking something new, cancelling or closing ends it.
12056
+ /// (`seconds:`): a real answer is never cut off. An app that builds something real in one request raises `steps:`
12057
+ /// (up to 40) and `writes:` (up to 320); a value that isn't positive keeps the default. `model:` names which AI
12058
+ /// answers, an id from `LampAI.models()`. Each step stops only when a call has stopped answering (`stepSeconds`).
12059
+ /// Asking something new, cancelling or closing ends it.
11998
12060
  @MainActor final class LampActSession {
12061
+ /// A request's steps and changes unless the app raises them: few, so a request that goes wrong ends soon and
12062
+ /// changes little.
11999
12063
  static let maxSteps = 4
12000
12064
  static let maxWrites = 6
12065
+ /// The most an app may raise them to: as many steps as Lamp takes in one turn (`MAX_STEPS` in lamp's app-ai.mjs),
12066
+ /// and as many changes as that many steps of 8 calls can hold (`ACT_LIMITS_MAX` in lamp.js).
12067
+ static let mostSteps = 40
12068
+ static let mostWrites = 320
12001
12069
  static let maxQuestions = 3
12002
12070
  /// What a session keeps of its changes; past this it stops taking changes and says so, rather than forget them.
12071
+ /// A session whose requests may make more keeps ten requests' worth (`receiptLimit`).
12003
12072
  static let maxReceipts = 60
12004
12073
  /// How long one step may take before its call is taken to have stopped answering.
12005
12074
  static let stepSeconds: Double = 600
@@ -12034,16 +12103,28 @@
12034
12103
  /// How much of one read goes back to the AI, in bytes: room for an app's real state in one step, since paging
12035
12104
  /// through it costs steps a request needs for its work.
12036
12105
  let resultBytes: Int
12106
+ /// The AI steps a request may take between questions, and the changes it may make in all.
12107
+ let stepLimit: Int
12108
+ let writeLimit: Int
12109
+ /// Which AI answers, by an id `LampAI.models()` lists; nil for the app's own.
12110
+ let model: String?
12111
+ private var receiptLimit: Int { max(Self.maxReceipts, writeLimit * 10) }
12037
12112
 
12038
12113
  /// `seconds` limits each request's time, for a feature that should answer quickly; leave it out for no limit.
12039
12114
  /// `resultBytes` raises how much of a read the AI sees (32 KB unless given, 256 KB at most).
12115
+ /// `steps` and `writes` raise a request's AI steps (4 unless given, 40 at most) and changes (6, 320 at most).
12116
+ /// `model` names which AI answers, an id from `LampAI.models()`; leave it out for the app's own.
12040
12117
  /// `send` stands in for Lamp in a test; leave it out.
12041
12118
  init(about: String = "", tools: [LampActTool], seconds: Double? = nil, resultBytes: Int? = nil,
12119
+ steps: Int? = nil, writes: Int? = nil, model: String? = nil,
12042
12120
  send: (([String: Any], TimeInterval) async throws -> [String: Any])? = nil) {
12043
12121
  self.about = about
12044
12122
  self.tools = tools
12045
12123
  self.seconds = seconds.map { max(1, $0) }
12046
12124
  self.resultBytes = min(max(resultBytes ?? 32_768, 1024), 262_144)
12125
+ self.stepLimit = min(steps.flatMap { $0 > 0 ? $0 : nil } ?? Self.maxSteps, Self.mostSteps)
12126
+ self.writeLimit = min(writes.flatMap { $0 > 0 ? $0 : nil } ?? Self.maxWrites, Self.mostWrites)
12127
+ self.model = model.flatMap { $0.isEmpty ? nil : $0 }
12047
12128
  self.send = send ?? { body, timeout in try await LampAI.post("/ai/act", body, timeout: timeout) }
12048
12129
  }
12049
12130
 
@@ -12127,7 +12208,8 @@
12127
12208
  "tools": tools.map(\.declared), "steps": steps, "seconds": Int(left)]
12128
12209
  if let context { body["context"] = context }
12129
12210
  if let route { body["route"] = route }
12130
- if steps.count >= Self.maxSteps - 1 { body["final"] = true }
12211
+ if let model { body["model"] = model }
12212
+ if steps.count >= stepLimit - 1 { body["final"] = true }
12131
12213
  onStep(.thinking)
12132
12214
  let answer: [String: Any]
12133
12215
  do { answer = try await send(body, left) } catch {
@@ -12154,7 +12236,7 @@
12154
12236
  return results[id] ?? ["id": id, "ok": false, "skipped": "Not run."]
12155
12237
  }])
12156
12238
  if out.stopped != nil { break }
12157
- if steps.count >= Self.maxSteps { out.stopped = .steps; break }
12239
+ if steps.count >= stepLimit { out.stopped = .steps; break }
12158
12240
  continue
12159
12241
  }
12160
12242
  out.reply = answer["reply"] as? String ?? ""
@@ -12220,18 +12302,19 @@
12220
12302
  if let stopped { miss(stopped, skipped: true); continue }
12221
12303
  if let bad = tool.problem(given) { miss(bad); continue }
12222
12304
  let key = Self.prefix(op) + Self.hash(tool.name + "|" + Self.canonical(given))
12223
- // The same change asked again in this request gets its first result, and doesn't run again: one that
12305
+ // The same change asked again in this request is pointed to its first result, and doesn't run again: one that
12224
12306
  // failed while it ran may have done part of it, so it isn't tried again by itself either.
12225
12307
  if let kept = ran[key] {
12226
12308
  if let failed = kept as? [String: Any], let reason = failed["failedWhileRunning"] as? String {
12227
12309
  miss("Already tried in this request, and it didn't go through: \(reason)")
12228
12310
  } else {
12229
- results[id] = ["id": id, "ok": true, "repeat": true, "value": kept]
12311
+ // Its result is in an earlier step, which every request carries: pointed to, not sent again.
12312
+ results[id] = ["id": id, "ok": true, "repeat": true, "value": ["note": "Already done in this request: its result is in an earlier step."]]
12230
12313
  }
12231
12314
  continue
12232
12315
  }
12233
- if op.writes >= Self.maxWrites { miss("Over this request's limit of \(Self.maxWrites) changes."); out.stopped = .limit; out.error = "That's more changes than one request can make. Ask for the rest separately."; return results }
12234
- if receipts.count >= Self.maxReceipts { miss("This conversation has made as many changes as it can keep track of."); out.stopped = .limit; out.error = "Close this and start again to make more changes."; return results }
12316
+ if op.writes >= writeLimit { miss("Over this request's limit of \(writeLimit) changes."); out.stopped = .limit; out.error = "That's more changes than one request can make. Ask for the rest separately."; return results }
12317
+ if receipts.count >= receiptLimit { miss("This conversation has made as many changes as it can keep track of."); out.stopped = .limit; out.error = "Close this and start again to make more changes."; return results }
12235
12318
  let info = callInfo(call, key: key)
12236
12319
  let problem: String?
12237
12320
  do { problem = try await tool.check?(given, info) } catch { problem = Self.words(error) }
@@ -12245,11 +12328,15 @@
12245
12328
  // Done is done, even if the turn was cancelled while it ran: it's kept and shown.
12246
12329
  var value = receipt.details.filter { JSONSerialization.isValidJSONObject([$0.value]) }
12247
12330
  value["summary"] = receipt.summary
12248
- ran[key] = value
12331
+ // What the AI is shown of it draws on the request's allowance as a read does, since every later
12332
+ // step carries it again; the receipt keeps it whole for the app.
12333
+ let shown = Self.cut(value, max: Swift.max(1024, Swift.min(resultBytes, op.resultsLeft)))
12334
+ op.resultsLeft = Swift.max(0, op.resultsLeft - ((try? JSONSerialization.data(withJSONObject: [shown]))?.count ?? 0))
12335
+ ran[key] = shown
12249
12336
  let done = LampActDone(tool: tool.name, key: key, receipt: receipt)
12250
12337
  out.done.append(done)
12251
12338
  receipts.append(done)
12252
- results[id] = ["id": id, "ok": true, "value": value]
12339
+ results[id] = ["id": id, "ok": true, "value": shown]
12253
12340
  onStep(.changed(done))
12254
12341
  } catch {
12255
12342
  ran[key] = ["failedWhileRunning": Self.words(error)]
@@ -285,7 +285,7 @@
285
285
  });
286
286
  }
287
287
  var kept = read('nearby.token', null);
288
- var done = path === '/ai/status' || (body && body.probe === true) ? go(kept) : (kept ? Promise.resolve(kept) : allow()).then(function (t) {
288
+ var done = path === '/ai/status' || path === '/ai/models' || (body && body.probe === true) ? go(kept) : (kept ? Promise.resolve(kept) : allow()).then(function (t) {
289
289
  return go(t).then(function (answer) {
290
290
  if (answer.status !== 401) return answer;
291
291
  // Taken back on the computer: ask once more.
@@ -519,8 +519,12 @@
519
519
 
520
520
  // A turn has no time limit of its own: a real answer is never cut off, however long the model takes. Each step is
521
521
  // given ACT_STEP_SECONDS, which only a call that has stopped answering reaches. An app that wants a quicker turn
522
- // asks for one (`limits: { seconds }`); steps and writes it may only lower.
522
+ // asks for one (`limits: { seconds }`). Steps and writes are few unless the app says otherwise, so a request that
523
+ // goes wrong ends soon and changes little; an app that builds something real raises them (`limits: { steps,
524
+ // writes }`) up to ACT_LIMITS_MAX: 40 steps, as many as Lamp takes in one turn (`MAX_STEPS` in lamp's app-ai.mjs),
525
+ // and as many writes as 40 steps of 8 calls can hold.
523
526
  var ACT_LIMITS = { steps: 4, writes: 6 };
527
+ var ACT_LIMITS_MAX = { steps: 40, writes: 320 };
524
528
  // How much of one read goes back to the model: room for a real app's state (a 184-piece model, a week's calendar)
525
529
  // in one step, since paging through it costs steps a turn needs for its work. An app with more raises it
526
530
  // (`limits: { result }`), up to ACT_RESULT_MAX.
@@ -565,8 +569,8 @@
565
569
  var o = options || {}, tools = o.tools || {};
566
570
  var limits = {};
567
571
  ['steps', 'writes'].forEach(function (k) {
568
- var asked = o.limits && typeof o.limits[k] === 'number' ? o.limits[k] : ACT_LIMITS[k];
569
- limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS[k]));
572
+ var asked = o.limits && typeof o.limits[k] === 'number' && o.limits[k] > 0 ? Math.floor(o.limits[k]) : ACT_LIMITS[k];
573
+ limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS_MAX[k]));
570
574
  });
571
575
  var asked = o.limits && typeof o.limits.seconds === 'number' && o.limits.seconds > 0 ? o.limits.seconds : null;
572
576
  var resultMax = o.limits && typeof o.limits.result === 'number' && o.limits.result > 0
@@ -651,7 +655,8 @@
651
655
  if (bad) return fail(bad);
652
656
  var key = 'k' + hash(id + '|' + c.name + '|' + canonical(c.args));
653
657
  // The same change asked again in this turn gets its first result, and doesn't run again.
654
- if (ran.hasOwnProperty(key)) { results[c.id] = { id: c.id, ok: true, repeat: true, value: ran[key] }; return next(i + 1); }
658
+ // Its result is in an earlier step, which every request carries: pointed to, not sent again.
659
+ if (ran.hasOwnProperty(key)) { results[c.id] = { id: c.id, ok: true, repeat: true, value: { note: 'Already done in this request: its result is in an earlier step.' } }; return next(i + 1); }
655
660
  if (writes >= limits.writes) return fail('Over this turn\'s limit of ' + limits.writes + ' changes.');
656
661
  var call = { turn: id, key: key, step: step, id: c.id };
657
662
  return Promise.resolve().then(function () { return tool.check ? tool.check(c.args, call) : null; }).then(function (problem) {
@@ -788,6 +793,11 @@
788
793
  * `connect: true`: show a small "Connect AI" button beside its reason that calls `LampAI.connect(capability)` with
789
794
  * the same kind of AI (`connect('pictures')` for pictures), and use what that gives as `status`'s answer. Never
790
795
  * call `connect()` as the page loads.
796
+ *
797
+ * Which AI answers is the person's choice in Lamp. An app may offer them another for its own asking: `LampAI.models()`
798
+ * lists what their setup can answer it with now. Show a small picker beside the app's Ask button with only the
799
+ * available ones and `default` chosen, and send the chosen id as `model` to `ask` and `act`. Leave the picker out
800
+ * when there's one or none to choose from.
791
801
  */
792
802
  var LampAI = {
793
803
  Failure: Failure,
@@ -841,15 +851,70 @@
841
851
  /** Whether this kind of AI can answer now, as `status` says. */
842
852
  isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
843
853
 
854
+ /** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name,
855
+ * available, reason }], default, reason, help, connect }. `default` is the id that answers when a call names no
856
+ * `model`; the ids are what `ask` and `act` take as `model`, and one that can't answer when it's sent is
857
+ * answered as no choice, never refused. With none to offer, `models` is empty and `reason` says why; on a page
858
+ * shared on lamp.dev that hasn't reached this computer yet it asks nothing and answers `connect: true`, as
859
+ * `status` does: ask again once `connect` has. Keep the person's choice with the app's saved things, and go back
860
+ * to `default` when it's no longer listed as available.
861
+ *
862
+ * LampAI.models().then(function (m) {
863
+ * var usable = m.models.filter(function (x) { return x.available; });
864
+ * picker.hidden = usable.length < 2;
865
+ * picker.innerHTML = usable.map(function (x) { return '<option value="' + x.id + '">' + x.name + '</option>'; }).join('');
866
+ * picker.value = m.default;
867
+ * });
868
+ * LampAI.ask(question, { model: picker.value });
869
+ */
870
+ models: function () {
871
+ var none = function (reason, help) {
872
+ var said = { models: [], default: null, reason: reason || 'AI isn\'t set up for this here.' };
873
+ if (help) said.help = help;
874
+ return said;
875
+ };
876
+ if (!bridge && shared()) {
877
+ return nearbyAllowed().then(function (allowed) {
878
+ if (allowed) return listed();
879
+ if (safari()) return none(SAFARI_AI, AI_HELP);
880
+ return { models: [], default: null, connect: true, reason: 'Connect to the AI on this computer to use this.', help: AI_HELP };
881
+ });
882
+ }
883
+ return listed();
884
+ function listed() {
885
+ return post('/ai/models', {}, 10000).then(function (reply) {
886
+ var models = (isList(reply.models) ? reply.models : []).filter(function (m) {
887
+ return m && typeof m.id === 'string' && m.id;
888
+ }).map(function (m) {
889
+ var one = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id, available: m.available === true };
890
+ if (!one.available && typeof m.reason === 'string') one.reason = m.reason;
891
+ return one;
892
+ });
893
+ var fallback = models.filter(function (m) { return m.available && m.id === reply.default; })[0];
894
+ if (!models.length) return none(reply.reason);
895
+ return { models: models, default: fallback ? fallback.id : null, reason: '' };
896
+ }, function (e) {
897
+ // A Lamp from before models were listed doesn't know the route.
898
+ var words = e && e.message;
899
+ return none(/^Not found\.?$/.test(words || '') ? 'Choosing the AI needs a newer Lamp.' : words, e && e.help);
900
+ });
901
+ }
902
+ },
903
+
844
904
  /** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
845
905
  * system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
846
- * model (Lamp's id: "sonnet", "opus", "haiku", "claude-fable-5-2"). */
906
+ * model (an id `LampAI.models()` lists, such as "sonnet", "opus", "haiku", "claude-fable-5-2"). */
847
907
  ask: function (prompt, options) { return call(prompt, options); },
848
908
 
849
909
  /**
850
910
  * Does what the person asks, in their own words, with the app's own reads and writes: the model picks calls,
851
- * the page runs them, the model sees what happened, until it replies (at most 4 model calls and 6 writes a turn,
852
- * with no time limit unless the app sets one with `limits: { seconds }`; Cancel ends it at once). Check `LampAI.isAvailable('act')` first; keep the app's buttons for everything.
911
+ * the page runs them, the model sees what happened, until it replies; Cancel ends it at once. Check
912
+ * `LampAI.isAvailable('act')` first; keep the app's buttons for everything. A turn takes at most 4 model calls
913
+ * and 6 writes and has no time limit; `limits` changes that for an app that needs it: `steps` (up to 40) and
914
+ * `writes` (up to 320) for one that builds something real in a turn, many changes at a time, `seconds` for a
915
+ * feature that should answer quickly, and `result` (bytes, 32 KB unless raised, up to 256 KB) for how much of a
916
+ * read the model sees. A limit that isn't a positive number keeps its default. `model` names which AI answers,
917
+ * as `ask`'s does; the whole turn stays on it.
853
918
  *
854
919
  * var turn = LampAI.act('Add milk', {
855
920
  * about: 'A family dashboard: the calendar, chores and the shopping list.',
@@ -862,6 +927,8 @@
862
927
  * run: function (args, call) { return add(args, call.key); } }
863
928
  * },
864
929
  * history: [{ said: 'Who did it?', heard: 'Maya' }], // the last 3 exchanges, kept by the app
930
+ * limits: { steps: 20, writes: 200 }, // optional: see above
931
+ * model: picker.value, // optional: an id from LampAI.models()
865
932
  * onStep: function (s) { … } // { kind, name, args[, result] }
866
933
  * });
867
934
  * turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; });
package/cli/local-ai.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  // the request and a page can't change, so an approval is for one link's page: the first time it asks, the person is
8
8
  // asked in the terminal, and the page is given a token it keeps and sends from then on. Nothing else is answered:
9
9
  // another site, another link's page, or a request naming this computer by another name (a site that rebinds its own
10
- // name to 127.0.0.1) is refused. Whether there's an AI here (`/ai/status` and the probes) is answered without one, so a
10
+ // name to 127.0.0.1) is refused. Whether there's an AI here (`/ai/status`, `/ai/models` and the probes) is answered without one, so a
11
11
  // page can say what it can do before anyone is asked.
12
12
 
13
13
  import { createServer } from "node:http";
@@ -22,7 +22,7 @@ import { answer as answerAI, engine } from "./ai.mjs";
22
22
  export const LOCAL_AI_PORT = 47423;
23
23
  const PLAY_HOST = "play.lamp.dev";
24
24
  const MOST = 4_000_000, AT_ONCE = 3, ASKING_FOR = 120_000;
25
- const ROUTES = new Set(["/ai/status", "/ai", "/ai/act", "/ai/decide", "/ai/hear", "/ai/image"]);
25
+ const ROUTES = new Set(["/ai/status", "/ai/models", "/ai", "/ai/act", "/ai/decide", "/ai/hear", "/ai/image"]);
26
26
 
27
27
  const keptFile = () => join(process.env.LAMP_DEV_HOME || join(homedir(), ".lamp", "dev"), "shared-pages-ai.json");
28
28
 
@@ -153,9 +153,10 @@ export async function serveLocalAI({ port = LOCAL_AI_PORT, ask = askInTerminal()
153
153
  }
154
154
  if (!ROUTES.has(url.pathname)) return reply(404, { error: "Not found." });
155
155
  // What there is to answer with, asked before anyone is let in: only the routes whose probe asks no model.
156
- const asksNothing = url.pathname === "/ai/status" || (body?.probe === true && (url.pathname === "/ai/act" || url.pathname === "/ai/hear"));
156
+ const listing = url.pathname === "/ai/status" || url.pathname === "/ai/models";
157
+ const asksNothing = listing || (body?.probe === true && (url.pathname === "/ai/act" || url.pathname === "/ai/hear"));
157
158
  if (asksNothing) {
158
- const [status, answered] = await answer(url.pathname, url.pathname === "/ai/status" ? {} : { probe: true }, { id });
159
+ const [status, answered] = await answer(url.pathname, listing ? {} : { probe: true }, { id });
159
160
  return reply(status, answered);
160
161
  }
161
162
  const kept = allowed()[origin];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lampdotdev/lamp",
3
- "version": "0.36.2-nightly.26",
3
+ "version": "0.36.2-nightly.28",
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": {