@elisym/mcp 0.8.10 → 0.8.11

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,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { LIMITS, DEFAULT_KIND_OFFSET, SolanaPaymentStrategy, makeCensor, DEFAULT_REDACT_PATHS, validateAgentName, RELAYS, toDTag, formatAssetAmount, USDC_SOLANA_DEVNET, estimateSolFeeLamports, formatFeeBreakdown, resolveAssetFromPaymentRequest as resolveAssetFromPaymentRequest$1, parseAssetAmount, ElisymIdentity, ElisymClient, NATIVE_SOL, resolveKnownAsset, getProtocolProgramId, getProtocolConfig, assetKey, assetByKey, KNOWN_ASSETS } from '@elisym/sdk';
3
- import { listAgents, createAgentDir, writeYaml, writeSecrets, loadAgent, globalConfigPath } from '@elisym/sdk/agent-store';
2
+ import { LIMITS, DEFAULT_KIND_OFFSET, SolanaPaymentStrategy, makeCensor, DEFAULT_REDACT_PATHS, validateAgentName, RELAYS, toDTag, formatAssetAmount, USDC_SOLANA_DEVNET, estimateSolFeeLamports, formatFeeBreakdown, resolveAssetFromPaymentRequest as resolveAssetFromPaymentRequest$1, parseAssetAmount, ElisymIdentity, ElisymClient, NATIVE_SOL, resolveKnownAsset, estimateNetworkBaseline, formatNetworkBaseline, getProtocolProgramId, getProtocolConfig, assetKey, assetByKey, KNOWN_ASSETS } from '@elisym/sdk';
3
+ import { listAgents, createAgentDir, writeYaml, writeSecrets, resolveAgent, loadAgent, globalConfigPath, writeFileAtomic as writeFileAtomic$1 } from '@elisym/sdk/agent-store';
4
4
  import { loadGlobalConfig, writeGlobalConfig } from '@elisym/sdk/node';
5
5
  import { getBase58Encoder, getBase58Decoder, generateKeyPairSigner, address, createSolanaRpcSubscriptions, sendAndConfirmTransactionFactory, getSignatureFromTransaction, pipe, createTransactionMessage, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, appendTransactionMessageInstructions, signTransactionMessageWithSigners, createKeyPairSignerFromBytes, createSolanaRpc, isAddress } from '@solana/kit';
6
6
  import { Command } from 'commander';
@@ -352,6 +352,13 @@ function payment() {
352
352
  _paymentStrategy ??= new SolanaPaymentStrategy();
353
353
  return _paymentStrategy;
354
354
  }
355
+ function decodeNpub(npub) {
356
+ const decoded = nip19.decode(npub);
357
+ if (decoded.type !== "npub") {
358
+ throw new Error(`Expected npub, got ${decoded.type}`);
359
+ }
360
+ return decoded.data;
361
+ }
355
362
 
356
363
  // src/install.ts
357
364
  function elisymPackageArgs() {
@@ -752,13 +759,15 @@ async function buildAgentInstance(name, config) {
752
759
  );
753
760
  }
754
761
  }
762
+ const resolved = resolveAgent(name, process.cwd());
755
763
  return {
756
764
  client,
757
765
  identity,
758
766
  name,
759
767
  network: config.network,
760
768
  solanaKeypair,
761
- security: config.security ?? {}
769
+ security: config.security ?? {},
770
+ agentDir: resolved?.dir
762
771
  };
763
772
  }
764
773
  var agentTools = [
@@ -1156,6 +1165,107 @@ function sanitizeField(input, maxLen) {
1156
1165
  }
1157
1166
  return text;
1158
1167
  }
