@lampdotdev/lamp 0.36.2-nightly.28 → 0.36.2-nightly.30

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/cli/ai.mjs CHANGED
@@ -212,6 +212,14 @@ export function models() {
212
212
  return { models: listedHere, default: defaultId(which) };
213
213
  }
214
214
 
215
+ /**
216
+ * How long a page's request may take: a real answer is never cut off however long the model takes, and the guard only
217
+ * ends a call that has stopped answering (`MAX_TURN_SECONDS` for a step of `act`, the same here). A page that wants a
218
+ * quicker answer asks for one (`seconds`), never a longer one.
219
+ */
220
+ const GUARD_SECONDS = 600;
221
+ const secondsFor = (body) => (typeof body?.seconds === "number" && body.seconds > 0 ? Math.min(body.seconds, GUARD_SECONDS) : GUARD_SECONDS);
222
+
215
223
  /** `/ai`: a text answer, or JSON when asked for it. */
216
224
  export async function ask(body) {
217
225
  const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : "";
@@ -219,7 +227,7 @@ export async function ask(body) {
219
227
  let system = typeof body.system === "string" ? body.system : "";
220
228
  if (body.json) system += (system ? "\n\n" : "") + "Answer with JSON only, nothing around it.";
221
229
  const which = engine();
222
- try { return [200, { text: await complete({ system, prompt, images: body.images, which, model: modelFor(which, body.model) }) }]; }
230
+ try { return [200, { text: await complete({ system, prompt, images: body.images, which, model: modelFor(which, body.model), seconds: secondsFor(body) }) }]; }
223
231
  catch (error) { return [502, { error: error.message }]; }
224
232
  }
225
233
 
@@ -233,7 +241,7 @@ export async function decide(body) {
233
241
  }
234
242
  const { system, prompt, schema } = decidePrompt(body.state, questions);
235
243
  let text;
236
- try { text = await complete({ system, prompt, schema }); } catch (error) { return [502, { error: error.message }]; }
244
+ try { text = await complete({ system, prompt, schema, seconds: secondsFor(body) }); } catch (error) { return [502, { error: error.message }]; }
237
245
  const read = readDecision(text, questions);
238
246
  return read.answers ? [200, { answers: read.answers }] : [502, { error: read.problem }];
239
247
  }
package/cli/calls.mjs CHANGED
@@ -373,7 +373,7 @@ export const CALLS = {
373
373
  */
374
374
  async openRouterText(given, { stopped } = {}) {
375
375
  return openRouter(() => openRouterText({ key: text(given, "key"), prompt: text(given, "prompt"), images: given.images, system: given.system ?? "",
376
- model: text(given, "model"), json: given.json === true, schema: given.schema ?? undefined, seconds: seconds(given.seconds, 120, 600), signal: stopped }));
376
+ model: text(given, "model"), json: given.json === true, schema: given.schema ?? undefined, seconds: seconds(given.seconds, 600, 600), signal: stopped }));
377
377
  },
378
378
 
379
379
  /** One chat completion from OpenRouter, its request as `body` (`actAsk`'s): `{message, cost}`, or `{trouble}`. */
@@ -384,7 +384,7 @@ export const CALLS = {
384
384
  /** Decisions from OpenRouter's decision model (`AppAI.decide`): `key`, `model`, `state`, `questions`. `{answers, cost}` or `{trouble}`. */
385
385
  async openRouterDecide(given, { stopped } = {}) {
386
386
  return openRouter(() => openRouterDecide({ key: text(given, "key"), model: text(given, "model"), state: given.state, questions: given.questions ?? {},
387
- seconds: seconds(given.seconds, 60, 120), signal: stopped }));
387
+ seconds: seconds(given.seconds, 600, 600), signal: stopped }));
388
388
  },
389
389
 
390
390
  /** A picture from OpenRouter (`AppAI.image`): `key`, `model`, `prompt`, `references`, `aspectRatio`. `{image, cost}` or `{trouble}`. */
