@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.
@@ -0,0 +1,133 @@
1
+ // An app's AI asked of OpenRouter with the person's own key: the one place it's sent, for Lamp's app (`openRouterText`,
2
+ // `openRouterChat`, `openRouterDecide` and `openRouterImage` in calls.mjs; `AppAI.viaOpenRouter`, `AppAct.openRouterStep`,
3
+ // `AppAI.decide` and `AppAI.image`) and for lamp's own answers
4
+ // (ai.mjs, with OPENROUTER_API_KEY). The key comes with each request and is kept nowhere here.
5
+
6
+ const ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
7
+ /** The model lamp answers with when none is named: the one Lamp's app offers first for apps' text. */
8
+ export const DEFAULT_MODEL = "anthropic/claude-sonnet-5.5";
9
+ const FILTERED = "The AI provider's content filter cut the reply off. Try rewording the request.";
10
+
11
+ /** Why OpenRouter gave no answer: `kind` "refused" (with its `status` and `reply`), "filtered", "timeout" or "stopped". */
12
+ export class OpenRouterTrouble extends Error {
13
+ constructor(kind, message, { status = 0, reply = {}, cost = 0 } = {}) {
14
+ super(message);
15
+ Object.assign(this, { kind, status, reply, cost });
16
+ }
17
+ }
18
+
19
+ /**
20
+ * A request for text: the prompt with its pictures (base64, with their `media_type`) and the system prompt. With
21
+ * `json` the answer must be a JSON object, with room for a long one (a cut-off reply is what breaks apps), and with a
22
+ * `schema` it must fit it too.
23
+ */
24
+ export function textBody({ prompt, images = [], system = "", model = DEFAULT_MODEL, json = false, schema } = {}) {
25
+ const messages = [];
26
+ if (String(system).trim()) messages.push({ role: "system", content: system });
27
+ messages.push({ role: "user", content: [{ type: "text", text: prompt },
28
+ ...(Array.isArray(images) ? images : []).filter((image) => image?.data).map((image) => ({ type: "image_url", image_url: { url: `data:${image.media_type || "image/jpeg"};base64,${image.data}` } }))] });
29
+ const body = { model, messages, usage: { include: true } };
30
+ if (json) {
31
+ body.response_format = schema ? { type: "json_schema", json_schema: { name: "answer", strict: true, schema } } : { type: "json_object" };
32
+ body.max_tokens = 16000;
33
+ }
34
+ return body;
35
+ }
36
+
37
+ /**
38
+ * One chat completion: `{message, cost}`, the model's message as OpenRouter gives it (its text, or its tool calls) and
39
+ * what it cost. A provider's safety filter sometimes refuses an ordinary request at random (finish_reason
40
+ * "content_filter", an empty reply, HTTP 200), so it's asked again, twice at most, within the same `seconds`.
41
+ */
42
+ export async function chat({ key, body, seconds = 120, signal, fetcher = fetch }) {
43
+ const deadline = Date.now() + seconds * 1000;
44
+ let cost = 0;
45
+ for (let attempt = 0; attempt < 3; attempt++) {
46
+ // Whole milliseconds: a timer takes nothing finer, and the seconds given may have more.
47
+ const left = Math.floor(deadline - Date.now());
48
+ if (left < 1000) throw new OpenRouterTrouble("timeout", "This ran out of time.", { cost });
49
+ let answer;
50
+ try {
51
+ answer = await fetcher(ENDPOINT, { method: "POST", body: JSON.stringify(body),
52
+ headers: { Authorization: `Bearer ${key || ""}`, "Content-Type": "application/json", "X-Title": "Lamp" },
53
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(left)]) : AbortSignal.timeout(left) });
54
+ } catch (error) {
55
+ if (signal?.aborted) throw new OpenRouterTrouble("stopped", "Stopped.", { cost });
56
+ if (error?.name === "TimeoutError" || Date.now() >= deadline) throw new OpenRouterTrouble("timeout", "This ran out of time.", { cost });
57
+ throw new OpenRouterTrouble("refused", "Couldn't reach OpenRouter.", { cost });
58
+ }
59
+ let reply = {};
60
+ try { reply = await answer.json(); } catch {}
61
+ cost += Number(reply?.usage?.cost) || 0;
62
+ const choice = Array.isArray(reply?.choices) ? reply.choices[0] : null;
63
+ if (answer.status !== 200 || !choice) throw new OpenRouterTrouble("refused", "OpenRouter didn't answer.", { status: answer.status, reply, cost });
64
+ if (choice.finish_reason === "content_filter") {
65
+ if (attempt < 2) { await new Promise((done) => setTimeout(done, Math.min(400 << attempt, Math.max(0, deadline - Date.now())))); continue; }
66
+ throw new OpenRouterTrouble("filtered", FILTERED, { cost });
67
+ }
68
+ return { message: choice.message ?? {}, cost };
69
+ }
70
+ throw new OpenRouterTrouble("refused", "The AI didn't answer.", { cost });
71
+ }
72
+
73
+ /** A text answer: `{text, cost}`. */
74
+ export async function text(given) {
75
+ const { message, cost } = await chat({ ...given, body: textBody(given) });
76
+ return { text: typeof message.content === "string" ? message.content : "", cost };
77
+ }
78
+
79
+ /** One request to OpenRouter, within `seconds`: its status and its JSON, or why there was none. */
80
+ async function post(path, { key, body, seconds, signal, fetcher = fetch }) {
81
+ const timeout = AbortSignal.timeout(Math.floor(seconds * 1000));
82
+ let answer;
83
+ try {
84
+ answer = await fetcher(`https://openrouter.ai/api/v1/${path}`, { method: "POST", body: JSON.stringify(body),
85
+ headers: { Authorization: `Bearer ${key || ""}`, "Content-Type": "application/json", "X-Title": "Lamp" },
86
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
87
+ } catch (error) {
88
+ if (signal?.aborted) throw new OpenRouterTrouble("stopped", "Stopped.");
89
+ if (error?.name === "TimeoutError") throw new OpenRouterTrouble("timeout", "This ran out of time.");
90
+ throw new OpenRouterTrouble("refused", "Couldn't reach OpenRouter.");
91
+ }
92
+ let reply = {};
93
+ try { reply = await answer.json(); } catch {}
94
+ return { status: answer.status, reply: reply && typeof reply === "object" ? reply : {} };
95
+ }
96
+
97
+ /**
98
+ * Decisions from a decision model (`LampAI.decide`, `AppAI.decide`): `{answers, cost}`. Rate limits (429) and overload
99
+ * (529) are expected under load, so it backs off and asks again three times at most. A request the model can't take
100
+ * (422) is "invalid", with what it said.
101
+ */
102
+ export async function decide({ key, model, state, questions, seconds = 60, signal, fetcher }) {
103
+ for (let attempt = 0; attempt < 4; attempt++) {
104
+ const { status, reply } = await post("systemone", { key, body: { state, model, questions }, seconds, signal, fetcher });
105
+ if ((status === 429 || status === 529) && attempt < 3) { await new Promise((done) => setTimeout(done, 500 << attempt)); continue; }
106
+ if (status !== 200 || !reply.answers || typeof reply.answers !== "object") {
107
+ const detail = reply.error?.message ?? (reply.detail !== undefined ? String(typeof reply.detail === "string" ? reply.detail : JSON.stringify(reply.detail)) : `HTTP ${status}`);
108
+ throw new OpenRouterTrouble(status === 422 ? "invalid" : "refused", status === 422 ? `The decision request was invalid: ${detail}` : "The decision model didn't answer.", { status, reply });
109
+ }
110
+ // Jev charges per input token ($0.042 per million); OpenRouter may report the exact cost.
111
+ // A cost OpenRouter reports stands, nothing included; the estimate is only for when it reports none.
112
+ const reported = reply.usage?.cost;
113
+ const cost = typeof reported === "number" && Number.isFinite(reported) ? reported : (Number(reply.usage?.input_tokens) || 0) * 0.042 / 1_000_000;
114
+ return { answers: reply.answers, cost };
115
+ }
116
+ throw new OpenRouterTrouble("busy", "The decision model is busy. Try again in a moment.");
117
+ }
118
+
119
+ /**
120
+ * A picture from an image model (`LampAI.makeImage` and its edits, `AppAI.image`): `{image, cost}`, the PNG in base64.
121
+ * `references` are the pictures to edit or build on (base64, with their `media_type`).
122
+ */
123
+ export async function image({ key, model, prompt, references = [], aspectRatio, seconds = 240, signal, fetcher }) {
124
+ const body = { model, prompt, n: 1, output_format: "png" };
125
+ if (aspectRatio) body.aspect_ratio = aspectRatio;
126
+ const given = (Array.isArray(references) ? references : []).filter((reference) => reference?.data);
127
+ if (given.length) body.input_references = given.map((reference) => ({ type: "image_url", image_url: { url: `data:${reference.media_type || "image/png"};base64,${reference.data}` } }));
128
+ const { status, reply } = await post("images", { key, body, seconds, signal, fetcher });
129
+ if (status !== 200) throw new OpenRouterTrouble("refused", "The image model didn't answer.", { status, reply });
130
+ const picture = Array.isArray(reply.data) ? reply.data[0]?.b64_json : null;
131
+ if (typeof picture !== "string" || !picture) throw new OpenRouterTrouble("empty", "The image model didn't return a picture.", { reply });
132
+ return { image: picture, cost: Number(reply.usage?.cost) || 0 };
133
+ }
package/cli/outside.mjs CHANGED
@@ -6,9 +6,9 @@
6
6
  // carries the folder's path there and the answer back.
7
7
 
8
8
  import { createConnection } from "node:net";
9
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
- import { join } from "node:path";
11
+ import { dirname, join } from "node:path";
12
12
  import { Problem, stateOf } from "./state.mjs";
13
13
 
14
14
  // The handshake's version, as Lamp's helper sends it (`OutsideKit.protocolVersion`).
@@ -113,6 +113,20 @@ export function answerOf(result) {
113
113
  return { outcome, facts, words, pictures, failed: result?.isError === true };
114
114
  }
115
115
 
116
+ /**
117
+ * The Lamp whose own folder an app's is in (`~/Library/Application Support/<Lamp>/Apps/…`, followed through a link to
118
+ * it), by its bundle id, or null for a folder of anyone else's.
119
+ */
120
+ export function lampOwning(folder) {
121
+ let real = folder;
122
+ try { real = realpathSync(folder); } catch {}
123
+ const name = new RegExp(`${join(homedir(), "Library", "Application Support").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/(Lamp(?: Nightly| Dev(?: [A-Za-z0-9-]+)?)?)/Apps/`).exec(real)?.[1];
124
+ if (!name) return null;
125
+ if (name === "Lamp") return "dev.lamp.host";
126
+ if (name === "Lamp Nightly") return "dev.lamp.host.nightly";
127
+ return name === "Lamp Dev" ? "dev.lamp.host.dev" : `dev.lamp.host.dev.${name.slice("Lamp Dev ".length)}`;
128
+ }
129
+
116
130
  // MARK: Which folders are Lamp's app's
117
131
 
118
132
  const markOf = (folder) => join(stateOf(folder), "in-lamp.json");
@@ -138,3 +152,52 @@ export function keepPictures(folder, pictures) {
138
152
  return path;
139
153
  });
140
154
  }
