@lampdotdev/lamp 0.36.2-nightly.41 → 0.36.2-nightly.44
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 +161 -41
- package/cli/app-ai.mjs +145 -7
- package/cli/calls.mjs +27 -11
- package/cli/dev.mjs +8 -2
- package/cli/lamps/lamp-for-games.js +229 -91
- package/cli/lamps/lamp.js +229 -91
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -1
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +18 -16
- package/cli/lamps/web-starter/lamp.js +229 -91
- package/cli/openrouter.mjs +78 -0
- 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
|
@@ -9,8 +9,8 @@ import { accessSync, closeSync, constants, fstatSync, mkdtempSync, openSync, rea
|
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { delimiter, join } from "node:path";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
|
-
import { chatBody, decidePrompt, planPrompt, planSchema, readDecision, readMessage, readPlan, readStep } from "./app-ai.mjs";
|
|
13
|
-
import { DEFAULT_MODEL, IMAGE_MODELS, OpenRouterTrouble, chat as openRouterChat, image as openRouterImage, text as openRouterText } from "./openrouter.mjs";
|
|
12
|
+
import { afterStream, chatBody, decidePrompt, planPrompt, planSchema, readDecision, readMessage, readPlan, readStep, stepReader } from "./app-ai.mjs";
|
|
13
|
+
import { DEFAULT_MODEL, IMAGE_MODELS, OpenRouterTrouble, chat as openRouterChat, chatStream as openRouterChatStream, image as openRouterImage, text as openRouterText } from "./openrouter.mjs";
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -56,10 +56,12 @@ 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. `onPartial` hears the answer to a
|
|
61
|
+
* `schema` as it's written, the JSON so far, where the engine says it as it goes (Claude Code; Codex says it at the end).
|
|
60
62
|
*/
|
|
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 });
|
|
63
|
+
export function complete({ system, prompt, schema, seconds = 60, which = engine(), tool, env = {}, model = process.env.LAMP_AI_MODEL || "", images = [], signal, spent, onPartial } = {}) {
|
|
64
|
+
if (which === "openrouter") return viaOpenRouter({ system, prompt, schema, seconds, model, images, signal, spent });
|
|
63
65
|
if (which !== "claude" && which !== "codex") return Promise.reject(new EngineTrouble("missing", missing));
|
|
64
66
|
const folder = mkdtempSync(join(tmpdir(), "lamp-ai-"));
|
|
65
67
|
const sayer = system || "You are a helpful assistant inside an app.";
|
|
@@ -72,6 +74,7 @@ export function complete({ system, prompt, schema, seconds = 60, which = engine(
|
|
|
72
74
|
"--strict-mcp-config", "--disable-slash-commands", "--tools", "", "--system-prompt", sayer];
|
|
73
75
|
if (model) args.push("--model", model);
|
|
74
76
|
if (schema) args.push("--json-schema", JSON.stringify(schema));
|
|
77
|
+
if (schema && onPartial) args.push("--include-partial-messages");
|
|
75
78
|
if (pictures.length) {
|
|
76
79
|
args.push("--input-format", "stream-json");
|
|
77
80
|
const content = [{ type: "text", text: prompt }, ...pictures.map((image) => ({ type: "image",
|
|
@@ -104,7 +107,7 @@ export function complete({ system, prompt, schema, seconds = 60, which = engine(
|
|
|
104
107
|
delete childEnv.BUN_BE_BUN;
|
|
105
108
|
return new Promise((resolve, reject) => {
|
|
106
109
|
const child = spawn(tool || onPath(which) || which, args, { cwd: folder, env: childEnv, detached: true, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
107
|
-
let text = "", trouble = null, rest = "", said = "";
|
|
110
|
+
let text = "", trouble = null, rest = "", said = "", partial = "";
|
|
108
111
|
// The whole group, since an engine may leave a child holding its output.
|
|
109
112
|
const stop = (kind, words) => { if (!trouble) trouble = new EngineTrouble(kind, words); try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } };
|
|
110
113
|
const timer = setTimeout(() => stop("timeout", `${named(which)} took too long to answer.`), Math.max(1, seconds) * 1000);
|
|
@@ -118,6 +121,16 @@ export function complete({ system, prompt, schema, seconds = 60, which = engine(
|
|
|
118
121
|
else if (codexFailure(event)) trouble ??= new EngineTrouble("failed", codexFailure(event));
|
|
119
122
|
return;
|
|
120
123
|
}
|
|
124
|
+
// The schema's answer as it's written: its JSON, a piece at a time.
|
|
125
|
+
if (event.type === "stream_event" && onPartial) {
|
|
126
|
+
const inner = event.event ?? {};
|
|
127
|
+
if (inner.type === "content_block_start") partial = "";
|
|
128
|
+
else if (inner.type === "content_block_delta" && inner.delta?.type === "input_json_delta" && typeof inner.delta.partial_json === "string") {
|
|
129
|
+
partial += inner.delta.partial_json;
|
|
130
|
+
try { onPartial(partial); } catch {}
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
121
134
|
if (event.type !== "result") return;
|
|
122
135
|
if (event.is_error) trouble ??= new EngineTrouble("failed", String(event.result || "Claude Code couldn't answer."));
|
|
123
136
|
else if (schema && event.structured_output !== undefined) text = JSON.stringify(event.structured_output);
|
|
@@ -156,31 +169,68 @@ function codexFailure(event) {
|
|
|
156
169
|
return nested || words;
|
|
157
170
|
}
|
|
158
171
|
|
|
172
|
+
// MARK: The OpenRouter key
|
|
173
|
+
|
|
174
|
+
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.";
|
|
175
|
+
const KEY_EMPTY = "The OpenRouter key in OPENROUTER_API_KEY has no credit left. Add credit at openrouter.ai.";
|
|
176
|
+
let keyAsked = { key: "", at: 0, problem: null };
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* What's wrong with the key in OPENROUTER_API_KEY, as OpenRouter says (as Lamp's app asks it, `openRouterVerdict`), or
|
|
180
|
+
* null: a key that's there isn't one that works (revoked, or out of credit), and `status` says so rather than offer
|
|
181
|
+
* what every call would then refuse. Asked at most every five minutes, and quickly, since a page waits on its status; when
|
|
182
|
+
* OpenRouter can't be reached in time, the key may be good.
|
|
183
|
+
*/
|
|
184
|
+
export async function keyProblem(fetcher = fetch) {
|
|
185
|
+
const key = process.env.OPENROUTER_API_KEY;
|
|
186
|
+
if (!key) return null;
|
|
187
|
+
if (keyAsked.key === key && Date.now() - keyAsked.at < 5 * 60_000) return keyAsked.problem;
|
|
188
|
+
let problem = null;
|
|
189
|
+
try {
|
|
190
|
+
const answer = await fetcher("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(4000) });
|
|
191
|
+
if (answer.status === 401 || answer.status === 403) problem = KEY_REFUSED;
|
|
192
|
+
else if (answer.status === 200) {
|
|
193
|
+
const left = (await answer.json().catch(() => ({})))?.data?.limit_remaining;
|
|
194
|
+
if (typeof left === "number" && left <= 0) problem = KEY_EMPTY;
|
|
195
|
+
} else return null;
|
|
196
|
+
} catch { return null; }
|
|
197
|
+
keyAsked = { key, at: Date.now(), problem };
|
|
198
|
+
return problem;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** What an OpenRouter failure says to the page: the key's trouble as the setting to fix, else what OpenRouter said. */
|
|
202
|
+
function openRouterWords(error) {
|
|
203
|
+
if (error?.status === 401 || error?.status === 403) return KEY_REFUSED;
|
|
204
|
+
if (error?.status === 402) return KEY_EMPTY;
|
|
205
|
+
const words = error?.reply?.error?.message;
|
|
206
|
+
return words ? String(words) : String(error?.message || "OpenRouter didn't answer.");
|
|
207
|
+
}
|
|
208
|
+
|
|
159
209
|
/** 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 }) {
|
|
210
|
+
async function viaOpenRouter({ system, prompt, schema, seconds, model, images, signal, spent }) {
|
|
161
211
|
try {
|
|
162
|
-
|
|
163
|
-
model: model || DEFAULT_MODEL, json: !!schema, schema, seconds, signal })
|
|
212
|
+
const { text, cost } = await openRouterText({ key: process.env.OPENROUTER_API_KEY, prompt, images, system: system || "You are a helpful assistant inside an app.",
|
|
213
|
+
model: model || DEFAULT_MODEL, json: !!schema, schema, seconds, signal });
|
|
214
|
+
spent?.(cost);
|
|
215
|
+
return text;
|
|
164
216
|
} catch (error) {
|
|
165
217
|
if (!(error instanceof OpenRouterTrouble)) throw error;
|
|
166
|
-
|
|
167
|
-
throw new EngineTrouble(error.kind === "refused" ? "failed" : error.kind,
|
|
218
|
+
spent?.(error.cost);
|
|
219
|
+
throw new EngineTrouble(error.kind === "refused" ? "failed" : error.kind, error.kind === "refused" ? openRouterWords(error) : error.message);
|
|
168
220
|
}
|
|
169
221
|
}
|
|
170
222
|
|
|
171
223
|
// MARK: Status and asking
|
|
172
224
|
|
|
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
|
-
};
|
|
225
|
+
/** `/ai/status`: what can answer here. Text, decisions and actions with an engine; pictures as `pictureRoute` says.
|
|
226
|
+
* What goes through OpenRouter answers only while it takes the key (`keyProblem`). */
|
|
227
|
+
export async function status(page, problem = keyProblem) {
|
|
228
|
+
const which = engine(), drawer = pictureRoute(page);
|
|
229
|
+
const broken = which === "openrouter" || drawer === "openrouter" ? await problem() : null;
|
|
230
|
+
const one = (by, reason) => !by ? { available: false, reason, setup: "lamp" }
|
|
231
|
+
: by === "openrouter" && broken ? { available: false, reason: broken, setup: "lamp" } : { available: true, route: named(by), where: "remote" };
|
|
232
|
+
const details = { text: one(which, missing), decisions: one(which, missing), act: one(which, missing), pictures: one(drawer, noPictures(page)) };
|
|
233
|
+
return { text: details.text.available, decisions: details.decisions.available, pictures: details.pictures.available, act: details.act.available, details };
|
|
184
234
|
}
|
|
185
235
|
|
|
186
236
|
// MARK: Models (`LampAI.models`, and `model` on `ask` and `act`)
|
|
@@ -281,16 +331,35 @@ const GUARD_SECONDS = 600;
|
|
|
281
331
|
const secondsFor = (body) => (typeof body?.seconds === "number" && body.seconds > 0 ? Math.min(body.seconds, GUARD_SECONDS) : GUARD_SECONDS);
|
|
282
332
|
|
|
283
333
|
/** `/ai`: a text answer, or JSON when asked for it. */
|
|
284
|
-
export async function ask(body, here = engines()) {
|
|
334
|
+
export async function ask(body, here = engines(), signal) {
|
|
285
335
|
const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : "";
|
|
286
336
|
if (!prompt) return [400, { error: "Missing prompt." }];
|
|
337
|
+
// A JSON Schema the answer must fit (`LampAI.ask`'s `schema`): every engine holds the model to it.
|
|
338
|
+
const schema = body.schema && typeof body.schema === "object" && !Array.isArray(body.schema) ? body.schema : undefined;
|
|
339
|
+
const json = !!body.json || !!schema;
|
|
287
340
|
let system = typeof body.system === "string" ? body.system : "";
|
|
288
|
-
if (
|
|
341
|
+
if (json) system += (system ? "\n\n" : "") + "Answer with JSON only, nothing around it.";
|
|
289
342
|
const { which, model } = routeFor(body.model, here);
|
|
290
|
-
|
|
291
|
-
|
|
343
|
+
let cost = 0;
|
|
344
|
+
const spent = (more) => { cost += Number(more) || 0; };
|
|
345
|
+
// One time for the whole request, its second try included, as Lamp's app gives it (`AppAI.complete`).
|
|
346
|
+
const deadline = Date.now() + secondsFor(body) * 1000;
|
|
347
|
+
const once = (asked) => complete({ system, prompt: asked, schema, images: body.images, which, model, seconds: Math.max(1, (deadline - Date.now()) / 1000), signal, spent });
|
|
348
|
+
try {
|
|
349
|
+
let text = await once(prompt);
|
|
350
|
+
// JSON that doesn't read (cut off, or with words around it) is no use to the page: asked once more, saying so.
|
|
351
|
+
if (json && !readsAsJSON(text)) {
|
|
352
|
+
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 }];
|
|
353
|
+
text = await once(`${prompt}\n\nYour previous reply was not valid JSON (it may have been cut off). Reply with the complete JSON only.`);
|
|
354
|
+
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 }];
|
|
355
|
+
}
|
|
356
|
+
return [200, { text: json ? unfenced(text) : text, cost }];
|
|
357
|
+
} catch (error) { return [502, { error: error.message, cost }]; }
|
|
292
358
|
}
|
|
293
359
|
|
|
360
|
+
const unfenced = (text) => String(text).trim().replace(/^```(?:json)?\s*|\s*```$/g, "").trim();
|
|
361
|
+
function readsAsJSON(text) { try { JSON.parse(unfenced(text)); return true; } catch { return false; } }
|
|
362
|
+
|
|
294
363
|
// MARK: Decisions
|
|
295
364
|
|
|
296
365
|
/** `/ai/decide`: each question answered by a text model, told and read as Lamp's app does it (app-ai.mjs). */
|
|
@@ -309,9 +378,34 @@ export async function decide(body) {
|
|
|
309
378
|
// MARK: Actions (`LampAI.act`)
|
|
310
379
|
|
|
311
380
|
/** `/ai/act`: one step of the page's agent loop, told and checked as Lamp's app does it (app-ai.mjs). */
|
|
381
|
+
// A step being streamed (`stream` on lamp.js's act), by its id: its calls so far, and its answer once it's done. The page
|
|
382
|
+
// asks for what's new (`follow`) until it has the answer; a step nobody follows for a few minutes is let go.
|
|
383
|
+
const live = new Map();
|
|
384
|
+
const FOLLOW_WAIT = 50_000;
|
|
385
|
+
|
|
386
|
+
/** What a streamed step has that the page hasn't (`have` calls): waits for something new, or the end, a while at most. */
|
|
387
|
+
async function following(id, have) {
|
|
388
|
+
const step = live.get(id);
|
|
389
|
+
if (!step) return [404, { error: "This step is over." }];
|
|
390
|
+
step.seen = Date.now();
|
|
391
|
+
if (step.calls.length <= have && !step.done) {
|
|
392
|
+
await new Promise((woken) => { const timer = setTimeout(woken, FOLLOW_WAIT); step.waiting.add(() => { clearTimeout(timer); woken(); }); });
|
|
393
|
+
}
|
|
394
|
+
const calls = step.calls.slice(have);
|
|
395
|
+
if (!step.done) return [200, { stream: id, more: true, calls }];
|
|
396
|
+
live.delete(id);
|
|
397
|
+
const [status, json] = step.done;
|
|
398
|
+
return [status, { ...json, stream: id, more: false, calls: [...calls, ...(Array.isArray(json.calls) ? json.calls : [])] }];
|
|
399
|
+
}
|
|
400
|
+
|
|
312
401
|
export async function act(body, here = engines()) {
|
|
313
402
|
const first = here[0] ?? null;
|
|
314
|
-
if (body?.
|
|
403
|
+
if (typeof body?.follow === "string") return following(body.follow, Math.max(0, Math.floor(Number(body.have)) || 0));
|
|
404
|
+
if (body?.probe === true) {
|
|
405
|
+
// A key OpenRouter refuses can't answer a step, as `/ai/status` says too.
|
|
406
|
+
const refused = first === "openrouter" ? await keyProblem() : null;
|
|
407
|
+
return [200, { act: !!first && !refused, route: first || "", why: refused || (first ? "" : missing) }];
|
|
408
|
+
}
|
|
315
409
|
if (!first) return [501, { error: missing }];
|
|
316
410
|
let r;
|
|
317
411
|
try { r = readStep(JSON.stringify(body ?? null)); } catch (refused) { return [refused.status ?? 400, { error: refused.message }]; }
|
|
@@ -321,26 +415,52 @@ export async function act(body, here = engines()) {
|
|
|
321
415
|
const seconds = 600;
|
|
322
416
|
const deadline = Date.now() + Math.min(r.seconds, seconds) * 1000;
|
|
323
417
|
const tag = "outside-text-" + randomBytes(4).toString("hex");
|
|
324
|
-
|
|
418
|
+
// Streamed: each call goes to the page as soon as it's whole, and the page follows the step for the rest.
|
|
419
|
+
if (body?.stream === true) {
|
|
420
|
+
const id = "st" + randomBytes(6).toString("hex");
|
|
421
|
+
const step = { calls: [], done: null, waiting: new Set(), seen: Date.now() };
|
|
422
|
+
const wake = () => { for (const woken of step.waiting) woken(); step.waiting.clear(); };
|
|
423
|
+
live.set(id, step);
|
|
424
|
+
for (const [other, kept] of live) if (Date.now() - kept.seen > 5 * 60_000) live.delete(other);
|
|
425
|
+
runStep(r, { which, model, tag, deadline, seconds, release: (call) => { step.calls.push(call); wake(); } })
|
|
426
|
+
.then((done) => { step.done = done; wake(); });
|
|
427
|
+
return following(id, 0);
|
|
428
|
+
}
|
|
429
|
+
return runStep(r, { which, model, tag, deadline, seconds });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** A step asked of its route, asked again once when its answer can't be used: [status, json]. With `release`, calls go
|
|
433
|
+
* out as they're written, and once one has gone the answer isn't asked again. */
|
|
434
|
+
async function runStep(r, { which, model, tag, deadline, seconds, release }) {
|
|
435
|
+
let retry = null, cost = 0;
|
|
325
436
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
326
437
|
const left = (deadline - Date.now()) / 1000;
|
|
327
|
-
if (left < 1) return [504, { error: "This step ran out of time." }];
|
|
438
|
+
if (left < 1) return [504, { error: "This step ran out of time.", cost }];
|
|
328
439
|
let read;
|
|
440
|
+
const reader = release ? stepReader(r, release) : null;
|
|
329
441
|
try {
|
|
330
442
|
if (which === "openrouter") {
|
|
331
443
|
const body = chatBody(r, tag, retry, model);
|
|
332
|
-
|
|
444
|
+
const answered = reader
|
|
445
|
+
? await openRouterChatStream({ key: process.env.OPENROUTER_API_KEY, body, seconds: left, onCall: (name, args) => reader.call(name, args) })
|
|
446
|
+
: await openRouterChat({ key: process.env.OPENROUTER_API_KEY, body, seconds: left });
|
|
447
|
+
cost += answered.cost;
|
|
448
|
+
read = readMessage(answered.message, r);
|
|
333
449
|
} else {
|
|
334
|
-
const { system, prompt } = planPrompt(r, tag, retry);
|
|
335
|
-
|
|
450
|
+
const { system, prompt, images } = planPrompt(r, tag, retry);
|
|
451
|
+
const text = await complete({ system, prompt, images, schema: planSchema(r), seconds: left, which, model, onPartial: reader ? (so) => reader.planned(so) : undefined });
|
|
452
|
+
reader?.planned(text);
|
|
453
|
+
read = readPlan(text, r);
|
|
336
454
|
}
|
|
455
|
+
if (reader) read = afterStream(reader, read);
|
|
337
456
|
} catch (error) {
|
|
338
|
-
|
|
339
|
-
|
|
457
|
+
cost += Number(error?.cost) || 0;
|
|
458
|
+
const words = error instanceof OpenRouterTrouble && error.kind === "refused" ? openRouterWords(error) : error?.reply?.error?.message || error.message;
|
|
459
|
+
return Date.now() >= deadline || error?.kind === "timeout" ? [504, { error: "This step ran out of time.", cost }] : [502, { error: String(words), cost }];
|
|
340
460
|
}
|
|
341
|
-
if (read.answer) return [200, { ...read.answer, route: which, seconds }];
|
|
461
|
+
if (read.answer) return [200, { ...read.answer, route: which, seconds, cost }];
|
|
342
462
|
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 }];
|
|
463
|
+
return [200, { reply: "I couldn't work out how to do that.", ask: false, route: which, seconds, trouble: read.problem, cost }];
|
|
344
464
|
}
|
|
345
465
|
return [502, { error: "The AI didn't answer." }];
|
|
346
466
|
}
|
|
@@ -404,7 +524,7 @@ export async function makeImage(body, page, app = {}) {
|
|
|
404
524
|
}
|
|
405
525
|
} catch (error) {
|
|
406
526
|
if (error?.kind === "timeout") return [504, { error: "The picture took too long to make." }];
|
|
407
|
-
return [502, { error:
|
|
527
|
+
return [502, { error: openRouterWords(error) }];
|
|
408
528
|
}
|
|
409
529
|
const type = pictureType(made);
|
|
410
530
|
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 +603,11 @@ export function codexImage({ prompt, references = [], aspectRatio, seconds = PIC
|
|
|
483
603
|
}
|
|
484
604
|
|
|
485
605
|
/** 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)];
|
|
606
|
+
* (`imageModel`), when that's known; `signal` stops an answer the page no longer waits for. */
|
|
607
|
+
export async function answer(path, body, page, app = {}, signal) {
|
|
608
|
+
if (path === "/ai/status") return [200, await status(page)];
|
|
489
609
|
if (path === "/ai/models") return [200, models(enginesFor(page))];
|
|
490
|
-
if (path === "/ai") return ask(body, enginesFor(page));
|
|
610
|
+
if (path === "/ai") return ask(body, enginesFor(page), signal);
|
|
491
611
|
if (path === "/ai/act") return act(body, enginesFor(page));
|
|
492
612
|
if (path === "/ai/decide") return decide(body);
|
|
493
613
|
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;
|
|
@@ -289,12 +315,102 @@ export function readPlan(text, r) {
|
|
|
289
315
|
return readMessage({ tool_calls: [{ function: { name: REPLY, arguments: JSON.stringify(args) } }] }, r);
|
|
290
316
|
}
|
|
291
317
|
|
|
318
|
+
// MARK: Calls as they're written
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The calls finished so far in an answer in `planSchema`'s shape that's still being written: each element of its `calls`
|
|
322
|
+
* whose closing brace has come, parsed, in order. What's after them (an element half written, the reply) waits.
|
|
323
|
+
*/
|
|
324
|
+
export function plannedCalls(text) {
|
|
325
|
+
const found = [];
|
|
326
|
+
const key = text.search(/"calls"\s*:\s*\[/);
|
|
327
|
+
if (key < 0) return found;
|
|
328
|
+
let i = text.indexOf("[", key) + 1, depth = 0, start = -1, inString = false, escaped = false;
|
|
329
|
+
for (; i < text.length; i++) {
|
|
330
|
+
const c = text[i];
|
|
331
|
+
if (inString) {
|
|
332
|
+
if (escaped) escaped = false;
|
|
333
|
+
else if (c === "\\") escaped = true;
|
|
334
|
+
else if (c === '"') inString = false;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (c === '"') inString = true;
|
|
338
|
+
else if (c === "{" || c === "[") { if (depth === 0) start = i; depth++; }
|
|
339
|
+
else if (c === "}" || c === "]") {
|
|
340
|
+
if (depth === 0) break;
|
|
341
|
+
depth--;
|
|
342
|
+
if (depth === 0 && start >= 0) {
|
|
343
|
+
try { found.push(JSON.parse(text.slice(start, i + 1))); } catch { break; }
|
|
344
|
+
start = -1;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return found;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Reads a step's calls one at a time as the model writes them (`stream` on `LampAI.act`), with the checks `readMessage`
|
|
353
|
+
* gives a whole answer: `release` gets each call that fits as soon as it's whole, with the id it would have had. One that
|
|
354
|
+
* doesn't fit is held, with why (`problem`), until one that fits comes after it, which it goes before, in its place, as
|
|
355
|
+
* a call that failed: the page never runs it, and a change that failed stops the changes after it. One left held at the
|
|
356
|
+
* end goes last when calls were released (`afterStream`), or, when none was, the whole answer is read and asked again
|
|
357
|
+
* as before. Calls past `MAX_CALLS` are left out.
|
|
358
|
+
*/
|
|
359
|
+
export function stepReader(r, release) {
|
|
360
|
+
const step = r.steps.length + 1;
|
|
361
|
+
let given = 0, seen = 0;
|
|
362
|
+
const reader = {
|
|
363
|
+
released: 0,
|
|
364
|
+
held: [],
|
|
365
|
+
/** One whole call, `args` its arguments as an object or as the JSON text the model wrote. */
|
|
366
|
+
call(name, raw) {
|
|
367
|
+
if (name === REPLY) return;
|
|
368
|
+
const id = `s${step}c${++given}`;
|
|
369
|
+
let args = raw;
|
|
370
|
+
if (typeof raw === "string") { try { args = JSON.parse(raw || "{}"); } catch { args = undefined; } }
|
|
371
|
+
const hold = (problem) => reader.held.push({ id, name: String(name), args: args && typeof args === "object" && !Array.isArray(args) ? args : {}, problem });
|
|
372
|
+
// Past the most a step holds, a call is left out: the AI sees what ran, and writes the rest next.
|
|
373
|
+
if (given > MAX_CALLS) return;
|
|
374
|
+
if (typeof name !== "string") return hold("A call had no name.");
|
|
375
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return hold(`${name}'s arguments weren't a JSON object.`);
|
|
376
|
+
if (r.final) return hold("Only `reply` is possible now.");
|
|
377
|
+
const tool = r.tools.find((t) => t.name === name);
|
|
378
|
+
if (!tool) return hold(`There's no tool called ${name}.`);
|
|
379
|
+
const bad = toolProblem(tool, args);
|
|
380
|
+
if (bad) return hold(bad);
|
|
381
|
+
reader.released++;
|
|
382
|
+
for (const held of reader.held.splice(0)) release(held);
|
|
383
|
+
release({ id, name, args });
|
|
384
|
+
},
|
|
385
|
+
/** The answer in `planSchema`'s shape as written so far: calls finished since last time are read. An optional
|
|
386
|
+
* argument left null is left out, as `readPlan` leaves it. */
|
|
387
|
+
planned(text) {
|
|
388
|
+
const found = plannedCalls(text);
|
|
389
|
+
for (const call of found.slice(seen)) {
|
|
390
|
+
const args = call?.args && typeof call.args === "object" && !Array.isArray(call.args) ? Object.fromEntries(Object.entries(call.args).filter(([, v]) => v !== null)) : call?.args;
|
|
391
|
+
reader.call(call?.name, args);
|
|
392
|
+
}
|
|
393
|
+
seen = found.length;
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
return reader;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* A streamed step's answer once the model is done: when calls were released, only what's left (the calls that didn't
|
|
401
|
+
* fit, as failures); else the whole answer read as before (`read`, from `readMessage` or `readPlan`).
|
|
402
|
+
*/
|
|
403
|
+
export function afterStream(reader, read) {
|
|
404
|
+
if (!reader.released) return read;
|
|
405
|
+
return { answer: { calls: reader.held.map((c) => ({ id: c.id, name: c.name, args: c.args, problem: c.problem })) } };
|
|
406
|
+
}
|
|
407
|
+
|
|
292
408
|
// MARK: What the model is told
|
|
293
409
|
|
|
294
410
|
const RULES = `You act for the person inside this app, only through its tools.
|
|
295
411
|
- Use a tool to look something up before acting on it, and use ids exactly as the tools gave them. Never invent an id.
|
|
296
412
|
- Change only what the person asked for. When the request is unclear (which one, who, what day or time), ask back in the reply instead of guessing.
|
|
297
|
-
- Several
|
|
413
|
+
- Several calls can go in one answer, and they run in the order you write them: look-ups side by side run together, changes run one after another, each checked right before it runs, and a change that fails stops the changes after it in that answer. A look-up after a change sees it.
|
|
298
414
|
- Never say something was done unless its result says it was. If something failed or was skipped, say so.
|
|
299
415
|
- When the request is met, or you need to ask, finish with \`reply\`: one or two short sentences that can be spoken aloud, no lists, no emoji, no markdown. Set \`ask\` to true only when the reply is a question for the person.
|
|
300
416
|
- When your question has a few short answers (yes or no, which of two or three), give them in \`choices\`: two to four, each a word or a few, plain text, in the order to offer them. Leave \`choices\` out when any answer could do.`;
|
|
@@ -336,6 +452,16 @@ export function chatBody(r, tag, retry, model) {
|
|
|
336
452
|
for (const step of r.steps) {
|
|
337
453
|
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
454
|
for (const call of step.calls) messages.push({ role: "tool", tool_call_id: call.id, content: fence(tag, json(step.results[call.id] ?? skipped)) });
|
|
455
|
+
// A tool's result can't hold a picture on every provider, so the step's pictures follow its results as the person's
|
|
456
|
+
// message, each named as its result names it.
|
|
457
|
+
if (step.pictures.length) {
|
|
458
|
+
const content = [{ type: "text", text: picturesSaid(step.pictures) }];
|
|
459
|
+
for (const picture of step.pictures.filter((p) => p.data)) {
|
|
460
|
+
content.push({ type: "text", text: `[picture ${picture.number}], from ${picture.call}:` });
|
|
461
|
+
content.push({ type: "image_url", image_url: { url: `data:${picture.media_type};base64,${picture.data}` } });
|
|
462
|
+
}
|
|
463
|
+
messages.push({ role: "user", content });
|
|
464
|
+
}
|
|
339
465
|
}
|
|
340
466
|
if (retry) messages.push({ role: "user", content: `Your last answer couldn't be used: ${retry} Answer again.` });
|
|
341
467
|
const tools = r.tools.map((tool) => ({ type: "function", function: { name: tool.name,
|
|
@@ -381,12 +507,24 @@ export function planPrompt(r, tag, retry) {
|
|
|
381
507
|
system += "\n\n" + PLAN;
|
|
382
508
|
if (r.final) system += '\nOnly a reply is possible now: answer {"reply": {...}}.';
|
|
383
509
|
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");
|
|
510
|
+
const images = [];
|
|
384
511
|
r.steps.forEach((step, i) => {
|
|
385
512
|
prompt += `\n\nYour answer ${i + 1} called:\n` + json(step.calls.map((c) => ({ name: c.name, args: c.args })));
|
|
386
513
|
prompt += "\nWhat happened, in that order:\n" + fence(tag, json(step.calls.map((c) => ({ ...(step.results[c.id] ?? skipped), name: c.name }))));
|
|
514
|
+
if (step.pictures.length) prompt += "\n" + picturesSaid(step.pictures, true);
|
|
515
|
+
for (const picture of step.pictures) if (picture.data) images.push({ media_type: picture.media_type, data: picture.data });
|
|
387
516
|
});
|
|
388
517
|
if (retry) prompt += `\n\nYour last answer couldn't be used: ${retry} Answer again.`;
|
|
389
|
-
return { system, prompt };
|
|
518
|
+
return { system, prompt, images };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** What's said of a step's pictures: which come with the message, in order, and which are no longer shown. */
|
|
522
|
+
function picturesSaid(pictures, attached = false) {
|
|
523
|
+
const named = (list) => list.map((p) => `[picture ${p.number}] (from ${p.call})`).join(", ");
|
|
524
|
+
const sent = pictures.filter((p) => p.data), gone = pictures.filter((p) => !p.data);
|
|
525
|
+
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.` : "";
|
|
526
|
+
if (gone.length) said += `${said ? " " : ""}No longer shown, as newer pictures came since: ${named(gone)}.`;
|
|
527
|
+
return said;
|
|
390
528
|
}
|
|
391
529
|
|
|
392
530
|
// MARK: Decisions (`LampAI.decide`) answered by a text model
|