@elisym/cli 0.20.0 → 0.21.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.
package/dist/index.js CHANGED
@@ -2,19 +2,22 @@
2
2
  import { ReadableStream } from 'node:stream/web';
3
3
  import { readFileSync, existsSync, readdirSync, statSync, renameSync, chmodSync, mkdirSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join, resolve, basename, relative, sep } from 'node:path';
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, LIMITS, calculateProtocolFee, BoundedSet, KIND_JOB_FEEDBACK, NATIVE_SOL } from '@elisym/sdk';
6
- 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';
7
7
  import { isAddress, createSolanaRpc, address } from '@solana/kit';
8
8
  import { generateSecretKey, getPublicKey, nip19, verifyEvent } from 'nostr-tools';
9
9
  import YAML from 'yaml';
10
10
  import { Command } from 'commander';
11
11
  import { createHash } from 'node:crypto';
12
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';
13
14
  import { lookup } from 'node:dns/promises';
14
15
  import { Socket } from 'node:net';
15
16
  import pino from 'pino';
17
+ import { mkdtemp, rm } from 'node:fs/promises';
18
+ import { tmpdir } from 'node:os';
16
19
  import pLimit from 'p-limit';
17
- 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';
18
21
  import { fileURLToPath } from 'node:url';
19
22
 
20
23
  var __defProp = Object.defineProperty;
@@ -1847,6 +1850,20 @@ var JobLedger = class {
1847
1850
  this.flush();
1848
1851
  }
