@lampdotdev/lamp 0.36.2-nightly.24 → 0.36.2-nightly.26
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/web-app/SKILL.md +1 -1
- package/cli/app-ai.mjs +2 -2
- package/cli/ignore.mjs +56 -0
- package/cli/lampapp.mjs +16 -8
- package/cli/lamps/lamp-for-games.js +32 -6
- package/cli/lamps/lamp.js +32 -6
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -1
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +24 -6
- package/cli/lamps/web-starter/lamp.js +32 -6
- package/cli/local-ai.mjs +1 -1
- package/cli/share.mjs +4 -1
- package/cli/skill/SKILL.md +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Mac app, `projectOf`), or any folder with an `index.html`; without one, the fold
|
|
|
18
18
|
| `lamp new <folder> [--name "Its Name"] [--web \| --mac]` | Makes a new web game's folder from Lamp's starting files, or with `--web` a new web app's, or with `--mac` a new Mac app in Swift. |
|
|
19
19
|
| `lamp init [--update]` | Puts Lamp's game runtime beside a page that's already there, changing none of the page's own files. `--update` writes again the Lamp files the folder has (`lamp.js` too), adding the game's only to a page that's a game already. |
|
|
20
20
|
| `lamp unpack <file.lampapp> [folder]` | Unpacks a Lamp app file into a new folder beside it, named for it. A file carries none of Lamp's own files: a page gets them beside it as it's unpacked (the runtime for a game, lamp.js, the libraries its import map names), and a Mac app gets Package.swift, build.sh and LampSupport.swift from Lamp's starting files as it's first built (`writeMacStarter`), where it has none. One with a widget, a share extension or a 3D scene is left to Lamp's app, which writes the files they're built with. |
|
|
21
|
-
| `lamp export [--to file] [--anyway]` | Makes the app's Lamp app file as Lamp's Share… does (`Sharing.export`, followed by `cli/lampapp.mjs`): its own files as its kind travels, Assets/, APP.md, what it declares, its credits with the app added to the chain, the licenses they name, its icon's design and lamp-app.json, never Lamp's own files. Refused when a license its credits name is gone or something in it is shaped like a key; `--anyway` sends work its credits give no license for. The shapes of keys, the names a license goes by and the sizes another Lamp opens are in `Resources/share-rules.json`, which Lamp's app reads too (`ShareRules`). |
|
|
21
|
+
| `lamp export [--to file] [--anyway]` | Makes the app's Lamp app file as Lamp's Share… does (`Sharing.export`, followed by `cli/lampapp.mjs`): its own files as its kind travels, Assets/, APP.md, what it declares, its credits with the app added to the chain, the licenses they name, its icon's design and lamp-app.json, never Lamp's own files, nor what the folder's `.lampignore` names. Refused when a license its credits name is gone or something in it is shaped like a key; `--anyway` sends work its credits give no license for. The shapes of keys, the names a license goes by and the sizes another Lamp opens are in `Resources/share-rules.json`, which Lamp's app reads too (`ShareRules`). |
|
|
22
22
|
| `lamp dev [--open] [--lan] [--port N]` | Runs the page and says how each change went. Each save goes into every open copy of it, with the card Lamp's app puts on an app taking a change (`page.js`, left out of lamp's pictures; `lamp changing "<words>"` says on it what's changing). `--open` opens a window for it; `--lan` lets other devices on the network open it. |
|
|
23
23
|
| `lamp open` | Shows the page in a window, running it in the background if it isn't running, and returns at once. |
|
|
24
24
|
| `lamp open --in-lamp` | Gives a Mac app to Lamp's app (`cli/outside.mjs`), over the socket the person's own coding agent uses (`OutsideServer`, `OutsideWork`): `open_project` takes the folder on, with Lamp's review the first time, and from then on `look`, `reload` and `dev` call `apply_change`, so a change goes into the open app as Lamp's app puts it there (`LiveReload`) and its outcome is said: live, held, reopened, not_built, crashed. `use` calls `use_app`, in plain sight in the app Lamp's app has open, and `stop` calls `close_app`. A folder Lamp's app no longer has (its refusal says `taken_on: false`) is lamp's own again, said once. `lamp open --here` has lamp run its own copy again. `LAMP_APP` names another Lamp by its bundle id (a Lamp Dev instance). A Mac app `lamp new --mac` makes has the `lamp-app.json` Lamp's app takes a folder on by. |
|
|
@@ -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. 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), 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. |
|
|
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. |
|
|
@@ -75,7 +75,7 @@ needs Lamp (for AI, `LampAI.status`'s `reason`), never an error or an empty scre
|
|
|
75
75
|
|
|
76
76
|
`LampAI.act(request, { about, context, tools, history, onStep })` runs a short loop: the AI picks calls among the
|
|
77
77
|
app's tools, the page runs them, the AI sees the results, until it replies (at most 4 AI calls and 6 writes a
|
|
78
|
-
turn, and no time limit unless the app sets one with `limits: { seconds }`, for a feature that should answer quickly). It works with
|
|
78
|
+
turn, and no time limit unless the app sets one with `limits: { seconds }`, for a feature that should answer quickly; each read gives the AI up to 32 KB of its result, and an app whose state is bigger raises it with `limits: { result }`, up to 256 KB, rather than having the AI page through it). It works with
|
|
79
79
|
whichever AI the person set up for apps' text, so don't tie it to voice: typing works wherever `act` does. Its comment in lamp.js has the shapes. This is the one recipe for it:
|
|
80
80
|
|
|
81
81
|
- Show the feature where `LampAI.status('act')` says it's available, and keep the app's own buttons for everything:
|
package/cli/app-ai.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// (OpenRouter: `chatBody`, `readMessage`), or one prompt whose answer must fit the step's JSON shape (Claude Code, Codex:
|
|
15
15
|
// `planPrompt`, `planSchema`, `readPlan`).
|
|
16
16
|
|
|
17
|
-
export const MAX_TOOLS = 24, MAX_CALLS = 8, MAX_STEPS = 4, MAX_BODY =
|
|
17
|
+
export const MAX_TOOLS = 24, MAX_CALLS = 8, MAX_STEPS = 4, MAX_BODY = 2 * 1024 * 1024, MAX_CHOICES = 4, MAX_CHOICE = 24;
|
|
18
18
|
/** The longest a step may take, whatever its route, before its call is taken to have stopped answering (`ACT_STEP_SECONDS`
|
|
19
19
|
* in lamp.js, `AppAct.turnSeconds`). A turn has no time of its own unless the app gives it one. */
|
|
20
20
|
export const MAX_TURN_SECONDS = 600;
|
|
@@ -66,7 +66,7 @@ function parseArg(raw, path) {
|
|
|
66
66
|
* steps, route, seconds, final}`, each tool's `args` as [name, shape] pairs by name. Throws `Refused`.
|
|
67
67
|
*/
|
|
68
68
|
export function readStep(text) {
|
|
69
|
-
if (Buffer.byteLength(text) > MAX_BODY) throw new Refused(413, "A step can carry at most
|
|
69
|
+
if (Buffer.byteLength(text) > MAX_BODY) throw new Refused(413, "A step can carry at most 2 MB.");
|
|
70
70
|
let body;
|
|
71
71
|
try { body = JSON.parse(text); } catch {}
|
|
72
72
|
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Refused(400, "A step needs a JSON object.");
|
package/cli/ignore.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// `.lampignore`: what an app's folder keeps out of what it shares (its page on lamp.dev, and the app's file that goes
|
|
2
|
+
// with its link): data only its maker's computer needs, too big to share, say (a 600 MB parts library a page fetches
|
|
3
|
+
// from elsewhere when it isn't there). The patterns are gitignore's simpler half: one a line, `#` for a note, `*`, `?` and
|
|
4
|
+
// `**`, a trailing `/` for a folder, a leading `/` (or any `/` inside) for a path from the folder's top, and otherwise a
|
|
5
|
+
// name matched at any depth. Nothing starting with `!`: what's left out stays out.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join, relative, sep } from "node:path";
|
|
9
|
+
|
|
10
|
+
export const IGNORE_FILE = ".lampignore";
|
|
11
|
+
|
|
12
|
+
/** A pattern as a regular expression over a path from the folder's top ("ldraw/parts/3001.dat"). */
|
|
13
|
+
function compile(line) {
|
|
14
|
+
let pattern = line.trim();
|
|
15
|
+
const folder = pattern.endsWith("/");
|
|
16
|
+
if (folder) pattern = pattern.replace(/\/+$/, "");
|
|
17
|
+
const anchored = pattern.startsWith("/") || pattern.includes("/");
|
|
18
|
+
pattern = pattern.replace(/^\/+/, "");
|
|
19
|
+
if (!pattern) return null;
|
|
20
|
+
let source = "";
|
|
21
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
22
|
+
const c = pattern[i];
|
|
23
|
+
if (c === "*" && pattern[i + 1] === "*") {
|
|
24
|
+
// `**/` matches any folders, none included; a lone `**` anything at all.
|
|
25
|
+
if (pattern[i + 2] === "/") { source += "(?:.*/)?"; i += 2; } else { source += ".*"; i += 1; }
|
|
26
|
+
} else if (c === "*") source += "[^/]*";
|
|
27
|
+
else if (c === "?") source += "[^/]";
|
|
28
|
+
else source += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
29
|
+
}
|
|
30
|
+
// What it names is left out with everything under it; a folder pattern names folders only, so it must have more after it.
|
|
31
|
+
return new RegExp(`^${anchored ? "" : "(?:.*/)?"}${source}${folder ? "/" : "(?:/|$)"}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether a path from the folder's top ("ldraw/parts/3001.dat"), a folder's when `folder` is true, is one its
|
|
36
|
+
* `.lampignore` leaves out; never, with none.
|
|
37
|
+
*/
|
|
38
|
+
export function ignoredIn(folder) {
|
|
39
|
+
let lines = [];
|
|
40
|
+
try { lines = readFileSync(join(folder, IGNORE_FILE), "utf8").split(/\r?\n/); } catch {}
|
|
41
|
+
const tests = lines.map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && !line.startsWith("!")).map(compile).filter(Boolean);
|
|
42
|
+
if (!tests.length) return () => false;
|
|
43
|
+
// A folder's own path is asked about with a trailing slash too, so a folder-only pattern names it (and never a file).
|
|
44
|
+
return (path, folder = false) => tests.some((test) => test.test(path) || (folder && test.test(`${path}/`)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether a path from a page's top is left out by the page's `.lampignore` or by its app's (`app`, the folder above it
|
|
49
|
+
* when the page is in Web/, whose patterns count from its own top).
|
|
50
|
+
*/
|
|
51
|
+
export function ignoredFor(root, app = root) {
|
|
52
|
+
const own = ignoredIn(root);
|
|
53
|
+
if (app === root) return own;
|
|
54
|
+
const outer = ignoredIn(app), under = relative(app, root).split(sep).join("/");
|
|
55
|
+
return (path, folder = false) => own(path, folder) || outer(under ? `${under}/${path}` : path, folder);
|
|
56
|
+
}
|
package/cli/lampapp.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { inflateRawSync } from "node:zlib";
|
|
|
9
9
|
import { Problem, RESOURCES, manifestOf, projectOf, stateOf } from "./state.mjs";
|
|
10
10
|
import { LIMITS, nameOf, zipOf } from "./share.mjs";
|
|
11
11
|
import { isID } from "../site/lib/shared-wishes.js";
|
|
12
|
+
import { ignoredFor, ignoredIn } from "./ignore.mjs";
|
|
12
13
|
|
|
13
14
|
// What a Lamp app file may hold, written once in Resources/share-rules.json, which Lamp's app reads too (`ShareRules`):
|
|
14
15
|
// the sizes another Lamp opens, the names a license goes by, and the shapes of keys a shared file never carries.
|
|
@@ -185,7 +186,7 @@ function filesUnder(dir, prefix = "", { hidden = () => false, skip = () => false
|
|
|
185
186
|
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return found; }
|
|
186
187
|
for (const entry of entries) {
|
|
187
188
|
const path = prefix + entry.name;
|
|
188
|
-
if ((entry.name.startsWith(".") && !hidden(path)) || skip(path)) continue;
|
|
189
|
+
if ((entry.name.startsWith(".") && !hidden(path)) || skip(path, entry.isDirectory())) continue;
|
|
189
190
|
if (entry.isDirectory()) found.push(...filesUnder(join(dir, entry.name), path + "/", { hidden: () => false, skip }));
|
|
190
191
|
else if (entry.isFile()) found.push(path);
|
|
191
192
|
}
|
|
@@ -237,6 +238,10 @@ export function exportApp(given, { anyway = false, link = null } = {}) {
|
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
const files = new Map(), from = (path) => readFileSync(join(app, path));
|
|
241
|
+
// The folder's .lampignore covers the app's own files too (its code beside the page, Assets/), as they are from the
|
|
242
|
+
// folder's top; a wish's own copy in .lamp-wish/ isn't the folder's, so its rules don't reach in there.
|
|
243
|
+
const appIgnored = app === folder ? ignoredIn(folder) : () => false;
|
|
244
|
+
const keep = (path) => !appIgnored(path);
|
|
240
245
|
let kind, site = false, flags = {};
|
|
241
246
|
if (project.kind === "page") {
|
|
242
247
|
kind = typeof manifest?.platform === "string" ? manifest.platform : project.game ? "game" : "web-app";
|
|
@@ -245,19 +250,21 @@ export function exportApp(given, { anyway = false, link = null } = {}) {
|
|
|
245
250
|
// page is Web/ in the file and whose own files beside it go as themselves.
|
|
246
251
|
// A site stays one at every export, whether its page is at the folder's top or (unpacked) in Web/.
|
|
247
252
|
site = kind === "web-app" && (manifest?.site === true || !project.lamps);
|
|
248
|
-
|
|
253
|
+
// What the folder's .lampignore keeps to this computer stays out of the file, as it does of the page.
|
|
254
|
+
const ignored = ignoredFor(project.root, folder);
|
|
255
|
+
for (const path of filesUnder(project.root, "", { hidden: (path) => site && SERVED_HIDDEN.has(path), skip: (path, dir) => path === "node_modules" || ignored(path, dir) || (!project.lamps && (wishFolder ? path === ".lamp-wish" || path === "LAMP-LICENSE.txt" : WISH_FILES.has(path))) })) {
|
|
249
256
|
if (!lamps(path)) files.set(`Web/${path}`, readFileSync(join(project.root, path)));
|
|
250
257
|
}
|
|
251
258
|
if (project.lamps || wishFolder) {
|
|
252
259
|
// The Swift beside the page that answers its calls of the device, and a game's phone page.
|
|
253
|
-
for (const path of filesUnder(join(app, "Sources"), "Sources/")) if (path.endsWith(".swift") && (kind === "game" ? path === "Sources/Native.swift" : !["Sources/LampSupport.swift", "Sources/LampPage.swift", "Sources/LampControllerKit.swift", "Sources/LampScreen.swift", "Sources/LampWebApp.swift"].includes(path))) files.set(path, from(path));
|
|
254
|
-
if (kind === "game") for (const path of [...filesUnder(join(app, "Phone"), "Phone/"), ...filesUnder(join(app, "src"), "src/")]) files.set(path, from(path));
|
|
260
|
+
for (const path of filesUnder(join(app, "Sources"), "Sources/")) if (keep(path) && path.endsWith(".swift") && (kind === "game" ? path === "Sources/Native.swift" : !["Sources/LampSupport.swift", "Sources/LampPage.swift", "Sources/LampControllerKit.swift", "Sources/LampScreen.swift", "Sources/LampWebApp.swift"].includes(path))) files.set(path, from(path));
|
|
261
|
+
if (kind === "game") for (const path of [...filesUnder(join(app, "Phone"), "Phone/"), ...filesUnder(join(app, "src"), "src/")]) if (keep(path)) files.set(path, from(path));
|
|
255
262
|
}
|
|
256
263
|
} else {
|
|
257
264
|
kind = "mac";
|
|
258
|
-
for (const path of filesUnder(join(folder, "Sources"), "Sources/")) if (path.endsWith(".swift") && !SWIFT_LAMPS.has(basename(path))) files.set(path, from(path));
|
|
265
|
+
for (const path of filesUnder(join(folder, "Sources"), "Sources/")) if (keep(path) && path.endsWith(".swift") && !SWIFT_LAMPS.has(basename(path))) files.set(path, from(path));
|
|
259
266
|
for (const [extra, flag] of [["Widget", "widget"], ["Share", "share"]]) {
|
|
260
|
-
const code = filesUnder(join(folder, extra), `${extra}/`).filter((path) => path.endsWith(".swift") && !SWIFT_LAMPS.has(basename(path)));
|
|
267
|
+
const code = filesUnder(join(folder, extra), `${extra}/`).filter((path) => keep(path) && path.endsWith(".swift") && !SWIFT_LAMPS.has(basename(path)));
|
|
261
268
|
for (const path of code) files.set(path, from(path));
|
|
262
269
|
flags[flag] = code.length > 0;
|
|
263
270
|
}
|
|
@@ -271,11 +278,12 @@ export function exportApp(given, { anyway = false, link = null } = {}) {
|
|
|
271
278
|
let attached = 0;
|
|
272
279
|
// A page at the folder's top with no `.lamp-wish/` keeps an Assets/ there as its own: it went with the page.
|
|
273
280
|
for (const path of project.kind === "page" && !project.lamps && !wishFolder ? [] : filesUnder(join(app, "Assets"), "Assets/")) {
|
|
281
|
+
if (!keep(path)) continue;
|
|
274
282
|
attached += statSync(join(app, path)).size;
|
|
275
283
|
if (attached > MAX_ARCHIVE - 8 * 1024 * 1024) throw new Problem(`${name} is too big to share: its pictures and other files come to over ${Math.floor((MAX_ARCHIVE - 8 * 1024 * 1024) / 1_000_000)} MB`);
|
|
276
284
|
files.set(path, from(path));
|
|
277
285
|
}
|
|
278
|
-
if (existsSync(join(app, "APP.md"))) files.set("APP.md", from("APP.md"));
|
|
286
|
+
if (keep("APP.md") && existsSync(join(app, "APP.md"))) files.set("APP.md", from("APP.md"));
|
|
279
287
|
// What the app declares; the Lamp that opens the file checks each of it again as it reads it.
|
|
280
288
|
let declares = false;
|
|
281
289
|
try {
|
|
@@ -286,7 +294,7 @@ export function exportApp(given, { anyway = false, link = null } = {}) {
|
|
|
286
294
|
// icon.png at the top of a page lamp made is the page's, which went with it.
|
|
287
295
|
for (const icon of ["icon.json", "icon-glass.json", "icon-art.png", "icon.png"]) {
|
|
288
296
|
if (icon === "icon.png" && project.kind === "page" && !project.lamps && !wishFolder) continue;
|
|
289
|
-
if (existsSync(join(app, icon))) files.set(icon, from(icon));
|
|
297
|
+
if (keep(icon) && existsSync(join(app, icon))) files.set(icon, from(icon));
|
|
290
298
|
}
|
|
291
299
|
const releases = releasesOf(app);
|
|
292
300
|
if (releases.length) files.set("releases.json", Buffer.from(JSON.stringify(releases, null, 2)));
|
|
@@ -521,16 +521,33 @@
|
|
|
521
521
|
// given ACT_STEP_SECONDS, which only a call that has stopped answering reaches. An app that wants a quicker turn
|
|
522
522
|
// asks for one (`limits: { seconds }`); steps and writes it may only lower.
|
|
523
523
|
var ACT_LIMITS = { steps: 4, writes: 6 };
|
|
524
|
+
// How much of one read goes back to the model: room for a real app's state (a 184-piece model, a week's calendar)
|
|
525
|
+
// in one step, since paging through it costs steps a turn needs for its work. An app with more raises it
|
|
526
|
+
// (`limits: { result }`), up to ACT_RESULT_MAX.
|
|
527
|
+
var ACT_RESULT = 32768, ACT_RESULT_MAX = 262144;
|
|
528
|
+
// What a turn's reads give back in all, which every later step carries again: kept under what a step may carry
|
|
529
|
+
// (`MAX_BODY` in lamp's app-ai.mjs), with room for the request, the tools and the steps' own words.
|
|
530
|
+
var ACT_TURN_RESULTS = 1200000;
|
|
524
531
|
var ACT_STEP_SECONDS = 600;
|
|
525
532
|
var actRan = {}; // turn id -> call key -> the result a write gave, while this copy of the page is open
|
|
526
533
|
function actId() { return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
|
|
527
534
|
function isList(v) { return Object.prototype.toString.call(v) === '[object Array]'; }
|
|
528
|
-
/**
|
|
529
|
-
function
|
|
535
|
+
/** A text's size in UTF-8 bytes, as a step is measured where it's checked. */
|
|
536
|
+
function utf8Bytes(text) { try { return unescape(encodeURIComponent(text)).length; } catch (e) { return text.length * 3; } }
|
|
537
|
+
/** At most `max` bytes of a result go back to the model, as it's sent (UTF-8, its cut wrapper included), cut with a note. */
|
|
538
|
+
function actValue(value, max) {
|
|
530
539
|
var json = jsonState(value === undefined ? null : value), text;
|
|
531
540
|
try { text = JSON.stringify(json); } catch (e) { return { note: 'The result couldn\'t be read.' }; }
|
|
532
|
-
if (text
|
|
533
|
-
|
|
541
|
+
if (!text || utf8Bytes(text) <= max) return json;
|
|
542
|
+
// The cut is JSON inside JSON, so its quotes are escaped again: shrink it until the whole wrapper fits.
|
|
543
|
+
var cut = text.slice(0, max);
|
|
544
|
+
for (var i = 0; i < 24; i++) {
|
|
545
|
+
var wrapped = { cut: cut, note: 'Cut short: ask for less.' };
|
|
546
|
+
var size = utf8Bytes(JSON.stringify(wrapped));
|
|
547
|
+
if (size <= max) return wrapped;
|
|
548
|
+
cut = cut.slice(0, Math.max(0, Math.floor(cut.length * max / size) - 16));
|
|
549
|
+
}
|
|
550
|
+
return { note: 'Cut short: ask for less.' };
|
|
534
551
|
}
|
|
535
552
|
/** The page's own check of a call before it runs: a tool it declared, its arguments an object with nothing
|
|
536
553
|
* undeclared and nothing required missing. Lamp has checked the shapes; this is what the page itself can hold to. */
|
|
@@ -552,6 +569,15 @@
|
|
|
552
569
|
limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS[k]));
|
|
553
570
|
});
|
|
554
571
|
var asked = o.limits && typeof o.limits.seconds === 'number' && o.limits.seconds > 0 ? o.limits.seconds : null;
|
|
572
|
+
var resultMax = o.limits && typeof o.limits.result === 'number' && o.limits.result > 0
|
|
573
|
+
? Math.min(Math.max(1024, Math.floor(o.limits.result)), ACT_RESULT_MAX) : ACT_RESULT;
|
|
574
|
+
// Each result takes from the turn's allowance, so later reads get less rather than the step growing past it.
|
|
575
|
+
var resultsLeft = ACT_TURN_RESULTS;
|
|
576
|
+
function sized(value) {
|
|
577
|
+
var kept = actValue(value, Math.max(1024, Math.min(resultMax, resultsLeft)));
|
|
578
|
+
try { resultsLeft = Math.max(0, resultsLeft - utf8Bytes(JSON.stringify(kept))); } catch (e) {}
|
|
579
|
+
return kept;
|
|
580
|
+
}
|
|
555
581
|
var id = o.turn || actId();
|
|
556
582
|
if (!actRan[id]) {
|
|
557
583
|
var kept = Object.keys(actRan);
|
|
@@ -601,7 +627,7 @@
|
|
|
601
627
|
}).then(function (value) {
|
|
602
628
|
if (value === NOT_RUN) return;
|
|
603
629
|
out.reads.push({ name: c.name, args: c.args });
|
|
604
|
-
results[c.id] = { id: c.id, ok: true, value:
|
|
630
|
+
results[c.id] = { id: c.id, ok: true, value: sized(value) };
|
|
605
631
|
}, function (e) { results[c.id] = { id: c.id, ok: false, error: refused(e) }; });
|
|
606
632
|
})).then(function () {
|
|
607
633
|
var stopped = null;
|
|
@@ -638,7 +664,7 @@
|
|
|
638
664
|
var running;
|
|
639
665
|
try { running = Promise.resolve(tool.run(c.args, call)); } catch (e) { running = Promise.reject(e); }
|
|
640
666
|
return running.then(function (value) {
|
|
641
|
-
var kept =
|
|
667
|
+
var kept = sized(value);
|
|
642
668
|
ran[key] = kept;
|
|
643
669
|
var entry = { name: c.name, args: c.args, result: value };
|
|
644
670
|
if (value && typeof value === 'object' && value.partly) entry.partly = String(value.partly);
|
package/cli/lamps/lamp.js
CHANGED
|
@@ -521,16 +521,33 @@
|
|
|
521
521
|
// given ACT_STEP_SECONDS, which only a call that has stopped answering reaches. An app that wants a quicker turn
|
|
522
522
|
// asks for one (`limits: { seconds }`); steps and writes it may only lower.
|
|
523
523
|
var ACT_LIMITS = { steps: 4, writes: 6 };
|
|
524
|
+
// How much of one read goes back to the model: room for a real app's state (a 184-piece model, a week's calendar)
|
|
525
|
+
// in one step, since paging through it costs steps a turn needs for its work. An app with more raises it
|
|
526
|
+
// (`limits: { result }`), up to ACT_RESULT_MAX.
|
|
527
|
+
var ACT_RESULT = 32768, ACT_RESULT_MAX = 262144;
|
|
528
|
+
// What a turn's reads give back in all, which every later step carries again: kept under what a step may carry
|
|
529
|
+
// (`MAX_BODY` in lamp's app-ai.mjs), with room for the request, the tools and the steps' own words.
|
|
530
|
+
var ACT_TURN_RESULTS = 1200000;
|
|
524
531
|
var ACT_STEP_SECONDS = 600;
|
|
525
532
|
var actRan = {}; // turn id -> call key -> the result a write gave, while this copy of the page is open
|
|
526
533
|
function actId() { return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
|
|
527
534
|
function isList(v) { return Object.prototype.toString.call(v) === '[object Array]'; }
|
|
528
|
-
/**
|
|
529
|
-
function
|
|
535
|
+
/** A text's size in UTF-8 bytes, as a step is measured where it's checked. */
|
|
536
|
+
function utf8Bytes(text) { try { return unescape(encodeURIComponent(text)).length; } catch (e) { return text.length * 3; } }
|
|
537
|
+
/** At most `max` bytes of a result go back to the model, as it's sent (UTF-8, its cut wrapper included), cut with a note. */
|
|
538
|
+
function actValue(value, max) {
|
|
530
539
|
var json = jsonState(value === undefined ? null : value), text;
|
|
531
540
|
try { text = JSON.stringify(json); } catch (e) { return { note: 'The result couldn\'t be read.' }; }
|
|
532
|
-
if (text
|
|
533
|
-
|
|
541
|
+
if (!text || utf8Bytes(text) <= max) return json;
|
|
542
|
+
// The cut is JSON inside JSON, so its quotes are escaped again: shrink it until the whole wrapper fits.
|
|
543
|
+
var cut = text.slice(0, max);
|
|
544
|
+
for (var i = 0; i < 24; i++) {
|
|
545
|
+
var wrapped = { cut: cut, note: 'Cut short: ask for less.' };
|
|
546
|
+
var size = utf8Bytes(JSON.stringify(wrapped));
|
|
547
|
+
if (size <= max) return wrapped;
|
|
548
|
+
cut = cut.slice(0, Math.max(0, Math.floor(cut.length * max / size) - 16));
|
|
549
|
+
}
|
|
550
|
+
return { note: 'Cut short: ask for less.' };
|
|
534
551
|
}
|
|
535
552
|
/** The page's own check of a call before it runs: a tool it declared, its arguments an object with nothing
|
|
536
553
|
* undeclared and nothing required missing. Lamp has checked the shapes; this is what the page itself can hold to. */
|
|
@@ -552,6 +569,15 @@
|
|
|
552
569
|
limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS[k]));
|
|
553
570
|
});
|
|
554
571
|
var asked = o.limits && typeof o.limits.seconds === 'number' && o.limits.seconds > 0 ? o.limits.seconds : null;
|
|
572
|
+
var resultMax = o.limits && typeof o.limits.result === 'number' && o.limits.result > 0
|
|
573
|
+
? Math.min(Math.max(1024, Math.floor(o.limits.result)), ACT_RESULT_MAX) : ACT_RESULT;
|
|
574
|
+
// Each result takes from the turn's allowance, so later reads get less rather than the step growing past it.
|
|
575
|
+
var resultsLeft = ACT_TURN_RESULTS;
|
|
576
|
+
function sized(value) {
|
|
577
|
+
var kept = actValue(value, Math.max(1024, Math.min(resultMax, resultsLeft)));
|
|
578
|
+
try { resultsLeft = Math.max(0, resultsLeft - utf8Bytes(JSON.stringify(kept))); } catch (e) {}
|
|
579
|
+
return kept;
|
|
580
|
+
}
|
|
555
581
|
var id = o.turn || actId();
|
|
556
582
|
if (!actRan[id]) {
|
|
557
583
|
var kept = Object.keys(actRan);
|
|
@@ -601,7 +627,7 @@
|
|
|
601
627
|
}).then(function (value) {
|
|
602
628
|
if (value === NOT_RUN) return;
|
|
603
629
|
out.reads.push({ name: c.name, args: c.args });
|
|
604
|
-
results[c.id] = { id: c.id, ok: true, value:
|
|
630
|
+
results[c.id] = { id: c.id, ok: true, value: sized(value) };
|
|
605
631
|
}, function (e) { results[c.id] = { id: c.id, ok: false, error: refused(e) }; });
|
|
606
632
|
})).then(function () {
|
|
607
633
|
var stopped = null;
|
|
@@ -638,7 +664,7 @@
|
|
|
638
664
|
var running;
|
|
639
665
|
try { running = Promise.resolve(tool.run(c.args, call)); } catch (e) { running = Promise.reject(e); }
|
|
640
666
|
return running.then(function (value) {
|
|
641
|
-
var kept =
|
|
667
|
+
var kept = sized(value);
|
|
642
668
|
ran[key] = kept;
|
|
643
669
|
var entry = { name: c.name, args: c.args, result: value };
|
|
644
670
|
if (value && typeof value === 'object' && value.partly) entry.partly = String(value.partly);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"Zqx App Name","
|
|
1
|
+
{"exe":"ZqxAppName","name":"Zqx App Name","platform":"mac","instance":"10DC6CFC-3873-414E-B724-6AA904DC37A0"}
|
|
@@ -12017,6 +12017,9 @@
|
|
|
12017
12017
|
let request: String
|
|
12018
12018
|
var answers: [(question: String, answer: String)] = []
|
|
12019
12019
|
var writes = 0
|
|
12020
|
+
/// What its reads may still give back in all: every later step carries them again, under what a step may
|
|
12021
|
+
/// carry (`MAX_BODY` in lamp's app-ai.mjs).
|
|
12022
|
+
var resultsLeft = 1_200_000
|
|
12020
12023
|
}
|
|
12021
12024
|
private var operation: Operation?
|
|
12022
12025
|
/// Each change's result by its key, so the same change asked again gets it without running again.
|
|
@@ -12028,14 +12031,19 @@
|
|
|
12028
12031
|
private let send: ([String: Any], TimeInterval) async throws -> [String: Any]
|
|
12029
12032
|
/// The longest a request may take, when the app wants a quick answer; nil for no limit.
|
|
12030
12033
|
let seconds: Double?
|
|
12034
|
+
/// How much of one read goes back to the AI, in bytes: room for an app's real state in one step, since paging
|
|
12035
|
+
/// through it costs steps a request needs for its work.
|
|
12036
|
+
let resultBytes: Int
|
|
12031
12037
|
|
|
12032
12038
|
/// `seconds` limits each request's time, for a feature that should answer quickly; leave it out for no limit.
|
|
12039
|
+
/// `resultBytes` raises how much of a read the AI sees (32 KB unless given, 256 KB at most).
|
|
12033
12040
|
/// `send` stands in for Lamp in a test; leave it out.
|
|
12034
|
-
init(about: String = "", tools: [LampActTool], seconds: Double? = nil,
|
|
12041
|
+
init(about: String = "", tools: [LampActTool], seconds: Double? = nil, resultBytes: Int? = nil,
|
|
12035
12042
|
send: (([String: Any], TimeInterval) async throws -> [String: Any])? = nil) {
|
|
12036
12043
|
self.about = about
|
|
12037
12044
|
self.tools = tools
|
|
12038
12045
|
self.seconds = seconds.map { max(1, $0) }
|
|
12046
|
+
self.resultBytes = min(max(resultBytes ?? 32_768, 1024), 262_144)
|
|
12039
12047
|
self.send = send ?? { body, timeout in try await LampAI.post("/ai/act", body, timeout: timeout) }
|
|
12040
12048
|
}
|
|
12041
12049
|
|
|
@@ -12192,7 +12200,9 @@
|
|
|
12192
12200
|
onStep(.reading(tool.name))
|
|
12193
12201
|
do {
|
|
12194
12202
|
let value = try await tool.perform(args(call), callInfo(call, key: ""))
|
|
12195
|
-
|
|
12203
|
+
let kept = Self.cut(value, max: max(1024, min(resultBytes, op.resultsLeft)))
|
|
12204
|
+
op.resultsLeft = max(0, op.resultsLeft - ((try? JSONSerialization.data(withJSONObject: [kept]))?.count ?? 0))
|
|
12205
|
+
results[id] = ["id": id, "ok": true, "value": kept]
|
|
12196
12206
|
} catch {
|
|
12197
12207
|
results[id] = ["id": id, "ok": false, "error": Self.words(error)]
|
|
12198
12208
|
}
|
|
@@ -12251,13 +12261,21 @@
|
|
|
12251
12261
|
|
|
12252
12262
|
private static func words(_ error: Error) -> String { (error as? LampAI.Failure)?.message ?? error.localizedDescription }
|
|
12253
12263
|
|
|
12254
|
-
/// At most
|
|
12255
|
-
private static func cut(_ value: Any) -> Any {
|
|
12264
|
+
/// At most `max` bytes of a read go back to the AI, cut with a note.
|
|
12265
|
+
private static func cut(_ value: Any, max: Int) -> Any {
|
|
12256
12266
|
guard JSONSerialization.isValidJSONObject([value]), let data = try? JSONSerialization.data(withJSONObject: [value]) else {
|
|
12257
12267
|
return ["note": "The result couldn't be read."]
|
|
12258
12268
|
}
|
|
12259
|
-
guard data.count >
|
|
12260
|
-
|
|
12269
|
+
guard data.count > max else { return value }
|
|
12270
|
+
// The cut is JSON inside JSON, so its quotes are escaped again: shrink it until the whole wrapper fits.
|
|
12271
|
+
var cut = String(decoding: data.prefix(max), as: UTF8.self)
|
|
12272
|
+
for _ in 0..<24 {
|
|
12273
|
+
let wrapped: [String: Any] = ["cut": cut, "note": "Cut short: ask for less."]
|
|
12274
|
+
let size = (try? JSONSerialization.data(withJSONObject: [wrapped]))?.count ?? Int.max
|
|
12275
|
+
if size <= max { return wrapped }
|
|
12276
|
+
cut = String(cut.prefix(Swift.max(0, cut.count * max / Swift.max(size, 1) - 16)))
|
|
12277
|
+
}
|
|
12278
|
+
return ["note": "Cut short: ask for less."]
|
|
12261
12279
|
}
|
|
12262
12280
|
|
|
12263
12281
|
private static func canonical(_ value: Any) -> String {
|
|
@@ -521,16 +521,33 @@
|
|
|
521
521
|
// given ACT_STEP_SECONDS, which only a call that has stopped answering reaches. An app that wants a quicker turn
|
|
522
522
|
// asks for one (`limits: { seconds }`); steps and writes it may only lower.
|
|
523
523
|
var ACT_LIMITS = { steps: 4, writes: 6 };
|
|
524
|
+
// How much of one read goes back to the model: room for a real app's state (a 184-piece model, a week's calendar)
|
|
525
|
+
// in one step, since paging through it costs steps a turn needs for its work. An app with more raises it
|
|
526
|
+
// (`limits: { result }`), up to ACT_RESULT_MAX.
|
|
527
|
+
var ACT_RESULT = 32768, ACT_RESULT_MAX = 262144;
|
|
528
|
+
// What a turn's reads give back in all, which every later step carries again: kept under what a step may carry
|
|
529
|
+
// (`MAX_BODY` in lamp's app-ai.mjs), with room for the request, the tools and the steps' own words.
|
|
530
|
+
var ACT_TURN_RESULTS = 1200000;
|
|
524
531
|
var ACT_STEP_SECONDS = 600;
|
|
525
532
|
var actRan = {}; // turn id -> call key -> the result a write gave, while this copy of the page is open
|
|
526
533
|
function actId() { return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
|
|
527
534
|
function isList(v) { return Object.prototype.toString.call(v) === '[object Array]'; }
|
|
528
|
-
/**
|
|
529
|
-
function
|
|
535
|
+
/** A text's size in UTF-8 bytes, as a step is measured where it's checked. */
|
|
536
|
+
function utf8Bytes(text) { try { return unescape(encodeURIComponent(text)).length; } catch (e) { return text.length * 3; } }
|
|
537
|
+
/** At most `max` bytes of a result go back to the model, as it's sent (UTF-8, its cut wrapper included), cut with a note. */
|
|
538
|
+
function actValue(value, max) {
|
|
530
539
|
var json = jsonState(value === undefined ? null : value), text;
|
|
531
540
|
try { text = JSON.stringify(json); } catch (e) { return { note: 'The result couldn\'t be read.' }; }
|
|
532
|
-
if (text
|
|
533
|
-
|
|
541
|
+
if (!text || utf8Bytes(text) <= max) return json;
|
|
542
|
+
// The cut is JSON inside JSON, so its quotes are escaped again: shrink it until the whole wrapper fits.
|
|
543
|
+
var cut = text.slice(0, max);
|
|
544
|
+
for (var i = 0; i < 24; i++) {
|
|
545
|
+
var wrapped = { cut: cut, note: 'Cut short: ask for less.' };
|
|
546
|
+
var size = utf8Bytes(JSON.stringify(wrapped));
|
|
547
|
+
if (size <= max) return wrapped;
|
|
548
|
+
cut = cut.slice(0, Math.max(0, Math.floor(cut.length * max / size) - 16));
|
|
549
|
+
}
|
|
550
|
+
return { note: 'Cut short: ask for less.' };
|
|
534
551
|
}
|
|
535
552
|
/** The page's own check of a call before it runs: a tool it declared, its arguments an object with nothing
|
|
536
553
|
* undeclared and nothing required missing. Lamp has checked the shapes; this is what the page itself can hold to. */
|
|
@@ -552,6 +569,15 @@
|
|
|
552
569
|
limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS[k]));
|
|
553
570
|
});
|
|
554
571
|
var asked = o.limits && typeof o.limits.seconds === 'number' && o.limits.seconds > 0 ? o.limits.seconds : null;
|
|
572
|
+
var resultMax = o.limits && typeof o.limits.result === 'number' && o.limits.result > 0
|
|
573
|
+
? Math.min(Math.max(1024, Math.floor(o.limits.result)), ACT_RESULT_MAX) : ACT_RESULT;
|
|
574
|
+
// Each result takes from the turn's allowance, so later reads get less rather than the step growing past it.
|
|
575
|
+
var resultsLeft = ACT_TURN_RESULTS;
|
|
576
|
+
function sized(value) {
|
|
577
|
+
var kept = actValue(value, Math.max(1024, Math.min(resultMax, resultsLeft)));
|
|
578
|
+
try { resultsLeft = Math.max(0, resultsLeft - utf8Bytes(JSON.stringify(kept))); } catch (e) {}
|
|
579
|
+
return kept;
|
|
580
|
+
}
|
|
555
581
|
var id = o.turn || actId();
|
|
556
582
|
if (!actRan[id]) {
|
|
557
583
|
var kept = Object.keys(actRan);
|
|
@@ -601,7 +627,7 @@
|
|
|
601
627
|
}).then(function (value) {
|
|
602
628
|
if (value === NOT_RUN) return;
|
|
603
629
|
out.reads.push({ name: c.name, args: c.args });
|
|
604
|
-
results[c.id] = { id: c.id, ok: true, value:
|
|
630
|
+
results[c.id] = { id: c.id, ok: true, value: sized(value) };
|
|
605
631
|
}, function (e) { results[c.id] = { id: c.id, ok: false, error: refused(e) }; });
|
|
606
632
|
})).then(function () {
|
|
607
633
|
var stopped = null;
|
|
@@ -638,7 +664,7 @@
|
|
|
638
664
|
var running;
|
|
639
665
|
try { running = Promise.resolve(tool.run(c.args, call)); } catch (e) { running = Promise.reject(e); }
|
|
640
666
|
return running.then(function (value) {
|
|
641
|
-
var kept =
|
|
667
|
+
var kept = sized(value);
|
|
642
668
|
ran[key] = kept;
|
|
643
669
|
var entry = { name: c.name, args: c.args, result: value };
|
|
644
670
|
if (value && typeof value === 'object' && value.partly) entry.partly = String(value.partly);
|
package/cli/local-ai.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { answer as answerAI, engine } from "./ai.mjs";
|
|
|
21
21
|
/** The port lamp.js on a shared page asks (`LOCAL_AI` there): one, fixed, since the page can't be told another. */
|
|
22
22
|
export const LOCAL_AI_PORT = 47423;
|
|
23
23
|
const PLAY_HOST = "play.lamp.dev";
|
|
24
|
-
const MOST =
|
|
24
|
+
const MOST = 4_000_000, AT_ONCE = 3, ASKING_FOR = 120_000;
|
|
25
25
|
const ROUTES = new Set(["/ai/status", "/ai", "/ai/act", "/ai/decide", "/ai/hear", "/ai/image"]);
|
|
26
26
|
|
|
27
27
|
const keptFile = () => join(process.env.LAMP_DEV_HOME || join(homedir(), ".lamp", "dev"), "shared-pages-ai.json");
|
package/cli/share.mjs
CHANGED
|
@@ -17,6 +17,7 @@ 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
18
|
import { exportApp, sourceLinkOf } from "./lampapp.mjs";
|
|
19
19
|
import { scaled, sizeOf } from "./png.mjs";
|
|
20
|
+
import { ignoredFor } from "./ignore.mjs";
|
|
20
21
|
|
|
21
22
|
export { siteOrigin };
|
|
22
23
|
|
|
@@ -104,10 +105,12 @@ export function zipOf(files) {
|
|
|
104
105
|
*/
|
|
105
106
|
export function pageFiles(layout, { device = ANY_BROWSER } = {}) {
|
|
106
107
|
const files = new Map();
|
|
108
|
+
// What the folder's .lampignore keeps to this computer (data too big to share, say) never goes.
|
|
109
|
+
const ignored = ignoredFor(layout.root, layout.folder);
|
|
107
110
|
const walk = (dir, prefix) => {
|
|
108
111
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
109
112
|
const path = prefix + entry.name;
|
|
110
|
-
if (entry.name.startsWith(".") || entry.name === "node_modules" || path === NOTES_FILE) continue;
|
|
113
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules" || path === NOTES_FILE || ignored(path, entry.isDirectory())) continue;
|
|
111
114
|
if (entry.isDirectory()) walk(join(dir, entry.name), path + "/");
|
|
112
115
|
else if (entry.isFile()) files.set(path, readFileSync(join(dir, entry.name)));
|
|
113
116
|
}
|
package/cli/skill/SKILL.md
CHANGED
|
@@ -198,7 +198,9 @@ Only when the person asks: a link is public, and anyone who has it can play the
|
|
|
198
198
|
and their email, and run `{{lamp}} account <username> --email <theirs>`; they confirm it from the email, and the link
|
|
199
199
|
moves there at its next share. `{{lamp}} account` says which they're signed in as. A page that's logging errors
|
|
200
200
|
isn't shared until they're put right. Again, after changes, it sends the page as it is now to the same link, with the
|
|
201
|
-
same words unless you give new ones.
|
|
201
|
+
same words unless you give new ones. Files only this computer needs (data too big for a link, which the page fetches
|
|
202
|
+
from elsewhere when it isn't there) go in a `.lampignore` at the folder's top, gitignore's way (`ldraw/`, `*.csv`):
|
|
203
|
+
share and export leave them out. Never share from a copy of the folder made for it.
|
|
202
204
|
- `{{lamp}} ideas` lists what people playing it have sent, each with the address of a picture of the app as they saw
|
|
203
205
|
it when they kept one in: look at it before building the idea. Once one is in the game and the game shared again,
|
|
204
206
|
`{{lamp}} ideas in <number>` says so, and whoever asked to hear is written to; `{{lamp}} ideas dismiss <number>` lets
|