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