1168
+ var CUSTOMER_HISTORY_FILENAME = ".customer-history.json";
1169
+ var MAX_HISTORY_ENTRIES = 500;
1170
+ var StatusSchema = z.enum(["completed", "failed", "timeout"]);
1171
+ var FeedbackSchema = z.enum(["positive", "negative"]);
1172
+ var CustomerJobEntrySchema = z.object({
1173
+ jobEventId: z.string().min(1).max(128),
1174
+ capability: z.string().min(1).max(200),
1175
+ providerPubkey: z.string().regex(/^[a-f0-9]{64}$/),
1176
+ providerName: z.string().max(200).optional(),
1177
+ paidAmountSubunits: z.string().max(40).optional(),
1178
+ assetKey: z.string().max(80).optional(),
1179
+ status: StatusSchema,
1180
+ submittedAt: z.number().int().nonnegative(),
1181
+ completedAt: z.number().int().nonnegative(),
1182
+ resultPreview: z.string().max(500).optional(),
1183
+ paymentSig: z.string().max(128).optional(),
1184
+ customerFeedback: FeedbackSchema.optional()
1185
+ }).strict();
1186
+ var CustomerHistorySchema = z.object({
1187
+ version: z.literal(1),
1188
+ jobs: z.array(CustomerJobEntrySchema)
1189
+ }).strict();
1190
+ var EMPTY = { version: 1, jobs: [] };
1191
+ var writeLocks = /* @__PURE__ */ new Map();
1192
+ function withLock(path, fn) {
1193
+ const previous = writeLocks.get(path) ?? Promise.resolve();
1194
+ const next = previous.then(fn, fn);
1195
+ writeLocks.set(
1196
+ path,
1197
+ next.finally(() => {
1198
+ if (writeLocks.get(path) === next) {
1199
+ writeLocks.delete(path);
1200
+ }
1201
+ })
1202
+ );
1203
+ return next;
1204
+ }
1205
+ function pathFor(agentDir) {
1206
+ return join(agentDir, CUSTOMER_HISTORY_FILENAME);
1207
+ }
1208
+ async function readRaw(path) {
1209
+ let raw;
1210
+ try {
1211
+ raw = await readFile(path, "utf-8");
1212
+ } catch {
1213
+ return { ...EMPTY, jobs: [] };
1214
+ }
1215
+ try {
1216
+ const parsed = JSON.parse(raw);
1217
+ const result = CustomerHistorySchema.safeParse(parsed);
1218
+ return result.success ? result.data : { ...EMPTY, jobs: [] };
1219
+ } catch {
1220
+ return { ...EMPTY, jobs: [] };
1221
+ }
1222
+ }
1223
+ async function writeRaw(path, history) {
1224
+ const body = JSON.stringify(history, null, 2) + "\n";
1225
+ await writeFileAtomic$1(path, body, 384);
1226
+ }
1227
+ async function readCustomerHistory(agentDir) {
1228
+ return readRaw(pathFor(agentDir));
1229
+ }
1230
+ async function appendCustomerJob(agentDir, entry) {
1231
+ const validated = CustomerJobEntrySchema.parse(entry);
1232
+ const path = pathFor(agentDir);
1233
+ return withLock(path, async () => {
1234
+ const history = await readRaw(path);
1235
+ const existingIndex = history.jobs.findIndex((job) => job.jobEventId === validated.jobEventId);
1236
+ if (existingIndex >= 0) {
1237
+ history.jobs[existingIndex] = validated;
1238
+ } else {
1239
+ history.jobs.push(validated);
1240
+ }
1241
+ if (history.jobs.length > MAX_HISTORY_ENTRIES) {
1242
+ history.jobs.sort((left, right) => left.submittedAt - right.submittedAt);
1243
+ history.jobs.splice(0, history.jobs.length - MAX_HISTORY_ENTRIES);
1244
+ }
1245
+ await writeRaw(path, history);
1246
+ });
1247
+ }
1248
+ async function updateCustomerJob(agentDir, jobEventId, patch) {
1249
+ const path = pathFor(agentDir);
1250
+ return withLock(path, async () => {
1251
+ const history = await readRaw(path);
1252
+ const index = history.jobs.findIndex((job) => job.jobEventId === jobEventId);
1253
+ if (index < 0) {
1254
+ return;
1255
+ }
1256
+ const merged = CustomerJobEntrySchema.parse({ ...history.jobs[index], ...patch });
1257
+ history.jobs[index] = merged;
1258
+ await writeRaw(path, history);
1259
+ });
1260
+ }
1261
+ async function findCustomerJob(agentDir, jobEventId) {
1262
+ const history = await readCustomerHistory(agentDir);
1263
+ return history.jobs.find((job) => job.jobEventId === jobEventId);
1264
+ }
1265
+ async function findCustomerJobsByProvider(agentDir, providerPubkey) {
1266
+ const history = await readCustomerHistory(agentDir);
1267
+ return history.jobs.filter((job) => job.providerPubkey === providerPubkey).sort((left, right) => right.completedAt - left.completedAt);
1268
+ }
1159
1269
 
1160
1270
  // src/tools/customer.ts
1161
1271
  var PRE_PING_TIMEOUT_MS = 5e3;
@@ -1174,7 +1284,10 @@ var GetJobResultSchema = z.object({
1174
1284
  });
1175
1285
  var ListMyJobsSchema = z.object({
1176
1286
  limit: z.number().int().min(1).max(50).default(20),
1177
- kind_offset: z.number().int().min(0).max(999).default(DEFAULT_KIND_OFFSET)
1287
+ kind_offset: z.number().int().min(0).max(999).default(DEFAULT_KIND_OFFSET),
1288
+ include_nostr: z.boolean().default(false).describe(
1289
+ "When true, also pull jobs from Nostr relays and merge them with the local cache. Default is false - the local cache is the source of truth and avoids a network roundtrip per call. Use true when looking for jobs submitted from outside this MCP (e.g. the web app) or to recover after a local-cache wipe."
1290
+ )
1178
1291
  });