155
+
156
+ /**
157
+ * The link Lamp's app keeps for an app in its own folder (its "Wish Links.json", by the app's instance id), as lamp
158
+ * keeps a link (`readLink`), or null. lamp shares such an app itself, to the same link, whether Lamp is open or not.
159
+ */
160
+ export function lampsKeptLink(folder) {
161
+ const where = keptLinkOf(folder);
162
+ if (!where) return null;
163
+ let kept;
164
+ try { kept = JSON.parse(readFileSync(where.file, "utf8"))[where.key]; } catch { return null; }
165
+ if (typeof kept?.id !== "string" || typeof kept.secret !== "string" || typeof kept.url !== "string") return null;
166
+ const shown = kept.shown ?? {};
167
+ let site = "https://lamp.dev";
168
+ try { site = new URL(kept.url).origin; } catch {}
169
+ return { site, id: kept.id, url: kept.url, secret: kept.secret, name: shown.name ?? "", words: shown.words ?? "", line: shown.line ?? "",
170
+ by: shown.by ?? "", asks: shown.asks ?? [], ...(shown.file ? { file: true } : {}) };
171
+ }
172
+
173
+ /** Tells Lamp's app what lamp shared to an app's link (or, with none, that it was taken down), so its Share Link card
174
+ * shows the link as it is now. */
175
+ export function keepInLamp(folder, link) {
176
+ const where = keptLinkOf(folder);
177
+ if (!where) return;
178
+ let all = {};
179
+ try { all = JSON.parse(readFileSync(where.file, "utf8")) || {}; } catch {}
180
+ const was = all[where.key] ?? {};
181
+ // Taken down: Lamp's card has no link for it any more.
182
+ if (!link) delete all[where.key];
183
+ else all[where.key] = { ...was, id: link.id, url: link.url, secret: link.secret,
184
+ // Its picture isn't the one Lamp took (Lamp knows its own by a digest), so the card offers to send Lamp's again.
185
+ shown: { ...(({ picture: _picture, ...rest }) => rest)(was.shown ?? {}), name: link.name, words: link.words, line: link.line, by: link.by,
186
+ asks: link.asks ?? [], file: link.file ? true : undefined,
187
+ ...(link.shape !== undefined ? { shape: link.shape === "fill" ? "fill" : Number(link.shape).toFixed(4) } : {}) } };
188
+ mkdirSync(dirname(where.file), { recursive: true });
189
+ writeFileSync(where.file, JSON.stringify(all), { mode: 0o600 });
190
+ }
191
+
192
+ /** Where Lamp's app keeps an app's link: its Lamp's "Wish Links.json", under the app's instance id (`.lamp/meta.json`). */
193
+ function keptLinkOf(folder) {
194
+ let real = folder;
195
+ try { real = realpathSync(folder); } catch {}
196
+ const at = real.lastIndexOf("/Apps/");
197
+ if (!lampOwning(real) || at < 0) return null;
198
+ const app = join(real.slice(0, at), "Apps", real.slice(at + 6).split("/")[0]);
199
+ let meta;
200
+ try { meta = JSON.parse(readFileSync(join(app, ".lamp", "meta.json"), "utf8")); } catch { return null; }
201
+ const key = typeof meta?.instance === "string" && meta.instance ? meta.instance : meta?.id;
202
+ return typeof key === "string" && key ? { file: join(real.slice(0, at), "Wish Links.json"), key } : null;
203
+ }
package/cli/page.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // game's code left running each time that code runs again, and a timer of lamp's set through the page's
6
6
  // `setTimeout` would go with them, and the log with it.
