@elisym/cli 0.20.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
@@ -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.
@@ -2851,6 +3017,9 @@ var AgentRuntime = class {
2851
3017
  const recoveryAbort = new AbortController();
2852
3018
  this.jobAbortControllers.add(recoveryAbort);
2853
3019
  try {
3020
+ if (entry.raw_event_json === void 0) {
3021
+ return;
3022
+ }
2854
3023
  const rawEvent = JSON.parse(entry.raw_event_json);
2855
3024
  const fakeJob = {
2856
3025
  jobId: entry.job_id,
@@ -2863,7 +3032,15 @@ var AgentRuntime = class {
2863
3032
  };
2864
3033
  if (entry.status === "executed" && entry.result !== void 0) {
2865
3034
  this.ledger.incrementRetry(entry.job_id);
2866
- 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);
2867
3044
  this.ledger.markDelivered(entry.job_id);
2868
3045
  log(`[${entry.job_id.slice(0, 8)}] Recovery: re-delivered`);
2869
3046
  } else if (entry.status === "paid") {
@@ -2906,16 +3083,27 @@ var AgentRuntime = class {
2906
3083
  return;
2907
3084
  }
2908
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
+ }
2909
3096
  const recoveryBudgetMs = this.resolveExecutionBudgetMs(skill);
2910
3097
  let budgetTimer;
2911
3098
  let output;
2912
3099
  try {
2913
3100
  const execPromise = skill.execute(
2914
3101
  {
2915
- data: entry.input,
3102
+ data: recoveryInputFile?.inlineText ?? entry.input,
2916
3103
  inputType: entry.input_type,
2917
3104
  tags: entry.tags,
2918
- jobId: entry.job_id
3105
+ jobId: entry.job_id,
3106
+ filePath: recoveryInputFile?.filePath
2919
3107
  },
2920
3108
  { ...this.skillCtx, signal: recoveryAbort.signal }
2921
3109
  );
@@ -2938,9 +3126,22 @@ var AgentRuntime = class {
2938
3126
  if (budgetTimer) {
2939
3127
  clearTimeout(budgetTimer);
2940
3128
  }
3129
+ if (recoveryInputFile) {
3130
+ await recoveryInputFile.cleanup().catch(() => {
3131
+ });
3132
+ }
2941
3133
  }
2942
- this.ledger.markExecuted(entry.job_id, output.data);
2943
- 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
+ );
2944
3145
  this.ledger.markDelivered(entry.job_id);
2945
3146
  log(`[${entry.job_id.slice(0, 8)}] Recovery: re-executed and delivered`);
2946
3147
  }
@@ -3130,6 +3331,11 @@ var DynamicScriptSkill = class {
3130
3331
  imageFile;
3131
3332
  dir;
3132
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;
3133
3339
  inner;
3134
3340
  constructor(params) {
3135
3341
  this.name = params.name;
@@ -3141,6 +3347,8 @@ var DynamicScriptSkill = class {
3141
3347
  this.imageFile = params.imageFile;
3142
3348
  this.dir = params.dir;
3143
3349
  this.llmOverride = params.llmOverride;
3350
+ this.inputMime = params.inputMime;
3351
+ this.outputMime = params.outputMime;
3144
3352
  this.inner = new DynamicScriptSkill$1({
3145
3353
  name: params.name,
3146
3354
  description: params.description,
@@ -3152,7 +3360,8 @@ var DynamicScriptSkill = class {
3152
3360
  scriptTimeoutMs: params.scriptTimeoutMs,
3153
3361
  scriptEnv: params.scriptEnv,
3154
3362
  image: params.image,
3155
- imageFile: params.imageFile
3363
+ imageFile: params.imageFile,
3364
+ outputMime: params.outputMime
3156
3365
  });
3157
3366
  }
3158
3367
  async execute(input, ctx) {
@@ -3227,6 +3436,13 @@ var ScriptSkill = class {
3227
3436
 
3228
3437
  // src/skill/loader.ts
3229
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
+ }
3230
3446
  let skill;
3231
3447
  switch (parsed.mode) {
3232
3448
  case "llm":
@@ -3237,7 +3453,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3237
3453
  Number(parsed.priceSubunits),
3238
3454
  parsed.asset,
3239
3455
  parsed.image,
3240
- parsed.imageFile,
3456
+ safeImageFile,
3241
3457
  entryPath,
3242
3458
  parsed.systemPrompt,
3243
3459
  parsed.tools,
@@ -3265,7 +3481,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3265
3481
  asset: parsed.asset,
3266
3482
  outputFilePath,
3267
3483
  image: parsed.image,
3268
- imageFile: parsed.imageFile,
3484
+ imageFile: safeImageFile,
3269
3485
  dir: entryPath,
3270
3486
  llmOverride: parsed.llmOverride
3271
3487
  });
@@ -3282,8 +3498,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3282
3498
  if (!scriptPath) {
3283
3499
  throw new Error(`SKILL.md "${parsed.name}": "script" must stay inside the skill directory`);
3284
3500
  }
3285
- const Ctor = parsed.mode === "static-script" ? StaticScriptSkill : DynamicScriptSkill;
3286
- skill = new Ctor({
3501
+ const scriptParams = {
3287
3502
  name: parsed.name,
3288
3503
  description: parsed.description,
3289
3504
  capabilities: parsed.capabilities,
@@ -3294,10 +3509,15 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3294
3509
  scriptTimeoutMs: parsed.scriptTimeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS,
3295
3510
  scriptEnv,
3296
3511
  image: parsed.image,
3297
- imageFile: parsed.imageFile,
3512
+ imageFile: safeImageFile,
3298
3513
  dir: entryPath,
3299
3514
  llmOverride: parsed.llmOverride
3300
- });
3515
+ };
3516
+ skill = parsed.mode === "dynamic-script" ? new DynamicScriptSkill({
3517
+ ...scriptParams,
3518
+ outputMime: parsed.outputMime,
3519
+ inputMime: parsed.inputMime
3520
+ }) : new StaticScriptSkill(scriptParams);
3301
3521
  break;
3302
3522
  }
3303
3523
  }
@@ -3344,6 +3564,13 @@ function loadSkillsFromDir(skillsDir, options = {}) {
3344
3564
  function isEncrypted(event) {
3345
3565
  return event.tags.some((t) => t[0] === "encrypted" && t[1] === "nip44");
3346
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
+ }
3347
3574
  var HEALTH_CHECK_IDLE_MS = 30 * 60 * 1e3;
3348
3575
  var NostrTransport = class {
3349
3576
  constructor(client, identity, kindOffsets) {
@@ -3373,7 +3600,7 @@ var NostrTransport = class {
3373
3600
  if (!hasElisym) {
3374
3601
  return;
3375
3602
  }
3376
- 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");
3377
3604
  const bidTag = event.tags.find((t) => t[0] === "bid");
3378
3605
  const encrypted = isEncrypted(event);
3379
3606
  const iTag = event.tags.find((t) => t[0] === "i");
@@ -3381,16 +3608,26 @@ var NostrTransport = class {
3381
3608
  if (iTag?.[2]) {
3382
3609
  inputType = iTag[2];
3383
3610
  }
3384
- 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
+ }
3385
3621
  onJob({
3386
3622
  jobId: event.id,
3387
3623
  input,
3388
3624
  inputType,
3389
3625
  tags,
3390
3626
  customerId: event.pubkey,
3391
- bid: bidTag?.[1] ? parseInt(bidTag[1], 10) : void 0,
3627
+ bid: parseBidTag(bidTag?.[1]),
3392
3628
  encrypted,
3393
- rawEvent: event
3629
+ rawEvent: event,
3630
+ attachment
3394
3631
  });
3395
3632
  }
3396
3633
  );
@@ -3441,6 +3678,9 @@ var NostrTransport = class {
3441
3678
  if (!verifyEvent(event)) {
3442
3679
  return;
3443
3680
  }
3681
+ if (event.pubkey !== customerPubkey) {
3682
+ return;
3683
+ }
3444
3684
  const status = event.tags.find((t) => t[0] === "status")?.[1];
3445
3685
  if (status !== "payment-completed") {
3446
3686
  return;
@@ -3485,13 +3725,15 @@ var NostrTransport = class {
3485
3725
  }
3486
3726
  }
3487
3727
  /** Deliver result to customer. Retries with exponential backoff via SDK. */
3488
- async deliverResult(job, content, amount, retries = 3) {
3728
+ async deliverResult(job, content, amount, attachment, retries = 3) {
3489
3729
  return this.client.marketplace.submitJobResultWithRetry(
3490
3730
  this.identity,
3491
3731
  job.rawEvent,
3492
3732
  content,
3493
3733
  amount,
3494
- retries
3734
+ retries,
3735
+ void 0,
3736
+ attachment
3495
3737
  );
3496
3738
  }
3497
3739
  /** Returns true if an event was received within the given idle window. */
@@ -4013,6 +4255,10 @@ async function cmdStart(nameArg, options = {}) {
4013
4255
  capabilities: skill.capabilities,
4014
4256
  image: skill.image,
4015
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 } : {},
4016
4262
  payment: solanaAddress ? {
4017
4263
  chain: "solana",
4018
4264
  network: walletNetwork,
@@ -4078,6 +4324,8 @@ async function cmdStart(nameArg, options = {}) {
4078
4324
  };
4079
4325
  const transport = new NostrTransport(client, identity, [DEFAULT_KIND_OFFSET]);
4080
4326
  const ledger = new JobLedger(paths.jobs);
4327
+ const irohTransport = createIrohTransport({ storePath: join(loaded.dir, ".iroh") });
4328
+ await ensureGitignoreHasIrohEntry(dirname(loaded.dir));
4081
4329
  const runtimeConfig = {
4082
4330
  paymentTimeoutSecs: DEFAULTS.PAYMENT_EXPIRY_SECS,
4083
4331
  maxConcurrentJobs: MAX_CONCURRENT_JOBS,
@@ -4130,7 +4378,7 @@ async function cmdStart(nameArg, options = {}) {
4130
4378
  ledger,
4131
4379
  {
4132
4380
  onJobReceived: (job) => {
4133
- const cap = job.tags.find((t) => t !== "elisym") ?? "unknown";
4381
+ const cap = sanitizeForTerminal(job.tags.find((t) => t !== "elisym") ?? "unknown");
4134
4382
  process.stdout.write(` [job] ${job.jobId.slice(0, 16)} | cap=${cap}
4135
4383
  `);
4136
4384
  logger.info({ event: "job_received", jobId: job.jobId, capability: cap });
@@ -4141,9 +4389,10 @@ async function cmdStart(nameArg, options = {}) {
4141
4389
  logger.info({ event: "job_delivered", jobId });
4142
4390
  },
4143
4391
  onJobError: (jobId, error) => {
4144
- 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}
4145
4394
  `);
4146
- logger.error({ event: "job_error", jobId, error });
4395
+ logger.error({ event: "job_error", jobId, error: safeError });
4147
4396
  },
4148
4397
  onLog: diagLog,
4149
4398
  onStop: () => {
@@ -4151,7 +4400,8 @@ async function cmdStart(nameArg, options = {}) {
4151
4400
  llmHeartbeat?.stop();
4152
4401
  }
4153
4402
  },
4154
- healthMonitor
4403
+ healthMonitor,
4404
+ irohTransport
4155
4405
  );
4156
4406
  console.log(" * Running. Press Ctrl+C to stop.\n");
4157
4407
  await runtime.run();