1179
1292
  var SubmitAndPayJobSchema = z.object({
1180
1293
  input: z.string(),
@@ -1191,13 +1304,6 @@ var BuyCapabilitySchema = z.object({
1191
1304
  max_price_lamports: z.number().int().optional(),
1192
1305
  timeout_secs: z.number().int().min(1).max(600).default(120)
1193
1306
  });
1194
- function decodeNpub(npub) {
1195
- const decoded = nip19.decode(npub);
1196
- if (decoded.type !== "npub") {
1197
- throw new Error(`Expected npub, got ${decoded.type}`);
1198
- }
1199
- return decoded.data;
1200
- }
1201
1307
  function providerSolanaAddress(provider, dTag) {
1202
1308
  const cards = provider.cards ?? [];
1203
1309
  const candidates = dTag ? cards.filter(
@@ -1213,6 +1319,48 @@ function providerSolanaAddress(provider, dTag) {
1213
1319
  function wsUrlFor(httpUrl) {
1214
1320
  return httpUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
1215
1321
  }
1322
+ async function recordJobOutcome(agent, entry) {
1323
+ if (!agent.agentDir) {
1324
+ return;
1325
+ }
1326
+ try {
1327
+ await appendCustomerJob(agent.agentDir, entry);
1328
+ } catch (e) {
1329
+ logger.warn(
1330
+ { event: "customer_history_write_failed", agent: agent.name, error: String(e) },
1331
+ "failed to write .customer-history.json"
1332
+ );
1333
+ }
1334
+ }
1335
+ function clipProviderName(name) {
1336
+ if (name === void 0) {
1337
+ return void 0;
1338
+ }
1339
+ return name.length > 200 ? name.slice(0, 200) : name;
1340
+ }
1341
+ function buildJobCompletionTip(jobId, providerNpub) {
1342
+ return `
1343
+
1344
+ Tip: rate this provider with submit_feedback (job_event_id="${jobId}", rating="positive"|"negative"), or save them with add_contact (npub="${providerNpub}").`;
1345
+ }
1346
+ function classifyJobFailure(message) {
1347
+ return /timed out/i.test(message) ? "timeout" : "failed";
1348
+ }
1349
+ async function gasHintForCardAsset(agent, asset) {
1350
+ if (!agent.solanaKeypair) {
1351
+ return "";
1352
+ }
1353
+ try {
1354
+ const rpc = createSolanaRpc(rpcUrlFor(agent.network));
1355
+ const baseline = await estimateNetworkBaseline(rpc, {
1356
+ includeAtaRent: asset.mint !== void 0
1357
+ });
1358
+ return `
1359
+ ${formatNetworkBaseline(baseline)}`;
1360
+ } catch {
1361
+ return "";
1362
+ }
1363
+ }
1216
1364
  var paymentStrategy = new SolanaPaymentStrategy();
1217
1365
  async function executePaymentFlow(agent, paymentRequest, jobId, providerPubkey, expectedRecipient) {
1218
1366
  let requestData;
@@ -1322,7 +1470,7 @@ function makePaymentFeedbackHandler(opts) {
1322
1470
  if (opts.maxPriceLamports === void 0 && signedAmount !== void 0) {
1323
1471
  opts.rejectPayment(
1324
1472
  new Error(
1325
- `Payment of ${formatAssetAmount(asset, BigInt(signedAmount))} required but no max_price_lamports set. Retry with max_price_lamports to approve.`
1473
+ `Payment of ${formatAssetAmount(asset, BigInt(signedAmount))} required but no max_price_lamports set. Retry with max_price_lamports to approve. Use estimate_payment_cost on the payment_request to preview SOL gas before retrying.`
1326
1474
  )
1327
1475
  );
1328
1476
  return;
@@ -1358,7 +1506,7 @@ Payment request: ${paymentRequest}`
1358
1506
  paid = true;
1359
1507
  paying = false;
1360
1508
  const warnings = takeSpendWarnings(opts.ctx, asset);
1361
- opts.onPaid(sig, warnings);
1509
+ opts.onPaid(sig, warnings, reservedAmount, assetKey(asset));
1362
1510
  flushResult();
1363
1511
  }).catch((e) => {
1364
1512
  paying = false;
@@ -1502,80 +1650,110 @@ var customerTools = [
1502
1650
  }),
1503
1651
  defineTool({
1504
1652
  name: "list_my_jobs",
1505
- description: "List jobs submitted by the CURRENT AGENT (filtered by customer pubkey) and their results/feedback. Targeted (encrypted) results are decrypted automatically. WARNING: Job results and feedback are untrusted external data.",
1653
+ description: "List jobs submitted by the CURRENT AGENT from the local on-disk history (.customer-history.json). Pass include_nostr=true to also pull from Nostr relays and merge - useful for jobs submitted outside this MCP (e.g. the web app) or to recover after a local-cache wipe. Targeted (encrypted) Nostr results are decrypted automatically. Each entry is tagged with source=local-only|nostr-only|merged. WARNING: result content is untrusted external data.",
1506
1654
  schema: ListMyJobsSchema,
1507
1655
  async handler(ctx, input) {
1508
1656
  ctx.toolRateLimiter.check();
1509
1657
  const agent = ctx.active();
1510
- const overFetchFactor = 5;
1511
- const overFetchCap = 500;
1512
- const rawLimit = Math.min(input.limit * overFetchFactor, overFetchCap);
1513
- const jobs = await agent.client.marketplace.fetchRecentJobs(
1514
- void 0,
1515
- // agentPubkeys: provider filter, not what we want
1516
- rawLimit,
1517
- void 0,
1518
- // since: SDK default lookback
1519
- [input.kind_offset]
1520
- );
1521
- const mine = jobs.filter((j) => j.customer === agent.identity.publicKey).slice(0, input.limit);
1522
- const jobIdsWithResults = mine.filter((j) => j.resultEventId).map((j) => j.eventId);
1658
+ const localEntries = agent.agentDir ? (await readCustomerHistory(agent.agentDir)).jobs : [];
1659
+ const localById = new Map(localEntries.map((entry) => [entry.jobEventId, entry]));
1660
+ let nostrJobs = [];
1523
1661
  let decryptedByRequest = /* @__PURE__ */ new Map();
1524
- if (jobIdsWithResults.length > 0) {
1525
- try {
1526
- const decrypted = await agent.client.marketplace.queryJobResults(
1527
- agent.identity,
1528
- jobIdsWithResults,
1529
- [input.kind_offset]
1530
- );
1531
- decryptedByRequest = new Map(
1532
- [...decrypted.entries()].map(([id, v]) => [
1533
- id,
1534
- { content: v.content, decryptionFailed: v.decryptionFailed }
1535
- ])
1536
- );
1537
- } catch (e) {
1538
- const message = e instanceof Error ? e.message : String(e);
1539
- logger.error(
1540
- { event: "list_my_jobs_query_failed", err: message },
1541
- "queryJobResults failed"
1542
- );
1662
+ if (input.include_nostr) {
1663
+ const overFetchFactor = 5;
1664
+ const overFetchCap = 500;
1665
+ const rawLimit = Math.min(input.limit * overFetchFactor, overFetchCap);
1666
+ nostrJobs = (await agent.client.marketplace.fetchRecentJobs(void 0, rawLimit, void 0, [
1667
+ input.kind_offset
1668
+ ])).filter((job) => job.customer === agent.identity.publicKey);
1669
+ const jobIdsWithResults = nostrJobs.filter((job) => job.resultEventId).map((job) => job.eventId);
1670
+ if (jobIdsWithResults.length > 0) {
1671
+ try {
1672
+ const decrypted = await agent.client.marketplace.queryJobResults(
1673
+ agent.identity,
1674
+ jobIdsWithResults,
1675
+ [input.kind_offset]
1676
+ );
1677
+ decryptedByRequest = new Map(
1678
+ [...decrypted.entries()].map(([id, value]) => [
1679
+ id,
1680
+ { content: value.content, decryptionFailed: value.decryptionFailed }
1681
+ ])
1682
+ );
1683
+ } catch (e) {
1684
+ const message = e instanceof Error ? e.message : String(e);
1685
+ logger.error(
1686
+ { event: "list_my_jobs_query_failed", err: message },
1687
+ "queryJobResults failed"
1688
+ );
1689
+ }
1543
1690
  }
1544
1691
  }
1692
+ const nostrById = new Map(nostrJobs.map((job) => [job.eventId, job]));
1545
1693
  let freetextSuspicious = false;
1546
- const results = mine.map((job) => {
1547
- const dec = decryptedByRequest.get(job.eventId);
1694
+ const allIds = /* @__PURE__ */ new Set([...localById.keys(), ...nostrById.keys()]);
1695
+ const merged = [...allIds].map((eventId) => {
1696
+ const local = localById.get(eventId);
1697
+ const nostr = nostrById.get(eventId);
1698
+ let source;
1699
+ if (local && nostr) {
1700
+ source = "merged";
1701
+ } else if (local) {
1702
+ source = "local-only";
1703
+ } else {
1704
+ source = "nostr-only";
1705
+ }
1548
1706
  let resultText;
1549
- if (dec) {
1550
- if (dec.decryptionFailed) {
1551
- resultText = "[decryption failed - targeted result not for this agent]";
1552
- } else {
1553
- const cleaned = sanitizeInner(dec.content);
1707
+ if (nostr) {
1708
+ const decrypted = decryptedByRequest.get(eventId);
1709
+ if (decrypted) {
1710
+ if (decrypted.decryptionFailed) {
1711
+ resultText = "[decryption failed - targeted result not for this agent]";
1712
+ } else {
1713
+ const cleaned = sanitizeInner(decrypted.content);
1714
+ if (scanForInjections(cleaned, "full")) {
1715
+ freetextSuspicious = true;
1716
+ }
1717
+ resultText = cleaned;
1718
+ }
1719
+ } else if (nostr.result) {
1720
+ const cleaned = sanitizeInner(nostr.result);
1554
1721
  if (scanForInjections(cleaned, "full")) {
1555
1722
  freetextSuspicious = true;
1556
1723
  }
1557
1724
  resultText = cleaned;
1558
1725
  }
1559
- } else if (job.result) {
1560
- const cleaned = sanitizeInner(job.result);
1726
+ }
1727
+ if (!resultText && local?.resultPreview) {
1728
+ const cleaned = sanitizeInner(local.resultPreview);
1561
1729
  if (scanForInjections(cleaned, "full")) {
1562
1730
  freetextSuspicious = true;
1563
1731
  }
1564
1732
  resultText = cleaned;
1565
1733
  }
1734
+ const status = nostr?.status ?? local?.status;
1735
+ const capability = nostr?.capability ?? local?.capability;
1736
+ const amount = nostr?.amount ?? local?.paidAmountSubunits;
1737
+ const timestamp = nostr?.createdAt ?? (local ? Math.floor(local.submittedAt / 1e3) : void 0);
1566
1738
  return {
1567
- event_id: job.eventId,
1568
- status: sanitizeField(job.status ?? "", 100),
1569
- capability: sanitizeField(job.capability ?? "", 100),
1570
- amount: job.amount,
1571
- timestamp: job.createdAt,
1572
- result: resultText
1739
+ event_id: eventId,
1740
+ source,
1741
+ status: status !== void 0 ? sanitizeField(String(status), 100) : void 0,
1742
+ capability: capability !== void 0 ? sanitizeField(String(capability), 100) : void 0,
1743
+ amount: amount !== void 0 ? String(amount) : void 0,
1744
+ asset_key: local?.assetKey,
1745
+ timestamp,
1746
+ result: resultText,
1747
+ payment_sig: local?.paymentSig,
1748
+ customer_feedback: local?.customerFeedback
1573
1749
  };
1574
1750
  });
1575
- const { text: wrapped } = sanitizeUntrusted(JSON.stringify(results, null, 2), "structured", {
1751
+ merged.sort((left, right) => (right.timestamp ?? 0) - (left.timestamp ?? 0));
1752
+ const limited = merged.slice(0, input.limit);
1753
+ const { text: wrapped } = sanitizeUntrusted(JSON.stringify(limited, null, 2), "structured", {
1576
1754
  extraInjectionSignal: freetextSuspicious
1577
1755
  });
1578
- return textResult(`Found ${results.length} of your jobs:
1756
+ return textResult(`Found ${limited.length} of your jobs:
1579
1757
  ${wrapped}`);
1580
1758
  }
1581
1759
  }),
@@ -1609,6 +1787,7 @@ ${wrapped}`);
1609
1787
  `Provider "${input.provider_npub}" has no Solana payment address for capability "${input.capability}". Cannot verify payment recipient - refusing to proceed. Ask the provider to publish a capability card with a payment address.`
1610
1788
  );
1611
1789
  }
1790
+ const submittedAt = Date.now();
1612
1791
  const jobId = await agent.client.marketplace.submitJobRequest(agent.identity, {
1613
1792
  input: input.input,
1614
1793
  capability: input.capability,
@@ -1616,6 +1795,8 @@ ${wrapped}`);
1616
1795
  kindOffset: input.kind_offset
1617
1796
  });
1618
1797
  let paymentSig;
1798
+ let paidAmountSubunits;
1799
+ let paidAssetKey;
1619
1800
  let paymentWarnings = [];
1620
1801
  try {
1621
1802
  const result = await awaitJobResult(
@@ -1632,8 +1813,10 @@ ${wrapped}`);
1632
1813
  resolveNoWallet: resolve,
1633
1814
  resolveResult: resolve,
1634
1815
  rejectPayment: reject,
1635
- onPaid: (sig, warnings) => {
1816
+ onPaid: (sig, warnings, amount, assetKey5) => {
1636
1817
  paymentSig = sig;
1818
+ paidAmountSubunits = amount;
1819
+ paidAssetKey = assetKey5;
1637
1820
  paymentWarnings = warnings;
1638
1821
  for (const line of warnings) {
1639
1822
  logger.warn({ event: "session_spend_threshold", agent: agent.name }, line);
@@ -1663,12 +1846,38 @@ ${sanitized.text}`);
1663
1846
  },
1664
1847
  timeout + 5e3
1665
1848
  );
1849
+ await recordJobOutcome(agent, {
1850
+ jobEventId: jobId,
1851
+ capability: input.capability,
1852
+ providerPubkey,
1853
+ providerName: clipProviderName(provider.name),
1854
+ paidAmountSubunits: paidAmountSubunits?.toString(),
1855
+ assetKey: paidAssetKey,
1856
+ status: "completed",
1857
+ submittedAt,
1858
+ completedAt: Date.now(),
1859
+ resultPreview: result.slice(0, 500),
1860
+ paymentSig
1861
+ });
1666
1862
  const warningBlock = paymentWarnings.length > 0 ? `${paymentWarnings.join("\n")}
1667
1863
  ` : "";
1864
+ const tip = buildJobCompletionTip(jobId, input.provider_npub);
1668
1865
  return textResult(`${warningBlock}event_id=${jobId}
1669
- ${result}`);
1866
+ ${result}${tip}`);
1670
1867
  } catch (e) {
1671
1868
  const msg = e instanceof Error ? e.message : String(e);
1869
+ await recordJobOutcome(agent, {
1870
+ jobEventId: jobId,
1871
+ capability: input.capability,
1872
+ providerPubkey,
1873
+ providerName: clipProviderName(provider.name),
1874
+ paidAmountSubunits: paidAmountSubunits?.toString(),
1875
+ assetKey: paidAssetKey,
1876
+ status: classifyJobFailure(msg),
1877
+ submittedAt,
1878
+ completedAt: Date.now(),
1879
+ paymentSig
1880
+ });
1672
1881
  const paid = paymentSig ? ` Payment already sent (sig=${paymentSig}) - use get_job_result with event_id="${jobId}" to retrieve once ready.` : "";
1673
1882
  const warningBlock = paymentWarnings.length > 0 ? `${paymentWarnings.join("\n")}
1674
1883
  ` : "";
@@ -1718,11 +1927,12 @@ ${result}`);
1718
1927
  );
