@napplet/conformance-cli 0.4.0 → 0.4.1

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.
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  var NappletConformanceHostBundle = (() => {
3
3
  // ../core/dist/index.js
4
- var NAP_DOMAINS = ["shell", "relay", "identity", "storage", "inc", "theme", "keys", "media", "notify", "config", "resource", "cvm", "outbox", "upload", "intent", "ble", "webrtc", "link", "count", "lists", "serial", "fs", "common", "dm", "gamepad"];
4
+ var NAP_DOMAINS = ["shell", "relay", "identity", "storage", "inc", "theme", "keys", "media", "notify", "config", "resource", "cvm", "outbox", "upload", "intent", "ble", "webrtc", "link", "count", "lists", "serial", "fs", "common", "dm", "gamepad", "llm"];
5
5
  function createDispatch() {
6
6
  const handlers = /* @__PURE__ */ new Map();
7
7
  function registerNap2(domain, handler) {
@@ -10,13 +10,13 @@ var NappletConformanceHostBundle = (() => {
10
10
  }
11
11
  handlers.set(domain, handler);
12
12
  }
13
- function dispatch2(message) {
14
- const dotIndex = message.type.indexOf(".");
13
+ function dispatch2(message2) {
14
+ const dotIndex = message2.type.indexOf(".");
15
15
  if (dotIndex <= 0) return false;
16
- const domain = message.type.slice(0, dotIndex);
16
+ const domain = message2.type.slice(0, dotIndex);
17
17
  const handler = handlers.get(domain);
18
18
  if (!handler) return false;
19
- handler(message);
19
+ handler(message2);
20
20
  return true;
21
21
  }
22
22
  function getRegisteredDomains2() {
@@ -32,6 +32,17 @@ var NappletConformanceHostBundle = (() => {
32
32
  // ../conformance/dist/index.js
33
33
  var ID = { id: "string" };
34
34
  var ENVELOPE_SPECS = {
35
+ // NAP-LLM Wire Protocol: four request/result pairs plus the unsolicited delta.
36
+ // https://github.com/napplet/naps/pull/134
37
+ "llm.select": { dir: "out", fields: { ...ID } },
38
+ "llm.select.result": { dir: "in", fields: { ...ID } },
39
+ "llm.describe": { dir: "out", fields: { ...ID, handle: "string" } },
40
+ "llm.describe.result": { dir: "in", fields: { ...ID } },
41
+ "llm.generate": { dir: "out", fields: { ...ID, request: "object" } },
42
+ "llm.generate.result": { dir: "in", fields: { ...ID } },
43
+ "llm.delta": { dir: "in", fields: { requestId: "string", sequence: "number", text: "string" } },
44
+ "llm.cancel": { dir: "out", fields: { ...ID, requestId: "string" } },
45
+ "llm.cancel.result": { dir: "in", fields: { ...ID } },
35
46
  // NAP-GAMEPAD Wire Protocol: all three messages are fire-and-forget, without ids.
36
47
  // https://github.com/napplet/naps/pull/108
37
48
  "gamepad.subscribe": { dir: "out", forbiddenFields: ["id"], forbiddenReason: "NAP-GAMEPAD fire-and-forget envelopes do not carry it" },
@@ -305,6 +316,59 @@ var NappletConformanceHostBundle = (() => {
305
316
  "dm.unsubscribe.result": { dir: "in" },
306
317
  "dm.message": { dir: "in" }
307
318
  };
319
+ var text = (value) => typeof value === "string";
320
+ var nonemptyText = (value) => typeof value === "string" && value.length > 0;
321
+ var boolean = (value) => typeof value === "boolean";
322
+ var oneOf = (...allowed) => (value) => typeof value === "string" && allowed.includes(value);
323
+ var base64 = (value) => typeof value === "string" && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) && value.length >= 4;
324
+ function exactRecord(required, optional = {}) {
325
+ return (value) => {
326
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
327
+ const data = value;
328
+ if (Object.keys(data).some((key) => !(key in required) && !(key in optional))) return false;
329
+ return Object.entries(required).every(([key, check]) => check(data[key])) && Object.entries(optional).every(([key, check]) => data[key] === void 0 || check(data[key]));
330
+ };
331
+ }
332
+ var nonemptyList = (item) => (value) => Array.isArray(value) && value.length > 0 && value.every(item);
333
+ var textPart = exactRecord({ type: oneOf("text"), text });
334
+ var imagePart = exactRecord({ type: oneOf("image"), mimeType: oneOf("image/png", "image/jpeg", "image/webp"), data: base64 });
335
+ var part = (value) => textPart(value) || imagePart(value);
336
+ var message = exactRecord({ role: oneOf("system", "user", "assistant"), content: nonemptyList(part) });
337
+ var requirements = exactRecord({}, { model: nonemptyText, images: boolean, json: boolean });
338
+ var generationRequest = exactRecord({ handle: nonemptyText, messages: nonemptyList(message) }, { format: oneOf("text", "json") });
339
+ var PAYLOADS = {
340
+ "llm.select": { optional: { requirements } },
341
+ "llm.describe": { required: { handle: nonemptyText } },
342
+ "llm.generate": { required: { request: generationRequest } },
343
+ "llm.cancel": { required: { requestId: nonemptyText } }
344
+ };
345
+ function validateLlmRequest(type, envelope, errors) {
346
+ const schema = PAYLOADS[type];
347
+ if (!schema) return;
348
+ const required = schema.required ?? {};
349
+ const optional = schema.optional ?? {};
350
+ if (envelope.id !== void 0 && !nonemptyText(envelope.id)) {
351
+ errors.push({ code: "wrong-type", field: "id", message: "NAP-LLM request ids are non-empty text" });
352
+ }
353
+ for (const field of Object.keys(envelope)) {
354
+ if (field === "type" || field === "id" || field in required || field in optional) continue;
355
+ errors.push({
356
+ code: "forbidden-field",
357
+ field,
358
+ message: `Field "${field}" is not a parameter of ${type}; NAP-LLM requests carry exactly their API row parameters`
359
+ });
360
+ }
361
+ for (const [field, check] of Object.entries({ ...required, ...optional })) {
362
+ const value = envelope[field];
363
+ if (value === void 0) {
364
+ if (field in required) errors.push({ code: "missing-field", field, message: `Field "${field}" is required by ${type}` });
365
+ continue;
366
+ }
367
+ if (!check(value)) {
368
+ errors.push({ code: "wrong-type", field, message: `Field "${field}" must match the NAP-LLM ${type} schema` });
369
+ }
370
+ }
371
+ }
308
372
  function kindOf(value) {
309
373
  if (value === void 0) return "undefined";
310
374
  if (value === null) return "null";
@@ -415,12 +479,12 @@ var NappletConformanceHostBundle = (() => {
415
479
  if (entry.servers !== void 0) validateServerHints(entry.servers, `${field}.servers`, errors);
416
480
  }
417
481
  }
418
- function validateEnvelope(message) {
482
+ function validateEnvelope(message2) {
419
483
  const errors = [];
420
- if (typeof message !== "object" || message === null || Array.isArray(message)) {
484
+ if (typeof message2 !== "object" || message2 === null || Array.isArray(message2)) {
421
485
  return { ok: false, errors: [{ code: "not-an-object", message: "Envelope must be a non-null object" }] };
422
486
  }
423
- const record = message;
487
+ const record = message2;
424
488
  const type = record["type"];
425
489
  if (typeof type !== "string") {
426
490
  return { ok: false, errors: [{ code: "missing-type", message: "Envelope is missing a string `type` field" }] };
@@ -472,6 +536,7 @@ var NappletConformanceHostBundle = (() => {
472
536
  });
473
537
  }
474
538
  }
539
+ if (domain === "llm") validateLlmRequest(type, record, errors);
475
540
  if (type === "intent.invoke") {
476
541
  validateIntentInvokeRequest(record.request, errors);
477
542
  }
@@ -491,6 +556,8 @@ var NappletConformanceHostBundle = (() => {
491
556
  NAPPLET_KIND_ROOT,
492
557
  NAPPLET_KIND_NAMED
493
558
  ];
559
+ var SCREENSHOT_FORMATS = ["image/png", "image/jpeg", "image/webp"];
560
+ var MIME_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i;
494
561
  function firstTag(event, name) {
495
562
  return event.tags.find((tag) => tag[0] === name);
496
563
  }
@@ -507,6 +574,71 @@ var NappletConformanceHostBundle = (() => {
507
574
  function manifestRequires(event) {
508
575
  return event.tags.filter((tag) => tag[0] === "R").map((tag) => tag[1]?.trim() ?? "").filter(Boolean);
509
576
  }
577
+ function parsePresentationMedia(event) {
578
+ const warnings = [];
579
+ const screenshots = [];
580
+ for (const tag of event.tags.filter((tag2) => tag2[0] === "screenshot")) {
581
+ const media = parseMediaTag(tag, warnings);
582
+ if (media) screenshots.push(media);
583
+ }
584
+ const previews = event.tags.filter((tag) => tag[0] === "preview");
585
+ let preview;
586
+ if (previews.length > 1) {
587
+ warnings.push({ code: "invalid-presentation-media", message: "Multiple preview tags; clients ignore the preview (NIP-5D \xA7Presentation media)" });
588
+ } else if (previews.length === 1) {
589
+ preview = parseMediaTag(previews[0], warnings);
590
+ }
591
+ return { screenshots, preview, warnings };
592
+ }
593
+ function parseMediaTag(tag, warnings) {
594
+ const name = tag[0];
595
+ const ignore = (reason) => {
596
+ warnings.push({ code: "invalid-presentation-media", message: `Ignore ${name} tag: ${reason} (NIP-5D \xA7Presentation media)` });
597
+ return void 0;
598
+ };
599
+ const fields = /* @__PURE__ */ new Map();
600
+ for (const field of tag.slice(1)) {
601
+ const space = typeof field === "string" ? field.indexOf(" ") : -1;
602
+ const key = space < 0 ? field : field.slice(0, space);
603
+ const value = space < 0 ? void 0 : field.slice(space + 1);
604
+ const values = fields.get(key) ?? [];
605
+ if (value !== void 0) values.push(value);
606
+ else values.push("");
607
+ fields.set(key, values);
608
+ }
609
+ const hashes = fields.get("x");
610
+ if (!hashes || hashes.length !== 1 || !isSha256Hex(hashes[0])) return ignore("exactly one x field with a lowercase sha256 is required");
611
+ const media = { sha256: hashes[0] };
612
+ const optional = (key, accept) => {
613
+ const values = fields.get(key);
614
+ if (!values) return void 0;
615
+ if (values.length !== 1 || !accept(values[0])) {
616
+ warnings.push({ code: "invalid-presentation-media", message: `Ignore ${key} field on ${name} ${media.sha256}: malformed or repeated (NIP-5D \xA7Presentation media)` });
617
+ return void 0;
618
+ }
619
+ return values[0];
620
+ };
621
+ const mimeType = optional("m", (value) => MIME_TYPE.test(value));
622
+ if (mimeType !== void 0) {
623
+ const decodable = name === "preview" ? mimeType.toLowerCase().startsWith("video/") : SCREENSHOT_FORMATS.includes(mimeType.toLowerCase());
624
+ if (!decodable) return ignore(`${mimeType} cannot match a decodable ${name === "preview" ? "video" : "PNG, JPEG or WebP"} format`);
625
+ media.mimeType = mimeType;
626
+ }
627
+ const dim = optional("dim", (value) => /^[1-9][0-9]*x[1-9][0-9]*$/.test(value));
628
+ if (dim !== void 0) {
629
+ const [width, height] = dim.split("x").map(Number);
630
+ media.dim = { width, height };
631
+ }
632
+ const size = optional("size", (value) => /^(?:0|[1-9][0-9]*)$/.test(value));
633
+ if (size !== void 0) media.size = Number(size);
634
+ if (name === "preview") {
635
+ const duration = optional("duration", (value) => /^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(value));
636
+ if (duration !== void 0) media.duration = Number(duration);
637
+ }
638
+ const alt = optional("alt", (value) => value.trim().length > 0);
639
+ if (alt !== void 0) media.alt = alt;
640
+ return media;
641
+ }
510
642
  function validateManifestEvent(event) {
511
643
  const errors = [];
512
644
  const warnings = [];
@@ -569,26 +701,51 @@ var NappletConformanceHostBundle = (() => {
569
701
  errors.push({ code: "invalid-metadata", message: `Invalid ${name} tag cardinality for kind ${event.kind}` });
570
702
  }
571
703
  }
572
- for (const tag of event.tags.filter((tag2) => tag2[0] === "source")) {
704
+ const sources = event.tags.filter((tag) => tag[0] === "source");
705
+ for (const tag of sources) {
573
706
  if (tag.length !== 2 || !isSourceLocation(tag[1])) {
574
- errors.push({ code: "invalid-metadata", message: "source must contain one HTTP(S), SSH, Git, or SCP repository location" });
707
+ errors.push({ code: "invalid-metadata", message: "source must contain one HTTP(S), SSH, Git, SCP, or nostr repository location" });
575
708
  }
576
709
  }
710
+ validateSourcePins(event, sources.length === 1 && sources[0].length === 2 && isSourceLocation(sources[0][1]), errors, warnings);
577
711
  for (const tag of event.tags.filter((tag2) => tag2[0] === "i")) {
578
712
  if (!tag[1]?.trim() || tag[1].includes("?")) {
579
713
  errors.push({ code: "invalid-metadata", message: "Intent advertisements must be non-empty queryless identities" });
580
714
  }
581
715
  }
582
716
  const icons = event.tags.filter((tag) => tag[0] === "icon");
583
- if (icons.length > 1 || icons.some((tag) => tag.length !== 3 || !isSha256Hex(tag[1]) || !["image/png", "image/jpeg", "image/webp"].includes(tag[2]))) {
717
+ if (icons.length > 1 || icons.some((tag) => tag.length !== 3 || !isSha256Hex(tag[1]) || !SCREENSHOT_FORMATS.includes(tag[2]))) {
584
718
  warnings.push({ code: "invalid-icon", message: "Ignore invalid icon metadata and use generic artwork (NIP-5D \xA7Icon)" });
585
719
  }
720
+ warnings.push(...parsePresentationMedia(event).warnings);
721
+ }
722
+ function validateSourcePins(event, hasRepositorySource, errors, warnings) {
723
+ const commits = event.tags.filter((tag) => tag[0] === "source-commit");
724
+ if (commits.length > 1 || commits.some((tag) => tag.length !== 2 || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(tag[1]))) {
725
+ errors.push({ code: "invalid-metadata", message: "source-commit must appear once with a full lowercase-hex Git commit ID (40 or 64 characters)" });
726
+ } else if (commits.length === 1 && !hasRepositorySource) {
727
+ warnings.push({ code: "unused-source-commit", message: "source-commit without a Git repository source is ignored (NIP-5D \xA7Source)" });
728
+ }
729
+ const archives = event.tags.filter((tag) => tag[0] === "source-archive");
730
+ if (archives.length > 1 || archives.some((tag) => tag.length !== 4 || !isArchiveUrl(tag[1]) || !isSha256Hex(tag[2]) || !MIME_TYPE.test(tag[3] ?? ""))) {
731
+ errors.push({ code: "invalid-metadata", message: "source-archive must appear once with an HTTPS URL without credentials or fragment, a lowercase sha256, and a MIME type" });
732
+ }
733
+ }
734
+ function isArchiveUrl(value) {
735
+ if (!value || value.includes("#")) return false;
736
+ try {
737
+ const url = new URL(value);
738
+ return url.protocol === "https:" && !url.username && !url.password;
739
+ } catch {
740
+ return false;
741
+ }
586
742
  }
587
743
  function isSourceLocation(value) {
588
744
  if (!value || /^[a-z]:[\\/]/i.test(value)) return false;
589
745
  try {
590
746
  const { protocol } = new URL(value);
591
747
  if (["http:", "https:", "ssh:", "git:"].includes(protocol)) return true;
748
+ if (protocol === "nostr:") return value.length > "nostr://".length && value.startsWith("nostr://");
592
749
  if (protocol === "file:") return false;
593
750
  } catch {
594
751
  }
@@ -615,10 +772,10 @@ var NappletConformanceHostBundle = (() => {
615
772
  const meta = url.slice("data:".length, comma);
616
773
  const body = url.slice(comma + 1);
617
774
  const parts = meta.split(";").filter(Boolean);
618
- const base64 = parts.includes("base64");
619
- const mime = parts.find((part) => part.includes("/")) ?? "text/plain";
775
+ const base642 = parts.includes("base64");
776
+ const mime = parts.find((part2) => part2.includes("/")) ?? "text/plain";
620
777
  try {
621
- if (base64) {
778
+ if (base642) {
622
779
  const binary = globalThis.atob(body);
623
780
  const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
624
781
  return { blob: new Blob([bytes], { type: mime }), mime };
@@ -850,9 +1007,111 @@ var NappletConformanceHostBundle = (() => {
850
1007
  "fs.watch": (e) => ok({ type: "fs.watch.result", id: e.id, watchId: `watch-${String(e.id)}` }),
851
1008
  "fs.unwatch": (e) => ok({ type: "fs.unwatch.result", id: e.id })
852
1009
  };
1010
+ var REFERENCE_LLM_CONNECTION = {
1011
+ name: "Reference connection",
1012
+ model: "reference-model",
1013
+ route: "Reference shell \u2192 reference provider (no network)",
1014
+ images: true,
1015
+ json: true
1016
+ };
1017
+ var fail = (code, message2) => ({ error: { code, message: message2 } });
1018
+ var object = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
1019
+ function sessionFor(sessions, endpoint) {
1020
+ let session = sessions.get(endpoint.id);
1021
+ if (!session) {
1022
+ session = { handles: /* @__PURE__ */ new Set(), active: /* @__PURE__ */ new Map(), sequence: 0 };
1023
+ sessions.set(endpoint.id, session);
1024
+ }
1025
+ return session;
1026
+ }
1027
+ function modelFor(handle2) {
1028
+ return { handle: handle2, ...REFERENCE_LLM_CONNECTION };
1029
+ }
1030
+ function select(session, envelope) {
1031
+ const requirements2 = object(envelope.requirements);
1032
+ if (requirements2.model !== void 0 && requirements2.model !== REFERENCE_LLM_CONNECTION.model) {
1033
+ return fail("unsupported", "No connection offers the requested model");
1034
+ }
1035
+ session.sequence += 1;
1036
+ const handle2 = `reference-llm-h${session.sequence}`;
1037
+ session.handles.add(handle2);
1038
+ return { model: modelFor(handle2) };
1039
+ }
1040
+ function describe(session, envelope) {
1041
+ const handle2 = String(envelope.handle);
1042
+ if (!session.handles.has(handle2)) return fail("selection-expired", "Select a model again");
1043
+ return { model: modelFor(handle2) };
1044
+ }
1045
+ function lastUserText(request) {
1046
+ const messages = Array.isArray(request.messages) ? request.messages : [];
1047
+ let text2 = "";
1048
+ for (const message2 of messages) {
1049
+ const record = object(message2);
1050
+ if (record.role !== "user" || !Array.isArray(record.content)) continue;
1051
+ for (const part2 of record.content) {
1052
+ const item = object(part2);
1053
+ if (item.type === "text" && typeof item.text === "string") text2 = item.text;
1054
+ }
1055
+ }
1056
+ return text2;
1057
+ }
1058
+ function generate(session, id, envelope) {
1059
+ const reply = (outcome) => [{ type: "llm.generate.result", id, ...outcome }];
1060
+ if (session.active.has(id)) return reply(fail("invalid-request", "Request id is still active"));
1061
+ const request = object(envelope.request);
1062
+ if (!session.handles.has(String(request.handle))) return reply(fail("selection-expired", "Select a model again"));
1063
+ const prompt = lastUserText(request);
1064
+ if (prompt.includes("[[decline]]")) return reply(fail("user-cancelled", "The user declined disclosure"));
1065
+ if (prompt.includes("[[stall]]")) {
1066
+ session.active.set(id, envelope);
1067
+ return [];
1068
+ }
1069
+ if (prompt.includes("[[refuse]]")) return reply({ result: { text: "", finish: "refusal" } });
1070
+ const output = request.format === "json" ? JSON.stringify({ echo: prompt }) : `Echo: ${prompt}`;
1071
+ const usage = { inputTokens: JSON.stringify(request.messages).length, outputTokens: output.length };
1072
+ return [
1073
+ { type: "llm.delta", requestId: id, sequence: 0, text: output.slice(0, Math.ceil(output.length / 2)) },
1074
+ ...reply({ result: { text: output, finish: "stop", usage } })
1075
+ ];
1076
+ }
1077
+ function cancel(session, id, envelope) {
1078
+ const requestId = String(envelope.requestId);
1079
+ if (!session.active.delete(requestId)) return [{ type: "llm.cancel.result", id, cancelled: false }];
1080
+ return [
1081
+ { type: "llm.generate.result", id: requestId, error: { code: "cancelled", message: "Generation cancelled; provider charges may still apply." } },
1082
+ { type: "llm.cancel.result", id, cancelled: true }
1083
+ ];
1084
+ }
1085
+ function handle(sessions, endpoint, envelope) {
1086
+ const type = String(envelope.type);
1087
+ const id = String(envelope.id);
1088
+ const session = sessionFor(sessions, endpoint);
1089
+ switch (type) {
1090
+ case "llm.select":
1091
+ return [{ type: "llm.select.result", id, ...select(session, envelope) }];
1092
+ case "llm.describe":
1093
+ return [{ type: "llm.describe.result", id, ...describe(session, envelope) }];
1094
+ case "llm.generate":
1095
+ return structuredClone(generate(session, id, envelope));
1096
+ case "llm.cancel":
1097
+ return cancel(session, id, envelope);
1098
+ default:
1099
+ return [{ type: `${type}.result`, id, error: { code: "invalid-request", message: `Unknown operation ${type}` } }];
1100
+ }
1101
+ }
1102
+ function createLlmHandlers() {
1103
+ const sessions = /* @__PURE__ */ new Map();
1104
+ return {
1105
+ handle: (endpoint, envelope) => handle(sessions, endpoint, envelope),
1106
+ reset() {
1107
+ sessions.clear();
1108
+ }
1109
+ };
1110
+ }
853
1111
  function createReferenceHandler(options) {
854
1112
  const initialized = /* @__PURE__ */ new Set();
855
- return (endpoint, envelope) => {
1113
+ const llm = createLlmHandlers();
1114
+ const handle2 = (endpoint, envelope) => {
856
1115
  const type = getEnvelopeType(envelope);
857
1116
  const verdict = validateEnvelope(envelope);
858
1117
  options.records.push({ envelope, verdict, timestamp: options.now() });
@@ -865,6 +1124,7 @@ var NappletConformanceHostBundle = (() => {
865
1124
  return ok({ type: "shell.init", capabilities: { domains: [...domains] }, services: [] });
866
1125
  }
867
1126
  const env = envelope;
1127
+ if (type.startsWith("llm.")) return llm.handle(endpoint, env);
868
1128
  if (type === "intent.invoke") return options.intents.handleInvoke(endpoint, env);
869
1129
  if (type === "intent.available") {
870
1130
  return ok({ type: "intent.available.result", id: env.id, availability: options.intents.availability(env.archetype) });
@@ -876,6 +1136,9 @@ var NappletConformanceHostBundle = (() => {
876
1136
  const responder = RESPONDERS[type];
877
1137
  return responder ? responder(env) : [];
878
1138
  };
1139
+ return Object.assign(handle2, { reset() {
1140
+ llm.reset();
1141
+ } });
879
1142
  }
880
1143
  function getEnvelopeType(envelope) {
881
1144
  if (!envelope || typeof envelope !== "object") return void 0;
@@ -971,6 +1234,7 @@ var NappletConformanceHostBundle = (() => {
971
1234
  takeDeliveries: deliveries.take,
972
1235
  reset() {
973
1236
  records.length = 0;
1237
+ handleFrom.reset();
974
1238
  deliveries.clear();
975
1239
  }
976
1240
  };
@@ -993,6 +1257,56 @@ var NappletConformanceHostBundle = (() => {
993
1257
  }
994
1258
  };
995
1259
  }
1260
+ var LLM_PRELUDE_SOURCE = `
1261
+ if (domains.includes('llm')) {
1262
+ const llmFields = { 'llm.select.result': 'model', 'llm.describe.result': 'model', 'llm.generate.result': 'result', 'llm.cancel.result': 'cancelled' };
1263
+ const llmDeltas = new Map();
1264
+ window.addEventListener('message', (event) => {
1265
+ if (event.source !== window.parent) return;
1266
+ const msg = event.data;
1267
+ if (!msg || typeof msg !== 'object' || msg.type !== 'llm.delta' || 'id' in msg || typeof msg.requestId !== 'string') return;
1268
+ const entry = llmDeltas.get(msg.requestId);
1269
+ if (!entry || typeof msg.sequence !== 'number' || typeof msg.text !== 'string') return;
1270
+ if (msg.sequence !== entry.next) { entry.next = Infinity; return; }
1271
+ entry.next += 1;
1272
+ if (entry.onDelta) entry.onDelta({ requestId: msg.requestId, sequence: msg.sequence, text: msg.text });
1273
+ });
1274
+ const llmRequest = (payload, id) => {
1275
+ const type = payload.type;
1276
+ return request(id === undefined ? payload : { ...payload, id }, type + '.result').then((message) => {
1277
+ const field = llmFields[message.type];
1278
+ const hasValue = message[field] !== undefined && message[field] !== null;
1279
+ const hasError = message.error !== undefined && message.error !== null;
1280
+ if (hasValue === hasError) throw new Error(type + '.result must carry exactly one of ' + field + ' or error');
1281
+ if (hasError) {
1282
+ const failure = new Error(String(message.error.message));
1283
+ failure.name = 'LlmError';
1284
+ failure.code = message.error.code;
1285
+ throw failure;
1286
+ }
1287
+ return message[field];
1288
+ });
1289
+ };
1290
+ napplet.llm = {
1291
+ select: (requirements) => llmRequest(requirements === undefined ? { type: 'llm.select' } : { type: 'llm.select', requirements }),
1292
+ describe: (handle) => llmRequest({ type: 'llm.describe', handle }),
1293
+ generate: (generation, options) => {
1294
+ const id = nextId();
1295
+ const entry = { next: 0, onDelta: options && options.onDelta };
1296
+ llmDeltas.set(id, entry);
1297
+ const signal = options && options.signal;
1298
+ const abort = () => { napplet.llm.cancel(id).catch(() => undefined); };
1299
+ if (signal) { if (signal.aborted) abort(); else signal.addEventListener('abort', abort, { once: true }); }
1300
+ const pendingGeneration = llmRequest({ type: 'llm.generate', request: generation }, id).finally(() => {
1301
+ llmDeltas.delete(id);
1302
+ if (signal) signal.removeEventListener('abort', abort);
1303
+ });
1304
+ return Object.assign(pendingGeneration, { requestId: id });
1305
+ },
1306
+ cancel: (requestId) => llmRequest({ type: 'llm.cancel', requestId }),
1307
+ };
1308
+ }
1309
+ `;
996
1310
  var INTERNAL_BOOT_ERROR = "__nappletConformance.error";
997
1311
  var INTERNAL_INSTALLED = "__nappletConformance.installed";
998
1312
  function sleep(ms) {
@@ -1039,21 +1353,16 @@ var NappletConformanceHostBundle = (() => {
1039
1353
  window.addEventListener('unhandledrejection', (event) => report(event.reason));
1040
1354
  })();`;
1041
1355
  }
1042
- function runtimePrelude(domains, runtimePreludeSource) {
1043
- const installed = `window.parent.postMessage({ type: '${INTERNAL_INSTALLED}', installed: !!window.napplet }, '*');`;
1044
- if (runtimePreludeSource !== void 0) {
1045
- return `${runtimePreludeSource}
1046
- ;globalThis.NappletShimPrelude.install(${JSON.stringify({ domains })});
1047
- ${installed}`;
1048
- }
1049
- return `
1356
+ var FALLBACK_RUNTIME_OPEN = `
1050
1357
  (() => {
1051
- const domains = ${JSON.stringify(domains)};
1358
+ const domains = `;
1359
+ var FALLBACK_RUNTIME_BEFORE_GAMEPAD_TOOLS = `;
1052
1360
  const napplet = {};
1053
1361
  for (const domain of domains) {
1054
1362
  if (domain === 'resource' || domain === 'gamepad') napplet[domain] = {};
1055
1363
  }
1056
- const gamepadTools = ${GAMEPAD_STATE_TOOLS_SOURCE};
1364
+ const gamepadTools = `;
1365
+ var FALLBACK_RUNTIME_AFTER_GAMEPAD_TOOLS = `;
1057
1366
  let gamepadState = { available: true, focused: false, pads: [] };
1058
1367
  const gamepadHandlers = new Set();
1059
1368
  // Subscribe on first use, like the nap binding, so napplets that never touch
@@ -1152,10 +1461,20 @@ ${installed}`;
1152
1461
  return handle;
1153
1462
  };
1154
1463
  }
1464
+ ${LLM_PRELUDE_SOURCE}
1155
1465
  window.napplet = napplet;
1156
- ${installed}
1466
+ `;
1467
+ var FALLBACK_RUNTIME_CLOSE = `
1157
1468
  })();
1158
1469
  `;
1470
+ function runtimePrelude(domains, runtimePreludeSource) {
1471
+ const installed = `window.parent.postMessage({ type: '${INTERNAL_INSTALLED}', installed: !!window.napplet }, '*');`;
1472
+ if (runtimePreludeSource !== void 0) {
1473
+ return `${runtimePreludeSource}
1474
+ ;globalThis.NappletShimPrelude.install(${JSON.stringify({ domains })});
1475
+ ${installed}`;
1476
+ }
1477
+ return FALLBACK_RUNTIME_OPEN + JSON.stringify(domains) + FALLBACK_RUNTIME_BEFORE_GAMEPAD_TOOLS + GAMEPAD_STATE_TOOLS_SOURCE + FALLBACK_RUNTIME_AFTER_GAMEPAD_TOOLS + installed + FALLBACK_RUNTIME_CLOSE;
1159
1478
  }
1160
1479
  function injectRuntime(html, url, domains, runtimePreludeSource) {
1161
1480
  const base = `<base href="${escapeHtmlAttr(url)}">`;
@@ -1268,9 +1587,9 @@ ${installed}`;
1268
1587
  };
1269
1588
  }
1270
1589
  var pass = (detail) => ({ status: "pass", detail });
1271
- var fail = (detail, diagnostics) => ({ status: "fail", detail, diagnostics });
1590
+ var fail2 = (detail, diagnostics) => ({ status: "fail", detail, diagnostics });
1272
1591
  var skip = (detail) => ({ status: "skip", detail });
1273
- var result = { pass, fail, skip };
1592
+ var result = { pass, fail: fail2, skip };
1274
1593
  function manifestErrors(ctx, codes) {
1275
1594
  const verdict = validateManifestEvent(ctx.manifestEvent);
1276
1595
  return [...verdict.errors, ...verdict.warnings].filter((e) => codes.includes(e.code));
@@ -1308,6 +1627,12 @@ ${installed}`;
1308
1627
  manifestCheck("manifest/requires-known", "R/O domains are in this toolchain registry (advisory)", [
1309
1628
  "unknown-required-nap"
1310
1629
  ], "warning"),
1630
+ // NIP-5D §Icon, §Presentation media and §Source: clients ignore these, never fail.
1631
+ manifestCheck("manifest/media-usable", "Icon, screenshots, preview and source pins are usable (advisory)", [
1632
+ "invalid-icon",
1633
+ "invalid-presentation-media",
1634
+ "unused-source-commit"
1635
+ ], "warning"),
1311
1636
  // ── boot ─────────────────────────────────────────────────────────────────────
1312
1637
  {
1313
1638
  id: "boot/sandbox-allow-scripts",