@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 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 = 256 * 1024, MAX_CHOICES = 4, MAX_CHOICE = 24;
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 256 KB.");
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
- for (const path of filesUnder(project.root, "", { hidden: (path) => site && SERVED_HIDDEN.has(path), skip: (path) => path === "node_modules" || (!project.lamps && (wishFolder ? path === ".lamp-wish" || path === "LAMP-LICENSE.txt" : WISH_FILES.has(path))) })) {
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
- /** At most 4 KB of a result goes back to the model, cut with a note. */
529
- function actValue(value) {
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 && text.length > 4096) return { cut: text.slice(0, 4000), note: 'Cut short: ask for less.' };
533
- return json;
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: actValue(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 = actValue(value);
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
- /** At most 4 KB of a result goes back to the model, cut with a note. */
529
- function actValue(value) {
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 && text.length > 4096) return { cut: text.slice(0, 4000), note: 'Cut short: ask for less.' };
533
- return json;
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: actValue(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 = actValue(value);
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","exe":"ZqxAppName","platform":"mac","instance":"EC9221D5-9306-400D-8A12-28239D505919"}
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
- results[id] = ["id": id, "ok": true, "value": Self.cut(value)]
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 4 KB of a read goes back to the AI, cut with a note.
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 > 4096 else { return value }
12260
- return ["cut": String(String(decoding: data, as: UTF8.self).prefix(4000)), "note": "Cut short: ask for less."]
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
- /** At most 4 KB of a result goes back to the model, cut with a note. */
529
- function actValue(value) {
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 && text.length > 4096) return { cut: text.slice(0, 4000), note: 'Cut short: ask for less.' };
533
- return json;
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: actValue(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 = actValue(value);
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 = 2_000_000, AT_ONCE = 3, ASKING_FOR = 120_000;
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
  }
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lampdotdev/lamp",
3
- "version": "0.36.2-nightly.24",
3
+ "version": "0.36.2-nightly.26",
4
4
  "description": "Runs a web app or game from its folder and puts each saved change into the open page.",
5
5
  "type": "module",
6
6
  "bin": {