7
7
  const later = window.setTimeout.bind(window), ask = window.fetch.bind(window);
8
+ const unlater = window.clearTimeout.bind(window), every = window.setInterval.bind(window), unevery = window.clearInterval.bind(window);
8
9
  // A page opened again to show its beginning (`lamp look --reopen`) says so to the game runtime, which then starts
9
10
  // the game from its start, its saves kept (`window.lampFreshStart` in lamp-game.js).
10
11
  const FRESH = 'lamp.dev.fresh';
@@ -19,10 +20,154 @@
19
20
  };
20
21
  const said = message => post('said', message, true);
21
22
 
23
+ // MARK: The page's AI
24
+ // LampAI in lamp.js reaches Lamp's app through a host bridge when the app shows the page; here lamp itself answers
25
+ // (`/__lamp/ai`, with the person's own Claude Code or Codex), through the same shape of bridge lamp.js takes from
26
+ // Lamp's Android app: postMessage of a request, an onmessage with its answer.
27
+ if (!window.lampGatewayHost && !(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lampGateway)) {
28
+ const gateway = { onmessage: null };
29
+ gateway.postMessage = (text) => {
30
+ let asked;
31
+ try { asked = JSON.parse(text); } catch (e) { return; }
32
+ const answer = (status, body) => { if (typeof gateway.onmessage === 'function') gateway.onmessage({ data: JSON.stringify({ id: asked.id, status, body }) }); };
33
+ const stop = new AbortController();
34
+ const timer = later(() => stop.abort(), Math.max(1000, Number(asked.timeout) || 240000));
35
+ ask('/__lamp/ai', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Lamp-Page': '1' }, body: JSON.stringify({ path: asked.path, body: asked.body }), signal: stop.signal })
36
+ .then((r) => r.json()).then((r) => answer(r.status, r.body), () => answer(502, JSON.stringify({ error: 'lamp dev took too long to answer.' })))
37
+ .finally(() => unlater(timer));
38
+ };
39
+ try { Object.defineProperty(window, 'lampGatewayHost', { value: gateway, configurable: true }); } catch (e) {}
40
+ }
41
+
42
+ // MARK: The card on a change
43
+ // The same card Lamp's app puts on an open app as a change goes in (`LampTransition.Look`, TransitionLook.swift, which
44
+ // says how it looks: keep the two in step), at the foot of the page: Lamp's mark turning, "Updating" and what's
45
+ // changing (the words the coding agent gave with `lamp changing`, if it did), then "Updated" for a moment once the
46
+ // change is on screen, so every change is seen to land. It covers nothing but itself and lets every click through.
47
+ // The mark turns by the browser's compositor, so it keeps turning while the page is busy with the new code. Never in
48
+ // a picture lamp takes (`__lampLightHidden`), and still where less motion is asked for.
49
+ const light = (() => {
50
+ const HUES = ['#1f8a4a', '#7cbf3a', '#f6b81c', '#f08a1c', '#e2402a', '#d23778'];
51
+ // As in the Look: the card stays at least this long, "Updated" this long, and the mark turns once in this long.
52
+ const SHOWN_AT_LEAST = 1.2, HELD_AFTER = 1.4, SPIN_EVERY = 1.4;
53
+ const still = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) { return false; } };
54
+ let root = null, card = null, bands = null, title = null, detail = null, spinning = null, shownAt = 0, gone = null, hidden = false, safety = null;
55
+ // The card's way out ("Updated" fading, or a fade), which a change coming meanwhile stops.
56
+ let outgoing = null;
57
+ const make = () => {
58
+ if (root && root.isConnected) return;
59
+ root = document.createElement('div');
60
+ root.setAttribute('aria-hidden', 'true');
61
+ root.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647;overflow:hidden;contain:strict';
62
+ card = document.createElement('div');
63
+ card.style.cssText = 'position:absolute;left:50%;bottom:min(20px,6vh);transform:translateX(-50%);display:flex;align-items:center;gap:8px;' +
64
+ 'height:36px;box-sizing:border-box;padding:0 16px 0 12px;max-width:min(460px,calc(100vw - 32px));border-radius:18px;' +
65
+ 'background:rgba(20,20,20,.86);border:1px solid rgba(255,255,255,.12);box-shadow:0 3px 12px rgba(0,0,0,.3);' +
66
+ 'font:13px/17px -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:#fff;white-space:nowrap;opacity:0';
67
+ // Lamp's mark: its stripes on their slant, in a disc, which turns while the change is under way.
68
+ const mark = document.createElement('div');
69
+ mark.style.cssText = 'flex:none;width:18px;height:18px;border-radius:50%;overflow:hidden;position:relative';
70
+ bands = document.createElement('div');
71
+ bands.style.cssText = `position:absolute;left:-5.4px;top:-5.4px;width:28.8px;height:28.8px;transform:rotate(${-30}deg);` +
72
+ `background:linear-gradient(to bottom, ${HUES.map((hue, i) => `${hue} ${i * 100 / 6}% ${(i + 1) * 100 / 6}%`).join(', ')})`;
73
+ mark.appendChild(bands);
74
+ title = document.createElement('span');
75
+ title.style.cssText = 'flex:none;font-weight:600';
76
+ detail = document.createElement('span');
77
+ detail.style.cssText = 'min-width:0;overflow:hidden;text-overflow:ellipsis;opacity:.72';
78
+ card.append(mark, title, detail);
79
+ root.appendChild(card);
80
+ // Beside the body rather than in it: a game running its code again clears what its body held.
81
+ document.documentElement.appendChild(root);
82
+ if (hidden) root.style.visibility = 'hidden';
83
+ };
84
+ const goAfter = seconds => {
85
+ unlater(gone);
86
+ gone = later(() => { spinning = null; root && root.remove(); root = null; shownAt = 0; }, seconds * 1000);
87
+ };
88
+ const show = () => {
89
+ make();
90
+ unlater(gone);
91
+ if (outgoing) { outgoing.cancel(); outgoing = null; }
92
+ if (shownAt) return;
93
+ shownAt = performance.now();
94
+ card.animate([{ opacity: 0, transform: still() ? 'translateX(-50%)' : 'translate(-50%, 10px)' }, { opacity: 1, transform: 'translateX(-50%)' }],
95
+ { duration: 250, easing: 'ease-out', fill: 'forwards' });
96
+ };
97
+ const spin = () => {
98
+ if (still() || spinning) return;
99
+ spinning = bands.animate([{ transform: 'rotate(-30deg)' }, { transform: 'rotate(330deg)' }], { duration: SPIN_EVERY * 1000, iterations: Infinity });
100
+ };
101
+ // Never left over a page that doesn't say its change is on screen (a game stopped by an error): it goes after a
102
+ // minute, as the card in Lamp's app goes when Lamp stops renewing it.
103
+ const watch = () => { unlater(safety); safety = later(() => api.fade(), 60000); };
104
+ let words = '';
105
+ const api = {
106
+ // A change came: the card comes up with "Updating", what's changing, and the mark turning.
107
+ glow(given) {
108
+ if (typeof given === 'string') words = given;
109
+ show();
110
+ watch();
111
+ title.textContent = 'Updating';
112
+ detail.textContent = words;
113
+ spin();
114
+ },
115
+ // The game stands still taking it in: the card stays as it is until `reveal`.
116
+ turn() { api.glow(); },
117
+ // The change is on screen: "Updated" for a moment, then the card goes.
118
+ reveal() {
119
+ if (!root) return;
120
+ unlater(safety);
121
+ title.textContent = 'Updated';
122
+ if (spinning) { spinning.cancel(); spinning = null; }
123
+ if (!still()) bands.parentNode.animate([{ transform: 'scale(1.25)' }, { transform: 'scale(1)' }], { duration: 300, easing: 'ease-out' });
124
+ const wait = Math.max(HELD_AFTER, SHOWN_AT_LEAST - (performance.now() - shownAt) / 1000);
125
+ if (outgoing) outgoing.cancel();
126
+ outgoing = card.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 300, delay: wait * 1000, fill: 'forwards', easing: 'ease-in-out' });
127
+ words = '';
128
+ goAfter(wait + 0.35);
129
+ },
130
+ // The change didn't go in now (held for a pause or a Reload): the card goes.
131
+ fade() {
132
+ unlater(safety);
133
+ if (!root) return;
134
+ if (outgoing) outgoing.cancel();
135
+ outgoing = card.animate([{ opacity: getComputedStyle(card).opacity }, { opacity: 0 }], { duration: 250, fill: 'forwards' });
136
+ words = '';
137
+ goAfter(0.3);
138
+ },
139
+ hide(on) { hidden = !!on; if (root) root.style.visibility = on ? 'hidden' : ''; },
140
+ get words() { return words; },
141
+ };
142
+ return api;
143
+ })();
144
+ // Left out of the pictures lamp takes of the page.
145
+ window.__lampLightHidden = on => light.hide(on);
146
+ // A page opening again for a change goes on saying so until its new code draws, with the same words.
147
+ const TURNING = 'lamp.dev.turning', WORDS = 'lamp.dev.words';
148
+ const turnAcross = () => { try { sessionStorage.setItem(TURNING, String(Date.now())); sessionStorage.setItem(WORDS, light.words); } catch (e) {} };
149
+ try {
150
+ const since = Number(sessionStorage.getItem(TURNING)), carried = sessionStorage.getItem(WORDS) || '';
151
+ sessionStorage.removeItem(TURNING);
152
+ sessionStorage.removeItem(WORDS);
153
+ if (since && Date.now() - since < 30000) light.glow(carried);
154
+ } catch (e) {}
155
+ // How the game said a change went, read for the card: it goes in now, stands still taking it in, waits, or is on screen.
156
+ const lit = message => {
157
+ const state = message && message.state;
158
+ if (state === 'swapped') light.reveal();
159
+ else if (state === 'rerun') light.turn();
160
+ else if (state === 'reloading') { light.turn(); turnAcross(); }
161
+ else if (state === 'held') light.fade();
162
+ // Once its new frames can show, as Lamp's app reads it (`PageLive`): a game put back where it was says so before its
163
+ // first frame and its shaders.
164
+ else if (state === 'shown') light.reveal();
165
+ };
166
+
22
167
  // What Lamp's app around a page gives it: with these the game runtime knows changes come while it's played, and says
