@lampdotdev/lamp 0.36.2-nightly.26 → 0.36.2-nightly.29
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/GameKit/apple/LampPage.swift +1 -1
- package/Resources/skills/app-ai/SKILL.md +5 -1
- package/Resources/skills/web-app/SKILL.md +13 -1
- package/cli/ai.mjs +64 -5
- package/cli/app-ai.mjs +9 -4
- package/cli/calls.mjs +3 -3
- package/cli/lampapp.mjs +16 -3
- package/cli/lamps/android-host/LampPageGateway.kt +1 -1
- package/cli/lamps/lamp-for-games.js +84 -11
- package/cli/lamps/lamp.js +84 -11
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -1
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +104 -16
- package/cli/lamps/web-starter/lamp.js +84 -11
- package/cli/local-ai.mjs +5 -4
- package/package.json +1 -1
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).
|
|
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,71 @@ 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
|
+
|
|
215
|
+
/**
|
|
216
|
+
* How long a page's request may take: a real answer is never cut off however long the model takes, and the guard only
|
|
217
|
+
* ends a call that has stopped answering (`MAX_TURN_SECONDS` for a step of `act`, the same here). A page that wants a
|
|
218
|
+
* quicker answer asks for one (`seconds`), never a longer one.
|
|
219
|
+
*/
|
|
220
|
+
const GUARD_SECONDS = 600;
|
|
221
|
+
const secondsFor = (body) => (typeof body?.seconds === "number" && body.seconds > 0 ? Math.min(body.seconds, GUARD_SECONDS) : GUARD_SECONDS);
|
|
222
|
+
|
|
166
223
|
/** `/ai`: a text answer, or JSON when asked for it. */
|
|
167
224
|
export async function ask(body) {
|
|
168
225
|
const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : "";
|
|
169
226
|
if (!prompt) return [400, { error: "Missing prompt." }];
|
|
170
227
|
let system = typeof body.system === "string" ? body.system : "";
|
|
171
228
|
if (body.json) system += (system ? "\n\n" : "") + "Answer with JSON only, nothing around it.";
|
|
172
|
-
|
|
229
|
+
const which = engine();
|
|
230
|
+
try { return [200, { text: await complete({ system, prompt, images: body.images, which, model: modelFor(which, body.model), seconds: secondsFor(body) }) }]; }
|
|
173
231
|
catch (error) { return [502, { error: error.message }]; }
|
|
174
232
|
}
|
|
175
233
|
|
|
@@ -183,7 +241,7 @@ export async function decide(body) {
|
|
|
183
241
|
}
|
|
184
242
|
const { system, prompt, schema } = decidePrompt(body.state, questions);
|
|
185
243
|
let text;
|
|
186
|
-
try { text = await complete({ system, prompt, schema }); } catch (error) { return [502, { error: error.message }]; }
|
|
244
|
+
try { text = await complete({ system, prompt, schema, seconds: secondsFor(body) }); } catch (error) { return [502, { error: error.message }]; }
|
|
187
245
|
const read = readDecision(text, questions);
|
|
188
246
|
return read.answers ? [200, { answers: read.answers }] : [502, { error: read.problem }];
|
|
189
247
|
}
|
|
@@ -208,11 +266,11 @@ export async function act(body) {
|
|
|
208
266
|
let read;
|
|
209
267
|
try {
|
|
210
268
|
if (which === "openrouter") {
|
|
211
|
-
const body = chatBody(r, tag, retry,
|
|
269
|
+
const body = chatBody(r, tag, retry, modelFor(which, r.model));
|
|
212
270
|
read = readMessage((await openRouterChat({ key: process.env.OPENROUTER_API_KEY, body, seconds: left })).message, r);
|
|
213
271
|
} else {
|
|
214
272
|
const { system, prompt } = planPrompt(r, tag, retry);
|
|
215
|
-
read = readPlan(await complete({ system, prompt, schema: planSchema(r), seconds: left, which }), r);
|
|
273
|
+
read = readPlan(await complete({ system, prompt, schema: planSchema(r), seconds: left, which, model: modelFor(which, r.model) }), r);
|
|
216
274
|
}
|
|
217
275
|
} catch (error) {
|
|
218
276
|
const words = error?.reply?.error?.message || error.message;
|
|
@@ -228,6 +286,7 @@ export async function act(body) {
|
|
|
228
286
|
/** A page's AI request, by its route, as Lamp's app answers it: [status, JSON]. */
|
|
229
287
|
export async function answer(path, body) {
|
|
230
288
|
if (path === "/ai/status") return [200, status()];
|
|
289
|
+
if (path === "/ai/models") return [200, models()];
|
|
231
290
|
if (path === "/ai") return ask(body);
|
|
232
291
|
if (path === "/ai/act") return act(body);
|
|
233
292
|
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.
|
|
11
|
-
//
|
|
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
|
-
|
|
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/calls.mjs
CHANGED
|
@@ -373,7 +373,7 @@ export const CALLS = {
|
|
|
373
373
|
*/
|
|
374
374
|
async openRouterText(given, { stopped } = {}) {
|
|
375
375
|
return openRouter(() => openRouterText({ key: text(given, "key"), prompt: text(given, "prompt"), images: given.images, system: given.system ?? "",
|
|
376
|
-
model: text(given, "model"), json: given.json === true, schema: given.schema ?? undefined, seconds: seconds(given.seconds,
|
|
376
|
+
model: text(given, "model"), json: given.json === true, schema: given.schema ?? undefined, seconds: seconds(given.seconds, 600, 600), signal: stopped }));
|
|
377
377
|
},
|
|
378
378
|
|
|
379
379
|
/** One chat completion from OpenRouter, its request as `body` (`actAsk`'s): `{message, cost}`, or `{trouble}`. */
|
|
@@ -384,7 +384,7 @@ export const CALLS = {
|
|
|
384
384
|
/** Decisions from OpenRouter's decision model (`AppAI.decide`): `key`, `model`, `state`, `questions`. `{answers, cost}` or `{trouble}`. */
|
|
385
385
|
async openRouterDecide(given, { stopped } = {}) {
|
|
386
386
|
return openRouter(() => openRouterDecide({ key: text(given, "key"), model: text(given, "model"), state: given.state, questions: given.questions ?? {},
|
|
387
|
-
seconds: seconds(given.seconds,
|
|
387
|
+
seconds: seconds(given.seconds, 600, 600), signal: stopped }));
|
|
388
388
|
},
|
|
389
389
|
|
|
390
390
|
/** A picture from OpenRouter (`AppAI.image`): `key`, `model`, `prompt`, `references`, `aspectRatio`. `{image, cost}` or `{trouble}`. */
|
|
@@ -403,7 +403,7 @@ export const CALLS = {
|
|
|
403
403
|
return { text: await complete({ which: text(given, "engine"), tool: typeof given.tool === "string" && given.tool ? given.tool : undefined,
|
|
404
404
|
env: given.env && typeof given.env === "object" ? given.env : {}, system: typeof given.system === "string" ? given.system : "",
|
|
405
405
|
prompt: text(given, "prompt"), schema: given.schema ?? undefined, model: typeof given.model === "string" ? given.model : "",
|
|
406
|
-
images: Array.isArray(given.images) ? given.images : [], seconds: seconds(given.seconds,
|
|
406
|
+
images: Array.isArray(given.images) ? given.images : [], seconds: seconds(given.seconds, 600, 600), signal: stopped }) };
|
|
407
407
|
} catch (error) {
|
|
408
408
|
if (error instanceof EngineTrouble) return { trouble: { kind: error.kind, message: error.message } };
|
|
409
409
|
throw error;
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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.
|
|
@@ -301,6 +301,10 @@
|
|
|
301
301
|
});
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
// How long the page waits for an answer: as long as Lamp's guard on the call (10 minutes), or the page's own limit,
|
|
305
|
+
// with a little more for the answer to come back.
|
|
306
|
+
function AI_WAIT(seconds) { return (typeof seconds === 'number' && seconds > 0 ? Math.min(seconds, 600) : 600) * 1000 + 15000; }
|
|
307
|
+
|
|
304
308
|
function post(path, body, timeout, deadline) {
|
|
305
309
|
timeout = timeout || 240000;
|
|
306
310
|
var done;
|
|
@@ -485,7 +489,9 @@
|
|
|
485
489
|
return encode(o.images).then(function (images) {
|
|
486
490
|
var body = { prompt: text, system: rules.filter(function (r) { return r; }).join('\n\n'), json: !!o.json, images: images };
|
|
487
491
|
if (o.model) body.model = o.model;
|
|
488
|
-
|
|
492
|
+
// No limit of the page's own unless it asks for one: Lamp's guard ends only a call that stopped answering.
|
|
493
|
+
if (typeof o.seconds === 'number' && o.seconds > 0) body.seconds = o.seconds;
|
|
494
|
+
return post('/ai', body, AI_WAIT(body.seconds));
|
|
489
495
|
}).then(function (reply) {
|
|
490
496
|
if (typeof reply.text !== 'string') throw new Failure('The AI request failed.');
|
|
491
497
|
if (!o.json) return reply.text;
|
|
@@ -519,8 +525,12 @@
|
|
|
519
525
|
|
|
520
526
|
// 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
527
|
// 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 }`)
|
|
528
|
+
// asks for one (`limits: { seconds }`). Steps and writes are few unless the app says otherwise, so a request that
|
|
529
|
+
// goes wrong ends soon and changes little; an app that builds something real raises them (`limits: { steps,
|
|
530
|
+
// writes }`) up to ACT_LIMITS_MAX: 40 steps, as many as Lamp takes in one turn (`MAX_STEPS` in lamp's app-ai.mjs),
|
|
531
|
+
// and as many writes as 40 steps of 8 calls can hold.
|
|
523
532
|
var ACT_LIMITS = { steps: 4, writes: 6 };
|
|
533
|
+
var ACT_LIMITS_MAX = { steps: 40, writes: 320 };
|
|
524
534
|
// 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
535
|
// in one step, since paging through it costs steps a turn needs for its work. An app with more raises it
|
|
526
536
|
// (`limits: { result }`), up to ACT_RESULT_MAX.
|
|
@@ -565,8 +575,8 @@
|
|
|
565
575
|
var o = options || {}, tools = o.tools || {};
|
|
566
576
|
var limits = {};
|
|
567
577
|
['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,
|
|
578
|
+
var asked = o.limits && typeof o.limits[k] === 'number' && o.limits[k] > 0 ? Math.floor(o.limits[k]) : ACT_LIMITS[k];
|
|
579
|
+
limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS_MAX[k]));
|
|
570
580
|
});
|
|
571
581
|
var asked = o.limits && typeof o.limits.seconds === 'number' && o.limits.seconds > 0 ? o.limits.seconds : null;
|
|
572
582
|
var resultMax = o.limits && typeof o.limits.result === 'number' && o.limits.result > 0
|
|
@@ -651,7 +661,8 @@
|
|
|
651
661
|
if (bad) return fail(bad);
|
|
652
662
|
var key = 'k' + hash(id + '|' + c.name + '|' + canonical(c.args));
|
|
653
663
|
// The same change asked again in this turn gets its first result, and doesn't run again.
|
|
654
|
-
|
|
664
|
+
// Its result is in an earlier step, which every request carries: pointed to, not sent again.
|
|
665
|
+
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
666
|
if (writes >= limits.writes) return fail('Over this turn\'s limit of ' + limits.writes + ' changes.');
|
|
656
667
|
var call = { turn: id, key: key, step: step, id: c.id };
|
|
657
668
|
return Promise.resolve().then(function () { return tool.check ? tool.check(c.args, call) : null; }).then(function (problem) {
|
|
@@ -788,6 +799,11 @@
|
|
|
788
799
|
* `connect: true`: show a small "Connect AI" button beside its reason that calls `LampAI.connect(capability)` with
|
|
789
800
|
* the same kind of AI (`connect('pictures')` for pictures), and use what that gives as `status`'s answer. Never
|
|
790
801
|
* call `connect()` as the page loads.
|
|
802
|
+
*
|
|
803
|
+
* Which AI answers is the person's choice in Lamp. An app may offer them another for its own asking: `LampAI.models()`
|
|
804
|
+
* lists what their setup can answer it with now. Show a small picker beside the app's Ask button with only the
|
|
805
|
+
* available ones and `default` chosen, and send the chosen id as `model` to `ask` and `act`. Leave the picker out
|
|
806
|
+
* when there's one or none to choose from.
|
|
791
807
|
*/
|
|
792
808
|
var LampAI = {
|
|
793
809
|
Failure: Failure,
|
|
@@ -841,15 +857,70 @@
|
|
|
841
857
|
/** Whether this kind of AI can answer now, as `status` says. */
|
|
842
858
|
isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
|
|
843
859
|
|
|
860
|
+
/** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name,
|
|
861
|
+
* available, reason }], default, reason, help, connect }. `default` is the id that answers when a call names no
|
|
862
|
+
* `model`; the ids are what `ask` and `act` take as `model`, and one that can't answer when it's sent is
|
|
863
|
+
* answered as no choice, never refused. With none to offer, `models` is empty and `reason` says why; on a page
|
|
864
|
+
* shared on lamp.dev that hasn't reached this computer yet it asks nothing and answers `connect: true`, as
|
|
865
|
+
* `status` does: ask again once `connect` has. Keep the person's choice with the app's saved things, and go back
|
|
866
|
+
* to `default` when it's no longer listed as available.
|
|
867
|
+
*
|
|
868
|
+
* LampAI.models().then(function (m) {
|
|
869
|
+
* var usable = m.models.filter(function (x) { return x.available; });
|
|
870
|
+
* picker.hidden = usable.length < 2;
|
|
871
|
+
* picker.innerHTML = usable.map(function (x) { return '<option value="' + x.id + '">' + x.name + '</option>'; }).join('');
|
|
872
|
+
* picker.value = m.default;
|
|
873
|
+
* });
|
|
874
|
+
* LampAI.ask(question, { model: picker.value });
|
|
875
|
+
*/
|
|
876
|
+
models: function () {
|
|
877
|
+
var none = function (reason, help) {
|
|
878
|
+
var said = { models: [], default: null, reason: reason || 'AI isn\'t set up for this here.' };
|
|
879
|
+
if (help) said.help = help;
|
|
880
|
+
return said;
|
|
881
|
+
};
|
|
882
|
+
if (!bridge && shared()) {
|
|
883
|
+
return nearbyAllowed().then(function (allowed) {
|
|
884
|
+
if (allowed) return listed();
|
|
885
|
+
if (safari()) return none(SAFARI_AI, AI_HELP);
|
|
886
|
+
return { models: [], default: null, connect: true, reason: 'Connect to the AI on this computer to use this.', help: AI_HELP };
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
return listed();
|
|
890
|
+
function listed() {
|
|
891
|
+
return post('/ai/models', {}, 10000).then(function (reply) {
|
|
892
|
+
var models = (isList(reply.models) ? reply.models : []).filter(function (m) {
|
|
893
|
+
return m && typeof m.id === 'string' && m.id;
|
|
894
|
+
}).map(function (m) {
|
|
895
|
+
var one = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id, available: m.available === true };
|
|
896
|
+
if (!one.available && typeof m.reason === 'string') one.reason = m.reason;
|
|
897
|
+
return one;
|
|
898
|
+
});
|
|
899
|
+
var fallback = models.filter(function (m) { return m.available && m.id === reply.default; })[0];
|
|
900
|
+
if (!models.length) return none(reply.reason);
|
|
901
|
+
return { models: models, default: fallback ? fallback.id : null, reason: '' };
|
|
902
|
+
}, function (e) {
|
|
903
|
+
// A Lamp from before models were listed doesn't know the route.
|
|
904
|
+
var words = e && e.message;
|
|
905
|
+
return none(/^Not found\.?$/.test(words || '') ? 'Choosing the AI needs a newer Lamp.' : words, e && e.help);
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
|
|
844
910
|
/** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
|
|
845
911
|
* system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
|
|
846
|
-
* model (
|
|
912
|
+
* model (an id `LampAI.models()` lists, such as "sonnet", "opus", "haiku", "claude-fable-5-2"). */
|
|
847
913
|
ask: function (prompt, options) { return call(prompt, options); },
|
|
848
914
|
|
|
849
915
|
/**
|
|
850
916
|
* 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
|
|
852
|
-
*
|
|
917
|
+
* the page runs them, the model sees what happened, until it replies; Cancel ends it at once. Check
|
|
918
|
+
* `LampAI.isAvailable('act')` first; keep the app's buttons for everything. A turn takes at most 4 model calls
|
|
919
|
+
* and 6 writes and has no time limit; `limits` changes that for an app that needs it: `steps` (up to 40) and
|
|
920
|
+
* `writes` (up to 320) for one that builds something real in a turn, many changes at a time, `seconds` for a
|
|
921
|
+
* feature that should answer quickly, and `result` (bytes, 32 KB unless raised, up to 256 KB) for how much of a
|
|
922
|
+
* read the model sees. A limit that isn't a positive number keeps its default. `model` names which AI answers,
|
|
923
|
+
* as `ask`'s does; the whole turn stays on it.
|
|
853
924
|
*
|
|
854
925
|
* var turn = LampAI.act('Add milk', {
|
|
855
926
|
* about: 'A family dashboard: the calendar, chores and the shopping list.',
|
|
@@ -862,6 +933,8 @@
|
|
|
862
933
|
* run: function (args, call) { return add(args, call.key); } }
|
|
863
934
|
* },
|
|
864
935
|
* history: [{ said: 'Who did it?', heard: 'Maya' }], // the last 3 exchanges, kept by the app
|
|
936
|
+
* limits: { steps: 20, writes: 200 }, // optional: see above
|
|
937
|
+
* model: picker.value, // optional: an id from LampAI.models()
|
|
865
938
|
* onStep: function (s) { … } // { kind, name, args[, result] }
|
|
866
939
|
* });
|
|
867
940
|
* turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; });
|
|
@@ -931,7 +1004,7 @@
|
|
|
931
1004
|
},
|
|
932
1005
|
/** Asks several questions about the same thing in one request. Answers come back under the same keys. */
|
|
933
1006
|
decide: function (about, questions) {
|
|
934
|
-
return post('/ai/decide', { state: decisionState(about), questions: questions }).then(function (reply) {
|
|
1007
|
+
return post('/ai/decide', { state: decisionState(about), questions: questions }, AI_WAIT()).then(function (reply) {
|
|
935
1008
|
var answers = {};
|
|
936
1009
|
for (var key in (reply.answers || {})) if (reply.answers.hasOwnProperty(key)) answers[key] = decision(reply.answers[key]);
|
|
937
1010
|
return answers;
|
|
@@ -969,7 +1042,7 @@
|
|
|
969
1042
|
finish(i, answers);
|
|
970
1043
|
return Promise.resolve();
|
|
971
1044
|
}
|
|
972
|
-
return post('/ai/decide', { state: decisionState(state), questions: questions }).then(function (reply) {
|
|
1045
|
+
return post('/ai/decide', { state: decisionState(state), questions: questions }, AI_WAIT()).then(function (reply) {
|
|
973
1046
|
var answers = {};
|
|
974
1047
|
for (var k in (reply.answers || {})) if (reply.answers.hasOwnProperty(k)) answers[k] = decision(reply.answers[k]);
|
|
975
1048
|
if (key) saved[key] = { answers: reply.answers, until: now + o.remember * 1000 };
|
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.
|
|
@@ -301,6 +301,10 @@
|
|
|
301
301
|
});
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
// How long the page waits for an answer: as long as Lamp's guard on the call (10 minutes), or the page's own limit,
|
|
305
|
+
// with a little more for the answer to come back.
|
|
306
|
+
function AI_WAIT(seconds) { return (typeof seconds === 'number' && seconds > 0 ? Math.min(seconds, 600) : 600) * 1000 + 15000; }
|
|
307
|
+
|
|
304
308
|
function post(path, body, timeout, deadline) {
|
|
305
309
|
timeout = timeout || 240000;
|
|
306
310
|
var done;
|
|
@@ -485,7 +489,9 @@
|
|
|
485
489
|
return encode(o.images).then(function (images) {
|
|
486
490
|
var body = { prompt: text, system: rules.filter(function (r) { return r; }).join('\n\n'), json: !!o.json, images: images };
|
|
487
491
|
if (o.model) body.model = o.model;
|
|
488
|
-
|
|
492
|
+
// No limit of the page's own unless it asks for one: Lamp's guard ends only a call that stopped answering.
|
|
493
|
+
if (typeof o.seconds === 'number' && o.seconds > 0) body.seconds = o.seconds;
|
|
494
|
+
return post('/ai', body, AI_WAIT(body.seconds));
|
|
489
495
|
}).then(function (reply) {
|
|
490
496
|
if (typeof reply.text !== 'string') throw new Failure('The AI request failed.');
|
|
491
497
|
if (!o.json) return reply.text;
|
|
@@ -519,8 +525,12 @@
|
|
|
519
525
|
|
|
520
526
|
// 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
527
|
// 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 }`)
|
|
528
|
+
// asks for one (`limits: { seconds }`). Steps and writes are few unless the app says otherwise, so a request that
|
|
529
|
+
// goes wrong ends soon and changes little; an app that builds something real raises them (`limits: { steps,
|
|
530
|
+
// writes }`) up to ACT_LIMITS_MAX: 40 steps, as many as Lamp takes in one turn (`MAX_STEPS` in lamp's app-ai.mjs),
|
|
531
|
+
// and as many writes as 40 steps of 8 calls can hold.
|
|
523
532
|
var ACT_LIMITS = { steps: 4, writes: 6 };
|
|
533
|
+
var ACT_LIMITS_MAX = { steps: 40, writes: 320 };
|
|
524
534
|
// 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
535
|
// in one step, since paging through it costs steps a turn needs for its work. An app with more raises it
|
|
526
536
|
// (`limits: { result }`), up to ACT_RESULT_MAX.
|
|
@@ -565,8 +575,8 @@
|
|
|
565
575
|
var o = options || {}, tools = o.tools || {};
|
|
566
576
|
var limits = {};
|
|
567
577
|
['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,
|
|
578
|
+
var asked = o.limits && typeof o.limits[k] === 'number' && o.limits[k] > 0 ? Math.floor(o.limits[k]) : ACT_LIMITS[k];
|
|
579
|
+
limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS_MAX[k]));
|
|
570
580
|
});
|
|
571
581
|
var asked = o.limits && typeof o.limits.seconds === 'number' && o.limits.seconds > 0 ? o.limits.seconds : null;
|
|
572
582
|
var resultMax = o.limits && typeof o.limits.result === 'number' && o.limits.result > 0
|
|
@@ -651,7 +661,8 @@
|
|
|
651
661
|
if (bad) return fail(bad);
|
|
652
662
|
var key = 'k' + hash(id + '|' + c.name + '|' + canonical(c.args));
|
|
653
663
|
// The same change asked again in this turn gets its first result, and doesn't run again.
|
|
654
|
-
|
|
664
|
+
// Its result is in an earlier step, which every request carries: pointed to, not sent again.
|
|
665
|
+
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
666
|
if (writes >= limits.writes) return fail('Over this turn\'s limit of ' + limits.writes + ' changes.');
|
|
656
667
|
var call = { turn: id, key: key, step: step, id: c.id };
|
|
657
668
|
return Promise.resolve().then(function () { return tool.check ? tool.check(c.args, call) : null; }).then(function (problem) {
|
|
@@ -788,6 +799,11 @@
|
|
|
788
799
|
* `connect: true`: show a small "Connect AI" button beside its reason that calls `LampAI.connect(capability)` with
|
|
789
800
|
* the same kind of AI (`connect('pictures')` for pictures), and use what that gives as `status`'s answer. Never
|
|
790
801
|
* call `connect()` as the page loads.
|
|
802
|
+
*
|
|
803
|
+
* Which AI answers is the person's choice in Lamp. An app may offer them another for its own asking: `LampAI.models()`
|
|
804
|
+
* lists what their setup can answer it with now. Show a small picker beside the app's Ask button with only the
|
|
805
|
+
* available ones and `default` chosen, and send the chosen id as `model` to `ask` and `act`. Leave the picker out
|
|
806
|
+
* when there's one or none to choose from.
|
|
791
807
|
*/
|
|
792
808
|
var LampAI = {
|
|
793
809
|
Failure: Failure,
|
|
@@ -841,15 +857,70 @@
|
|
|
841
857
|
/** Whether this kind of AI can answer now, as `status` says. */
|
|
842
858
|
isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
|
|
843
859
|
|
|
860
|
+
/** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name,
|
|
861
|
+
* available, reason }], default, reason, help, connect }. `default` is the id that answers when a call names no
|
|
862
|
+
* `model`; the ids are what `ask` and `act` take as `model`, and one that can't answer when it's sent is
|
|
863
|
+
* answered as no choice, never refused. With none to offer, `models` is empty and `reason` says why; on a page
|
|
864
|
+
* shared on lamp.dev that hasn't reached this computer yet it asks nothing and answers `connect: true`, as
|
|
865
|
+
* `status` does: ask again once `connect` has. Keep the person's choice with the app's saved things, and go back
|
|
866
|
+
* to `default` when it's no longer listed as available.
|
|
867
|
+
*
|
|
868
|
+
* LampAI.models().then(function (m) {
|
|
869
|
+
* var usable = m.models.filter(function (x) { return x.available; });
|
|
870
|
+
* picker.hidden = usable.length < 2;
|
|
871
|
+
* picker.innerHTML = usable.map(function (x) { return '<option value="' + x.id + '">' + x.name + '</option>'; }).join('');
|
|
872
|
+
* picker.value = m.default;
|
|
873
|
+
* });
|
|
874
|
+
* LampAI.ask(question, { model: picker.value });
|
|
875
|
+
*/
|
|
876
|
+
models: function () {
|
|
877
|
+
var none = function (reason, help) {
|
|
878
|
+
var said = { models: [], default: null, reason: reason || 'AI isn\'t set up for this here.' };
|
|
879
|
+
if (help) said.help = help;
|
|
880
|
+
return said;
|
|
881
|
+
};
|
|
882
|
+
if (!bridge && shared()) {
|
|
883
|
+
return nearbyAllowed().then(function (allowed) {
|
|
884
|
+
if (allowed) return listed();
|
|
885
|
+
if (safari()) return none(SAFARI_AI, AI_HELP);
|
|
886
|
+
return { models: [], default: null, connect: true, reason: 'Connect to the AI on this computer to use this.', help: AI_HELP };
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
return listed();
|
|
890
|
+
function listed() {
|
|
891
|
+
return post('/ai/models', {}, 10000).then(function (reply) {
|
|
892
|
+
var models = (isList(reply.models) ? reply.models : []).filter(function (m) {
|
|
893
|
+
return m && typeof m.id === 'string' && m.id;
|
|
894
|
+
}).map(function (m) {
|
|
895
|
+
var one = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id, available: m.available === true };
|
|
896
|
+
if (!one.available && typeof m.reason === 'string') one.reason = m.reason;
|
|
897
|
+
return one;
|
|
898
|
+
});
|
|
899
|
+
var fallback = models.filter(function (m) { return m.available && m.id === reply.default; })[0];
|
|
900
|
+
if (!models.length) return none(reply.reason);
|
|
901
|
+
return { models: models, default: fallback ? fallback.id : null, reason: '' };
|
|
902
|
+
}, function (e) {
|
|
903
|
+
// A Lamp from before models were listed doesn't know the route.
|
|
904
|
+
var words = e && e.message;
|
|
905
|
+
return none(/^Not found\.?$/.test(words || '') ? 'Choosing the AI needs a newer Lamp.' : words, e && e.help);
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
|
|
844
910
|
/** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
|
|
845
911
|
* system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
|
|
846
|
-
* model (
|
|
912
|
+
* model (an id `LampAI.models()` lists, such as "sonnet", "opus", "haiku", "claude-fable-5-2"). */
|
|
847
913
|
ask: function (prompt, options) { return call(prompt, options); },
|
|
848
914
|
|
|
849
915
|
/**
|
|
850
916
|
* 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
|
|
852
|
-
*
|
|
917
|
+
* the page runs them, the model sees what happened, until it replies; Cancel ends it at once. Check
|
|
918
|
+
* `LampAI.isAvailable('act')` first; keep the app's buttons for everything. A turn takes at most 4 model calls
|
|
919
|
+
* and 6 writes and has no time limit; `limits` changes that for an app that needs it: `steps` (up to 40) and
|
|
920
|
+
* `writes` (up to 320) for one that builds something real in a turn, many changes at a time, `seconds` for a
|
|
921
|
+
* feature that should answer quickly, and `result` (bytes, 32 KB unless raised, up to 256 KB) for how much of a
|
|
922
|
+
* read the model sees. A limit that isn't a positive number keeps its default. `model` names which AI answers,
|
|
923
|
+
* as `ask`'s does; the whole turn stays on it.
|
|
853
924
|
*
|
|
854
925
|
* var turn = LampAI.act('Add milk', {
|
|
855
926
|
* about: 'A family dashboard: the calendar, chores and the shopping list.',
|
|
@@ -862,6 +933,8 @@
|
|
|
862
933
|
* run: function (args, call) { return add(args, call.key); } }
|
|
863
934
|
* },
|
|
864
935
|
* history: [{ said: 'Who did it?', heard: 'Maya' }], // the last 3 exchanges, kept by the app
|
|
936
|
+
* limits: { steps: 20, writes: 200 }, // optional: see above
|
|
937
|
+
* model: picker.value, // optional: an id from LampAI.models()
|
|
865
938
|
* onStep: function (s) { … } // { kind, name, args[, result] }
|
|
866
939
|
* });
|
|
867
940
|
* turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; });
|
|
@@ -931,7 +1004,7 @@
|
|
|
931
1004
|
},
|
|
932
1005
|
/** Asks several questions about the same thing in one request. Answers come back under the same keys. */
|
|
933
1006
|
decide: function (about, questions) {
|
|
934
|
-
return post('/ai/decide', { state: decisionState(about), questions: questions }).then(function (reply) {
|
|
1007
|
+
return post('/ai/decide', { state: decisionState(about), questions: questions }, AI_WAIT()).then(function (reply) {
|
|
935
1008
|
var answers = {};
|
|
936
1009
|
for (var key in (reply.answers || {})) if (reply.answers.hasOwnProperty(key)) answers[key] = decision(reply.answers[key]);
|
|
937
1010
|
return answers;
|
|
@@ -969,7 +1042,7 @@
|
|
|
969
1042
|
finish(i, answers);
|
|
970
1043
|
return Promise.resolve();
|
|
971
1044
|
}
|
|
972
|
-
return post('/ai/decide', { state: decisionState(state), questions: questions }).then(function (reply) {
|
|
1045
|
+
return post('/ai/decide', { state: decisionState(state), questions: questions }, AI_WAIT()).then(function (reply) {
|
|
973
1046
|
var answers = {};
|
|
974
1047
|
for (var k in (reply.answers || {})) if (reply.answers.hasOwnProperty(k)) answers[k] = decision(reply.answers[k]);
|
|
975
1048
|
if (key) saved[key] = { answers: reply.answers, until: now + o.remember * 1000 };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"
|
|
1
|
+
{"name":"Zqx App Name","instance":"1F28D505-8715-4018-8ADA-A146F6FEB632","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
|
|
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)
|
|
@@ -11289,7 +11299,7 @@
|
|
|
11289
11299
|
let reply = try await post("/ai/decide", [
|
|
11290
11300
|
"state": try decisionState(state),
|
|
11291
11301
|
"questions": questions.mapValues(\.json),
|
|
11292
|
-
])
|
|
11302
|
+
], timeout: 615)
|
|
11293
11303
|
let answers = reply["answers"] as? [String: [String: Any]] ?? [:]
|
|
11294
11304
|
return answers.mapValues(LampDecision.init)
|
|
11295
11305
|
}
|
|
@@ -11417,7 +11427,7 @@
|
|
|
11417
11427
|
group.addTask {
|
|
11418
11428
|
try Task.checkCancellation()
|
|
11419
11429
|
do {
|
|
11420
|
-
let reply = try await post("/ai/decide", body: request.body)
|
|
11430
|
+
let reply = try await post("/ai/decide", body: request.body, timeout: 615)
|
|
11421
11431
|
guard let answers = reply["answers"] as? [String: Any], !answers.isEmpty else {
|
|
11422
11432
|
return Outcome(index: request.index, error: "The decision model didn't answer.")
|
|
11423
11433
|
}
|
|
@@ -11590,7 +11600,8 @@
|
|
|
11590
11600
|
"images": images.compactMap(encode),
|
|
11591
11601
|
]
|
|
11592
11602
|
if let model { body["model"] = model }
|
|
11593
|
-
|
|
11603
|
+
// As long as Lamp's guard on the call: a real answer is never cut off, however long the model takes.
|
|
11604
|
+
let reply = try await post("/ai", body, timeout: 615)
|
|
11594
11605
|
guard let text = reply["text"] as? String else { throw Failure(message: "The AI request failed.") }
|
|
11595
11606
|
return text
|
|
11596
11607
|
}
|
|
@@ -11677,6 +11688,27 @@
|
|
|
11677
11688
|
|
|
11678
11689
|
// MARK: - Doing things for the person
|
|
11679
11690
|
|
|
11691
|
+
/// A model an app may offer the person (`LampAI.models()`): `id` is what `ask(model:)` takes, `name` what to show.
|
|
11692
|
+
struct LampAIModel: Identifiable, Equatable {
|
|
11693
|
+
let id: String
|
|
11694
|
+
let name: String
|
|
11695
|
+
/// Whether it can answer now; one that can't isn't worth offering.
|
|
11696
|
+
let available: Bool
|
|
11697
|
+
/// Why it can't, when it can't.
|
|
11698
|
+
let reason: String?
|
|
11699
|
+
}
|
|
11700
|
+
|
|
11701
|
+
/// What the person's setup can answer an app with now (`LampAI.models()`).
|
|
11702
|
+
struct LampAIModels {
|
|
11703
|
+
var models: [LampAIModel] = []
|
|
11704
|
+
/// The one that answers a call naming no model; nil when nothing can answer.
|
|
11705
|
+
var defaultID: String?
|
|
11706
|
+
/// Why there's nothing to choose from, when there isn't.
|
|
11707
|
+
var reason: String?
|
|
11708
|
+
/// The ones to show in a picker.
|
|
11709
|
+
var available: [LampAIModel] { models.filter(\.available) }
|
|
11710
|
+
}
|
|
11711
|
+
|
|
11680
11712
|
/// How one kind of AI stands for this app now, as Lamp sees it (`LampAI.status`).
|
|
11681
11713
|
struct LampAIStatus {
|
|
11682
11714
|
/// Where the AI works out its answer, for telling the person where their words go. A Claude Code or Codex
|
|
@@ -11732,12 +11764,41 @@
|
|
|
11732
11764
|
}
|
|
11733
11765
|
}
|
|
11734
11766
|
|
|
11767
|
+
/// The models the person's setup can answer this app with now, asked of no model, so it's cheap to call as a
|
|
11768
|
+
/// view appears. The ids are what `ask(model:)` and `LampActSession(model:)` take. Keep the person's choice with
|
|
11769
|
+
/// the app's saved things, and go back to `defaultID` when it's no longer listed as available.
|
|
11770
|
+
///
|
|
11771
|
+
/// let choices = await LampAI.models()
|
|
11772
|
+
/// if choices.available.count > 1 {
|
|
11773
|
+
/// Picker("AI", selection: $model) { ForEach(choices.available) { Text($0.name).tag(Optional($0.id)) } }
|
|
11774
|
+
/// }
|
|
11775
|
+
/// // model starts as choices.defaultID; then: try await LampAI.ask(question, model: model)
|
|
11776
|
+
static func models() async -> LampAIModels {
|
|
11777
|
+
let reply: [String: Any]
|
|
11778
|
+
do { reply = try await post("/ai/models", [:], timeout: 10) } catch {
|
|
11779
|
+
// A Lamp from before models were listed doesn't know the route.
|
|
11780
|
+
let words = (error as? Failure)?.message ?? ""
|
|
11781
|
+
return LampAIModels(reason: words.hasPrefix("Not found") ? "Choosing the AI needs a newer Lamp."
|
|
11782
|
+
: (words.isEmpty ? "Lamp isn't open, or can't be reached from here." : words))
|
|
11783
|
+
}
|
|
11784
|
+
let models = (reply["models"] as? [[String: Any]] ?? []).compactMap { m -> LampAIModel? in
|
|
11785
|
+
guard let id = m["id"] as? String, !id.isEmpty else { return nil }
|
|
11786
|
+
let available = m["available"] as? Bool ?? false
|
|
11787
|
+
return LampAIModel(id: id, name: (m["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? id, available: available,
|
|
11788
|
+
reason: available ? nil : m["reason"] as? String)
|
|
11789
|
+
}
|
|
11790
|
+
let fallback = reply["default"] as? String
|
|
11791
|
+
return LampAIModels(models: models, defaultID: models.contains { $0.id == fallback && $0.available } ? fallback : nil,
|
|
11792
|
+
reason: models.isEmpty ? (reply["reason"] as? String ?? "AI isn't set up in Lamp yet.") : nil)
|
|
11793
|
+
}
|
|
11794
|
+
|
|
11735
11795
|
/// 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
11796
|
/// checked writes over its data, chosen by the AI, run by the app. `LampActSession` has the details; this starts a
|
|
11737
11797
|
/// request in `session`, or in a session of its own when there's none to keep (no questions back can be answered then).
|
|
11798
|
+
/// `model` is for that session of its own, as `LampActSession(model:)` takes it.
|
|
11738
11799
|
@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)
|
|
11800
|
+
session: LampActSession? = nil, model: String? = nil, onStep: ((LampActStep) -> Void)? = nil) -> LampActTurn {
|
|
11801
|
+
(session ?? LampActSession(about: about, tools: tools, model: model)).ask(request, context: context, onStep: onStep)
|
|
11741
11802
|
}
|
|
11742
11803
|
|
|
11743
11804
|
fileprivate static var actPresent: String { present }
|
|
@@ -11993,13 +12054,22 @@
|
|
|
11993
12054
|
/// its `run` makes it. Reads run first, then changes one at a time, each checked right before it runs; a change that
|
|
11994
12055
|
/// fails skips the rest of that step. The same change asked twice in a request runs once. A request may take 4 AI
|
|
11995
12056
|
/// 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.
|
|
11997
|
-
/// (`
|
|
12057
|
+
/// (`seconds:`): a real answer is never cut off. An app that builds something real in one request raises `steps:`
|
|
12058
|
+
/// (up to 40) and `writes:` (up to 320); a value that isn't positive keeps the default. `model:` names which AI
|
|
12059
|
+
/// answers, an id from `LampAI.models()`. Each step stops only when a call has stopped answering (`stepSeconds`).
|
|
12060
|
+
/// Asking something new, cancelling or closing ends it.
|
|
11998
12061
|
@MainActor final class LampActSession {
|
|
12062
|
+
/// A request's steps and changes unless the app raises them: few, so a request that goes wrong ends soon and
|
|
12063
|
+
/// changes little.
|
|
11999
12064
|
static let maxSteps = 4
|
|
12000
12065
|
static let maxWrites = 6
|
|
12066
|
+
/// 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),
|
|
12067
|
+
/// and as many changes as that many steps of 8 calls can hold (`ACT_LIMITS_MAX` in lamp.js).
|
|
12068
|
+
static let mostSteps = 40
|
|
12069
|
+
static let mostWrites = 320
|
|
12001
12070
|
static let maxQuestions = 3
|
|
12002
12071
|
/// What a session keeps of its changes; past this it stops taking changes and says so, rather than forget them.
|
|
12072
|
+
/// A session whose requests may make more keeps ten requests' worth (`receiptLimit`).
|
|
12003
12073
|
static let maxReceipts = 60
|
|
12004
12074
|
/// How long one step may take before its call is taken to have stopped answering.
|
|
12005
12075
|
static let stepSeconds: Double = 600
|
|
@@ -12034,16 +12104,28 @@
|
|
|
12034
12104
|
/// 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
12105
|
/// through it costs steps a request needs for its work.
|
|
12036
12106
|
let resultBytes: Int
|
|
12107
|
+
/// The AI steps a request may take between questions, and the changes it may make in all.
|
|
12108
|
+
let stepLimit: Int
|
|
12109
|
+
let writeLimit: Int
|
|
12110
|
+
/// Which AI answers, by an id `LampAI.models()` lists; nil for the app's own.
|
|
12111
|
+
let model: String?
|
|
12112
|
+
private var receiptLimit: Int { max(Self.maxReceipts, writeLimit * 10) }
|
|
12037
12113
|
|
|
12038
12114
|
/// `seconds` limits each request's time, for a feature that should answer quickly; leave it out for no limit.
|
|
12039
12115
|
/// `resultBytes` raises how much of a read the AI sees (32 KB unless given, 256 KB at most).
|
|
12116
|
+
/// `steps` and `writes` raise a request's AI steps (4 unless given, 40 at most) and changes (6, 320 at most).
|
|
12117
|
+
/// `model` names which AI answers, an id from `LampAI.models()`; leave it out for the app's own.
|
|
12040
12118
|
/// `send` stands in for Lamp in a test; leave it out.
|
|
12041
12119
|
init(about: String = "", tools: [LampActTool], seconds: Double? = nil, resultBytes: Int? = nil,
|
|
12120
|
+
steps: Int? = nil, writes: Int? = nil, model: String? = nil,
|
|
12042
12121
|
send: (([String: Any], TimeInterval) async throws -> [String: Any])? = nil) {
|
|
12043
12122
|
self.about = about
|
|
12044
12123
|
self.tools = tools
|
|
12045
12124
|
self.seconds = seconds.map { max(1, $0) }
|
|
12046
12125
|
self.resultBytes = min(max(resultBytes ?? 32_768, 1024), 262_144)
|
|
12126
|
+
self.stepLimit = min(steps.flatMap { $0 > 0 ? $0 : nil } ?? Self.maxSteps, Self.mostSteps)
|
|
12127
|
+
self.writeLimit = min(writes.flatMap { $0 > 0 ? $0 : nil } ?? Self.maxWrites, Self.mostWrites)
|
|
12128
|
+
self.model = model.flatMap { $0.isEmpty ? nil : $0 }
|
|
12047
12129
|
self.send = send ?? { body, timeout in try await LampAI.post("/ai/act", body, timeout: timeout) }
|
|
12048
12130
|
}
|
|
12049
12131
|
|
|
@@ -12127,7 +12209,8 @@
|
|
|
12127
12209
|
"tools": tools.map(\.declared), "steps": steps, "seconds": Int(left)]
|
|
12128
12210
|
if let context { body["context"] = context }
|
|
12129
12211
|
if let route { body["route"] = route }
|
|
12130
|
-
if
|
|
12212
|
+
if let model { body["model"] = model }
|
|
12213
|
+
if steps.count >= stepLimit - 1 { body["final"] = true }
|
|
12131
12214
|
onStep(.thinking)
|
|
12132
12215
|
let answer: [String: Any]
|
|
12133
12216
|
do { answer = try await send(body, left) } catch {
|
|
@@ -12154,7 +12237,7 @@
|
|
|
12154
12237
|
return results[id] ?? ["id": id, "ok": false, "skipped": "Not run."]
|
|
12155
12238
|
}])
|
|
12156
12239
|
if out.stopped != nil { break }
|
|
12157
|
-
if steps.count >=
|
|
12240
|
+
if steps.count >= stepLimit { out.stopped = .steps; break }
|
|
12158
12241
|
continue
|
|
12159
12242
|
}
|
|
12160
12243
|
out.reply = answer["reply"] as? String ?? ""
|
|
@@ -12220,18 +12303,19 @@
|
|
|
12220
12303
|
if let stopped { miss(stopped, skipped: true); continue }
|
|
12221
12304
|
if let bad = tool.problem(given) { miss(bad); continue }
|
|
12222
12305
|
let key = Self.prefix(op) + Self.hash(tool.name + "|" + Self.canonical(given))
|
|
12223
|
-
// The same change asked again in this request
|
|
12306
|
+
// The same change asked again in this request is pointed to its first result, and doesn't run again: one that
|
|
12224
12307
|
// failed while it ran may have done part of it, so it isn't tried again by itself either.
|
|
12225
12308
|
if let kept = ran[key] {
|
|
12226
12309
|
if let failed = kept as? [String: Any], let reason = failed["failedWhileRunning"] as? String {
|
|
12227
12310
|
miss("Already tried in this request, and it didn't go through: \(reason)")
|
|
12228
12311
|
} else {
|
|
12229
|
-
|
|
12312
|
+
// Its result is in an earlier step, which every request carries: pointed to, not sent again.
|
|
12313
|
+
results[id] = ["id": id, "ok": true, "repeat": true, "value": ["note": "Already done in this request: its result is in an earlier step."]]
|
|
12230
12314
|
}
|
|
12231
12315
|
continue
|
|
12232
12316
|
}
|
|
12233
|
-
if op.writes >=
|
|
12234
|
-
if receipts.count >=
|
|
12317
|
+
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 }
|
|
12318
|
+
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
12319
|
let info = callInfo(call, key: key)
|
|
12236
12320
|
let problem: String?
|
|
12237
12321
|
do { problem = try await tool.check?(given, info) } catch { problem = Self.words(error) }
|
|
@@ -12245,11 +12329,15 @@
|
|
|
12245
12329
|
// Done is done, even if the turn was cancelled while it ran: it's kept and shown.
|
|
12246
12330
|
var value = receipt.details.filter { JSONSerialization.isValidJSONObject([$0.value]) }
|
|
12247
12331
|
value["summary"] = receipt.summary
|
|
12248
|
-
|
|
12332
|
+
// What the AI is shown of it draws on the request's allowance as a read does, since every later
|
|
12333
|
+
// step carries it again; the receipt keeps it whole for the app.
|
|
12334
|
+
let shown = Self.cut(value, max: Swift.max(1024, Swift.min(resultBytes, op.resultsLeft)))
|
|
12335
|
+
op.resultsLeft = Swift.max(0, op.resultsLeft - ((try? JSONSerialization.data(withJSONObject: [shown]))?.count ?? 0))
|
|
12336
|
+
ran[key] = shown
|
|
12249
12337
|
let done = LampActDone(tool: tool.name, key: key, receipt: receipt)
|
|
12250
12338
|
out.done.append(done)
|
|
12251
12339
|
receipts.append(done)
|
|
12252
|
-
results[id] = ["id": id, "ok": true, "value":
|
|
12340
|
+
results[id] = ["id": id, "ok": true, "value": shown]
|
|
12253
12341
|
onStep(.changed(done))
|
|
12254
12342
|
} catch {
|
|
12255
12343
|
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.
|
|
@@ -301,6 +301,10 @@
|
|
|
301
301
|
});
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
// How long the page waits for an answer: as long as Lamp's guard on the call (10 minutes), or the page's own limit,
|
|
305
|
+
// with a little more for the answer to come back.
|
|
306
|
+
function AI_WAIT(seconds) { return (typeof seconds === 'number' && seconds > 0 ? Math.min(seconds, 600) : 600) * 1000 + 15000; }
|
|
307
|
+
|
|
304
308
|
function post(path, body, timeout, deadline) {
|
|
305
309
|
timeout = timeout || 240000;
|
|
306
310
|
var done;
|
|
@@ -485,7 +489,9 @@
|
|
|
485
489
|
return encode(o.images).then(function (images) {
|
|
486
490
|
var body = { prompt: text, system: rules.filter(function (r) { return r; }).join('\n\n'), json: !!o.json, images: images };
|
|
487
491
|
if (o.model) body.model = o.model;
|
|
488
|
-
|
|
492
|
+
// No limit of the page's own unless it asks for one: Lamp's guard ends only a call that stopped answering.
|
|
493
|
+
if (typeof o.seconds === 'number' && o.seconds > 0) body.seconds = o.seconds;
|
|
494
|
+
return post('/ai', body, AI_WAIT(body.seconds));
|
|
489
495
|
}).then(function (reply) {
|
|
490
496
|
if (typeof reply.text !== 'string') throw new Failure('The AI request failed.');
|
|
491
497
|
if (!o.json) return reply.text;
|
|
@@ -519,8 +525,12 @@
|
|
|
519
525
|
|
|
520
526
|
// 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
527
|
// 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 }`)
|
|
528
|
+
// asks for one (`limits: { seconds }`). Steps and writes are few unless the app says otherwise, so a request that
|
|
529
|
+
// goes wrong ends soon and changes little; an app that builds something real raises them (`limits: { steps,
|
|
530
|
+
// writes }`) up to ACT_LIMITS_MAX: 40 steps, as many as Lamp takes in one turn (`MAX_STEPS` in lamp's app-ai.mjs),
|
|
531
|
+
// and as many writes as 40 steps of 8 calls can hold.
|
|
523
532
|
var ACT_LIMITS = { steps: 4, writes: 6 };
|
|
533
|
+
var ACT_LIMITS_MAX = { steps: 40, writes: 320 };
|
|
524
534
|
// 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
535
|
// in one step, since paging through it costs steps a turn needs for its work. An app with more raises it
|
|
526
536
|
// (`limits: { result }`), up to ACT_RESULT_MAX.
|
|
@@ -565,8 +575,8 @@
|
|
|
565
575
|
var o = options || {}, tools = o.tools || {};
|
|
566
576
|
var limits = {};
|
|
567
577
|
['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,
|
|
578
|
+
var asked = o.limits && typeof o.limits[k] === 'number' && o.limits[k] > 0 ? Math.floor(o.limits[k]) : ACT_LIMITS[k];
|
|
579
|
+
limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS_MAX[k]));
|
|
570
580
|
});
|
|
571
581
|
var asked = o.limits && typeof o.limits.seconds === 'number' && o.limits.seconds > 0 ? o.limits.seconds : null;
|
|
572
582
|
var resultMax = o.limits && typeof o.limits.result === 'number' && o.limits.result > 0
|
|
@@ -651,7 +661,8 @@
|
|
|
651
661
|
if (bad) return fail(bad);
|
|
652
662
|
var key = 'k' + hash(id + '|' + c.name + '|' + canonical(c.args));
|
|
653
663
|
// The same change asked again in this turn gets its first result, and doesn't run again.
|
|
654
|
-
|
|
664
|
+
// Its result is in an earlier step, which every request carries: pointed to, not sent again.
|
|
665
|
+
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
666
|
if (writes >= limits.writes) return fail('Over this turn\'s limit of ' + limits.writes + ' changes.');
|
|
656
667
|
var call = { turn: id, key: key, step: step, id: c.id };
|
|
657
668
|
return Promise.resolve().then(function () { return tool.check ? tool.check(c.args, call) : null; }).then(function (problem) {
|
|
@@ -788,6 +799,11 @@
|
|
|
788
799
|
* `connect: true`: show a small "Connect AI" button beside its reason that calls `LampAI.connect(capability)` with
|
|
789
800
|
* the same kind of AI (`connect('pictures')` for pictures), and use what that gives as `status`'s answer. Never
|
|
790
801
|
* call `connect()` as the page loads.
|
|
802
|
+
*
|
|
803
|
+
* Which AI answers is the person's choice in Lamp. An app may offer them another for its own asking: `LampAI.models()`
|
|
804
|
+
* lists what their setup can answer it with now. Show a small picker beside the app's Ask button with only the
|
|
805
|
+
* available ones and `default` chosen, and send the chosen id as `model` to `ask` and `act`. Leave the picker out
|
|
806
|
+
* when there's one or none to choose from.
|
|
791
807
|
*/
|
|
792
808
|
var LampAI = {
|
|
793
809
|
Failure: Failure,
|
|
@@ -841,15 +857,70 @@
|
|
|
841
857
|
/** Whether this kind of AI can answer now, as `status` says. */
|
|
842
858
|
isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
|
|
843
859
|
|
|
860
|
+
/** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name,
|
|
861
|
+
* available, reason }], default, reason, help, connect }. `default` is the id that answers when a call names no
|
|
862
|
+
* `model`; the ids are what `ask` and `act` take as `model`, and one that can't answer when it's sent is
|
|
863
|
+
* answered as no choice, never refused. With none to offer, `models` is empty and `reason` says why; on a page
|
|
864
|
+
* shared on lamp.dev that hasn't reached this computer yet it asks nothing and answers `connect: true`, as
|
|
865
|
+
* `status` does: ask again once `connect` has. Keep the person's choice with the app's saved things, and go back
|
|
866
|
+
* to `default` when it's no longer listed as available.
|
|
867
|
+
*
|
|
868
|
+
* LampAI.models().then(function (m) {
|
|
869
|
+
* var usable = m.models.filter(function (x) { return x.available; });
|
|
870
|
+
* picker.hidden = usable.length < 2;
|
|
871
|
+
* picker.innerHTML = usable.map(function (x) { return '<option value="' + x.id + '">' + x.name + '</option>'; }).join('');
|
|
872
|
+
* picker.value = m.default;
|
|
873
|
+
* });
|
|
874
|
+
* LampAI.ask(question, { model: picker.value });
|
|
875
|
+
*/
|
|
876
|
+
models: function () {
|
|
877
|
+
var none = function (reason, help) {
|
|
878
|
+
var said = { models: [], default: null, reason: reason || 'AI isn\'t set up for this here.' };
|
|
879
|
+
if (help) said.help = help;
|
|
880
|
+
return said;
|
|
881
|
+
};
|
|
882
|
+
if (!bridge && shared()) {
|
|
883
|
+
return nearbyAllowed().then(function (allowed) {
|
|
884
|
+
if (allowed) return listed();
|
|
885
|
+
if (safari()) return none(SAFARI_AI, AI_HELP);
|
|
886
|
+
return { models: [], default: null, connect: true, reason: 'Connect to the AI on this computer to use this.', help: AI_HELP };
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
return listed();
|
|
890
|
+
function listed() {
|
|
891
|
+
return post('/ai/models', {}, 10000).then(function (reply) {
|
|
892
|
+
var models = (isList(reply.models) ? reply.models : []).filter(function (m) {
|
|
893
|
+
return m && typeof m.id === 'string' && m.id;
|
|
894
|
+
}).map(function (m) {
|
|
895
|
+
var one = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id, available: m.available === true };
|
|
896
|
+
if (!one.available && typeof m.reason === 'string') one.reason = m.reason;
|
|
897
|
+
return one;
|
|
898
|
+
});
|
|
899
|
+
var fallback = models.filter(function (m) { return m.available && m.id === reply.default; })[0];
|
|
900
|
+
if (!models.length) return none(reply.reason);
|
|
901
|
+
return { models: models, default: fallback ? fallback.id : null, reason: '' };
|
|
902
|
+
}, function (e) {
|
|
903
|
+
// A Lamp from before models were listed doesn't know the route.
|
|
904
|
+
var words = e && e.message;
|
|
905
|
+
return none(/^Not found\.?$/.test(words || '') ? 'Choosing the AI needs a newer Lamp.' : words, e && e.help);
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
|
|
844
910
|
/** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
|
|
845
911
|
* system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
|
|
846
|
-
* model (
|
|
912
|
+
* model (an id `LampAI.models()` lists, such as "sonnet", "opus", "haiku", "claude-fable-5-2"). */
|
|
847
913
|
ask: function (prompt, options) { return call(prompt, options); },
|
|
848
914
|
|
|
849
915
|
/**
|
|
850
916
|
* 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
|
|
852
|
-
*
|
|
917
|
+
* the page runs them, the model sees what happened, until it replies; Cancel ends it at once. Check
|
|
918
|
+
* `LampAI.isAvailable('act')` first; keep the app's buttons for everything. A turn takes at most 4 model calls
|
|
919
|
+
* and 6 writes and has no time limit; `limits` changes that for an app that needs it: `steps` (up to 40) and
|
|
920
|
+
* `writes` (up to 320) for one that builds something real in a turn, many changes at a time, `seconds` for a
|
|
921
|
+
* feature that should answer quickly, and `result` (bytes, 32 KB unless raised, up to 256 KB) for how much of a
|
|
922
|
+
* read the model sees. A limit that isn't a positive number keeps its default. `model` names which AI answers,
|
|
923
|
+
* as `ask`'s does; the whole turn stays on it.
|
|
853
924
|
*
|
|
854
925
|
* var turn = LampAI.act('Add milk', {
|
|
855
926
|
* about: 'A family dashboard: the calendar, chores and the shopping list.',
|
|
@@ -862,6 +933,8 @@
|
|
|
862
933
|
* run: function (args, call) { return add(args, call.key); } }
|
|
863
934
|
* },
|
|
864
935
|
* history: [{ said: 'Who did it?', heard: 'Maya' }], // the last 3 exchanges, kept by the app
|
|
936
|
+
* limits: { steps: 20, writes: 200 }, // optional: see above
|
|
937
|
+
* model: picker.value, // optional: an id from LampAI.models()
|
|
865
938
|
* onStep: function (s) { … } // { kind, name, args[, result] }
|
|
866
939
|
* });
|
|
867
940
|
* turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; });
|
|
@@ -931,7 +1004,7 @@
|
|
|
931
1004
|
},
|
|
932
1005
|
/** Asks several questions about the same thing in one request. Answers come back under the same keys. */
|
|
933
1006
|
decide: function (about, questions) {
|
|
934
|
-
return post('/ai/decide', { state: decisionState(about), questions: questions }).then(function (reply) {
|
|
1007
|
+
return post('/ai/decide', { state: decisionState(about), questions: questions }, AI_WAIT()).then(function (reply) {
|
|
935
1008
|
var answers = {};
|
|
936
1009
|
for (var key in (reply.answers || {})) if (reply.answers.hasOwnProperty(key)) answers[key] = decision(reply.answers[key]);
|
|
937
1010
|
return answers;
|
|
@@ -969,7 +1042,7 @@
|
|
|
969
1042
|
finish(i, answers);
|
|
970
1043
|
return Promise.resolve();
|
|
971
1044
|
}
|
|
972
|
-
return post('/ai/decide', { state: decisionState(state), questions: questions }).then(function (reply) {
|
|
1045
|
+
return post('/ai/decide', { state: decisionState(state), questions: questions }, AI_WAIT()).then(function (reply) {
|
|
973
1046
|
var answers = {};
|
|
974
1047
|
for (var k in (reply.answers || {})) if (reply.answers.hasOwnProperty(k)) answers[k] = decision(reply.answers[k]);
|
|
975
1048
|
if (key) saved[key] = { answers: reply.answers, until: now + o.remember * 1000 };
|
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
|
|
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,
|
|
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];
|