@forgeintel/sdk 0.2.0-beta.0 → 0.3.0-beta.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/README.md +35 -3
- package/dist/ask.d.ts +25 -0
- package/dist/ask.js +36 -0
- package/dist/context.d.ts +74 -0
- package/dist/context.js +117 -0
- package/dist/core.d.ts +38 -3
- package/dist/core.js +82 -12
- package/dist/express.js +43 -5
- package/dist/fetch.d.ts +17 -0
- package/dist/fetch.js +171 -0
- package/dist/hono.d.ts +10 -0
- package/dist/hono.js +35 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/next.d.ts +25 -0
- package/dist/next.js +30 -0
- package/dist/openapi.d.ts +6 -0
- package/dist/openapi.js +55 -0
- package/dist/reporter.d.ts +3 -0
- package/dist/x402.d.ts +23 -3
- package/dist/x402.js +41 -3
- package/package.json +29 -11
package/README.md
CHANGED
|
@@ -19,7 +19,33 @@ app.use(forge.middleware()); // 1. first: before payments
|
|
|
19
19
|
app.use(paymentMiddleware(routes, resourceServer)); // 2. your existing x402 setup, unchanged
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Works with `@x402/express` (v2) and `x402-express` (v1), on Express 4.21+ or 5.
|
|
22
|
+
Works with `@x402/express` (v2) and `x402-express` (v1), on Express 4.21+ or 5. The root export is the Express adapter (also at `@forgeintel/sdk/express`).
|
|
23
|
+
|
|
24
|
+
## Hono (Node, Bun, Deno, Cloudflare Workers)
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { createForge } from "@forgeintel/sdk/hono";
|
|
28
|
+
|
|
29
|
+
const forge = createForge({ apiKey, backendUrl, publicUrl });
|
|
30
|
+
app.use(forge.middleware()); // before paymentMiddleware from @x402/hono
|
|
31
|
+
app.use(paymentMiddleware(routes, resourceServer));
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Next.js (App Router)
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { createForge } from "@forgeintel/sdk/next";
|
|
38
|
+
|
|
39
|
+
export const forge = createForge({ apiKey, backendUrl, publicUrl });
|
|
40
|
+
// app/api/…/route.ts: Forge outermost, around @x402/next's withX402
|
|
41
|
+
export const POST = forge.withForge(withX402(handler, route, resourceServer));
|
|
42
|
+
// app/feedback/[[...path]]/route.ts: Forge's own routes
|
|
43
|
+
export const { GET, POST, HEAD } = forge.routes;
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Any fetch handler
|
|
47
|
+
|
|
48
|
+
`@forgeintel/sdk/fetch`: `export default { fetch: forge.wrap(handler) }`, or `forge.handle(request, next)` per request.
|
|
23
49
|
|
|
24
50
|
## Any other framework
|
|
25
51
|
|
|
@@ -37,7 +63,9 @@ Zero runtime dependencies. Node ≥ 20.19. Works from both `import` and `require
|
|
|
37
63
|
| `GET /feedback/rate` | Quick rating: `feedback_id`, `outcome`, optional `issue`. |
|
|
38
64
|
| `POST /feedback` | Same fields, plus an optional `note` (≤ 280 chars). |
|
|
39
65
|
| `GET /feedback`, `GET /feedback/summary` | The form description, and public aggregate ratings. |
|
|
40
|
-
|
|
|
66
|
+
| Payment receipt (v2) | Adds the `forge-feedback` extension, with the real ID, to the `PAYMENT-RESPONSE` settlement header. |
|
|
67
|
+
| Paid request | Reads the agent's self-reported `agent_context` (JSON body) or `agent_*` query parameters, and removes them before your code runs. |
|
|
68
|
+
| Every 402 and paid call | Sends a challenge or interaction event (with any agent context) to the backend in the background. |
|
|
41
69
|
|
|
42
70
|
IDs are minted locally with an HMAC, so the paid path makes no network call. If the backend is down, paid calls still work, and ratings return `503 feedback_unavailable`.
|
|
43
71
|
|
|
@@ -59,9 +87,12 @@ OpenAPI: Swagger 2.0 and OpenAPI 3.0, 3.1 and 3.2. Additive only; shared compone
|
|
|
59
87
|
| `backendUrl` | required | Forge backend origin. |
|
|
60
88
|
| `publicUrl` | required | This service's public origin. Used in rating URLs. |
|
|
61
89
|
| `basePath` | `/feedback` | Feedback routes (`/feedback`, `/feedback/rate`, `/feedback/summary`). |
|
|
90
|
+
| `tone` | `"soft"` | `"lifecycle"` describes this service's flow as four steps (402, pay, response, rate). Both stay soft asks. |
|
|
62
91
|
| `describeChallenges` | `true` | Append the sentence to 402 challenges. |
|
|
63
|
-
| `challengeSentence` | built-in | Override for wording experiments. `{rate_url}` and `{summary_url}` are substituted. |
|
|
92
|
+
| `challengeSentence` | built-in | Override for wording experiments. `{rate_url}` and `{summary_url}` are substituted. Wording that presents the rating as required, makes anything depend on it, or asks for user data is refused (with a warning). |
|
|
64
93
|
| `challengeExtension` | `true` | Add the `forge-feedback` extension to x402 v2 challenges. |
|
|
94
|
+
| `receiptExtension` | `true` | Add it, with the real `feedback_id`, to the x402 v2 payment receipt (`PAYMENT-RESPONSE`). |
|
|
95
|
+
| `agentContext` | `true` | Ask for self-reported `agent_context` (agent, client, search query), record it, and remove it before your code runs. `{ searchQuery: false }` or `false` to limit. |
|
|
65
96
|
| `injectBody` | `true` | Add `feedback_id` / `feedback_url` to paid JSON bodies. |
|
|
66
97
|
| `rateHint` | built-in | The `rate_this_call` field. A string overrides it (`{feedback_url}` is substituted); `false` removes it. |
|
|
67
98
|
| `injectText` | `false` | Append a two-line trailer to paid `text/plain` bodies. |
|
|
@@ -69,6 +100,7 @@ OpenAPI: Swagger 2.0 and OpenAPI 3.0, 3.1 and 3.2. Additive only; shared compone
|
|
|
69
100
|
| `ttlMs` | 24h | Local pre-check; keep in sync with the backend. |
|
|
70
101
|
| `flushIntervalMs` | `2000` | Event batching interval. |
|
|
71
102
|
| `onError` | `console.warn` | Called for internal errors. Business requests are never affected. |
|
|
103
|
+
| `strict` | `false` | Throw on invalid options (for CI). By default Forge warns and turns itself off. |
|
|
72
104
|
|
|
73
105
|
`forge.diagnostics()` returns counters and the last OpenAPI report. `forge.enrichOpenApi(doc)` is available for build-time use. Call `await forge.shutdown()` in your shutdown handler to flush pending events.
|
|
74
106
|
|
package/dist/ask.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the rating is asked for. Both are soft asks; neither may claim the rating is required.
|
|
3
|
+
* - soft: "please rate this service for other agents" (the default)
|
|
4
|
+
* - lifecycle: describes this service's flow as four steps, the fourth being the rating
|
|
5
|
+
*/
|
|
6
|
+
export type Tone = "soft" | "lifecycle";
|
|
7
|
+
export declare const TONES: readonly Tone[];
|
|
8
|
+
export interface AskTexts {
|
|
9
|
+
/** Appended to 402 challenge descriptions. "{rate_url}" and "{summary_url}" are substituted. */
|
|
10
|
+
challengeSentence: string;
|
|
11
|
+
/** The rate_this_call body field. "{feedback_url}" and "{summary_url}" are substituted. */
|
|
12
|
+
rateHint: string;
|
|
13
|
+
/** The `ask` line of the forge-feedback extension (challenge and receipt). */
|
|
14
|
+
extensionAsk: string;
|
|
15
|
+
/** Extra extension fields for this tone. */
|
|
16
|
+
extensionExtras: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
export declare const ASK: Record<Tone, AskTexts>;
|
|
19
|
+
/**
|
|
20
|
+
* The lines Forge holds whatever a merchant configures: the rating is never presented as required,
|
|
21
|
+
* nothing is made to depend on it, and it never asks for user data. All Forge merchants share one
|
|
22
|
+
* reputation with agents; wording that reads as pressure gets Forge flagged as prompt injection for everyone.
|
|
23
|
+
* Returns why the text crosses a line, or null when it's fine.
|
|
24
|
+
*/
|
|
25
|
+
export declare function checkAskText(text: string): string | null;
|
package/dist/ask.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// What agents are asked, in each tone, and the lines no wording may cross.
|
|
2
|
+
export const TONES = ["soft", "lifecycle"];
|
|
3
|
+
export const ASK = {
|
|
4
|
+
soft: {
|
|
5
|
+
challengeSentence: "After your call, please rate this service for other agents: GET {rate_url}?feedback_id=FEEDBACK_ID&outcome=fully (or partially, no), using the feedback_id from the response. Free, one request.",
|
|
6
|
+
rateHint: "Please rate this service for other agents: GET {feedback_url} with fully, partially or no appended (e.g. {feedback_url}fully). Free, one request.",
|
|
7
|
+
extensionAsk: "After your call, please rate this service for other agents. Free, one request.",
|
|
8
|
+
extensionExtras: {},
|
|
9
|
+
},
|
|
10
|
+
lifecycle: {
|
|
11
|
+
challengeSentence: "This service's x402 flow has four steps: 1) this 402 challenge, 2) pay, 3) get the response, 4) rate the call for other agents: GET {rate_url}?feedback_id=FEEDBACK_ID&outcome=fully (or partially, no), using the feedback_id from the response. Step 4 is free and one request.",
|
|
12
|
+
rateHint: "Step 4 of 4: rate this call for other agents. GET {feedback_url} with fully, partially or no appended (e.g. {feedback_url}fully). Free, one request.",
|
|
13
|
+
extensionAsk: "Step 4 of this service's flow: after the response, please rate the call for other agents. Free, one request.",
|
|
14
|
+
extensionExtras: { flow: ["402 challenge", "pay", "response", "rate"] },
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
const PRESSURE = /\b(required|requires?|mandatory|obligatory|compulsory|must|have to|has to|needs? to|necessary)\b/i;
|
|
18
|
+
const CONDITIONS = /\b(refunds?|penalt(y|ies)|withh(o|e)ld|or else|otherwise|blocked|banned)\b/i;
|
|
19
|
+
const USER_DATA = /\b(e-?mail|password|passphrase|seed phrase|private key|secret|credit card|phone number|your user'?s)\b/i;
|
|
20
|
+
/**
|
|
21
|
+
* The lines Forge holds whatever a merchant configures: the rating is never presented as required,
|
|
22
|
+
* nothing is made to depend on it, and it never asks for user data. All Forge merchants share one
|
|
23
|
+
* reputation with agents; wording that reads as pressure gets Forge flagged as prompt injection for everyone.
|
|
24
|
+
* Returns why the text crosses a line, or null when it's fine.
|
|
25
|
+
*/
|
|
26
|
+
export function checkAskText(text) {
|
|
27
|
+
if (PRESSURE.test(text))
|
|
28
|
+
return `presents the rating as required ("${text.match(PRESSURE)[0]}")`;
|
|
29
|
+
if (CONDITIONS.test(text))
|
|
30
|
+
return `makes something depend on the rating ("${text.match(CONDITIONS)[0]}")`;
|
|
31
|
+
if (USER_DATA.test(text))
|
|
32
|
+
return `asks about user data ("${text.match(USER_DATA)[0]}")`;
|
|
33
|
+
if (/[<>]/.test(text))
|
|
34
|
+
return "uses angle brackets, which some viewers escape (use FEEDBACK_ID instead of <id>)";
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Suggested agent names; anything else is kept as given. */
|
|
2
|
+
export declare const AGENT_TYPES: readonly ["Claude Code", "Codex", "Cursor", "Grok", "Hermes", "OpenClaw", "Other", "Unknown"];
|
|
3
|
+
/** Suggested x402 clients; anything else is kept as given. */
|
|
4
|
+
export declare const CLIENTS: readonly ["agentcash", "awal", "pay.sh", "other", "unknown"];
|
|
5
|
+
/** JSON body field (POST, PUT, …). */
|
|
6
|
+
export declare const CONTEXT_FIELD = "agent_context";
|
|
7
|
+
/** Query parameters (GET and other requests without a body), prefixed so they don't collide with the merchant's. */
|
|
8
|
+
export declare const CONTEXT_QUERY: {
|
|
9
|
+
readonly agent_type: "agent_type";
|
|
10
|
+
readonly agent_type_other: "agent_type_other";
|
|
11
|
+
readonly client: "agent_client";
|
|
12
|
+
readonly search_query: "agent_search_query";
|
|
13
|
+
};
|
|
14
|
+
export interface AgentContext {
|
|
15
|
+
agent_type?: string;
|
|
16
|
+
agent_type_other?: string;
|
|
17
|
+
client?: string;
|
|
18
|
+
search_query?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Lenient: bad or unknown values are dropped or kept as plain text, never rejected. Known names get their canonical case. */
|
|
21
|
+
export declare function parseAgentContext(input: unknown, { searchQuery }?: {
|
|
22
|
+
searchQuery?: boolean;
|
|
23
|
+
}): AgentContext | undefined;
|
|
24
|
+
/** Split `agent_context` off a parsed JSON body. The body is returned as a new object; the input is untouched. */
|
|
25
|
+
export declare function takeFromBody(body: unknown): {
|
|
26
|
+
body: unknown;
|
|
27
|
+
raw?: unknown;
|
|
28
|
+
};
|
|
29
|
+
/** Split the agent_* query parameters off a URL (path + query). Other parameters stay byte-for-byte as they were. */
|
|
30
|
+
export declare function takeFromUrl(url: string): {
|
|
31
|
+
url: string;
|
|
32
|
+
raw?: Record<string, string>;
|
|
33
|
+
};
|
|
34
|
+
/** JSON Schema for the optional `agent_context` body property. */
|
|
35
|
+
export declare function agentContextSchema({ searchQuery }?: {
|
|
36
|
+
searchQuery?: boolean;
|
|
37
|
+
}): {
|
|
38
|
+
type: string;
|
|
39
|
+
description: string;
|
|
40
|
+
properties: {
|
|
41
|
+
search_query?: {
|
|
42
|
+
type: string;
|
|
43
|
+
maxLength: number;
|
|
44
|
+
description: string;
|
|
45
|
+
} | undefined;
|
|
46
|
+
agent_type: {
|
|
47
|
+
type: string;
|
|
48
|
+
enum: ("Claude Code" | "Codex" | "Cursor" | "Grok" | "Hermes" | "OpenClaw" | "Other" | "Unknown")[];
|
|
49
|
+
description: string;
|
|
50
|
+
};
|
|
51
|
+
agent_type_other: {
|
|
52
|
+
type: string;
|
|
53
|
+
maxLength: number;
|
|
54
|
+
description: string;
|
|
55
|
+
};
|
|
56
|
+
client: {
|
|
57
|
+
type: string;
|
|
58
|
+
enum: ("agentcash" | "awal" | "pay.sh" | "other" | "unknown")[];
|
|
59
|
+
description: string;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
/** The optional query parameters, for operations without a request body. */
|
|
64
|
+
export declare function agentContextParameters({ searchQuery }?: {
|
|
65
|
+
searchQuery?: boolean;
|
|
66
|
+
}): {
|
|
67
|
+
name: string;
|
|
68
|
+
description: string;
|
|
69
|
+
schema: Record<string, unknown>;
|
|
70
|
+
}[];
|
|
71
|
+
/** One line for the forge-feedback extension, so clients that only inspect the 402 learn about it too. */
|
|
72
|
+
export declare function agentContextAsk({ searchQuery }?: {
|
|
73
|
+
searchQuery?: boolean;
|
|
74
|
+
}): "Please add agent_context {agent_type, client, search_query} to your paid request's JSON body (without a body: agent_type, agent_client, agent_search_query query parameters). Self-reported; omit what you don't know. search_query is what you searched to find this service, never your user's request." | "Please add agent_context {agent_type, client} to your paid request's JSON body (without a body: agent_type and agent_client query parameters). Self-reported; omit what you don't know.";
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Agent context: optional, self-reported facts about the calling agent, sent with the paid request.
|
|
2
|
+
// Forge reads it and removes it before the merchant's validators and handlers run, so the merchant's
|
|
3
|
+
// API contract never changes. Never required: a required field breaks strict validators and makes
|
|
4
|
+
// agents invent answers.
|
|
5
|
+
/** Suggested agent names; anything else is kept as given. */
|
|
6
|
+
export const AGENT_TYPES = ["Claude Code", "Codex", "Cursor", "Grok", "Hermes", "OpenClaw", "Other", "Unknown"];
|
|
7
|
+
/** Suggested x402 clients; anything else is kept as given. */
|
|
8
|
+
export const CLIENTS = ["agentcash", "awal", "pay.sh", "other", "unknown"];
|
|
9
|
+
/** JSON body field (POST, PUT, …). */
|
|
10
|
+
export const CONTEXT_FIELD = "agent_context";
|
|
11
|
+
/** Query parameters (GET and other requests without a body), prefixed so they don't collide with the merchant's. */
|
|
12
|
+
export const CONTEXT_QUERY = {
|
|
13
|
+
agent_type: "agent_type",
|
|
14
|
+
agent_type_other: "agent_type_other",
|
|
15
|
+
client: "agent_client",
|
|
16
|
+
search_query: "agent_search_query",
|
|
17
|
+
};
|
|
18
|
+
const LIMITS = { agent_type: 80, agent_type_other: 80, client: 80, search_query: 200 };
|
|
19
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
20
|
+
/** Lenient: bad or unknown values are dropped or kept as plain text, never rejected. Known names get their canonical case. */
|
|
21
|
+
export function parseAgentContext(input, { searchQuery = true } = {}) {
|
|
22
|
+
if (!isObj(input))
|
|
23
|
+
return undefined;
|
|
24
|
+
const context = {};
|
|
25
|
+
for (const key of Object.keys(LIMITS)) {
|
|
26
|
+
if (key === "search_query" && !searchQuery)
|
|
27
|
+
continue;
|
|
28
|
+
const raw = Array.isArray(input[key]) ? input[key][0] : input[key];
|
|
29
|
+
if (typeof raw !== "string")
|
|
30
|
+
continue;
|
|
31
|
+
const text = raw.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, LIMITS[key]);
|
|
32
|
+
if (!text)
|
|
33
|
+
continue;
|
|
34
|
+
const known = key === "agent_type" ? AGENT_TYPES : key === "client" ? CLIENTS : null;
|
|
35
|
+
context[key] = known?.find((v) => v.toLowerCase() === text.toLowerCase()) ?? text;
|
|
36
|
+
}
|
|
37
|
+
return Object.keys(context).length ? context : undefined;
|
|
38
|
+
}
|
|
39
|
+
/** Split `agent_context` off a parsed JSON body. The body is returned as a new object; the input is untouched. */
|
|
40
|
+
export function takeFromBody(body) {
|
|
41
|
+
if (!isObj(body) || Object.getPrototypeOf(body) !== Object.prototype || !Object.hasOwn(body, CONTEXT_FIELD))
|
|
42
|
+
return { body };
|
|
43
|
+
const { [CONTEXT_FIELD]: raw, ...rest } = body;
|
|
44
|
+
return { body: rest, raw };
|
|
45
|
+
}
|
|
46
|
+
/** Split the agent_* query parameters off a URL (path + query). Other parameters stay byte-for-byte as they were. */
|
|
47
|
+
export function takeFromUrl(url) {
|
|
48
|
+
const q = url.indexOf("?");
|
|
49
|
+
if (q < 0)
|
|
50
|
+
return { url };
|
|
51
|
+
const hash = url.indexOf("#", q);
|
|
52
|
+
const query = url.slice(q + 1, hash < 0 ? undefined : hash);
|
|
53
|
+
const names = new Map(Object.entries(CONTEXT_QUERY).map(([k, v]) => [v, k]));
|
|
54
|
+
const raw = {};
|
|
55
|
+
const kept = [];
|
|
56
|
+
for (const segment of query.split("&")) {
|
|
57
|
+
const eq = segment.indexOf("=");
|
|
58
|
+
let key;
|
|
59
|
+
let value;
|
|
60
|
+
try {
|
|
61
|
+
key = decodeURIComponent((eq < 0 ? segment : segment.slice(0, eq)).replace(/\+/g, " "));
|
|
62
|
+
value = eq < 0 ? "" : decodeURIComponent(segment.slice(eq + 1).replace(/\+/g, " "));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
kept.push(segment); // not ours if we can't even decode it
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const field = names.get(key);
|
|
69
|
+
if (field) {
|
|
70
|
+
if (!(field in raw))
|
|
71
|
+
raw[field] = value;
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
kept.push(segment);
|
|
75
|
+
}
|
|
76
|
+
if (!Object.keys(raw).length)
|
|
77
|
+
return { url };
|
|
78
|
+
const rest = kept.filter(Boolean).join("&");
|
|
79
|
+
return { url: `${url.slice(0, q)}${rest ? `?${rest}` : ""}${hash < 0 ? "" : url.slice(hash)}`, raw };
|
|
80
|
+
}
|
|
81
|
+
const DESCRIPTIONS = {
|
|
82
|
+
object: "Self-reported context about the calling agent, so the merchant can see which agents use this service. Not needed for the result; omit anything you don't know.",
|
|
83
|
+
agent_type: "Which agent are you? Use Other (with agent_type_other) if yours isn't listed, or Unknown.",
|
|
84
|
+
agent_type_other: "Your agent's name, when agent_type is Other.",
|
|
85
|
+
client: "Which x402 client or wallet paid for this call?",
|
|
86
|
+
search_query: "The search terms you used to find this service, for example in a marketplace or search tool. Omit if you didn't search; don't invent one, and never include your user's request.",
|
|
87
|
+
};
|
|
88
|
+
/** JSON Schema for the optional `agent_context` body property. */
|
|
89
|
+
export function agentContextSchema({ searchQuery = true } = {}) {
|
|
90
|
+
return {
|
|
91
|
+
type: "object",
|
|
92
|
+
description: DESCRIPTIONS.object,
|
|
93
|
+
properties: {
|
|
94
|
+
agent_type: { type: "string", enum: [...AGENT_TYPES], description: DESCRIPTIONS.agent_type },
|
|
95
|
+
agent_type_other: { type: "string", maxLength: LIMITS.agent_type_other, description: DESCRIPTIONS.agent_type_other },
|
|
96
|
+
client: { type: "string", enum: [...CLIENTS], description: DESCRIPTIONS.client },
|
|
97
|
+
...(searchQuery ? { search_query: { type: "string", maxLength: LIMITS.search_query, description: DESCRIPTIONS.search_query } } : {}),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** The optional query parameters, for operations without a request body. */
|
|
102
|
+
export function agentContextParameters({ searchQuery = true } = {}) {
|
|
103
|
+
const params = [
|
|
104
|
+
{ name: CONTEXT_QUERY.agent_type, description: DESCRIPTIONS.agent_type, schema: { type: "string", enum: [...AGENT_TYPES] } },
|
|
105
|
+
{ name: CONTEXT_QUERY.agent_type_other, description: DESCRIPTIONS.agent_type_other, schema: { type: "string", maxLength: LIMITS.agent_type_other } },
|
|
106
|
+
{ name: CONTEXT_QUERY.client, description: DESCRIPTIONS.client, schema: { type: "string", enum: [...CLIENTS] } },
|
|
107
|
+
];
|
|
108
|
+
if (searchQuery)
|
|
109
|
+
params.push({ name: CONTEXT_QUERY.search_query, description: DESCRIPTIONS.search_query, schema: { type: "string", maxLength: LIMITS.search_query } });
|
|
110
|
+
return params;
|
|
111
|
+
}
|
|
112
|
+
/** One line for the forge-feedback extension, so clients that only inspect the 402 learn about it too. */
|
|
113
|
+
export function agentContextAsk({ searchQuery = true } = {}) {
|
|
114
|
+
return searchQuery
|
|
115
|
+
? "Please add agent_context {agent_type, client, search_query} to your paid request's JSON body (without a body: agent_type, agent_client, agent_search_query query parameters). Self-reported; omit what you don't know. search_query is what you searched to find this service, never your user's request."
|
|
116
|
+
: "Please add agent_context {agent_type, client} to your paid request's JSON body (without a body: agent_type and agent_client query parameters). Self-reported; omit what you don't know.";
|
|
117
|
+
}
|
package/dist/core.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type EnrichReport } from "./openapi.js";
|
|
2
|
+
import { type Tone } from "./ask.js";
|
|
2
3
|
export interface OpenApiOptions {
|
|
3
4
|
/** Paths where your OpenAPI JSON is served. Default ["/openapi.json"]. */
|
|
4
5
|
paths?: string[];
|
|
@@ -20,15 +21,39 @@ export interface ForgeOptions {
|
|
|
20
21
|
publicUrl: string;
|
|
21
22
|
/** Path for the feedback routes. Default "/feedback" (quick rating at "/feedback/rate"). */
|
|
22
23
|
basePath?: string;
|
|
24
|
+
/**
|
|
25
|
+
* How the rating is asked for: "soft" (default, "please rate this service for other agents") or "lifecycle"
|
|
26
|
+
* (describes this service's flow as four steps: 402, pay, response, rate). Both stay soft asks.
|
|
27
|
+
*/
|
|
28
|
+
tone?: Tone;
|
|
23
29
|
/** Append the rating sentence to x402 challenges (v2 header, v1 JSON body). Default true. */
|
|
24
30
|
describeChallenges?: boolean;
|
|
25
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Override the appended sentence, for wording experiments. "{rate_url}" and "{summary_url}" are substituted.
|
|
33
|
+
* Text that presents the rating as required, makes anything depend on it, or asks for user data is refused
|
|
34
|
+
* (with a warning) and the tone's default is used.
|
|
35
|
+
*/
|
|
26
36
|
challengeSentence?: string;
|
|
27
37
|
/**
|
|
28
38
|
* Add a structured `forge-feedback` extension to x402 v2 challenges (next to e.g. `bazaar`), which clients
|
|
29
39
|
* that inspect the 402 print as its own block. Default true; `false` turns it off. v1 challenges have no extensions.
|
|
30
40
|
*/
|
|
31
41
|
challengeExtension?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Add the `forge-feedback` extension, with the real feedback_id, to the x402 v2 payment receipt
|
|
44
|
+
* (the PAYMENT-RESPONSE settlement header), so the rating follows the payment on the wire. Default true.
|
|
45
|
+
*/
|
|
46
|
+
receiptExtension?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Agent context: optional, self-reported `agent_context` {agent_type, client, search_query} on the paid request
|
|
49
|
+
* (JSON body, or agent_type / agent_client / agent_search_query query parameters). Forge documents it in the
|
|
50
|
+
* extension and OpenAPI, reads it, reports it with the call, and removes it before your validators and handlers
|
|
51
|
+
* run. Default true. `{ searchQuery: false }` stops asking for (and recording) the search query; `false` stops
|
|
52
|
+
* asking and recording altogether (the fields are still removed if an agent sends them).
|
|
53
|
+
*/
|
|
54
|
+
agentContext?: boolean | {
|
|
55
|
+
searchQuery?: boolean;
|
|
56
|
+
};
|
|
32
57
|
/** Add feedback_id and feedback_url to JSON object bodies of paid responses. Default true. */
|
|
33
58
|
injectBody?: boolean;
|
|
34
59
|
/**
|
|
@@ -80,8 +105,18 @@ export interface ForgeCall {
|
|
|
80
105
|
json(status: number, body: unknown): unknown;
|
|
81
106
|
/** A text body about to be sent: gets the two-line trailer on paid 2xx text/plain when injectText is on. */
|
|
82
107
|
text(status: number, contentType: string, body: string): string;
|
|
83
|
-
/**
|
|
84
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Headers to set once the final status is known: a rewritten PAYMENT-REQUIRED on 402; on a paid 2xx,
|
|
110
|
+
* Forge-Feedback-Id and (when you pass the settlement header) a PAYMENT-RESPONSE with the receipt extension.
|
|
111
|
+
*/
|
|
112
|
+
headers(status: number, paymentRequired: string | undefined, paymentResponse?: string): Record<string, string>;
|
|
113
|
+
/**
|
|
114
|
+
* A parsed JSON request body, before your validators and handlers see it: reads `agent_context` and returns
|
|
115
|
+
* the body without it (a new object; other fields untouched). Anything else is returned as is.
|
|
116
|
+
*/
|
|
117
|
+
requestBody(body: unknown): unknown;
|
|
118
|
+
/** A request URL (path + query): reads the agent_* query parameters and returns the URL without them. */
|
|
119
|
+
requestUrl(url: string): string;
|
|
85
120
|
/**
|
|
86
121
|
* Report the challenge / interaction events after the response is sent. A 402 counts as a challenge when
|
|
87
122
|
* headers() saw a PAYMENT-REQUIRED value or json() saw a challenge body; pass true if you know of one otherwise.
|
package/dist/core.js
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
// objects to the small interfaces below and write out what the core returns.
|
|
3
3
|
import { DEFAULT_TTL_MS, mintFeedbackId, verifyFeedbackId } from "./id.js";
|
|
4
4
|
import { createOperationIndex, enrichOpenApi } from "./openapi.js";
|
|
5
|
+
import { ASK, TONES, checkAskText } from "./ask.js";
|
|
6
|
+
import { agentContextAsk, parseAgentContext, takeFromBody, takeFromUrl } from "./context.js";
|
|
5
7
|
import { EventReporter } from "./reporter.js";
|
|
6
8
|
import { ISSUES, NOTE_MAX_LENGTH, OUTCOMES, PROTOCOL, parseSubmission } from "./values.js";
|
|
7
|
-
import { describeChallenge, describeChallengeBody, feedbackExtension, readPaymentHeader } from "./x402.js";
|
|
9
|
+
import { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, readPaymentHeader, receiptExtension } from "./x402.js";
|
|
8
10
|
export const FEEDBACK_HEADERS = { "Cache-Control": "no-store", "X-Robots-Tag": "noindex" };
|
|
9
11
|
/** Max JSON body for POST {basePath}. */
|
|
10
12
|
export const BODY_LIMIT = 8 * 1024;
|
|
11
13
|
/** Max OpenAPI document an adapter should buffer for enrichment. */
|
|
12
14
|
export const SPEC_LIMIT = 10 * 1024 * 1024;
|
|
13
|
-
const DEFAULT_RATE_HINT = "Please rate this service for other agents: GET {feedback_url} with fully, partially or no appended (e.g. {feedback_url}fully). Free, one request.";
|
|
14
15
|
/**
|
|
15
16
|
* Check options without throwing. Invalid required options are errors (Forge runs disabled);
|
|
16
17
|
* invalid optional values are warnings and fall back to their defaults.
|
|
@@ -55,10 +56,21 @@ export function checkOptions(input) {
|
|
|
55
56
|
fallback("flushIntervalMs", positive(raw.flushIntervalMs), "must be a positive number of milliseconds");
|
|
56
57
|
fallback("fetch", typeof raw.fetch === "function", "must be a fetch function");
|
|
57
58
|
fallback("onError", typeof raw.onError === "function", "must be a function");
|
|
59
|
+
fallback("tone", TONES.includes(raw.tone), `must be one of ${TONES.map((t) => `"${t}"`).join(", ")}`);
|
|
58
60
|
fallback("challengeSentence", typeof raw.challengeSentence === "string" && raw.challengeSentence.trim() !== "", "must be a non-empty string");
|
|
59
61
|
fallback("rateHint", boolean(raw.rateHint) || typeof raw.rateHint === "string", "must be a boolean or a string");
|
|
60
|
-
|
|
62
|
+
fallback("agentContext", boolean(raw.agentContext) || (!!raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)), "must be true, false or { searchQuery }");
|
|
63
|
+
for (const key of ["describeChallenges", "challengeExtension", "receiptExtension", "injectBody", "injectText", "strict"])
|
|
61
64
|
fallback(key, boolean(raw[key]), "must be true or false");
|
|
65
|
+
// The lines no wording may cross, whatever the merchant configures (see ask.ts).
|
|
66
|
+
for (const key of ["challengeSentence", "rateHint"]) {
|
|
67
|
+
const text = options[key];
|
|
68
|
+
const problem = typeof text === "string" ? checkAskText(text) : null;
|
|
69
|
+
if (problem) {
|
|
70
|
+
warnings.push(`${key} ${problem}; Forge never presents the rating as required or conditional, or asks for user data. Using the default`);
|
|
71
|
+
delete options[key];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
62
74
|
if (raw.openapi !== undefined && raw.openapi !== false) {
|
|
63
75
|
const openapi = raw.openapi;
|
|
64
76
|
if (!openapi || typeof openapi !== "object")
|
|
@@ -81,7 +93,15 @@ export function checkOptions(input) {
|
|
|
81
93
|
}
|
|
82
94
|
/** Everything passes through untouched. Used when invalid options turned Forge off. */
|
|
83
95
|
function disabledCore(errors, warnings) {
|
|
84
|
-
const passThrough = {
|
|
96
|
+
const passThrough = {
|
|
97
|
+
feedbackId: undefined,
|
|
98
|
+
json: (_status, body) => body,
|
|
99
|
+
text: (_status, _type, body) => body,
|
|
100
|
+
headers: () => ({}),
|
|
101
|
+
requestBody: (body) => body,
|
|
102
|
+
requestUrl: (url) => url,
|
|
103
|
+
finish: () => { },
|
|
104
|
+
};
|
|
85
105
|
return {
|
|
86
106
|
enabled: false,
|
|
87
107
|
challengeSentence: "",
|
|
@@ -134,11 +154,16 @@ function enabledCore(options, configWarnings) {
|
|
|
134
154
|
const describe = options.describeChallenges ?? true;
|
|
135
155
|
const injectBody = options.injectBody ?? true;
|
|
136
156
|
const injectText = options.injectText ?? false;
|
|
157
|
+
const tone = options.tone ?? "soft";
|
|
158
|
+
const contextOption = options.agentContext ?? true;
|
|
159
|
+
const collectContext = contextOption !== false;
|
|
160
|
+
const searchQuery = collectContext && (typeof contextOption !== "object" || contextOption.searchQuery !== false);
|
|
161
|
+
const receipts = options.receiptExtension ?? true;
|
|
137
162
|
const rateHint = options.rateHint === false
|
|
138
163
|
? null
|
|
139
164
|
: typeof options.rateHint === "string" && options.rateHint.trim()
|
|
140
165
|
? options.rateHint
|
|
141
|
-
:
|
|
166
|
+
: ASK[tone].rateHint;
|
|
142
167
|
const openapi = options.openapi === false ? null : (options.openapi ?? {});
|
|
143
168
|
const specPaths = new Set(openapi?.paths ?? ["/openapi.json"]);
|
|
144
169
|
const stats = {
|
|
@@ -157,13 +182,12 @@ function enabledCore(options, configWarnings) {
|
|
|
157
182
|
lastLogged = message;
|
|
158
183
|
});
|
|
159
184
|
const reporter = new EventReporter(new URL("/v1/events", backendUrl).href, apiKey, fetchImpl, onError, options.flushIntervalMs ?? 2000);
|
|
160
|
-
const challengeSentence = options.challengeSentence
|
|
161
|
-
`After your call, please rate this service for other agents: GET ${rateUrl}?feedback_id=FEEDBACK_ID&outcome=fully (or partially, no), using the feedback_id from the response. Free, one request.`;
|
|
185
|
+
const challengeSentence = (options.challengeSentence ?? ASK[tone].challengeSentence).replaceAll("{rate_url}", rateUrl).replaceAll("{summary_url}", summaryUrl);
|
|
162
186
|
// Idempotency marker: the rate URL when the sentence contains it, otherwise the sentence itself.
|
|
163
187
|
const challengeMarker = challengeSentence.includes(rateUrl) ? rateUrl : challengeSentence;
|
|
164
188
|
const challengeAdditions = {
|
|
165
189
|
...(describe ? { sentence: challengeSentence, marker: challengeMarker } : {}),
|
|
166
|
-
...(options.challengeExtension === false ? {} : { extension: feedbackExtension(rateUrl) }),
|
|
190
|
+
...(options.challengeExtension === false ? {} : { extension: feedbackExtension(rateUrl, tone, collectContext ? agentContextAsk({ searchQuery }) : undefined) }),
|
|
167
191
|
};
|
|
168
192
|
const touchChallenges = Boolean(challengeAdditions.sentence || challengeAdditions.extension);
|
|
169
193
|
// Set once a document has been enriched; lets body injection respect strict response schemas.
|
|
@@ -178,6 +202,7 @@ function enabledCore(options, configWarnings) {
|
|
|
178
202
|
isPaidOperation: openapi?.isPaidOperation,
|
|
179
203
|
describeOperations: openapi?.describeOperations,
|
|
180
204
|
hintField: Boolean(rateHint),
|
|
205
|
+
agentContext: collectContext ? { searchQuery } : undefined,
|
|
181
206
|
});
|
|
182
207
|
stats.openapi = result.report;
|
|
183
208
|
if (result.report.enriched)
|
|
@@ -301,7 +326,15 @@ function enabledCore(options, configWarnings) {
|
|
|
301
326
|
}
|
|
302
327
|
return null;
|
|
303
328
|
}
|
|
304
|
-
const passThrough = {
|
|
329
|
+
const passThrough = {
|
|
330
|
+
feedbackId: undefined,
|
|
331
|
+
json: (_status, body) => body,
|
|
332
|
+
text: (_status, _type, body) => body,
|
|
333
|
+
headers: () => ({}),
|
|
334
|
+
requestBody: (body) => body,
|
|
335
|
+
requestUrl: (url) => url,
|
|
336
|
+
finish: () => { },
|
|
337
|
+
};
|
|
305
338
|
function call(request) {
|
|
306
339
|
try {
|
|
307
340
|
return startCall(request);
|
|
@@ -322,6 +355,14 @@ function enabledCore(options, configWarnings) {
|
|
|
322
355
|
const feedbackUrl = feedbackId ? `${rateUrl}?feedback_id=${feedbackId}&outcome=` : "";
|
|
323
356
|
let bodyChallenge = false;
|
|
324
357
|
let headerChallenge = false;
|
|
358
|
+
let context;
|
|
359
|
+
const remember = (raw) => {
|
|
360
|
+
if (!collectContext)
|
|
361
|
+
return;
|
|
362
|
+
const parsed = parseAgentContext(raw, { searchQuery });
|
|
363
|
+
if (parsed)
|
|
364
|
+
context = { ...context, ...parsed };
|
|
365
|
+
};
|
|
325
366
|
const ok = (status) => status >= 200 && status < 300;
|
|
326
367
|
const handle = {
|
|
327
368
|
feedbackId,
|
|
@@ -366,7 +407,7 @@ function enabledCore(options, configWarnings) {
|
|
|
366
407
|
return body;
|
|
367
408
|
return `${body}${body.endsWith("\n") ? "" : "\n"}\nfeedback_id: ${feedbackId}\nfeedback_url: ${feedbackUrl}\n`;
|
|
368
409
|
},
|
|
369
|
-
headers(status, paymentRequired) {
|
|
410
|
+
headers(status, paymentRequired, paymentResponse) {
|
|
370
411
|
const set = {};
|
|
371
412
|
try {
|
|
372
413
|
if (status === 402 && paymentRequired)
|
|
@@ -378,14 +419,42 @@ function enabledCore(options, configWarnings) {
|
|
|
378
419
|
stats.challengesDescribed++;
|
|
379
420
|
}
|
|
380
421
|
}
|
|
381
|
-
if (feedbackId && ok(status))
|
|
422
|
+
if (feedbackId && ok(status)) {
|
|
382
423
|
set["Forge-Feedback-Id"] = feedbackId;
|
|
424
|
+
const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(rateUrl, feedbackId, tone)) : undefined;
|
|
425
|
+
if (receipt)
|
|
426
|
+
set["PAYMENT-RESPONSE"] = receipt;
|
|
427
|
+
}
|
|
383
428
|
}
|
|
384
429
|
catch (error) {
|
|
385
430
|
onError(error);
|
|
386
431
|
}
|
|
387
432
|
return set;
|
|
388
433
|
},
|
|
434
|
+
requestBody(body) {
|
|
435
|
+
try {
|
|
436
|
+
const taken = takeFromBody(body);
|
|
437
|
+
if ("raw" in taken)
|
|
438
|
+
remember(taken.raw);
|
|
439
|
+
return taken.body;
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
onError(error);
|
|
443
|
+
return body;
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
requestUrl(url) {
|
|
447
|
+
try {
|
|
448
|
+
const taken = takeFromUrl(url);
|
|
449
|
+
if (taken.raw)
|
|
450
|
+
remember(taken.raw);
|
|
451
|
+
return taken.url;
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
onError(error);
|
|
455
|
+
return url;
|
|
456
|
+
}
|
|
457
|
+
},
|
|
389
458
|
finish(status, hadChallengeHeader = false) {
|
|
390
459
|
try {
|
|
391
460
|
report(status, hadChallengeHeader);
|
|
@@ -399,7 +468,7 @@ function enabledCore(options, configWarnings) {
|
|
|
399
468
|
const ts = Date.now();
|
|
400
469
|
if (status === 402 && (hadChallengeHeader || headerChallenge || bodyChallenge)) {
|
|
401
470
|
// v2 carries the challenge in a header; v1 in the body. Both count as a challenge.
|
|
402
|
-
reporter.push({ type: "challenge", route, ts, user_agent: userAgent });
|
|
471
|
+
reporter.push({ type: "challenge", route, ts, user_agent: userAgent, ...(context ? { agent_context: context } : {}) });
|
|
403
472
|
}
|
|
404
473
|
if (feedbackId) {
|
|
405
474
|
const facts = readPaymentHeader(paymentHeader);
|
|
@@ -412,6 +481,7 @@ function enabledCore(options, configWarnings) {
|
|
|
412
481
|
// Only report the payer once the call succeeded (i.e. settlement went through).
|
|
413
482
|
...(ok(status) ? facts : { network: facts.network, amount: facts.amount }),
|
|
414
483
|
user_agent: userAgent,
|
|
484
|
+
...(context ? { agent_context: context } : {}),
|
|
415
485
|
ts,
|
|
416
486
|
});
|
|
417
487
|
}
|