@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
|
@@ -113,10 +113,11 @@
|
|
|
113
113
|
image: function (name) { return loadImage(LampAssets.url(name)); }
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
-
function Failure(message, needsSetup) {
|
|
116
|
+
function Failure(message, needsSetup, help) {
|
|
117
117
|
this.name = 'LampAI.Failure';
|
|
118
118
|
this.message = message;
|
|
119
119
|
this.needsSetup = needsSetup || null;
|
|
120
|
+
this.help = help || null;
|
|
120
121
|
this.stack = (new Error(message)).stack;
|
|
121
122
|
}
|
|
122
123
|
Failure.prototype = Object.create(Error.prototype);
|
|
@@ -190,6 +191,8 @@
|
|
|
190
191
|
}
|
|
191
192
|
|
|
192
193
|
// Why a copy Lamp didn't put on a device has no AI: a page on the web (its published page) is away from Lamp.
|
|
194
|
+
// Where someone with no Lamp finds how to turn AI on, for a page away from Lamp.
|
|
195
|
+
var AI_HELP = 'https://lamp.dev';
|
|
193
196
|
function unplaced() {
|
|
194
197
|
return here.protocol === 'https:'
|
|
195
198
|
? 'This page is on the web, away from Lamp, so it has no AI here. Open it in Lamp for that.'
|
|
@@ -206,7 +209,7 @@
|
|
|
206
209
|
var ipv4 = /^http:\/\/\d+\.\d+\.\d+\.\d+:/;
|
|
207
210
|
list = list.filter(function (r) { return ipv4.test(r); }).concat(list.filter(function (r) { return !ipv4.test(r); }));
|
|
208
211
|
}
|
|
209
|
-
if (!list.length) return Promise.reject(new Failure(unplaced()));
|
|
212
|
+
if (!list.length) return Promise.reject(new Failure(unplaced(), null, AI_HELP));
|
|
210
213
|
// With a deadline (LampAI.act's turn), each try gets only the time left, and none starts after it.
|
|
211
214
|
function left() { return deadline ? deadline - Date.now() : timeout; }
|
|
212
215
|
function attempt(i, round) {
|
|
@@ -229,7 +232,7 @@
|
|
|
229
232
|
function token() {
|
|
230
233
|
var kept = read('install.token', null);
|
|
231
234
|
if (kept && installed && kept.app === installed.app) return Promise.resolve(kept.token);
|
|
232
|
-
if (!installed) return Promise.reject(new Failure(unplaced()));
|
|
235
|
+
if (!installed) return Promise.reject(new Failure(unplaced(), null, AI_HELP));
|
|
233
236
|
return send('/app/claim', { app: installed.app }, null, 10000).then(function (answer) {
|
|
234
237
|
if (answer.status !== 200 || !answer.reply.token) {
|
|
235
238
|
throw new Failure('This app needs Lamp to put it on this device once more before it can use AI: press Play in Lamp on your Mac.');
|
|
@@ -240,10 +243,50 @@
|
|
|
240
243
|
});
|
|
241
244
|
}
|
|
242
245
|
|
|
246
|
+
// A page shared on lamp.dev plays from a host of its own under play.lamp.dev, away from Lamp. The person's own
|
|
247
|
+
// Lamp or lamp on this computer can answer it (Lamp's `SharedPagesAI`, or `lamp ai`, at a port of the computer's
|
|
248
|
+
// own), once they've allowed this
|
|
249
|
+
// page there; the token it's given is kept with the page's saved things. Whether there's an AI is asked first,
|
|
250
|
+
// without one, so the person is asked only when the page uses it.
|
|
251
|
+
var NEARBY = 'http://127.0.0.1:47423';
|
|
252
|
+
function shared() { return !installed && here.protocol === 'https:' && /\.play\.lamp\.dev$/.test(here.hostname || ''); }
|
|
253
|
+
function safari() {
|
|
254
|
+
var ua = (window.navigator && navigator.userAgent) || '';
|
|
255
|
+
return /Safari\//.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Firefox|FxiOS/.test(ua);
|
|
256
|
+
}
|
|
257
|
+
function nearby(path, body, timeout, deadline) {
|
|
258
|
+
var text = JSON.stringify(body);
|
|
259
|
+
function go(t) { return exchange(NEARBY, path, t, text, deadline ? Math.max(1000, deadline - Date.now()) : timeout); }
|
|
260
|
+
function allow() {
|
|
261
|
+
return exchange(NEARBY, '/pair', null, JSON.stringify({ name: document.title || '' }), 130000).then(function (answer) {
|
|
262
|
+
if (answer.status !== 200 || !answer.reply.token) throw new Failure('This computer didn\'t allow this page to use its AI.');
|
|
263
|
+
write('nearby.token', answer.reply.token);
|
|
264
|
+
return answer.reply.token;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
var kept = read('nearby.token', null);
|
|
268
|
+
var done = path === '/ai/status' || (body && body.probe === true) ? go(kept) : (kept ? Promise.resolve(kept) : allow()).then(function (t) {
|
|
269
|
+
return go(t).then(function (answer) {
|
|
270
|
+
if (answer.status !== 401) return answer;
|
|
271
|
+
// Taken back on the computer: ask once more.
|
|
272
|
+
forget('nearby.token');
|
|
273
|
+
return allow().then(go);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
return done.then(null, function (e) {
|
|
277
|
+
if (e instanceof Failure) throw e;
|
|
278
|
+
// Safari never lets a page on the web reach this computer this way, so Lamp or lamp ai open here wouldn't help.
|
|
279
|
+
if (safari()) throw new Failure('AI here works in Chrome or in Lamp, or with Lamp\'s command line: lamp play and this page\'s link.', null, AI_HELP);
|
|
280
|
+
throw new Failure('AI isn\'t available here. Open Lamp on this computer, or with Lamp\'s command line, run lamp ai.', null, AI_HELP);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
243
284
|
function post(path, body, timeout, deadline) {
|
|
244
285
|
timeout = timeout || 240000;
|
|
245
286
|
var done;
|
|
246
|
-
if (bridge) {
|
|
287
|
+
if (!bridge && shared()) {
|
|
288
|
+
done = nearby(path, body, timeout, deadline);
|
|
289
|
+
} else if (bridge) {
|
|
247
290
|
// The TV preview on the Mac answers for Lamp itself.
|
|
248
291
|
done = bridge.postMessage({ path: path, body: JSON.stringify(body), timeout: timeout }).then(function (r) {
|
|
249
292
|
var reply = {};
|
|
@@ -454,8 +497,11 @@
|
|
|
454
497
|
|
|
455
498
|
// Doing things for the person: LampAI.act
|
|
456
499
|
|
|
457
|
-
//
|
|
458
|
-
|
|
500
|
+
// A turn has no time limit of its own: a real answer is never cut off, however long the model takes. Each step is
|
|
501
|
+
// given ACT_STEP_SECONDS, which only a call that has stopped answering reaches. An app that wants a quicker turn
|
|
502
|
+
// asks for one (`limits: { seconds }`); steps and writes it may only lower.
|
|
503
|
+
var ACT_LIMITS = { steps: 4, writes: 6 };
|
|
504
|
+
var ACT_STEP_SECONDS = 600;
|
|
459
505
|
var actRan = {}; // turn id -> call key -> the result a write gave, while this copy of the page is open
|
|
460
506
|
function actId() { return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
|
|
461
507
|
function isList(v) { return Object.prototype.toString.call(v) === '[object Array]'; }
|
|
@@ -481,10 +527,11 @@
|
|
|
481
527
|
function act(request, options) {
|
|
482
528
|
var o = options || {}, tools = o.tools || {};
|
|
483
529
|
var limits = {};
|
|
484
|
-
['steps', 'writes'
|
|
530
|
+
['steps', 'writes'].forEach(function (k) {
|
|
485
531
|
var asked = o.limits && typeof o.limits[k] === 'number' ? o.limits[k] : ACT_LIMITS[k];
|
|
486
532
|
limits[k] = Math.max(1, Math.min(asked, ACT_LIMITS[k]));
|
|
487
533
|
});
|
|
534
|
+
var asked = o.limits && typeof o.limits.seconds === 'number' && o.limits.seconds > 0 ? o.limits.seconds : null;
|
|
488
535
|
var id = o.turn || actId();
|
|
489
536
|
if (!actRan[id]) {
|
|
490
537
|
var kept = Object.keys(actRan);
|
|
@@ -492,7 +539,7 @@
|
|
|
492
539
|
actRan[id] = {};
|
|
493
540
|
}
|
|
494
541
|
var ran = actRan[id];
|
|
495
|
-
var started = Date.now(), deadline = started +
|
|
542
|
+
var started = Date.now(), deadline = asked ? started + Math.max(1, asked) * 1000 : Infinity;
|
|
496
543
|
var out = { turn: id, reply: '', ask: false, choices: [], done: [], failed: [], skipped: [], reads: [], stopped: null, error: '' };
|
|
497
544
|
var steps = [], route = null, writes = 0, over = false, settle;
|
|
498
545
|
var declared = Object.keys(tools).map(function (name) {
|
|
@@ -514,7 +561,7 @@
|
|
|
514
561
|
return over;
|
|
515
562
|
}
|
|
516
563
|
var result = new Promise(function (resolve) { settle = resolve; });
|
|
517
|
-
var timer = setTimeout(function () { stop('time'); },
|
|
564
|
+
var timer = asked ? setTimeout(function () { stop('time'); }, Math.max(1, asked) * 1000) : null;
|
|
518
565
|
result.then(function () { clearTimeout(timer); });
|
|
519
566
|
|
|
520
567
|
// Reads run together; then the writes, one after another, each checked right before it runs. A write that
|
|
@@ -590,23 +637,15 @@
|
|
|
590
637
|
if (over) return;
|
|
591
638
|
var left = deadline - Date.now();
|
|
592
639
|
if (left <= 500) return stop('time');
|
|
640
|
+
// The step's own time: what's left of an app's turn, or the step's guard against a call that has stopped.
|
|
641
|
+
var stepTime = Math.min(left, ACT_STEP_SECONDS * 1000);
|
|
593
642
|
var body = { request: String(request), about: o.about || '', now: present(), tools: declared, steps: steps,
|
|
594
|
-
history: (o.history || []).slice(-3), seconds: Math.floor(
|
|
643
|
+
history: (o.history || []).slice(-3), seconds: Math.floor(stepTime / 1000) };
|
|
595
644
|
if (o.context !== undefined) body.context = jsonState(o.context);
|
|
596
645
|
if (route) body.route = route;
|
|
597
646
|
if (steps.length >= limits.steps - 1) body.final = true;
|
|
598
647
|
if (o.model) body.model = o.model;
|
|
599
|
-
post('/ai/act', body,
|
|
600
|
-
// The route's own time for a turn (25 seconds over OpenRouter, a minute through Claude Code, Codex or a model
|
|
601
|
-
// on the Mac) shortens the turn's deadline, counted from its start; it never lengthens it.
|
|
602
|
-
if (!over && typeof answer.seconds === 'number' && answer.seconds > 0) {
|
|
603
|
-
var end = started + Math.min(answer.seconds, limits.seconds) * 1000;
|
|
604
|
-
if (end < deadline) {
|
|
605
|
-
deadline = end;
|
|
606
|
-
clearTimeout(timer);
|
|
607
|
-
timer = setTimeout(function () { stop('time'); }, Math.max(0, deadline - Date.now()));
|
|
608
|
-
}
|
|
609
|
-
}
|
|
648
|
+
post('/ai/act', body, stepTime, Date.now() + stepTime).then(function (answer) {
|
|
610
649
|
// Cancelled, out of time or already over: an answer that comes late runs nothing.
|
|
611
650
|
if (late()) return;
|
|
612
651
|
route = answer.route || route;
|
|
@@ -693,24 +732,42 @@
|
|
|
693
732
|
* Text calls take a few seconds; decisions well under a second; image calls 10-60 seconds. Show `error.message` on
|
|
694
733
|
* failure. Aspect ratios look like "1:1", "16:9", "3:4".
|
|
695
734
|
*
|
|
696
|
-
* Not every Mac has every kind of AI set up,
|
|
697
|
-
* `LampAI.
|
|
698
|
-
*
|
|
699
|
-
*
|
|
735
|
+
* Not every Mac has every kind of AI set up, a device away from the Mac's Wi-Fi or with Lamp closed has none, and a
|
|
736
|
+
* page shared on lamp.dev has it only on a computer that answers it. `LampAI.status('pictures')` (or 'text',
|
|
737
|
+
* 'decisions', 'act', 'hear') says whether it can answer now and, when it can't, the line to show: check it where
|
|
738
|
+
* a feature depends on it, and show the feature with its `reason` as it's given rather than words of the app's
|
|
739
|
+
* own, or hiding it or failing at it, and where it gives `help` (a page away from Lamp), a link to it beside the
|
|
740
|
+
* reason: "How to turn on AI". A call that fails for that reason has `needsSetup`, or `help`, set on its error.
|
|
700
741
|
*/
|
|
701
742
|
var LampAI = {
|
|
702
743
|
Failure: Failure,
|
|
703
744
|
|
|
704
|
-
/** Whether this kind of AI ('text', 'decisions', 'pictures' or '
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
745
|
+
/** Whether this kind of AI ('text', 'decisions', 'pictures', 'act' or 'hear') can answer now: { available, reason,
|
|
746
|
+
* help }, where `reason` is the short line to show when it can't, written for where the page is, and `help`, when
|
|
747
|
+
* it's there, the page that says how to turn AI on, to link to beside it. */
|
|
748
|
+
status: function (capability) {
|
|
749
|
+
var no = function (reason, help) {
|
|
750
|
+
var said = { available: false, reason: reason || 'AI isn\'t set up for this here.' };
|
|
751
|
+
if (help) said.help = help;
|
|
752
|
+
return said;
|
|
753
|
+
};
|
|
754
|
+
// 'act' is asked of /ai/act itself: available only when this lamp.js has it, the app around the page lets the
|
|
755
|
+
// route through (an older one answers Not found) and the AI that answers the app can take it. 'hear' the same.
|
|
756
|
+
if (capability === 'act' || capability === 'hear') {
|
|
757
|
+
return post('/ai/' + capability, { probe: true }, 10000).then(function (reply) {
|
|
758
|
+
return reply[capability] === true ? { available: true, reason: '' } : no(reply.reason || reply.why);
|
|
759
|
+
}, function (e) { return no(e && e.message, e && e.help); });
|
|
760
|
+
}
|
|
761
|
+
return post('/ai/status', {}, 10000).then(function (reply) {
|
|
762
|
+
if (reply[capability] === true) return { available: true, reason: '' };
|
|
763
|
+
var detail = reply.details && reply.details[capability];
|
|
764
|
+
return no(detail && detail.reason);
|
|
765
|
+
}, function (e) { return no(e && e.message, e && e.help); });
|
|
712
766
|
},
|
|
713
767
|
|
|
768
|
+
/** Whether this kind of AI can answer now, as `status` says. */
|
|
769
|
+
isAvailable: function (capability) { return LampAI.status(capability).then(function (s) { return s.available; }); },
|
|
770
|
+
|
|
714
771
|
/** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
|
|
715
772
|
* system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
|
|
716
773
|
* model (Lamp's id: "sonnet", "opus", "haiku", "claude-fable-5-2"). */
|
|
@@ -719,7 +776,7 @@
|
|
|
719
776
|
/**
|
|
720
777
|
* Does what the person asks, in their own words, with the app's own reads and writes: the model picks calls,
|
|
721
778
|
* the page runs them, the model sees what happened, until it replies (at most 4 model calls and 6 writes a turn,
|
|
722
|
-
*
|
|
779
|
+
* with no time limit unless the app sets one with `limits: { seconds }`; Cancel ends it at once). Check `LampAI.isAvailable('act')` first; keep the app's buttons for everything.
|
|
723
780
|
*
|
|
724
781
|
* var turn = LampAI.act('Add milk', {
|
|
725
782
|
* about: 'A family dashboard: the calendar, chores and the shopping list.',
|
|
@@ -816,6 +873,7 @@
|
|
|
816
873
|
* probability, choice, score and confidence. Fails only when no item could be judged.
|
|
817
874
|
*/
|
|
818
875
|
decideEach: function (items, questions, options) {
|
|
876
|
+
var failed = null;
|
|
819
877
|
var o = options || {}, about = o.about || function (item) { return item; };
|
|
820
878
|
var limit = Math.max(1, o.concurrency || 12), total = items.length, done = 0, next = 0;
|
|
821
879
|
var results = new Array(total), saved = o.remember ? read('verdicts', {}) : null, now = Date.now();
|
|
@@ -843,7 +901,7 @@
|
|
|
843
901
|
for (var k in (reply.answers || {})) if (reply.answers.hasOwnProperty(k)) answers[k] = decision(reply.answers[k]);
|
|
844
902
|
if (key) saved[key] = { answers: reply.answers, until: now + o.remember * 1000 };
|
|
845
903
|
finish(i, answers);
|
|
846
|
-
}, function (error) { finish(i, null, error.message); });
|
|
904
|
+
}, function (error) { if (!failed) failed = error; finish(i, null, error.message); });
|
|
847
905
|
}
|
|
848
906
|
function worker() {
|
|
849
907
|
if (next >= total) return Promise.resolve();
|
|
@@ -859,7 +917,8 @@
|
|
|
859
917
|
keys.slice(-2000).forEach(function (k) { kept[k] = saved[k]; });
|
|
860
918
|
write('verdicts', kept);
|
|
861
919
|
}
|
|
862
|
-
|
|
920
|
+
// Every item failed: the first failure's setup and help go with it, as a single decision's would.
|
|
921
|
+
if (total && results.every(function (r) { return r.error; })) throw new Failure(results[0].error, failed && failed.needsSetup, failed && failed.help);
|
|
863
922
|
return results;
|
|
864
923
|
});
|
|
865
924
|
},
|
package/cli/link.mjs
CHANGED
|
@@ -22,6 +22,9 @@ const REFUSALS = {
|
|
|
22
22
|
rate_limited: "the site has taken as many as it takes for now: try again in a while",
|
|
23
23
|
service_unavailable: "the site can't keep it just now: try again in a moment",
|
|
24
24
|
not_found: "the link is no longer there",
|
|
25
|
+
invalid_username: "a username is 2 to 30 lowercase letters, digits and dashes, starting and ending with a letter or digit, and not one of the site's own words",
|
|
26
|
+
username_taken: "that username is someone else's: choose another",
|
|
27
|
+
invalid_email: "that isn't an email address the site can write to",
|
|
25
28
|
};
|
|
26
29
|
|
|
27
30
|
/** The site couldn't be reached at all: offline, or the site is down. */
|
|
@@ -32,14 +35,15 @@ export class Unreachable extends Problem {}
|
|
|
32
35
|
* a little later each time, when the site can't be reached or can't answer just now; any other is sent once. A refusal
|
|
33
36
|
* carries the status the site gave it. No redirect is followed: a link's secret goes to the site and nowhere else.
|
|
34
37
|
*/
|
|
35
|
-
export async function call(method, url, { secret, json, bytes, tries = 1 } = {}) {
|
|
38
|
+
export async function call(method, url, { secret, account, json, bytes, tries = 1, seconds = 120 } = {}) {
|
|
36
39
|
const headers = {
|
|
37
40
|
...(secret ? { authorization: `Bearer ${secret}` } : {}),
|
|
41
|
+
...(account ? { "x-lamp-account": account } : {}),
|
|
38
42
|
...(json ? { "content-type": "application/json" } : bytes ? { "content-type": "application/octet-stream" } : {}),
|
|
39
43
|
};
|
|
40
44
|
for (let attempt = 0; ; attempt++) {
|
|
41
45
|
let answer = null;
|
|
42
|
-
try { answer = await fetch(url, { method, headers, body: json ? JSON.stringify(json) : bytes, redirect: "manual", signal: AbortSignal.timeout(
|
|
46
|
+
try { answer = await fetch(url, { method, headers, body: json ? JSON.stringify(json) : bytes, redirect: "manual", signal: AbortSignal.timeout(seconds * 1000) }); } catch {}
|
|
43
47
|
if (answer && answer.status < 500) {
|
|
44
48
|
const said = await answer.json().catch(() => null);
|
|
45
49
|
if (answer.ok) return { status: answer.status, said: said ?? {} };
|
|
@@ -65,33 +69,65 @@ function onSite(site, address) {
|
|
|
65
69
|
* name, line, words, who it's by, the questions its page asks, its shape, picture and icon). A link the site no longer
|
|
66
70
|
* has is made again. Answers its id, its address and its secret.
|
|
67
71
|
*/
|
|
68
|
-
export async function publishLink({ site = siteOrigin(), body, earlier }) {
|
|
72
|
+
export async function publishLink({ site = siteOrigin(), body, earlier, account }) {
|
|
69
73
|
if (earlier) {
|
|
70
74
|
try {
|
|
71
|
-
const { said } = await call("PUT", `${site}/api/wishes?id=${encodeURIComponent(earlier.id)}`, { secret: earlier.secret, json: body });
|
|
75
|
+
const { said } = await call("PUT", `${site}/api/wishes?id=${encodeURIComponent(earlier.id)}`, { secret: earlier.secret, account, json: body });
|
|
72
76
|
// A change keeps the link; its address follows the name, so a rename shows in it (the old one still works).
|
|
73
77
|
return { id: earlier.id, url: onSite(site, said.url) ?? earlier.url ?? null, secret: earlier.secret };
|
|
74
78
|
} catch (error) { if (error.status !== 404) throw error; }
|
|
75
79
|
}
|
|
76
|
-
const { said } = await call("POST", `${site}/api/wishes`, { json: body });
|
|
80
|
+
const { said } = await call("POST", `${site}/api/wishes`, { account, json: body });
|
|
77
81
|
const url = onSite(site, said.url);
|
|
78
82
|
if (typeof said.id !== "string" || typeof said.secret !== "string" || !url) throw new Problem("the site's answer couldn't be read");
|
|
79
83
|
return { id: said.id, url, secret: said.secret };
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Asks for a maker's account (`username` and `email`), or with an address that has one, to sign in to it: answers
|
|
88
|
+
* `{ username, key, existing, sent }`. The key is the account's once the email's link is opened.
|
|
89
|
+
*/
|
|
90
|
+
export async function askAccount({ site = siteOrigin(), username, email }) {
|
|
91
|
+
const { said } = await call("POST", `${site}/api/accounts`, { json: { ...(username ? { username } : {}), email } });
|
|
92
|
+
if (typeof said.key !== "string" || typeof said.username !== "string") throw new Problem("the site's answer couldn't be read");
|
|
93
|
+
return said;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The username a key is an account's, once the email's link was opened; null before. */
|
|
97
|
+
export async function accountName({ site = siteOrigin(), key }) {
|
|
98
|
+
const { said } = await call("GET", `${site}/api/accounts`, { secret: key, seconds: 20 });
|
|
99
|
+
return typeof said.username === "string" ? said.username : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
/**
|
|
83
103
|
* What a followed link has now: its name, who it's by (null for nobody), its file's version (0 with no file), and the
|
|
84
104
|
* file's address, only from where the site keeps them. Null when it can't be asked, or is gone. Asks with nothing of
|
|
85
105
|
* the person's.
|
|
86
106
|
*/
|
|
87
|
-
export async function latestOf({ site = siteOrigin(), id }) {
|
|
107
|
+
export async function latestOf({ site = siteOrigin(), id, user, app }) {
|
|
88
108
|
let said;
|
|
89
|
-
|
|
109
|
+
const asked = id ? `id=${encodeURIComponent(id)}` : `user=${encodeURIComponent(user)}&app=${encodeURIComponent(app)}`;
|
|
110
|
+
try { ({ said } = await call("GET", `${site}/api/wishes?${asked}`)); } catch { return null; }
|
|
90
111
|
if (said.format !== 1 || !Number.isInteger(said.version)) return null;
|
|
91
112
|
let file = null;
|
|
92
113
|
try { const url = new URL(said.file); if (url.protocol === "https:" || (url.protocol === "data:" && new URL(site).protocol === "http:")) file = url.href; } catch {}
|
|
93
114
|
const by = typeof said.by === "string" ? said.by.trim().slice(0, LIMITS.by) : "";
|
|
94
|
-
return { name: typeof said.name === "string" ? said.name : null, by: by || null, version: said.version, file
|
|
115
|
+
return { id: typeof said.id === "string" ? said.id : id ?? null, name: typeof said.name === "string" ? said.name : null, by: by || null, version: said.version, file,
|
|
116
|
+
// Absent from a site from before that doesn't say; null while a link's first page is still being stored.
|
|
117
|
+
page: !("page" in said) ? undefined : Number.isInteger(said.page) ? said.page : null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The site stores a page's files after it answers its last part, and the link shows the page before until they're in:
|
|
122
|
+
* waits until it shows `version` (a minute at most; a site from before that doesn't say is taken at its word).
|
|
123
|
+
*/
|
|
124
|
+
export async function pageUp({ site = siteOrigin(), id, version, said = () => {} }) {
|
|
125
|
+
for (let waited = 0; waited < 60; waited += 2) {
|
|
126
|
+
const now = await latestOf({ site, id });
|
|
127
|
+
if (!now || now.page === undefined || (Number.isInteger(now.page) && now.page >= version)) return;
|
|
128
|
+
if (waited === 0) said("Waiting for lamp.dev to put the page up…");
|
|
129
|
+
await sleep(2000);
|
|
130
|
+
}
|
|
95
131
|
}
|
|
96
132
|
|
|
97
133
|
const fileAddress = (site, id, more = "") => `${site}/api/wish-file?id=${encodeURIComponent(id)}${more}`;
|
|
@@ -108,7 +144,8 @@ export async function sendFile({ site = siteOrigin(), link, bytes, page = false,
|
|
|
108
144
|
said(`Sending ${page ? "the page" : "the file"}: part ${n + 1} of ${count}`);
|
|
109
145
|
await call("PUT", fileAddress(site, link.id, `${what}&part=${n}&of=${count}`), { secret: link.secret, bytes: bytes.subarray(n * FILE_LIMITS.part, (n + 1) * FILE_LIMITS.part), tries: 4 });
|
|
110
146
|
}
|
|
111
|
-
|
|
147
|
+
// Putting a page together stores each of its files, which for a big game's takes minutes (the site gives it five).
|
|
148
|
+
return (await call("POST", fileAddress(site, link.id, what), { secret: link.secret, seconds: 330 })).said.version ?? null;
|
|
112
149
|
}
|
|
113
150
|
|
|
114
151
|
/** Takes the link's file away, which keeps showing the idea, and its page with it; with `page` only the page. */
|
|
@@ -118,19 +155,46 @@ export async function removeFile({ site = siteOrigin(), link, page = false }) {
|
|
|
118
155
|
}
|
|
119
156
|
|
|
120
157
|
/** The ideas sent to the link that are still open, oldest first: each with its number. Only the link's secret reads them. */
|
|
121
|
-
export async function ideasOf({ site = siteOrigin(), link }) {
|
|
122
|
-
try { return (await call("GET", `${site}/api/wish-ideas?id=${encodeURIComponent(link.id)}`, { secret: link.secret })).said.ideas ?? []; }
|
|
158
|
+
export async function ideasOf({ site = siteOrigin(), link, earlier = false }) {
|
|
159
|
+
try { return (await call("GET", `${site}/api/wish-ideas?id=${encodeURIComponent(link.id)}${earlier ? "&earlier=1" : ""}`, { secret: link.secret })).said.ideas ?? []; }
|
|
123
160
|
catch (error) { if (error.status === 404) return []; throw error; }
|
|
124
161
|
}
|
|
125
162
|
|
|
126
163
|
/** Lets an idea go: with `built` it's in, and the site first writes to whoever asked to hear. Whether it did. */
|
|
127
|
-
export async function removeIdea({ site = siteOrigin(), link, n, built = false }) {
|
|
164
|
+
export async function removeIdea({ site = siteOrigin(), link, n, built = false, note }) {
|
|
128
165
|
try {
|
|
129
|
-
const { said } = await call("DELETE", `${site}/api/wish-ideas?id=${encodeURIComponent(link.id)}&n=${Number(n)}${built ? "&built=1" : ""}`,
|
|
166
|
+
const { said } = await call("DELETE", `${site}/api/wish-ideas?id=${encodeURIComponent(link.id)}&n=${Number(n)}${built ? "&built=1" : ""}`,
|
|
167
|
+
{ secret: link.secret, ...(note ? { json: { note } } : {}) });
|
|
130
168
|
return { notified: said.notified === true };
|
|
131
169
|
} catch (error) { if (error.status === 404) return { notified: false }; throw error; }
|
|
132
170
|
}
|
|
133
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Says something more about an idea, its sender emailed if they gave an address, and answers whether they were;
|
|
174
|
+
* without a note, only that the maker has seen to their answer.
|
|
175
|
+
*/
|
|
176
|
+
export async function replyIdea({ site = siteOrigin(), link, n, note }) {
|
|
177
|
+
try {
|
|
178
|
+
const { said } = await call("PATCH", `${site}/api/wish-ideas?id=${encodeURIComponent(link.id)}&n=${Number(n)}`,
|
|
179
|
+
{ secret: link.secret, ...(note ? { json: { note } } : {}) });
|
|
180
|
+
return { notified: said.notified === true };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error.status === 404) throw new Problem(`there's no idea ${Number(n)} on this link`);
|
|
183
|
+
if (error.status === 429) throw new Problem(`idea ${Number(n)} has as many messages as it can keep`);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The maker's own address for the link's ideas and replies, as `{ email, confirmed }`; with `email`, given ("" takes it
|
|
190
|
+
* away), and the site emails it to confirm first.
|
|
191
|
+
*/
|
|
192
|
+
export async function makerOf({ site = siteOrigin(), link, email }) {
|
|
193
|
+
const { said } = await call(email === undefined ? "GET" : "PUT", `${site}/api/wish-ideas?id=${encodeURIComponent(link.id)}&maker=1`,
|
|
194
|
+
{ secret: link.secret, ...(email === undefined ? {} : { json: { email } }) });
|
|
195
|
+
return { email: typeof said.email === "string" ? said.email : null, confirmed: said.confirmed === true, ...(email ? { sent: said.sent === true } : {}) };
|
|
196
|
+
}
|
|
197
|
+
|
|
134
198
|
/** Takes the link down, with its page and the ideas sent to it. A link the site no longer has is down already. */
|
|
135
199
|
export async function removeLink({ site = siteOrigin(), link }) {
|
|
136
200
|
try { await call("DELETE", `${site}/api/wishes?id=${encodeURIComponent(link.id)}`, { secret: link.secret }); }
|
package/cli/local-ai.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// `lamp ai`: the AI for a page shared on lamp.dev, answered on this computer with the person's own Claude Code or
|
|
2
|
+
// Codex (ai.mjs), the way `lamp dev` answers a page it serves. A shared page is away from Lamp, so lamp.js asks here,
|
|
3
|
+
// at a port of this computer's own (127.0.0.1), where a browser lets an https page call plain http.
|
|
4
|
+
//
|
|
5
|
+
// Only the person decides which pages spend their subscription. Each shared link plays from an origin of its own
|
|
6
|
+
// (`playOrigin` in site/lib/shared-wishes.js: <the link's id in hex>.play.lamp.dev), which a browser always names in
|
|
7
|
+
// the request and a page can't change, so an approval is for one link's page: the first time it asks, the person is
|
|
8
|
+
// asked in the terminal, and the page is given a token it keeps and sends from then on. Nothing else is answered:
|
|
9
|
+
// another site, another link's page, or a request naming this computer by another name (a site that rebinds its own
|
|
10
|
+
// name to 127.0.0.1) is refused. Whether there's an AI here (`/ai/status` and the probes) is answered without one, so a
|
|
11
|
+
// page can say what it can do before anyone is asked.
|
|
12
|
+
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { createInterface } from "node:readline";
|
|
19
|
+
import { answer as answerAI, engine } from "./ai.mjs";
|
|
20
|
+
|
|
21
|
+
/** The port lamp.js on a shared page asks (`LOCAL_AI` there): one, fixed, since the page can't be told another. */
|
|
22
|
+
export const LOCAL_AI_PORT = 47423;
|
|
23
|
+
const PLAY_HOST = "play.lamp.dev";
|
|
24
|
+
const MOST = 2_000_000, AT_ONCE = 3, ASKING_FOR = 120_000;
|
|
25
|
+
const ROUTES = new Set(["/ai/status", "/ai", "/ai/act", "/ai/decide", "/ai/hear", "/ai/image"]);
|
|
26
|
+
|
|
27
|
+
const keptFile = () => join(process.env.LAMP_DEV_HOME || join(homedir(), ".lamp", "dev"), "shared-pages-ai.json");
|
|
28
|
+
|
|
29
|
+
/** The pages the person has let use their AI, by origin: `{ token, id, name, at }`. */
|
|
30
|
+
export function allowed() {
|
|
31
|
+
try { const kept = JSON.parse(readFileSync(keptFile(), "utf8")); return kept && typeof kept === "object" ? kept : {}; } catch { return {}; }
|
|
32
|
+
}
|
|
33
|
+
function keep(all) {
|
|
34
|
+
const file = keptFile();
|
|
35
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
36
|
+
writeFileSync(file + ".new", JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
37
|
+
renameSync(file + ".new", file);
|
|
38
|
+
}
|
|
39
|
+
/** Forgets every page let in: each asks again. */
|
|
40
|
+
export function forgetAllowed() { if (existsSync(keptFile())) rmSync(keptFile()); }
|
|
41
|
+
|
|
42
|
+
/** Forgets one page let in, by its origin: it asks again the next time it uses the AI. */
|
|
43
|
+
export function forgetPage(origin) {
|
|
44
|
+
const all = allowed();
|
|
45
|
+
if (!(origin in all)) return;
|
|
46
|
+
delete all[origin];
|
|
47
|
+
if (Object.keys(all).length) keep(all); else forgetAllowed();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The link a shared page's origin plays, by its id, or null for any other origin. */
|
|
51
|
+
export function linkOf(origin) {
|
|
52
|
+
let url;
|
|
53
|
+
try { url = new URL(origin); } catch { return null; }
|
|
54
|
+
if (url.protocol !== "https:" || url.port || !url.host.endsWith("." + PLAY_HOST)) return null;
|
|
55
|
+
const hex = url.host.slice(0, -PLAY_HOST.length - 1);
|
|
56
|
+
if (!/^(?:[0-9a-f]{2}){10}$/.test(hex)) return null;
|
|
57
|
+
const id = Buffer.from(hex, "hex").toString();
|
|
58
|
+
return /^[A-Za-z0-9]{10}$/.test(id) ? id : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const same = (a, b) => {
|
|
62
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
63
|
+
const x = Buffer.from(a), y = Buffer.from(b);
|
|
64
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Answers shared pages' AI until it's closed. `ask(question, page)` puts a yes-or-no to the person and resolves true
|
|
69
|
+
* for yes; by default it's the terminal's, and without a terminal nothing is let in. `answer(path, body, page)` is the
|
|
70
|
+
* AI's [status, reply]: the person's Claude Code, Codex or OpenRouter key by default, Lamp's own AI when Lamp's app runs this
|
|
71
|
+
* (`sharedPages` in calls.mjs). Resolves to the server once it listens.
|
|
72
|
+
*/
|
|
73
|
+
export async function serveLocalAI({ port = LOCAL_AI_PORT, ask = askInTerminal(), answer = answerAI, said = console.log } = {}) {
|
|
74
|
+
const busy = new Map(), queued = new Map(), refused = new Set(), waiting = new Map();
|
|
75
|
+
// A few of a page's requests run at once and the rest wait their turn, since lamp.js asks many at a time
|
|
76
|
+
// (`LampAI.decideEach`) and takes a refusal as that item's answer.
|
|
77
|
+
const turn = (origin) => {
|
|
78
|
+
if ((busy.get(origin) || 0) < AT_ONCE) { busy.set(origin, (busy.get(origin) || 0) + 1); return Promise.resolve(); }
|
|
79
|
+
return new Promise((go) => queued.set(origin, [...(queued.get(origin) || []), go]));
|
|
80
|
+
};
|
|
81
|
+
const done = (origin) => {
|
|
82
|
+
const next = queued.get(origin)?.shift();
|
|
83
|
+
if (next) next();
|
|
84
|
+
else busy.set(origin, busy.get(origin) - 1);
|
|
85
|
+
};
|
|
86
|
+
// One question at a time in the terminal; a page that asks again while it's waiting gets the same answer, and one
|
|
87
|
+
// the person said no to isn't asked about again while this runs.
|
|
88
|
+
let asking = Promise.resolve();
|
|
89
|
+
const askAbout = (origin, id, name) => {
|
|
90
|
+
if (waiting.has(origin)) return waiting.get(origin);
|
|
91
|
+
const asked = asking.then(async () => {
|
|
92
|
+
const kept = allowed()[origin];
|
|
93
|
+
if (kept) return kept.token;
|
|
94
|
+
const titled = name ? `"${name}" ` : "A page ";
|
|
95
|
+
const yes = await ask(`${titled}shared at lamp.dev/w/${id} wants to use your ${engineName()} here. Allow it?`, { id, name });
|
|
96
|
+
if (!yes) { refused.add(origin); return null; }
|
|
97
|
+
const token = randomBytes(32).toString("base64url");
|
|
98
|
+
keep({ ...allowed(), [origin]: { token, id, name: name || "", at: new Date().toISOString() } });
|
|
99
|
+
said(`Allowed ${name ? `"${name}"` : `lamp.dev/w/${id}`}. lamp ai --forget takes it back.`);
|
|
100
|
+
return token;
|
|
101
|
+
});
|
|
102
|
+
asking = asked.catch(() => {});
|
|
103
|
+
waiting.set(origin, asked);
|
|
104
|
+
asked.finally(() => waiting.delete(origin));
|
|
105
|
+
return asked;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const server = createServer(async (request, response) => {
|
|
109
|
+
const origin = request.headers.origin || "";
|
|
110
|
+
const id = linkOf(origin);
|
|
111
|
+
const reply = (status, body) => {
|
|
112
|
+
const headers = { "Content-Type": "application/json", "Cache-Control": "no-store", Vary: "Origin" };
|
|
113
|
+
if (id) headers["Access-Control-Allow-Origin"] = origin;
|
|
114
|
+
response.writeHead(status, headers);
|
|
115
|
+
response.end(JSON.stringify(body));
|
|
116
|
+
};
|
|
117
|
+
// Only this computer's own name for itself: a site that points a name of its own at 127.0.0.1 is someone else.
|
|
118
|
+
const host = String(request.headers.host || "").replace(/:\d+$/, "");
|
|
119
|
+
if (host !== "127.0.0.1" && host !== "localhost") return reply(403, { error: "Not here." });
|
|
120
|
+
if (!id) return reply(403, { error: "Only a page shared on lamp.dev can ask here." });
|
|
121
|
+
const url = new URL(request.url, "http://127.0.0.1");
|
|
122
|
+
if (request.method === "OPTIONS") {
|
|
123
|
+
response.writeHead(204, {
|
|
124
|
+
"Access-Control-Allow-Origin": origin,
|
|
125
|
+
"Access-Control-Allow-Methods": "POST",
|
|
126
|
+
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
127
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
128
|
+
"Access-Control-Max-Age": "600",
|
|
129
|
+
Vary: "Origin",
|
|
130
|
+
});
|
|
131
|
+
return response.end();
|
|
132
|
+
}
|
|
133
|
+
if (request.method !== "POST") return reply(405, { error: "Method not allowed." });
|
|
134
|
+
|
|
135
|
+
let body = {};
|
|
136
|
+
try {
|
|
137
|
+
const chunks = [];
|
|
138
|
+
let size = 0;
|
|
139
|
+
for await (const chunk of request) {
|
|
140
|
+
size += chunk.length;
|
|
141
|
+
if (size > MOST) return reply(413, { error: "That's too much to ask at once." });
|
|
142
|
+
chunks.push(chunk);
|
|
143
|
+
}
|
|
144
|
+
const text = Buffer.concat(chunks).toString();
|
|
145
|
+
body = text ? JSON.parse(text) : {};
|
|
146
|
+
} catch { return reply(400, { error: "That wasn't JSON." }); }
|
|
147
|
+
|
|
148
|
+
if (url.pathname === "/pair") {
|
|
149
|
+
if (refused.has(origin)) return reply(403, { error: "Not allowed on this computer." });
|
|
150
|
+
const name = typeof body?.name === "string" ? body.name.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 60) : "";
|
|
151
|
+
const token = await askAbout(origin, id, name);
|
|
152
|
+
return token ? reply(200, { token }) : reply(403, { error: "Not allowed on this computer." });
|
|
153
|
+
}
|
|
154
|
+
if (!ROUTES.has(url.pathname)) return reply(404, { error: "Not found." });
|
|
155
|
+
// What there is to answer with, asked before anyone is let in: only the routes whose probe asks no model.
|
|
156
|
+
const asksNothing = url.pathname === "/ai/status" || (body?.probe === true && (url.pathname === "/ai/act" || url.pathname === "/ai/hear"));
|
|
157
|
+
if (asksNothing) {
|
|
158
|
+
const [status, answered] = await answer(url.pathname, url.pathname === "/ai/status" ? {} : { probe: true }, { id });
|
|
159
|
+
return reply(status, answered);
|
|
160
|
+
}
|
|
161
|
+
const kept = allowed()[origin];
|
|
162
|
+
const given = String(request.headers.authorization || "").replace(/^Bearer\s+/i, "");
|
|
163
|
+
if (!kept || !same(given, kept.token)) return reply(401, { error: "This page isn't allowed to use the AI here yet." });
|
|
164
|
+
await turn(origin);
|
|
165
|
+
try {
|
|
166
|
+
const [status, answered] = await answer(url.pathname, body, { id });
|
|
167
|
+
reply(status, answered);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
reply(502, { error: error?.message || "The AI didn't answer." });
|
|
170
|
+
} finally {
|
|
171
|
+
done(origin);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
await new Promise((done, fail) => { server.once("error", fail); server.listen(port, "127.0.0.1", () => { server.off("error", fail); done(); }); });
|
|
175
|
+
return server;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const engineName = () => ({ codex: "Codex", openrouter: "OpenRouter key" })[engine()] ?? "Claude Code";
|
|
179
|
+
|
|
180
|
+
/** A yes-or-no in the terminal, or always no without one. */
|
|
181
|
+
export function askInTerminal({ input = process.stdin, output = process.stdout } = {}) {
|
|
182
|
+
return (question) => {
|
|
183
|
+
if (!input.isTTY) {
|
|
184
|
+
output.write(`${question} No: lamp ai can only ask in a terminal.\n`);
|
|
185
|
+
return Promise.resolve(false);
|
|
186
|
+
}
|
|
187
|
+
const lines = createInterface({ input, output });
|
|
188
|
+
return new Promise((done) => {
|
|
189
|
+
const timer = setTimeout(() => { output.write("\nNo answer, so not allowed.\n"); lines.close(); done(false); }, ASKING_FOR);
|
|
190
|
+
lines.question(`${question} [y/N] `, (said) => { clearTimeout(timer); lines.close(); done(/^y(es)?$/i.test(said.trim())); });
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
}
|