@lampdotdev/lamp 0.36.2-nightly.17 → 0.36.2-nightly.19

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 CHANGED
@@ -222,7 +222,10 @@ of the link's own.
222
222
  errors isn't shared without `--anyway`.
223
223
  - **The link's secret**, which is what lets it be changed or taken down, is kept with lamp's state for the folder
224
224
  (`link.json`, readable only by the person), never in the folder.
225
- - **Not sent:** the wish's own file, which only Lamp's app makes. The link plays the page and carries its words.
225
+ - **The app's file** goes too (`exportApp`, as `lamp export` makes it), as Lamp's Share Link card sends it, unless
226
+ `--no-file`. Its own place on its credits' chain names the link it went up with (`link`, `lamp.dev/w/<id>`).
227
+ - **Where it came from:** a copy of an app someone shared (`lamp play`, `lamp unpack`) has their link on its chain, so
228
+ its own share names that link (`from`, `sourceLinkOf`), and its page says "from" them.
226
229
  - **`LAMP_SITE`** names another site to share to, for trying this against a copy of the site (`npm run dev` in
227
230
  `site/` keeps links in memory). The tests run against the site's handlers the same way, and never reach lamp.dev.
228
231
 
@@ -86,7 +86,7 @@ else { status = result.summary }
86
86
  ```
87
87
 
88
88
  - Show the feature where `await LampAI.status(.act)` says it's available, with what answers and where its `processing`
89
- is (`remote` for a service, Claude Code and Codex included; `onDevice` for a model on this Mac). When it isn't, keep
89
+ is (`remote` for a service, Claude Code and Codex included). When it isn't, keep
90
90
  the feature visible with its `reason` and a "Set up in Lamp" button (`LampAI.openSetup(for: .act)`), or "Open Lamp" /
91
91
  "Update Lamp" when `setup` says so; keep the typed draft, and every manual control working. Check again when the app
92
92
  comes back to the front, and never send the draft by itself after setup. Available is no guarantee: show the actual
@@ -28,32 +28,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
28
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
29
  SOFTWARE.
30
30
 
31
- ## llama.cpp
32
-
33
- https://github.com/ggml-org/llama.cpp (llama-server and its libraries)
34
-
35
- MIT License
36
-
37
- Copyright (c) 2023-2026 The ggml authors
38
-
39
- Permission is hereby granted, free of charge, to any person obtaining a copy
40
- of this software and associated documentation files (the "Software"), to deal
41
- in the Software without restriction, including without limitation the rights
42
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
43
- copies of the Software, and to permit persons to whom the Software is
44
- furnished to do so, subject to the following conditions:
45
-
46
- The above copyright notice and this permission notice shall be included in all
47
- copies or substantial portions of the Software.
48
-
49
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
50
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
51
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
52
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
53
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
54
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
55
- SOFTWARE.
56
-
57
31
  ## Lobe Icons
58
32
 
59
33
  https://github.com/lobehub/lobe-icons (`@lobehub/icons-static-png`, the provider logos in Settings)
package/cli/app-ai.mjs CHANGED
@@ -11,8 +11,8 @@
11
11
  // page's.
12
12
  //
13
13
  // Every route is asked one of two ways and read into the same calls-or-reply: a chat with OpenAI-style tools
14
- // (OpenRouter, an MLX model whose server takes tools: `chatBody`, `readMessage`), or one prompt whose answer must fit
15
- // the step's JSON shape (Claude Code, Codex, a GGUF model, an older MLX model: `planPrompt`, `planSchema`, `readPlan`).
14
+ // (OpenRouter: `chatBody`, `readMessage`), or one prompt whose answer must fit the step's JSON shape (Claude Code, Codex:
15
+ // `planPrompt`, `planSchema`, `readPlan`).
16
16
 
17
17
  export const MAX_TOOLS = 24, MAX_CALLS = 8, MAX_STEPS = 4, MAX_BODY = 256 * 1024, MAX_CHOICES = 4, MAX_CHOICE = 24;
18
18
  /** The longest a step may take, whatever its route, before its call is taken to have stopped answering (`ACT_STEP_SECONDS`
@@ -320,8 +320,8 @@ function framing(r, tag) {
320
320
  }
321
321
 
322
322
  /**
323
- * An OpenAI-style chat with tools, Lamp's `reply` among them, for OpenRouter and an MLX model's server. With `model`,
324
- * it's OpenRouter's request: `require_parameters` keeps OpenRouter on providers that take tools and `tool_choice` as
323
+ * An OpenAI-style chat with tools, Lamp's `reply` among them, for OpenRouter, whose `model` it names:
324
+ * `require_parameters` keeps OpenRouter on providers that take tools and `tool_choice` as
325
325
  * given, and the models offered for apps' text take only "auto" and "none" there, so an answer may also be words without
326
326
  * a call, which is the reply; a step asked for a reply only says "none".
327
327
  */
@@ -369,15 +369,13 @@ export function planSchema(r) {
369
369
 
370
370
  /**
371
371
  * The step as one prompt, for a route that takes text and answers in `planSchema`'s shape: the tools described, then the
372
- * turn so far, every earlier call and its result fenced as outside text. `schemaInPrompt` says the shape in words too,
373
- * for a model nothing holds to it.
372
+ * turn so far, every earlier call and its result fenced as outside text.
374
373
  */
375
- export function planPrompt(r, tag, retry, schemaInPrompt = false) {
374
+ export function planPrompt(r, tag, retry) {
376
375
  let { system, user: prompt } = framing(r, tag);
377
376
  system += "\n\n" + PLAN;
378
377
  if (r.final) system += '\nOnly a reply is possible now: answer {"reply": {...}}.';
379
378
  else system += "\n\nThe app's tools:\n" + r.tools.map((tool) => `- ${tool.name}: ${tool.reads ? "Looks up, changes nothing. " : "Changes the app's data. "}${tool.about}\n Arguments: ${json(toolSchema(tool))}`).join("\n");
380
- if (schemaInPrompt) system += "\n\nThe JSON Schema your answer must fit:\n" + json(planSchema(r));
381
379
  r.steps.forEach((step, i) => {
382
380
  prompt += `\n\nYour answer ${i + 1} called:\n` + json(step.calls.map((c) => ({ name: c.name, args: c.args })));
383
381
  prompt += "\nWhat happened, in that order:\n" + fence(tag, json(step.calls.map((c) => ({ ...(step.results[c.id] ?? skipped), name: c.name }))));
package/cli/calls.mjs CHANGED
@@ -423,12 +423,12 @@ export const CALLS = {
423
423
 
424
424
  /**
425
425
  * What a step's route is asked: `way` "chat" is a chat with tools (`{body}`, OpenRouter's own when `model` names one),
426
- * "plan" one prompt held to the step's shape (`{system, prompt, schema}`), and "words" one with the shape said in it.
426
+ * and "plan" one prompt held to the step's shape (`{system, prompt, schema}`).
427
427
  */
428
428
  async actAsk(given) {
429
429
  const step = given.step, tag = text(given, "tag"), retry = typeof given.retry === "string" ? given.retry : null;
430
430
  if (given.way === "chat") return { body: chatBody(step, tag, retry, typeof given.model === "string" ? given.model : undefined) };
431
- return { ...planPrompt(step, tag, retry, given.way === "words"), schema: planSchema(step) };
431
+ return { ...planPrompt(step, tag, retry), schema: planSchema(step) };
432
432
  },
433
433
 
434
434
  /** A step's answer read and checked: `message` from a chat with tools, or `plan`'s text. `{answer}` or `{problem}`. */
package/cli/lampapp.mjs CHANGED
@@ -8,6 +8,7 @@ import { basename, dirname, join, resolve, sep } from "node:path";
8
8
  import { inflateRawSync } from "node:zlib";
9
9
  import { Problem, RESOURCES, manifestOf, projectOf, stateOf } from "./state.mjs";
10
10
  import { LIMITS, nameOf, zipOf } from "./share.mjs";
11
+ import { isID } from "../site/lib/shared-wishes.js";
11
12
 
12
13
  // What a Lamp app file may hold, written once in Resources/share-rules.json, which Lamp's app reads too (`ShareRules`):
13
14
  // the sizes another Lamp opens, the names a license goes by, and the shapes of keys a shared file never carries.
@@ -214,9 +215,10 @@ function identityOf(folder) {
214
215
  * and lamp-app.json, which says what it is. Never Lamp's own files, which the Mac that opens it writes, nor anything
215
216
  * hidden. Refused when a license its credits name is gone, when something in it is shaped like a key, or when it's
216
217
  * more than another Lamp opens. Work its credits give no license for goes only with `anyway`: its author may not want
217
- * it passed on.
218
+ * it passed on. `link` is the lamp.dev link the file goes up with (`share`), which this app's place in the chain carries,
219
+ * so a copy of it shared on can say which link it came from (`sourceLinkOf`).
218
220
  */
219
- export function exportApp(given, { anyway = false } = {}) {
221
+ export function exportApp(given, { anyway = false, link = null } = {}) {
220
222
  const project = projectOf(given), folder = project.folder, manifest = manifestOf(folder);
221
223
  // Where the app's own record is (its credits, licenses, notes, files and the Swift beside its page): the folder, or
222
224
  // for a site laid out as Lamp puts one on GitHub, `.lamp-wish/` beside the page (`StaticSite.layOut`).
@@ -291,7 +293,7 @@ export function exportApp(given, { anyway = false } = {}) {
291
293
 
292
294
  // Whoever opens the file is remixing this app: it's added to the chain its credits carry, as one link if it's
293
295
  // already the last (`Credits.remixed`).
294
- const me = { name, wish: identityOf(folder) }, chain = Array.isArray(credits?.remixOf) ? credits.remixOf.filter((link) => link && typeof link.name === "string") : [];
296
+ const me = { name, wish: identityOf(folder), ...(link ? { link } : {}) }, chain = Array.isArray(credits?.remixOf) ? credits.remixOf.filter((link) => link && typeof link.name === "string") : [];
295
297
  const last = chain.at(-1);
296
298
  if (last && (last.wish ? last.wish === me.wish : !last.starter && last.name === me.name)) chain[chain.length - 1] = { ...last, ...me, url: last.url, commit: last.commit };
297
299
  else chain.push(me);
@@ -324,6 +326,28 @@ export function exportApp(given, { anyway = false } = {}) {
324
326
  return { name, kind, bytes, files: files.size };
325
327
  }
326
328
 
329
+ /**
330
+ * The id of the link on `site` that the app in the folder came from, for a share to name (`from`): the last app in its
331
+ * credits' chain that went up with a link there (`exportApp`'s `link`), other than `own`, the folder's own link. Null for
332
+ * an app that came from no link. An unpacked copy keeps the app's identity, so its first share replaces that entry's
333
+ * link in the file it sends; the folder's own credits still name the one it came from.
334
+ */
335
+ export function sourceLinkOf(folder, site, own = null) {
336
+ const app = existsSync(join(folder, ".lamp-wish", "lamp-app.json")) ? join(folder, ".lamp-wish") : folder;
337
+ let chain;
338
+ try { chain = JSON.parse(readFileSync(join(app, "credits.json"), "utf8"))?.remixOf; } catch { return null; }
339
+ if (!Array.isArray(chain)) return null;
340
+ let origin;
341
+ try { origin = new URL(site).origin; } catch { return null; }
342
+ for (const entry of [...chain].reverse()) {
343
+ let url;
344
+ try { url = new URL(entry?.link); } catch { continue; }
345
+ const id = /^\/w\/([^/]+)$/.exec(url.pathname)?.[1];
346
+ if (url.origin === origin && isID(id) && id !== own) return id;
347
+ }
348
+ return null;
349
+ }
350
+
327
351
  /** An object with its keys in order, at every level, as Lamp's own encoder writes its files. */
328
352
  function sortedKeys(value) {
329
353
  if (Array.isArray(value)) return value.map(sortedKeys);
@@ -1 +1 @@
1
- {"exe":"ZqxAppName","platform":"mac","name":"Zqx App Name","instance":"1DF88C4D-5D7E-40DA-8372-9DF474A63D4E"}
1
+ {"platform":"mac","instance":"12A4CA51-92CD-4391-8AD0-548165ABFCAE","exe":"ZqxAppName","name":"Zqx App Name"}
package/cli/share.mjs CHANGED
@@ -15,11 +15,13 @@ import { NOTES_FILE, Problem, stateOf } from "./state.mjs";
15
15
  import { ANY_BROWSER, CONTROLLERS, CONTROLLERS_FILE, DEVICE_FILE, lampJS, LIBRARIES } from "./dev.mjs";
16
16
  import { LIMITS, FILE_LIMITS, PAGE_LIMITS } from "../site/lib/shared-wishes.js";
17
17
  import { ideasOf, makerOf, pageUp, publishLink, removeFile, removeIdea, removeLink, replyIdea, sendFile, siteOrigin } from "./link.mjs";
18
- import { exportApp } from "./lampapp.mjs";
18
+ import { exportApp, sourceLinkOf } from "./lampapp.mjs";
19
19
 
20
20
  export { siteOrigin };
21
21
 
22
22
  const linkFileOf = (folder) => join(stateOf(folder), "link.json");
23
+ /** A link by its id alone, which the site sends on to its address now: what a file names, so a rename never breaks it. */
24
+ const stableLink = (site, id) => `${site}/w/${id}`;
23
25
 
24
26
 
25
27
  /**
@@ -174,9 +176,13 @@ const megabytes = (bytes) => `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ?
174
176
  * page or null, `shape` "fill" or the page's width over its height. Answers the link and what went up.