@@ -403,7 +403,7 @@ export const CALLS = {
403
403
  return { text: await complete({ which: text(given, "engine"), tool: typeof given.tool === "string" && given.tool ? given.tool : undefined,
404
404
  env: given.env && typeof given.env === "object" ? given.env : {}, system: typeof given.system === "string" ? given.system : "",
405
405
  prompt: text(given, "prompt"), schema: given.schema ?? undefined, model: typeof given.model === "string" ? given.model : "",
406
- images: Array.isArray(given.images) ? given.images : [], seconds: seconds(given.seconds, 300, 600), signal: stopped }) };
406
+ images: Array.isArray(given.images) ? given.images : [], seconds: seconds(given.seconds, 600, 600), signal: stopped }) };
407
407
  } catch (error) {
408
408
  if (error instanceof EngineTrouble) return { trouble: { kind: error.kind, message: error.message } };
409
409
  throw error;
@@ -301,6 +301,10 @@
301
301
  });
302
302
  }
303
303
 
304
+ // How long the page waits for an answer: as long as Lamp's guard on the call (10 minutes), or the page's own limit,
305
+ // with a little more for the answer to come back.
306
+ function AI_WAIT(seconds) { return (typeof seconds === 'number' && seconds > 0 ? Math.min(seconds, 600) : 600) * 1000 + 15000; }
307
+
304
308
  function post(path, body, timeout, deadline) {
305
309
  timeout = timeout || 240000;
306
310
  var done;
@@ -485,7 +489,9 @@
485
489
  return encode(o.images).then(function (images) {
486
490
  var body = { prompt: text, system: rules.filter(function (r) { return r; }).join('\n\n'), json: !!o.json, images: images };
487
491
  if (o.model) body.model = o.model;
488
- return post('/ai', body);
492
+ // No limit of the page's own unless it asks for one: Lamp's guard ends only a call that stopped answering.
493
+ if (typeof o.seconds === 'number' && o.seconds > 0) body.seconds = o.seconds;
494
+ return post('/ai', body, AI_WAIT(body.seconds));
489
495
  }).then(function (reply) {
490
496
  if (typeof reply.text !== 'string') throw new Failure('The AI request failed.');
491
497
  if (!o.json) return reply.text;
@@ -998,7 +1004,7 @@
998
1004
  },
999
1005
  /** Asks several questions about the same thing in one request. Answers come back under the same keys. */
1000
1006
  decide: function (about, questions) {
1001
- return post('/ai/decide', { state: decisionState(about), questions: questions }).then(function (reply) {
1007
+ return post('/ai/decide', { state: decisionState(about), questions: questions }, AI_WAIT()).then(function (reply) {
1002
1008
  var answers = {};
1003
1009
  for (var key in (reply.answers || {})) if (reply.answers.hasOwnProperty(key)) answers[key] = decision(reply.answers[key]);
1004
1010
  return answers;
@@ -1036,7 +1042,7 @@
1036
1042
  finish(i, answers);
1037
1043
  return Promise.resolve();
1038
1044
  }
1039
- return post('/ai/decide', { state: decisionState(state), questions: questions }).then(function (reply) {
1045
+ return post('/ai/decide', { state: decisionState(state), questions: questions }, AI_WAIT()).then(function (reply) {
1040
1046
  var answers = {};
1041
1047
  for (var k in (reply.answers || {})) if (reply.answers.hasOwnProperty(k)) answers[k] = decision(reply.answers[k]);
1042
1048
  if (key) saved[key] = { answers: reply.answers, until: now + o.remember * 1000 };
package/cli/lamps/lamp.js CHANGED
@@ -301,6 +301,10 @@
301
301
  });
302
302
  }
303
303
 
304
+ // How long the page waits for an answer: as long as Lamp's guard on the call (10 minutes), or the page's own limit,
305
+ // with a little more for the answer to come back.
306
+ function AI_WAIT(seconds) { return (typeof seconds === 'number' && seconds > 0 ? Math.min(seconds, 600) : 600) * 1000 + 15000; }
307
+
304
308
  function post(path, body, timeout, deadline) {
305
309
  timeout = timeout || 240000;
306
310
  var done;
@@ -485,7 +489,9 @@
485
489
  return encode(o.images).then(function (images) {
486
490
  var body = { prompt: text, system: rules.filter(function (r) { return r; }).join('\n\n'), json: !!o.json, images: images };
487
491
  if (o.model) body.model = o.model;
488
- return post('/ai', body);
492
+ // No limit of the page's own unless it asks for one: Lamp's guard ends only a call that stopped answering.
493
+ if (typeof o.seconds === 'number' && o.seconds > 0) body.seconds = o.seconds;
494
+ return post('/ai', body, AI_WAIT(body.seconds));
489
495
  }).then(function (reply) {
490
496
  if (typeof reply.text !== 'string') throw new Failure('The AI request failed.');
491
497
  if (!o.json) return reply.text;
@@ -998,7 +1004,7 @@
998
1004
  },
999
1005
  /** Asks several questions about the same thing in one request. Answers come back under the same keys. */
1000
1006
  decide: function (about, questions) {
1001
- return post('/ai/decide', { state: decisionState(about), questions: questions }).then(function (reply) {
1007
+ return post('/ai/decide', { state: decisionState(about), questions: questions }, AI_WAIT()).then(function (reply) {
1002
1008
  var answers = {};
1003
1009
  for (var key in (reply.answers || {})) if (reply.answers.hasOwnProperty(key)) answers[key] = decision(reply.answers[key]);
1004
1010
  return answers;
@@ -1036,7 +1042,7 @@
1036
1042
  finish(i, answers);
1037
1043
  return Promise.resolve();
1038
1044
  }
1039
- return post('/ai/decide', { state: decisionState(state), questions: questions }).then(function (reply) {
1045
+ return post('/ai/decide', { state: decisionState(state), questions: questions }, AI_WAIT()).then(function (reply) {
1040
1046
  var answers = {};
1041
1047
  for (var k in (reply.answers || {})) if (reply.answers.hasOwnProperty(k)) answers[k] = decision(reply.answers[k]);
1042
1048
  if (key) saved[key] = { answers: reply.answers, until: now + o.remember * 1000 };
@@ -1 +1 @@
1
- {"name":"Zqx App Name","instance":"FE98C883-9417-425A-8B4A-689C3EB7CC0A","exe":"ZqxAppName","platform":"mac"}
1
+ {"platform":"mac","name":"Zqx App Name","exe":"ZqxAppName","instance":"45F58BDE-3A30-4885-9300-2DCAC8AB84BA"}
@@ -11299,7 +11299,7 @@
11299
11299
  let reply = try await post("/ai/decide", [
11300
11300
  "state": try decisionState(state),
11301
11301
  "questions": questions.mapValues(\.json),
11302
- ])
11302
+ ], timeout: 615)
11303
11303
  let answers = reply["answers"] as? [String: [String: Any]] ?? [:]
11304
11304
  return answers.mapValues(LampDecision.init)
11305
11305
  }
@@ -11427,7 +11427,7 @@
11427
11427
  group.addTask {
11428
11428
  try Task.checkCancellation()
11429
11429
  do {
11430
- let reply = try await post("/ai/decide", body: request.body)
11430
+ let reply = try await post("/ai/decide", body: request.body, timeout: 615)
11431
11431
  guard let answers = reply["answers"] as? [String: Any], !answers.isEmpty else {
11432
11432
  return Outcome(index: request.index, error: "The decision model didn't answer.")
11433
11433
  }
@@ -11600,7 +11600,8 @@
11600
11600
  "images": images.compactMap(encode),
11601
11601
  ]
11602
11602
  if let model { body["model"] = model }
11603
- let reply = try await post("/ai", body)
11603
+ // As long as Lamp's guard on the call: a real answer is never cut off, however long the model takes.
11604
+ let reply = try await post("/ai", body, timeout: 615)
11604
11605
  guard let text = reply["text"] as? String else { throw Failure(message: "The AI request failed.") }
11605
11606
  return text
11606
11607
  }
@@ -301,6 +301,10 @@
301
301
  });
302
302
  }
303
303
 
304
+ // How long the page waits for an answer: as long as Lamp's guard on the call (10 minutes), or the page's own limit,
305
+ // with a little more for the answer to come back.
306
+ function AI_WAIT(seconds) { return (typeof seconds === 'number' && seconds > 0 ? Math.min(seconds, 600) : 600) * 1000 + 15000; }
307
+
304
308
  function post(path, body, timeout, deadline) {
305
309
  timeout = timeout || 240000;
306
310
  var done;
@@ -485,7 +489,9 @@
485
489
  return encode(o.images).then(function (images) {
486
490
  var body = { prompt: text, system: rules.filter(function (r) { return r; }).join('\n\n'), json: !!o.json, images: images };
487
491
  if (o.model) body.model = o.model;
488
- return post('/ai', body);
492
+ // No limit of the page's own unless it asks for one: Lamp's guard ends only a call that stopped answering.
493
+ if (typeof o.seconds === 'number' && o.seconds > 0) body.seconds = o.seconds;
494
+ return post('/ai', body, AI_WAIT(body.seconds));
489
495
  }).then(function (reply) {
490
496
  if (typeof reply.text !== 'string') throw new Failure('The AI request failed.');
491
497
  if (!o.json) return reply.text;
@@ -998,7 +1004,7 @@
998
1004
  },
999
1005
  /** Asks several questions about the same thing in one request. Answers come back under the same keys. */
1000
1006
  decide: function (about, questions) {
1001
- return post('/ai/decide', { state: decisionState(about), questions: questions }).then(function (reply) {
1007
+ return post('/ai/decide', { state: decisionState(about), questions: questions }, AI_WAIT()).then(function (reply) {
1002
1008
  var answers = {};
1003
1009
  for (var key in (reply.answers || {})) if (reply.answers.hasOwnProperty(key)) answers[key] = decision(reply.answers[key]);
1004
1010
  return answers;
@@ -1036,7 +1042,7 @@
1036
1042
  finish(i, answers);
1037
1043
  return Promise.resolve();
1038
1044
  }
1039
- return post('/ai/decide', { state: decisionState(state), questions: questions }).then(function (reply) {
1045
+ return post('/ai/decide', { state: decisionState(state), questions: questions }, AI_WAIT()).then(function (reply) {
1040
1046
  var answers = {};
1041
1047
  for (var k in (reply.answers || {})) if (reply.answers.hasOwnProperty(k)) answers[k] = decision(reply.answers[k]);
1042
1048
  if (key) saved[key] = { answers: reply.answers, until: now + o.remember * 1000 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lampdotdev/lamp",
3
- "version": "0.36.2-nightly.28",
3
+ "version": "0.36.2-nightly.30",
4
4
  "description": "Runs a web app or game from its folder and puts each saved change into the open page.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -299,6 +299,7 @@ export function cloudStore(env, fetcher = fetch) {
299
299
  const accountByKeyKey = (hash) => `lamp:shared-wish:{${scope}}:account-key:${hash}`;
300
300
  const accountWaitingKey = (token) => `lamp:shared-wish:{${scope}}:account-waiting:${token}`;
301
301
  const appKey = (name, app) => `lamp:shared-wish:{${scope}}:app:${name}:${app}`;
302
+ const copiesKey = (id) => `lamp:shared-wish:{${scope}}:copies:${id}`;
302
303
  const command = async (args) => {
303
304
  const response = await fetcher(db.url, {
304
305
  method: "POST", redirect: "error", signal: AbortSignal.timeout(3000),
@@ -330,7 +331,19 @@ export function cloudStore(env, fetcher = fetch) {
330
331
  return (await command(["EVAL", "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('SET', KEYS[1], ARGV[2]) end return false",
331
332
  "1", key(after.id), JSON.stringify(before), JSON.stringify(after)])) === "OK";
332
333
  },
333
- async remove(id) { await command(["DEL", key(id), ideasKey(id), makerKey(id)]); },
334
+ async remove(id) { await command(["DEL", key(id), ideasKey(id), makerKey(id), copiesKey(id)]); },
335
+ /** The links shared from copies of a link (each record's `from`), by id: a copy taken down since is let go when listed. */
336
+ /** Kept by when each was last shared, so the newest come first however many there are. */
337
+ async addCopy(id, copy, at = Date.now()) { await command(["ZADD", copiesKey(id), String(at), copy]); },
338
+ async copies(id, count = 200) { const ids = await command(["ZREVRANGE", copiesKey(id), "0", String(count - 1)]); return Array.isArray(ids) ? ids : []; },
339
+ async copyCount(id) { const n = await command(["ZCARD", copiesKey(id)]); return Number(n) || 0; },
340
+ async removeCopy(id, copy) { await command(["ZREM", copiesKey(id), copy]); },
341
+ /** Several records at once, in one round trip, null for one that's gone. */
342
+ async getMany(ids) {
343
+ if (!ids.length) return [];
344
+ const texts = await command(["MGET", ...ids.map(key)]);
345
+ return (Array.isArray(texts) ? texts : []).map((text) => (typeof text === "string" ? JSON.parse(text) : null));
346
+ },
334
347
  /** A maker's account by its username: `{ name, email, created }`, or null. */
335
348
  async account(name) { const text = await command(["GET", accountKey(name)]); return typeof text === "string" ? JSON.parse(text) : null; },
336
349
  async accountForEmail(email) { const name = await command(["GET", accountEmailKey(email)]); return typeof name === "string" ? name : null; },
@@ -583,7 +596,7 @@ function remember(url, bytes) {
583
596
 
584
597
  /** Kept in memory, for `npm run dev` and the tests: pictures as data: URLs, so nothing else needs serving. */
585
598
  export function memoryStore() {
586
- const records = new Map(), pages = new Map(), ideas = new Map(), mailboxes = new Map(), makers = new Map();
599
+ const records = new Map(), pages = new Map(), ideas = new Map(), mailboxes = new Map(), makers = new Map(), copies = new Map();
587
600
  const accounts = new Map(), emails = new Map(), keys = new Map(), waiting = new Map(), apps = new Map();
588
601
  return {
589
602
  limiter: null,
@@ -611,7 +624,12 @@ export function memoryStore() {
611
624
  records.set(after.id, structuredClone(after));
612
625
  return true;
613
626
  },
614
- async remove(id) { records.delete(id); ideas.delete(id); makers.delete(id); },
627
+ async remove(id) { records.delete(id); ideas.delete(id); makers.delete(id); copies.delete(id); },
628
+ async addCopy(id, copy, at = Date.now()) { if (!copies.has(id)) copies.set(id, new Map()); copies.get(id).set(copy, at); },
629
+ async copies(id, count = 200) { return [...(copies.get(id) ?? new Map())].sort((a, b) => b[1] - a[1]).slice(0, count).map(([copy]) => copy); },
630
+ async copyCount(id) { return copies.get(id)?.size ?? 0; },
631
+ async removeCopy(id, copy) { copies.get(id)?.delete(copy); },
632
+ async getMany(ids) { return ids.map((id) => (records.has(id) ? structuredClone(records.get(id)) : null)); },
615
633
  async maker(id) { return makers.has(id) ? structuredClone(makers.get(id)) : null; },
616
634
  async setMaker(id, value) { if (value) makers.set(id, structuredClone(value)); else makers.delete(id); },
617
635
  async makerStep(id, key, step) {
@@ -705,6 +723,42 @@ const bearer = (request) => /^Bearer (\S+)$/.exec(request.headers.get("authoriza
705
723
  * keeping the link; DELETE ?id= with the secret takes it down. Lamp is the only caller: a browser on another site
706
724
  * is refused, and nothing a request holds is logged.
707
725
  */
726
+ /**
727
+ * A copy of a link was shared (`from`): kept among the link's copies at every share, so one missing from the index (shared
728
+ * before it, or a write that failed) comes back, and moved up as the newest; its maker told by email at the address that
729
+ * confirmed their account (or the link's own maker address) only the first time (`first`). Never fails the share.
730
+ */
731
+ async function copied(kept, from, copy, origin, { env, fetcher, first }) {
732
+ try { await kept.addCopy(from, copy.id); } catch {}
733
+ if (!first) return;
734
+ try {
735
+ const source = await kept.get(from);
736
+ if (!source) return;
737
+ const account = source.owner ? await kept.account(source.owner) : null;
738
+ const maker = account ? null : await kept.maker(from);
739
+ const to = account?.email ?? (maker?.confirmed ? maker.email : null);
740
+ if (!to) return;
741
+ const who = copy.owner || copy.by || "Someone";
742
+ const theirs = linkOf(origin, copy), yours = linkOf(origin, source);
743
+ await sendMail(env, fetcher, { to: [to], subject: `${who} made their own ${source.name}`,
744
+ text: `${who} made their own copy of ${source.name} and shared it:\n\n${theirs}\n\nEvery copy of yours is listed on its page: ${yours}\n` }, `copy-${from}-${copy.id}`);
745
+ } catch {}
746
+ }
747
+
748
+ /** The copies of a link as its page lists them, newest first; one taken down since is let go. */
749
+ export async function copiesOf(kept, id, origin) {
750
+ const found = [];
751
+ // The newest 200, by when each was last shared, read in one round trip.
752
+ const ids = await kept.copies(id, 200);
753
+ const records = await kept.getMany(ids);
754
+ for (const [i, copy] of ids.entries()) {
755
+ const record = records[i];
756
+ if (!record || record.from !== id) { try { await kept.removeCopy(id, copy); } catch {} continue; }
757
+ found.push({ url: linkOf(origin, record), name: record.name, by: record.owner || record.by || "", updated: record.updated ?? record.created ?? null });
758
+ }
759
+ return found.sort((a, b) => String(b.updated).localeCompare(String(a.updated)));
760
+ }
761
+
708
762
  export function createShareHandler({ env = process.env, fetcher = fetch, store, limited = true, now = () => new Date() } = {}) {
709
763
  return async (request) => {
710
764
  const method = request.method;
@@ -721,6 +775,10 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
721
775
  if (method === "GET") {
722
776
  let kept;
723
777
  try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
778
+ // The copies people shared of this link, newest first: public, as every link is.
779
+ if (url.searchParams.get("copies") === "1" && isID(id)) {
780
+ try { return reply(200, { copies: await copiesOf(kept, id, url.origin) }); } catch { return reply(503, { error: "service_unavailable" }); }
781
+ }
724
782
  try {
725
783
  const found = byName ? await kept.appID(user, app) : id;
726
784
  const record = found ? await kept.get(found) : null;
@@ -786,6 +844,7 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
786
844
  if (method === "PUT") {
787
845
  const record = { ...earlier, ...words, ...named, updated: time };
788
846
  await kept.replace(record);
847
+ if (record.from) await copied(kept, record.from, record, url.origin, { env, fetcher, first: !!from && earlier?.from !== from });
789
848
  // Kept: the name is the record's now, whatever the cleanup after it does.
790
849
  uploaded = [];
791
850
  claimed = null;
@@ -800,6 +859,7 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
800
859
  }
801
860
  uploaded = [];
802
861
  claimed = null;
862
+ if (from) await copied(kept, from, record, url.origin, { env, fetcher, first: true });
803
863
  return reply(201, { id: wishID, url: linkOf(url.origin, record), secret });
804
864
  } catch {
805
865
  try { await kept.removeImages(uploaded); } catch {}
@@ -1305,6 +1365,8 @@ export async function findWish(id, { env = process.env, fetcher = fetch, store,
1305
1365
  if (source) wish.from = { url: linkOf(origin, source), label: source.owner && source.app ? `${source.owner}/${source.app}` : source.name };
1306
1366
  } catch {}
1307
1367
  }
1368
+ // How many copies of it people shared, for the page's "copies" (the list itself is asked for when opened).
1369
+ if (wish && origin) { try { wish.copies = await kept.copyCount(id); } catch {} }
1308
1370
  return wish;
1309
1371
  }
1310
1372