@lampdotdev/lamp 0.36.2-nightly.31 → 0.36.2-nightly.35
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 +2 -2
- package/Resources/skills/app-ai/SKILL.md +2 -1
- package/Resources/skills/web-app/SKILL.md +3 -1
- package/cli/ai.mjs +97 -40
- package/cli/calls.mjs +9 -2
- package/cli/ignore.mjs +2 -0
- package/cli/lampapp.mjs +28 -0
- package/cli/lamps/lamp-for-games.js +29 -9
- package/cli/lamps/lamp.js +29 -9
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -1
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +30 -8
- package/cli/lamps/web-starter/lamp.js +29 -9
- package/cli/share.mjs +4 -1
- package/cli/skill/SKILL.md +2 -1
- package/package.json +1 -1
- package/site/lib/shared-wishes.js +50 -5
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Mac app, `projectOf`), or any folder with an `index.html`; without one, the fold
|
|
|
26
26
|
| `lamp use key w 2` | Tries it as a person would, then looks: `click`, `type`, `key` (several at once with `w+d`), `drag`, `scroll`, `wait`, and in a game on Lamp's runtime `stick` and `button`, which hold Lamp's own controller as Lamp's app does (`window.__lampDrive`) and give it back when the person plays. Several moves with `then` between them go one after another, with one look at the end. |
|
|
27
27
|
| `lamp reload [--fresh]` | Opens the page again from its files, a game from where the player is, or with `--fresh` from its beginning (`window.lampFreshStart`). |
|
|
28
28
|
| `lamp stop` | Stops running it. |
|
|
29
|
-
| `lamp share --words "…"` | Puts the page on lamp.dev: anyone with the link plays it there and can send an idea. Again, it sends the page as it is now to the same link. What the folder's `.lampignore` names stays out (`cli/ignore.mjs`: gitignore's simpler half, one pattern a line, `*`, `?`, `**`, a trailing `/` for a folder, no `!`; the app's own file counts for a page in Web/ too), so a page whose data is too big for a link shares from its own folder. The app's icon goes with it (`iconOf`: a square `icon.png` or `apple-touch-icon.png` beside the page, a PNG its index.html names as its icon, or Lamp's `.lamp/icon.png`, made 512 pixels a side when bigger), for its page and the browser's tab. |
|
|
29
|
+
| `lamp share --words "…"` | Puts the page on lamp.dev: anyone with the link plays it there and can send an idea. Again, it sends the page as it is now to the same link. What the folder's `.lampignore` names stays out (`cli/ignore.mjs`: gitignore's simpler half, one pattern a line, `*`, `?`, `**`, a trailing `/` for a folder, no `!`; the app's own file counts for a page in Web/ too; Lamp's app follows it when it shares, `LampIgnore`), so a page whose data is too big for a link shares from its own folder. The app's icon goes with it (`iconOf`: a square `icon.png` or `apple-touch-icon.png` beside the page, a PNG its index.html names as its icon, or Lamp's `.lamp/icon.png`, made 512 pixels a side when bigger), for its page and the browser's tab. The copies on the site it took changes from (`lamp take`'s credits, and the ones Lamp's app brought in, `.lamp/pulled-in.json`; `takenLinksOf`) go as `with`, so its page says "with changes from" them. |
|
|
30
30
|
| `lamp share` (a page of Lamp's) | A web app or game in a Lamp's own folder is lamp's to share like any other page, Lamp open or not, to the link that Lamp keeps for it: lamp takes the link from the Lamp's `Wish Links.json` (by the app's instance id in `.lamp/meta.json`, `lampsKeptLink`) the first time, and tells it what went after each share or `unshare` (`keepInLamp`), so its Share Link card shows the link as it is. `ideas` and `unshare` take the link the same way. |
|
|
31
31
|
| `lamp share` (any other app of Lamp's) | For a folder in Lamp's own (or a link to one) that lamp doesn't share as a page, shared by Lamp's app over its socket (`share_link`, `OutsideWork`): its Share Link card filled as it fills itself (`WishLinkCards.prepare`: the link's last words, the questions and what's new Lamp writes, the app's file, the GitHub release) and shared without its window, with `--line`, `--by`, `--asks`, `--notes`, `--no-file`, `--no-github`, `--put-on-github` in place of the card's own, and `--preview` to see it first. The link's secret stays in Lamp. Which Lamp is the one whose folder it is (`lampOwning`), or the one it was given to (`lamp open --in-lamp`, `givenTo`); any other folder is lamp's to share, whatever Lamp is running, and so is a given folder when its Lamp is too old to share for lamp. |
|
|
32
32
|
| `lamp ideas [in <n> \| dismiss <n> \| reply <n> \| done <n> \| email <address\|off>] [note]` | The ideas people playing the link have sent and what's been said since, saying what became of one, and answering whoever sent it. |
|
|
@@ -35,7 +35,7 @@ Mac app, `projectOf`), or any folder with an `index.html`; without one, the fold
|
|
|
35
35
|
| `lamp play <link> [--to folder] [--newest]` | Plays an app shared on lamp.dev on this computer: the link's file (`latestOf`, its `.lampapp`), unpacked as `lamp unpack` does into a folder of its own in `~/Lamp Games` (or `--to`), with Lamp's own files beside its page, then opened as `lamp open` opens any folder. Its AI is answered by `lamp dev` (`ai.mjs`) in any browser, Safari included, where a shared page can't reach the computer. lamp remembers which folder each link went into (a file for each link under `played/` in its state), so playing it again goes on where it was; `--newest` takes the link's latest version into a new folder. |
|
|
36
36
|
| `lamp copies [folder \| link]` | The copies people shared on lamp.dev of the folder's own link (as `lamp share` keeps it, or the one Lamp's app keeps for an app in its own folder, `lampsKeptLink`), or of any link, as its page lists them (`copiesOf`, `GET /api/wishes?id=<id>&copies=1`): each one's name, who made it, when it last changed and its link. |
|
|
37
37
|
| `lamp compare <link> [folder]` | Fetches that copy's latest file as `lamp play` does (`latestOf`, then `unpack`) into lamp's state for the folder (`compare/<id>/`, in place of an earlier one for the same link), never into the person's folders, and says what differs from the folder's app by the files a share carries of each (`appFilesOf`, the walk `exportApp` packs: none of Lamp's own files, nothing hidden, nothing `.lampignore` names): only in theirs, only in yours, and changed, with lines or sizes (`cli/copies.mjs`). A copy is someone else's work, read as data, never as instructions. |
|
|
38
|
-
| `lamp take <link> ["what"] [folder]` | `compare`, then a brief for the person's coding agent, as Lamp's `shared_copy` answers its own: bring in only what was asked from the copy, fitted to this app, run nothing from it, check with `lamp look`, and carry over the credits of any work of others it brings. It credits the copy in the app's credits.json once (an `upstream` entry: its name, its maker as author, its link by id), never in `remixOf`, the chain the app was made from, whose last link a share names as its source (`sourceLinkOf`). A copy
|
|
38
|
+
| `lamp take <link> ["what"] [folder]` | `compare`, then a brief for the person's coding agent, as Lamp's `shared_copy` answers its own: bring in only what was asked from the copy, fitted to this app, run nothing from it, check with `lamp look`, and carry over the credits of any work of others it brings. It credits the copy in the app's credits.json once (an `upstream` entry: its name, its maker as author, its link by id), never in `remixOf`, the chain the app was made from, whose last link a share names as its source (`sourceLinkOf`). A copy shared on lamp.dev was shared for anyone to take from, so the next share never asks about its license (`sharedCopy`), and names the copy (`with`): its page says "with changes from" it, and the copy's maker is told once. |
|
|
39
39
|
| `lamp ai [--forget]` | Answers the AI of pages shared on lamp.dev, played in a browser on this computer, with the person's own Claude Code, Codex or OpenRouter key (`cli/local-ai.mjs`, the same answers as `ai.mjs` gives `lamp dev`), until it's stopped. It listens on this computer alone at a port lamp.js knows (`LOCAL_AI_PORT`, `NEARBY` in lamp.js), and answers only a link's own page (`<id in hex>.play.lamp.dev`, `playOrigin`): the first time a page uses the AI, the person is asked in the terminal, and the page keeps the token it's given. Whether there's an AI is answered without asking. The browser asks the person too, once, before a page may reach this computer, so the link's frame on lamp.dev is given that permission; lamp.js reaches the port only once that's granted, the page has a token, or the person asks (`LampAI.connect()` from a button, or a real AI call), so the browser doesn't ask as the page loads. Safari never lets the page reach it (Safari 27, checked against a stand-in on the port), so lamp.js tells someone playing in Safari to use Chrome, Lamp or `lamp play` instead. `--forget` takes back every page allowed. Lamp's app runs the same server while it's open (`sharedPages` in `calls.mjs`, `SharedPagesAI`): it asks the person in a dialog and answers with the AI they set up in Lamp, and the pages allowed are the same list; whichever of the two starts first answers. |
|
|
40
40
|
| `lamp devices [add \| forget <name>]` | The person's devices lamp puts a page on: an Xbox in Developer Mode (those set up and those found on the network), and Android (lamp's emulators, and the phones, tablets and TVs plugged in with USB debugging on). `add` sets one up, a step at a time; `add android` downloads Android's tools. |
|
|
41
41
|
| `lamp open --on xbox` | Packages the page, installs it on the device and opens it there. `lamp look --on xbox` is the device's screen and the app's log there, `lamp stop --on xbox` ends it, and `lamp remove --on xbox` takes it off. |
|
|
@@ -73,7 +73,8 @@ back, the route's time), cancellation, questions back and the receipts. An app t
|
|
|
73
73
|
request raises `steps:` (up to 40) and `writes:` (up to 320) on its session. Where the person might want another AI for
|
|
74
74
|
the app's asking, `await LampAI.models()` lists what their setup can answer it with: a small Picker beside the Ask
|
|
75
75
|
button with only the `available` ones and `defaultID` chosen, left out when there's one or none, its id passed as
|
|
76
|
-
`model:` to `ask` and the session.
|
|
76
|
+
`model:` to `ask` and the session. Each choice shows its `model` (the model's short name, or `name` when an older Lamp
|
|
77
|
+
sends none), in a `Section` per `via?.name` only when more than one is available, with `name` as its `.help`.
|
|
77
78
|
|
|
78
79
|
```swift
|
|
79
80
|
struct Rename: Decodable { let id: String; let name: String }
|
|
@@ -108,7 +108,9 @@ whichever AI the person set up for apps' text, so don't tie it to voice: typing
|
|
|
108
108
|
that both fit.
|
|
109
109
|
- Where the person might want another AI for the app's asking, `LampAI.models()` lists what their setup can answer it
|
|
110
110
|
with: a small picker beside the Ask button with only the `available` ones and `default` chosen, left out when
|
|
111
|
-
there's one or none; the chosen id goes as `model` to `act` and `ask`.
|
|
111
|
+
there's one or none; the chosen id goes as `model` to `act` and `ask`. Each option shows the model's short `model`
|
|
112
|
+
(`name` when an older Lamp sends no `model`), with `name` as its `title`, under an `<optgroup>` per `via.name` only
|
|
113
|
+
when the available ones come through more than one; keep the list's order. Keep the choice with the app's saved things,
|
|
112
114
|
and go back to `default` when it's no longer listed. On a page shared on lamp.dev it answers `connect: true` until
|
|
113
115
|
the person connects, as `status` does.
|
|
114
116
|
|
package/cli/ai.mjs
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
// same code (app-ai.mjs, which Lamp's app has do it too), so a page can't tell which answered it. Pictures and
|
|
5
5
|
// hearing need Lamp's app: asked here, they say so.
|
|
6
6
|
|
|
7
|
-
import { spawn
|
|
8
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { accessSync, constants, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
10
|
+
import { delimiter, join } from "node:path";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
12
|
import { chatBody, decidePrompt, planPrompt, planSchema, readDecision, readMessage, readPlan, readStep } from "./app-ai.mjs";
|
|
13
13
|
import { DEFAULT_MODEL, OpenRouterTrouble, chat as openRouterChat, text as openRouterText } from "./openrouter.mjs";
|
|
@@ -18,11 +18,26 @@ import { DEFAULT_MODEL, OpenRouterTrouble, chat as openRouterChat, text as openR
|
|
|
18
18
|
* the person's key in OPENROUTER_API_KEY.
|
|
19
19
|
*/
|
|
20
20
|
export function engine() {
|
|
21
|
+
return engines()[0] ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every engine that can answer here, what answers first: only `LAMP_AI`'s when it's set. */
|
|
25
|
+
export function engines() {
|
|
21
26
|
const wanted = process.env.LAMP_AI;
|
|
22
|
-
|
|
23
|
-
if (name === "openrouter")
|
|
24
|
-
if (name !== "claude" && name !== "codex")
|
|
25
|
-
|
|
27
|
+
return (wanted ? [wanted] : ["claude", "codex", "openrouter"]).filter((name) => {
|
|
28
|
+
if (name === "openrouter") return !!process.env.OPENROUTER_API_KEY;
|
|
29
|
+
if (name !== "claude" && name !== "codex") return false;
|
|
30
|
+
return !!onPath(name);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Where a program is on the PATH this process has now, looked up here rather than by asking `which`, whose answer
|
|
35
|
+
* can differ from this process's own PATH under Bun. */
|
|
36
|
+
function onPath(name) {
|
|
37
|
+
for (const folder of String(process.env.PATH || "").split(delimiter)) {
|
|
38
|
+
if (!folder) continue;
|
|
39
|
+
const path = join(folder, name);
|
|
40
|
+
try { accessSync(path, constants.X_OK); if (statSync(path).isFile()) return path; } catch {}
|
|
26
41
|
}
|
|
27
42
|
return null;
|
|
28
43
|
}
|
|
@@ -88,7 +103,7 @@ export function complete({ system, prompt, schema, seconds = 60, which = engine(
|
|
|
88
103
|
const childEnv = { ...process.env, ...env };
|
|
89
104
|
delete childEnv.BUN_BE_BUN;
|
|
90
105
|
return new Promise((resolve, reject) => {
|
|
91
|
-
const child = spawn(tool || which, args, { cwd: folder, env: childEnv, detached: true, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
106
|
+
const child = spawn(tool || onPath(which) || which, args, { cwd: folder, env: childEnv, detached: true, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
92
107
|
let text = "", trouble = null, rest = "", said = "";
|
|
93
108
|
// The whole group, since an engine may leave a child holding its output.
|
|
94
109
|
const stop = (kind, words) => { if (!trouble) trouble = new EngineTrouble(kind, words); try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } };
|
|
@@ -176,19 +191,32 @@ const APP_MODELS = [
|
|
|
176
191
|
/** Codex's models aren't known here without running it, so it's offered as one choice, by the id Lamp's app gives it then. */
|
|
177
192
|
const CODEX = "engine:codex";
|
|
178
193
|
|
|
179
|
-
/**
|
|
180
|
-
|
|
194
|
+
/** How a page names a model through each engine, as Lamp's app does (`TextRoute.appID`), and what answers it
|
|
195
|
+
* (`via`). A bare id ("opus") is that model through the engine that answers first. */
|
|
196
|
+
const PREFIX = { claude: "engine:claude:", codex: CODEX, openrouter: "openrouter:" };
|
|
197
|
+
const via = (which) => ({ id: which === "claude" ? "claude-code" : which, name: named(which) });
|
|
198
|
+
|
|
199
|
+
/** One of the listed models by its bare id. */
|
|
200
|
+
const listed = (asked) => APP_MODELS.find((m) => m.id === String(asked || ""));
|
|
201
|
+
|
|
202
|
+
/** A model `LAMP_AI_MODEL` names that isn't one of Lamp's: what a page naming none gets from the engine that answers
|
|
203
|
+
* first, so it's listed too. */
|
|
204
|
+
function unlisted() {
|
|
205
|
+
const wanted = process.env.LAMP_AI_MODEL || "";
|
|
206
|
+
return wanted && !APP_MODELS.some((m) => m.id === wanted || m.openRouter === wanted) ? wanted : "";
|
|
207
|
+
}
|
|
181
208
|
|
|
182
|
-
/** The model's id a page gets when it names none: `LAMP_AI_MODEL` when it names
|
|
183
|
-
* answers apps (never Haiku unasked). */
|
|
209
|
+
/** The model's id a page gets when it names none: `LAMP_AI_MODEL` when it names one, else Sonnet, as Lamp's app
|
|
210
|
+
* answers apps (never Haiku unasked), through the engine that answers first. */
|
|
184
211
|
function defaultId(which) {
|
|
185
212
|
if (which === "codex") return CODEX;
|
|
186
213
|
const wanted = process.env.LAMP_AI_MODEL || "";
|
|
187
|
-
|
|
214
|
+
const model = unlisted() || (APP_MODELS.find((m) => m.id === wanted || m.openRouter === wanted) ?? listed("sonnet")).id;
|
|
215
|
+
return PREFIX[which] + model;
|
|
188
216
|
}
|
|
189
217
|
|
|
190
|
-
/** What `complete` is told to run
|
|
191
|
-
* refused. A `LAMP_AI_MODEL` that isn't listed still answers what names none, as before. */
|
|
218
|
+
/** What `complete` is told to run on `which` for a bare model id: an unknown one is answered as one that named none,
|
|
219
|
+
* never refused. A `LAMP_AI_MODEL` that isn't listed still answers what names none, as before. */
|
|
192
220
|
export function modelFor(which, asked) {
|
|
193
221
|
const model = listed(asked);
|
|
194
222
|
if (which === "codex") return process.env.LAMP_AI_MODEL || "";
|
|
@@ -197,19 +225,46 @@ export function modelFor(which, asked) {
|
|
|
197
225
|
return which === "openrouter" ? DEFAULT_MODEL : "sonnet";
|
|
198
226
|
}
|
|
199
227
|
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
228
|
+
/** The engine and model a page's `model` goes to (`ask` and `act`): an id with its engine ("engine:claude:opus",
|
|
229
|
+
* "openrouter:opus", "engine:codex") is that engine when it can answer here; a bare id ("opus") is that model through
|
|
230
|
+
* the engine that answers first. One that can't answer is answered as one that named none, never refused: a model
|
|
231
|
+
* this doesn't list included, and any Codex model but its default, since Codex's own list isn't known here.
|
|
232
|
+
* `here` is the engines the page may use (`enginesFor`). */
|
|
233
|
+
export function routeFor(asked, here = engines()) {
|
|
234
|
+
const first = here[0] ?? null;
|
|
235
|
+
const id = String(asked || "");
|
|
236
|
+
for (const which of here) {
|
|
237
|
+
if (which === "codex") {
|
|
238
|
+
if (id === CODEX) return { which, model: which === first ? process.env.LAMP_AI_MODEL || "" : "" };
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (!id.startsWith(PREFIX[which])) continue;
|
|
242
|
+
const model = id.slice(PREFIX[which].length);
|
|
243
|
+
if (listed(model)) return { which, model: modelFor(which, model) };
|
|
244
|
+
if (which === first && model === unlisted()) return { which, model };
|
|
245
|
+
break;
|
|
211
246
|
}
|
|
212
|
-
|
|
247
|
+
const bare = id.includes(":") ? "" : id;
|
|
248
|
+
return { which: first, model: first ? modelFor(first, bare) : "" };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The engines a request may use: every one here for the person's own page (`lamp dev`), only the one that answers
|
|
252
|
+
* first for a page shared on lamp.dev (`page`, from `serveLocalAI`), the one the person was asked to let it use. */
|
|
253
|
+
export const enginesFor = (page) => (page ? engines().slice(0, 1) : engines());
|
|
254
|
+
|
|
255
|
+
/** `/ai/models`: what a page can offer to choose from here, asked of nothing. The engine that answers first comes
|
|
256
|
+
* first, then the others, each with its most capable model first. */
|
|
257
|
+
export function models(here = engines()) {
|
|
258
|
+
if (!here.length) return { models: [], default: null, reason: missing };
|
|
259
|
+
const list = here.flatMap((which) => {
|
|
260
|
+
if (which === "codex") return [{ id: CODEX, name: "ChatGPT's default model via Codex", model: "ChatGPT's default model", via: via(which), available: true }];
|
|
261
|
+
const one = (id, model) => ({ id: PREFIX[which] + id, name: `${model} via ${named(which)}`, model, via: via(which), available: true });
|
|
262
|
+
// A model LAMP_AI_MODEL names that isn't one of Lamp's is what a page naming none gets: it's offered too, first,
|
|
263
|
+
// so choosing the default answers the same as choosing nothing.
|
|
264
|
+
const extra = which === here[0] && unlisted() ? [one(unlisted(), unlisted())] : [];
|
|
265
|
+
return [...extra, ...APP_MODELS.map((m) => one(m.id, m.name))];
|
|
266
|
+
});
|
|
267
|
+
return { models: list, default: defaultId(here[0]) };
|
|
213
268
|
}
|
|
214
269
|
|
|
215
270
|
/**
|
|
@@ -221,13 +276,13 @@ const GUARD_SECONDS = 600;
|
|
|
221
276
|
const secondsFor = (body) => (typeof body?.seconds === "number" && body.seconds > 0 ? Math.min(body.seconds, GUARD_SECONDS) : GUARD_SECONDS);
|
|
222
277
|
|
|
223
278
|
/** `/ai`: a text answer, or JSON when asked for it. */
|
|
224
|
-
export async function ask(body) {
|
|
279
|
+
export async function ask(body, here = engines()) {
|
|
225
280
|
const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : "";
|
|
226
281
|
if (!prompt) return [400, { error: "Missing prompt." }];
|
|
227
282
|
let system = typeof body.system === "string" ? body.system : "";
|
|
228
283
|
if (body.json) system += (system ? "\n\n" : "") + "Answer with JSON only, nothing around it.";
|
|
229
|
-
const which =
|
|
230
|
-
try { return [200, { text: await complete({ system, prompt, images: body.images, which, model
|
|
284
|
+
const { which, model } = routeFor(body.model, here);
|
|
285
|
+
try { return [200, { text: await complete({ system, prompt, images: body.images, which, model, seconds: secondsFor(body) }) }]; }
|
|
231
286
|
catch (error) { return [502, { error: error.message }]; }
|
|
232
287
|
}
|
|
233
288
|
|
|
@@ -249,12 +304,14 @@ export async function decide(body) {
|
|
|
249
304
|
// MARK: Actions (`LampAI.act`)
|
|
250
305
|
|
|
251
306
|
/** `/ai/act`: one step of the page's agent loop, told and checked as Lamp's app does it (app-ai.mjs). */
|
|
252
|
-
export async function act(body) {
|
|
253
|
-
const
|
|
254
|
-
if (body?.probe === true) return [200, { act: !!
|
|
255
|
-
if (!
|
|
307
|
+
export async function act(body, here = engines()) {
|
|
308
|
+
const first = here[0] ?? null;
|
|
309
|
+
if (body?.probe === true) return [200, { act: !!first, route: first || "", why: first ? "" : missing }];
|
|
310
|
+
if (!first) return [501, { error: missing }];
|
|
256
311
|
let r;
|
|
257
312
|
try { r = readStep(JSON.stringify(body ?? null)); } catch (refused) { return [refused.status ?? 400, { error: refused.message }]; }
|
|
313
|
+
// The model the page named, through its own engine when that can answer here; else what answers first.
|
|
314
|
+
const { which, model } = routeFor(r.model, here);
|
|
258
315
|
// A step's guard against a call that has stopped answering, as Lamp's app gives it (`AppAct.turnSeconds`).
|
|
259
316
|
const seconds = 600;
|
|
260
317
|
const deadline = Date.now() + Math.min(r.seconds, seconds) * 1000;
|
|
@@ -266,11 +323,11 @@ export async function act(body) {
|
|
|
266
323
|
let read;
|
|
267
324
|
try {
|
|
268
325
|
if (which === "openrouter") {
|
|
269
|
-
const body = chatBody(r, tag, retry,
|
|
326
|
+
const body = chatBody(r, tag, retry, model);
|
|
270
327
|
read = readMessage((await openRouterChat({ key: process.env.OPENROUTER_API_KEY, body, seconds: left })).message, r);
|
|
271
328
|
} else {
|
|
272
329
|
const { system, prompt } = planPrompt(r, tag, retry);
|
|
273
|
-
read = readPlan(await complete({ system, prompt, schema: planSchema(r), seconds: left, which, model
|
|
330
|
+
read = readPlan(await complete({ system, prompt, schema: planSchema(r), seconds: left, which, model }), r);
|
|
274
331
|
}
|
|
275
332
|
} catch (error) {
|
|
276
333
|
const words = error?.reply?.error?.message || error.message;
|
|
@@ -284,11 +341,11 @@ export async function act(body) {
|
|
|
284
341
|
}
|
|
285
342
|
|
|
286
343
|
/** A page's AI request, by its route, as Lamp's app answers it: [status, JSON]. */
|
|
287
|
-
export async function answer(path, body) {
|
|
344
|
+
export async function answer(path, body, page) {
|
|
288
345
|
if (path === "/ai/status") return [200, status()];
|
|
289
|
-
if (path === "/ai/models") return [200, models()];
|
|
290
|
-
if (path === "/ai") return ask(body);
|
|
291
|
-
if (path === "/ai/act") return act(body);
|
|
346
|
+
if (path === "/ai/models") return [200, models(enginesFor(page))];
|
|
347
|
+
if (path === "/ai") return ask(body, enginesFor(page));
|
|
348
|
+
if (path === "/ai/act") return act(body, enginesFor(page));
|
|
292
349
|
if (path === "/ai/decide") return decide(body);
|
|
293
350
|
if (path === "/ai/hear") return body?.probe === true ? [200, { hear: false, why: "Hearing needs Lamp's app." }] : [501, { error: "Hearing needs Lamp's app: open the app in Lamp." }];
|
|
294
351
|
if (path === "/ai/image") return [501, { error: "Pictures need Lamp's app: open the app in Lamp.", needs: "pictures" }];
|
package/cli/calls.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { Away, OffNetwork, Refused, Rejected, Runner, StillWorking, answersOn, b
|
|
|
25
25
|
import { adbCommand, connectTo, deviceListing, deviceProperties, displaySizeOf, dumpIn, installApk, installTools, launchApp, makeEmulator, pairWith, processIn, processOf,
|
|
26
26
|
rowsOf, saveState, screenItems, screenshotOf, tellApp, toolsThere, useLamps } from "./android.mjs";
|
|
27
27
|
import { accountFor, keepAccount } from "./share.mjs";
|
|
28
|
-
import { Unreachable, accountName, askAccount, ideasOf, latestOf, pageUp, makerOf, publishLink, removeFile, removeIdea, removeLink, replyIdea, sendFile } from "./link.mjs";
|
|
28
|
+
import { Unreachable, accountName, askAccount, copiesOf, ideasOf, latestOf, pageUp, makerOf, publishLink, removeFile, removeIdea, removeLink, replyIdea, sendFile } from "./link.mjs";
|
|
29
29
|
import { defaultRoute, homeNetwork, neighbours, samsungAt, subnetOn, tvsIn } from "./network.mjs";
|
|
30
30
|
import { PUBLISHER, identityName, keptSigner, namesOf, packageOf, versionAfter } from "./msix.mjs";
|
|
31
31
|
import { tile } from "./png.mjs";
|
|
@@ -211,7 +211,14 @@ export const CALLS = {
|
|
|
211
211
|
|
|
212
212
|
/** What a followed link has now, asked with nothing of the person's; `latest` null when it can't be asked or is gone. */
|
|
213
213
|
async linkLatest(given) {
|
|
214
|
-
|
|
214
|
+
// By the link's id, or by its maker's username and its name there (`lamp.dev/<user>/<app>`).
|
|
215
|
+
const named = typeof given?.user === "string" && typeof given?.app === "string";
|
|
216
|
+
return { latest: await latestOf(named ? { site: siteOf(given), user: given.user, app: given.app } : { site: siteOf(given), id: text(given, "id") }) };
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
/** The copies people shared of a link, newest first, as its page lists them: asked with nothing of the person's. */
|
|
220
|
+
async linkCopies(given) {
|
|
221
|
+
return { copies: await copiesOf({ site: siteOf(given), id: text(given, "id") }) };
|
|
215
222
|
},
|
|
216
223
|
|
|
217
224
|
/** The link's file (or with `page`, its playable page), from a file of Lamp's at `file`, sent in parts. */
|
package/cli/ignore.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// from elsewhere when it isn't there). The patterns are gitignore's simpler half: one a line, `#` for a note, `*`, `?` and
|
|
4
4
|
// `**`, a trailing `/` for a folder, a leading `/` (or any `/` inside) for a path from the folder's top, and otherwise a
|
|
5
5
|
// name matched at any depth. Nothing starting with `!`: what's left out stays out.
|
|
6
|
+
// Lamp's app shares the same folders and reads them by these rules too (`LampIgnore`, which a test holds to this file):
|
|
7
|
+
// a change here is made there in the same change.
|
|
6
8
|
|
|
7
9
|
import { readFileSync } from "node:fs";
|
|
8
10
|
import { join, relative, sep } from "node:path";
|
package/cli/lampapp.mjs
CHANGED
|
@@ -402,6 +402,34 @@ export function sourceLinkOf(folder, site, own = null) {
|
|
|
402
402
|
return null;
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
/**
|
|
406
|
+
* The ids of the links on `site` whose copies the app took changes from, which a share of its own names (`with`) so its
|
|
407
|
+
* page says "with changes from" them: each `upstream` credit there by its link (`lamp take`, by id, with a name after it
|
|
408
|
+
* or not), and each copy Lamp's app brought into a saved version (`shared_copy`, `.lamp/pulled-in.json`, which writes
|
|
409
|
+
* lamp.dev's address whichever site it fetched from). Never the app's own link (`own`). The link it was made from
|
|
410
|
+
* (`sourceLinkOf`) is named only when changes were taken from it again later, which is what such a credit says.
|
|
411
|
+
*/
|
|
412
|
+
export function takenLinksOf(folder, site, own = null) {
|
|
413
|
+
const app = existsSync(join(folder, ".lamp-wish", "lamp-app.json")) ? join(folder, ".lamp-wish") : folder;
|
|
414
|
+
const read = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
|
|
415
|
+
let origin;
|
|
416
|
+
try { origin = new URL(site).origin; } catch { return []; }
|
|
417
|
+
const idOf = (link, anySite) => {
|
|
418
|
+
let url;
|
|
419
|
+
try { url = new URL(link); } catch { return null; }
|
|
420
|
+
const id = /^\/w\/([^/]+)$/.exec(url.pathname)?.[1]?.slice(0, 10);
|
|
421
|
+
const named = /^\/w\/[^/]{10}(?:-[^/]*)?$/.test(url.pathname);
|
|
422
|
+
return (anySite || url.origin === origin) && named && isID(id) && id !== own ? id : null;
|
|
423
|
+
};
|
|
424
|
+
const upstream = read(join(app, "credits.json"))?.upstream;
|
|
425
|
+
const pulled = read(join(folder, ".lamp", "pulled-in.json"));
|
|
426
|
+
const ids = [
|
|
427
|
+
...(Array.isArray(upstream) ? upstream.map((source) => idOf(source?.url, false)) : []),
|
|
428
|
+
...(Array.isArray(pulled) ? pulled.filter((credit) => typeof credit?.version === "string").map((credit) => idOf(credit?.link, true)) : []),
|
|
429
|
+
];
|
|
430
|
+
return [...new Set(ids.filter(Boolean))].slice(0, LIMITS.with);
|
|
431
|
+
}
|
|
432
|
+
|
|
405
433
|
/** An object with its keys in order, at every level, as Lamp's own encoder writes its files. */
|
|
406
434
|
function sortedKeys(value) {
|
|
407
435
|
if (Array.isArray(value)) return value.map(sortedKeys);
|
|
@@ -857,18 +857,34 @@
|
|
|
857
857
|
/** Whether this kind of AI can answer now, as `status` says. */
|
|
858
858
|
isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
|
|
859
859
|
|
|
860
|
-
/** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name,
|
|
861
|
-
* available, reason }], default, reason, help, connect }. `
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
860
|
+
/** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name, model,
|
|
861
|
+
* via: { id, name }, available, reason }], default, reason, help, connect }. `name` says the model with what
|
|
862
|
+
* answers it ("Claude Sonnet via Claude Code"); `model` is the model's own short name ("Claude Sonnet") and `via`
|
|
863
|
+
* what answers it ("claude-code" / "Claude Code", "codex" / "Codex", "openrouter" / "OpenRouter"), both left out
|
|
864
|
+
* by an older Lamp. They come with what answers when a call names no `model` first, then by what else answers,
|
|
865
|
+
* each with its most capable model first. Every id is the model through one harness: "engine:claude:opus",
|
|
866
|
+
* "openrouter:opus", "engine:codex"; a bare "opus" is Opus through whatever answers by default. `default` is the
|
|
867
|
+
* id that answers when a call names no `model`; the ids are what `ask` and `act` take as `model`, and one that
|
|
868
|
+
* can't answer when it's sent is answered as no choice, never refused. With none to offer, `models` is empty and
|
|
869
|
+
* `reason` says why; on a page shared on lamp.dev that hasn't reached this computer yet it asks nothing and
|
|
870
|
+
* answers `connect: true`, as `status` does: ask again once `connect` has. Keep the person's choice with the
|
|
871
|
+
* app's saved things, and go back to `default` when it's no longer listed as available.
|
|
867
872
|
*
|
|
868
873
|
* LampAI.models().then(function (m) {
|
|
869
874
|
* var usable = m.models.filter(function (x) { return x.available; });
|
|
875
|
+
* var harnesses = usable.map(function (x) { return x.via ? x.via.name : ''; })
|
|
876
|
+
* .filter(function (v, i, all) { return all.indexOf(v) === i; });
|
|
870
877
|
* picker.hidden = usable.length < 2;
|
|
871
|
-
* picker.
|
|
878
|
+
* picker.replaceChildren();
|
|
879
|
+
* usable.forEach(function (x) {
|
|
880
|
+
* var where = picker, label = x.via ? x.via.name : '';
|
|
881
|
+
* if (harnesses.length > 1) {
|
|
882
|
+
* where = [].filter.call(picker.children, function (g) { return g.label === label; })[0];
|
|
883
|
+
* if (!where) { where = document.createElement('optgroup'); where.label = label; picker.appendChild(where); }
|
|
884
|
+
* }
|
|
885
|
+
* where.appendChild(new Option(x.model || x.name, x.id));
|
|
886
|
+
* where.lastChild.title = x.name;
|
|
887
|
+
* });
|
|
872
888
|
* picker.value = m.default;
|
|
873
889
|
* });
|
|
874
890
|
* LampAI.ask(question, { model: picker.value });
|
|
@@ -893,6 +909,9 @@
|
|
|
893
909
|
return m && typeof m.id === 'string' && m.id;
|
|
894
910
|
}).map(function (m) {
|
|
895
911
|
var one = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id, available: m.available === true };
|
|
912
|
+
// What answers it, as its own fields: left out by a Lamp from before they were sent.
|
|
913
|
+
if (typeof m.model === 'string' && m.model) one.model = m.model;
|
|
914
|
+
if (m.via && typeof m.via.id === 'string' && m.via.id && typeof m.via.name === 'string' && m.via.name) one.via = { id: m.via.id, name: m.via.name };
|
|
896
915
|
if (!one.available && typeof m.reason === 'string') one.reason = m.reason;
|
|
897
916
|
return one;
|
|
898
917
|
});
|
|
@@ -909,7 +928,8 @@
|
|
|
909
928
|
|
|
910
929
|
/** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
|
|
911
930
|
* system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
|
|
912
|
-
* model (an id `LampAI.models()` lists, such as "
|
|
931
|
+
* model (an id `LampAI.models()` lists, such as "engine:claude:opus" or "openrouter:sonnet"; a bare "opus" is
|
|
932
|
+
* Opus through whatever answers by default). */
|
|
913
933
|
ask: function (prompt, options) { return call(prompt, options); },
|
|
914
934
|
|
|
915
935
|
/**
|
package/cli/lamps/lamp.js
CHANGED
|
@@ -857,18 +857,34 @@
|
|
|
857
857
|
/** Whether this kind of AI can answer now, as `status` says. */
|
|
858
858
|
isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
|
|
859
859
|
|
|
860
|
-
/** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name,
|
|
861
|
-
* available, reason }], default, reason, help, connect }. `
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
860
|
+
/** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name, model,
|
|
861
|
+
* via: { id, name }, available, reason }], default, reason, help, connect }. `name` says the model with what
|
|
862
|
+
* answers it ("Claude Sonnet via Claude Code"); `model` is the model's own short name ("Claude Sonnet") and `via`
|
|
863
|
+
* what answers it ("claude-code" / "Claude Code", "codex" / "Codex", "openrouter" / "OpenRouter"), both left out
|
|
864
|
+
* by an older Lamp. They come with what answers when a call names no `model` first, then by what else answers,
|
|
865
|
+
* each with its most capable model first. Every id is the model through one harness: "engine:claude:opus",
|
|
866
|
+
* "openrouter:opus", "engine:codex"; a bare "opus" is Opus through whatever answers by default. `default` is the
|
|
867
|
+
* id that answers when a call names no `model`; the ids are what `ask` and `act` take as `model`, and one that
|
|
868
|
+
* can't answer when it's sent is answered as no choice, never refused. With none to offer, `models` is empty and
|
|
869
|
+
* `reason` says why; on a page shared on lamp.dev that hasn't reached this computer yet it asks nothing and
|
|
870
|
+
* answers `connect: true`, as `status` does: ask again once `connect` has. Keep the person's choice with the
|
|
871
|
+
* app's saved things, and go back to `default` when it's no longer listed as available.
|
|
867
872
|
*
|
|
868
873
|
* LampAI.models().then(function (m) {
|
|
869
874
|
* var usable = m.models.filter(function (x) { return x.available; });
|
|
875
|
+
* var harnesses = usable.map(function (x) { return x.via ? x.via.name : ''; })
|
|
876
|
+
* .filter(function (v, i, all) { return all.indexOf(v) === i; });
|
|
870
877
|
* picker.hidden = usable.length < 2;
|
|
871
|
-
* picker.
|
|
878
|
+
* picker.replaceChildren();
|
|
879
|
+
* usable.forEach(function (x) {
|
|
880
|
+
* var where = picker, label = x.via ? x.via.name : '';
|
|
881
|
+
* if (harnesses.length > 1) {
|
|
882
|
+
* where = [].filter.call(picker.children, function (g) { return g.label === label; })[0];
|
|
883
|
+
* if (!where) { where = document.createElement('optgroup'); where.label = label; picker.appendChild(where); }
|
|
884
|
+
* }
|
|
885
|
+
* where.appendChild(new Option(x.model || x.name, x.id));
|
|
886
|
+
* where.lastChild.title = x.name;
|
|
887
|
+
* });
|
|
872
888
|
* picker.value = m.default;
|
|
873
889
|
* });
|
|
874
890
|
* LampAI.ask(question, { model: picker.value });
|
|
@@ -893,6 +909,9 @@
|
|
|
893
909
|
return m && typeof m.id === 'string' && m.id;
|
|
894
910
|
}).map(function (m) {
|
|
895
911
|
var one = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id, available: m.available === true };
|
|
912
|
+
// What answers it, as its own fields: left out by a Lamp from before they were sent.
|
|
913
|
+
if (typeof m.model === 'string' && m.model) one.model = m.model;
|
|
914
|
+
if (m.via && typeof m.via.id === 'string' && m.via.id && typeof m.via.name === 'string' && m.via.name) one.via = { id: m.via.id, name: m.via.name };
|
|
896
915
|
if (!one.available && typeof m.reason === 'string') one.reason = m.reason;
|
|
897
916
|
return one;
|
|
898
917
|
});
|
|
@@ -909,7 +928,8 @@
|
|
|
909
928
|
|
|
910
929
|
/** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
|
|
911
930
|
* system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
|
|
912
|
-
* model (an id `LampAI.models()` lists, such as "
|
|
931
|
+
* model (an id `LampAI.models()` lists, such as "engine:claude:opus" or "openrouter:sonnet"; a bare "opus" is
|
|
932
|
+
* Opus through whatever answers by default). */
|
|
913
933
|
ask: function (prompt, options) { return call(prompt, options); },
|
|
914
934
|
|
|
915
935
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"
|
|
1
|
+
{"name":"Zqx App Name","instance":"2C4D485D-3F9A-4E45-8A4A-59D2AD6F176E","exe":"ZqxAppName","platform":"mac"}
|
|
@@ -11224,8 +11224,9 @@
|
|
|
11224
11224
|
}
|
|
11225
11225
|
|
|
11226
11226
|
/// `data` is text from outside the app (an email, a web page, a file) for the AI to work on, not obey.
|
|
11227
|
-
/// `model` overrides the app's text model for one call, by an id `LampAI.models()` lists ("
|
|
11228
|
-
/// "
|
|
11227
|
+
/// `model` overrides the app's text model for one call, by an id `LampAI.models()` lists ("engine:claude:opus",
|
|
11228
|
+
/// "openrouter:sonnet", …; a bare "opus" is Opus through whatever answers by default); one that can't answer
|
|
11229
|
+
/// now is answered as no choice, never refused.
|
|
11229
11230
|
static func ask(_ prompt: String, data: String? = nil, images: [LampImage] = [],
|
|
11230
11231
|
system: String? = nil, model: String? = nil) async throws -> String {
|
|
11231
11232
|
try await call(prompt: prompt, data: data, images: images, system: system, json: false, model: model)
|
|
@@ -11688,10 +11689,20 @@
|
|
|
11688
11689
|
|
|
11689
11690
|
// MARK: - Doing things for the person
|
|
11690
11691
|
|
|
11691
|
-
/// A model an app may offer the person (`LampAI.models()`): `id` is what `ask(model:)` takes, `name`
|
|
11692
|
+
/// A model an app may offer the person (`LampAI.models()`): `id` is what `ask(model:)` takes, `name` the model with
|
|
11693
|
+
/// what answers it ("Claude Sonnet via Claude Code").
|
|
11692
11694
|
struct LampAIModel: Identifiable, Equatable {
|
|
11695
|
+
/// What answers a model: `id` is "claude-code", "codex" or "openrouter", `name` what to show ("Claude Code").
|
|
11696
|
+
struct Via: Equatable {
|
|
11697
|
+
let id: String
|
|
11698
|
+
let name: String
|
|
11699
|
+
}
|
|
11693
11700
|
let id: String
|
|
11694
11701
|
let name: String
|
|
11702
|
+
/// The model's own short name ("Claude Sonnet"); nil from a Lamp from before it was sent, so show `name` then.
|
|
11703
|
+
var model: String? = nil
|
|
11704
|
+
/// What answers it; nil from a Lamp from before it was sent.
|
|
11705
|
+
var via: Via? = nil
|
|
11695
11706
|
/// Whether it can answer now; one that can't isn't worth offering.
|
|
11696
11707
|
let available: Bool
|
|
11697
11708
|
/// Why it can't, when it can't.
|
|
@@ -11765,12 +11776,18 @@
|
|
|
11765
11776
|
}
|
|
11766
11777
|
|
|
11767
11778
|
/// The models the person's setup can answer this app with now, asked of no model, so it's cheap to call as a
|
|
11768
|
-
/// view appears. The ids are what `ask(model:)` and `LampActSession(model:)` take
|
|
11769
|
-
///
|
|
11779
|
+
/// view appears. The ids are what `ask(model:)` and `LampActSession(model:)` take, each the model through one
|
|
11780
|
+
/// harness ("engine:claude:opus", "openrouter:opus", "engine:codex"; a bare "opus" is Opus through whatever
|
|
11781
|
+
/// answers by default). They come with what answers a call naming no model first, then by what else answers,
|
|
11782
|
+
/// each with its most capable model first. Show `model` (the model's own short name) in a picker, under `via`'s
|
|
11783
|
+
/// name when more than one harness is available, and `name` where `model` is nil (an older Lamp). Keep the
|
|
11784
|
+
/// person's choice with the app's saved things, and go back to `defaultID` when it's no longer listed as available.
|
|
11770
11785
|
///
|
|
11771
11786
|
/// let choices = await LampAI.models()
|
|
11772
11787
|
/// if choices.available.count > 1 {
|
|
11773
|
-
/// Picker("AI", selection: $model) {
|
|
11788
|
+
/// Picker("AI", selection: $model) {
|
|
11789
|
+
/// ForEach(choices.available) { Text($0.model ?? $0.name).help($0.name).tag(Optional($0.id)) }
|
|
11790
|
+
/// }
|
|
11774
11791
|
/// }
|
|
11775
11792
|
/// // model starts as choices.defaultID; then: try await LampAI.ask(question, model: model)
|
|
11776
11793
|
static func models() async -> LampAIModels {
|
|
@@ -11784,8 +11801,13 @@
|
|
|
11784
11801
|
let models = (reply["models"] as? [[String: Any]] ?? []).compactMap { m -> LampAIModel? in
|
|
11785
11802
|
guard let id = m["id"] as? String, !id.isEmpty else { return nil }
|
|
11786
11803
|
let available = m["available"] as? Bool ?? false
|
|
11787
|
-
|
|
11788
|
-
|
|
11804
|
+
let via = (m["via"] as? [String: Any]).flatMap { v -> LampAIModel.Via? in
|
|
11805
|
+
guard let id = v["id"] as? String, !id.isEmpty, let name = v["name"] as? String, !name.isEmpty else { return nil }
|
|
11806
|
+
return LampAIModel.Via(id: id, name: name)
|
|
11807
|
+
}
|
|
11808
|
+
return LampAIModel(id: id, name: (m["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? id,
|
|
11809
|
+
model: (m["model"] as? String).flatMap { $0.isEmpty ? nil : $0 }, via: via,
|
|
11810
|
+
available: available, reason: available ? nil : m["reason"] as? String)
|
|
11789
11811
|
}
|
|
11790
11812
|
let fallback = reply["default"] as? String
|
|
11791
11813
|
return LampAIModels(models: models, defaultID: models.contains { $0.id == fallback && $0.available } ? fallback : nil,
|
|
@@ -857,18 +857,34 @@
|
|
|
857
857
|
/** Whether this kind of AI can answer now, as `status` says. */
|
|
858
858
|
isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
|
|
859
859
|
|
|
860
|
-
/** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name,
|
|
861
|
-
* available, reason }], default, reason, help, connect }. `
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
860
|
+
/** The models the person's setup can answer this app with now, asked of no model: { models: [{ id, name, model,
|
|
861
|
+
* via: { id, name }, available, reason }], default, reason, help, connect }. `name` says the model with what
|
|
862
|
+
* answers it ("Claude Sonnet via Claude Code"); `model` is the model's own short name ("Claude Sonnet") and `via`
|
|
863
|
+
* what answers it ("claude-code" / "Claude Code", "codex" / "Codex", "openrouter" / "OpenRouter"), both left out
|
|
864
|
+
* by an older Lamp. They come with what answers when a call names no `model` first, then by what else answers,
|
|
865
|
+
* each with its most capable model first. Every id is the model through one harness: "engine:claude:opus",
|
|
866
|
+
* "openrouter:opus", "engine:codex"; a bare "opus" is Opus through whatever answers by default. `default` is the
|
|
867
|
+
* id that answers when a call names no `model`; the ids are what `ask` and `act` take as `model`, and one that
|
|
868
|
+
* can't answer when it's sent is answered as no choice, never refused. With none to offer, `models` is empty and
|
|
869
|
+
* `reason` says why; on a page shared on lamp.dev that hasn't reached this computer yet it asks nothing and
|
|
870
|
+
* answers `connect: true`, as `status` does: ask again once `connect` has. Keep the person's choice with the
|
|
871
|
+
* app's saved things, and go back to `default` when it's no longer listed as available.
|
|
867
872
|
*
|
|
868
873
|
* LampAI.models().then(function (m) {
|
|
869
874
|
* var usable = m.models.filter(function (x) { return x.available; });
|
|
875
|
+
* var harnesses = usable.map(function (x) { return x.via ? x.via.name : ''; })
|
|
876
|
+
* .filter(function (v, i, all) { return all.indexOf(v) === i; });
|
|
870
877
|
* picker.hidden = usable.length < 2;
|
|
871
|
-
* picker.
|
|
878
|
+
* picker.replaceChildren();
|
|
879
|
+
* usable.forEach(function (x) {
|
|
880
|
+
* var where = picker, label = x.via ? x.via.name : '';
|
|
881
|
+
* if (harnesses.length > 1) {
|
|
882
|
+
* where = [].filter.call(picker.children, function (g) { return g.label === label; })[0];
|
|
883
|
+
* if (!where) { where = document.createElement('optgroup'); where.label = label; picker.appendChild(where); }
|
|
884
|
+
* }
|
|
885
|
+
* where.appendChild(new Option(x.model || x.name, x.id));
|
|
886
|
+
* where.lastChild.title = x.name;
|
|
887
|
+
* });
|
|
872
888
|
* picker.value = m.default;
|
|
873
889
|
* });
|
|
874
890
|
* LampAI.ask(question, { model: picker.value });
|
|
@@ -893,6 +909,9 @@
|
|
|
893
909
|
return m && typeof m.id === 'string' && m.id;
|
|
894
910
|
}).map(function (m) {
|
|
895
911
|
var one = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id, available: m.available === true };
|
|
912
|
+
// What answers it, as its own fields: left out by a Lamp from before they were sent.
|
|
913
|
+
if (typeof m.model === 'string' && m.model) one.model = m.model;
|
|
914
|
+
if (m.via && typeof m.via.id === 'string' && m.via.id && typeof m.via.name === 'string' && m.via.name) one.via = { id: m.via.id, name: m.via.name };
|
|
896
915
|
if (!one.available && typeof m.reason === 'string') one.reason = m.reason;
|
|
897
916
|
return one;
|
|
898
917
|
});
|
|
@@ -909,7 +928,8 @@
|
|
|
909
928
|
|
|
910
929
|
/** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
|
|
911
930
|
* system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
|
|
912
|
-
* model (an id `LampAI.models()` lists, such as "
|
|
931
|
+
* model (an id `LampAI.models()` lists, such as "engine:claude:opus" or "openrouter:sonnet"; a bare "opus" is
|
|
932
|
+
* Opus through whatever answers by default). */
|
|
913
933
|
ask: function (prompt, options) { return call(prompt, options); },
|
|
914
934
|
|
|
915
935
|
/**
|
package/cli/share.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { NOTES_FILE, Problem, stateOf } from "./state.mjs";
|
|
|
15
15
|
import { ANY_BROWSER, CONTROLLERS, CONTROLLERS_FILE, DEVICE_FILE, lampJS, LIBRARIES } from "./dev.mjs";
|
|
16
16
|
import { LIMITS, FILE_LIMITS, PAGE_LIMITS } from "../site/lib/shared-wishes.js";
|
|
17
17
|
import { ideasOf, makerOf, pageUp, publishLink, removeFile, removeIdea, removeLink, replyIdea, sendFile, siteOrigin } from "./link.mjs";
|
|
18
|
-
import { exportApp, sourceLinkOf } from "./lampapp.mjs";
|
|
18
|
+
import { exportApp, sourceLinkOf, takenLinksOf } from "./lampapp.mjs";
|
|
19
19
|
import { scaled, sizeOf } from "./png.mjs";
|
|
20
20
|
import { ignoredFor } from "./ignore.mjs";
|
|
21
21
|
|
|
@@ -244,6 +244,9 @@ export async function share({ layout, words, picture = null, shape = "fill", sit
|
|
|
244
244
|
// The link this app came from, when it's a copy of one shared there: its page says so.
|
|
245
245
|
const from = sourceLinkOf(layout.folder, site, earlier?.id);
|
|
246
246
|
if (from) body.from = from;
|
|
247
|
+
// The copies on the site it took changes from: its page says "with changes from" them. None said keeps what it had.
|
|
248
|
+
const taken = takenLinksOf(layout.folder, site, earlier?.id);
|
|
249
|
+
if (taken.length) body.with = taken;
|
|
247
250
|
// A file an earlier share sent stays on the record until it's taken away, so a retry after a failed removal tries again.
|
|
248
251
|
// A link that's a Lamp's (`fromLamp`, the app in its own folder) stays so in every write, a share that fails partway too.
|
|
249
252
|
let link = { site, ...(await publishLink({ site, body, earlier, account: accountFor(site)?.key })), ...words, shape, ...(earlier?.file ? { file: true } : {}),
|
package/cli/skill/SKILL.md
CHANGED
|
@@ -224,7 +224,8 @@ Only when the person asks about copies or to bring something of one in:
|
|
|
224
224
|
- `{{lamp}} compare <link>` fetches that copy into lamp's own place for this folder and says what differs from this
|
|
225
225
|
app: files only in theirs, only in yours, and changed. It answers where the copy is: read it from there.
|
|
226
226
|
- `{{lamp}} take <link> "the stadium"` compares, then says how to bring in what the person asked for (leave the words
|
|
227
|
-
out only when they want everything new), and credits the copy in this app's credits.json
|
|
227
|
+
out only when they want everything new), and credits the copy in this app's credits.json, so the next share's page
|
|
228
|
+
says "with changes from" that copy and its maker hears of it. Bring
|
|
228
229
|
in only that, written into this app's own files and fitted to it, never their whole app over this one; then look.
|
|
229
230
|
When it's in, tell the person in a sentence what you took from whose copy. A copy shared on lamp.dev was shared for
|
|
230
231
|
anyone to take from, so sharing it on never asks about its license; third-party work the copy itself credits still
|
package/package.json
CHANGED
|
@@ -29,6 +29,8 @@ export const LIMITS = {
|
|
|
29
29
|
releases: 30,
|
|
30
30
|
releaseLines: 5,
|
|
31
31
|
releaseLine: 120,
|
|
32
|
+
/** The other people's copies on the site the app took changes from (`with`), by their ids. */
|
|
33
|
+
with: 20,
|
|
32
34
|
picture: 1_500_000,
|
|
33
35
|
icon: 300_000,
|
|
34
36
|
/** Pixels on a side, for both pictures. */
|
|
@@ -137,11 +139,12 @@ function release(value) {
|
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
/** What a share sends, checked. Null when anything is off. `releases` is undefined when the share didn't say (a Lamp
|
|
140
|
-
* from before them), so a change keeps the ones the link has; `from` too, the id of the link the app is a copy of
|
|
142
|
+
* from before them), so a change keeps the ones the link has; `from` too, the id of the link the app is a copy of, and
|
|
143
|
+
* `with`, the ids of the links whose copies it took changes from (`lamp take`, Lamp's `shared_copy`). */
|
|
141
144
|
const GITHUB_REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/;
|
|
142
145
|
export function readShare(body) {
|
|
143
146
|
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
144
|
-
const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon", "github", "from"];
|
|
147
|
+
const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon", "github", "from", "with"];
|
|
145
148
|
if (Object.keys(body).some((key) => !known.includes(key))) return null;
|
|
146
149
|
const name = tidy(body.name, LIMITS.name);
|
|
147
150
|
const words = tidy(body.words, LIMITS.words, { lines: true });
|
|
@@ -160,9 +163,10 @@ export function readShare(body) {
|
|
|
160
163
|
// The app's public repository on GitHub, as "owner/name", which the page links to: nothing else is taken.
|
|
161
164
|
const github = body.github == null || body.github === "" ? null : GITHUB_REPO.test(body.github) ? body.github : undefined;
|
|
162
165
|
const from = body.from == null ? undefined : isID(body.from) ? body.from : null;
|
|
163
|
-
|
|
166
|
+
const taken = body.with == null ? undefined : Array.isArray(body.with) && body.with.length <= LIMITS.with && body.with.every(isID) ? [...new Set(body.with)] : null;
|
|
167
|
+
if (!name || !words || line == null || by == null || ideas.includes(null) || asks.includes(null) || shape === undefined || releases?.includes(null) || !shot || !icon || github === undefined || from === null || taken === null) return null;
|
|
164
168
|
return { name, by, line, words, ideas, asks, shape, releases: releases?.sort((a, b) => b.date.localeCompare(a.date)),
|
|
165
|
-
picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null, github, from };
|
|
169
|
+
picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null, github, from, with: taken };
|
|
166
170
|
}
|
|
167
171
|
|
|
168
172
|
/** What anyone may see of a shared wish: never its secret. */
|
|
@@ -745,6 +749,33 @@ async function copied(kept, from, copy, origin, { env, fetcher, first }) {
|
|
|
745
749
|
} catch {}
|
|
746
750
|
}
|
|
747
751
|
|
|
752
|
+
/**
|
|
753
|
+
* A link started naming copies it took changes from (`with`): each copy's maker told by email, once (the send key), at
|
|
754
|
+
* the address that confirmed their account (or the copy's own maker address), as `copied` tells a source's. Never fails
|
|
755
|
+
* the share.
|
|
756
|
+
*/
|
|
757
|
+
async function tookFrom(kept, ids, link, origin, { env, fetcher }) {
|
|
758
|
+
const one = async (id) => {
|
|
759
|
+
try {
|
|
760
|
+
const copy = await kept.get(id);
|
|
761
|
+
if (!copy) return;
|
|
762
|
+
const account = copy.owner ? await kept.account(copy.owner) : null;
|
|
763
|
+
const maker = account ? null : await kept.maker(id);
|
|
764
|
+
const to = account?.email ?? (maker?.confirmed ? maker.email : null);
|
|
765
|
+
if (!to) return;
|
|
766
|
+
const who = link.owner || link.by || "Someone";
|
|
767
|
+
await sendMail(env, fetcher, { to: [to], subject: `${who} took changes from your ${copy.name}`,
|
|
768
|
+
text: `${who} took changes from your ${copy.name} into ${link.name}:\n\n${linkOf(origin, link)}\n` }, `with-${link.id}-${id}`);
|
|
769
|
+
} catch {}
|
|
770
|
+
};
|
|
771
|
+
// All at once, and the share waits for them a few seconds at most: it has to answer well within its function's time,
|
|
772
|
+
// with the link's secret, whatever the mail service does. A send cut short here is only an email not sent.
|
|
773
|
+
await Promise.race([Promise.all(ids.map(one)), new Promise((done) => setTimeout(done, NOTIFY_MS))]);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** The longest a share waits on the emails it sends its copies' makers, well within the function's time. */
|
|
777
|
+
const NOTIFY_MS = 6000;
|
|
778
|
+
|
|
748
779
|
/** The copies of a link as its page lists them, newest first; one taken down since is let go. */
|
|
749
780
|
export async function copiesOf(kept, id, origin) {
|
|
750
781
|
const found = [];
|
|
@@ -834,8 +865,12 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
|
|
|
834
865
|
// The link the app is a copy of, kept only while there's such a link and it isn't this one: a share that doesn't
|
|
835
866
|
// say, or names one that's gone, keeps what the record had.
|
|
836
867
|
const from = share.from && share.from !== wishID && (await kept.get(share.from)) ? share.from : null;
|
|
868
|
+
// The copies it took changes from, kept only while each is there and isn't this link: a share that doesn't say
|
|
869
|
+
// keeps what the record had, and one that says none clears it.
|
|
870
|
+
const taken = share.with && (await kept.getMany(share.with.filter((one) => one !== wishID)))
|
|
871
|
+
.flatMap((record) => (record ? [record.id] : []));
|
|
837
872
|
const words = { name: share.name, by: share.by, line: share.line, words: share.words, ideas: share.ideas, asks: share.asks, shape: share.shape, github: share.github,
|
|
838
|
-
...(share.releases ? { releases: share.releases } : {}), ...(from ? { from } : {}), picture: picture || null, icon: icon || null };
|
|
873
|
+
...(share.releases ? { releases: share.releases } : {}), ...(from ? { from } : {}), ...(taken ? { with: taken } : {}), picture: picture || null, icon: icon || null };
|
|
839
874
|
// Its name under the account stays what it was first given, whatever the app is called later.
|
|
840
875
|
const named = earlier?.owner ? { owner: earlier.owner, app: earlier.app }
|
|
841
876
|
: owner ? { owner, app: await claimAppName(kept, owner, words.name, wishID) } : {};
|
|
@@ -845,6 +880,7 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
|
|
|
845
880
|
const record = { ...earlier, ...words, ...named, updated: time };
|
|
846
881
|
await kept.replace(record);
|
|
847
882
|
if (record.from) await copied(kept, record.from, record, url.origin, { env, fetcher, first: !!from && earlier?.from !== from });
|
|
883
|
+
if (taken) await tookFrom(kept, taken.filter((one) => !(earlier?.with ?? []).includes(one)), record, url.origin, { env, fetcher });
|
|
848
884
|
// Kept: the name is the record's now, whatever the cleanup after it does.
|
|
849
885
|
uploaded = [];
|
|
850
886
|
claimed = null;
|
|
@@ -860,6 +896,7 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
|
|
|
860
896
|
uploaded = [];
|
|
861
897
|
claimed = null;
|
|
862
898
|
if (from) await copied(kept, from, record, url.origin, { env, fetcher, first: true });
|
|
899
|
+
if (taken) await tookFrom(kept, taken, record, url.origin, { env, fetcher });
|
|
863
900
|
return reply(201, { id: wishID, url: linkOf(url.origin, record), secret });
|
|
864
901
|
} catch {
|
|
865
902
|
try { await kept.removeImages(uploaded); } catch {}
|
|
@@ -1365,6 +1402,14 @@ export async function findWish(id, { env = process.env, fetcher = fetch, store,
|
|
|
1365
1402
|
if (source) wish.from = { url: linkOf(origin, source), label: source.owner && source.app ? `${source.owner}/${source.app}` : source.name };
|
|
1366
1403
|
} catch {}
|
|
1367
1404
|
}
|
|
1405
|
+
// The copies it took changes from, as each is known now, `with` on its page, in one round trip; one taken down since,
|
|
1406
|
+
// or the source already named as `from`, is left out.
|
|
1407
|
+
if (wish && origin && Array.isArray(record.with) && record.with.length) {
|
|
1408
|
+
try {
|
|
1409
|
+
const records = await kept.getMany(record.with.filter((one) => isID(one) && one !== id && one !== record.from));
|
|
1410
|
+
wish.with = records.filter(Boolean).map((copy) => ({ url: linkOf(origin, copy), label: copy.owner && copy.app ? `${copy.owner}/${copy.app}` : copy.name }));
|
|
1411
|
+
} catch {}
|
|
1412
|
+
}
|
|
1368
1413
|
// How many copies of it people shared, for the page's "copies" (the list itself is asked for when opened).
|
|
1369
1414
|
if (wish && origin) { try { wish.copies = await kept.copyCount(id); } catch {} }
|
|
1370
1415
|
return wish;
|