23
168
  // how each one went and when the game is past its loading screen.
24
169
  const handlers = {
25
- lampLive: { postMessage: said }, lampPlaying: { postMessage: state => said({ state: 'page-' + state }) },
170
+ lampLive: { postMessage: message => { lit(message); said(message); } }, lampPlaying: { postMessage: state => said({ state: 'page-' + state }) },
26
171
  // The person played while lamp held the game's controller (`lamp use stick`), and has it back.
27
172
  lampDriveTakenOver: { postMessage: why => said({ state: 'taken-over', why: String(why || '') }) },
28
173
  };
@@ -41,10 +186,15 @@
41
186
  const real = console[level];
42
187
  console[level] = (...parts) => { real.apply(console, parts); say(level === 'info' ? 'log' : level, parts); };
43
188
  }
189
+ // Whether the page runs on the game runtime (lamp-game.js), which says so. A game's page without it has `__lampLive`
190
+ // too (lamp-live.js, given after this script), so that alone doesn't tell; a runtime from before it said so does have
191
+ // its own `__lampLive`, never lamp-live.js's.
192
+ const runtime = () => !!window.__lampRuntime || (!!window.__lampLive && !window.__lampLive.lampHelper);
44
193
  // A page with the game runtime logs its own errors; any other page's are logged here.