1719
1928
  }
1720
1929
  if (price > 0 && input.max_price_lamports === void 0) {
1930
+ const gasLine = await gasHintForCardAsset(agent, cardAsset);
1721
1931
  return {
1722
1932
  content: [
1723
1933
  {
1724
1934
  type: "text",
1725
- text: `Capability "${input.capability}" from "${provider.name || input.provider_npub}" costs ${formatAssetAmount(cardAsset, BigInt(price))}.
1935
+ text: `Capability "${input.capability}" from "${provider.name || input.provider_npub}" costs ${formatAssetAmount(cardAsset, BigInt(price))}.${gasLine}
1726
1936
 
1727
1937
  To confirm, call buy_capability again with max_price_lamports set (e.g. ${price} or higher).`
1728
1938
  }
@@ -1735,12 +1945,15 @@ To confirm, call buy_capability again with max_price_lamports set (e.g. ${price}
1735
1945
  `Provider "${input.provider_npub}" has no Solana payment address for capability "${input.capability}". Cannot verify payment recipient.`
1736
1946
  );
1737
1947
  }
1948
+ const submittedAt = Date.now();
1738
1949
  const jobId = await agent.client.marketplace.submitJobRequest(agent.identity, {
1739
1950
  input: input.input || "",
1740
1951
  capability: dTag,
1741
1952
  providerPubkey
1742
1953
  });
1743
1954
  let paymentSig;
1955
+ let paidAmountSubunits;
1956
+ let paidAssetKey;
1744
1957
  let paymentWarnings = [];
1745
1958
  try {
1746
1959
  const result = await awaitJobResult(
@@ -1757,8 +1970,10 @@ To confirm, call buy_capability again with max_price_lamports set (e.g. ${price}
1757
1970
  resolveNoWallet: resolve,
1758
1971
  resolveResult: resolve,
1759
1972
  rejectPayment: reject,
1760
- onPaid: (sig, warnings) => {
1973
+ onPaid: (sig, warnings, amount, assetKey5) => {
1761
1974
  paymentSig = sig;
1975
+ paidAmountSubunits = amount;
1976
+ paidAssetKey = assetKey5;
1762
1977
  paymentWarnings = warnings;
1763
1978
  for (const line of warnings) {
1764
1979
  logger.warn({ event: "session_spend_threshold", agent: agent.name }, line);
@@ -1790,12 +2005,38 @@ ${sanitized.text}`
1790
2005
  },
1791
2006
  timeout + 5e3
1792
2007
  );
2008
+ await recordJobOutcome(agent, {
2009
+ jobEventId: jobId,
2010
+ capability: dTag,
2011
+ providerPubkey,
2012
+ providerName: clipProviderName(provider.name),
2013
+ paidAmountSubunits: paidAmountSubunits?.toString(),
2014
+ assetKey: paidAssetKey,
2015
+ status: "completed",
2016
+ submittedAt,
2017
+ completedAt: Date.now(),
2018
+ resultPreview: result.slice(0, 500),
2019
+ paymentSig
2020
+ });
1793
2021
  const warningBlock = paymentWarnings.length > 0 ? `${paymentWarnings.join("\n")}
1794
2022
  ` : "";
2023
+ const tip = buildJobCompletionTip(jobId, input.provider_npub);
1795
2024
  return textResult(`${warningBlock}event_id=${jobId}
1796
- ${result}`);
2025
+ ${result}${tip}`);
1797
2026
  } catch (e) {
1798
2027
  const msg = e instanceof Error ? e.message : String(e);
2028
+ await recordJobOutcome(agent, {
2029
+ jobEventId: jobId,
2030
+ capability: dTag,
2031
+ providerPubkey,
2032
+ providerName: clipProviderName(provider.name),
2033
+ paidAmountSubunits: paidAmountSubunits?.toString(),
2034
+ assetKey: paidAssetKey,
2035
+ status: classifyJobFailure(msg),
2036
+ submittedAt,
2037
+ completedAt: Date.now(),
2038
+ paymentSig
2039
+ });
1799
2040
  const paid = paymentSig ? ` Payment already sent (sig=${paymentSig}) - use get_job_result with event_id="${jobId}" to retrieve once ready.` : "";
1800
2041
  const warningBlock = paymentWarnings.length > 0 ? `${paymentWarnings.join("\n")}
1801
2042
  ` : "";
@@ -1847,6 +2088,111 @@ ${text}`);
1847
2088
  }
