agentic-relay 3.8.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/CLAUDE.md +56 -0
- package/README.md +178 -0
- package/bin/cli.mjs +36 -0
- package/bin/install.mjs +887 -0
- package/bin/lib/api.mjs +188 -0
- package/bin/lib/cache.mjs +161 -0
- package/bin/lib/commands.mjs +495 -0
- package/bin/lib/config.mjs +69 -0
- package/bin/lib/deliverable.mjs +70 -0
- package/bin/lib/driver.mjs +165 -0
- package/bin/lib/fetch.mjs +189 -0
- package/bin/lib/parse.mjs +148 -0
- package/bin/lib/pool.mjs +26 -0
- package/bin/relay-core.mjs +382 -0
- package/bin/test/budget.test.mjs +31 -0
- package/bin/test/cache.test.mjs +71 -0
- package/bin/test/driver.test.mjs +49 -0
- package/bin/test/fetch.test.mjs +128 -0
- package/bin/test/parse.test.mjs +70 -0
- package/bin/test/session.test.mjs +67 -0
- package/package.json +49 -0
- package/schema/deliverable.json +47 -0
- package/skill/SKILL.md +194 -0
package/bin/lib/api.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin upstream client for the AttentionMarket REST API (spec §2). Every call is
|
|
3
|
+
* wrapped with a timeout (AbortController) and lenient JSON parsing so a
|
|
4
|
+
* malformed body or slow turn degrades to a handled error rather than a crash.
|
|
5
|
+
*
|
|
6
|
+
* The API key is set on the X-AM-API-Key header and never logged.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { API_BASE } from "./config.mjs";
|
|
10
|
+
import { lenientParse } from "./parse.mjs";
|
|
11
|
+
|
|
12
|
+
export class ApiError extends Error {
|
|
13
|
+
constructor(message, { status, body } = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ApiError";
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.body = body;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* POST JSON to `${API_BASE}${path}`. Returns the parsed response body.
|
|
23
|
+
* Throws ApiError on non-2xx, timeout, or network failure.
|
|
24
|
+
*/
|
|
25
|
+
export async function apiPost(path, body, { apiKey, timeoutMs = 30000 } = {}) {
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
28
|
+
let resp;
|
|
29
|
+
try {
|
|
30
|
+
resp = await fetch(`${API_BASE}${path}`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: {
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
"X-AM-API-Key": apiKey,
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
});
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err && err.name === "AbortError") {
|
|
41
|
+
throw new ApiError(`timeout after ${Math.round(timeoutMs / 1000)}s`, {
|
|
42
|
+
status: 0,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
throw new ApiError(`network error: ${err.message}`, { status: 0 });
|
|
46
|
+
} finally {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const text = await resp.text();
|
|
51
|
+
const parsed = lenientParse(text);
|
|
52
|
+
const data = parsed.ok ? parsed.value : null;
|
|
53
|
+
|
|
54
|
+
if (!resp.ok) {
|
|
55
|
+
const msg =
|
|
56
|
+
(data && (data.message || data.error)) ||
|
|
57
|
+
`HTTP ${resp.status}`;
|
|
58
|
+
throw new ApiError(String(msg), { status: resp.status, body: data });
|
|
59
|
+
}
|
|
60
|
+
if (!parsed.ok) {
|
|
61
|
+
throw new ApiError("unparseable response from upstream", {
|
|
62
|
+
status: resp.status,
|
|
63
|
+
body: text.slice(0, 500),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function search(query, { apiKey, userContext, maxResults = 25, timeoutMs }) {
|
|
70
|
+
const body = { query, max_results: maxResults };
|
|
71
|
+
if (userContext) body.user_context = userContext;
|
|
72
|
+
return apiPost("/search", body, { apiKey, timeoutMs });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function sessionStart(
|
|
76
|
+
{ capabilityId, adUnitId, clearingPriceCents, initialData },
|
|
77
|
+
{ apiKey, timeoutMs },
|
|
78
|
+
) {
|
|
79
|
+
return apiPost(
|
|
80
|
+
"/session/start",
|
|
81
|
+
{
|
|
82
|
+
capability_id: capabilityId,
|
|
83
|
+
ad_unit_id: adUnitId,
|
|
84
|
+
clearing_price_cents: clearingPriceCents,
|
|
85
|
+
...(initialData ? { initial_data: initialData } : {}),
|
|
86
|
+
},
|
|
87
|
+
{ apiKey, timeoutMs },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function sessionMessage(
|
|
92
|
+
{ sessionId, message, structuredData },
|
|
93
|
+
{ apiKey, timeoutMs },
|
|
94
|
+
) {
|
|
95
|
+
return apiPost(
|
|
96
|
+
"/session/message",
|
|
97
|
+
{
|
|
98
|
+
session_id: sessionId,
|
|
99
|
+
message,
|
|
100
|
+
...(structuredData ? { structured_data: structuredData } : {}),
|
|
101
|
+
},
|
|
102
|
+
{ apiKey, timeoutMs },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function sessionEnd({ sessionId }, { apiKey, timeoutMs }) {
|
|
107
|
+
return apiPost("/session/end", { session_id: sessionId }, { apiKey, timeoutMs });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Settle a payment request for a session. Omit `paymentCredential` to pay from the
|
|
112
|
+
* account's server-side wallet grant; pass it for a client-side MPP/x402 wallet.
|
|
113
|
+
* Returns the structured SettleResult ({ ok, error_code, ... }); does not throw on a
|
|
114
|
+
* 402 policy/charge rejection — the caller inspects `ok`.
|
|
115
|
+
*/
|
|
116
|
+
export async function sessionPay({ sessionId, referenceId, paymentCredential }, { apiKey, timeoutMs }) {
|
|
117
|
+
try {
|
|
118
|
+
return await apiPost(
|
|
119
|
+
"/session/pay",
|
|
120
|
+
{
|
|
121
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
122
|
+
...(referenceId ? { reference_id: referenceId } : {}),
|
|
123
|
+
...(paymentCredential ? { payment_credential: paymentCredential } : {}),
|
|
124
|
+
},
|
|
125
|
+
{ apiKey, timeoutMs },
|
|
126
|
+
);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// A 402 is an expected "policy/charge rejected" outcome — return its structured body
|
|
129
|
+
// so the agent can fall back to bubbling the payment_url, not crash.
|
|
130
|
+
if (err instanceof ApiError && err.body && typeof err.body === "object") return err.body;
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function feedback({ sessionId, feedbackText, score }, { apiKey, timeoutMs }) {
|
|
136
|
+
return apiPost(
|
|
137
|
+
"/feedback",
|
|
138
|
+
{ session_id: sessionId, feedback_text: feedbackText, score },
|
|
139
|
+
{ apiKey, timeoutMs },
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Generic request used by the GET/PATCH payment-policy endpoints. Same timeout +
|
|
145
|
+
* lenient-parse + error handling as apiPost; omits the body for GET.
|
|
146
|
+
*/
|
|
147
|
+
async function apiRequest(method, path, body, { apiKey, timeoutMs = 30000 } = {}) {
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
150
|
+
let resp;
|
|
151
|
+
try {
|
|
152
|
+
resp = await fetch(`${API_BASE}${path}`, {
|
|
153
|
+
method,
|
|
154
|
+
headers: { "Content-Type": "application/json", "X-AM-API-Key": apiKey },
|
|
155
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
156
|
+
signal: controller.signal,
|
|
157
|
+
});
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (err && err.name === "AbortError") {
|
|
160
|
+
throw new ApiError(`timeout after ${Math.round(timeoutMs / 1000)}s`, { status: 0 });
|
|
161
|
+
}
|
|
162
|
+
throw new ApiError(`network error: ${err.message}`, { status: 0 });
|
|
163
|
+
} finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const text = await resp.text();
|
|
168
|
+
const parsed = lenientParse(text);
|
|
169
|
+
const data = parsed.ok ? parsed.value : null;
|
|
170
|
+
if (!resp.ok) {
|
|
171
|
+
const msg = (data && (data.message || data.error)) || `HTTP ${resp.status}`;
|
|
172
|
+
throw new ApiError(String(msg), { status: resp.status, body: data });
|
|
173
|
+
}
|
|
174
|
+
if (!parsed.ok) {
|
|
175
|
+
throw new ApiError("unparseable response from upstream", { status: resp.status, body: text.slice(0, 500) });
|
|
176
|
+
}
|
|
177
|
+
return data;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Read the account's autonomous-spend policy. */
|
|
181
|
+
export function getPaymentPolicy({ apiKey, timeoutMs }) {
|
|
182
|
+
return apiRequest("GET", "/account/payment-policy", undefined, { apiKey, timeoutMs });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Update the account's autonomous-spend policy (partial patch; cents). */
|
|
186
|
+
export function patchPaymentPolicy(patch, { apiKey, timeoutMs }) {
|
|
187
|
+
return apiRequest("PATCH", "/account/payment-policy", patch, { apiKey, timeoutMs });
|
|
188
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk cache (spec §9). Layout under --cache-dir (default ./.agent-relay):
|
|
3
|
+
*
|
|
4
|
+
* index.json { slug: { ts, expires, capability_id, ad_unit_id, last_session_id } }
|
|
5
|
+
* specs/<slug>.json a Deliverable
|
|
6
|
+
* search/<hash>.json { ts, expires, query, capabilities }
|
|
7
|
+
*
|
|
8
|
+
* This is what makes just-in-time, project-long consulting near-free: a repeat
|
|
9
|
+
* consult reads specs/<slug>.json instead of re-engaging the agent.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
readFileSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
} from "fs";
|
|
20
|
+
import { join } from "path";
|
|
21
|
+
import { createHash } from "crypto";
|
|
22
|
+
|
|
23
|
+
const SPEC_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
24
|
+
const SEARCH_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
25
|
+
|
|
26
|
+
export class Cache {
|
|
27
|
+
constructor(dir = ".agent-relay", { enabled = true } = {}) {
|
|
28
|
+
this.dir = dir;
|
|
29
|
+
this.enabled = enabled;
|
|
30
|
+
this.indexPath = join(dir, "index.json");
|
|
31
|
+
this.specsDir = join(dir, "specs");
|
|
32
|
+
this.searchDir = join(dir, "search");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_ensure() {
|
|
36
|
+
mkdirSync(this.specsDir, { recursive: true });
|
|
37
|
+
mkdirSync(this.searchDir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_readJson(path) {
|
|
41
|
+
if (!existsSync(path)) return null;
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── index ────────────────────────────────────────────────────────────
|
|
50
|
+
readIndex() {
|
|
51
|
+
return this._readJson(this.indexPath) || {};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
updateIndex(slug, entry) {
|
|
55
|
+
if (!this.enabled) return;
|
|
56
|
+
this._ensure();
|
|
57
|
+
const index = this.readIndex();
|
|
58
|
+
index[slug] = { ...(index[slug] || {}), ...entry };
|
|
59
|
+
writeFileSync(this.indexPath, JSON.stringify(index, null, 2) + "\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getIndexEntry(slug) {
|
|
63
|
+
return this.readIndex()[slug] || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── search cache ─────────────────────────────────────────────────────
|
|
67
|
+
_searchPath(query) {
|
|
68
|
+
const h = createHash("sha1").update(query).digest("hex").slice(0, 16);
|
|
69
|
+
return join(this.searchDir, `${h}.json`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
readSearch(query) {
|
|
73
|
+
if (!this.enabled) return null;
|
|
74
|
+
const entry = this._readJson(this._searchPath(query));
|
|
75
|
+
if (!entry) return null;
|
|
76
|
+
if (Date.now() > entry.expires) return null;
|
|
77
|
+
return entry.capabilities || null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The buyer-side spend policy snapshot the search API returns alongside the
|
|
81
|
+
// menu. Cached with the search so a cache hit still hands the agent a policy
|
|
82
|
+
// to decide auto-pay-vs-bubble against (see SKILL.md Pay). Null on older
|
|
83
|
+
// cache files written before this field existed.
|
|
84
|
+
readSearchPaymentContext(query) {
|
|
85
|
+
if (!this.enabled) return null;
|
|
86
|
+
const entry = this._readJson(this._searchPath(query));
|
|
87
|
+
if (!entry) return null;
|
|
88
|
+
if (Date.now() > entry.expires) return null;
|
|
89
|
+
return entry.payment_context || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
writeSearch(query, capabilities, paymentContext = null) {
|
|
93
|
+
if (!this.enabled) return;
|
|
94
|
+
this._ensure();
|
|
95
|
+
writeFileSync(
|
|
96
|
+
this._searchPath(query),
|
|
97
|
+
JSON.stringify(
|
|
98
|
+
{
|
|
99
|
+
ts: new Date().toISOString(),
|
|
100
|
+
expires: Date.now() + SEARCH_TTL_MS,
|
|
101
|
+
query,
|
|
102
|
+
capabilities,
|
|
103
|
+
payment_context: paymentContext,
|
|
104
|
+
},
|
|
105
|
+
null,
|
|
106
|
+
2,
|
|
107
|
+
) + "\n",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── spec (Deliverable) cache ─────────────────────────────────────────
|
|
112
|
+
_specPath(slug) {
|
|
113
|
+
return join(this.specsDir, `${slug}.json`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
readSpec(slug, { ttlMs = SPEC_TTL_MS } = {}) {
|
|
117
|
+
if (!this.enabled) return null;
|
|
118
|
+
const spec = this._readJson(this._specPath(slug));
|
|
119
|
+
if (!spec) return null;
|
|
120
|
+
if (spec.ts && Date.now() - new Date(spec.ts).getTime() > ttlMs) return null;
|
|
121
|
+
return spec;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
writeSpec(slug, deliverable) {
|
|
125
|
+
if (!this.enabled) return;
|
|
126
|
+
this._ensure();
|
|
127
|
+
writeFileSync(this._specPath(slug), JSON.stringify(deliverable, null, 2) + "\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
listSpecs() {
|
|
131
|
+
if (!existsSync(this.specsDir)) return [];
|
|
132
|
+
return readdirSync(this.specsDir)
|
|
133
|
+
.filter((f) => f.endsWith(".json"))
|
|
134
|
+
.map((f) => f.replace(/\.json$/, ""));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Triage view of the banked specs — read straight from each spec file (no
|
|
139
|
+
* shared index.json), so it's safe under the parallel breadth pass where N
|
|
140
|
+
* subagents each `cache put` their own slug file concurrently. Lets the main
|
|
141
|
+
* agent see what's available + each summary without opening every file.
|
|
142
|
+
*/
|
|
143
|
+
listSpecHeadlines() {
|
|
144
|
+
if (!existsSync(this.specsDir)) return [];
|
|
145
|
+
return this.listSpecs().map((slug) => {
|
|
146
|
+
const spec = this._readJson(this._specPath(slug)) || {};
|
|
147
|
+
return {
|
|
148
|
+
slug,
|
|
149
|
+
name: spec.name ?? null,
|
|
150
|
+
summary: spec.summary ?? null,
|
|
151
|
+
features: Array.isArray(spec.features) ? spec.features.length : null,
|
|
152
|
+
capability_id: spec.capability_id ?? null,
|
|
153
|
+
ts: spec.ts ?? null,
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
clear() {
|
|
159
|
+
if (existsSync(this.dir)) rmSync(this.dir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
}
|