45
- addEventListener('error', e => { if (!window.__lampLive) say('error', [e.message + ' (' + (String(e.filename || '').split('/').pop() || 'the page') + ':' + e.lineno + ')']); });
46
- addEventListener('unhandledrejection', e => { if (!window.__lampLive) say('error', [String(e.reason && e.reason.message || e.reason)]); });
47
- addEventListener('load', () => said({ state: 'page-loaded' }));
194
+ addEventListener('error', e => { if (!runtime()) say('error', [e.message + ' (' + (String(e.filename || '').split('/').pop() || 'the page') + ':' + e.lineno + ')']); });
195
+ addEventListener('unhandledrejection', e => { if (!runtime()) say('error', [String(e.reason && e.reason.message || e.reason)]); });
196
+ // A page without the game runtime is on screen once it has loaded.
197
+ addEventListener('load', () => { said({ state: 'page-loaded' }); if (!runtime()) light.reveal(); });
48
198
 
49
199
  // Changes. The line says which change this copy's files had, so one saved while the page was opening comes as soon
50
200
  // as the line is open; the browser says the last it heard when the line is opened again after a break.
@@ -62,10 +212,12 @@
62
212
  events.addEventListener('hello', e => { if (e.data !== LAMP.run) location.reload(); });
63
213
  events.addEventListener('change', e => {
64
214
  const change = JSON.parse(e.data);
65
- // A game takes the change and keeps the player's place; any other page opens again.
215
+ light.glow(typeof change.words === 'string' ? change.words : '');
216
+ // A game takes the change and keeps the player's place (on the runtime, or where lamp-live.js can); any other page
217
+ // opens again.
66
218
  let taken = false;
67
219
  try { taken = !!(window.__lampLive && window.__lampLive(change)); } catch (e) {}
68
- if (!taken) { said({ gen: change.gen, state: 'reloading' }); location.reload(); }
220
+ if (!taken) { light.turn(); turnAcross(); said({ gen: change.gen, state: 'reloading' }); location.reload(); }
69
221
  });
