@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.
@@ -139,7 +139,10 @@ async function loadProblem(status) {
139
139
  export function work(path, message, transfer = []) {
140
140
  read(clean(path));
141
141
  return new Promise((done, fail) => {
142
- const worker = new browser.Worker(`${addressOf(clean(path))}?lamp=${++versionCount}`, { type: 'module' });
142
+ // At the file's own address until something changes (`fresh`): a game starts its workers again and again, and a
143
+ // new address each time is a new request all the way to where the page is kept, which a shared link's host
144
+ // can't keep up with. After a change, the address changes with it, so the new version is the one that runs.
145
+ const worker = new browser.Worker(fresh(addressOf(clean(path))), { type: 'module' });
143
146
  worker.onmessage = e => { worker.terminate(); done(e.data); };
144
147
  worker.onerror = e => { worker.terminate(); fail(new Error(`${path}: ${e.message || 'the worker failed'}`)); };
145
148
  worker.postMessage(message, transfer);
@@ -985,6 +988,9 @@ let hooks = {}, started = false, waiting = null, pill = null;
985
988
  // the state didn't take.
986
989
  let pending = null, reopened = false, taken = false, guarding = false;
987
990
  const host = () => window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lampLive;
991
+ // That the page runs on this runtime, for lamp dev (cli/page.js): a game's page without it has `__lampLive` too
992
+ // (lamp-live.js, which hands over to this runtime as it sets its own below).
993
+ window.__lampRuntime = true;
988
994
  // Whether the game is past its loading screen, for the picture of the app Lamp's desk shows (`LampPagePlaying` in a Mac
989
995
  // app): 'loading' from when this runtime loads, 'playing' at the first frame of play.
990
996
  const tellPlaying = state => { try { const h = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lampPlaying; h && h.postMessage(state); } catch (e) {} };
@@ -0,0 +1,64 @@
1
+ {
2
+ "about": "What a Lamp app file may hold, read by Lamp's app (ShareRules) and lamp's command line (cli/lampapp.mjs) alike, so the two never differ. tokenShapes are keys and tokens whoever's they are: a file holding one isn't shared. licenseFiles are the names a license or notice goes by. limits are what another Lamp opens.",
3
+ "tokenShapes": [
4
+ {
5
+ "pattern": "-----BEGIN [A-Z ]*PRIVATE KEY-----",
6
+ "what": "a private key"
7
+ },
8
+ {
9
+ "pattern": "\\bsk-(or-v1-|ant-|proj-)?[A-Za-z0-9_\\-]{32,}",
10
+ "what": "OpenRouter, Anthropic, OpenAI"
11
+ },
12
+ {
13
+ "pattern": "\\bgh[pousr]_[A-Za-z0-9]{36,}",
14
+ "what": "GitHub"
15
+ },
16
+ {
17
+ "pattern": "\\bgithub_pat_[A-Za-z0-9_]{60,}",
18
+ "what": "GitHub"
19
+ },
20
+ {
21
+ "pattern": "\\bAKIA[0-9A-Z]{16}\\b",
22
+ "what": "AWS"
23
+ },
24
+ {
25
+ "pattern": "\\bAIza[0-9A-Za-z_\\-]{35}\\b",
26
+ "what": "Google"
27
+ },
28
+ {
29
+ "pattern": "\\bxox[abprs]-[A-Za-z0-9\\-]{20,}",
30
+ "what": "Slack"
31
+ },
32
+ {
33
+ "pattern": "\\b(sk|rk)_live_[A-Za-z0-9]{20,}",
34
+ "what": "Stripe"
35
+ }
36
+ ],
37
+ "licenseFiles": {
38
+ "startsWith": [
39
+ "LICENSE",
40
+ "LICENCE",
41
+ "COPYING",
42
+ "NOTICE",
43
+ "UNLICENSE"
44
+ ],
45
+ "longestExtension": 10,
46
+ "neverExtensions": [
47
+ "sh",
48
+ "command",
49
+ "app",
50
+ "js",
51
+ "py",
52
+ "rb",
53
+ "pl",
54
+ "swift",
55
+ "html",
56
+ "htm"
57
+ ]
58
+ },
59
+ "limits": {
60
+ "files": 2000,
61
+ "unpackedBytes": 268435456,
62
+ "archiveBytes": 67108864
63
+ }
64
+ }
@@ -25,6 +25,8 @@ The tools Lamp has for games:
25
25
  `new Worker(new URL("./<name>.worker.js", import.meta.url), { type: "module" })` (`.js`: every file is bundled flat
26
26
  into `Web/app/`; a module worker, since what it shares with the page comes as an import). Edit `src/`,
27
27
  never `Web/app/`. A game in plain JavaScript stays as it is, its modules in `Web/`.
28
+ - **AI in a game** (`LampAI` in Web/lamp.js): checked and shown where it can't answer as the web-app skill's
29
+ "LampAI" says, with lamp.js's own line and its link, never words of the game's own.
28
30
  - **Phones as players**: a game on the runtime takes phones on the Wi‑Fi with `Lamp.controllers` (Web/lamp.js) and
29
31
  its page for them in Phone/ (the phone-controllers and phone-pages skills), on the Mac, in the TV preview, on a
30
32
  Samsung or LG TV and on the Xbox; elsewhere `Lamp.controllers` says it can't start, so the game stays whole with a controller,
@@ -36,20 +36,23 @@ its credit and licenses in place, and the rest of what a Lamp app has.
36
36
  code, an engine Lamp's devices can't run.
37
37
  2. Give the app its structure with `make_app_for` (a game or an app, the devices, web or native), and read the skill
38
38
  for that kind (`game`, `web-app`, `2d-game`, `3d-game`). The structure is Lamp's: its page or its Swift package,
39
- `build.sh`, `Package.swift`, Lamp's own files, `icon.json`, `APP.md`.
39
+ `build.sh`, `Package.swift`, Lamp's own files, `icon.json`, `APP.md`. A web page with no build step (index.html with
40
+ its scripts and styles, a canvas or WebGL game among them) is a web app brought in: `kind` "app", `made` "web", and
41
+ the web-app skill's "A web app brought in" is the rule for everything after, whatever the person's devices are.
40
42
  3. Before using any of its code or assets, call `lampify` with its address and the commit your clone is at
41
43
  (`git -C .lamp/reference/name rev-parse HEAD`). Lamp gets its own untouched copy at that commit, credits it in
42
44
  credits.json, keeps every license and notice file under `Licenses/`, and answers with the manifest and what's still
43
45
  missing. `adapted` is true for the project the app is made from (the default): its updates come to the person from
44
46
  there. Anything else you bring in from GitHub (a library, a sprite pack) is cloned and lampified the same way with
45
47
  `adapted: false`. Every later call passes the same commit: lampify never moves on to a newer one by itself.
46
- 4. Bring its code and assets into Lamp's structure: a web game's scripts, styles and assets into the page's folder,
47
- loaded from Lamp's page; native code into the package's sources. Keep what makes it work and drop only what the
48
- person asked to drop. Never copy its own build scripts, package manifests, workflows or wrapper page over Lamp's
48
+ 4. Bring its code and assets into Lamp's structure: a web page's files into `Web/` as they are, laid out as the project
49
+ has them, in place of the scaffold's; native code into the package's sources. Keep what makes it work and drop only
50
+ what the person asked to drop. Never copy its own build scripts, package manifests, workflows or wrapper page over Lamp's
49
51
  files (`Package.swift`, `build.sh`, `Sources/LampSupport.swift`, the `lamp*.js` files), and never run anything it
50
52
  holds (its installers, its build, its scripts). Keep its copyright headers, and its author's credit on screen.
51
53
  5. Give it what a Lamp app has that a project on GitHub doesn't: its line in `about.json`, its icon's design in
52
- `icon.json`, and `APP.md` saying what it is and what it's based on. Never write `lamp-app.json`, the project's entry
54
+ `icon.json`, and `APP.md` saying what it is and what it's based on (and, for a web app brought in, that it came in
55
+ as it is). Never write `lamp-app.json`, the project's entry
53
56
  in `credits.json` or copies of its licenses yourself: lampify does.
54
57
  6. Look at it with `look_at_app` until it works.
55
58
  7. Call `lampify` again with the same address and commit, and fix whatever it says is still missing, until it says
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: web-app
3
- description: Apps made as a web page Lamp shows on each device - lamp.js's building blocks (AI, saved data, the app's files, the project's other apps), the remote's focus and keys, what the person's own things need from the app around the page, and what each device's browser runs. Read before writing or changing an app's page.
3
+ description: Apps made as a web page Lamp shows on each device - lamp.js's building blocks (AI, saved data, the app's files, the project's other apps), the remote's focus and keys, what the person's own things need from the app around the page, what each device's browser runs, and a web app brought in from elsewhere, which stays as it is. Read before writing or changing an app's page.
4
4
  ---
5
5
 
6
6
  # Web apps
@@ -17,8 +17,10 @@ opening comment for the whole list. What it gives:
17
17
 
18
18
  - **LampAI**: text, pictures and quick judgments, answered by Lamp on the person's Mac with the AI they set up.
19
19
  Every call is a Promise. Text from outside the app goes in `data`, never into the prompt. Where a feature depends
20
- on it, check `LampAI.isAvailable('text')` and show a short note when it can't answer: away from the Mac's Wi‑Fi, or
21
- with Lamp closed, it can't.
20
+ on it, check `LampAI.status('text')` and, when it can't answer, show its `reason` as it's given: lamp.js words it
21
+ for where the page is (away from the Mac's Wi‑Fi, Lamp closed, a link on lamp.dev), so don't write your own. Where it
22
+ gives `help` (a page away from Lamp), add a link to it beside the reason, "How to turn on AI", so someone who has never
23
+ heard of Lamp finds out how.
22
24
  - **LampAI.act**: the person asks the app to do something in their own words, and the app's own reads and writes do
23
25
  it; "Doing things for the person" below, with **LampVoice** and **LampAI.hear** for saying it.
24
26
  - **LampStore**: what the app saves, as JSON on the device. Once something is saved, only ever add to it: a new field
@@ -30,24 +32,51 @@ opening comment for the whole list. What it gives:
30
32
  - **Lamp.controllers** and `Lamp.phonePage`: phones on the same Wi‑Fi as controllers or a second screen; the
31
33
  phone-controllers skill first.
32
34
 
35
+ ## A web app brought in
36
+
37
+ This section is the rule for a web app someone made elsewhere and brought to Lamp: a site opened from GitHub or from
38
+ a folder (index.html at its top, no build step), or a page with no build step made into a Lamp app from a project on
39
+ GitHub (the github-projects skill). A project that's built before it's served (a package.json, a bundler) isn't
40
+ covered here.
41
+
42
+ It comes in as it is. Lamp's features need nothing from its code: it opens in Lamp's app around the page, is shared
43
+ and put on GitHub with its files as they are, gets its updates from where it came from, and takes a change by opening
44
+ again. So:
45
+
46
+ - Do only what Lamp needs and what the person asks for: no refactoring, no new layout or file names, no redesign, no
47
+ framework, no tidying of code that works.
48
+ - Its kind stays what it is: a web app (`make_app_for` with `kind` "app" and `made` "web"), whether it's a tool or a
49
+ game. Moving it onto Lamp's game runtime or into native code is a conversion, done only when the person asks for
50
+ that in so many words.
51
+ - No Swift of its own: Sources/Native.swift stays as Lamp wrote it, and the app stays Lamp's ready-made one, unless
52
+ the person asks for something only the app around the page can reach (their own things, below) or for native code.
53
+ - "I'm on a Mac" or "make it a Mac app" asks for nothing more: Lamp's app around the page already is one.
54
+ - A feature its page uses that Lamp's app around it doesn't support yet (a download, a file picker) is Lamp's
55
+ limitation, not the app's: say so, and leave its code as it is. Never rewrite the feature, or the app, around it.
56
+ - lamp.js only when a change the person asks for needs Lamp's building blocks, added before its own scripts, with the
57
+ page still working without it.
58
+
59
+ APP.md says that it was brought in as it is, so every later turn keeps to this.
60
+
33
61
  ## On the web, without Lamp
34
62
 
35
- Putting it on GitHub (Also put on GitHub, on its link's card) gives a web app a page anyone opens in a browser, with no Lamp anywhere: the page as it is, with
36
- lamp.js. There, `LampAI.isAvailable` answers false and its calls fail with a sentence saying the page is away from
37
- Lamp, `LampNative.available` is false, and `Lamp.wish` can't reach the project's other apps; `LampStore` and `LampAssets`
38
- work as they do everywhere. So every feature that leans on Lamp keeps the app usable without it: the app's own buttons
63
+ Sharing its link gives a web app a page on lamp.dev anyone opens in a browser, with no Lamp around it: the page as it
64
+ is, with lamp.js. There its AI is answered only on a computer with Lamp open or running `lamp ai`, for whoever plays it there; elsewhere
65
+ `LampAI.status` says it's unavailable, with the line to show, and its calls fail with that sentence. `LampNative.available`
66
+ is false, and `Lamp.wish` can't reach the project's other apps; `LampStore` and `LampAssets` work as they do everywhere.
67
+ So every feature that leans on Lamp keeps the app usable without it, going by what `status` says rather than where it is: the app's own buttons
39
68
  for everything AI would do, example content where the person's own things would be, and a short line where something
40
- needs Lamp, never an error or an empty screen.
69
+ needs Lamp (for AI, `LampAI.status`'s `reason`), never an error or an empty screen.
41
70
 
42
71
  ## Doing things for the person
43
72
 
44
73
  `LampAI.act(request, { about, context, tools, history, onStep })` runs a short loop: the AI picks calls among the
45
74
  app's tools, the page runs them, the AI sees the results, until it replies (at most 4 AI calls and 6 writes a
46
- turn, and 25 seconds over OpenRouter or a minute through Claude Code, Codex or a model on the Mac). It works with
75
+ turn, and no time limit unless the app sets one with `limits: { seconds }`, for a feature that should answer quickly). It works with
47
76
  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:
48
77
 
49
- - Show the feature where `LampAI.isAvailable('act')` is true, and keep the app's own buttons for everything: it only
50
- adds a way in. Where it's false, keep it visible with a short reason and a way to set it up in Lamp, and keep what
78
+ - Show the feature where `LampAI.status('act')` says it's available, and keep the app's own buttons for everything:
79
+ it only adds a way in. Where it isn't, keep it visible with its `reason`, and keep what
51
80
  was typed; don't send it by itself once setup is done. Whether AI belongs in the app at all, and the contract to
52
81
  write in APP.md, are the app-ai skill's.
53
82
  - Tools are small reads and writes over the app's own data, ids in and ids out, never "do anything". A read has
@@ -91,12 +120,17 @@ turns it into text for `act`. Their comments in lamp.js have the details. The re
91
120
 
92
121
  ## The person's own things
93
122
 
123
+ Opening and saving files the person picks needs no Swift: on the Mac a file field (`<input type="file">`, with
124
+ `multiple` or `webkitdirectory`) shows the open panel, and a download (a link with `download`, often to a Blob the page
125
+ made) asks where to save it, as a browser does. Never add Native.swift for that.
126
+
94
127
  A page can't read the person's mail, calendar, contacts, photos or where they are. Where Lamp's app is around the page
95
128
  (a Mac, an iPhone or iPad), that app can: register a call in Sources/Native.swift and ask for it from the page with
96
- `LampNative.call(name, args)`. Elsewhere `LampNative.available` is false and the calls reject, so the page shows
97
- example content then, never an error. When a web app that's to go on a TV, Android or the Xbox lives on the person's own
98
- things, say what a page can't reach there; when it needs what only native code does, offer it native (the web-or-native
99
- skill).
129
+ `LampNative.call(name, args)`, only when the app needs those things (in a web app brought in, only when the person
130
+ asks for them: "A web app brought in", above). Elsewhere `LampNative.available` is false and the calls reject, so the
131
+ page shows example content then, never an error. When a web app that's to go on a TV, Android or the Xbox lives on the
132
+ person's own things, say what a page can't reach there; when it needs what only native code does, offer it native (the
133
+ web-or-native skill), except for a web app brought in, which is made native only when the person asks for that.
100
134
 
101
135
  ## A remote: focus and keys
102
136
 
@@ -40,7 +40,9 @@ Make it **native** only when it needs something a page can't be:
40
40
  - 3D in Swift with RealityKit (the scene-3d skill), or a game inside an Apple thing (Game Center, a widget).
41
41
 
42
42
  When one of these is part of what was asked, choose native and say why in a sentence. When the person asks for one
43
- later in an app made web, say what it needs, and offer to make it native.
43
+ later in an app made web, say what it needs, and offer to make it native. A web app brought in from elsewhere is the
44
+ exception: it stays as it is, made native only when the person asks for that (the web-app skill, "A web app brought
45
+ in").
44
46
 
45
47
  The default is where you start, not a limit:
46
48
 
@@ -50,6 +52,9 @@ The default is where you start, not a limit:
50
52
  only once the person has said that's what they want, or answered so when you asked.
51
53
  - **An app the project has is never converted to follow the default.** It's for a new app, and for a change the person
52
54
  asks for. An app made native before keeps its make.
55
+ - **A web app brought in keeps its make and its kind.** A site or page someone made elsewhere stays a web app, with no
56
+ Swift of its own, unless the person asks in so many words for it to be native or a game on Lamp's runtime; being on a
57
+ Mac isn't such a request (the web-app skill, "A web app brought in").
53
58
  - **Separate apps that talk don't need the same make.** The project's apps reach each other through `LampWish` on
54
59
  whichever devices they're on, however each is made (the wish-links skill).
55
60
  - **Never choose a make to get around a limit of Lamp's**: two apps of one code in one project, say (the more-apps
package/cli/ai.mjs ADDED
@@ -0,0 +1,237 @@
1
+ // The AI a page asks for with LampAI (lamp.js), answered by lamp itself for the pages it serves, with the person's own
2
+ // Claude Code or Codex on this Mac: their subscription, through the program they signed in to, never a key in the page.
3
+ // The same routes and answers Lamp's app gives (`/ai/status`, `/ai`, `/ai/act`, `/ai/decide`), told and checked by the
4
+ // same code (app-ai.mjs, which Lamp's app has do it too), so a page can't tell which answered it. Pictures and
5
+ // hearing need Lamp's app: asked here, they say so.
6
+
7
+ import { spawn, execFileSync } from "node:child_process";
8
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { randomBytes } from "node:crypto";
12
+ import { chatBody, decidePrompt, planPrompt, planSchema, readDecision, readMessage, readPlan, readStep } from "./app-ai.mjs";
13
+ import { DEFAULT_MODEL, OpenRouterTrouble, chat as openRouterChat, text as openRouterText } from "./openrouter.mjs";
14
+
15
+
16
+ /**
17
+ * What answers: `LAMP_AI` (claude, codex or openrouter) when it's set, else Claude Code, else Codex, else OpenRouter with
18
+ * the person's key in OPENROUTER_API_KEY.
19
+ */
20
+ export function engine() {
21
+ const wanted = process.env.LAMP_AI;
22
+ for (const name of wanted ? [wanted] : ["claude", "codex", "openrouter"]) {
23
+ if (name === "openrouter") { if (process.env.OPENROUTER_API_KEY) return name; continue; }
24
+ if (name !== "claude" && name !== "codex") continue;
25
+ try { execFileSync("/usr/bin/which", [name], { stdio: ["ignore", "pipe", "ignore"] }); return name; } catch {}
26
+ }
27
+ return null;
28
+ }
29
+
30
+ const missing = "AI here needs Claude Code or Codex on this computer, signed in, or an OpenRouter key in OPENROUTER_API_KEY. Or open the app in Lamp.";
31
+ const named = (which) => (which === "codex" ? "Codex" : which === "openrouter" ? "OpenRouter" : "Claude Code");
32
+
33
+ /** Why an engine gave no answer: `kind` is "missing", "timeout", "stopped", "failed" (its own words) or "silent". */
34
+ export class EngineTrouble extends Error {
35
+ constructor(kind, message) { super(message); this.kind = kind; }
36
+ }
37
+
38
+ /**
39
+ * One answer from Claude Code or Codex, the person's own sign-in: text, or JSON fitting `schema`. Every run is sealed:
40
+ * none of the person's settings, rules, hooks or MCP servers, and none of the engine's tools, so nothing of the
41
+ * computer is read or run whatever the prompt asks. Pictures (`images`, base64 with their `media_type`) go with the
42
+ * request itself: to Claude Code in its input, to Codex as attachments, never through a tool that reads files.
43
+ * Lamp's app runs it this way too (`engineAnswer` in calls.mjs, `AppAI.viaEngine`), naming the program (`tool`) and
44
+ * what it needs set (`env`); on its own lamp finds the person's own on the PATH.
45
+ */
46
+ export function complete({ system, prompt, schema, seconds = 60, which = engine(), tool, env = {}, model = process.env.LAMP_AI_MODEL || "", images = [], signal } = {}) {
47
+ if (which === "openrouter") return viaOpenRouter({ system, prompt, schema, seconds, model, images, signal });
48
+ if (which !== "claude" && which !== "codex") return Promise.reject(new EngineTrouble("missing", missing));
49
+ const folder = mkdtempSync(join(tmpdir(), "lamp-ai-"));
50
+ const sayer = system || "You are a helpful assistant inside an app.";
51
+ const pictures = (Array.isArray(images) ? images : []).filter((image) => typeof image?.data === "string" && image.data);
52
+ let args, input = null;
53
+ if (which === "claude") {
54
+ // None of the person's settings or hooks (--restricted), no MCP, no tools. With pictures, the prompt and the pictures
55
+ // go in on its input as one message, which it takes in stream-json.
56
+ args = ["-p", ...(pictures.length ? [] : [prompt]), "--output-format", "stream-json", "--verbose", "--no-session-persistence", "--restricted",
57
+ "--strict-mcp-config", "--disable-slash-commands", "--tools", "", "--system-prompt", sayer];
58
+ if (model) args.push("--model", model);
59
+ if (schema) args.push("--json-schema", JSON.stringify(schema));
60
+ if (pictures.length) {
61
+ args.push("--input-format", "stream-json");
62
+ const content = [{ type: "text", text: prompt }, ...pictures.map((image) => ({ type: "image",
63
+ source: { type: "base64", media_type: String(image.media_type || "image/jpeg"), data: image.data } }))];
64
+ input = JSON.stringify({ type: "user", message: { role: "user", content } }) + "\n";
65
+ }
66
+ } else {
67
+ // Read-only, none of the person's config, rules or hooks, and none of its tools. Its picture tool reads any file on
68
+ // the computer: `features.view_image` turns it off now, `tools.view_image` did before (each version ignores the
69
+ // other's, with a warning); pictures given with `-i` are part of the request, not that tool. What's left, checked
70
+ // with Codex 0.167.1, is asking the person a question, which a run with no one to ask can't use.
71
+ args = ["exec", "--json", "--ignore-user-config", "--ignore-rules", "--skip-git-repo-check", "--ephemeral", "-s", "read-only",
72
+ "-c", "hooks={}", "-c", 'model_reasoning_effort="low"', "-c", 'approval_policy="never"',
73
+ "-c", "features.shell_tool=false", "-c", "features.unified_exec=false", "-c", 'web_search="disabled"',
74
+ "-c", "features.apps=false", "-c", "features.multi_agent=false", "-c", "features.view_image=false",
75
+ "-c", "tools.view_image=false", "-c", "features.goals=false",
76
+ "-c", `developer_instructions=${JSON.stringify(sayer + "\n\nAnswer directly. Don't run commands or change files.")}`];
77
+ if (model) args.push("-m", model);
78
+ if (schema) { writeFileSync(join(folder, "schema.json"), JSON.stringify(schema)); args.push("--output-schema", "schema.json"); }
79
+ pictures.forEach((image, i) => {
80
+ const name = `image${i}.${String(image.media_type).endsWith("png") ? "png" : "jpg"}`;
81
+ writeFileSync(join(folder, name), Buffer.from(image.data, "base64"));
82
+ args.push("-i", name);
83
+ });
84
+ args.push("--", prompt);
85
+ }
86
+ // Run on Lamp's own runtime, lamp is Bun told to be itself; the engine is never told so, or Claude Code, which is
87
+ // built on Bun, would be Bun too.
88
+ const childEnv = { ...process.env, ...env };
89
+ delete childEnv.BUN_BE_BUN;
90
+ return new Promise((resolve, reject) => {
91
+ const child = spawn(tool || which, args, { cwd: folder, env: childEnv, detached: true, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
92
+ let text = "", trouble = null, rest = "", said = "";
93
+ // The whole group, since an engine may leave a child holding its output.
94
+ const stop = (kind, words) => { if (!trouble) trouble = new EngineTrouble(kind, words); try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } };
95
+ const timer = setTimeout(() => stop("timeout", `${named(which)} took too long to answer.`), Math.max(1, seconds) * 1000);
96
+ const stopped = () => stop("stopped", "Stopped.");
97
+ if (signal?.aborted) stopped(); else signal?.addEventListener("abort", stopped);
98
+ const line = (raw) => {
99
+ let event;
100
+ try { event = JSON.parse(raw); } catch { return; }
101
+ if (which === "codex") {
102
+ if (event.type === "item.completed" && event.item?.type === "agent_message" && typeof event.item.text === "string") text = event.item.text;
103
+ else if (event.type === "turn.failed" || event.type === "error") {
104
+ // Codex nests the service's error as JSON text: its message is what's said.
105
+ const words = String(event.message || event.error?.message || "Codex couldn't answer.");
106
+ let nested;
107
+ try { nested = JSON.parse(words)?.error?.message; } catch {}
108
+ trouble ??= new EngineTrouble("failed", nested || words);
109
+ }
110
+ return;
111
+ }
112
+ if (event.type !== "result") return;
113
+ if (event.is_error) trouble ??= new EngineTrouble("failed", String(event.result || "Claude Code couldn't answer."));
114
+ else if (schema && event.structured_output !== undefined) text = JSON.stringify(event.structured_output);
115
+ else if (typeof event.result === "string") text = event.result;
116
+ };
117
+ // Read as text across chunks, so a character split between two of them stays whole.
118
+ child.stdout.setEncoding("utf8");
119
+ child.stderr.setEncoding("utf8");
120
+ child.stdout.on("data", (chunk) => {
121
+ rest += chunk;
122
+ const lines = rest.split("\n");
123
+ rest = lines.pop();
124
+ lines.forEach(line);
125
+ });
126
+ child.stderr.on("data", (chunk) => { said = (said + chunk).slice(-400); });
127
+ if (input) { child.stdin.on("error", () => {}); child.stdin.end(input); }
128
+ const done = () => { clearTimeout(timer); signal?.removeEventListener("abort", stopped); rmSync(folder, { recursive: true, force: true }); };
129
+ child.on("error", () => { done(); reject(new EngineTrouble("missing", missing)); });
130
+ child.on("close", () => {
131
+ done();
132
+ if (rest) line(rest);
133
+ if (trouble) return reject(trouble);
134
+ if (!text) return reject(new EngineTrouble("silent", `${named(which)} didn't answer. Check it's signed in.${said.trim() ? ` ${said.trim().split("\n").pop()}` : ""}`));
135
+ resolve(text);
136
+ });
137
+ });
138
+ }
139
+
140
+ /** An answer from OpenRouter with the person's key (openrouter.mjs, as Lamp's app sends it), its troubles said as an engine's. */
141
+ async function viaOpenRouter({ system, prompt, schema, seconds, model, images, signal }) {
142
+ try {
143
+ return (await openRouterText({ key: process.env.OPENROUTER_API_KEY, prompt, images, system: system || "You are a helpful assistant inside an app.",
144
+ model: model || DEFAULT_MODEL, json: !!schema, schema, seconds, signal })).text;
145
+ } catch (error) {
146
+ if (!(error instanceof OpenRouterTrouble)) throw error;
147
+ const words = error.reply?.error?.message;
148
+ throw new EngineTrouble(error.kind === "refused" ? "failed" : error.kind, words ? String(words) : error.message);
149
+ }
150
+ }
151
+
152
+ // MARK: Status and asking
153
+
154
+ /** `/ai/status`: what can answer here. Text, decisions and actions with an engine; pictures only in Lamp's app. */
155
+ export function status() {
156
+ const which = engine();
157
+ const route = which ? named(which) : null;
158
+ const available = (kind) => route ? { available: true, route, where: "remote" } : { available: false, reason: missing, setup: "lamp" };
159
+ return {
160
+ text: !!route, decisions: !!route, pictures: false, act: !!route,
161
+ details: { text: available("text"), decisions: available("decisions"), act: available("act"),
162
+ pictures: { available: false, reason: "Pictures need Lamp's app: open the app in Lamp.", setup: "lamp" } },
163
+ };
164
+ }
165
+
166
+ /** `/ai`: a text answer, or JSON when asked for it. */
167
+ export async function ask(body) {
168
+ const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : "";
169
+ if (!prompt) return [400, { error: "Missing prompt." }];
170
+ let system = typeof body.system === "string" ? body.system : "";
171
+ if (body.json) system += (system ? "\n\n" : "") + "Answer with JSON only, nothing around it.";
172
+ try { return [200, { text: await complete({ system, prompt, images: body.images }) }]; }
173
+ catch (error) { return [502, { error: error.message }]; }
174
+ }
175
+
176
+ // MARK: Decisions
177
+
178
+ /** `/ai/decide`: each question answered by a text model, told and read as Lamp's app does it (app-ai.mjs). */
179
+ export async function decide(body) {
180
+ const questions = body?.questions;
181
+ if (body?.state === undefined || !questions || typeof questions !== "object" || Array.isArray(questions) || !Object.keys(questions).length) {
182
+ return [400, { error: "A decision needs `state` and at least one question." }];
183
+ }
184
+ const { system, prompt, schema } = decidePrompt(body.state, questions);
185
+ let text;
186
+ try { text = await complete({ system, prompt, schema }); } catch (error) { return [502, { error: error.message }]; }
187
+ const read = readDecision(text, questions);
188
+ return read.answers ? [200, { answers: read.answers }] : [502, { error: read.problem }];
189
+ }
190
+
191
+ // MARK: Actions (`LampAI.act`)
192
+
193
+ /** `/ai/act`: one step of the page's agent loop, told and checked as Lamp's app does it (app-ai.mjs). */
194
+ export async function act(body) {
195
+ const which = engine();
196
+ if (body?.probe === true) return [200, { act: !!which, route: which || "", why: which ? "" : missing }];
197
+ if (!which) return [501, { error: missing }];
198
+ let r;
199
+ try { r = readStep(JSON.stringify(body ?? null)); } catch (refused) { return [refused.status ?? 400, { error: refused.message }]; }
200
+ // A step's guard against a call that has stopped answering, as Lamp's app gives it (`AppAct.turnSeconds`).
201
+ const seconds = 600;
202
+ const deadline = Date.now() + Math.min(r.seconds, seconds) * 1000;
203
+ const tag = "outside-text-" + randomBytes(4).toString("hex");
204
+ let retry = null;
205
+ for (let attempt = 0; attempt < 2; attempt++) {
206
+ const left = (deadline - Date.now()) / 1000;
207
+ if (left < 1) return [504, { error: "This step ran out of time." }];
208
+ let read;
209
+ try {
210
+ if (which === "openrouter") {
211
+ const body = chatBody(r, tag, retry, process.env.LAMP_AI_MODEL || DEFAULT_MODEL);
212
+ read = readMessage((await openRouterChat({ key: process.env.OPENROUTER_API_KEY, body, seconds: left })).message, r);
213
+ } else {
214
+ const { system, prompt } = planPrompt(r, tag, retry);
215
+ read = readPlan(await complete({ system, prompt, schema: planSchema(r), seconds: left, which }), r);
216
+ }
217
+ } catch (error) {
218
+ const words = error?.reply?.error?.message || error.message;
219
+ return Date.now() >= deadline || error?.kind === "timeout" ? [504, { error: "This step ran out of time." }] : [502, { error: String(words) }];
220
+ }
221
+ if (read.answer) return [200, { ...read.answer, route: which, seconds }];
222
+ if (attempt === 0) { retry = read.problem; continue; }
223
+ return [200, { reply: "I couldn't work out how to do that.", ask: false, route: which, seconds, trouble: read.problem }];
224
+ }
225
+ return [502, { error: "The AI didn't answer." }];
226
+ }
227
+
228
+ /** A page's AI request, by its route, as Lamp's app answers it: [status, JSON]. */
229
+ export async function answer(path, body) {
230
+ if (path === "/ai/status") return [200, status()];
231
+ if (path === "/ai") return ask(body);
232
+ if (path === "/ai/act") return act(body);
233
+ if (path === "/ai/decide") return decide(body);
234
+ if (path === "/ai/hear") return body?.probe === true ? [200, { hear: false, why: "Hearing needs Lamp's app." }] : [501, { error: "Hearing needs Lamp's app: open the app in Lamp." }];
235
+ if (path === "/ai/image") return [501, { error: "Pictures need Lamp's app: open the app in Lamp.", needs: "pictures" }];
236
+ return [404, { error: "Not found." }];
237
+ }