175
177
  */
176
178
  export async function share({ layout, words, picture = null, shape = "fill", site = siteOrigin(), said = () => {}, file = true, anyway = false, github, fromLamp = false }) {
179
+ // The folder's own link when it has one on this site, changed in place; a link that's gone is made again.
180
+ let earlier = readLink(layout.folder);
181
+ if (earlier && earlier.site !== site) earlier = null;
177
182
  // The app's own file goes with the link, as Lamp's Share Link sends it, so whoever follows the link can open it in
178
- // Lamp too: made first, with Share…'s checks, so a page that can't be shared (a key in it) puts nothing up.
179
- const app = file ? exportApp(layout.folder, { anyway }) : null;
183
+ // Lamp too: made first, with Share…'s checks, so a page that can't be shared (a key in it) puts nothing up. It names
184
+ // the link it goes up with, so a copy shared on says it came from this one: made again below if that's a new link.
185
+ let app = file ? exportApp(layout.folder, { anyway, link: earlier ? stableLink(site, earlier.id) : null }) : null;
180
186
  const files = pageFiles(layout);
181
187
  const unpacked = files.reduce((total, [, bytes]) => total + bytes.length, 0);
182
188
  if (!files.some(([path]) => path === "index.html")) throw new Problem("the page has no index.html at its top");
@@ -194,9 +200,9 @@ export async function share({ layout, words, picture = null, shape = "fill", sit
194
200
  ...(words.asks?.length ? { asks: words.asks } : {}), ...(picture ? { picture: picture.toString("base64") } : {}),
195
201
  ...(repo ? { github: repo } : {}),
196
202
  };
197
- // The folder's own link when it has one on this site, changed in place; a link that's gone is made again.
198
- let earlier = readLink(layout.folder);
199
- if (earlier && earlier.site !== site) earlier = null;
203
+ // The link this app came from, when it's a copy of one shared there: its page says so.
204
+ const from = sourceLinkOf(layout.folder, site, earlier?.id);
205
+ if (from) body.from = from;
200
206
  // A file an earlier share sent stays on the record until it's taken away, so a retry after a failed removal tries again.
201
207
  // A link that's a Lamp's (`fromLamp`, the app in its own folder) stays so in every write, a share that fails partway too.
202
208
  let link = { site, ...(await publishLink({ site, body, earlier, account: accountFor(site)?.key })), ...words, shape, ...(earlier?.file ? { file: true } : {}),
@@ -204,6 +210,8 @@ export async function share({ layout, words, picture = null, shape = "fill", sit
204
210
  // Kept before the page goes up: a link whose page didn't make it is still this folder's to send again or take down.
205
211
  writeLink(layout.folder, link);
206
212
 
213
+ // A new link (or one made again) has its id only now, which the file carries.
214
+ if (app && link.id !== earlier?.id) app = exportApp(layout.folder, { anyway, link: stableLink(site, link.id) });
207
215
  // The file, or none: a link that carried one and shouldn't now has it taken away (and its page with it, sent next).
208
216
  if (app) {
209
217
  link.file = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lampdotdev/lamp",
3
- "version": "0.36.2-nightly.17",
3
+ "version": "0.36.2-nightly.19",
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": {
@@ -137,11 +137,11 @@ function release(value) {
137
137
  }
138
138
 
139
139
  /** What a share sends, checked. Null when anything is off. `releases` is undefined when the share didn't say (a Lamp
140
- * from before them), so a change keeps the ones the link has. */
140
+ * from before them), so a change keeps the ones the link has; `from` too, the id of the link the app is a copy of. */
141
141
  const GITHUB_REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/;
142
142
  export function readShare(body) {
143
143
  if (!body || typeof body !== "object" || Array.isArray(body)) return null;
144
- const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon", "github"];
144
+ const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon", "github", "from"];
145
145
  if (Object.keys(body).some((key) => !known.includes(key))) return null;
146
146
  const name = tidy(body.name, LIMITS.name);
147
147
  const words = tidy(body.words, LIMITS.words, { lines: true });
@@ -159,9 +159,10 @@ export function readShare(body) {
159
159
  const icon = picture(body.icon, LIMITS.icon, { square: true });
160
160
  // The app's public repository on GitHub, as "owner/name", which the page links to: nothing else is taken.
161
161
  const github = body.github == null || body.github === "" ? null : GITHUB_REPO.test(body.github) ? body.github : undefined;
162
- if (!name || !words || line == null || by == null || ideas.includes(null) || asks.includes(null) || shape === undefined || releases?.includes(null) || !shot || !icon || github === undefined) return null;
162
+ const from = body.from == null ? undefined : isID(body.from) ? body.from : null;
163
+ if (!name || !words || line == null || by == null || ideas.includes(null) || asks.includes(null) || shape === undefined || releases?.includes(null) || !shot || !icon || github === undefined || from === null) return null;
163
164
  return { name, by, line, words, ideas, asks, shape, releases: releases?.sort((a, b) => b.date.localeCompare(a.date)),
164
- picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null, github };
165
+ picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null, github, from };
165
166
  }
166
167
 
167
168
  /** What anyone may see of a shared wish: never its secret. */
@@ -772,8 +773,11 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
772
773
  const picture = share.picture && { url: await put("picture", share.picture), width: share.picture.width, height: share.picture.height };
773
774
  const icon = share.icon && { url: await put("icon", share.icon) };
774
775
  const time = now().toISOString();
776
+ // The link the app is a copy of, kept only while there's such a link and it isn't this one: a share that doesn't
777
+ // say, or names one that's gone, keeps what the record had.
778
+ const from = share.from && share.from !== wishID && (await kept.get(share.from)) ? share.from : null;
775
779
  const words = { name: share.name, by: share.by, line: share.line, words: share.words, ideas: share.ideas, asks: share.asks, shape: share.shape, github: share.github,
776
- ...(share.releases ? { releases: share.releases } : {}), picture: picture || null, icon: icon || null };
780
+ ...(share.releases ? { releases: share.releases } : {}), ...(from ? { from } : {}), picture: picture || null, icon: icon || null };
777
781
  // Its name under the account stays what it was first given, whatever the app is called later.
778
782
  const named = earlier?.owner ? { owner: earlier.owner, app: earlier.app }
779
783
  : owner ? { owner, app: await claimAppName(kept, owner, words.name, wishID) } : {};
@@ -1287,10 +1291,21 @@ export function createIdeaMailHandler({ env = process.env, fetcher = fetch, stor
1287
1291
  }
1288
1292
 
1289
1293
  /** A shared wish for the page and its card, or null when there's none by that id (or it can't be read now). */
1290
- export async function findWish(id, { env = process.env, fetcher = fetch, store } = {}) {
1294
+ export async function findWish(id, { env = process.env, fetcher = fetch, store, origin = null } = {}) {
1291
1295
  if (!isID(id)) return null;
1292
- try { return shown(await (store ?? cloudStore(env, fetcher)).get(id)); }
1296
+ let kept, record;
1297
+ try { kept = store ?? cloudStore(env, fetcher); record = await kept.get(id); }
1293
1298
  catch { return undefined; }
1299
+ const wish = shown(record);
1300
+ // Asked with the site's origin (the page): the link it came from, as that link is known now, `from` on its page. One
1301
+ // taken down since, or that can't be read just now, is left out.
1302
+ if (wish && origin && isID(record.from) && record.from !== id) {
1303
+ try {
1304
+ const source = await kept.get(record.from);
1305
+ if (source) wish.from = { url: linkOf(origin, source), label: source.owner && source.app ? `${source.owner}/${source.app}` : source.name };
1306
+ } catch {}
1307
+ }
1308
+ return wish;
1294
1309
  }
1295
1310
 
1296
1311
  /** Takes a shared wish down without its secret: for the Lamp team, when a link shows what it mustn't. */