70
222
  events.addEventListener('reload', reopen);
71
223
  // With no browser lamp can look into, the page says what's on it itself.
@@ -133,11 +285,11 @@
133
285
  // A game drawn on a stage of its own shape, fitted in the window with bars beside or above it: the stage's size.
134
286
  // One that fills the window, whatever its shape, has none.
135
287
  let stage = null;
136
- if (window.__lampLive) {
288
+ if (runtime()) {
137
289
  const canvas = [...document.querySelectorAll('canvas')].map(c => [c, c.getBoundingClientRect()]).sort((a, b) => b[1].width * b[1].height - a[1].width * a[1].height)[0];
138
290
  if (canvas && canvas[0].width > 1 && canvas[0].height > 1 && (width - canvas[1].width >= 2 || height - canvas[1].height >= 2)) stage = [canvas[0].width, canvas[0].height];
139
291
  }
140
- return { tab, stage, title: document.title, path: location.pathname + location.search, width, height, dpr: devicePixelRatio || 1, game: !!window.__lampLive, screen: lines.join('\n') };
292
+ return { tab, stage, title: document.title, path: location.pathname + location.search, width, height, dpr: devicePixelRatio || 1, game: runtime(), screen: lines.join('\n') };
141
293
  };
142
294
 
143
295
  // The point of the control called this in the screen as text (`button "Add"`, or just `Add`), for a click by name.
package/cli/share.mjs CHANGED
@@ -5,25 +5,46 @@
5
5
  // The link's secret, which is what lets it be changed or taken down, is kept with lamp's own state for the folder,
6
6
  // never in the folder: a folder is committed and published, and the secret is whoever shared it's alone.
7
7
 
8
+ import { execFileSync } from "node:child_process";
8
9
  import { createHash } from "node:crypto";
