@elisym/cli 0.19.0 → 0.21.0

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/dist/index.js CHANGED
@@ -1,19 +1,23 @@
1
1
  #!/usr/bin/env -S node --no-deprecation
2
- import { readFileSync, existsSync, readdirSync, statSync, renameSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { ReadableStream } from 'node:stream/web';
3
+ import { readFileSync, existsSync, readdirSync, statSync, renameSync, chmodSync, mkdirSync, writeFileSync } from 'node:fs';
3
4
  import { dirname, join, resolve, basename, relative, sep } from 'node:path';
4
- import { SolanaPaymentStrategy, validateAgentName, RELAYS, ElisymIdentity, formatSol, formatAssetAmount, USDC_SOLANA_DEVNET, ElisymClient, MediaService, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, POLICY_T_TAG, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, DEFAULTS, makeCensor, DEFAULT_REDACT_PATHS, createSlidingWindowLimiter, getProtocolProgramId, getProtocolConfig, LIMITS, calculateProtocolFee, BoundedSet, KIND_JOB_FEEDBACK, NATIVE_SOL } from '@elisym/sdk';
5
- import { ElisymYamlSchema, resolveInHome, resolveInProject, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, listAgents, loadAgent, writeYaml, agentPaths, readMediaCache, loadPoliciesFromDir, lookupCachedUrl, newCacheEntry, writeMediaCache } from '@elisym/sdk/agent-store';
5
+ import { SolanaPaymentStrategy, validateAgentName, RELAYS, ElisymIdentity, formatSol, formatAssetAmount, USDC_SOLANA_DEVNET, ElisymClient, MediaService, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, POLICY_T_TAG, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, DEFAULTS, makeCensor, DEFAULT_REDACT_PATHS, createSlidingWindowLimiter, getProtocolProgramId, getProtocolConfig, utf8ByteLength, LIMITS, calculateProtocolFee, decodeJobPayload, BoundedSet, KIND_JOB_FEEDBACK, NATIVE_SOL } from '@elisym/sdk';
6
+ import { ElisymYamlSchema, resolveInHome, resolveInProject, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, listAgents, loadAgent, writeYaml, agentPaths, readMediaCache, loadPoliciesFromDir, ensureGitignoreHasIrohEntry, lookupCachedUrl, newCacheEntry, writeMediaCache } from '@elisym/sdk/agent-store';
6
7
  import { isAddress, createSolanaRpc, address } from '@solana/kit';
7
8
  import { generateSecretKey, getPublicKey, nip19, verifyEvent } from 'nostr-tools';
8
9
  import YAML from 'yaml';
9
10
  import { Command } from 'commander';
10
11
  import { createHash } from 'node:crypto';
11
- import { LlmHealthMonitor, startLlmRecovery, createFreeLlmLimiterSet, ScriptBillingExhaustedError, FREE_LLM_GLOBAL_KEY, freeLlmCustomerKey, LlmHealthError } from '@elisym/sdk/llm-health';
12
+ import { LlmHealthMonitor, startLlmRecovery, createFreeLlmLimiterSet, ScriptBillingExhaustedError, ScriptExecutionError, FREE_LLM_GLOBAL_KEY, freeLlmCustomerKey, LlmHealthError } from '@elisym/sdk/llm-health';
13
+ import { createIrohTransport } from '@elisym/sdk/node';
12
14
  import { lookup } from 'node:dns/promises';
13
15
  import { Socket } from 'node:net';
14
16
  import pino from 'pino';
17
+ import { mkdtemp, rm } from 'node:fs/promises';
18
+ import { tmpdir } from 'node:os';
15
19
  import pLimit from 'p-limit';
16
- import { parseSkillMd, validateSkillFrontmatter, resolveInsidePath, DEFAULT_SCRIPT_TIMEOUT_MS, StaticScriptSkill as StaticScriptSkill$1, DynamicScriptSkill as DynamicScriptSkill$1, StaticFileSkill as StaticFileSkill$1, ScriptSkill as ScriptSkill$1 } from '@elisym/sdk/skills';
20
+ import { parseSkillMd, validateSkillFrontmatter, resolveInsidePath, DEFAULT_SCRIPT_TIMEOUT_MS, DynamicScriptSkill as DynamicScriptSkill$1, StaticScriptSkill as StaticScriptSkill$1, StaticFileSkill as StaticFileSkill$1, ScriptSkill as ScriptSkill$1 } from '@elisym/sdk/skills';
17
21
  import { fileURLToPath } from 'node:url';
18
22
 
19
23
  var __defProp = Object.defineProperty;
@@ -25,8 +29,6 @@ var __export = (target, all) => {
25
29
  for (var name in all)
26
30
  __defProp(target, name, { get: all[name], enumerable: true });
27
31
  };
28
-
29
- // src/llm/providers/http.ts
30
32
  function resolveLlmTimeoutMs() {
31
33
  const raw = process.env.ELISYM_LLM_TIMEOUT_MS;
32
34
  if (raw === void 0) {
@@ -71,12 +73,53 @@ async function fetchWithTimeout(url, init, signal) {
71
73
  const timer = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS);
72
74
  const onAbort = () => controller.abort();
73
75
  signal?.addEventListener("abort", onAbort, { once: true });
74
- try {
75
- return await fetch(url, { ...init, signal: controller.signal });
76
- } finally {
76
+ let toreDown = false;
77
+ const teardown = () => {
78
+ if (toreDown) {
79
+ return;
80
+ }
81
+ toreDown = true;
77
82
  clearTimeout(timer);
78
83
  signal?.removeEventListener("abort", onAbort);
84
+ };
85
+ let response;
86
+ try {
87
+ response = await fetch(url, { ...init, signal: controller.signal });
88
+ } catch (error) {
89
+ teardown();
90
+ throw error;
91
+ }
92
+ const body = response.body;
93
+ if (!body || typeof body.getReader !== "function") {
94
+ teardown();
95
+ return response;
79
96
  }
97
+ const reader = body.getReader();
98
+ const tappedStream = new ReadableStream({
99
+ async pull(streamController) {
100
+ try {
101
+ const { done, value } = await reader.read();
102
+ if (done) {
103
+ teardown();
104
+ streamController.close();
105
+ return;
106
+ }
107
+ streamController.enqueue(value);
108
+ } catch (error) {
109
+ teardown();
110
+ streamController.error(error);
111
+ }
112
+ },
113
+ cancel(reason) {
114
+ teardown();
115
+ return reader.cancel(reason);
116
+ }
117
+ });
118
+ return new Response(tappedStream, {
119
+ status: response.status,
120
+ statusText: response.statusText,
121
+ headers: response.headers
122
+ });
80
123
  }
81
124
  async function fetchWithRetry(url, init, signal) {
82
125
  for (let attempt = 0; ; attempt++) {
@@ -1721,6 +1764,8 @@ async function fetchUsdcBalance(rpc, owner) {
1721
1764
  return 0n;
1722
1765
  }
1723
1766
  }
1767
+ var LEDGER_DIR_MODE = 448;
1768
+ var LEDGER_FILE_MODE = 384;
1724
1769
  var VALID_TRANSITIONS = {
1725
1770
  paid: ["executed", "failed"],
1726
1771
  executed: ["delivered", "failed"],
@@ -1750,7 +1795,9 @@ var JobLedger = class {
1750
1795
  if (e?.code !== "ENOENT") {
1751
1796
  console.warn(` ! Ledger load warning: ${e?.message ?? "unknown error"}`);
1752
1797
  try {
1753
- renameSync(this.path, this.path + ".corrupt." + Date.now());
1798
+ const backupPath = this.path + ".corrupt." + Date.now();
1799
+ renameSync(this.path, backupPath);
1800
+ chmodSync(backupPath, LEDGER_FILE_MODE);
1754
1801
  } catch {
1755
1802
  }
1756
1803
  }
@@ -1758,11 +1805,12 @@ var JobLedger = class {
1758
1805
  }
1759
1806
  flush() {
1760
1807
  const dir = dirname(this.path);
1761
- mkdirSync(dir, { recursive: true });
1808
+ mkdirSync(dir, { recursive: true, mode: LEDGER_DIR_MODE });
1762
1809
  const obj = Object.fromEntries(this.entries);
1763
1810
  const tmp = this.path + ".tmp";
1764
- writeFileSync(tmp, JSON.stringify(obj, null, 2));
1811
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: LEDGER_FILE_MODE });
1765
1812
  renameSync(tmp, this.path);
1813
+ chmodSync(this.path, LEDGER_FILE_MODE);
1766
1814
  }
1767
1815
  recordPaid(entry) {
1768
1816
  if (this.entries.has(entry.job_id)) {
@@ -1802,6 +1850,20 @@ var JobLedger = class {
1802
1850
  this.flush();
1803
1851
  }
1804
1852
  }
1853
+ /**
1854
+ * Record the JSON-serialized file-result descriptor. Survives later
1855
+ * `markDelivered`/`markFailed` (which only null `result`).
1856
+ */
1857
+ recordAttachment(jobId, fields) {
1858
+ const entry = this.entries.get(jobId);
1859
+ if (!entry) {
1860
+ return;
1861
+ }
1862
+ if (fields.resultAttachment !== void 0) {
1863
+ entry.result_attachment = fields.resultAttachment;
1864
+ }
1865
+ this.flush();
1866
+ }
1805
1867
  markDelivered(jobId) {
1806
1868
  const entry = this.transition(jobId, "delivered");
1807
1869
  if (entry) {
@@ -1936,6 +1998,9 @@ function resolveProviderApiKey(input) {
1936
1998
  error: `Provider "${provider}" needs an API key (required by skill(s): ${skillList}). Set secrets.llm_api_keys.${provider} via 'npx @elisym/cli profile <agent>' or export ${descriptor.envVar}.`
1937
1999
  };
1938
2000
  }
2001
+ function sanitizeForTerminal(value) {
2002
+ return value.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
2003
+ }
1939
2004
  function resolveLevel(options) {
1940
2005
  if (options.level) {
1941
2006
  return options.level;
@@ -1974,7 +2039,7 @@ function createLogger(options = {}) {
1974
2039
  logger = pino(baseOptions, pino.destination(2));
1975
2040
  }
1976
2041
  function logWithIndent(line) {
1977
- process.stdout.write(` ${line}
2042
+ process.stdout.write(` ${sanitizeForTerminal(line)}
1978
2043
  `);
1979
2044
  }
1980
2045
  return {
@@ -2026,6 +2091,22 @@ var ExecutionBudgetExceededError = class extends Error {
2026
2091
  this.name = "ExecutionBudgetExceededError";
2027
2092
  }
2028
2093
  };
2094
+ var CUSTOMER_SAFE_MESSAGE_PREFIXES = ["Input too long", "No skill matched", "Payment timeout"];
2095
+ function customerSafeMessage(error) {
2096
+ if (error instanceof AgentUnavailableError || error instanceof ExecutionBudgetExceededError) {
2097
+ return error.message;
2098
+ }
2099
+ if (error instanceof ScriptExecutionError) {
2100
+ return error.message;
2101
+ }
2102
+ if (error instanceof ScriptBillingExhaustedError) {
2103
+ return AGENT_UNAVAILABLE_MESSAGE;
2104
+ }
2105
+ if (error instanceof Error && CUSTOMER_SAFE_MESSAGE_PREFIXES.some((prefix) => error.message.startsWith(prefix))) {
2106
+ return error.message;
2107
+ }
2108
+ return "Internal processing error";
2109
+ }
2029
2110
  function bodyLooksLikeBilling3(body) {
2030
2111
  const lower = body.toLowerCase();
2031
2112
  return BILLING_BODY_MARKERS3.some((marker) => lower.includes(marker));
@@ -2056,7 +2137,7 @@ var PAID_GLOBAL_MAX_JOBS_PER_WINDOW = 2e3;
2056
2137
  var MAX_TRACKED_CUSTOMERS = 1e3;
2057
2138
  var GLOBAL_LIMITER_KEY = "__global__";
2058
2139
  var AgentRuntime = class {
2059
- constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor) {
2140
+ constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor, irohTransport) {
2060
2141
  this.transport = transport;
2061
2142
  this.skills = skills;
2062
2143
  this.skillCtx = skillCtx;
@@ -2064,6 +2145,7 @@ var AgentRuntime = class {
2064
2145
  this.ledger = ledger;
2065
2146
  this.callbacks = callbacks;
2066
2147
  this.healthMonitor = healthMonitor;
2148
+ this.irohTransport = irohTransport;
2067
2149
  this.limit = pLimit(config.maxConcurrentJobs);
2068
2150
  this.maxQueueSize = config.maxQueueSize ?? config.maxConcurrentJobs * 10;
2069
2151
  }
@@ -2208,7 +2290,14 @@ var AgentRuntime = class {
2208
2290
  return true;
2209
2291
  }
2210
2292
  if (skill.mode !== "llm") {
2211
- const message = err instanceof Error ? err.message : String(err);
2293
+ let message;
2294
+ if (err instanceof ScriptExecutionError) {
2295
+ message = err.detail;
2296
+ } else if (err instanceof Error) {
2297
+ message = err.message;
2298
+ } else {
2299
+ message = String(err);
2300
+ }
2212
2301
  const provider = skill.llmOverride?.provider;
2213
2302
  const model = skill.llmOverride?.model;
2214
2303
  if (!provider || !model) {
@@ -2293,7 +2382,7 @@ var AgentRuntime = class {
2293
2382
  });
2294
2383
  return;
2295
2384
  }
2296
- const isFreeLlm = matched?.mode === "llm" && matched.priceSubunits === 0;
2385
+ const isFreeLlm = matched?.priceSubunits === 0 && (matched.mode === "llm" || resolveHealthPair(matched) !== null);
2297
2386
  let perCustomerLimiter;
2298
2387
  let perSkillKey;
2299
2388
  if (isFreeLlm && matched) {
@@ -2340,6 +2429,11 @@ var AgentRuntime = class {
2340
2429
  shuttingDown = true;
2341
2430
  log("Shutting down...");
2342
2431
  this.stop();
2432
+ const shutdownNode = this.irohTransport?.shutdown() ?? Promise.resolve();
2433
+ void shutdownNode.then(
2434
+ () => process.exit(0),
2435
+ () => process.exit(0)
2436
+ );
2343
2437
  setTimeout(() => process.exit(0), 3e3).unref();
2344
2438
  };
2345
2439
  process.on("SIGINT", onSignal);
@@ -2413,15 +2507,9 @@ var AgentRuntime = class {
2413
2507
  `[${job.jobId.slice(0, 8)}] Keeping status=paid; recovery will re-execute when LLM pair recovers (24h cutoff).`
2414
2508
  );
2415
2509
  }
2416
- this.callbacks.onJobError?.(job.jobId, e.message);
2417
- let safeMessage;
2418
- if (e instanceof AgentUnavailableError) {
2419
- safeMessage = e.message;
2420
- } else if (e.message?.includes("API")) {
2421
- safeMessage = "Internal processing error";
2422
- } else {
2423
- safeMessage = e.message ?? "Unknown error";
2424
- }
2510
+ const operatorMessage = e instanceof ScriptExecutionError ? `${e.message}: ${e.detail}` : e.message ?? "Unknown error";
2511
+ this.callbacks.onJobError?.(job.jobId, operatorMessage);
2512
+ const safeMessage = customerSafeMessage(e);
2425
2513
  await this.transport.sendFeedback(job, { type: "error", message: safeMessage }).catch(() => {
2426
2514
  });
2427
2515
  } finally {
@@ -2431,8 +2519,9 @@ var AgentRuntime = class {
2431
2519
  /** Core job processing logic - payment, skill execution, result delivery. */
2432
2520
  async executeJob(job, signal) {
2433
2521
  const log = this.callbacks.onLog ?? console.log;
2434
- if (job.input.length > LIMITS.MAX_INPUT_LENGTH) {
2435
- throw new Error(`Input too long: ${job.input.length} chars (max ${LIMITS.MAX_INPUT_LENGTH})`);
2522
+ const inputBytes = utf8ByteLength(job.input);
2523
+ if (inputBytes > LIMITS.MAX_INPUT_LENGTH) {
2524
+ throw new Error(`Input too long: ${inputBytes} bytes (max ${LIMITS.MAX_INPUT_LENGTH})`);
2436
2525
  }
2437
2526
  const matched = this.skills.route(job.tags);
2438
2527
  const healthPair = resolveHealthPair(matched);
@@ -2450,6 +2539,12 @@ var AgentRuntime = class {
2450
2539
  return;
2451
2540
  }
2452
2541
  }
2542
+ if (job.attachment !== void 0 && resolveJobPrice(job.tags, this.skills) === 0) {
2543
+ log(`[${job.jobId.slice(0, 8)}] Rejecting file input on a free skill`);
2544
+ await this.transport.sendFeedback(job, { type: "error", message: "File inputs require a paid skill." }).catch(() => {
2545
+ });
2546
+ return;
2547
+ }
2453
2548
  const jobPrice = resolveJobPrice(job.tags, this.skills);
2454
2549
  const jobAsset = resolveJobAsset(job.tags, this.skills);
2455
2550
  let netAmount;
@@ -2478,6 +2573,7 @@ var AgentRuntime = class {
2478
2573
  );
2479
2574
  this.callbacks.onPaymentReceived?.(job.jobId, netAmount);
2480
2575
  }
2576
+ const inputFile = await this.resolveInputFile(job.attachment);
2481
2577
  await this.transport.sendFeedback(job, { type: "processing" }).catch(() => {
2482
2578
  });
2483
2579
  const skill = this.skills.route(job.tags);
@@ -2501,10 +2597,11 @@ var AgentRuntime = class {
2501
2597
  try {
2502
2598
  const execPromise = skill.execute(
2503
2599
  {
2504
- data: job.input,
2600
+ data: inputFile?.inlineText ?? job.input,
2505
2601
  inputType: job.inputType,
2506
2602
  tags: job.tags,
2507
- jobId: job.jobId
2603
+ jobId: job.jobId,
2604
+ filePath: inputFile?.filePath
2508
2605
  },
2509
2606
  { ...this.skillCtx, signal: execAbort.signal }
2510
2607
  );
@@ -2541,14 +2638,145 @@ var AgentRuntime = class {
2541
2638
  if (signal) {
2542
2639
  signal.removeEventListener("abort", onOuterAbort);
2543
2640
  }
2641
+ if (inputFile) {
2642
+ await inputFile.cleanup().catch(() => {
2643
+ });
2644
+ }
2544
2645
  }
2545
- this.ledger.markExecuted(job.jobId, output.data);
2646
+ const { attachment, deliveredContent } = await this.buildResultAttachment(job.jobId, output);
2647
+ this.ledger.markExecuted(job.jobId, deliveredContent);
2546
2648
  log(`[${job.jobId.slice(0, 8)}] Skill completed, delivering result`);
2547
- const eventId = await this.transport.deliverResult(job, output.data, netAmount);
2649
+ const eventId = await this.transport.deliverResult(
2650
+ job,
2651
+ deliveredContent,
2652
+ netAmount,
2653
+ attachment
2654
+ );
2548
2655
  this.ledger.markDelivered(job.jobId);
2549
2656
  log(`[${job.jobId.slice(0, 8)}] Delivered: ${eventId.slice(0, 16)}...`);
2550
2657
  this.callbacks.onJobCompleted?.(job.jobId, output.data);
2551
2658
  }
2659
+ /**
2660
+ * Decide how a skill's result travels: inline text, a seeded file, or seeded
2661
+ * large text. Returns the attachment descriptor (if any) PLUS the content to
2662
+ * deliver on the wire - which is the EMPTY string whenever the payload was
2663
+ * spilled to iroh (file or large text), so the encrypted result event carries
2664
+ * only the ticket and never re-trips the NIP-44 byte cap. The single
2665
+ * `deliveredContent` value must drive `markExecuted`, `deliverResult`, and the
2666
+ * recovery re-delivery alike (so a crash-recovered spill re-delivers empty too).
2667
+ * Persists the descriptor so recovery can re-share + re-deliver.
2668
+ *
2669
+ * Must run BEFORE `markExecuted` so a seed failure leaves the job `paid` and
2670
+ * recovery re-executes (rather than re-delivering a dead ticket). Runs after the
2671
+ * `skill.execute` budget window, so a slow seed never trips `max_execution_secs`.
2672
+ */
2673
+ async buildResultAttachment(jobId, output) {
2674
+ if (output.filePath !== void 0) {
2675
+ try {
2676
+ if (!this.irohTransport) {
2677
+ throw new Error("Skill produced a file result but iroh transport is unavailable.");
2678
+ }
2679
+ const seeded = await this.irohTransport.seedPath(output.filePath);
2680
+ const attachment = {
2681
+ name: basename(output.filePath),
2682
+ size: seeded.size,
2683
+ mime: output.outputMime ?? "application/octet-stream",
2684
+ transports: [{ kind: "iroh", ticket: seeded.ticket }]
2685
+ };
2686
+ this.ledger.recordAttachment(jobId, { resultAttachment: JSON.stringify(attachment) });
2687
+ return { attachment, deliveredContent: output.data };
2688
+ } finally {
2689
+ await output.cleanup?.().catch(() => {
2690
+ });
2691
+ }
2692
+ }
2693
+ if (utf8ByteLength(output.data) > LIMITS.MAX_ENCRYPTED_INLINE_BYTES) {
2694
+ if (!this.irohTransport) {
2695
+ throw new Error("Result is too large to deliver inline and iroh transport is unavailable.");
2696
+ }
2697
+ const seeded = await this.irohTransport.seedBytes(Buffer.from(output.data, "utf8"));
2698
+ const attachment = {
2699
+ name: "result.txt",
2700
+ size: seeded.size,
2701
+ mime: "text/plain",
2702
+ transports: [{ kind: "iroh", ticket: seeded.ticket }]
2703
+ };
2704
+ this.ledger.recordAttachment(jobId, { resultAttachment: JSON.stringify(attachment) });
2705
+ return { attachment, deliveredContent: "" };
2706
+ }
2707
+ return { attachment: void 0, deliveredContent: output.data };
2708
+ }
2709
+ /**
2710
+ * Rebuild a file-result attachment for crash-recovery re-delivery: re-share the
2711
+ * blob from the persistent store to mint a fresh ticket (the original ticket's
2712
+ * direct addresses go stale across a restart). Returns undefined for a text
2713
+ * result; throws if the blob is gone (caller marks the job failed).
2714
+ */
2715
+ async reShareResultAttachment(resultAttachmentJson) {
2716
+ if (resultAttachmentJson === void 0) {
2717
+ return void 0;
2718
+ }
2719
+ if (!this.irohTransport) {
2720
+ throw new Error("Cannot recover a file result: iroh transport is unavailable.");
2721
+ }
2722
+ const stored = JSON.parse(resultAttachmentJson);
2723
+ const irohTransport = stored.transports.find((transport) => transport.kind === "iroh");
2724
+ if (!irohTransport) {
2725
+ throw new Error("Stored result attachment has no iroh transport.");
2726
+ }
2727
+ const freshTicket = await this.irohTransport.reShare(irohTransport.ticket);
2728
+ return { ...stored, transports: [{ kind: "iroh", ticket: freshTicket }] };
2729
+ }
2730
+ /**
2731
+ * Resolve a job's file/large-text input (when it carries an attachment) via iroh.
2732
+ * Only called post-payment - free + attachment is rejected in the `executeJob`
2733
+ * preflight. Two outcomes, both with a `cleanup` callback:
2734
+ * - `text/*` within `MAX_REINLINE_TEXT_BYTES`: fetched into memory and returned
2735
+ * as `inlineText`, transparently re-inlined into `SkillInput.data` so skills
2736
+ * are unchanged (cleanup is a no-op - nothing on disk).
2737
+ * - binary, or text over the ceiling: streamed to a fixed `input` name inside a
2738
+ * unique `mkdtemp` dir (the untrusted attachment `name` never touches the path),
2739
+ * returned as `filePath`.
2740
+ * `fetchToBytes`/`fetchToPath` enforce the real cap on the BLAKE3-verified size, so
2741
+ * an incorrect declared `size` cannot exceed the in-memory ceiling.
2742
+ */
2743
+ async resolveInputFile(attachment) {
2744
+ if (attachment === void 0) {
2745
+ return void 0;
2746
+ }
2747
+ if (!this.irohTransport) {
2748
+ throw new Error("Job carries a file input but iroh transport is unavailable.");
2749
+ }
2750
+ const irohTransport = attachment.transports.find((transport) => transport.kind === "iroh");
2751
+ if (!irohTransport) {
2752
+ throw new Error("File input has no iroh transport.");
2753
+ }
2754
+ if (attachment.mime.startsWith("text/") && attachment.size <= LIMITS.MAX_REINLINE_TEXT_BYTES) {
2755
+ const bytes = await this.irohTransport.fetchToBytes(irohTransport.ticket, {
2756
+ maxBytes: LIMITS.MAX_REINLINE_TEXT_BYTES
2757
+ });
2758
+ return {
2759
+ inlineText: Buffer.from(bytes).toString("utf8"),
2760
+ cleanup: async () => {
2761
+ }
2762
+ };
2763
+ }
2764
+ const dir = await mkdtemp(join(tmpdir(), "elisym-job-"));
2765
+ const filePath = join(dir, "input");
2766
+ try {
2767
+ await this.irohTransport.fetchToPath(irohTransport.ticket, filePath);
2768
+ } catch (error) {
2769
+ await rm(dir, { recursive: true, force: true }).catch(() => {
2770
+ });
2771
+ throw error;
2772
+ }
2773
+ return {
2774
+ filePath,
2775
+ cleanup: async () => {
2776
+ await rm(dir, { recursive: true, force: true });
2777
+ }
2778
+ };
2779
+ }
2552
2780
  /**
2553
2781
  * Collect payment for a job. Creates payment request, sends PaymentRequired feedback,
2554
2782
  * polls for on-chain confirmation. Aborts if signal fires.
@@ -2789,6 +3017,9 @@ var AgentRuntime = class {
2789
3017
  const recoveryAbort = new AbortController();
2790
3018
  this.jobAbortControllers.add(recoveryAbort);
2791
3019
  try {
3020
+ if (entry.raw_event_json === void 0) {
3021
+ return;
3022
+ }
2792
3023
  const rawEvent = JSON.parse(entry.raw_event_json);
2793
3024
  const fakeJob = {
2794
3025
  jobId: entry.job_id,
@@ -2801,7 +3032,15 @@ var AgentRuntime = class {
2801
3032
  };
2802
3033
  if (entry.status === "executed" && entry.result !== void 0) {
2803
3034
  this.ledger.incrementRetry(entry.job_id);
2804
- await this.transport.deliverResult(fakeJob, entry.result, entry.net_amount);
3035
+ let attachment;
3036
+ try {
3037
+ attachment = await this.reShareResultAttachment(entry.result_attachment);
3038
+ } catch {
3039
+ log(`[${entry.job_id.slice(0, 8)}] Recovery: result blob unavailable, marking failed`);
3040
+ this.ledger.markFailed(entry.job_id);
3041
+ return;
3042
+ }
3043
+ await this.transport.deliverResult(fakeJob, entry.result, entry.net_amount, attachment);
2805
3044
  this.ledger.markDelivered(entry.job_id);
2806
3045
  log(`[${entry.job_id.slice(0, 8)}] Recovery: re-delivered`);
2807
3046
  } else if (entry.status === "paid") {
@@ -2844,16 +3083,27 @@ var AgentRuntime = class {
2844
3083
  return;
2845
3084
  }
2846
3085
  }
3086
+ let recoveryInputFile;
3087
+ try {
3088
+ recoveryInputFile = await this.resolveInputFile(
3089
+ decodeJobPayload(rawEvent.content).attachment
3090
+ );
3091
+ } catch {
3092
+ log(`[${entry.job_id.slice(0, 8)}] Recovery: input file unavailable, marking failed`);
3093
+ this.ledger.markFailed(entry.job_id);
3094
+ return;
3095
+ }
2847
3096
  const recoveryBudgetMs = this.resolveExecutionBudgetMs(skill);
2848
3097
  let budgetTimer;
2849
3098
  let output;
2850
3099
  try {
2851
3100
  const execPromise = skill.execute(
2852
3101
  {
2853
- data: entry.input,
3102
+ data: recoveryInputFile?.inlineText ?? entry.input,
2854
3103
  inputType: entry.input_type,
2855
3104
  tags: entry.tags,
2856
- jobId: entry.job_id
3105
+ jobId: entry.job_id,
3106
+ filePath: recoveryInputFile?.filePath
2857
3107
  },
2858
3108
  { ...this.skillCtx, signal: recoveryAbort.signal }
2859
3109
  );
@@ -2876,9 +3126,22 @@ var AgentRuntime = class {
2876
3126
  if (budgetTimer) {
2877
3127
  clearTimeout(budgetTimer);
2878
3128
  }
3129
+ if (recoveryInputFile) {
3130
+ await recoveryInputFile.cleanup().catch(() => {
3131
+ });
3132
+ }
2879
3133
  }
2880
- this.ledger.markExecuted(entry.job_id, output.data);
2881
- await this.transport.deliverResult(fakeJob, output.data, entry.net_amount);
3134
+ const { attachment: resultAttachment, deliveredContent } = await this.buildResultAttachment(
3135
+ entry.job_id,
3136
+ output
3137
+ );
3138
+ this.ledger.markExecuted(entry.job_id, deliveredContent);
3139
+ await this.transport.deliverResult(
3140
+ fakeJob,
3141
+ deliveredContent,
3142
+ entry.net_amount,
3143
+ resultAttachment
3144
+ );
2882
3145
  this.ledger.markDelivered(entry.job_id);
2883
3146
  log(`[${entry.job_id.slice(0, 8)}] Recovery: re-executed and delivered`);
2884
3147
  }
@@ -3068,6 +3331,11 @@ var DynamicScriptSkill = class {
3068
3331
  imageFile;
3069
3332
  dir;
3070
3333
  llmOverride;
3334
+ // Discovery hints surfaced in the published capability card (buildCard).
3335
+ // `outputMime` is also forwarded to the inner SDK runner (it labels the file
3336
+ // result); `inputMime` is publish-time metadata only.
3337
+ inputMime;
3338
+ outputMime;
3071
3339
  inner;
3072
3340
  constructor(params) {
3073
3341
  this.name = params.name;
@@ -3079,6 +3347,8 @@ var DynamicScriptSkill = class {
3079
3347
  this.imageFile = params.imageFile;
3080
3348
  this.dir = params.dir;
3081
3349
  this.llmOverride = params.llmOverride;
3350
+ this.inputMime = params.inputMime;
3351
+ this.outputMime = params.outputMime;
3082
3352
  this.inner = new DynamicScriptSkill$1({
3083
3353
  name: params.name,
3084
3354
  description: params.description,
@@ -3090,7 +3360,8 @@ var DynamicScriptSkill = class {
3090
3360
  scriptTimeoutMs: params.scriptTimeoutMs,
3091
3361
  scriptEnv: params.scriptEnv,
3092
3362
  image: params.image,
3093
- imageFile: params.imageFile
3363
+ imageFile: params.imageFile,
3364
+ outputMime: params.outputMime
3094
3365
  });
3095
3366
  }
3096
3367
  async execute(input, ctx) {
@@ -3165,6 +3436,13 @@ var ScriptSkill = class {
3165
3436
 
3166
3437
  // src/skill/loader.ts
3167
3438
  function buildCliSkill(parsed, entryPath, scriptEnv) {
3439
+ let safeImageFile = parsed.imageFile;
3440
+ if (safeImageFile !== void 0 && resolveInsidePath(entryPath, safeImageFile) === null) {
3441
+ console.warn(
3442
+ `SKILL.md "${parsed.name}": ignoring "image_file" that resolves outside the skill directory: ${safeImageFile}`
3443
+ );
3444
+ safeImageFile = void 0;
3445
+ }
3168
3446
  let skill;
3169
3447
  switch (parsed.mode) {
3170
3448
  case "llm":
@@ -3175,7 +3453,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3175
3453
  Number(parsed.priceSubunits),
3176
3454
  parsed.asset,
3177
3455
  parsed.image,
3178
- parsed.imageFile,
3456
+ safeImageFile,
3179
3457
  entryPath,
3180
3458
  parsed.systemPrompt,
3181
3459
  parsed.tools,
@@ -3203,7 +3481,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3203
3481
  asset: parsed.asset,
3204
3482
  outputFilePath,
3205
3483
  image: parsed.image,
3206
- imageFile: parsed.imageFile,
3484
+ imageFile: safeImageFile,
3207
3485
  dir: entryPath,
3208
3486
  llmOverride: parsed.llmOverride
3209
3487
  });
@@ -3220,8 +3498,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3220
3498
  if (!scriptPath) {
3221
3499
  throw new Error(`SKILL.md "${parsed.name}": "script" must stay inside the skill directory`);
3222
3500
  }
3223
- const Ctor = parsed.mode === "static-script" ? StaticScriptSkill : DynamicScriptSkill;
3224
- skill = new Ctor({
3501
+ const scriptParams = {
3225
3502
  name: parsed.name,
3226
3503
  description: parsed.description,
3227
3504
  capabilities: parsed.capabilities,
@@ -3232,10 +3509,15 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3232
3509
  scriptTimeoutMs: parsed.scriptTimeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS,
3233
3510
  scriptEnv,
3234
3511
  image: parsed.image,
3235
- imageFile: parsed.imageFile,
3512
+ imageFile: safeImageFile,
3236
3513
  dir: entryPath,
3237
3514
  llmOverride: parsed.llmOverride
3238
- });
3515
+ };
3516
+ skill = parsed.mode === "dynamic-script" ? new DynamicScriptSkill({
3517
+ ...scriptParams,
3518
+ outputMime: parsed.outputMime,
3519
+ inputMime: parsed.inputMime
3520
+ }) : new StaticScriptSkill(scriptParams);
3239
3521
  break;
3240
3522
  }
3241
3523
  }
@@ -3282,6 +3564,13 @@ function loadSkillsFromDir(skillsDir, options = {}) {
3282
3564
  function isEncrypted(event) {
3283
3565
  return event.tags.some((t) => t[0] === "encrypted" && t[1] === "nip44");
3284
3566
  }
3567
+ function parseBidTag(value) {
3568
+ if (value === void 0) {
3569
+ return void 0;
3570
+ }
3571
+ const parsed = parseInt(value, 10);
3572
+ return Number.isFinite(parsed) ? parsed : void 0;
3573
+ }
3285
3574
  var HEALTH_CHECK_IDLE_MS = 30 * 60 * 1e3;
3286
3575
  var NostrTransport = class {
3287
3576
  constructor(client, identity, kindOffsets) {
@@ -3311,7 +3600,7 @@ var NostrTransport = class {
3311
3600
  if (!hasElisym) {
3312
3601
  return;
3313
3602
  }
3314
- const tags = event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
3603
+ const tags = event.tags.filter((tag) => tag[0] === "t").map((tag) => tag[1]).filter((value) => typeof value === "string");
3315
3604
  const bidTag = event.tags.find((t) => t[0] === "bid");
3316
3605
  const encrypted = isEncrypted(event);
3317
3606
  const iTag = event.tags.find((t) => t[0] === "i");
@@ -3319,16 +3608,26 @@ var NostrTransport = class {
3319
3608
  if (iTag?.[2]) {
3320
3609
  inputType = iTag[2];
3321
3610
  }
3322
- const input = encrypted ? event.content : iTag?.[1] ?? event.content;
3611
+ const rawInput = encrypted ? event.content : iTag?.[1] ?? event.content;
3612
+ let input;
3613
+ let attachment;
3614
+ try {
3615
+ const decoded = decodeJobPayload(rawInput);
3616
+ input = decoded.text ?? "";
3617
+ attachment = decoded.attachment;
3618
+ } catch {
3619
+ return;
3620
+ }
3323
3621
  onJob({
3324
3622
  jobId: event.id,
3325
3623
  input,
3326
3624
  inputType,
3327
3625
  tags,
3328
3626
  customerId: event.pubkey,
3329
- bid: bidTag?.[1] ? parseInt(bidTag[1], 10) : void 0,
3627
+ bid: parseBidTag(bidTag?.[1]),
3330
3628
  encrypted,
3331
- rawEvent: event
3629
+ rawEvent: event,
3630
+ attachment
3332
3631
  });
3333
3632
  }
3334
3633
  );
@@ -3379,6 +3678,9 @@ var NostrTransport = class {
3379
3678
  if (!verifyEvent(event)) {
3380
3679
  return;
3381
3680
  }
3681
+ if (event.pubkey !== customerPubkey) {
3682
+ return;
3683
+ }
3382
3684
  const status = event.tags.find((t) => t[0] === "status")?.[1];
3383
3685
  if (status !== "payment-completed") {
3384
3686
  return;
@@ -3423,13 +3725,15 @@ var NostrTransport = class {
3423
3725
  }
3424
3726
  }
3425
3727
  /** Deliver result to customer. Retries with exponential backoff via SDK. */
3426
- async deliverResult(job, content, amount, retries = 3) {
3728
+ async deliverResult(job, content, amount, attachment, retries = 3) {
3427
3729
  return this.client.marketplace.submitJobResultWithRetry(
3428
3730
  this.identity,
3429
3731
  job.rawEvent,
3430
3732
  content,
3431
3733
  amount,
3432
- retries
3734
+ retries,
3735
+ void 0,
3736
+ attachment
3433
3737
  );
3434
3738
  }
3435
3739
  /** Returns true if an event was received within the given idle window. */
@@ -3608,7 +3912,7 @@ async function cmdStart(nameArg, options = {}) {
3608
3912
  console.log(` Network ${walletNetwork}`);
3609
3913
  console.log(` Address ${solanaAddress}`);
3610
3914
  if (process.env.SOLANA_RPC_URL) {
3611
- console.log(` RPC ${process.env.SOLANA_RPC_URL} (custom)`);
3915
+ console.log(` RPC ${stripRpcSecrets(process.env.SOLANA_RPC_URL)} (custom)`);
3612
3916
  }
3613
3917
  console.log(` SOL ${formatSol(balance)} (${balance} lamports)`);
3614
3918
  console.log(` USDC ${formatAssetAmount(USDC_SOLANA_DEVNET, usdcBalance)}`);
@@ -3951,6 +4255,10 @@ async function cmdStart(nameArg, options = {}) {
3951
4255
  capabilities: skill.capabilities,
3952
4256
  image: skill.image,
3953
4257
  ...isStatic ? { static: true } : {},
4258
+ // File-exchange hints (dynamic-script only). `inputMime` lets clients that
4259
+ // cannot send files (the web app) gate the Buy button before payment.
4260
+ ...skill.inputMime ? { inputMime: skill.inputMime } : {},
4261
+ ...skill.outputMime ? { outputMime: skill.outputMime } : {},
3954
4262
  payment: solanaAddress ? {
3955
4263
  chain: "solana",
3956
4264
  network: walletNetwork,
@@ -4016,6 +4324,8 @@ async function cmdStart(nameArg, options = {}) {
4016
4324
  };
4017
4325
  const transport = new NostrTransport(client, identity, [DEFAULT_KIND_OFFSET]);
4018
4326
  const ledger = new JobLedger(paths.jobs);
4327
+ const irohTransport = createIrohTransport({ storePath: join(loaded.dir, ".iroh") });
4328
+ await ensureGitignoreHasIrohEntry(dirname(loaded.dir));
4019
4329
  const runtimeConfig = {
4020
4330
  paymentTimeoutSecs: DEFAULTS.PAYMENT_EXPIRY_SECS,
4021
4331
  maxConcurrentJobs: MAX_CONCURRENT_JOBS,
@@ -4068,7 +4378,7 @@ async function cmdStart(nameArg, options = {}) {
4068
4378
  ledger,
4069
4379
  {
4070
4380
  onJobReceived: (job) => {
4071
- const cap = job.tags.find((t) => t !== "elisym") ?? "unknown";
4381
+ const cap = sanitizeForTerminal(job.tags.find((t) => t !== "elisym") ?? "unknown");
4072
4382
  process.stdout.write(` [job] ${job.jobId.slice(0, 16)} | cap=${cap}
4073
4383
  `);
4074
4384
  logger.info({ event: "job_received", jobId: job.jobId, capability: cap });
@@ -4079,9 +4389,10 @@ async function cmdStart(nameArg, options = {}) {
4079
4389
  logger.info({ event: "job_delivered", jobId });
4080
4390
  },
4081
4391
  onJobError: (jobId, error) => {
4082
- process.stderr.write(` [job] ${jobId.slice(0, 16)} | error: ${error}
4392
+ const safeError = sanitizeForTerminal(error);
4393
+ process.stderr.write(` [job] ${jobId.slice(0, 16)} | error: ${safeError}
4083
4394
  `);
4084
- logger.error({ event: "job_error", jobId, error });
4395
+ logger.error({ event: "job_error", jobId, error: safeError });
4085
4396
  },
4086
4397
  onLog: diagLog,
4087
4398
  onStop: () => {
@@ -4089,16 +4400,25 @@ async function cmdStart(nameArg, options = {}) {
4089
4400
  llmHeartbeat?.stop();
4090
4401
  }
4091
4402
  },
4092
- healthMonitor
4403
+ healthMonitor,
4404
+ irohTransport
4093
4405
  );
4094
4406
  console.log(" * Running. Press Ctrl+C to stop.\n");
4095
4407
  await runtime.run();
4096
4408
  }
4409
+ var PUBLIC_SOLANA_RPC_HOSTS = /* @__PURE__ */ new Set([
4410
+ "api.devnet.solana.com",
4411
+ "api.mainnet-beta.solana.com",
4412
+ "api.testnet.solana.com"
4413
+ ]);
4097
4414
  function stripRpcSecrets(raw) {
4098
4415
  try {
4099
4416
  const parsed = new URL(raw);
4100
4417
  parsed.username = "";
4101
4418
  parsed.password = "";
4419
+ if (!PUBLIC_SOLANA_RPC_HOSTS.has(parsed.hostname)) {
4420
+ return `${parsed.protocol}//${parsed.host}/***`;
4421
+ }
4102
4422
  const marker = parsed.search.length > 0 ? "?***" : "";
4103
4423
  parsed.search = "";
4104
4424
  return `${parsed.toString()}${marker}`;