1848
2089
  })
1849
2090
  ];
2091
+ var CONTACTS_FILENAME = ".contacts.json";
2092
+ var ContactSchema = z.object({
2093
+ pubkey: z.string().regex(/^[a-f0-9]{64}$/),
2094
+ npub: z.string().min(1).max(80),
2095
+ name: z.string().max(200).optional(),
2096
+ addedAt: z.number().int().nonnegative(),
2097
+ lastJobAt: z.number().int().nonnegative().optional(),
2098
+ jobCount: z.number().int().nonnegative(),
2099
+ lastCapability: z.string().max(200).optional(),
2100
+ note: z.string().max(500).optional()
2101
+ }).strict();
2102
+ var ContactsSchema = z.object({
2103
+ version: z.literal(1),
2104
+ contacts: z.array(ContactSchema)
2105
+ }).strict();
2106
+ var EMPTY2 = { version: 1, contacts: [] };
2107
+ var writeLocks2 = /* @__PURE__ */ new Map();
2108
+ function withLock2(path, fn) {
2109
+ const previous = writeLocks2.get(path) ?? Promise.resolve();
2110
+ const next = previous.then(fn, fn);
2111
+ writeLocks2.set(
2112
+ path,
2113
+ next.finally(() => {
2114
+ if (writeLocks2.get(path) === next) {
2115
+ writeLocks2.delete(path);
2116
+ }
2117
+ })
2118
+ );
2119
+ return next;
2120
+ }
2121
+ function pathFor2(agentDir) {
2122
+ return join(agentDir, CONTACTS_FILENAME);
2123
+ }
2124
+ async function readRaw2(path) {
2125
+ let raw;
2126
+ try {
2127
+ raw = await readFile(path, "utf-8");
2128
+ } catch {
2129
+ return { ...EMPTY2, contacts: [] };
2130
+ }
2131
+ try {
2132
+ const parsed = JSON.parse(raw);
2133
+ const result = ContactsSchema.safeParse(parsed);
2134
+ return result.success ? result.data : { ...EMPTY2, contacts: [] };
2135
+ } catch {
2136
+ return { ...EMPTY2, contacts: [] };
2137
+ }
2138
+ }
2139
+ async function writeRaw2(path, contacts) {
2140
+ const body = JSON.stringify(contacts, null, 2) + "\n";
2141
+ await writeFileAtomic$1(path, body, 384);
2142
+ }
2143
+ async function readContacts(agentDir) {
2144
+ return readRaw2(pathFor2(agentDir));
2145
+ }
2146
+ async function upsertContact(agentDir, input) {
2147
+ const path = pathFor2(agentDir);
2148
+ return withLock2(path, async () => {
2149
+ const data = await readRaw2(path);
2150
+ const index = data.contacts.findIndex((existing) => existing.pubkey === input.pubkey);
2151
+ let merged;
2152
+ if (index >= 0) {
2153
+ const existing = data.contacts[index];
2154
+ merged = ContactSchema.parse({
2155
+ ...existing,
2156
+ npub: input.npub,
2157
+ name: input.name ?? existing.name,
2158
+ note: input.note ?? existing.note,
2159
+ lastJobAt: input.lastJobAt ?? existing.lastJobAt,
2160
+ lastCapability: input.lastCapability ?? existing.lastCapability,
2161
+ jobCount: input.jobCount ?? existing.jobCount + 1
2162
+ });
2163
+ data.contacts[index] = merged;
2164
+ } else {
2165
+ merged = ContactSchema.parse({
2166
+ pubkey: input.pubkey,
2167
+ npub: input.npub,
2168
+ name: input.name,
2169
+ note: input.note,
2170
+ addedAt: Date.now(),
2171
+ lastJobAt: input.lastJobAt,
2172
+ lastCapability: input.lastCapability,
2173
+ jobCount: input.jobCount ?? 0
2174
+ });
2175
+ data.contacts.push(merged);
2176
+ }
2177
+ await writeRaw2(path, data);
2178
+ return merged;
2179
+ });
2180
+ }
2181
+ async function removeContact(agentDir, pubkey) {
2182
+ const path = pathFor2(agentDir);
2183
+ return withLock2(path, async () => {
2184
+ const data = await readRaw2(path);
2185
+ const before = data.contacts.length;
2186
+ data.contacts = data.contacts.filter((existing) => existing.pubkey !== pubkey);
2187
+ if (data.contacts.length === before) {
2188
+ return false;
2189
+ }
2190
+ await writeRaw2(path, data);
2191
+ return true;
2192
+ });
2193
+ }
2194
+
2195
+ // src/tools/discovery.ts
1850
2196
  var SEARCH_PING_TIMEOUT_MS = 3e3;