9
10
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
10
- import { basename, join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { basename, dirname, join, resolve } from "node:path";
11
13
  import { deflateRawSync } from "node:zlib";
12
- import { Problem, stateOf } from "./state.mjs";
14
+ import { NOTES_FILE, Problem, stateOf } from "./state.mjs";
13
15
  import { ANY_BROWSER, CONTROLLERS, CONTROLLERS_FILE, DEVICE_FILE, lampJS, LIBRARIES } from "./dev.mjs";
14
16
  import { LIMITS, FILE_LIMITS, PAGE_LIMITS } from "../site/lib/shared-wishes.js";
15
- import { ideasOf, publishLink, removeFile, removeIdea, removeLink, sendFile, siteOrigin } from "./link.mjs";
17
+ import { ideasOf, makerOf, pageUp, publishLink, removeFile, removeIdea, removeLink, replyIdea, sendFile, siteOrigin } from "./link.mjs";
16
18
  import { exportApp } from "./lampapp.mjs";
17
19
 
18
20
  export { siteOrigin };
19
21
 
20
22
  const linkFileOf = (folder) => join(stateOf(folder), "link.json");
21
23
 
24
+
25
+ /**
26
+ * The maker's account on a site, as lamp keeps it (`lamp account`): `{ username, key }`, the key sent with every share
27
+ * so a link goes under the username (lamp.dev/<username>/<app>). Kept with lamp's state, never in a folder.
28
+ */
29
+ const accountsFile = () => join(process.env.LAMP_DEV_HOME || join(homedir(), ".lamp", "dev"), "account.json");
30
+ export function accountFor(site = siteOrigin()) {
31
+ try { const kept = JSON.parse(readFileSync(accountsFile(), "utf8"))[site]; return kept?.key ? kept : null; } catch { return null; }
32
+ }
33
+ export function keepAccount(site, account) {
34
+ let all = {};
35
+ try { all = JSON.parse(readFileSync(accountsFile(), "utf8")) || {}; } catch {}
36
+ all[site] = account;
37
+ mkdirSync(dirname(accountsFile()), { recursive: true });
38
+ writeFileSync(accountsFile(), JSON.stringify(all, null, 2), { mode: 0o600 });
39
+ }
40
+
22
41
  /** The folder's link, as it was last shared: its address, its secret and the words it went out with. */
23
42
  export function readLink(folder) {
24
43
  try { return JSON.parse(readFileSync(linkFileOf(folder), "utf8")); } catch { return null; }
25
44
  }
26
- function writeLink(folder, link) {
45
+ /** Forgets the folder's link here, leaving it on the site (one Lamp's app let go of, say). */
46
+ export function forgetLink(folder) { rmSync(linkFileOf(folder), { force: true }); }
47
+ export function writeLink(folder, link) {
27
48
  mkdirSync(stateOf(folder), { recursive: true });
28
49
  writeFileSync(linkFileOf(folder), JSON.stringify(link, null, 2), { mode: 0o600 });
29
50
  }
@@ -83,7 +104,7 @@ export function pageFiles(layout, { device = ANY_BROWSER } = {}) {
83
104
  const walk = (dir, prefix) => {
84
105
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
85
106
  const path = prefix + entry.name;
86
- if (entry.name.startsWith(".") || entry.name === "node_modules" || path === "APP.md") continue;
107
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || path === NOTES_FILE) continue;
87
108
  if (entry.isDirectory()) walk(join(dir, entry.name), path + "/");
88
109
  else if (entry.isFile()) files.set(path, readFileSync(join(dir, entry.name)));
89
110
  }
@@ -118,6 +139,33 @@ export function pageFiles(layout, { device = ANY_BROWSER } = {}) {
118
139
 
119
140
  // MARK: The site
120
141
 
142
+ /** The repository a git remote's address names on GitHub, as "owner/name", or null for any other place. */
143
+ export function githubRepoOf(remote) {
144
+ const found = /^(?:https:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?\/?$/.exec(String(remote ?? "").trim());
145
+ return found && found[2] !== "." && found[2] !== ".." ? `${found[1]}/${found[2]}` : null;
146
+ }
147
+
148
+ /**
149
+ * The folder's repository on GitHub, for its link's page to point to: only when the folder is the top of a git
150
+ * checkout whose origin is on GitHub, and GitHub, asked with no sign-in, says anyone can read it. Null otherwise, and
151
+ * when git or GitHub can't answer, so nothing is linked that might not be public.
152
+ */
153
+ export async function publicRepoOf(folder) {
154
+ let top, remote;
155
+ try {
156
+ top = execFileSync("git", ["-C", folder, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
157
+ remote = execFileSync("git", ["-C", folder, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
158
+ } catch { return null; }
159
+ if (resolve(top) !== resolve(folder)) return null;
160
+ const repo = githubRepoOf(remote);
161
+ if (!repo) return null;
162
+ try {
163
+ const answer = await fetch(`https://api.github.com/repos/${repo}`, { headers: { accept: "application/vnd.github+json" }, signal: AbortSignal.timeout(6000) });
164
+ if (!answer.ok) return null;
165
+ return (await answer.json())?.private === false ? repo : null;
166
+ } catch { return null; }
167
+ }
168
+
121
169
  const megabytes = (bytes) => `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1 : 0)} MB`;
122
170
 
123
171
  /**
@@ -125,7 +173,7 @@ const megabytes = (bytes) => `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ?
125
173
  * are what the link says (`name`, `words`, and `line`, `by` and `asks` when given), `picture` a PNG or JPEG of the
126
174
  * page or null, `shape` "fill" or the page's width over its height. Answers the link and what went up.
127
175
  */
128
- export async function share({ layout, words, picture = null, shape = "fill", site = siteOrigin(), said = () => {}, file = true, anyway = false }) {
176
+ export async function share({ layout, words, picture = null, shape = "fill", site = siteOrigin(), said = () => {}, file = true, anyway = false, github, fromLamp = false }) {
129
177
  // The app's own file goes with the link, as Lamp's Share Link sends it, so whoever follows the link can open it in
130
178
  // Lamp too: made first, with Share…'s checks, so a page that can't be shared (a key in it) puts nothing up.
131
179
  const app = file ? exportApp(layout.folder, { anyway }) : null;
@@ -137,17 +185,22 @@ export async function share({ layout, words, picture = null, shape = "fill", sit
137
185
  const zip = zipOf(files);
138
186
  if (zip.length > FILE_LIMITS.total) throw new Problem(`the page is ${megabytes(zip.length)} packed, and a link carries ${megabytes(FILE_LIMITS.total)} at most`);
139
187
  if (picture && picture.length > LIMITS.picture) picture = null;
188
+ // The page links to the folder's public repository on GitHub, when it's one (`github` given in its place by a test).
189
+ const repo = github === undefined ? await publicRepoOf(layout.folder) : github;
140
190
 
141
191
  const body = {
142
192
  name: words.name, words: words.words, shape,
143
193
  ...(words.line ? { line: words.line } : {}), ...(words.by ? { by: words.by } : {}),
144
194
  ...(words.asks?.length ? { asks: words.asks } : {}), ...(picture ? { picture: picture.toString("base64") } : {}),
195
+ ...(repo ? { github: repo } : {}),
145
196
  };
146
197
  // The folder's own link when it has one on this site, changed in place; a link that's gone is made again.
147
198
  let earlier = readLink(layout.folder);
148
199
  if (earlier && earlier.site !== site) earlier = null;
149
200
  // A file an earlier share sent stays on the record until it's taken away, so a retry after a failed removal tries again.
150
- let link = { site, ...(await publishLink({ site, body, earlier })), ...words, shape, ...(earlier?.file ? { file: true } : {}) };
201
+ // 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.
202
+ let link = { site, ...(await publishLink({ site, body, earlier, account: accountFor(site)?.key })), ...words, shape, ...(earlier?.file ? { file: true } : {}),
203
+ ...(fromLamp ? { fromLamp: true } : {}) };
151
204
  // Kept before the page goes up: a link whose page didn't make it is still this folder's to send again or take down.
152
205
  writeLink(layout.folder, link);
153
206
 
@@ -164,7 +217,8 @@ export async function share({ layout, words, picture = null, shape = "fill", sit
164
217
  const version = await sendFile({ site, link, bytes: zip, page: true, said });
165
218
  link = { ...link, version, shared: new Date().toISOString() };
166
219
  writeLink(layout.folder, link);
167
- return { url: link.url, version, files: files.length, bytes: zip.length, picture: !!picture, file: !!app };
220
+ await pageUp({ site, id: link.id, version, said });
221
+ return { url: link.url, version, files: files.length, bytes: zip.length, picture: !!picture, file: !!app, github: repo || null };
168
222
  }
169
223
 
170
224
  /** Takes the folder's link down, with its page and the ideas sent to it. */
@@ -177,17 +231,31 @@ export async function unshare(folder) {
177
231
  }
178
232
 
179
233
  /** The ideas people playing the link have sent its maker and that are still open: each with its number. */
180
- export async function ideas(folder) {
234
+ export async function ideas(folder, { earlier = false } = {}) {
181
235
  const link = readLink(folder);
182
236
  if (!link) throw new Problem("this folder has no link: lamp share makes one");
183
- return { url: link.url, ideas: await ideasOf({ site: link.site, link }) };
237
+ return { url: link.url, ideas: await ideasOf({ site: link.site, link, earlier }) };
184
238
  }
185
239
 
186
240
  /** Says what became of an idea: it's in (the site writes to whoever asked to hear), or it's let go. */
187
- export async function finishIdea(folder, n, built) {
241
+ export async function finishIdea(folder, n, built, note) {
188
242
  const link = readLink(folder);
189
243
  if (!link) throw new Problem("this folder has no link");
190
- return removeIdea({ site: link.site, link, n, built });
244
+ return removeIdea({ site: link.site, link, n, built, note });
245
+ }
246
+
247
+ /** Says something more about an idea to whoever sent it; with no note, that their answer is seen to. */
248
+ export async function answerIdea(folder, n, note) {
249
+ const link = readLink(folder);
250
+ if (!link) throw new Problem("this folder has no link");
251
+ return replyIdea({ site: link.site, link, n, note });
252
+ }
253
+
254
+ /** The maker's own address for the link's ideas, or, with `email`, given ("" takes it away). */
255
+ export async function makerEmail(folder, email) {
256
+ const link = readLink(folder);
257
+ if (!link) throw new Problem("this folder has no link: lamp share makes one");
258
+ return makerOf({ site: link.site, link, email });
191
259
  }
192
260
 
193
261
  /** The page's name where nothing gave one: its title, or its folder's name. */