1849
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
+ }
1850
1867
  markDelivered(jobId) {
1851
1868
  const entry = this.transition(jobId, "delivered");
1852
1869
  if (entry) {
@@ -1981,6 +1998,9 @@ function resolveProviderApiKey(input) {
1981
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}.`
1982
1999
  };
1983
2000
  }
2001
+ function sanitizeForTerminal(value) {
2002
+ return value.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
2003
+ }
1984
2004
  function resolveLevel(options) {
1985
2005
  if (options.level) {
1986
2006
  return options.level;
@@ -2019,7 +2039,7 @@ function createLogger(options = {}) {
2019
2039
  logger = pino(baseOptions, pino.destination(2));
2020
2040
  }
2021
2041
  function logWithIndent(line) {
2022
- process.stdout.write(` ${line}
2042
+ process.stdout.write(` ${sanitizeForTerminal(line)}
2023
2043
  `);
2024
2044
  }
2025
2045
  return {
@@ -2117,7 +2137,7 @@ var PAID_GLOBAL_MAX_JOBS_PER_WINDOW = 2e3;
2117
2137
  var MAX_TRACKED_CUSTOMERS = 1e3;
2118
2138
  var GLOBAL_LIMITER_KEY = "__global__";
2119
2139
  var AgentRuntime = class {
2120
- constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor) {
2140
+ constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor, irohTransport) {
2121
2141
  this.transport = transport;
2122
2142
  this.skills = skills;
2123
2143
  this.skillCtx = skillCtx;
@@ -2125,6 +2145,7 @@ var AgentRuntime = class {
2125
2145
  this.ledger = ledger;
2126
2146
  this.callbacks = callbacks;
2127
2147
  this.healthMonitor = healthMonitor;
2148
+ this.irohTransport = irohTransport;
2128
2149
  this.limit = pLimit(config.maxConcurrentJobs);
2129
2150
  this.maxQueueSize = config.maxQueueSize ?? config.maxConcurrentJobs * 10;
2130
2151
  }
@@ -2361,7 +2382,7 @@ var AgentRuntime = class {
2361
2382
  });
2362
2383
  return;
2363
2384
  }
2364
- const isFreeLlm = matched?.mode === "llm" && matched.priceSubunits === 0;
2385
+ const isFreeLlm = matched?.priceSubunits === 0 && (matched.mode === "llm" || resolveHealthPair(matched) !== null);
2365
2386
  let perCustomerLimiter;
2366
2387
  let perSkillKey;
2367
2388
  if (isFreeLlm && matched) {
@@ -2408,6 +2429,11 @@ var AgentRuntime = class {
2408
2429
  shuttingDown = true;
2409
2430
  log("Shutting down...");
2410
2431
  this.stop();
2432
+ const shutdownNode = this.irohTransport?.shutdown() ?? Promise.resolve();
2433
+ void shutdownNode.then(
2434
+ () => process.exit(0),
2435
+ () => process.exit(0)
2436
+ );
2411
2437
  setTimeout(() => process.exit(0), 3e3).unref();
2412
2438
  };
2413
2439
  process.on("SIGINT", onSignal);
@@ -2493,8 +2519,9 @@ var AgentRuntime = class {
2493
2519
  /** Core job processing logic - payment, skill execution, result delivery. */
2494
2520
  async executeJob(job, signal) {
2495
2521
  const log = this.callbacks.onLog ?? console.log;
2496
- if (job.input.length > LIMITS.MAX_INPUT_LENGTH) {
2497
- 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})`);
2498
2525
  }
2499
2526
  const matched = this.skills.route(job.tags);
2500
2527
  const healthPair = resolveHealthPair(matched);
@@ -2512,6 +2539,12 @@ var AgentRuntime = class {
2512
2539
  return;
2513
2540
  }
2514
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
+ }
2515
2548
  const jobPrice = resolveJobPrice(job.tags, this.skills);
2516
2549
  const jobAsset = resolveJobAsset(job.tags, this.skills);
2517
2550
  let netAmount;
@@ -2540,6 +2573,7 @@ var AgentRuntime = class {
2540
2573
  );
2541
2574
  this.callbacks.onPaymentReceived?.(job.jobId, netAmount);
2542
2575
  }
2576
+ const inputFile = await this.resolveInputFile(job.attachment);
2543
2577
  await this.transport.sendFeedback(job, { type: "processing" }).catch(() => {
2544
2578
  });
2545
2579
  const skill = this.skills.route(job.tags);
@@ -2563,10 +2597,11 @@ var AgentRuntime = class {
2563
2597
  try {
2564
2598
  const execPromise = skill.execute(
2565
2599
  {
2566
- data: job.input,
2600
+ data: inputFile?.inlineText ?? job.input,
2567
2601
  inputType: job.inputType,
2568
2602
  tags: job.tags,
2569
- jobId: job.jobId
2603
+ jobId: job.jobId,
2604
+ filePath: inputFile?.filePath
2570
2605
  },
2571
2606
  { ...this.skillCtx, signal: execAbort.signal }
2572
2607
  );
@@ -2603,14 +2638,145 @@ var AgentRuntime = class {
2603
2638
  if (signal) {
2604
2639
  signal.removeEventListener("abort", onOuterAbort);
2605
2640
  }
2641
+ if (inputFile) {
2642
+ await inputFile.cleanup().catch(() => {
2643
+ });
2644
+ }
2606
2645
  }
2607
- this.ledger.markExecuted(job.jobId, output.data);
2646
+ const { attachment, deliveredContent } = await this.buildResultAttachment(job.jobId, output);
2647
+ this.ledger.markExecuted(job.jobId, deliveredContent);
2608
2648
  log(`[${job.jobId.slice(0, 8)}] Skill completed, delivering result`);
2609
- 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
+ );
2610
2655
  this.ledger.markDelivered(job.jobId);
2611
2656
  log(`[${job.jobId.slice(0, 8)}] Delivered: ${eventId.slice(0, 16)}...`);
2612
2657
  this.callbacks.onJobCompleted?.(job.jobId, output.data);
2613
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
+ }
2614
2780
  /**
2615
2781
  * Collect payment for a job. Creates payment request, sends PaymentRequired feedback,
2616
2782
  * polls for on-chain confirmation. Aborts if signal fires.
@@ -2652,6 +2818,8 @@ var AgentRuntime = class {
2652
2818
  }
2653
2819
  const deadlineMs = this.config.paymentTimeoutSecs * 1e3;
2654
2820
  const sigPathTimeoutMs = Math.min(deadlineMs, SIG_PATH_TIMEOUT_MS);
2821
+ let sigOutcome;
2822
+ let refOutcome;
2655
2823
  let result;
2656
2824
  try {
2657
2825
  result = await new Promise((resolve3, reject) => {
@@ -2684,6 +2852,7 @@ var AgentRuntime = class {
2684
2852
  return;
2685
2853
  }
2686
2854
  if (!sig) {
2855
+ sigOutcome = { gotSignature: false };
2687
2856
  lose({ verified: false }, "sig path: no payment-completed feedback");
2688
2857
  return;
2689
2858
  }
@@ -2696,33 +2865,32 @@ var AgentRuntime = class {
2696
2865
  win(verified);
2697
2866
  } else {
2698
2867
  const reason = verified.error ?? "unknown";
2868
+ sigOutcome = { gotSignature: true, error: reason };
2699
2869
  lose(verified, `sig path: not verified (${reason})`);
2700
2870
  }
2701
2871
  } catch (err) {
2702
- lose(
2703
- { verified: false },
2704
- `sig path error: ${err instanceof Error ? err.message : String(err)}`
2705
- );
2872
+ const message = err instanceof Error ? err.message : String(err);
2873
+ sigOutcome = { gotSignature: true, error: message };
2874
+ lose({ verified: false }, `sig path error: ${message}`);
2706
2875
  }
2707
- }).catch(
2708
- (err) => lose(
2709
- { verified: false },
2710
- `sig path error: ${err instanceof Error ? err.message : String(err)}`
2711
- )
2712
- );
2876
+ }).catch((err) => {
2877
+ const message = err instanceof Error ? err.message : String(err);
2878
+ sigOutcome = { gotSignature: false, error: message };
2879
+ lose({ verified: false }, `sig path error: ${message}`);
2880
+ });
2713
2881
  payment.verifyPayment(rpc, request, protocolConfig).then((verified) => {
2714
2882
  if (verified.verified) {
2715
2883
  win(verified);
2716
2884
  } else {
2717
2885
  const reason = verified.error ?? "unknown";
2886
+ refOutcome = { error: reason };
2718
2887
  lose(verified, `ref path: not verified (${reason})`);
2719
2888
  }
2720
- }).catch(
2721
- (err) => lose(
2722
- { verified: false },
2723
- `ref path error: ${err instanceof Error ? err.message : String(err)}`
2724
- )
2725
- );
2889
+ }).catch((err) => {
2890
+ const message = err instanceof Error ? err.message : String(err);
2891
+ refOutcome = { error: message };
2892
+ lose({ verified: false }, `ref path error: ${message}`);
2893
+ });
2726
2894
  const deadline = setTimeout(() => {
2727
2895
  if (settled) {
2728
2896
  return;
@@ -2763,9 +2931,18 @@ var AgentRuntime = class {
2763
2931
  if (result.verified) {
2764
2932
  return { netAmount, paymentRequest: requestJson };
2765
2933
  }
2766
- log(
2767
- `[${job.jobId.slice(0, 8)}] WARNING: Payment verification timed out. Customer may have paid on-chain. Check address ${this.config.solanaAddress} manually.`
2768
- );
2934
+ const refDefinitiveNoPay = refOutcome?.error === "No matching transaction found for reference key";
2935
+ const sigGotSignature = sigOutcome?.gotSignature === true;
2936
+ const customerAbandoned = !sigGotSignature && refDefinitiveNoPay;
2937
+ if (customerAbandoned) {
2938
+ log(
2939
+ `[${job.jobId.slice(0, 8)}] Payment not received; on-chain scan found no matching transaction - job abandoned by customer.`
2940
+ );
2941
+ } else {
2942
+ log(
2943
+ `[${job.jobId.slice(0, 8)}] WARNING: Payment verification timed out. Customer may have paid on-chain. Check address ${this.config.solanaAddress} manually.`
2944
+ );
2945
+ }
2769
2946
  await this.transport.sendFeedback(job, { type: "error", message: "payment timeout" }).catch(() => {
2770
2947
  });
2771
2948
  throw new Error("Payment timeout");
@@ -2851,6 +3028,9 @@ var AgentRuntime = class {
2851
3028
  const recoveryAbort = new AbortController();
2852
3029
  this.jobAbortControllers.add(recoveryAbort);
2853
3030
  try {
3031
+ if (entry.raw_event_json === void 0) {
3032
+ return;
3033
+ }
2854
3034
  const rawEvent = JSON.parse(entry.raw_event_json);
2855
3035
  const fakeJob = {
2856
3036
  jobId: entry.job_id,
@@ -2863,7 +3043,15 @@ var AgentRuntime = class {
2863
3043
  };
2864
3044
  if (entry.status === "executed" && entry.result !== void 0) {
2865
3045
  this.ledger.incrementRetry(entry.job_id);
2866
- await this.transport.deliverResult(fakeJob, entry.result, entry.net_amount);
3046
+ let attachment;
3047
+ try {
3048
+ attachment = await this.reShareResultAttachment(entry.result_attachment);
3049
+ } catch {
3050
+ log(`[${entry.job_id.slice(0, 8)}] Recovery: result blob unavailable, marking failed`);
3051
+ this.ledger.markFailed(entry.job_id);
3052
+ return;
3053
+ }
3054
+ await this.transport.deliverResult(fakeJob, entry.result, entry.net_amount, attachment);
2867
3055
  this.ledger.markDelivered(entry.job_id);
2868
3056
  log(`[${entry.job_id.slice(0, 8)}] Recovery: re-delivered`);
2869
3057
  } else if (entry.status === "paid") {
@@ -2906,16 +3094,27 @@ var AgentRuntime = class {
2906
3094
  return;
2907
3095
  }
2908
3096
  }
3097
+ let recoveryInputFile;
3098
+ try {
3099
+ recoveryInputFile = await this.resolveInputFile(
3100
+ decodeJobPayload(rawEvent.content).attachment
3101
+ );
3102
+ } catch {
3103
+ log(`[${entry.job_id.slice(0, 8)}] Recovery: input file unavailable, marking failed`);
3104
+ this.ledger.markFailed(entry.job_id);
3105
+ return;
3106
+ }
2909
3107
  const recoveryBudgetMs = this.resolveExecutionBudgetMs(skill);
2910
3108
  let budgetTimer;
2911
3109
  let output;
2912
3110
  try {
2913
3111
  const execPromise = skill.execute(
2914
3112
  {
2915
- data: entry.input,
3113
+ data: recoveryInputFile?.inlineText ?? entry.input,
2916
3114
  inputType: entry.input_type,
2917
3115
  tags: entry.tags,
2918
- jobId: entry.job_id
3116
+ jobId: entry.job_id,
3117
+ filePath: recoveryInputFile?.filePath
2919
3118
  },
2920
3119
  { ...this.skillCtx, signal: recoveryAbort.signal }
2921
3120
  );
@@ -2938,9 +3137,22 @@ var AgentRuntime = class {
2938
3137
  if (budgetTimer) {
2939
3138
  clearTimeout(budgetTimer);
2940
3139
  }
3140
+ if (recoveryInputFile) {
3141
+ await recoveryInputFile.cleanup().catch(() => {
3142
+ });
3143
+ }
2941
3144
  }
2942
- this.ledger.markExecuted(entry.job_id, output.data);
2943
- await this.transport.deliverResult(fakeJob, output.data, entry.net_amount);
3145
+ const { attachment: resultAttachment, deliveredContent } = await this.buildResultAttachment(
3146
+ entry.job_id,
3147
+ output
3148
+ );
3149
+ this.ledger.markExecuted(entry.job_id, deliveredContent);
3150
+ await this.transport.deliverResult(
3151
+ fakeJob,
3152
+ deliveredContent,
3153
+ entry.net_amount,
3154
+ resultAttachment
3155
+ );
2944
3156
  this.ledger.markDelivered(entry.job_id);
2945
3157
  log(`[${entry.job_id.slice(0, 8)}] Recovery: re-executed and delivered`);
2946
3158
  }
@@ -3130,6 +3342,11 @@ var DynamicScriptSkill = class {
3130
3342
  imageFile;
3131
3343
  dir;
3132
3344
  llmOverride;
3345
+ // Discovery hints surfaced in the published capability card (buildCard).
3346
+ // `outputMime` is also forwarded to the inner SDK runner (it labels the file
3347
+ // result); `inputMime` is publish-time metadata only.
3348
+ inputMime;
3349
+ outputMime;
3133
3350
  inner;
3134
3351
  constructor(params) {
3135
3352
  this.name = params.name;
@@ -3141,6 +3358,8 @@ var DynamicScriptSkill = class {
3141
3358
  this.imageFile = params.imageFile;
3142
3359
  this.dir = params.dir;
3143
3360
  this.llmOverride = params.llmOverride;
3361
+ this.inputMime = params.inputMime;
3362
+ this.outputMime = params.outputMime;
3144
3363
  this.inner = new DynamicScriptSkill$1({
3145
3364
  name: params.name,
3146
3365
  description: params.description,
@@ -3152,7 +3371,8 @@ var DynamicScriptSkill = class {
3152
3371
  scriptTimeoutMs: params.scriptTimeoutMs,
3153
3372
  scriptEnv: params.scriptEnv,
3154
3373
  image: params.image,
3155
- imageFile: params.imageFile
3374
+ imageFile: params.imageFile,
3375
+ outputMime: params.outputMime
3156
3376
  });
3157
3377
  }
3158
3378
  async execute(input, ctx) {
@@ -3227,6 +3447,13 @@ var ScriptSkill = class {
3227
3447
 
3228
3448
  // src/skill/loader.ts
3229
3449
  function buildCliSkill(parsed, entryPath, scriptEnv) {
3450
+ let safeImageFile = parsed.imageFile;
3451
+ if (safeImageFile !== void 0 && resolveInsidePath(entryPath, safeImageFile) === null) {
3452
+ console.warn(
3453
+ `SKILL.md "${parsed.name}": ignoring "image_file" that resolves outside the skill directory: ${safeImageFile}`
3454
+ );
3455
+ safeImageFile = void 0;
3456
+ }
3230
3457
  let skill;
3231
3458
  switch (parsed.mode) {
3232
3459
  case "llm":
@@ -3237,7 +3464,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3237
3464
  Number(parsed.priceSubunits),
3238
3465
  parsed.asset,
3239
3466
  parsed.image,
3240
- parsed.imageFile,
3467
+ safeImageFile,
3241
3468
  entryPath,
3242
3469
  parsed.systemPrompt,
3243
3470
  parsed.tools,
@@ -3265,7 +3492,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3265
3492
  asset: parsed.asset,
3266
3493
  outputFilePath,
3267
3494
  image: parsed.image,
3268
- imageFile: parsed.imageFile,
3495
+ imageFile: safeImageFile,
3269
3496
  dir: entryPath,
3270
3497
  llmOverride: parsed.llmOverride
3271
3498
  });
@@ -3282,8 +3509,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3282
3509
  if (!scriptPath) {
3283
3510
  throw new Error(`SKILL.md "${parsed.name}": "script" must stay inside the skill directory`);
3284
3511
  }
3285
- const Ctor = parsed.mode === "static-script" ? StaticScriptSkill : DynamicScriptSkill;
3286
- skill = new Ctor({
3512
+ const scriptParams = {
3287
3513
  name: parsed.name,
3288
3514
  description: parsed.description,
3289
3515
  capabilities: parsed.capabilities,
@@ -3294,10 +3520,15 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3294
3520
  scriptTimeoutMs: parsed.scriptTimeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS,
3295
3521
  scriptEnv,
3296
3522
  image: parsed.image,
3297
- imageFile: parsed.imageFile,
3523
+ imageFile: safeImageFile,
3298
3524
  dir: entryPath,
3299
3525
  llmOverride: parsed.llmOverride
3300
- });
3526
+ };
3527
+ skill = parsed.mode === "dynamic-script" ? new DynamicScriptSkill({
3528
+ ...scriptParams,
3529
+ outputMime: parsed.outputMime,
3530
+ inputMime: parsed.inputMime
3531
+ }) : new StaticScriptSkill(scriptParams);
3301
3532
  break;
3302
3533
  }
3303
3534
  }
@@ -3344,6 +3575,13 @@ function loadSkillsFromDir(skillsDir, options = {}) {
3344
3575
  function isEncrypted(event) {
3345
3576
  return event.tags.some((t) => t[0] === "encrypted" && t[1] === "nip44");
3346
3577
  }
3578
+ function parseBidTag(value) {
3579
+ if (value === void 0) {
3580
+ return void 0;
3581
+ }
3582
+ const parsed = parseInt(value, 10);
3583
+ return Number.isFinite(parsed) ? parsed : void 0;
3584
+ }
3347
3585
  var HEALTH_CHECK_IDLE_MS = 30 * 60 * 1e3;
3348
3586
  var NostrTransport = class {
3349
3587
  constructor(client, identity, kindOffsets) {
@@ -3373,7 +3611,7 @@ var NostrTransport = class {
3373
3611
  if (!hasElisym) {
3374
3612
  return;
3375
3613
  }
3376
- const tags = event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
3614
+ const tags = event.tags.filter((tag) => tag[0] === "t").map((tag) => tag[1]).filter((value) => typeof value === "string");
3377
3615
  const bidTag = event.tags.find((t) => t[0] === "bid");
3378
3616
  const encrypted = isEncrypted(event);
3379
3617
  const iTag = event.tags.find((t) => t[0] === "i");
@@ -3381,16 +3619,26 @@ var NostrTransport = class {
3381
3619
  if (iTag?.[2]) {
3382
3620
  inputType = iTag[2];
3383
3621
  }
3384
- const input = encrypted ? event.content : iTag?.[1] ?? event.content;
3622
+ const rawInput = encrypted ? event.content : iTag?.[1] ?? event.content;
3623
+ let input;
3624
+ let attachment;
3625
+ try {
3626
+ const decoded = decodeJobPayload(rawInput);
3627
+ input = decoded.text ?? "";
3628
+ attachment = decoded.attachment;
3629
+ } catch {
3630
+ return;
3631
+ }
3385
3632
  onJob({
3386
3633
  jobId: event.id,
3387
3634
  input,
3388
3635
  inputType,
3389
3636
  tags,
3390
3637
  customerId: event.pubkey,
3391
- bid: bidTag?.[1] ? parseInt(bidTag[1], 10) : void 0,
3638
+ bid: parseBidTag(bidTag?.[1]),
3392
3639
  encrypted,
3393
- rawEvent: event
3640
+ rawEvent: event,
3641
+ attachment
3394
3642
  });
3395
3643
  }
3396
3644
  );
@@ -3441,6 +3689,9 @@ var NostrTransport = class {
3441
3689
  if (!verifyEvent(event)) {
3442
3690
  return;
3443
3691
  }
3692
+ if (event.pubkey !== customerPubkey) {
3693
+ return;
3694
+ }
3444
3695
  const status = event.tags.find((t) => t[0] === "status")?.[1];
3445
3696
  if (status !== "payment-completed") {
3446
3697
  return;
@@ -3485,13 +3736,15 @@ var NostrTransport = class {
3485
3736
  }
3486
3737
  }
3487
3738
  /** Deliver result to customer. Retries with exponential backoff via SDK. */
3488
- async deliverResult(job, content, amount, retries = 3) {
3739
+ async deliverResult(job, content, amount, attachment, retries = 3) {
3489
3740
  return this.client.marketplace.submitJobResultWithRetry(
3490
3741
  this.identity,
3491
3742
  job.rawEvent,
3492
3743
  content,
3493
3744
  amount,
3494
- retries
3745
+ retries,
3746
+ void 0,
3747
+ attachment
3495
3748
  );
3496
3749
  }
3497
3750
  /** Returns true if an event was received within the given idle window. */
@@ -4013,6 +4266,10 @@ async function cmdStart(nameArg, options = {}) {
4013
4266
  capabilities: skill.capabilities,
4014
4267
  image: skill.image,
4015
4268
  ...isStatic ? { static: true } : {},
4269
+ // File-exchange hints (dynamic-script only). `inputMime` lets clients that
4270
+ // cannot send files (the web app) gate the Buy button before payment.
4271
+ ...skill.inputMime ? { inputMime: skill.inputMime } : {},
4272
+ ...skill.outputMime ? { outputMime: skill.outputMime } : {},
4016
4273
  payment: solanaAddress ? {
4017
4274
  chain: "solana",
4018
4275
  network: walletNetwork,
@@ -4078,6 +4335,8 @@ async function cmdStart(nameArg, options = {}) {
4078
4335
  };
4079
4336
  const transport = new NostrTransport(client, identity, [DEFAULT_KIND_OFFSET]);
4080
4337
  const ledger = new JobLedger(paths.jobs);
4338
+ const irohTransport = createIrohTransport({ storePath: join(loaded.dir, ".iroh") });
4339
+ await ensureGitignoreHasIrohEntry(dirname(loaded.dir));
4081
4340
  const runtimeConfig = {
4082
4341
  paymentTimeoutSecs: DEFAULTS.PAYMENT_EXPIRY_SECS,
4083
4342
  maxConcurrentJobs: MAX_CONCURRENT_JOBS,
@@ -4130,7 +4389,7 @@ async function cmdStart(nameArg, options = {}) {
4130
4389
  ledger,
4131
4390
  {
4132
4391
  onJobReceived: (job) => {
4133
- const cap = job.tags.find((t) => t !== "elisym") ?? "unknown";
4392
+ const cap = sanitizeForTerminal(job.tags.find((t) => t !== "elisym") ?? "unknown");
4134
4393
  process.stdout.write(` [job] ${job.jobId.slice(0, 16)} | cap=${cap}
4135
4394
  `);
4136
4395
  logger.info({ event: "job_received", jobId: job.jobId, capability: cap });
@@ -4141,9 +4400,10 @@ async function cmdStart(nameArg, options = {}) {
4141
4400
  logger.info({ event: "job_delivered", jobId });
4142
4401
  },
4143
4402
  onJobError: (jobId, error) => {
4144
- process.stderr.write(` [job] ${jobId.slice(0, 16)} | error: ${error}
4403
+ const safeError = sanitizeForTerminal(error);
4404
+ process.stderr.write(` [job] ${jobId.slice(0, 16)} | error: ${safeError}
4145
4405
  `);
4146
- logger.error({ event: "job_error", jobId, error });
4406
+ logger.error({ event: "job_error", jobId, error: safeError });
4147
4407
  },
4148
4408
  onLog: diagLog,
4149
4409
  onStop: () => {
@@ -4151,7 +4411,8 @@ async function cmdStart(nameArg, options = {}) {
4151
4411
  llmHeartbeat?.stop();
4152
4412
  }
4153
4413
  },
4154
- healthMonitor
4414
+ healthMonitor,
4415
+ irohTransport
4155
4416
  );
4156
4417
  console.log(" * Running. Press Ctrl+C to stop.\n");
4157
4418
  await runtime.run();