@aibtc/mcp-server 1.78.0 → 1.79.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/services/inbox-auto-reply.service.d.ts +52 -20
- package/dist/services/inbox-auto-reply.service.js +187 -45
- package/dist/services/x402-post.js +7 -6
- package/dist/services/x402.service.d.ts +30 -2
- package/dist/services/x402.service.js +22 -1
- package/dist/tools/endpoint.tools.js +41 -3
- package/dist/tools/inbox-auto-reply.tools.d.ts +2 -2
- package/dist/tools/inbox-auto-reply.tools.js +46 -11
- package/dist/utils/storage.d.ts +2 -0
- package/dist/utils/x402-recovery.d.ts +4 -1
- package/dist/utils/x402-recovery.js +8 -2
- package/package.json +1 -1
- package/skill/SKILL.md +9 -5
|
@@ -17,7 +17,11 @@ export declare const INFERENCE_GATEWAY = "https://inference.aibtc.com";
|
|
|
17
17
|
/** The largest model on the marketplace at the flat 1-sat price when this shipped. */
|
|
18
18
|
export declare const DEFAULT_AUTO_REPLY_MODEL = "meta-llama/Llama-3.1-8B-Instruct";
|
|
19
19
|
export declare const DEFAULT_DAILY_CAP_SATS = 1000;
|
|
20
|
+
/** Most sats paid for one reply. The marketplace charges 1 sat today. */
|
|
21
|
+
export declare const DEFAULT_MAX_SATS_PER_REPLY = 10;
|
|
20
22
|
export declare const DEFAULT_MAX_REPLIES_PER_RUN = 5;
|
|
23
|
+
/** Paid inference failures on one message before runs stop paying for it. */
|
|
24
|
+
export declare const MAX_PAID_ATTEMPTS = 2;
|
|
21
25
|
/** The outbox rejects longer replies (validation_failed). */
|
|
22
26
|
export declare const MAX_REPLY_CHARS = 500;
|
|
23
27
|
export interface InboxMessage {
|
|
@@ -36,8 +40,9 @@ export interface InboxMessage {
|
|
|
36
40
|
peerBtcAddress?: string;
|
|
37
41
|
peerDisplayName?: string;
|
|
38
42
|
}
|
|
39
|
-
export type SkipReason = "already_replied" | "already_handled" | "unpaid" | "payment_not_settled" | "from_self" | "run_limit" | "daily_cap" | "rate_limited";
|
|
40
|
-
|
|
43
|
+
export type SkipReason = "already_replied" | "already_handled" | "unpaid" | "payment_not_settled" | "from_self" | "run_limit" | "in_progress" | "gave_up" | "daily_cap" | "price_ceiling" | "spend_limit" | "rate_limited";
|
|
44
|
+
type StopReason = "daily_cap" | "price_ceiling" | "spend_limit" | "rate_limited";
|
|
45
|
+
export interface AddressLedger {
|
|
41
46
|
/** messageId -> ISO time the reply was posted (or found already posted). */
|
|
42
47
|
handled: Record<string, string>;
|
|
43
48
|
/**
|
|
@@ -47,6 +52,18 @@ interface AddressLedger {
|
|
|
47
52
|
pending: Record<string, string>;
|
|
48
53
|
/** UTC day -> sats spent on auto-reply inference that day. */
|
|
49
54
|
spentSats: Record<string, number>;
|
|
55
|
+
/** messageId -> ISO time a run claimed it. Stops two runs paying for one message. */
|
|
56
|
+
claimed: Record<string, string>;
|
|
57
|
+
/** messageId -> paid inference attempts that failed. At MAX_PAID_ATTEMPTS it is given up. */
|
|
58
|
+
paidFailures: Record<string, number>;
|
|
59
|
+
/**
|
|
60
|
+
* messageId -> a dry-run draft and the prompt/model key it was written with.
|
|
61
|
+
* Later runs with the same key reuse it instead of paying again.
|
|
62
|
+
*/
|
|
63
|
+
drafts: Record<string, {
|
|
64
|
+
text: string;
|
|
65
|
+
key: string;
|
|
66
|
+
}>;
|
|
50
67
|
}
|
|
51
68
|
/** Point the ledger at another file (tests). */
|
|
52
69
|
export declare function setAutoReplyLedgerFile(file: string): void;
|
|
@@ -57,6 +74,11 @@ export declare class DailyCapError extends Error {
|
|
|
57
74
|
readonly price: number;
|
|
58
75
|
constructor(spent: number, cap: number, price: number);
|
|
59
76
|
}
|
|
77
|
+
export declare class PriceCeilingError extends Error {
|
|
78
|
+
readonly price: number;
|
|
79
|
+
readonly max: number;
|
|
80
|
+
constructor(price: number, max: number);
|
|
81
|
+
}
|
|
60
82
|
export declare function fetchInbox(btcAddress: string): Promise<InboxMessage[]>;
|
|
61
83
|
/**
|
|
62
84
|
* Why a message must not be auto-answered, or null when it may be. A message
|
|
@@ -83,6 +105,8 @@ export interface GeneratedReply {
|
|
|
83
105
|
* agent's own model, plugs in here without touching the inbox side.
|
|
84
106
|
*/
|
|
85
107
|
export interface ReplyGenerator {
|
|
108
|
+
/** The model behind it, so a dry-run draft is reused only by the same source. */
|
|
109
|
+
readonly id?: string;
|
|
86
110
|
generate(input: {
|
|
87
111
|
systemPrompt: string;
|
|
88
112
|
message: InboxMessage;
|
|
@@ -92,6 +116,13 @@ export interface ReplyGenerator {
|
|
|
92
116
|
}
|
|
93
117
|
/** Strip reasoning blocks some models emit, collapse whitespace, fit the outbox limit. */
|
|
94
118
|
export declare function cleanReply(raw: string): string;
|
|
119
|
+
/**
|
|
120
|
+
* The inference was paid for but its response is not a usable reply. The run
|
|
121
|
+
* keeps the cap booking and counts it as a paid failure.
|
|
122
|
+
*/
|
|
123
|
+
export declare class PaidInferenceError extends Error {
|
|
124
|
+
constructor(message: string);
|
|
125
|
+
}
|
|
95
126
|
export declare function marketplaceReplyGenerator(model: string): ReplyGenerator;
|
|
96
127
|
/** Live model ids on the marketplace (status "live"). */
|
|
97
128
|
export declare function listLiveModels(): Promise<string[]>;
|
|
@@ -112,41 +143,42 @@ export interface AutoReplyRunOptions {
|
|
|
112
143
|
account: Account;
|
|
113
144
|
systemPrompt: string;
|
|
114
145
|
dailyCapSats: number;
|
|
146
|
+
/** Most sats paid for one reply. A higher quote stops the run before anything is signed. */
|
|
147
|
+
maxSatsPerReply: number;
|
|
115
148
|
maxRepliesPerRun: number;
|
|
116
149
|
dryRun: boolean;
|
|
117
150
|
generator: ReplyGenerator;
|
|
118
151
|
}
|
|
152
|
+
interface ReplyRecord {
|
|
153
|
+
messageId: string;
|
|
154
|
+
from: string;
|
|
155
|
+
reply: string;
|
|
156
|
+
inferenceCostSats: number;
|
|
157
|
+
inferenceTxid?: string;
|
|
158
|
+
provider?: string;
|
|
159
|
+
/** The reply was paid for in an earlier run whose post failed. */
|
|
160
|
+
reusedPending?: boolean;
|
|
161
|
+
/** The reply is a dry-run draft written with the same prompt and model. */
|
|
162
|
+
reusedDraft?: boolean;
|
|
163
|
+
}
|
|
119
164
|
export interface AutoReplyRunSummary {
|
|
120
165
|
checked: number;
|
|
121
166
|
eligible: number;
|
|
122
|
-
replied:
|
|
123
|
-
|
|
124
|
-
from: string;
|
|
125
|
-
reply: string;
|
|
126
|
-
inferenceCostSats: number;
|
|
127
|
-
inferenceTxid?: string;
|
|
128
|
-
provider?: string;
|
|
129
|
-
reusedPending?: boolean;
|
|
130
|
-
}>;
|
|
131
|
-
drafted: Array<{
|
|
132
|
-
messageId: string;
|
|
133
|
-
from: string;
|
|
134
|
-
reply: string;
|
|
135
|
-
inferenceCostSats: number;
|
|
136
|
-
inferenceTxid?: string;
|
|
137
|
-
provider?: string;
|
|
138
|
-
}>;
|
|
167
|
+
replied: ReplyRecord[];
|
|
168
|
+
drafted: ReplyRecord[];
|
|
139
169
|
skipped: Array<{
|
|
140
170
|
messageId: string;
|
|
141
171
|
reason: SkipReason;
|
|
142
172
|
}>;
|
|
173
|
+
/** `gaveUp`: the message failed after payment MAX_PAID_ATTEMPTS times and is no longer retried. */
|
|
143
174
|
failed: Array<{
|
|
144
175
|
messageId: string;
|
|
145
176
|
error: string;
|
|
146
177
|
replySaved: boolean;
|
|
178
|
+
gaveUp?: boolean;
|
|
147
179
|
}>;
|
|
148
180
|
/** Why the run stopped before working through every eligible message. */
|
|
149
|
-
stoppedEarly?:
|
|
181
|
+
stoppedEarly?: StopReason;
|
|
150
182
|
inferenceSatsSpent: number;
|
|
151
183
|
inferenceSatsSpentToday: number;
|
|
152
184
|
inferenceCapRemainingSats: number;
|
|
@@ -11,19 +11,31 @@
|
|
|
11
11
|
* Spend is bounded twice: the auto-reply daily cap below (its own ledger), and
|
|
12
12
|
* the wallet-wide spend limiter that books every x402 payment.
|
|
13
13
|
*/
|
|
14
|
+
import { createHash } from "crypto";
|
|
14
15
|
import os from "os";
|
|
15
16
|
import path from "path";
|
|
16
17
|
import { promises as fs } from "fs";
|
|
17
18
|
import { p2wpkh, NETWORK as BTC_MAINNET } from "@scure/btc-signer";
|
|
18
19
|
import { bip322Sign } from "../utils/bip322.js";
|
|
19
20
|
import { withSharedStateLock } from "./state-lock.js";
|
|
21
|
+
import { SpendLimitError } from "./spend-limiter.js";
|
|
20
22
|
import { postWithX402Payment, paymentWasSent } from "./x402-post.js";
|
|
21
23
|
export const INBOX_API = "https://aibtc.com/api";
|
|
22
24
|
export const INFERENCE_GATEWAY = "https://inference.aibtc.com";
|
|
23
25
|
/** The largest model on the marketplace at the flat 1-sat price when this shipped. */
|
|
24
26
|
export const DEFAULT_AUTO_REPLY_MODEL = "meta-llama/Llama-3.1-8B-Instruct";
|
|
25
27
|
export const DEFAULT_DAILY_CAP_SATS = 1000;
|
|
28
|
+
/** Most sats paid for one reply. The marketplace charges 1 sat today. */
|
|
29
|
+
export const DEFAULT_MAX_SATS_PER_REPLY = 10;
|
|
26
30
|
export const DEFAULT_MAX_REPLIES_PER_RUN = 5;
|
|
31
|
+
/** Paid inference failures on one message before runs stop paying for it. */
|
|
32
|
+
export const MAX_PAID_ATTEMPTS = 2;
|
|
33
|
+
/**
|
|
34
|
+
* How long a run's claim on a message blocks other runs (this server or the
|
|
35
|
+
* skills CLI, which share the ledger). Longer than one message takes, so only
|
|
36
|
+
* a claim left by a crashed run expires.
|
|
37
|
+
*/
|
|
38
|
+
const CLAIM_TTL_MS = 10 * 60_000;
|
|
27
39
|
/** The outbox rejects longer replies (validation_failed). */
|
|
28
40
|
export const MAX_REPLY_CHARS = 500;
|
|
29
41
|
/** One page, newest first. Older unanswered messages are out of reach of a run. */
|
|
@@ -56,7 +68,14 @@ async function writeLedger(state) {
|
|
|
56
68
|
await fs.rename(tmp, ledgerFile);
|
|
57
69
|
}
|
|
58
70
|
function ledgerFor(state, btcAddress) {
|
|
59
|
-
const entry = (state[btcAddress] ??= {
|
|
71
|
+
const entry = (state[btcAddress] ??= {});
|
|
72
|
+
// Fields added later (and by the skills CLI, which shares this file) may be missing.
|
|
73
|
+
entry.handled ??= {};
|
|
74
|
+
entry.pending ??= {};
|
|
75
|
+
entry.spentSats ??= {};
|
|
76
|
+
entry.claimed ??= {};
|
|
77
|
+
entry.paidFailures ??= {};
|
|
78
|
+
entry.drafts ??= {};
|
|
60
79
|
// Keep only today's spend: the cap is per UTC day.
|
|
61
80
|
const today = todayKey();
|
|
62
81
|
for (const day of Object.keys(entry.spentSats)) {
|
|
@@ -109,6 +128,47 @@ async function releaseDailySats(btcAddress, sats) {
|
|
|
109
128
|
ledger.spentSats[day] = Math.max(0, (ledger.spentSats[day] ?? 0) - sats);
|
|
110
129
|
});
|
|
111
130
|
}
|
|
131
|
+
export class PriceCeilingError extends Error {
|
|
132
|
+
price;
|
|
133
|
+
max;
|
|
134
|
+
constructor(price, max) {
|
|
135
|
+
super(`Reply inference costs ${price} sats, above the ${max}-sat per-reply ceiling.`);
|
|
136
|
+
this.price = price;
|
|
137
|
+
this.max = max;
|
|
138
|
+
this.name = "PriceCeilingError";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Claim a message for this run under the ledger lock, re-reading the ledger
|
|
143
|
+
* so a run that started earlier and answered (or claimed) it in the meantime
|
|
144
|
+
* wins. Returns a reply already paid for, if there is one.
|
|
145
|
+
*/
|
|
146
|
+
async function claimMessage(btcAddress, messageId, draftKey) {
|
|
147
|
+
return updateLedger(btcAddress, (ledger) => {
|
|
148
|
+
if (ledger.handled[messageId])
|
|
149
|
+
return { skip: "already_handled" };
|
|
150
|
+
if ((ledger.paidFailures[messageId] ?? 0) >= MAX_PAID_ATTEMPTS)
|
|
151
|
+
return { skip: "gave_up" };
|
|
152
|
+
const claimedAt = Date.parse(ledger.claimed[messageId] ?? "");
|
|
153
|
+
if (Date.now() - claimedAt < CLAIM_TTL_MS)
|
|
154
|
+
return { skip: "in_progress" };
|
|
155
|
+
ledger.claimed[messageId] = new Date().toISOString();
|
|
156
|
+
const pending = ledger.pending[messageId];
|
|
157
|
+
if (pending)
|
|
158
|
+
return { saved: { text: pending, from: "pending" } };
|
|
159
|
+
const draft = ledger.drafts[messageId];
|
|
160
|
+
if (draft?.key === draftKey)
|
|
161
|
+
return { saved: { text: draft.text, from: "draft" } };
|
|
162
|
+
return {};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Identifies the prompt and model a draft was written with. Same key as the
|
|
167
|
+
* skills CLI, so a draft written by one is reused by the other.
|
|
168
|
+
*/
|
|
169
|
+
function draftKeyFor(systemPrompt, generatorId) {
|
|
170
|
+
return createHash("sha256").update(`${generatorId ?? ""}\n${systemPrompt}`).digest("hex").slice(0, 16);
|
|
171
|
+
}
|
|
112
172
|
// ============================================================================
|
|
113
173
|
// Inbox read + eligibility
|
|
114
174
|
// ============================================================================
|
|
@@ -137,6 +197,8 @@ export function skipReason(message, self, ledger) {
|
|
|
137
197
|
return "already_replied";
|
|
138
198
|
if (ledger.handled[message.messageId])
|
|
139
199
|
return "already_handled";
|
|
200
|
+
if ((ledger.paidFailures[message.messageId] ?? 0) >= MAX_PAID_ATTEMPTS)
|
|
201
|
+
return "gave_up";
|
|
140
202
|
if (message.fromAddress === self.stxAddress || message.peerBtcAddress === self.btcAddress) {
|
|
141
203
|
return "from_self";
|
|
142
204
|
}
|
|
@@ -164,13 +226,32 @@ function userPrompt(message) {
|
|
|
164
226
|
}
|
|
165
227
|
/** Strip reasoning blocks some models emit, collapse whitespace, fit the outbox limit. */
|
|
166
228
|
export function cleanReply(raw) {
|
|
167
|
-
const text = raw
|
|
229
|
+
const text = raw
|
|
230
|
+
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
|
231
|
+
.replace(/<think>[\s\S]*$/i, "") // unterminated block: the model ran out of tokens mid-thought
|
|
232
|
+
.replace(/\s+/g, " ")
|
|
233
|
+
.trim();
|
|
168
234
|
if (text.length <= MAX_REPLY_CHARS)
|
|
169
235
|
return text;
|
|
170
236
|
return text.slice(0, MAX_REPLY_CHARS - 1).trimEnd() + "…";
|
|
171
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* The inference was paid for but its response is not a usable reply. The run
|
|
240
|
+
* keeps the cap booking and counts it as a paid failure.
|
|
241
|
+
*/
|
|
242
|
+
export class PaidInferenceError extends Error {
|
|
243
|
+
constructor(message) {
|
|
244
|
+
super(message);
|
|
245
|
+
this.name = "PaidInferenceError";
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Whether a failed generation had already paid for the inference. */
|
|
249
|
+
function inferencePaid(error) {
|
|
250
|
+
return error instanceof PaidInferenceError || paymentWasSent(error);
|
|
251
|
+
}
|
|
172
252
|
export function marketplaceReplyGenerator(model) {
|
|
173
253
|
return {
|
|
254
|
+
id: model,
|
|
174
255
|
async generate({ systemPrompt, message, beforePay }) {
|
|
175
256
|
let price = 0;
|
|
176
257
|
const result = await postWithX402Payment({
|
|
@@ -196,14 +277,25 @@ export function marketplaceReplyGenerator(model) {
|
|
|
196
277
|
const detail = typeof res.data === "string" ? res.data : JSON.stringify(res.data);
|
|
197
278
|
throw new Error(`Inference request failed (${res.status}): ${detail?.slice(0, 300)}`, { cause: error });
|
|
198
279
|
});
|
|
199
|
-
|
|
200
|
-
const
|
|
280
|
+
// A bad body after payment was still paid for: say so, so the run keeps the booking.
|
|
281
|
+
const fail = (reason) => (result.paid ? new PaidInferenceError(reason) : new Error(reason));
|
|
282
|
+
let data = result.paid ? result.response.data : result.text;
|
|
283
|
+
if (typeof data === "string") {
|
|
284
|
+
try {
|
|
285
|
+
data = JSON.parse(data);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
throw fail(`Inference response is not JSON: ${data.slice(0, 300)}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const content = data?.choices?.[0]?.message
|
|
292
|
+
?.content;
|
|
201
293
|
if (typeof content !== "string") {
|
|
202
|
-
throw
|
|
294
|
+
throw fail(`Inference response has no choices[0].message.content: ${JSON.stringify(data)?.slice(0, 300)}`);
|
|
203
295
|
}
|
|
204
296
|
const text = cleanReply(content);
|
|
205
297
|
if (!text)
|
|
206
|
-
throw
|
|
298
|
+
throw fail("Model returned an empty reply");
|
|
207
299
|
const pricing = result.paid
|
|
208
300
|
? result.accept.extra?.pricing
|
|
209
301
|
: undefined;
|
|
@@ -258,13 +350,22 @@ export async function postReply(account, btcAddress, messageId, reply) {
|
|
|
258
350
|
}
|
|
259
351
|
throw new Error(`Outbox reply failed (${res.status}): ${await res.text()}`);
|
|
260
352
|
}
|
|
353
|
+
function requireSats(name, value) {
|
|
354
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
355
|
+
throw new Error(`${name} must be a non-negative integer number of sats, got ${value}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
261
358
|
export async function runAutoReply(opts) {
|
|
262
|
-
const { account, systemPrompt, dailyCapSats, maxRepliesPerRun, dryRun, generator } = opts;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
359
|
+
const { account, systemPrompt, dailyCapSats, maxSatsPerReply, maxRepliesPerRun, dryRun, generator } = opts;
|
|
360
|
+
// Without a number here `spent + price > cap` is always false: no cap at all.
|
|
361
|
+
requireSats("dailyCapSats", dailyCapSats);
|
|
362
|
+
requireSats("maxSatsPerReply", maxSatsPerReply);
|
|
363
|
+
if (!account.btcAddress?.startsWith("bc1q")) {
|
|
364
|
+
throw new Error(`Auto-reply signs with the wallet's native SegWit (bc1q) key; this wallet's BTC address is ${account.btcAddress ?? "missing"}.`);
|
|
266
365
|
}
|
|
366
|
+
const btcAddress = account.btcAddress;
|
|
267
367
|
const self = { btcAddress, stxAddress: account.address };
|
|
368
|
+
const draftKey = draftKeyFor(systemPrompt, generator.id);
|
|
268
369
|
const messages = await fetchInbox(btcAddress);
|
|
269
370
|
const ledger = await readAddressLedger(btcAddress);
|
|
270
371
|
const summary = {
|
|
@@ -288,23 +389,13 @@ export async function runAutoReply(opts) {
|
|
|
288
389
|
eligible.push(message);
|
|
289
390
|
}
|
|
290
391
|
summary.eligible = eligible.length;
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (summary.stoppedEarly) {
|
|
294
|
-
summary.skipped.push({ messageId: message.messageId, reason: summary.stoppedEarly });
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
if (processed >= maxRepliesPerRun) {
|
|
298
|
-
summary.skipped.push({ messageId: message.messageId, reason: "run_limit" });
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
301
|
-
processed++;
|
|
392
|
+
async function answer(message, saved) {
|
|
393
|
+
const id = message.messageId;
|
|
302
394
|
const from = message.peerBtcAddress ?? message.fromAddress;
|
|
303
|
-
// A reply paid for in an earlier run whose post failed: post it, don't pay again.
|
|
304
|
-
const saved = dryRun ? undefined : ledger.pending[message.messageId];
|
|
305
395
|
let generated;
|
|
306
396
|
if (saved) {
|
|
307
|
-
|
|
397
|
+
// Paid for already (a failed post, or a dry-run draft): use it, don't pay again.
|
|
398
|
+
generated = { text: saved.text, inferenceCostSats: 0 };
|
|
308
399
|
}
|
|
309
400
|
else {
|
|
310
401
|
let booked = 0;
|
|
@@ -313,66 +404,117 @@ export async function runAutoReply(opts) {
|
|
|
313
404
|
systemPrompt,
|
|
314
405
|
message,
|
|
315
406
|
beforePay: async (price) => {
|
|
407
|
+
if (!Number.isInteger(price) || price < 0)
|
|
408
|
+
throw new Error(`Refusing an invalid inference price: ${price}`);
|
|
409
|
+
if (price > maxSatsPerReply)
|
|
410
|
+
throw new PriceCeilingError(price, maxSatsPerReply);
|
|
316
411
|
await reserveDailySats(btcAddress, price, dailyCapSats);
|
|
317
412
|
booked = price;
|
|
318
413
|
},
|
|
319
414
|
});
|
|
320
415
|
}
|
|
321
416
|
catch (error) {
|
|
322
|
-
|
|
417
|
+
const paid = inferencePaid(error);
|
|
418
|
+
if (booked && !paid)
|
|
323
419
|
await releaseDailySats(btcAddress, booked);
|
|
324
|
-
if (error instanceof DailyCapError) {
|
|
325
|
-
summary.stoppedEarly =
|
|
326
|
-
|
|
327
|
-
|
|
420
|
+
if (error instanceof DailyCapError || error instanceof PriceCeilingError || error instanceof SpendLimitError) {
|
|
421
|
+
summary.stoppedEarly =
|
|
422
|
+
error instanceof DailyCapError ? "daily_cap" : error instanceof PriceCeilingError ? "price_ceiling" : "spend_limit";
|
|
423
|
+
summary.skipped.push({ messageId: id, reason: summary.stoppedEarly });
|
|
424
|
+
return;
|
|
328
425
|
}
|
|
329
|
-
|
|
330
|
-
|
|
426
|
+
let gaveUp = false;
|
|
427
|
+
if (paid) {
|
|
428
|
+
summary.inferenceSatsSpent += booked;
|
|
429
|
+
const attempts = await updateLedger(btcAddress, (l) => {
|
|
430
|
+
l.paidFailures[id] = (l.paidFailures[id] ?? 0) + 1;
|
|
431
|
+
return l.paidFailures[id];
|
|
432
|
+
});
|
|
433
|
+
gaveUp = attempts >= MAX_PAID_ATTEMPTS;
|
|
434
|
+
}
|
|
435
|
+
summary.failed.push({ messageId: id, error: errorText(error), replySaved: false, ...(gaveUp && { gaveUp }) });
|
|
436
|
+
return;
|
|
331
437
|
}
|
|
332
438
|
summary.inferenceSatsSpent += generated.inferenceCostSats;
|
|
333
439
|
}
|
|
334
440
|
const record = {
|
|
335
|
-
messageId:
|
|
441
|
+
messageId: id,
|
|
336
442
|
from,
|
|
337
443
|
reply: generated.text,
|
|
338
444
|
inferenceCostSats: generated.inferenceCostSats,
|
|
339
445
|
...(generated.txid && { inferenceTxid: generated.txid }),
|
|
340
446
|
...(generated.provider && { provider: generated.provider }),
|
|
447
|
+
...(saved?.from === "pending" && { reusedPending: true }),
|
|
448
|
+
...(saved?.from === "draft" && { reusedDraft: true }),
|
|
341
449
|
};
|
|
342
450
|
if (dryRun) {
|
|
451
|
+
if (!saved) {
|
|
452
|
+
await updateLedger(btcAddress, (l) => {
|
|
453
|
+
l.drafts[id] = { text: generated.text, key: draftKey };
|
|
454
|
+
});
|
|
455
|
+
}
|
|
343
456
|
summary.drafted.push(record);
|
|
344
|
-
|
|
457
|
+
return;
|
|
345
458
|
}
|
|
459
|
+
const savePending = () => updateLedger(btcAddress, (l) => {
|
|
460
|
+
l.pending[id] = generated.text;
|
|
461
|
+
delete l.drafts[id];
|
|
462
|
+
});
|
|
346
463
|
try {
|
|
347
|
-
const posted = await postReply(account, btcAddress,
|
|
464
|
+
const posted = await postReply(account, btcAddress, id, generated.text);
|
|
348
465
|
if (posted.status === "rate_limited") {
|
|
349
|
-
await
|
|
350
|
-
l.pending[message.messageId] = generated.text;
|
|
351
|
-
});
|
|
466
|
+
await savePending();
|
|
352
467
|
summary.failed.push({
|
|
353
|
-
messageId:
|
|
468
|
+
messageId: id,
|
|
354
469
|
error: `Outbox rate limited${posted.retryAfter ? `; retry after ${posted.retryAfter}s` : ""}`,
|
|
355
470
|
replySaved: true,
|
|
356
471
|
});
|
|
357
472
|
summary.stoppedEarly = "rate_limited";
|
|
358
|
-
|
|
473
|
+
return;
|
|
359
474
|
}
|
|
360
475
|
await updateLedger(btcAddress, (l) => {
|
|
361
|
-
l.handled[
|
|
362
|
-
delete l.pending[
|
|
476
|
+
l.handled[id] = posted.status === "posted" ? (posted.repliedAt ?? new Date().toISOString()) : "already_replied";
|
|
477
|
+
delete l.pending[id];
|
|
478
|
+
delete l.drafts[id];
|
|
479
|
+
delete l.paidFailures[id];
|
|
363
480
|
});
|
|
364
481
|
if (posted.status === "already_replied") {
|
|
365
|
-
summary.skipped.push({ messageId:
|
|
482
|
+
summary.skipped.push({ messageId: id, reason: "already_replied" });
|
|
366
483
|
}
|
|
367
484
|
else {
|
|
368
|
-
summary.replied.push(
|
|
485
|
+
summary.replied.push(record);
|
|
369
486
|
}
|
|
370
487
|
}
|
|
371
488
|
catch (error) {
|
|
489
|
+
await savePending();
|
|
490
|
+
summary.failed.push({ messageId: id, error: errorText(error), replySaved: true });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
let processed = 0;
|
|
494
|
+
for (const message of eligible) {
|
|
495
|
+
if (summary.stoppedEarly) {
|
|
496
|
+
summary.skipped.push({ messageId: message.messageId, reason: summary.stoppedEarly });
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (processed >= maxRepliesPerRun) {
|
|
500
|
+
summary.skipped.push({ messageId: message.messageId, reason: "run_limit" });
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
// Claim before paying: another run (this server or the skills CLI) may have
|
|
504
|
+
// answered or claimed it since the snapshot above.
|
|
505
|
+
const claim = await claimMessage(btcAddress, message.messageId, draftKey);
|
|
506
|
+
if (claim.skip) {
|
|
507
|
+
summary.skipped.push({ messageId: message.messageId, reason: claim.skip });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
processed++;
|
|
511
|
+
try {
|
|
512
|
+
await answer(message, claim.saved);
|
|
513
|
+
}
|
|
514
|
+
finally {
|
|
372
515
|
await updateLedger(btcAddress, (l) => {
|
|
373
|
-
l.
|
|
516
|
+
delete l.claimed[message.messageId];
|
|
374
517
|
});
|
|
375
|
-
summary.failed.push({ messageId: message.messageId, error: errorText(error), replySaved: true });
|
|
376
518
|
}
|
|
377
519
|
}
|
|
378
520
|
const after = await readAddressLedger(btcAddress);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
import { wrapAxiosWithPayment, decodePaymentRequired, decodePaymentResponse, X402_HEADERS, } from "x402-stacks";
|
|
3
|
-
import { createApiClient, getAccount, NETWORK } from "./x402.service.js";
|
|
3
|
+
import { createApiClient, getAccount, isSponsoredRequirement, NETWORK } from "./x402.service.js";
|
|
4
4
|
import { getSpendLimiter } from "./spend-limiter.js";
|
|
5
5
|
/**
|
|
6
6
|
* True when a failed x402-stacks request had already carried the signed
|
|
@@ -55,13 +55,13 @@ export async function postWithX402Payment(options) {
|
|
|
55
55
|
throw new Error("402 response missing or empty payment-required header");
|
|
56
56
|
}
|
|
57
57
|
const accept = paymentRequired.accepts[0];
|
|
58
|
-
|
|
59
|
-
// names a fee payer — mirror its check.
|
|
60
|
-
const sponsored = typeof accept.extra?.feePayer === "string";
|
|
58
|
+
const sponsored = isSponsoredRequirement(accept.extra);
|
|
61
59
|
if (beforePay)
|
|
62
60
|
await beforePay(accept, sponsored);
|
|
63
61
|
let response;
|
|
64
|
-
|
|
62
|
+
// The SDK signs a sponsored (fee 0) transfer only when the requirement names
|
|
63
|
+
// a fee payer; `sponsored: true` without one is signed by createApiClient.
|
|
64
|
+
if (sponsored && typeof accept.extra?.feePayer === "string") {
|
|
65
65
|
const account = await getAccount();
|
|
66
66
|
// The amount is a signed sBTC spend: book it before signing.
|
|
67
67
|
const spendLimiter = getSpendLimiter();
|
|
@@ -88,7 +88,8 @@ export async function postWithX402Payment(options) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
else {
|
|
91
|
-
//
|
|
91
|
+
// createApiClient signs (self-paid with a clamped fee, or sponsored on
|
|
92
|
+
// `sponsored: true`) and meters the spend.
|
|
92
93
|
const api = await createApiClient(baseUrl, { toolName, asset: accept.asset });
|
|
93
94
|
response = await api.post(url, body, {
|
|
94
95
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AxiosInstance } from "axios";
|
|
1
|
+
import { type AxiosInstance, type AxiosRequestConfig } from "axios";
|
|
2
2
|
import { type PaymentRequirementsV2 } from "../utils/x402-protocol.js";
|
|
3
3
|
import { NETWORK, API_URL, type Network } from "../config/networks.js";
|
|
4
4
|
import type { Account } from "../transactions/builder.js";
|
|
@@ -45,9 +45,29 @@ export interface PaymentRequirements {
|
|
|
45
45
|
recipient: string;
|
|
46
46
|
network: string;
|
|
47
47
|
account: Account;
|
|
48
|
-
/** True when the endpoint advertised `extra.sponsored` — the relay pays the STX fee. */
|
|
48
|
+
/** True when the endpoint advertised `extra.feePayer` or `extra.sponsored` — the relay pays the STX fee. */
|
|
49
49
|
sponsored: boolean;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Who pays STX gas for the payment the interceptor signed. Sponsored: fee 0,
|
|
53
|
+
* the endpoint's relay pays at settlement. Self-paid: the sender's own fee.
|
|
54
|
+
*/
|
|
55
|
+
export interface X402PaymentGas {
|
|
56
|
+
sponsored: boolean;
|
|
57
|
+
feeUstx: string;
|
|
58
|
+
}
|
|
59
|
+
declare module "axios" {
|
|
60
|
+
interface AxiosRequestConfig {
|
|
61
|
+
/** Set by the x402 interceptor on the paid retry; read with getPaymentGas. */
|
|
62
|
+
x402PaymentGas?: X402PaymentGas;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Read the gas decision the interceptor attached to a paid request's config
|
|
67
|
+
* (`response.config` on success, `error.config` on failure). Undefined when
|
|
68
|
+
* no payment was signed.
|
|
69
|
+
*/
|
|
70
|
+
export declare function getPaymentGas(config: AxiosRequestConfig | undefined): X402PaymentGas | undefined;
|
|
51
71
|
/**
|
|
52
72
|
* Options for createApiClient
|
|
53
73
|
*/
|
|
@@ -128,6 +148,14 @@ export type PaymentTokenType = 'STX' | 'sBTC' | 'unsupported';
|
|
|
128
148
|
* @param asset - Full contract identifier or token name
|
|
129
149
|
*/
|
|
130
150
|
export declare function detectTokenType(asset: string): PaymentTokenType;
|
|
151
|
+
/**
|
|
152
|
+
* Whether an x402 payment requirement offers gas sponsorship (sign with fee 0,
|
|
153
|
+
* the relay co-signs and pays the STX fee). x402-stacks documents a non-empty
|
|
154
|
+
* `extra.feePayer` as the signal; `extra.sponsored === true` is also honored
|
|
155
|
+
* for endpoints that send only that, and an explicit `sponsored: false` wins.
|
|
156
|
+
* Shared by createApiClient and x402-post so both paths use one rule.
|
|
157
|
+
*/
|
|
158
|
+
export declare function isSponsoredRequirement(extra?: Record<string, unknown>): boolean;
|
|
131
159
|
/**
|
|
132
160
|
* Memo for an x402 payment from the server's `extra.payment_id`, cut to the
|
|
133
161
|
* first 34 UTF-8 bytes (the Stacks memo limit) on a character boundary.
|
|
@@ -367,6 +367,14 @@ export async function mnemonicToAccount(mnemonic, network) {
|
|
|
367
367
|
network,
|
|
368
368
|
};
|
|
369
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Read the gas decision the interceptor attached to a paid request's config
|
|
372
|
+
* (`response.config` on success, `error.config` on failure). Undefined when
|
|
373
|
+
* no payment was signed.
|
|
374
|
+
*/
|
|
375
|
+
export function getPaymentGas(config) {
|
|
376
|
+
return config?.x402PaymentGas;
|
|
377
|
+
}
|
|
370
378
|
/**
|
|
371
379
|
* Build a canonical absolute URL for a request so that the L402 attempt
|
|
372
380
|
* counter and macaroon cache are keyed by the actual endpoint, not the
|
|
@@ -662,7 +670,7 @@ export async function createApiClient(baseUrl, options) {
|
|
|
662
670
|
}
|
|
663
671
|
// The endpoint offers gas sponsorship: sign with `sponsored: true, fee: 0`
|
|
664
672
|
// and its relay co-signs and pays the STX fee at settlement.
|
|
665
|
-
const sponsored = selectedOption.extra
|
|
673
|
+
const sponsored = isSponsoredRequirement(selectedOption.extra);
|
|
666
674
|
// Invoke pre-payment callback (e.g. balance check) before signing/broadcasting.
|
|
667
675
|
// If the callback throws, the payment is aborted and the error propagates to the caller.
|
|
668
676
|
if (options?.onBeforePayment) {
|
|
@@ -758,6 +766,7 @@ export async function createApiClient(baseUrl, options) {
|
|
|
758
766
|
const originalRequest = error.config;
|
|
759
767
|
originalRequest.headers = originalRequest.headers || {};
|
|
760
768
|
originalRequest.headers[X402_HEADERS.PAYMENT_SIGNATURE] = encodedPayload;
|
|
769
|
+
originalRequest.x402PaymentGas = { sponsored, feeUstx: fee.toString() };
|
|
761
770
|
return axiosInstance.request(originalRequest);
|
|
762
771
|
}
|
|
763
772
|
catch (paymentError) {
|
|
@@ -827,6 +836,18 @@ export function detectTokenType(asset) {
|
|
|
827
836
|
}
|
|
828
837
|
return 'unsupported';
|
|
829
838
|
}
|
|
839
|
+
/**
|
|
840
|
+
* Whether an x402 payment requirement offers gas sponsorship (sign with fee 0,
|
|
841
|
+
* the relay co-signs and pays the STX fee). x402-stacks documents a non-empty
|
|
842
|
+
* `extra.feePayer` as the signal; `extra.sponsored === true` is also honored
|
|
843
|
+
* for endpoints that send only that, and an explicit `sponsored: false` wins.
|
|
844
|
+
* Shared by createApiClient and x402-post so both paths use one rule.
|
|
845
|
+
*/
|
|
846
|
+
export function isSponsoredRequirement(extra) {
|
|
847
|
+
if (extra?.sponsored === false)
|
|
848
|
+
return false;
|
|
849
|
+
return (typeof extra?.feePayer === "string" && extra.feePayer !== "") || extra?.sponsored === true;
|
|
850
|
+
}
|
|
830
851
|
const STACKS_MEMO_MAX_BYTES = 34;
|
|
831
852
|
/**
|
|
832
853
|
* Memo for an x402 payment from the server's `extra.payment_id`, cut to the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { createApiClient, API_URL, probeEndpoint, formatPaymentAmount, checkSufficientBalance, generateDedupKey, checkDedupCache, recordTransaction, X402_DEDUP_TTL_MS, NETWORK } from "../services/x402.service.js";
|
|
2
|
+
import { createApiClient, API_URL, probeEndpoint, formatPaymentAmount, checkSufficientBalance, generateDedupKey, checkDedupCache, recordTransaction, X402_DEDUP_TTL_MS, NETWORK, getPaymentGas } from "../services/x402.service.js";
|
|
3
3
|
import { STATIC_ENDPOINTS, formatEndpointsTable } from "../endpoints/registry.js";
|
|
4
4
|
import { discoverOpenApiEndpoints, fetchDirectoryEntries, } from "../services/x402-discovery.service.js";
|
|
5
5
|
import { createJsonResponse, createErrorResponse } from "../utils/index.js";
|
|
@@ -127,6 +127,18 @@ function formatProbeResponse(result, method, fullUrl, callWithOptions, messagePr
|
|
|
127
127
|
callWith: buildCallWith(callWithOptions),
|
|
128
128
|
});
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* One line saying who pays STX gas for a payment that was signed but whose
|
|
132
|
+
* call failed, or "" when none was signed. The fee is only charged if the
|
|
133
|
+
* payment settles on chain, which a failed call does not tell us.
|
|
134
|
+
*/
|
|
135
|
+
function formatGasLine(gas) {
|
|
136
|
+
if (!gas)
|
|
137
|
+
return "";
|
|
138
|
+
return gas.sponsored
|
|
139
|
+
? "\n\nGas: sponsored (the endpoint's relay pays the STX fee if the payment settles)."
|
|
140
|
+
: `\n\nGas: self-paid; the payment was signed with a ${gas.feeUstx} uSTX fee, charged only if it settles on chain.`;
|
|
141
|
+
}
|
|
130
142
|
export function registerEndpointTools(server) {
|
|
131
143
|
// List x402 endpoints
|
|
132
144
|
server.registerTool("list_x402_endpoints", {
|
|
@@ -273,6 +285,8 @@ Supported sources:
|
|
|
273
285
|
|
|
274
286
|
Use list_x402_endpoints to discover available endpoints.
|
|
275
287
|
|
|
288
|
+
A paid result includes txid and gas: {sponsored, feeUstx}. Sponsored means the endpoint's relay pays the STX fee (feeUstx "0"); otherwise this wallet paid feeUstx on top of the price.
|
|
289
|
+
|
|
276
290
|
For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs a standard sBTC transfer and settles directly through the x402 facilitator.`,
|
|
277
291
|
inputSchema: z.object({
|
|
278
292
|
method: z
|
|
@@ -402,10 +416,13 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
|
|
|
402
416
|
: txid !== null
|
|
403
417
|
? { txid }
|
|
404
418
|
: {};
|
|
419
|
+
// Who paid STX gas for the payment, as decided when it was signed.
|
|
420
|
+
const gas = getPaymentGas(response.config);
|
|
405
421
|
return createJsonResponse({
|
|
406
422
|
endpoint: `${method} ${fullUrl}`,
|
|
407
423
|
response: response.data,
|
|
408
424
|
...txidFields,
|
|
425
|
+
...(gas && { gas }),
|
|
409
426
|
});
|
|
410
427
|
}
|
|
411
428
|
catch (error) {
|
|
@@ -423,6 +440,9 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
|
|
|
423
440
|
// Mirrors the success path, including the synthetic pending marker
|
|
424
441
|
// used when the txid is not yet observable.
|
|
425
442
|
const paymentSigHeader = axiosError.config?.headers?.[X402_HEADERS.PAYMENT_SIGNATURE];
|
|
443
|
+
// A signed payment may still settle after the call failed, so say who
|
|
444
|
+
// pays gas on every error path too.
|
|
445
|
+
const gasLine = formatGasLine(getPaymentGas(axiosError.config));
|
|
426
446
|
if (paymentSigHeader && dedupKey) {
|
|
427
447
|
const broadcastTxid = extractTxidFromPaymentSignature(paymentSigHeader);
|
|
428
448
|
await recordTransaction(dedupKey, broadcastTxid ?? `pending:${dedupKey}`);
|
|
@@ -453,7 +473,7 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
|
|
|
453
473
|
}
|
|
454
474
|
return {
|
|
455
475
|
...baseError,
|
|
456
|
-
content: [{ type: "text", text }],
|
|
476
|
+
content: [{ type: "text", text: text + gasLine }],
|
|
457
477
|
};
|
|
458
478
|
}
|
|
459
479
|
if (paymentSigHeader) {
|
|
@@ -480,11 +500,29 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
|
|
|
480
500
|
`Transaction recovery info:\n` +
|
|
481
501
|
` txid: ${confirmation.txid}\n` +
|
|
482
502
|
` status: ${confirmation.status}\n` +
|
|
483
|
-
` explorer: ${confirmation.explorer}
|
|
503
|
+
` explorer: ${confirmation.explorer}` +
|
|
504
|
+
gasLine,
|
|
484
505
|
},
|
|
485
506
|
],
|
|
486
507
|
};
|
|
487
508
|
}
|
|
509
|
+
// No txid from the header: a sponsored payment (the relay assigns its
|
|
510
|
+
// txid when it sponsors) or one that didn't decode. It may still have
|
|
511
|
+
// settled, so point at where the real txid can be found.
|
|
512
|
+
const baseError = formatEndpointError(error, label);
|
|
513
|
+
return {
|
|
514
|
+
...baseError,
|
|
515
|
+
content: [
|
|
516
|
+
{
|
|
517
|
+
type: "text",
|
|
518
|
+
text: baseError.content[0].text +
|
|
519
|
+
`\n\nA payment was signed, but its txid is not known here` +
|
|
520
|
+
(fallbackPaymentId ? ` (paymentId: ${fallbackPaymentId})` : "") +
|
|
521
|
+
`. It may have settled: query get_account_transactions for this wallet before paying again.` +
|
|
522
|
+
gasLine,
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
};
|
|
488
526
|
}
|
|
489
527
|
return formatEndpointError(error, label);
|
|
490
528
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Inbox auto-reply tools.
|
|
3
3
|
*
|
|
4
|
-
* - inbox_auto_reply_configure: store enabled / model / systemPrompt / dailyCapSats
|
|
5
|
-
* in ~/.aibtc/config.json (no args = show the current settings)
|
|
4
|
+
* - inbox_auto_reply_configure: store enabled / model / systemPrompt / dailyCapSats /
|
|
5
|
+
* maxSatsPerReply in ~/.aibtc/config.json (no args = show the current settings)
|
|
6
6
|
* - inbox_auto_reply: answer paid, unanswered inbox messages with a reply
|
|
7
7
|
* written by an AIBTC Inference Marketplace model (x402, sBTC), then sign and
|
|
8
8
|
* post it to the outbox. Built to be called from an agent's autonomy loop.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Inbox auto-reply tools.
|
|
3
3
|
*
|
|
4
|
-
* - inbox_auto_reply_configure: store enabled / model / systemPrompt / dailyCapSats
|
|
5
|
-
* in ~/.aibtc/config.json (no args = show the current settings)
|
|
4
|
+
* - inbox_auto_reply_configure: store enabled / model / systemPrompt / dailyCapSats /
|
|
5
|
+
* maxSatsPerReply in ~/.aibtc/config.json (no args = show the current settings)
|
|
6
6
|
* - inbox_auto_reply: answer paid, unanswered inbox messages with a reply
|
|
7
7
|
* written by an AIBTC Inference Marketplace model (x402, sBTC), then sign and
|
|
8
8
|
* post it to the outbox. Built to be called from an agent's autonomy loop.
|
|
@@ -11,9 +11,29 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { getAccount, NETWORK } from "../services/x402.service.js";
|
|
14
|
-
import { DEFAULT_AUTO_REPLY_MODEL, DEFAULT_DAILY_CAP_SATS, DEFAULT_MAX_REPLIES_PER_RUN, listLiveModels, marketplaceReplyGenerator, runAutoReply, } from "../services/inbox-auto-reply.service.js";
|
|
14
|
+
import { DEFAULT_AUTO_REPLY_MODEL, DEFAULT_DAILY_CAP_SATS, DEFAULT_MAX_REPLIES_PER_RUN, DEFAULT_MAX_SATS_PER_REPLY, listLiveModels, marketplaceReplyGenerator, runAutoReply, } from "../services/inbox-auto-reply.service.js";
|
|
15
15
|
import { readAppConfig, writeAppConfig } from "../utils/storage.js";
|
|
16
16
|
import { createJsonResponse, createErrorResponse } from "../utils/index.js";
|
|
17
|
+
/**
|
|
18
|
+
* The stored sat limits, defaulted and checked. The config file is plain JSON
|
|
19
|
+
* (shared with the skills CLI), so a missing or hand-edited value must not
|
|
20
|
+
* turn into "no limit" at run time.
|
|
21
|
+
*/
|
|
22
|
+
function autoReplyLimits(config) {
|
|
23
|
+
const limit = (name, value, fallback) => {
|
|
24
|
+
if (value === undefined || value === null)
|
|
25
|
+
return fallback;
|
|
26
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
27
|
+
throw new Error(`inboxAutoReply.${name} in ~/.aibtc/config.json must be a non-negative integer, got ${JSON.stringify(value)}. ` +
|
|
28
|
+
`Fix it with inbox_auto_reply_configure { ${name}: <sats> }.`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
dailyCapSats: limit("dailyCapSats", config.dailyCapSats, DEFAULT_DAILY_CAP_SATS),
|
|
34
|
+
maxSatsPerReply: limit("maxSatsPerReply", config.maxSatsPerReply, DEFAULT_MAX_SATS_PER_REPLY),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
17
37
|
function requireMainnet() {
|
|
18
38
|
if (NETWORK !== "mainnet") {
|
|
19
39
|
throw new Error("Inbox auto-reply is mainnet only: the aibtc.com inbox and the inference marketplace settle in mainnet sBTC. Set NETWORK=mainnet.");
|
|
@@ -25,7 +45,7 @@ export function registerInboxAutoReplyTools(server) {
|
|
|
25
45
|
"settings. A systemPrompt (the agent's voice / personality / what it can help with) is required before " +
|
|
26
46
|
"auto-reply can be enabled. The model must be live on inference.aibtc.com; it is checked against the " +
|
|
27
47
|
"catalog when set. dailyCapSats caps sats spent on reply inference per UTC day, on top of the wallet-wide " +
|
|
28
|
-
"spending limit.",
|
|
48
|
+
"spending limit. maxSatsPerReply is the most one reply may cost; a higher quote stops the run unpaid.",
|
|
29
49
|
inputSchema: z.object({
|
|
30
50
|
enabled: z.boolean().optional().describe("Allow inbox_auto_reply to post replies. Dry runs work while disabled."),
|
|
31
51
|
model: z
|
|
@@ -44,12 +64,22 @@ export function registerInboxAutoReplyTools(server) {
|
|
|
44
64
|
.min(0)
|
|
45
65
|
.optional()
|
|
46
66
|
.describe(`Most sats spent on reply inference per UTC day. Default ${DEFAULT_DAILY_CAP_SATS}.`),
|
|
67
|
+
maxSatsPerReply: z
|
|
68
|
+
.number()
|
|
69
|
+
.int()
|
|
70
|
+
.min(0)
|
|
71
|
+
.optional()
|
|
72
|
+
.describe(`Most sats paid for one reply. Default ${DEFAULT_MAX_SATS_PER_REPLY}.`),
|
|
47
73
|
}),
|
|
48
|
-
}, async ({ enabled, model, systemPrompt, dailyCapSats }) => {
|
|
74
|
+
}, async ({ enabled, model, systemPrompt, dailyCapSats, maxSatsPerReply }) => {
|
|
49
75
|
try {
|
|
50
76
|
const config = await readAppConfig();
|
|
51
77
|
const current = config.inboxAutoReply;
|
|
52
|
-
if (enabled === undefined &&
|
|
78
|
+
if (enabled === undefined &&
|
|
79
|
+
model === undefined &&
|
|
80
|
+
systemPrompt === undefined &&
|
|
81
|
+
dailyCapSats === undefined &&
|
|
82
|
+
maxSatsPerReply === undefined) {
|
|
53
83
|
return createJsonResponse({
|
|
54
84
|
configured: Boolean(current),
|
|
55
85
|
settings: current ?? null,
|
|
@@ -69,6 +99,7 @@ export function registerInboxAutoReplyTools(server) {
|
|
|
69
99
|
model: model ?? current?.model ?? DEFAULT_AUTO_REPLY_MODEL,
|
|
70
100
|
systemPrompt: systemPrompt ?? current?.systemPrompt ?? "",
|
|
71
101
|
dailyCapSats: dailyCapSats ?? current?.dailyCapSats ?? DEFAULT_DAILY_CAP_SATS,
|
|
102
|
+
maxSatsPerReply: maxSatsPerReply ?? current?.maxSatsPerReply ?? DEFAULT_MAX_SATS_PER_REPLY,
|
|
72
103
|
};
|
|
73
104
|
if (next.enabled && !next.systemPrompt) {
|
|
74
105
|
throw new Error("Set a systemPrompt before enabling auto-reply.");
|
|
@@ -84,12 +115,14 @@ export function registerInboxAutoReplyTools(server) {
|
|
|
84
115
|
description: "Answer this agent's paid aibtc.com inbox messages automatically. In one call: reads the inbox, picks paid, " +
|
|
85
116
|
"settled, unanswered messages (oldest first), writes each reply with an AIBTC Inference Marketplace model " +
|
|
86
117
|
"(paid per request in sBTC via x402, ~1 sat), signs it (BIP-322) and posts it to the outbox (free). " +
|
|
87
|
-
"Never answers a message twice
|
|
118
|
+
"Never answers a message twice (overlapping runs skip a message another run is answering), stops at the " +
|
|
119
|
+
"daily cap or on a quote above maxSatsPerReply, gives up on a message after 2 paid failures, and returns a summary: checked, replied, " +
|
|
88
120
|
"skipped (with reason), failed, inference sats spent and inference cap remaining.\n\n" +
|
|
89
121
|
"Posting a reply is free; the only cost is the inference call that writes it. " +
|
|
90
122
|
"Call it from the autonomy loop when the heartbeat or inbox shows unread messages. Configure first with " +
|
|
91
123
|
"inbox_auto_reply_configure. dryRun writes (and pays for) replies without posting them, so the operator " +
|
|
92
|
-
"can review the voice
|
|
124
|
+
"can review the voice; each draft is saved and reused by later dry runs and the real run while the " +
|
|
125
|
+
"systemPrompt and model are unchanged. Requires an unlocked wallet with a bc1q address and a little sBTC. Mainnet only.",
|
|
93
126
|
inputSchema: z.object({
|
|
94
127
|
maxRepliesPerRun: z
|
|
95
128
|
.number()
|
|
@@ -101,7 +134,8 @@ export function registerInboxAutoReplyTools(server) {
|
|
|
101
134
|
dryRun: z
|
|
102
135
|
.boolean()
|
|
103
136
|
.optional()
|
|
104
|
-
.describe("Generate replies
|
|
137
|
+
.describe("Generate replies without posting them or marking messages handled. A new draft is paid for once; " +
|
|
138
|
+
"a saved draft with the same systemPrompt and model is reused free, here and by the real run."),
|
|
105
139
|
}),
|
|
106
140
|
}, async ({ maxRepliesPerRun, dryRun }) => {
|
|
107
141
|
try {
|
|
@@ -114,11 +148,12 @@ export function registerInboxAutoReplyTools(server) {
|
|
|
114
148
|
if (!config.enabled && !isDryRun) {
|
|
115
149
|
throw new Error("Inbox auto-reply is disabled. Enable it with inbox_auto_reply_configure { enabled: true }, or pass dryRun: true to preview replies.");
|
|
116
150
|
}
|
|
151
|
+
const limits = autoReplyLimits(config);
|
|
117
152
|
const account = await getAccount();
|
|
118
153
|
const summary = await runAutoReply({
|
|
119
154
|
account,
|
|
120
155
|
systemPrompt: config.systemPrompt,
|
|
121
|
-
|
|
156
|
+
...limits,
|
|
122
157
|
maxRepliesPerRun: maxRepliesPerRun ?? DEFAULT_MAX_REPLIES_PER_RUN,
|
|
123
158
|
dryRun: isDryRun,
|
|
124
159
|
generator: marketplaceReplyGenerator(config.model),
|
|
@@ -131,7 +166,7 @@ export function registerInboxAutoReplyTools(server) {
|
|
|
131
166
|
repliedCount: summary.replied.length,
|
|
132
167
|
skippedCount: summary.skipped.length,
|
|
133
168
|
failedCount: summary.failed.length,
|
|
134
|
-
|
|
169
|
+
...limits,
|
|
135
170
|
});
|
|
136
171
|
}
|
|
137
172
|
catch (error) {
|
package/dist/utils/storage.d.ts
CHANGED
|
@@ -14,7 +14,10 @@ import { type Network } from "../config/networks.js";
|
|
|
14
14
|
* The payment-signature header is a base64-encoded JSON PaymentPayloadV2 object
|
|
15
15
|
* whose payload.transaction field contains the signed transaction hex.
|
|
16
16
|
*
|
|
17
|
-
* Returns the txid string (64-char hex, no 0x prefix), or null if extraction
|
|
17
|
+
* Returns the txid string (64-char hex, no 0x prefix), or null if extraction
|
|
18
|
+
* fails or the payment is sponsored. A sponsored transaction gets its txid only
|
|
19
|
+
* when the relay adds its sponsor signature and fee, so the txid of the signed
|
|
20
|
+
* payment in the header never appears on chain.
|
|
18
21
|
*/
|
|
19
22
|
export declare function extractTxidFromPaymentSignature(paymentSignatureHeader: string): string | null;
|
|
20
23
|
/**
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Used by inbox.tools.ts and endpoint.tools.ts as an operational fallback
|
|
8
8
|
* when canonical paymentId polling is unavailable or inconclusive.
|
|
9
9
|
*/
|
|
10
|
-
import { deserializeTransaction } from "@stacks/transactions";
|
|
10
|
+
import { AuthType, deserializeTransaction } from "@stacks/transactions";
|
|
11
11
|
import { getHiroApi } from "../services/hiro-api.js";
|
|
12
12
|
import { getExplorerTxUrl } from "../config/networks.js";
|
|
13
13
|
import { decodePaymentPayload } from "./x402-protocol.js";
|
|
@@ -20,7 +20,10 @@ import { decodePaymentPayload } from "./x402-protocol.js";
|
|
|
20
20
|
* The payment-signature header is a base64-encoded JSON PaymentPayloadV2 object
|
|
21
21
|
* whose payload.transaction field contains the signed transaction hex.
|
|
22
22
|
*
|
|
23
|
-
* Returns the txid string (64-char hex, no 0x prefix), or null if extraction
|
|
23
|
+
* Returns the txid string (64-char hex, no 0x prefix), or null if extraction
|
|
24
|
+
* fails or the payment is sponsored. A sponsored transaction gets its txid only
|
|
25
|
+
* when the relay adds its sponsor signature and fee, so the txid of the signed
|
|
26
|
+
* payment in the header never appears on chain.
|
|
24
27
|
*/
|
|
25
28
|
export function extractTxidFromPaymentSignature(paymentSignatureHeader) {
|
|
26
29
|
try {
|
|
@@ -33,6 +36,9 @@ export function extractTxidFromPaymentSignature(paymentSignatureHeader) {
|
|
|
33
36
|
const hexWithout0x = txHex.startsWith("0x") ? txHex.slice(2) : txHex;
|
|
34
37
|
const txBytes = Buffer.from(hexWithout0x, "hex");
|
|
35
38
|
const tx = deserializeTransaction(txBytes);
|
|
39
|
+
if (tx.auth.authType === AuthType.Sponsored) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
36
42
|
return tx.txid();
|
|
37
43
|
}
|
|
38
44
|
catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aibtc/mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.79.0",
|
|
4
4
|
"description": "Bitcoin-native MCP server for AI agents: BTC/STX wallets, DeFi yield, sBTC peg, NFTs, and x402 payments.",
|
|
5
5
|
"mcpName": "io.github.aibtcdev/mcp-server",
|
|
6
6
|
"type": "module",
|
package/skill/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: Bitcoin L1 wallet for agents - check balances, send BTC, manage UTX
|
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: aibtcdev
|
|
7
|
-
version: 1.
|
|
7
|
+
version: 1.79.0 # x-release-please-version
|
|
8
8
|
npm: "@aibtc/mcp-server"
|
|
9
9
|
github: https://github.com/aibtcdev/aibtc-mcp-server
|
|
10
10
|
---
|
|
@@ -257,6 +257,7 @@ reply stays free; the only cost is the inference that writes it.
|
|
|
257
257
|
| `systemPrompt` | — (required) | Your voice: who you are, what you offer, what you won't do |
|
|
258
258
|
| `model` | `meta-llama/Llama-3.1-8B-Instruct` | Marketplace model id; must be live at `https://inference.aibtc.com/v1/models` |
|
|
259
259
|
| `dailyCapSats` | 1000 | Most sats spent on reply inference per UTC day |
|
|
260
|
+
| `maxSatsPerReply` | 10 | Most sats paid for one reply; a higher quote stops the run unpaid |
|
|
260
261
|
| `enabled` | false | Must be true before replies are posted |
|
|
261
262
|
|
|
262
263
|
```
|
|
@@ -264,7 +265,8 @@ inbox_auto_reply_configure({ systemPrompt: "You are K9, a Stacks research agent.
|
|
|
264
265
|
```
|
|
265
266
|
|
|
266
267
|
Check the voice first with a dry run: it writes the replies (and pays for the
|
|
267
|
-
inference) but posts nothing
|
|
268
|
+
inference) but posts nothing. Drafts are saved, so a later dry run or the real
|
|
269
|
+
run reuses them free while the system prompt and model are unchanged:
|
|
268
270
|
|
|
269
271
|
```
|
|
270
272
|
inbox_auto_reply({ dryRun: true, maxRepliesPerRun: 2 })
|
|
@@ -282,7 +284,8 @@ works oldest first from the newest 100 messages.
|
|
|
282
284
|
**Safety rules built in:**
|
|
283
285
|
- **No double replies.** A message is skipped when the inbox shows `repliedAt`,
|
|
284
286
|
when your local ledger (`~/.aibtc/inbox-auto-reply.json`) has it, or when the
|
|
285
|
-
outbox answers 409.
|
|
287
|
+
outbox answers 409. Overlapping runs skip a message another run is answering,
|
|
288
|
+
and a message that fails twice after payment is given up.
|
|
286
289
|
- **No reply loops.** Replies go to the outbox and never land in an inbox; every
|
|
287
290
|
inbox message is a paid send. One agent's auto-reply cannot trigger another's.
|
|
288
291
|
- **Spend is capped twice.** The cap is checked before each inference is signed,
|
|
@@ -293,8 +296,9 @@ works oldest first from the newest 100 messages.
|
|
|
293
296
|
|
|
294
297
|
**Reading the result:** `replied`, `skipped` (with `reason`: `already_replied`,
|
|
295
298
|
`already_handled`, `unpaid`, `payment_not_settled`, `from_self`, `run_limit`,
|
|
296
|
-
`
|
|
297
|
-
|
|
299
|
+
`in_progress`, `gave_up`, `daily_cap`, `price_ceiling`, `spend_limit`,
|
|
300
|
+
`rate_limited`) and `failed` (with `error`, `replySaved` when a paid reply is
|
|
301
|
+
waiting for the next run, and `gaveUp` when it will not be retried). Also returned: `inferenceSatsSpent`,
|
|
298
302
|
`inferenceSatsSpentToday` and `inferenceCapRemainingSats`. Each reply carries its `inferenceTxid`.
|
|
299
303
|
When `stoppedEarly` is `daily_cap`, wait for the next UTC day or ask your
|
|
300
304
|
operator to raise the cap. Don't raise it yourself.
|