1851
2197
  var STOP_WORDS = /* @__PURE__ */ new Set([
1852
2198
  "a",
@@ -1983,6 +2329,9 @@ var SearchAgentsSchema = z.object({
1983
2329
  max_price_lamports: z.number().int().optional(),
1984
2330
  include_offline: z.boolean().default(false).describe(
1985
2331
  "If true, skip the live online check and return agents regardless of reachability. Default: false - only currently-online agents are returned."
2332
+ ),
2333
+ contacts_only: z.boolean().default(false).describe(
2334
+ "If true, restrict results to providers saved in the active agent's .contacts.json. Each returned item gains a `last_worked_at` field."
1986
2335
  )
1987
2336
  });
1988
2337
  var ListCapabilitiesSchema = z.object({});
@@ -1993,13 +2342,31 @@ var discoveryTools = [
1993
2342
  description: "Search AI agents currently online on elisym. `capabilities` is a hard OR-filter of substring tokens from the user's request (never invent synonyms). `query` is optional re-ranking; omit if not needed. Offline agents are excluded by default - pass include_offline=true only when debugging.",
1994
2343
  schema: SearchAgentsSchema,
1995
2344
  async handler(ctx, input) {
1996
- const { capabilities, query, max_price_lamports, include_offline } = input;
2345
+ const { capabilities, query, max_price_lamports, include_offline, contacts_only } = input;
1997
2346
  if (capabilities.length > MAX_CAPABILITIES) {
1998
2347
  return errorResult(`Too many capabilities (max ${MAX_CAPABILITIES})`);
1999
2348
  }
2000
2349
  const agent = ctx.active();
2350
+ let lastWorkedAtByPubkey = /* @__PURE__ */ new Map();
2351
+ if (contacts_only) {
2352
+ if (!agent.agentDir) {
2353
+ return textResult(
2354
+ "contacts_only=true requires a persistent agent (no on-disk directory for the active agent)."
2355
+ );
2356
+ }
2357
+ const data = await readContacts(agent.agentDir);
2358
+ if (data.contacts.length === 0) {
2359
+ return textResult(
2360
+ "No contacts saved yet. Use add_contact (or rate a job positively with submit_feedback and then add_contact) before searching with contacts_only=true."
2361
+ );
2362
+ }
2363
+ lastWorkedAtByPubkey = new Map(
2364
+ data.contacts.map((contact) => [contact.pubkey, contact.lastJobAt ?? contact.addedAt])
2365
+ );
2366
+ }
2001
2367
  const agents = await agent.client.discovery.fetchAgents(agent.network);
2002
- let filtered = agents.filter(
2368
+ let filtered = contacts_only ? agents.filter((a) => lastWorkedAtByPubkey.has(a.pubkey)) : agents;
2369
+ filtered = filtered.filter(
2003
2370
  (a) => a.cards.some(
2004
2371
  (card) => capabilities.some(
2005
2372
  (cap) => card.capabilities?.some((c) => c.toLowerCase().includes(cap.toLowerCase())) || card.name?.toLowerCase().includes(cap.toLowerCase()) || card.description?.toLowerCase().includes(cap.toLowerCase())
@@ -2070,7 +2437,8 @@ var discoveryTools = [
2070
2437
  network: card.payment?.network
2071
2438
  };
2072
2439
  }),
2073
- supported_kinds: a.supportedKinds
2440
+ supported_kinds: a.supportedKinds,
2441
+ last_worked_at: contacts_only ? lastWorkedAtByPubkey.get(a.pubkey) : void 0
2074
2442
  }));
2075
2443
  const { text } = sanitizeUntrusted(JSON.stringify(results, null, 2), "structured");
2076
2444
  return textResult(text);
@@ -2119,6 +2487,184 @@ ${text}`);
2119
2487
  }
2120
2488
  })
2121
2489
  ];
2490
+ var SubmitFeedbackSchema = z.object({
2491
+ job_event_id: z.string().min(1).max(128).describe("Event ID returned by submit_and_pay_job, buy_capability, or create_job."),
2492
+ rating: z.enum(["positive", "negative"]),
2493
+ provider_npub: z.string().optional().describe(
2494
+ "Provider npub. Optional when the job is in local history (.customer-history.json); required when feedback is submitted for a job submitted from outside this MCP."
2495
+ )
2496
+ });
2497
+ var AddContactSchema = z.object({
2498
+ npub: z.string().min(1).max(MAX_NPUB_LEN),
2499
+ name: z.string().max(200).optional(),
2500
+ note: z.string().max(500).optional()
2501
+ });
2502
+ var RemoveContactSchema = z.object({
2503
+ npub: z.string().min(1).max(MAX_NPUB_LEN)
2504
+ });
2505
+ var ListContactsSchema = z.object({
2506
+ limit: z.number().int().min(1).max(200).default(50)
2507
+ });
2508
+ function npubFromHex(pubkey) {
2509
+ return nip19.npubEncode(pubkey);
2510
+ }
2511
+ var feedbackContactsTools = [
2512
+ defineTool({
2513
+ name: "submit_feedback",
2514
+ description: 'Rate a completed job (mirrors the web app \u{1F44D}/\u{1F44E} buttons). Publishes a NIP-90 kind 7000 feedback event with rating="1" (positive) or "0" (negative). Idempotent on (job_event_id, rating) - calling twice with the same rating is a no-op. After a positive rating, the response suggests calling add_contact to save the provider for future search_agents queries.',
2515
+ schema: SubmitFeedbackSchema,
2516
+ async handler(ctx, input) {
2517
+ ctx.toolRateLimiter.check();
2518
+ checkLen("job_event_id", input.job_event_id, MAX_EVENT_ID_LEN);
2519
+ if (input.provider_npub) {
2520
+ checkLen("provider_npub", input.provider_npub, MAX_NPUB_LEN);
2521
+ }
2522
+ const agent = ctx.active();
2523
+ const localEntry = agent.agentDir ? await findCustomerJob(agent.agentDir, input.job_event_id) : void 0;
2524
+ let providerPubkey = localEntry?.providerPubkey;
2525
+ if (!providerPubkey && input.provider_npub) {
2526
+ try {
2527
+ providerPubkey = decodeNpub(input.provider_npub);
2528
+ } catch (e) {
2529
+ return errorResult(
2530
+ `Invalid provider_npub: ${e instanceof Error ? e.message : String(e)}`
2531
+ );
2532
+ }
2533
+ }
2534
+ if (!providerPubkey) {
2535
+ return errorResult(
2536
+ `Job "${input.job_event_id}" not found in local history. Pass provider_npub explicitly to rate a job submitted from outside this MCP.`
2537
+ );
2538
+ }
2539
+ if (localEntry?.customerFeedback === input.rating) {
2540
+ return textResult(`Already rated as ${input.rating}.`);
2541
+ }
2542
+ const capability = localEntry?.capability;
2543
+ const positive = input.rating === "positive";
2544
+ try {
2545
+ await agent.client.marketplace.submitFeedback(
2546
+ agent.identity,
2547
+ input.job_event_id,
2548
+ providerPubkey,
2549
+ positive,
2550
+ capability
2551
+ );
2552
+ } catch (e) {
2553
+ return errorResult(
2554
+ `Failed to publish feedback: ${e instanceof Error ? e.message : String(e)}`
2555
+ );
2556
+ }
2557
+ if (agent.agentDir && localEntry) {
2558
+ await updateCustomerJob(agent.agentDir, input.job_event_id, {
2559
+ customerFeedback: input.rating
2560
+ }).catch(() => {
2561
+ });
2562
+ }
2563
+ const npubForTip = input.provider_npub ?? npubFromHex(providerPubkey);
2564
+ if (positive) {
2565
+ return textResult(
2566
+ `Feedback recorded (rating=positive). Save this provider for future searches? Use add_contact (npub="${npubForTip}").`
2567
+ );
2568
+ }
2569
+ return textResult("Feedback recorded (rating=negative).");
2570
+ }
2571
+ }),
2572
+ defineTool({
2573
+ name: "add_contact",
2574
+ description: "Add a provider to the active agent's contacts list (.contacts.json). When the provider has prior jobs in the local history, the contact is enriched with jobCount, lastJobAt, and lastCapability. Idempotent: re-calling with the same npub updates name/note in place without duplicating the entry.",
2575
+ schema: AddContactSchema,
2576
+ async handler(ctx, input) {
2577
+ ctx.toolRateLimiter.check();
2578
+ checkLen("npub", input.npub, MAX_NPUB_LEN);
2579
+ const agent = ctx.active();
2580
+ if (!agent.agentDir) {
2581
+ return errorResult(
2582
+ "Cannot save contacts: the active agent is ephemeral (no on-disk directory). Create a persistent agent first with create_agent."
2583
+ );
2584
+ }
2585
+ let pubkey;
2586
+ try {
2587
+ pubkey = decodeNpub(input.npub);
2588
+ } catch (e) {
2589
+ return errorResult(`Invalid npub: ${e instanceof Error ? e.message : String(e)}`);
2590
+ }
2591
+ const history = await findCustomerJobsByProvider(agent.agentDir, pubkey);
2592
+ const last = history[0];
2593
+ const cleanName = input.name !== void 0 ? sanitizeField(input.name, 200) : void 0;
2594
+ const cleanNote = input.note !== void 0 ? sanitizeField(input.note, 500) : void 0;
2595
+ const fallbackProviderName = last?.providerName !== void 0 ? sanitizeField(last.providerName, 200) : void 0;
2596
+ const contact = await upsertContact(agent.agentDir, {
2597
+ pubkey,
2598
+ npub: input.npub,
2599
+ name: cleanName ?? fallbackProviderName,
2600
+ note: cleanNote,
2601
+ lastJobAt: last?.completedAt,
2602
+ lastCapability: last?.capability,
2603
+ jobCount: history.length
2604
+ });
2605
+ const lines = [
2606
+ `Saved contact ${contact.npub}.`,
2607
+ contact.name ? ` name: ${contact.name}` : null,
2608
+ contact.lastCapability ? ` last capability: ${contact.lastCapability}` : null,
2609
+ contact.jobCount > 0 ? ` prior jobs: ${contact.jobCount}` : null
2610
+ ].filter((line) => line !== null);
2611
+ return textResult(lines.join("\n"));
2612
+ }
2613
+ }),
2614
+ defineTool({
2615
+ name: "remove_contact",
2616
+ description: "Remove a provider from the active agent's contacts list.",
2617
+ schema: RemoveContactSchema,
2618
+ async handler(ctx, input) {
2619
+ ctx.toolRateLimiter.check();
2620
+ checkLen("npub", input.npub, MAX_NPUB_LEN);
2621
+ const agent = ctx.active();
2622
+ if (!agent.agentDir) {
2623
+ return errorResult("Active agent is ephemeral; nothing to remove.");
2624
+ }
2625
+ let pubkey;
2626
+ try {
2627
+ pubkey = decodeNpub(input.npub);
2628
+ } catch (e) {
2629
+ return errorResult(`Invalid npub: ${e instanceof Error ? e.message : String(e)}`);
2630
+ }
2631
+ const removed = await removeContact(agent.agentDir, pubkey);
2632
+ return removed ? textResult(`Removed contact ${input.npub}.`) : textResult(`No contact found for ${input.npub}.`);
2633
+ }
2634
+ }),
2635
+ defineTool({
2636
+ name: "list_contacts",
2637
+ description: "List providers saved in the active agent's .contacts.json, newest activity first. Use search_agents with contacts_only=true to combine this with online/capability filters.",
2638
+ schema: ListContactsSchema,
2639
+ async handler(ctx, input) {
2640
+ ctx.toolRateLimiter.check();
2641
+ const agent = ctx.active();
2642
+ if (!agent.agentDir) {
2643
+ return textResult(
2644
+ "Active agent is ephemeral; no on-disk contacts. Create a persistent agent first."
2645
+ );
2646
+ }
2647
+ const data = await readContacts(agent.agentDir);
2648
+ const sorted = [...data.contacts].sort((left, right) => {
2649
+ const leftKey = left.lastJobAt ?? left.addedAt;
2650
+ const rightKey = right.lastJobAt ?? right.addedAt;
2651
+ return rightKey - leftKey;
2652
+ });
2653
+ const limited = sorted.slice(0, input.limit).map((contact) => ({
2654
+ npub: contact.npub,
2655
+ name: contact.name !== void 0 ? sanitizeField(contact.name, 200) : void 0,
2656
+ note: contact.note !== void 0 ? sanitizeField(contact.note, 500) : void 0,
2657
+ added_at: contact.addedAt,
2658
+ last_job_at: contact.lastJobAt,
2659
+ last_capability: contact.lastCapability !== void 0 ? sanitizeField(contact.lastCapability, 200) : void 0,
2660
+ job_count: contact.jobCount
2661
+ }));
2662
+ const { text: wrapped } = sanitizeUntrusted(JSON.stringify(limited, null, 2), "structured");
2663
+ return textResult(`${limited.length} contact(s):
2664
+ ${wrapped}`);
2665
+ }
2666
+ })
2667
+ ];
2122
2668
  var GetBalanceSchema = z.object({});
2123
2669
  var EstimatePaymentCostSchema = z.object({
2124
2670
  payment_request: z.string().describe(
@@ -2625,7 +3171,8 @@ var allTools = [
2625
3171
  ...customerTools,
2626
3172
  ...walletTools,
2627
3173
  ...dashboardTools,
2628
- ...agentTools
3174
+ ...agentTools,
3175
+ ...feedbackContactsTools
2629
3176
  ];
2630
3177
  var toolMap = /* @__PURE__ */ new Map();
2631
3178
  for (const tool of allTools) {