@lampdotdev/lamp 0.35.0 → 0.36.2-nightly.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -11
- package/Resources/GameKit/apple/LampPage.swift +202 -35
- package/Resources/GameKit/apple/LampWebGame.swift +1 -1
- package/Resources/GameKit/page/lamp-live.js +672 -0
- package/Resources/GameKit/web/lamp-game.js +7 -1
- package/Resources/share-rules.json +64 -0
- package/Resources/skills/game/SKILL.md +2 -0
- package/Resources/skills/github-projects/SKILL.md +8 -5
- package/Resources/skills/web-app/SKILL.md +49 -15
- package/Resources/skills/web-or-native/SKILL.md +6 -1
- package/cli/ai.mjs +237 -0
- package/cli/app-ai.mjs +473 -0
- package/cli/browser.mjs +18 -2
- package/cli/calls.mjs +213 -9
- package/cli/dev.mjs +61 -14
- package/cli/lamp.mjs +347 -37
- package/cli/lampapp.mjs +9 -9
- package/cli/lamps/android-host/LampPageGateway.kt +2 -2
- package/cli/lamps/lamp-for-games.js +95 -36
- package/cli/lamps/lamp.js +95 -36
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -1
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +403 -285
- package/cli/lamps/web-starter/lamp.js +95 -36
- package/cli/link.mjs +77 -13
- package/cli/local-ai.mjs +193 -0
- package/cli/openrouter.mjs +133 -0
- package/cli/outside.mjs +65 -2
- package/cli/page.js +160 -8
- package/cli/share.mjs +80 -12
- package/cli/skill/SKILL.md +35 -10
- package/cli/state.mjs +3 -0
- package/cli/update.mjs +117 -12
- package/package.json +1 -1
- package/site/lib/shared-wishes.js +848 -117
|
@@ -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
|
-
|
|
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
|
|
47
|
-
|
|
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
|
|
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,
|
|
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.
|
|
21
|
-
|
|
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
|
-
|
|
36
|
-
lamp.js. There
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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.
|
|
50
|
-
adds a way in. Where it'
|
|
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)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
+
}
|