@lampdotdev/lamp 0.36.2-nightly.41 → 0.36.2-nightly.44
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 +1 -1
- package/Resources/skills/web-app/SKILL.md +6 -1
- package/cli/ai.mjs +161 -41
- package/cli/app-ai.mjs +145 -7
- package/cli/calls.mjs +27 -11
- package/cli/dev.mjs +8 -2
- package/cli/lamps/lamp-for-games.js +229 -91
- package/cli/lamps/lamp.js +229 -91
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -1
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +18 -16
- package/cli/lamps/web-starter/lamp.js +229 -91
- package/cli/openrouter.mjs +78 -0
- package/cli/page.js +6 -3
- package/package.json +1 -1
|
@@ -138,8 +138,20 @@
|
|
|
138
138
|
delete asked[m.id];
|
|
139
139
|
waiting({ status: m.status, body: m.body });
|
|
140
140
|
};
|
|
141
|
-
bridge = { postMessage: function (m) {
|
|
142
|
-
return new Promise(function (resolve) {
|
|
141
|
+
bridge = { postMessage: function (m, signal) {
|
|
142
|
+
return new Promise(function (resolve) {
|
|
143
|
+
var id = ++askId;
|
|
144
|
+
asked[id] = resolve;
|
|
145
|
+
gate.postMessage(JSON.stringify({ id: id, path: m.path, body: m.body, timeout: m.timeout }));
|
|
146
|
+
// A page that stops waiting tells the host, which can stop the answer (lamp dev does), and hears nothing after.
|
|
147
|
+
function stop() {
|
|
148
|
+
if (!asked[id]) return;
|
|
149
|
+
delete asked[id];
|
|
150
|
+
gate.postMessage(JSON.stringify({ id: id, cancel: true }));
|
|
151
|
+
resolve({ status: 499, body: '{}' });
|
|
152
|
+
}
|
|
153
|
+
if (signal) { if (signal.aborted) stop(); else signal.addEventListener('abort', stop); }
|
|
154
|
+
});
|
|
143
155
|
} };
|
|
144
156
|
}
|
|
145
157
|
var preferred = null;
|
|
@@ -305,14 +317,14 @@
|
|
|
305
317
|
// with a little more for the answer to come back.
|
|
306
318
|
function AI_WAIT(seconds) { return (typeof seconds === 'number' && seconds > 0 ? Math.min(seconds, 600) : 600) * 1000 + 15000; }
|
|
307
319
|
|
|
308
|
-
function post(path, body, timeout, deadline) {
|
|
320
|
+
function post(path, body, timeout, deadline, signal) {
|
|
309
321
|
timeout = timeout || 240000;
|
|
310
322
|
var done;
|
|
311
323
|
if (!bridge && shared()) {
|
|
312
324
|
done = nearby(path, body, timeout, deadline);
|
|
313
325
|
} else if (bridge) {
|
|
314
326
|
// The TV preview on the Mac answers for Lamp itself.
|
|
315
|
-
done = bridge.postMessage({ path: path, body: JSON.stringify(body), timeout: timeout }).then(function (r) {
|
|
327
|
+
done = bridge.postMessage({ path: path, body: JSON.stringify(body), timeout: timeout }, signal).then(function (r) {
|
|
316
328
|
var reply = {};
|
|
317
329
|
try { reply = JSON.parse(r.body) || {}; } catch (e) {}
|
|
318
330
|
return { status: r.status, reply: reply };
|
|
@@ -328,10 +340,15 @@
|
|
|
328
340
|
});
|
|
329
341
|
}
|
|
330
342
|
return done.then(function (answer) {
|
|
343
|
+
// Stopped by the page: no failure of the AI's, so nothing is said about it.
|
|
344
|
+
if (signal && signal.aborted) throw stoppedFailure();
|
|
331
345
|
if (answer.status === 200) return answer.reply;
|
|
332
346
|
var message = answer.reply.error || 'The AI request failed.';
|
|
333
347
|
if (window.console) console.error('LampAI ' + path + ' failed: ' + message);
|
|
334
|
-
|
|
348
|
+
var failed = new Failure(message, answer.reply.needs || null);
|
|
349
|
+
// What was billed before it failed (a retry, a timeout), for a turn's `cost`.
|
|
350
|
+
if (typeof answer.reply.cost === 'number' && answer.reply.cost > 0) failed.cost = answer.reply.cost;
|
|
351
|
+
throw failed;
|
|
335
352
|
});
|
|
336
353
|
}
|
|
337
354
|
|
|
@@ -346,14 +363,14 @@
|
|
|
346
363
|
});
|
|
347
364
|
}
|
|
348
365
|
|
|
349
|
-
/** Downscales to at most 1568px on the long side (the most the model uses) and JPEG-encodes. Takes an
|
|
350
|
-
* <canvas>, or a picture's address (a data: URL or a file of the app's). */
|
|
351
|
-
function encode(images) {
|
|
366
|
+
/** Downscales to at most 1568px on the long side (the most the model uses), or `side`, and JPEG-encodes. Takes an
|
|
367
|
+
* <img>, a <canvas>, or a picture's address (a data: URL or a file of the app's). */
|
|
368
|
+
function encode(images, side) {
|
|
352
369
|
return Promise.all((images || []).map(function (image) {
|
|
353
370
|
var ready = typeof image === 'string' ? loadImage(image) : Promise.resolve(image);
|
|
354
371
|
return ready.then(function (source) {
|
|
355
372
|
var w = source.naturalWidth || source.width, h = source.naturalHeight || source.height;
|
|
356
|
-
var scale = Math.min(1, 1568 / Math.max(w, h, 1));
|
|
373
|
+
var scale = Math.min(1, (side || 1568) / Math.max(w, h, 1));
|
|
357
374
|
var canvas = document.createElement('canvas');
|
|
358
375
|
canvas.width = Math.max(1, Math.round(w * scale));
|
|
359
376
|
canvas.height = Math.max(1, Math.round(h * scale));
|
|
@@ -483,21 +500,44 @@
|
|
|
483
500
|
score: function (question, levels) { return { type: 'score', instructions: question, criteria: levels }; }
|
|
484
501
|
};
|
|
485
502
|
|
|
503
|
+
function stoppedFailure() { var f = new Failure('Stopped.'); f.stopped = true; return f; }
|
|
504
|
+
|
|
505
|
+
// A promise the page can stop waiting for with an AbortSignal: it fails at once with `stopped`, whatever comes after.
|
|
506
|
+
function stoppable(promise, signal) {
|
|
507
|
+
if (!signal) return promise;
|
|
508
|
+
return new Promise(function (resolve, reject) {
|
|
509
|
+
function stop() { reject(stoppedFailure()); }
|
|
510
|
+
// Settled either way, so a stopped ask's own failure is never left unhandled.
|
|
511
|
+
if (signal.aborted) { promise.then(null, function () {}); return stop(); }
|
|
512
|
+
signal.addEventListener('abort', stop);
|
|
513
|
+
promise.then(function (v) { signal.removeEventListener('abort', stop); if (!signal.aborted) resolve(v); },
|
|
514
|
+
function (e) { signal.removeEventListener('abort', stop); if (!signal.aborted) reject(e); });
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
486
518
|
function call(prompt, options) {
|
|
487
|
-
var o = options || {}, text = prompt, rules = [o.system || '', present()];
|
|
519
|
+
var o = options || {}, text = prompt, rules = [o.system || '', present()], started = Date.now(), cost = 0;
|
|
520
|
+
var schema = o.schema && typeof o.schema === 'object' ? o.schema : null, json = !!o.json || !!schema;
|
|
488
521
|
if (o.data) { var f = fenced(prompt, o.data); text = f.prompt; rules.push(f.rules); }
|
|
489
|
-
return encode(o.images).then(function (images) {
|
|
490
|
-
|
|
522
|
+
return stoppable(encode(o.images).then(function (images) {
|
|
523
|
+
// Stopped before or while its pictures were read: nothing is asked.
|
|
524
|
+
if (o.signal && o.signal.aborted) throw stoppedFailure();
|
|
525
|
+
var body = { prompt: text, system: rules.filter(function (r) { return r; }).join('\n\n'), json: json, images: images };
|
|
526
|
+
if (schema) body.schema = schema;
|
|
491
527
|
if (o.model) body.model = o.model;
|
|
492
528
|
// No limit of the page's own unless it asks for one: Lamp's guard ends only a call that stopped answering.
|
|
493
529
|
if (typeof o.seconds === 'number' && o.seconds > 0) body.seconds = o.seconds;
|
|
494
|
-
return post('/ai', body, AI_WAIT(body.seconds));
|
|
530
|
+
return post('/ai', body, AI_WAIT(body.seconds), null, o.signal);
|
|
495
531
|
}).then(function (reply) {
|
|
496
532
|
if (typeof reply.text !== 'string') throw new Failure('The AI request failed.');
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
533
|
+
cost = typeof reply.cost === 'number' && reply.cost > 0 ? reply.cost : 0;
|
|
534
|
+
var answer = reply.text;
|
|
535
|
+
if (json) {
|
|
536
|
+
try { answer = JSON.parse(reply.text.replace(/^\s*```(?:json)?\s*|\s*```\s*$/g, '')); }
|
|
537
|
+
catch (e) { throw new Failure('The AI\'s answer wasn\'t in the expected format. Try again.'); }
|
|
538
|
+
}
|
|
539
|
+
return o.details ? { answer: answer, seconds: Math.round((Date.now() - started) / 100) / 10, cost: cost } : answer;
|
|
540
|
+
}), o.signal);
|
|
501
541
|
}
|
|
502
542
|
|
|
503
543
|
function image(prompt, images, aspectRatio) {
|
|
@@ -538,6 +578,12 @@
|
|
|
538
578
|
// What a turn's reads give back in all, which every later step carries again: kept under what a step may carry
|
|
539
579
|
// (`MAX_BODY` in lamp's app-ai.mjs), with room for the request, the tools and the steps' own words.
|
|
540
580
|
var ACT_TURN_RESULTS = 1200000;
|
|
581
|
+
// The pictures a step carries from its tools' results (`LampAI.picture`): the newest few, each at most this many
|
|
582
|
+
// pixels on its long side, together at most this many bytes, beside the text results under the step's limit
|
|
583
|
+
// (`MAX_PICTURES`, `MAX_BODY` in lamp's app-ai.mjs). Older ones the model has seen go as no longer shown.
|
|
584
|
+
var ACT_PICTURES = 6, ACT_PICTURE_SIDE = 1024, ACT_PICTURE_BYTES = 1600000;
|
|
585
|
+
/** A picture in a tool's result, for the AI to look at (`LampAI.picture`). */
|
|
586
|
+
function ActPicture(source) { this.source = source; }
|
|
541
587
|
var ACT_STEP_SECONDS = 600;
|
|
542
588
|
var actRan = {}; // turn id -> call key -> the result a write gave, while this copy of the page is open
|
|
543
589
|
function actId() { return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
|
|
@@ -596,8 +642,50 @@
|
|
|
596
642
|
}
|
|
597
643
|
var ran = actRan[id];
|
|
598
644
|
var started = Date.now(), deadline = asked ? started + Math.max(1, asked) * 1000 : Infinity;
|
|
599
|
-
var out = { turn: id, reply: '', ask: false, choices: [], done: [], failed: [], skipped: [], reads: [], stopped: null, error: '' };
|
|
600
|
-
var steps = [], route = null, writes = 0, over = false, settle;
|
|
645
|
+
var out = { turn: id, reply: '', ask: false, choices: [], done: [], failed: [], skipped: [], reads: [], stopped: null, error: '', seconds: 0, cost: 0 };
|
|
646
|
+
var steps = [], route = null, writes = 0, over = false, settle, pictureCount = 0;
|
|
647
|
+
// A result with its pictures taken out: each one sent beside it, scaled down, and named in its place
|
|
648
|
+
// ("[picture 2]", numbered across the turn), so the model sees what the app drew in the middle of the turn.
|
|
649
|
+
function pictured(value) {
|
|
650
|
+
var found = [];
|
|
651
|
+
function walk(v, depth) {
|
|
652
|
+
if (v instanceof ActPicture) { found.push({ source: v.source, number: ++pictureCount }); return '[picture ' + pictureCount + ']'; }
|
|
653
|
+
if (!v || typeof v !== 'object' || depth > 8) return v;
|
|
654
|
+
if (isList(v)) return v.map(function (x) { return walk(x, depth + 1); });
|
|
655
|
+
var proto = Object.getPrototypeOf(v);
|
|
656
|
+
if (proto !== Object.prototype && proto !== null) return v;
|
|
657
|
+
var copy = {};
|
|
658
|
+
for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) copy[k] = walk(v[k], depth + 1);
|
|
659
|
+
return copy;
|
|
660
|
+
}
|
|
661
|
+
var kept = walk(value, 0);
|
|
662
|
+
if (!found.length) return Promise.resolve({ value: value });
|
|
663
|
+
// A picture that never loads costs the turn nothing: after a few seconds the result goes without it.
|
|
664
|
+
var slow = new Promise(function (resolve, reject) { browserTimeout(function () { reject(new Error('slow')); }, 10000); });
|
|
665
|
+
return Promise.race([encode(found.map(function (f) { return f.source; }), ACT_PICTURE_SIDE), slow]).then(function (encoded) {
|
|
666
|
+
return { value: kept, pictures: encoded.map(function (e, i) { return { media_type: e.media_type, data: e.data, number: found[i].number }; }) };
|
|
667
|
+
}, function () {
|
|
668
|
+
return { value: { result: kept, note: 'Its pictures couldn\'t be read, so they aren\'t shown.' } };
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
// The steps as sent: only the newest pictures go with their data, within the step's room for them.
|
|
672
|
+
function sentSteps() {
|
|
673
|
+
// Chosen newest first by number across every result, whatever order the results are in.
|
|
674
|
+
var room = ACT_PICTURE_BYTES, chosen = {}, all = [];
|
|
675
|
+
steps.forEach(function (s) { s.results.forEach(function (r) { (r.pictures || []).forEach(function (p) { all.push(p); }); }); });
|
|
676
|
+
all.sort(function (a, b) { return b.number - a.number; }).forEach(function (p) {
|
|
677
|
+
if (Object.keys(chosen).length < ACT_PICTURES && p.data.length <= room) { chosen[p.number] = true; room -= p.data.length; }
|
|
678
|
+
});
|
|
679
|
+
return steps.map(function (s) {
|
|
680
|
+
return { calls: s.calls, results: s.results.map(function (r) {
|
|
681
|
+
if (!r.pictures) return r;
|
|
682
|
+
var copy = {};
|
|
683
|
+
for (var k in r) if (Object.prototype.hasOwnProperty.call(r, k)) copy[k] = r[k];
|
|
684
|
+
copy.pictures = r.pictures.map(function (p) { return { media_type: p.media_type, number: p.number, data: chosen[p.number] ? p.data : null }; });
|
|
685
|
+
return copy;
|
|
686
|
+
}) };
|
|
687
|
+
});
|
|
688
|
+
}
|
|
601
689
|
var declared = Object.keys(tools).map(function (name) {
|
|
602
690
|
var t = tools[name];
|
|
603
691
|
return { name: name, about: t.about || '', reads: !!t.reads, args: t.args || {} };
|
|
@@ -608,6 +696,7 @@
|
|
|
608
696
|
over = true;
|
|
609
697
|
out.stopped = reason || null;
|
|
610
698
|
if (error) out.error = error;
|
|
699
|
+
out.seconds = Math.round((Date.now() - started) / 100) / 10;
|
|
611
700
|
settle(out);
|
|
612
701
|
}
|
|
613
702
|
// Over, or past the deadline by the clock even if its timer hasn't run yet (a slow write can carry the turn past
|
|
@@ -622,72 +711,92 @@
|
|
|
622
711
|
|
|
623
712
|
// Reads run together; then the writes, one after another, each checked right before it runs. A write that
|
|
624
713
|
// fails stops the writes after it in this step, which the model hears were skipped.
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
714
|
+
// A step's calls, run as they come (`stream`) and in the order the AI wrote them: reads side by side run together,
|
|
715
|
+
// a write waits for every call before it, and a read after a write sees it. A write that fails skips the writes
|
|
716
|
+
// after it in the step. A call Lamp held back as not fitting (`problem`) isn't run: the AI hears why, and a change of
|
|
717
|
+
// that kind fails in its place.
|
|
718
|
+
function stepRunner(step) {
|
|
719
|
+
var results = {}, chain = Promise.resolve(), reading = [], stopped = null;
|
|
720
|
+
function read(c) {
|
|
629
721
|
if (late()) return null;
|
|
630
722
|
var tool = tools[c.name], bad = actProblem(tool, c.name, c.args);
|
|
631
723
|
if (bad) { results[c.id] = { id: c.id, ok: false, error: bad }; return null; }
|
|
632
724
|
tell({ kind: 'read', name: c.name, args: c.args });
|
|
633
725
|
var NOT_RUN = {};
|
|
634
726
|
return Promise.resolve().then(function () {
|
|
635
|
-
//
|
|
727
|
+
// Each read starts only while there's time: an earlier one can be slow.
|
|
636
728
|
return late() ? NOT_RUN : tool.run(c.args, { turn: id, step: step, id: c.id });
|
|
637
729
|
}).then(function (value) {
|
|
638
730
|
if (value === NOT_RUN) return;
|
|
639
731
|
out.reads.push({ name: c.name, args: c.args });
|
|
640
|
-
|
|
732
|
+
return pictured(value).then(function (p) {
|
|
733
|
+
results[c.id] = { id: c.id, ok: true, value: sized(p.value) };
|
|
734
|
+
if (p.pictures) results[c.id].pictures = p.pictures;
|
|
735
|
+
});
|
|
641
736
|
}, function (e) { results[c.id] = { id: c.id, ok: false, error: refused(e) }; });
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
737
|
+
}
|
|
738
|
+
function write(c) {
|
|
739
|
+
var tool = tools[c.name];
|
|
740
|
+
function skip(reason) {
|
|
741
|
+
out.skipped.push({ name: c.name, args: c.args, reason: reason });
|
|
742
|
+
results[c.id] = { id: c.id, ok: false, skipped: reason };
|
|
743
|
+
}
|
|
744
|
+
function fail(reason) {
|
|
745
|
+
out.failed.push({ name: c.name, args: c.args, reason: reason });
|
|
746
|
+
results[c.id] = { id: c.id, ok: false, error: reason };
|
|
747
|
+
stopped = stopped || 'An earlier change in this step didn\'t go through.';
|
|
748
|
+
}
|
|
749
|
+
if (late()) return;
|
|
750
|
+
if (stopped) return skip(stopped);
|
|
751
|
+
// A change Lamp held back as not fitting failed, in its place.
|
|
752
|
+
if (typeof c.problem === 'string') return fail(c.problem);
|
|
753
|
+
var bad = actProblem(tool, c.name, c.args);
|
|
754
|
+
if (bad) return fail(bad);
|
|
755
|
+
var key = 'k' + hash(id + '|' + c.name + '|' + canonical(c.args));
|
|
756
|
+
// The same change asked again in this turn gets its first result, and doesn't run again.
|
|
757
|
+
// Its result is in an earlier step, which every request carries: pointed to, not sent again.
|
|
758
|
+
if (ran.hasOwnProperty(key)) { results[c.id] = { id: c.id, ok: true, repeat: true, value: { note: 'Already done in this request: its result is in an earlier step.' } }; return; }
|
|
759
|
+
if (writes >= limits.writes) return fail('Over this turn\'s limit of ' + limits.writes + ' changes.');
|
|
760
|
+
var call = { turn: id, key: key, step: step, id: c.id };
|
|
761
|
+
return Promise.resolve().then(function () { return tool.check ? tool.check(c.args, call) : null; }).then(function (problem) {
|
|
762
|
+
if (problem) return fail(String(problem));
|
|
763
|
+
// Cancel and the time are looked at again right before the change, never in the middle of it.
|
|
764
|
+
if (late()) return;
|
|
765
|
+
tell({ kind: 'write', name: c.name, args: c.args });
|
|
766
|
+
if (late()) return;
|
|
767
|
+
writes++;
|
|
768
|
+
var running;
|
|
769
|
+
try { running = Promise.resolve(tool.run(c.args, call)); } catch (e) { running = Promise.reject(e); }
|
|
770
|
+
return running.then(function (value) {
|
|
771
|
+
// The change is done: it's recorded now, whatever happens to its pictures after.
|
|
772
|
+
ran[key] = { note: 'Done.' };
|
|
773
|
+
var entry = { name: c.name, args: c.args, result: value };
|
|
774
|
+
if (value && typeof value === 'object' && value.partly) entry.partly = String(value.partly);
|
|
775
|
+
out.done.push(entry);
|
|
776
|
+
tell({ kind: 'write', name: c.name, args: c.args, result: value });
|
|
777
|
+
return pictured(value).then(function (p) {
|
|
778
|
+
var kept = sized(p.value);
|
|
679
779
|
ran[key] = kept;
|
|
680
|
-
var entry = { name: c.name, args: c.args, result: value };
|
|
681
|
-
if (value && typeof value === 'object' && value.partly) entry.partly = String(value.partly);
|
|
682
|
-
out.done.push(entry);
|
|
683
780
|
results[c.id] = { id: c.id, ok: true, value: kept };
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
781
|
+
if (p.pictures) results[c.id].pictures = p.pictures;
|
|
782
|
+
});
|
|
783
|
+
}, function (e) { fail(refused(e)); });
|
|
784
|
+
}, function (e) { fail(refused(e)); });
|
|
785
|
+
}
|
|
786
|
+
return {
|
|
787
|
+
add: function (c) {
|
|
788
|
+
if (typeof c.problem === 'string' && tools[c.name] && tools[c.name].reads) { results[c.id] = { id: c.id, ok: false, error: c.problem }; return; }
|
|
789
|
+
if (tools[c.name] && tools[c.name].reads) {
|
|
790
|
+
var going = chain.then(function () { return read(c); });
|
|
791
|
+
reading.push(going);
|
|
792
|
+
} else {
|
|
793
|
+
var before = [chain].concat(reading);
|
|
794
|
+
reading = [];
|
|
795
|
+
chain = Promise.all(before).then(function () { return write(c); });
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
finish: function () { return Promise.all([chain].concat(reading)).then(function () { return results; }); }
|
|
799
|
+
};
|
|
691
800
|
}
|
|
692
801
|
|
|
693
802
|
function step() {
|
|
@@ -696,23 +805,35 @@
|
|
|
696
805
|
if (left <= 500) return stop('time');
|
|
697
806
|
// The step's own time: what's left of an app's turn, or the step's guard against a call that has stopped.
|
|
698
807
|
var stepTime = Math.min(left, ACT_STEP_SECONDS * 1000);
|
|
699
|
-
var body = { request: String(request), about: o.about || '', now: present(), tools: declared, steps:
|
|
808
|
+
var body = { request: String(request), about: o.about || '', now: present(), tools: declared, steps: sentSteps(),
|
|
700
809
|
history: (o.history || []).slice(-3), seconds: Math.floor(stepTime / 1000) };
|
|
701
810
|
if (o.context !== undefined) body.context = jsonState(o.context);
|
|
702
811
|
if (route) body.route = route;
|
|
703
812
|
if (steps.length >= limits.steps - 1) body.final = true;
|
|
704
813
|
if (o.model) body.model = o.model;
|
|
705
|
-
|
|
814
|
+
// Streamed: each call comes as soon as the AI has written it, and runs then; the page follows the step for the
|
|
815
|
+
// rest. A Lamp from before streaming answers the whole step at once, which runs the same way.
|
|
816
|
+
body.stream = true;
|
|
817
|
+
var n = steps.length + 1, calls = [], ids = {}, runner = stepRunner(n), until = Date.now() + stepTime;
|
|
818
|
+
function heard(answer) {
|
|
819
|
+
// What the step cost through a key, paid for whether or not its answer still runs.
|
|
820
|
+
if (typeof answer.cost === 'number' && answer.cost > 0) out.cost += answer.cost;
|
|
706
821
|
// Cancelled, out of time or already over: an answer that comes late runs nothing.
|
|
707
822
|
if (late()) return;
|
|
708
823
|
route = answer.route || route;
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
824
|
+
var given = answer.calls || [];
|
|
825
|
+
for (var i = 0; i < given.length; i++) {
|
|
826
|
+
if (ids[given[i].id]) return stop('error', 'The AI gave two calls the same id.');
|
|
827
|
+
ids[given[i].id] = true;
|
|
828
|
+
calls.push(given[i]);
|
|
829
|
+
runner.add(given[i]);
|
|
830
|
+
}
|
|
831
|
+
if (answer.more === true && typeof answer.stream === 'string') {
|
|
832
|
+
var have = calls.length;
|
|
833
|
+
return post('/ai/act', { follow: answer.stream, have: have }, Math.max(1000, until - Date.now()), until).then(heard, failed);
|
|
834
|
+
}
|
|
835
|
+
if (calls.length) {
|
|
836
|
+
return runner.finish().then(function (results) {
|
|
716
837
|
if (late()) return;
|
|
717
838
|
steps.push({ calls: calls, results: calls.map(function (c) { return results[c.id] || { id: c.id, ok: false, skipped: 'Not run.' }; }) });
|
|
718
839
|
if (steps.length >= limits.steps) return stop('steps');
|
|
@@ -727,10 +848,13 @@
|
|
|
727
848
|
return typeof c === 'string' && c.trim() && c.length <= 24 && !/[<>*_`#\[\]{}|\\\n\r\t]/.test(c);
|
|
728
849
|
}) ? offered.map(function (c) { return c.trim(); }) : [];
|
|
729
850
|
stop(null);
|
|
730
|
-
}
|
|
851
|
+
}
|
|
852
|
+
function failed(e) {
|
|
853
|
+
if (e && typeof e.cost === 'number' && e.cost > 0) out.cost += e.cost;
|
|
731
854
|
var message = refused(e);
|
|
732
855
|
stop(/reach Lamp|Lamp isn't|wasn't put here|put it on this device/.test(message) ? 'unreachable' : 'error', message);
|
|
733
|
-
}
|
|
856
|
+
}
|
|
857
|
+
post('/ai/act', body, stepTime, until).then(heard, failed);
|
|
734
858
|
}
|
|
735
859
|
step();
|
|
736
860
|
return { id: id, result: result, cancel: function () { stop('cancelled'); } };
|
|
@@ -928,8 +1052,12 @@
|
|
|
928
1052
|
|
|
929
1053
|
/** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
|
|
930
1054
|
* system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
|
|
931
|
-
*
|
|
932
|
-
*
|
|
1055
|
+
* schema (a JSON Schema the answer must fit, which holds every AI to it, for large JSON: implies json; each object
|
|
1056
|
+
* lists every property in `required` and has `additionalProperties: false`), model (an id `LampAI.models()` lists,
|
|
1057
|
+
* such as "engine:claude:opus" or "openrouter:sonnet"; a bare "opus" is Opus through whatever answers by
|
|
1058
|
+
* default), signal (an AbortSignal: aborting it fails the ask at once with `error.stopped`), details (true
|
|
1059
|
+
* resolves { answer, seconds, cost }: `cost` in US dollars, 0 when the person's own Claude Code or Codex
|
|
1060
|
+
* answered). */
|
|
933
1061
|
ask: function (prompt, options) { return call(prompt, options); },
|
|
934
1062
|
|
|
935
1063
|
/**
|
|
@@ -957,24 +1085,34 @@
|
|
|
957
1085
|
* model: picker.value, // optional: an id from LampAI.models()
|
|
958
1086
|
* onStep: function (s) { … } // { kind, name, args[, result] }
|
|
959
1087
|
* });
|
|
960
|
-
* turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; });
|
|
1088
|
+
* turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; r.seconds; r.cost; });
|
|
961
1089
|
* turn.cancel();
|
|
962
1090
|
*
|
|
963
1091
|
* An argument's shape is { type: 'string' | 'integer' | 'number' | 'boolean' | 'date' | 'datetime' | 'list',
|
|
964
|
-
* oneOf: [strings], min, max, optional, about, of (a list's item) }. A tool with `reads: true` changes nothing
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
* calls,
|
|
1092
|
+
* oneOf: [strings], min, max, optional, about, of (a list's item) }. A tool with `reads: true` changes nothing.
|
|
1093
|
+
* Each call runs as soon as the AI has written it, before the rest of its answer has come, so a build shows as
|
|
1094
|
+
* it's made; calls run in the order the AI wrote them. Reads side by side run together; a write waits for the
|
|
1095
|
+
* calls before it, with its `check` right before it, and a read after it sees it. A write that fails stops the
|
|
1096
|
+
* writes after it in that step (`skipped`). Cancel and the time stop the turn between calls, never in the middle
|
|
1097
|
+
* of a write; an answer that comes after runs nothing.
|
|
968
1098
|
*
|
|
969
1099
|
* Say what happened from `done`, `failed` and `skipped`, never from `reply`: they're what the page ran. A question
|
|
970
1100
|
* back (`ask`) may come with `choices`, two to four short answers to offer as buttons; with none, any answer goes.
|
|
971
1101
|
* `call.key` is the same for the same write in the same turn: make the ids of what a write creates from it, or
|
|
972
1102
|
* keep it on the change, so a write asked again (or a turn tried again with `turn: r.turn`) finds it instead of
|
|
973
1103
|
* adding a second. The same write in one turn while the page is open isn't run twice; nothing promises more.
|
|
974
|
-
*
|
|
1104
|
+
* A tool's result can show the AI what the app drew, in the middle of the turn: put `LampAI.picture(canvas)` in it
|
|
1105
|
+
* (`{ rendered: [LampAI.picture(front), LampAI.picture(side)] }`), and the AI sees each picture in its place, so it
|
|
1106
|
+
* can build, look and fix in one turn. The newest few are shown, smaller than drawn; older ones the AI has seen.
|
|
1107
|
+
* Throw `LampAI.refuse('sentence')` from `check` or `run` to say why something can't be done. `seconds` is how long
|
|
1108
|
+
* the turn took and `cost` what it cost in US dollars (0 when the person's own Claude Code or Codex answered).
|
|
975
1109
|
*/
|
|
976
1110
|
act: function (request, options) { return act(request, options); },
|
|
977
1111
|
|
|
1112
|
+
/** A picture for the AI to look at, in what a tool of `act` returns: a <canvas>, an <img>, or a picture's address
|
|
1113
|
+
* (a data: URL or a file of the app's). */
|
|
1114
|
+
picture: function (source) { return new ActPicture(source); },
|
|
1115
|
+
|
|
978
1116
|
/**
|
|
979
1117
|
* What the person said, as text: `audio` is a WAV in base64 as `LampVoice.listen` gives it (16 kHz mono 16-bit,
|
|
980
1118
|
* at most 15 seconds), transcribed by Lamp on the Mac with the AI set up there (OpenRouter's Scribe), which
|
package/cli/openrouter.mjs
CHANGED
|
@@ -81,6 +81,84 @@ export async function chat({ key, body, seconds = 120, signal, fetcher = fetch }
|
|
|
81
81
|
throw new OpenRouterTrouble("refused", "The AI didn't answer.", { cost });
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* One chat completion streamed (`stream` on `LampAI.act`): `onCall(name, argumentsText)` hears each tool call, in order,
|
|
86
|
+
* as soon as its arguments are whole (they read as a JSON object, which can't go on after its closing brace), or when the
|
|
87
|
+
* answer ends. Providers may send several calls' pieces interleaved, so a later call starting says nothing. Resolves as `chat` does,
|
|
88
|
+
* `{message, cost}`, the whole message. Asked once: a provider's filter cutting it off is `filtered`, since calls may
|
|
89
|
+
* already have gone to the page.
|
|
90
|
+
*/
|
|
91
|
+
export async function chatStream({ key, body, seconds = 120, signal, fetcher = fetch, onCall = () => {} }) {
|
|
92
|
+
const timeout = AbortSignal.timeout(Math.max(1000, Math.floor(seconds * 1000)));
|
|
93
|
+
let answer;
|
|
94
|
+
try {
|
|
95
|
+
answer = await fetcher(ENDPOINT, { method: "POST", body: JSON.stringify({ ...body, stream: true }),
|
|
96
|
+
headers: { Authorization: `Bearer ${key || ""}`, "Content-Type": "application/json", "X-Title": "Lamp" },
|
|
97
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (signal?.aborted) throw new OpenRouterTrouble("stopped", "Stopped.");
|
|
100
|
+
if (error?.name === "TimeoutError") throw new OpenRouterTrouble("timeout", "This ran out of time.");
|
|
101
|
+
throw new OpenRouterTrouble("refused", "Couldn't reach OpenRouter.");
|
|
102
|
+
}
|
|
103
|
+
if (answer.status !== 200 || !answer.body) {
|
|
104
|
+
let reply = {};
|
|
105
|
+
try { reply = await answer.json(); } catch {}
|
|
106
|
+
throw new OpenRouterTrouble("refused", "OpenRouter didn't answer.", { status: answer.status, reply });
|
|
107
|
+
}
|
|
108
|
+
const calls = [], told = new Set();
|
|
109
|
+
let content = "", cost = 0, finish = null, rest = "";
|
|
110
|
+
const whole = (call) => { try { const args = JSON.parse(call.function.arguments); return !!call.function.name && !!args && typeof args === "object" && !Array.isArray(args); } catch { return false; } };
|
|
111
|
+
// In order: a call waits for every one before it, so they run as they were written.
|
|
112
|
+
const tell = (all = false) => {
|
|
113
|
+
for (let i = told.size; i < calls.length; i++) {
|
|
114
|
+
if (!calls[i] || (!all && !whole(calls[i]))) return;
|
|
115
|
+
told.add(i);
|
|
116
|
+
onCall(calls[i].function.name, calls[i].function.arguments);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const take = (line) => {
|
|
120
|
+
if (!line.startsWith("data:")) return;
|
|
121
|
+
const data = line.slice(5).trim();
|
|
122
|
+
if (!data || data === "[DONE]") return;
|
|
123
|
+
let chunk;
|
|
124
|
+
try { chunk = JSON.parse(data); } catch { return; }
|
|
125
|
+
if (chunk.usage) cost += Number(chunk.usage.cost) || 0;
|
|
126
|
+
if (chunk.error) throw new OpenRouterTrouble("refused", "OpenRouter didn't answer.", { status: Number(chunk.error.code) || 0, reply: { error: chunk.error }, cost });
|
|
127
|
+
const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : null;
|
|
128
|
+
if (!choice) return;
|
|
129
|
+
if (choice.finish_reason) finish = choice.finish_reason;
|
|
130
|
+
const delta = choice.delta ?? {};
|
|
131
|
+
if (typeof delta.content === "string") content += delta.content;
|
|
132
|
+
for (const part of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) {
|
|
133
|
+
const i = Number.isInteger(part.index) ? part.index : calls.length;
|
|
134
|
+
if (!calls[i]) calls[i] = { id: part.id || `call_${i}`, type: "function", function: { name: "", arguments: "" } };
|
|
135
|
+
if (part.id) calls[i].id = part.id;
|
|
136
|
+
if (part.function?.name) calls[i].function.name += part.function.name;
|
|
137
|
+
if (part.function?.arguments) calls[i].function.arguments += part.function.arguments;
|
|
138
|
+
}
|
|
139
|
+
tell();
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
const decoder = new TextDecoder();
|
|
143
|
+
for await (const bytes of answer.body) {
|
|
144
|
+
rest += decoder.decode(bytes, { stream: true });
|
|
145
|
+
const lines = rest.split("\n");
|
|
146
|
+
rest = lines.pop();
|
|
147
|
+
for (const line of lines) take(line.trim());
|
|
148
|
+
}
|
|
149
|
+
if (rest.trim()) take(rest.trim());
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error instanceof OpenRouterTrouble) throw error;
|
|
152
|
+
if (signal?.aborted) throw new OpenRouterTrouble("stopped", "Stopped.", { cost });
|
|
153
|
+
if (error?.name === "TimeoutError" || timeout.aborted) throw new OpenRouterTrouble("timeout", "This ran out of time.", { cost });
|
|
154
|
+
throw new OpenRouterTrouble("refused", "OpenRouter stopped answering.", { cost });
|
|
155
|
+
}
|
|
156
|
+
if (finish === "content_filter") throw new OpenRouterTrouble("filtered", FILTERED, { cost });
|
|
157
|
+
tell(true);
|
|
158
|
+
const message = { content, ...(calls.length ? { tool_calls: calls.filter(Boolean) } : {}) };
|
|
159
|
+
return { message, cost };
|
|
160
|
+
}
|
|
161
|
+
|
|
84
162
|
/** A text answer: `{text, cost}`. */
|
|
85
163
|
export async function text(given) {
|
|
86
164
|
const { message, cost } = await chat({ ...given, body: textBody(given) });
|
package/cli/page.js
CHANGED
|
@@ -25,16 +25,19 @@
|
|
|
25
25
|
// (`/__lamp/ai`, with the person's own Claude Code or Codex), through the same shape of bridge lamp.js takes from
|
|
26
26
|
// Lamp's Android app: postMessage of a request, an onmessage with its answer.
|
|
27
27
|
if (!window.lampGatewayHost && !(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lampGateway)) {
|
|
28
|
-
const gateway = { onmessage: null };
|
|
28
|
+
const gateway = { onmessage: null }, asking = new Map();
|
|
29
29
|
gateway.postMessage = (text) => {
|
|
30
30
|
let asked;
|
|
31
31
|
try { asked = JSON.parse(text); } catch (e) { return; }
|
|
32
|
+
// The page stopped waiting (lamp.js's `cancel`): the request ends, which stops lamp's answer.
|
|
33
|
+
if (asked.cancel) { const going = asking.get(asked.id); if (going) { going.cancelled = true; going.abort(); } return; }
|
|
32
34
|
const answer = (status, body) => { if (typeof gateway.onmessage === 'function') gateway.onmessage({ data: JSON.stringify({ id: asked.id, status, body }) }); };
|
|
33
35
|
const stop = new AbortController();
|
|
36
|
+
asking.set(asked.id, stop);
|
|
34
37
|
const timer = later(() => stop.abort(), Math.max(1000, Number(asked.timeout) || 240000));
|
|
35
38
|
ask('/__lamp/ai', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Lamp-Page': '1' }, body: JSON.stringify({ path: asked.path, body: asked.body }), signal: stop.signal })
|
|
36
|
-
.then((r) => r.json()).then((r) => answer(r.status, r.body), () => answer(502, JSON.stringify({ error: 'lamp dev took too long to answer.' })))
|
|
37
|
-
.finally(() => unlater(timer));
|
|
39
|
+
.then((r) => r.json()).then((r) => answer(r.status, r.body), () => { if (!stop.cancelled) answer(502, JSON.stringify({ error: 'lamp dev took too long to answer.' })); })
|
|
40
|
+
.finally(() => { unlater(timer); asking.delete(asked.id); });
|
|
38
41
|
};
|
|
39
42
|
try { Object.defineProperty(window, 'lampGatewayHost', { value: gateway, configurable: true }); } catch (e) {}
|
|
40
43
|
}
|