@lampdotdev/lamp 0.35.0 → 0.36.2-nightly.16
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 +51 -11
- package/Resources/GameKit/apple/LampPage.swift +202 -35
- package/Resources/GameKit/apple/LampWebGame.swift +1 -1
- package/Resources/GameKit/page/lamp-live.js +672 -0
- package/Resources/GameKit/web/lamp-game.js +7 -1
- package/Resources/share-rules.json +64 -0
- package/Resources/skills/game/SKILL.md +2 -0
- package/Resources/skills/github-projects/SKILL.md +8 -5
- package/Resources/skills/web-app/SKILL.md +49 -15
- package/Resources/skills/web-or-native/SKILL.md +6 -1
- package/cli/ai.mjs +237 -0
- package/cli/app-ai.mjs +473 -0
- package/cli/browser.mjs +18 -2
- package/cli/calls.mjs +213 -9
- package/cli/dev.mjs +61 -14
- package/cli/lamp.mjs +347 -37
- package/cli/lampapp.mjs +9 -9
- package/cli/lamps/android-host/LampPageGateway.kt +2 -2
- package/cli/lamps/lamp-for-games.js +95 -36
- package/cli/lamps/lamp.js +95 -36
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -1
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +403 -285
- package/cli/lamps/web-starter/lamp.js +95 -36
- package/cli/link.mjs +77 -13
- package/cli/local-ai.mjs +193 -0
- package/cli/openrouter.mjs +133 -0
- package/cli/outside.mjs +65 -2
- package/cli/page.js +160 -8
- package/cli/share.mjs +80 -12
- package/cli/skill/SKILL.md +35 -10
- package/cli/state.mjs +3 -0
- package/cli/update.mjs +117 -12
- package/package.json +1 -1
- package/site/lib/shared-wishes.js +848 -117
package/cli/calls.mjs
CHANGED
|
@@ -24,11 +24,28 @@ import { fileURLToPath } from "node:url";
|
|
|
24
24
|
import { Away, OffNetwork, Refused, Rejected, Runner, StillWorking, answersOn, bonjour, pinsAt, searchNetwork, send, ssdp } from "./devices.mjs";
|
|
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
|
-
import {
|
|
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
29
|
import { defaultRoute, homeNetwork, neighbours, samsungAt, subnetOn, tvsIn } from "./network.mjs";
|
|
29
30
|
import { PUBLISHER, identityName, keptSigner, namesOf, packageOf, versionAfter } from "./msix.mjs";
|
|
30
31
|
import { tile } from "./png.mjs";
|
|
31
32
|
import { Problem } from "./state.mjs";
|
|
33
|
+
import { allowed as pagesAllowed, forgetPage, serveLocalAI } from "./local-ai.mjs";
|
|
34
|
+
import { EngineTrouble, complete } from "./ai.mjs";
|
|
35
|
+
import { OpenRouterTrouble, chat as openRouterChat, decide as openRouterDecide, image as openRouterImage, text as openRouterText } from "./openrouter.mjs";
|
|
36
|
+
|
|
37
|
+
/** OpenRouter's answer, or what kept it from one, as Lamp's app reads it. */
|
|
38
|
+
async function openRouter(asking) {
|
|
39
|
+
try { return await asking(); } catch (error) {
|
|
40
|
+
if (!(error instanceof OpenRouterTrouble)) throw error;
|
|
41
|
+
return { trouble: { kind: error.kind, message: error.message, status: error.status, reply: error.reply, cost: error.cost } };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
import { Refused as StepRefused, chatBody, decidePrompt, planPrompt, planSchema, readDecision, readMessage, readPlan, readStep } from "./app-ai.mjs";
|
|
45
|
+
|
|
46
|
+
// Pages shared on lamp.dev asking for their AI, answered by Lamp's app (`sharedPages`): what each is waiting on from it.
|
|
47
|
+
const sharedPagesWaiting = new Map();
|
|
48
|
+
let sharedPagesAsked = 0;
|
|
32
49
|
|
|
33
50
|
/** A number of seconds a call was given, within what it may take. */
|
|
34
51
|
const seconds = (given, standard, most = 10) => Math.min(Math.max(Number(given) > 0 ? Number(given) : standard, 0.5), most);
|
|
@@ -153,7 +170,43 @@ export const CALLS = {
|
|
|
153
170
|
*/
|
|
154
171
|
async linkPublish(given) {
|
|
155
172
|
const earlier = given.earlier && typeof given.earlier.id === "string" ? given.earlier : null;
|
|
156
|
-
|
|
173
|
+
// Sent with the maker's account key, the one `lamp account` keeps, unless the call names another: the link goes
|
|
174
|
+
// under their username once it's confirmed.
|
|
175
|
+
const site = siteOf(given);
|
|
176
|
+
return publishLink({ site, body: given.body ?? {}, earlier, account: typeof given.account === "string" ? given.account : accountFor(site)?.key });
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The maker's account on `site`, as lamp keeps it: `account` null with none, else its username, the address that
|
|
181
|
+
* confirms it, and `confirmed` (null when the site can't be asked just now).
|
|
182
|
+
*/
|
|
183
|
+
async account(given) {
|
|
184
|
+
const site = siteOf(given);
|
|
185
|
+
const kept = accountFor(site);
|
|
186
|
+
if (!kept) return { account: null };
|
|
187
|
+
let name;
|
|
188
|
+
try { name = await accountName({ site, key: kept.key }); } catch { return { account: { username: kept.username, email: kept.email ?? null, confirmed: null } }; }
|
|
189
|
+
// An address signing in to its account learns the username only once it's confirmed.
|
|
190
|
+
if (name && name !== kept.username) keepAccount(site, { ...kept, username: name });
|
|
191
|
+
return { account: { username: name ?? kept.username, email: kept.email ?? null, confirmed: !!name } };
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Asks `site` for an account (`username`, `email`), or with an address alone, to sign in to the one it has; the key is
|
|
196
|
+
* kept as `lamp account` keeps it, and works once the email's link is opened. Its username and whether the email went.
|
|
197
|
+
*/
|
|
198
|
+
async accountAsk(given) {
|
|
199
|
+
const site = siteOf(given);
|
|
200
|
+
const email = text(given, "email");
|
|
201
|
+
const asked = await askAccount({ site, username: typeof given.username === "string" && given.username ? given.username : undefined, email });
|
|
202
|
+
keepAccount(site, { username: asked.username, key: asked.key, email });
|
|
203
|
+
return { username: asked.username ?? null, existing: !!asked.existing, sent: asked.sent !== false };
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
/** Forgets the maker's account on `site` here (the one waiting for a mistyped address, say). */
|
|
207
|
+
async accountForget(given) {
|
|
208
|
+
keepAccount(siteOf(given), undefined);
|
|
209
|
+
return {};
|
|
157
210
|
},
|
|
158
211
|
|
|
159
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. */
|
|
@@ -163,8 +216,10 @@ export const CALLS = {
|
|
|
163
216
|
|
|
164
217
|
/** The link's file (or with `page`, its playable page), from a file of Lamp's at `file`, sent in parts. */
|
|
165
218
|
async linkUpload(given, how = {}) {
|
|
166
|
-
|
|
167
|
-
|
|
219
|
+
const said = (line) => how.event?.({ said: line });
|
|
220
|
+
const version = await sendFile({ site: siteOf(given), link: linkOf(given), bytes: readFileSync(text(given, "file")), page: given.page === true, said });
|
|
221
|
+
if (given.page === true) await pageUp({ site: siteOf(given), id: linkOf(given).id, version, said });
|
|
222
|
+
return { version };
|
|
168
223
|
},
|
|
169
224
|
|
|
170
225
|
/** Takes the link's file away (with `page`, only its page). */
|
|
@@ -173,14 +228,26 @@ export const CALLS = {
|
|
|
173
228
|
return {};
|
|
174
229
|
},
|
|
175
230
|
|
|
176
|
-
/** The ideas sent to the link that are still open. */
|
|
231
|
+
/** The ideas sent to the link that are still open or answered since; with `earlier`, those seen to. */
|
|
177
232
|
async linkIdeas(given) {
|
|
178
|
-
return { ideas: await ideasOf({ site: siteOf(given), link: linkOf(given) }) };
|
|
233
|
+
return { ideas: await ideasOf({ site: siteOf(given), link: linkOf(given), earlier: given.earlier === true }) };
|
|
179
234
|
},
|
|
180
235
|
|
|
181
|
-
/** Lets an idea go; with `built` it's in, and the site writes to whoever asked to hear. */
|
|
236
|
+
/** Lets an idea go; with `built` it's in, and the site writes to whoever asked to hear, with `note` if there is one. */
|
|
182
237
|
async linkRemoveIdea(given) {
|
|
183
|
-
|
|
238
|
+
const note = typeof given.note === "string" && given.note.trim() ? given.note.trim() : undefined;
|
|
239
|
+
return removeIdea({ site: siteOf(given), link: linkOf(given), n: Number(given.n), built: given.built === true, note });
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
/** Says something more about an idea, emailed to its sender if they gave an address; without `note`, it's seen to. */
|
|
243
|
+
async linkReplyIdea(given) {
|
|
244
|
+
const note = typeof given.note === "string" && given.note.trim() ? given.note.trim() : undefined;
|
|
245
|
+
return replyIdea({ site: siteOf(given), link: linkOf(given), n: Number(given.n), note });
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
/** The maker's own address for the link's ideas; with `email` (a string, "" for none), given, to be confirmed. */
|
|
249
|
+
async linkMaker(given) {
|
|
250
|
+
return makerOf({ site: siteOf(given), link: linkOf(given), ...(typeof given.email === "string" ? { email: given.email.trim() } : {}) });
|
|
184
251
|
},
|
|
185
252
|
|
|
186
253
|
/** Takes the link down. */
|
|
@@ -299,6 +366,135 @@ export const CALLS = {
|
|
|
299
366
|
return { table: await neighbours(), gateway: route?.gateway ?? null, subnet: route ? subnetOn(route.interface) : null };
|
|
300
367
|
},
|
|
301
368
|
|
|
369
|
+
/**
|
|
370
|
+
* A text answer from OpenRouter for an app's AI (`AppAI.viaOpenRouter`), with the person's `key`, given on the input
|
|
371
|
+
* like everything else and kept nowhere: `prompt`, `images`, `system`, `model`, `json`, `schema`. `{text, cost}`, or
|
|
372
|
+
* `{trouble: {kind, message, status, reply, cost}}`.
|
|
373
|
+
*/
|
|
374
|
+
async openRouterText(given, { stopped } = {}) {
|
|
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, 120, 600), signal: stopped }));
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
/** One chat completion from OpenRouter, its request as `body` (`actAsk`'s): `{message, cost}`, or `{trouble}`. */
|
|
380
|
+
async openRouterChat(given, { stopped } = {}) {
|
|
381
|
+
return openRouter(() => openRouterChat({ key: text(given, "key"), body: given.body ?? {}, seconds: seconds(given.seconds, 60, 600), signal: stopped }));
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
/** Decisions from OpenRouter's decision model (`AppAI.decide`): `key`, `model`, `state`, `questions`. `{answers, cost}` or `{trouble}`. */
|
|
385
|
+
async openRouterDecide(given, { stopped } = {}) {
|
|
386
|
+
return openRouter(() => openRouterDecide({ key: text(given, "key"), model: text(given, "model"), state: given.state, questions: given.questions ?? {},
|
|
387
|
+
seconds: seconds(given.seconds, 60, 120), signal: stopped }));
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
/** A picture from OpenRouter (`AppAI.image`): `key`, `model`, `prompt`, `references`, `aspectRatio`. `{image, cost}` or `{trouble}`. */
|
|
391
|
+
async openRouterImage(given, { stopped } = {}) {
|
|
392
|
+
return openRouter(() => openRouterImage({ key: text(given, "key"), model: text(given, "model"), prompt: text(given, "prompt"),
|
|
393
|
+
references: given.references, aspectRatio: typeof given.aspectRatio === "string" ? given.aspectRatio : undefined, seconds: seconds(given.seconds, 240, 300), signal: stopped }));
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* One answer from Claude Code or Codex for an app's AI (`AppAI.viaEngine`), sealed as ai.mjs runs every one: `engine`,
|
|
398
|
+
* the program Lamp chose (`tool`) and what it needs set (`env`), `system`, `prompt`, optional `schema`, `model`,
|
|
399
|
+
* `images` and `seconds`. `{text}`, or `{trouble: {kind, message}}`; stopping the call stops the engine.
|
|
400
|
+
*/
|
|
401
|
+
async engineAnswer(given, { stopped } = {}) {
|
|
402
|
+
try {
|
|
403
|
+
return { text: await complete({ which: text(given, "engine"), tool: typeof given.tool === "string" && given.tool ? given.tool : undefined,
|
|
404
|
+
env: given.env && typeof given.env === "object" ? given.env : {}, system: typeof given.system === "string" ? given.system : "",
|
|
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, 300, 600), signal: stopped }) };
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (error instanceof EngineTrouble) return { trouble: { kind: error.kind, message: error.message } };
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* A step of an app's `LampAI.act` as the page sent it (`text`, its JSON), checked: `{step}`, the step as the other
|
|
415
|
+
* act calls take it, or `{refused: {status, error}}` for the page.
|
|
416
|
+
*/
|
|
417
|
+
async actStep(given) {
|
|
418
|
+
try { return { step: readStep(text(given, "text")) }; } catch (error) {
|
|
419
|
+
if (error instanceof StepRefused) return { refused: { status: error.status, error: error.message } };
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* What a step's route is asked: `way` "chat" is a chat with tools (`{body}`, OpenRouter's own when `model` names one),
|
|
426
|
+
* "plan" one prompt held to the step's shape (`{system, prompt, schema}`), and "words" one with the shape said in it.
|
|
427
|
+
*/
|
|
428
|
+
async actAsk(given) {
|
|
429
|
+
const step = given.step, tag = text(given, "tag"), retry = typeof given.retry === "string" ? given.retry : null;
|
|
430
|
+
if (given.way === "chat") return { body: chatBody(step, tag, retry, typeof given.model === "string" ? given.model : undefined) };
|
|
431
|
+
return { ...planPrompt(step, tag, retry, given.way === "words"), schema: planSchema(step) };
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
/** A step's answer read and checked: `message` from a chat with tools, or `plan`'s text. `{answer}` or `{problem}`. */
|
|
435
|
+
async actRead(given) {
|
|
436
|
+
return given.message !== undefined ? readMessage(given.message, given.step) : readPlan(String(given.plan ?? ""), given.step);
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
/** A decision asked of a text model: `{system, prompt, schema}`. */
|
|
440
|
+
async decideAsk(given) {
|
|
441
|
+
return decidePrompt(given.state, given.questions ?? {});
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
/** A text model's decision read: `{answers}` or `{problem}`. */
|
|
445
|
+
async decideRead(given) {
|
|
446
|
+
return readDecision(String(given.text ?? ""), given.questions ?? {});
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Answers the AI of pages shared on lamp.dev, played in a browser on this computer, as `lamp ai` does
|
|
451
|
+
* (local-ai.mjs), for as long as Lamp's app wants it: which pages are let in, and how, is lamp's alone. The person is
|
|
452
|
+
* asked by Lamp's app, and the AI is Lamp's: each is an event, `{ask: n, id, name}` or `{answer: n, path, body}`,
|
|
453
|
+
* which Lamp's app answers with `sharedPagesReply`. A problem when something else answers on this computer already.
|
|
454
|
+
*/
|
|
455
|
+
async sharedPages(given, { stopped, event } = {}) {
|
|
456
|
+
const fromLamp = (kind, fields) => new Promise((done) => {
|
|
457
|
+
const n = ++sharedPagesAsked;
|
|
458
|
+
sharedPagesWaiting.set(n, done);
|
|
459
|
+
event({ [kind]: n, ...fields });
|
|
460
|
+
});
|
|
461
|
+
let server;
|
|
462
|
+
try {
|
|
463
|
+
server = await serveLocalAI({
|
|
464
|
+
...(Number.isInteger(given.port) ? { port: given.port } : {}),
|
|
465
|
+
said: () => {},
|
|
466
|
+
ask: (_question, page) => fromLamp("ask", { id: page.id, name: page.name }).then((reply) => reply.yes === true),
|
|
467
|
+
answer: (path, body, page) => fromLamp("answer", { path, body, id: page.id }).then((reply) => [Number(reply.status) || 502, reply.reply ?? {}]),
|
|
468
|
+
});
|
|
469
|
+
} catch (error) {
|
|
470
|
+
if (error?.code === "EADDRINUSE") throw new Problem("lamp ai already answers shared pages' AI on this computer");
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
await new Promise((done) => (stopped?.aborted ? done() : stopped?.addEventListener("abort", done)));
|
|
474
|
+
server.close();
|
|
475
|
+
return {};
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
/** The pages shared on lamp.dev allowed to use the AI here, for Settings: `{pages: [{origin, id, name, at}]}`, newest first. */
|
|
479
|
+
async sharedPagesAllowed() {
|
|
480
|
+
return { pages: Object.entries(pagesAllowed()).map(([origin, page]) => ({ origin, id: page.id, name: page.name, at: page.at }))
|
|
481
|
+
.sort((a, b) => String(b.at).localeCompare(String(a.at))) };
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
/** Takes back one page's leave to use the AI here (`origin`): it asks again the next time. */
|
|
485
|
+
async sharedPagesForget(given) {
|
|
486
|
+
forgetPage(text(given, "origin"));
|
|
487
|
+
return {};
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
/** Lamp's app's answer to what `sharedPages` asked it: `{n, yes}` or `{n, status, reply}`. */
|
|
491
|
+
async sharedPagesReply(given) {
|
|
492
|
+
const done = sharedPagesWaiting.get(given.n);
|
|
493
|
+
sharedPagesWaiting.delete(given.n);
|
|
494
|
+
done?.(given);
|
|
495
|
+
return {};
|
|
496
|
+
},
|
|
497
|
+
|
|
302
498
|
/** A search of the home network for one search target, for a few seconds: every answer. */
|
|
303
499
|
async search(given) {
|
|
304
500
|
return { answers: await ssdp(typeof given.target === "string" ? given.target : "ssdp:all", seconds(given.seconds, 2)) };
|
|
@@ -358,11 +554,19 @@ async function once(name) {
|
|
|
358
554
|
async function serve() {
|
|
359
555
|
const say = (line) => process.stdout.write(JSON.stringify(line) + "\n");
|
|
360
556
|
const under = new Map();
|
|
557
|
+
// A stop can overtake the call it's for (Lamp's app cancelled it as it was sent): it's kept, and the call starts stopped.
|
|
558
|
+
const early = new Set();
|
|
361
559
|
const answer = async (line) => {
|
|
362
560
|
let asked;
|
|
363
561
|
try { asked = JSON.parse(line); } catch { return; }
|
|
364
|
-
if (asked?.stop !== undefined)
|
|
562
|
+
if (asked?.stop !== undefined) {
|
|
563
|
+
const stopping = under.get(asked.stop);
|
|
564
|
+
if (stopping) stopping.abort();
|
|
565
|
+
else { early.add(asked.stop); if (early.size > 1000) early.delete(early.values().next().value); }
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
365
568
|
const id = asked?.id, call = CALLS[asked?.call], stopping = new AbortController();
|
|
569
|
+
if (early.delete(id)) stopping.abort();
|
|
366
570
|
under.set(id, stopping);
|
|
367
571
|
try {
|
|
368
572
|
if (!call) throw new Error(`lamp has no call named "${asked?.call ?? ""}"`);
|
package/cli/dev.mjs
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
//
|
|
4
4
|
// A game made with Lamp's runtime (lamp-game.js beside the page) keeps the player's place: the runtime is handed the
|
|
5
5
|
// change exactly as Lamp's app around the page hands it over (`window.__lampLive`), and swaps the code in, runs it
|
|
6
|
-
// again in place, or opens the page again from where the player was.
|
|
6
|
+
// again in place, or opens the page again from where the player was. A game's page that doesn't run on the runtime is
|
|
7
|
+
// handed it too, through lamp-live.js (given after page.js), which puts in what it safely can and holds the rest for
|
|
8
|
+
// Reload. Any other page is opened again.
|
|
7
9
|
|
|
8
10
|
import { createServer } from "node:http";
|
|
9
11
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -13,8 +15,9 @@ import { networkInterfaces, hostname } from "node:os";
|
|
|
13
15
|
import { join, resolve, extname, sep, relative, dirname } from "node:path";
|
|
14
16
|
import { fileURLToPath } from "node:url";
|
|
15
17
|
import { spawn } from "node:child_process";
|
|
16
|
-
import { Problem, RESOURCES, layoutOf, stateOf, logFileOf, serverFileOf, firstPortOf } from "./state.mjs";
|
|
17
|
-
import { findBrowser, launch, keyNamed } from "./browser.mjs";
|
|
18
|
+
import { NOTES_FILE, Problem, RESOURCES, layoutOf, stateOf, logFileOf, serverFileOf, firstPortOf } from "./state.mjs";
|
|
19
|
+
import { findBrowser, noBrowser, launch, keyNamed } from "./browser.mjs";
|
|
20
|
+
import { answer as answerAI } from "./ai.mjs";
|
|
18
21
|
|
|
19
22
|
const TYPES = {
|
|
20
23
|
".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
@@ -37,10 +40,13 @@ export default {"keys":{"AltLeft":"Alt / Option","AltRight":"Alt / Option","Back
|
|
|
37
40
|
`;
|
|
38
41
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
39
42
|
const PAGE_SCRIPT = readFileSync(join(HERE, "page.js"), "utf8");
|
|
43
|
+
// What a game's page is given after lamp's script, as Lamp's app gives it (`LampPageLive.gameScript`): a game that
|
|
44
|
+
// doesn't run on Lamp's runtime takes a change while it's played where it can. Read from Lamp's own copy.
|
|
45
|
+
const GAME_SCRIPT = (() => { try { return readFileSync(join(RESOURCES, "GameKit", "page", "lamp-live.js"), "utf8"); } catch { return ""; } })();
|
|
40
46
|
// Lamp's building blocks for a page (saved data, the app's files, AI through Lamp's app): lamp.js, which Lamp's app
|
|
41
47
|
// writes at each build from its own code. A page whose folder has none is given it as that code writes it, when it has
|
|
42
48
|
// been written out for the command line (scripts/cli-files.sh, cli/lamps/lamp.js), and else a stand-in, so the page loads.
|
|
43
|
-
const LAMP_JS_STAND_IN = "// lamp's stand-in for lamp.js: Lamp's building blocks for a page aren't written out for the command line yet (scripts/cli-files.sh).\n";
|
|
49
|
+
export const LAMP_JS_STAND_IN = "// lamp's stand-in for lamp.js: Lamp's building blocks for a page aren't written out for the command line yet (scripts/cli-files.sh).\n";
|
|
44
50
|
export function lampJS(game = false) {
|
|
45
51
|
// A game's is its own (`GameKit.lampJS`): without a TV's remote, which would take the keys a game reads itself.
|
|
46
52
|
for (const name of game ? ["lamp-for-games.js", "lamp.js"] : ["lamp.js"]) {
|
|
@@ -109,9 +115,10 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
|
|
|
109
115
|
|
|
110
116
|
// MARK: The files and their changes
|
|
111
117
|
|
|
112
|
-
// What's never part of a change: hidden files and folders, what tools keep beside the code,
|
|
118
|
+
// What's never part of a change: hidden files and folders, what tools keep beside the code, Lamp's own two files,
|
|
119
|
+
// and the app's notes, which no page runs (other Markdown may be content a page loads, so it is a change).
|
|
113
120
|
const ignored = (path) => path.split("/").some((part) => part.startsWith(".") || part === "node_modules") || path.endsWith("~")
|
|
114
|
-
|| path === DEVICE_FILE || path === BUILD_FILE;
|
|
121
|
+
|| path === DEVICE_FILE || path === BUILD_FILE || path === NOTES_FILE;
|
|
115
122
|
const filesUnder = (dir = root, prefix = "", found = []) => {
|
|
116
123
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
117
124
|
const path = prefix + entry.name;
|
|
@@ -136,6 +143,9 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
|
|
|
136
143
|
const pages = new Map(); // page id → { response, tab, at: the change it has taken, ready, since }
|
|
137
144
|
const reopening = new Map(); // tab → when the page it said it's opening again is given up on
|
|
138
145
|
const early = new Map(); // what a page said before its line for changes was open
|
|
146
|
+
// Every file of the page a page asked for, by any page since the server started: a change says them, so a game's page
|
|
147
|
+
// that doesn't run on the runtime tells a file it uses from one it doesn't (lamp-live.js), which no page can see itself.
|
|
148
|
+
const served = new Set();
|
|
139
149
|
const printed = new Set();
|
|
140
150
|
|
|
141
151
|
const named = (change) => {
|
|
@@ -144,12 +154,19 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
|
|
|
144
154
|
};
|
|
145
155
|
const tell = (page, event, data, id) => page.response.write(`${id === undefined ? "" : `id: ${id}\n`}event: ${event}\ndata: ${data}\n\n`);
|
|
146
156
|
|
|
157
|
+
// What the coding agent said it's changing (`lamp changing`), for the card each page shows as a change goes in: the
|
|
158
|
+
// changes saved in the few minutes after say it, then it's let go.
|
|
159
|
+
let changing = null;
|
|
160
|
+
// What's changing, as the agent last said it: it stands for every save after it, until it says something new or half an
|
|
161
|
+
// hour has gone by.
|
|
162
|
+
const changingWords = () => (changing && Date.now() - changing.at < 30 * 60_000 ? { words: changing.words } : {});
|
|
163
|
+
|
|
147
164
|
function broadcast(changed, removed) {
|
|
148
165
|
gen += 1;
|
|
149
166
|
const change = { gen, changed, removed, said: new Map(), line: logCount };
|
|
150
167
|
changes.push(change);
|
|
151
168
|
if (changes.length > 40) changes.shift();
|
|
152
|
-
const data = JSON.stringify({ gen, changed, removed, now: false, pill: true });
|
|
169
|
+
const data = JSON.stringify({ gen, changed, removed, now: false, pill: true, read: [...served], ...changingWords() });
|
|
153
170
|
for (const page of pages.values()) { page.told = Date.now(); tell(page, "change", data, gen); }
|
|
154
171
|
if (!pages.size) print(`${named(change)}: no page is open; it's in the next one opened`);
|
|
155
172
|
}
|
|
@@ -331,6 +348,12 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
|
|
|
331
348
|
checkAll();
|
|
332
349
|
let target = null, problem = null;
|
|
333
350
|
try { target = await watched(); } catch (error) { problem = error.message; }
|
|
351
|
+
// With no browser lamp can drive, the page is opened in one it can't, and a look straight after can come before
|
|
352
|
+
// the page has said it's open: the look waits for it, as long as it's given to wait.
|
|
353
|
+
if (!target && !problem) {
|
|
354
|
+
const until = Date.now() + Math.max(0, wait) * 1000;
|
|
355
|
+
while (!pages.size && Date.now() < until) await sleep(100);
|
|
356
|
+
}
|
|
334
357
|
// Another size is for lamp's own copy: the person's window is the size they made it.
|
|
335
358
|
if (target?.where === "copy") {
|
|
336
359
|
if (size || copySized) await target.page.size(size?.[0], size?.[1]).catch(() => {});
|
|
@@ -393,14 +416,15 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
|
|
|
393
416
|
// Any open page says what's on it; there's no picture without a browser lamp can look into.
|
|
394
417
|
const ask = randomBytes(4).toString("hex"), page = [...pages.values()].pop();
|
|
395
418
|
seen = await new Promise((resolve) => {
|
|
396
|
-
|
|
397
|
-
setTimeout(() => { lookers.delete(ask); resolve(null); }, 3000);
|
|
419
|
+
// An answer ends the wait for one, so nothing is left waiting after it.
|
|
420
|
+
const given = setTimeout(() => { lookers.delete(ask); resolve(null); }, 3000);
|
|
421
|
+
lookers.set(ask, (answer) => { clearTimeout(given); resolve(answer); });
|
|
398
422
|
tell(page, "look", ask);
|
|
399
423
|
});
|
|
400
424
|
where = "page";
|
|
401
|
-
problem ??=
|
|
425
|
+
problem ??= `No picture. ${noBrowser()}`;
|
|
402
426
|
} else {
|
|
403
|
-
problem ??=
|
|
427
|
+
problem ??= `No page has opened${wait > 0 ? ` in ${wait} s` : ""}: open ${address} in a browser, then look again. ${noBrowser()}`;
|
|
404
428
|
}
|
|
405
429
|
// How each change since the last look went in the page looked at, or in any page when that one never said. A
|
|
406
430
|
// change a look said was held is said again once it has gone in.
|
|
@@ -455,7 +479,7 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
|
|
|
455
479
|
async function use(asked) {
|
|
456
480
|
const moves = Array.isArray(asked?.moves) ? asked.moves : [asked];
|
|
457
481
|
const target = await watched();
|
|
458
|
-
if (!target) throw new Problem(
|
|
482
|
+
if (!target) throw new Problem(`Nothing was done. ${noBrowser()}`);
|
|
459
483
|
// A page still opening, or opening again after a save, would lose what's pressed before it's in: each use waits
|
|
460
484
|
// for its tab, and makes no move in a page that hasn't settled by then.
|
|
461
485
|
let tab = null;
|
|
@@ -608,6 +632,11 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
|
|
|
608
632
|
if (what === "use") return json(200, await inTurn(() => use(asked)));
|
|
609
633
|
if (what === "card") return json(200, await inTurn(() => card(asked)));
|
|
610
634
|
if (what === "open") return json(200, { opened: await openWindow(), url: address });
|
|
635
|
+
if (what === "changing") {
|
|
636
|
+
const words = String(asked.words ?? "").replace(/\s+/g, " ").trim();
|
|
637
|
+
changing = words ? { words: words.length > 120 ? `${words.slice(0, 119).trimEnd()}…` : words, at: Date.now() } : null;
|
|
638
|
+
return json(200, { pages: pages.size });
|
|
639
|
+
}
|
|
611
640
|
if (what === "reload") { for (const page of pages.values()) tell(page, "reload", asked.fresh ? "fresh" : "now"); return json(200, { pages: pages.size }); }
|
|
612
641
|
if (what === "stop") { json(200, { stopped: true }); setTimeout(() => close().then(() => process.exit(0)), 50); return; }
|
|
613
642
|
return json(404, { problem: `lamp dev doesn't know "${what}"` });
|
|
@@ -616,7 +645,7 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
|
|
|
616
645
|
}
|
|
617
646
|
}
|
|
618
647
|
|
|
619
|
-
if (url.pathname === "/__lamp/page.js") return send(200, TYPES[".js"], `const LAMP = ${JSON.stringify({ gen, run })};\n${PAGE_SCRIPT}`);
|
|
648
|
+
if (url.pathname === "/__lamp/page.js") return send(200, TYPES[".js"], `const LAMP = ${JSON.stringify({ gen, run })};\n${PAGE_SCRIPT}${layout.game ? `\n${GAME_SCRIPT}` : ""}`);
|
|
620
649
|
if (url.pathname === "/__lamp/events") {
|
|
621
650
|
const id = url.searchParams.get("page") || randomBytes(4).toString("hex"), tab = url.searchParams.get("tab") || id;
|
|
622
651
|
// The last change this page has: what its files were served at, or what it last heard when the line broke.
|
|
@@ -636,7 +665,7 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
|
|
|
636
665
|
for (const path of change.changed) { changed.add(path); removed.delete(path); }
|
|
637
666
|
for (const path of change.removed) { removed.add(path); changed.delete(path); }
|
|
638
667
|
}
|
|
639
|
-
tell(page, "change", JSON.stringify({ gen, changed: [...changed], removed: [...removed], now: false, pill: true }), gen);
|
|
668
|
+
tell(page, "change", JSON.stringify({ gen, changed: [...changed], removed: [...removed], now: false, pill: true, read: [...served], ...changingWords() }), gen);
|
|
640
669
|
} else if (missed.length) {
|
|
641
670
|
// More was saved than is remembered: the page opens again from the files as they are.
|
|
642
671
|
tell(page, "reload", "now");
|
|
@@ -645,6 +674,23 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
|
|
|
645
674
|
request.on("close", () => { clearInterval(beat); if (pages.get(id) === page) pages.delete(id); });
|
|
646
675
|
return;
|
|
647
676
|
}
|
|
677
|
+
// The page's AI (LampAI in lamp.js, through `lampGatewayHost` in page.js): answered here with the person's own
|
|
678
|
+
// Claude Code or Codex (ai.mjs), only for the page itself on this Mac. Never another site in the person's browser,
|
|
679
|
+
// which can't send this request's header from another origin, nor another device the server is open to with --lan.
|
|
680
|
+
if (request.method === "POST" && url.pathname === "/__lamp/ai") {
|
|
681
|
+
const from = request.socket.remoteAddress || "";
|
|
682
|
+
const local = from === "127.0.0.1" || from === "::1" || from === "::ffff:127.0.0.1";
|
|
683
|
+
const origin = request.headers.origin;
|
|
684
|
+
if (!local || request.headers["x-lamp-page"] !== "1" || (origin && new URL(origin).host !== request.headers.host)) {
|
|
685
|
+
return json(403, { status: 403, body: JSON.stringify({ error: "The AI answers only the page on this Mac." }) });
|
|
686
|
+
}
|
|
687
|
+
const asked = await body(request);
|
|
688
|
+
const path = typeof asked?.path === "string" ? asked.path : "";
|
|
689
|
+
let given = {};
|
|
690
|
+
try { given = typeof asked?.body === "string" ? JSON.parse(asked.body) : asked?.body ?? {}; } catch {}
|
|
691
|
+
const [status, reply] = await answerAI(path, given);
|
|
692
|
+
return json(200, { status, body: JSON.stringify(reply) });
|
|
693
|
+
}
|
|
648
694
|
if (request.method === "POST" && url.pathname.startsWith("/__lamp/")) {
|
|
649
695
|
const message = await body(request);
|
|
650
696
|
if (message && url.pathname === "/__lamp/said") said(message);
|
|
@@ -660,6 +706,7 @@ export async function serve({ folder: given, port: asked, lan = false, quiet = f
|
|
|
660
706
|
let file = resolve(join(root, path));
|
|
661
707
|
if (file !== root && !file.startsWith(root + sep)) return send(403, "text/plain", "Outside the page");
|
|
662
708
|
const name = relative(root, file).split(sep).join("/");
|
|
709
|
+
served.add(name);
|
|
663
710
|
// Hidden files (.git, .lamp, .env) are no part of the page.
|
|
664
711
|
if (name.split("/").some((part) => part.startsWith("."))) return send(404, "text/plain", "Not found");
|
|
665
712
|
let info;
|