@relvo/mcp 0.1.2 → 0.1.3

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.
Files changed (50) hide show
  1. package/README.md +90 -5
  2. package/bin/relvo-mcp.mjs +1 -1
  3. package/dist/client/agent-run.js +681 -0
  4. package/dist/client/browser.js +650 -0
  5. package/dist/client/evm-wallet.js +214 -0
  6. package/dist/client/index.js +3 -360
  7. package/dist/client/local-input.js +18 -11
  8. package/dist/client/local-profile.js +22 -110
  9. package/dist/client/local-setup.js +0 -1
  10. package/dist/client/tool-name-hints.js +48 -0
  11. package/dist/client/wallet-access.js +207 -0
  12. package/dist/mcp/agent-server.js +237 -0
  13. package/dist/mcp/context.js +3 -2
  14. package/dist/mcp/host-schema.js +47 -0
  15. package/dist/mcp/instructions.js +12 -2
  16. package/dist/mcp/local-upload.js +12 -4
  17. package/dist/mcp/operations.js +30 -0
  18. package/dist/mcp/runtime.js +25 -5
  19. package/dist/mcp/server.js +191 -37
  20. package/dist/mcp/version.js +1 -0
  21. package/dist/schema/accountRequestMethods.js +1 -0
  22. package/dist/schema/cronValidator.js +150 -0
  23. package/dist/schema/platformApiKey.js +5 -5
  24. package/dist/schema/platformBillingConstants.js +3 -0
  25. package/dist/schema/platformCapabilityFamilies.js +217 -0
  26. package/dist/schema/platformConnectionCatalog.js +20 -3
  27. package/dist/schema/platformConnectionPolicy.js +26 -0
  28. package/dist/schema/platformCustomTools.js +113 -0
  29. package/dist/schema/platformFunding.js +9 -0
  30. package/dist/schema/platformHttpConstraints.js +7 -0
  31. package/dist/schema/platformMcpTools.js +7 -0
  32. package/dist/schema/platformMetaTools.js +8 -6
  33. package/dist/schema/platformMonitorSchedule.js +123 -0
  34. package/dist/schema/platformMonitors.js +159 -0
  35. package/dist/schema/platformOAuth.js +7 -0
  36. package/dist/schema/platformPublicExecution.js +53 -15
  37. package/dist/schema/platformRoutes.js +61 -1
  38. package/dist/schema/platformSkills.js +1 -1
  39. package/dist/schema/platformTeamSecretName.js +3 -0
  40. package/dist/schema/platformTeamSkills.js +49 -0
  41. package/dist/schema/platformToolAccess.js +13 -0
  42. package/dist/schema/platformTriggers.js +152 -0
  43. package/dist/schema/platformWorkflows.js +100 -0
  44. package/dist/schema/scheduleTimeZone.js +15 -0
  45. package/dist/schema/toolDiscovery.js +20 -3
  46. package/dist/schema/toolInputRepair.js +110 -0
  47. package/dist/schema/toolResultContext.js +1 -1
  48. package/package.json +1 -1
  49. package/dist/schema/amazonAds.js +0 -13
  50. package/dist/schema/oauth2.js +0 -64
@@ -0,0 +1,681 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, lstat, open, readFile, realpath, link, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { join, relative, sep } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { z } from "zod";
6
+ import { platformToolInputSchema, platformCreateExecutionSchema, platformQuoteSchema, platformExecutionIdSchema, platformJsonObjectSchema } from "../schema/platformPublicExecution.js";
7
+ import { fitToolResultPage, toolResultSelectionSchema, TOOL_RESULT_PAGE_MAX_CHARS, TOOL_RESULT_PAGE_MAX_ITEMS } from "../schema/toolResultContext.js";
8
+ import { platformFundingShortfallSchema } from "../schema/platformFunding.js";
9
+ import { AUTO_CAPABILITY_VARIANTS } from "../schema/platformCapabilityFamilies.js";
10
+ const keySchema = z.string().regex(/^[A-Za-z0-9._:-]{8,128}$/);
11
+ const digestSchema = z.string().regex(/^[a-f0-9]{64}$/);
12
+ const recoveryReferencePattern = /^run:([a-f0-9]{64}):([A-Za-z0-9._:-]{8,128})$/;
13
+ const recoveryReferenceSchema = z.string().regex(recoveryReferencePattern);
14
+ function pathText(text) {
15
+ const parts = text.startsWith("/") ? text.split("/").slice(1).map(part => part.replaceAll("~1", "/").replaceAll("~0", "~")) : text.replaceAll("[]", "[*]").match(/[^.[\]]+/g) ?? [];
16
+ return parts.map(part => part.replace(/^(['"])(.*)\1$/, "$2")).filter((part, index) => part !== "" && !(index === 0 && part === "$"));
17
+ }
18
+ const looseResultPath = z.union([z.array(z.union([z.string().max(256), z.number().int().nonnegative().safe().transform(String)])),
19
+ z.string().max(4096).transform(pathText)]);
20
+ const pathSchema = looseResultPath.pipe(z.array(z.string().max(256)).max(24));
21
+ const TEXT_PAGE_MAX_CHARS = 20_000;
22
+ const selectionSchema = toolResultSelectionSchema.extend({ path: looseResultPath.optional().pipe(toolResultSelectionSchema.shape.path),
23
+ limit: z.number().int().min(1).optional(), maxChars: z.number().int().min(1).optional(),
24
+ find: z.array(z.string().min(1).max(50)).max(32).refine(terms => terms.join("").length <= 400, "find takes at most 400 characters of terms in all.").optional() });
25
+ const picksSchema = z.record(z.string().min(1).max(80), pathSchema).refine(value => Object.keys(value).length <= 24);
26
+ export const agentRunSchema = platformToolInputSchema.extend({
27
+ maximumChargeMicros: z.number().int().nonnegative().safe().nullable().optional().describe("Optional user-requested Relvo spending ceiling. Omit or use null when none was requested; do not invent a limit or ask for one. 0 permits only zero Relvo charges. 1 USD = 1000000 micros. Does not cap external account charges."),
28
+ externalBillingAccepted: z.boolean().default(false).describe("True only when the user has authorized this connected-account or BYOK usage and its external cost or quota use. The Relvo ceiling still applies; this never authorizes sending, publishing or unrelated account changes."),
29
+ idempotencyKey: keySchema.describe("Unique per purchase (add a random suffix). Save before calling. Reuse unchanged after lost replies; never replace unknown work."),
30
+ waitSeconds: z.number().int().min(1).max(60).default(25),
31
+ selection: selectionSchema.optional().describe("Optional result path/page/fields. fields works on objects and rows. Full output remains stored."),
32
+ pick: picksSchema.optional().describe("Optional named exact result paths, e.g. {title:[\"data\",\"title\"]}; with a path, may be relative to it (each row of its page). Missing paths are reported; null/false/0 are preserved."),
33
+ file: z.boolean().optional().describe("Write the whole selected path (fields applied) to a private local file instead of returning it: JSON Lines, one row per line, for an array. Returns the path for the host's file tools."),
34
+ }).strict();
35
+ export const agentResumeSchema = z.object({ runId: z.union([recoveryReferenceSchema, digestSchema, platformExecutionIdSchema]), waitSeconds: agentRunSchema.shape.waitSeconds,
36
+ selection: agentRunSchema.shape.selection, pick: agentRunSchema.shape.pick, file: agentRunSchema.shape.file }).strict();
37
+ export const agentResultSchema = z.object({ id: platformExecutionIdSchema, ...selectionSchema.shape,
38
+ pick: agentRunSchema.shape.pick, file: agentRunSchema.shape.file, waitSeconds: agentRunSchema.shape.waitSeconds }).strict();
39
+ const PENDING = ["queued", "running", "waiting"];
40
+ const savedSchema = z.object({ version: z.literal(1), requestHash: digestSchema, idempotencyKey: keySchema,
41
+ quote: platformQuoteSchema, purchase: platformCreateExecutionSchema }).strict();
42
+ const hash = (value) => createHash("sha256").update(value).digest("hex");
43
+ const isApiError = (error, code) => error instanceof Error && error.name === "PlatformApiError" && "code" in error && error.code === code;
44
+ const RUN_FIELDS = new Set(["provider", "relevanceCheck"]);
45
+ const invalidFields = z.object({ name: z.literal("PlatformApiError"), code: z.literal("platform_invalid_tool_arguments"), fields: z.array(z.string()).min(1) });
46
+ function withRunFieldsMoved(error, request) {
47
+ const refused = invalidFields.safeParse(error);
48
+ if (!refused.success || !refused.data.fields.every(field => RUN_FIELDS.has(field) && Object.hasOwn(request.arguments, field)
49
+ && (field === "provider" ? request.provider : request.relevanceCheck) === undefined))
50
+ return undefined;
51
+ const fields = refused.data.fields, moved = platformToolInputSchema.safeParse({ ...request, ...Object.fromEntries(fields.map(field => [field, request.arguments[field]])),
52
+ arguments: Object.fromEntries(Object.entries(request.arguments).filter(([key]) => !fields.includes(key))) });
53
+ return moved.success ? moved.data : undefined;
54
+ }
55
+ function canonical(value) {
56
+ if (Array.isArray(value))
57
+ return `[${value.map(canonical).join(",")}]`;
58
+ if (value !== null && typeof value === "object")
59
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
60
+ return JSON.stringify(value);
61
+ }
62
+ const keyForm = (key) => key.toLowerCase().replace(/[_-]/g, "");
63
+ function ownKey(object, key) {
64
+ if (Object.hasOwn(object, key))
65
+ return key;
66
+ const matches = Object.keys(object).filter(name => keyForm(name) === keyForm(key));
67
+ return matches.length === 1 ? matches[0] : undefined;
68
+ }
69
+ function childAt(value, part) {
70
+ if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value))
71
+ return {};
72
+ const name = ownKey(value, part);
73
+ return name === undefined ? {} : { name, value: value[name] };
74
+ }
75
+ function at(value, path) {
76
+ let current = value;
77
+ for (const key of path) {
78
+ if (current === null || typeof current !== "object")
79
+ return { found: false };
80
+ if (Array.isArray(current)) {
81
+ if (key === "length") {
82
+ current = current.length;
83
+ continue;
84
+ }
85
+ const index = /^(?:0|-?[1-9][0-9]*)$/.test(key) ? Number(key) < 0 ? current.length + Number(key) : Number(key) : -1;
86
+ if (index < 0 || index >= current.length)
87
+ return { found: false };
88
+ current = current[index];
89
+ }
90
+ else {
91
+ const own = ownKey(current, key);
92
+ if (own === undefined)
93
+ return { found: false };
94
+ current = current[own];
95
+ }
96
+ }
97
+ return { found: true, value: current };
98
+ }
99
+ function shape(value, depth = 0) {
100
+ if (Array.isArray(value))
101
+ return { kind: "array", length: value.length, ...(value.length && depth < 2 ? { firstItem: shape(value[0], depth + 1) } : {}) };
102
+ if (value !== null && typeof value === "object")
103
+ return { kind: "object", totalKeys: Object.keys(value).length, keys: Object.keys(value).filter(key => key.length <= 256).slice(0, 64),
104
+ ...(depth < 1 ? { children: Object.fromEntries(Object.entries(value).filter(([key, child]) => key.length <= 256 && child !== null && typeof child === "object").slice(0, 2).map(([key, child]) => [key, shape(child, depth + 1)])) } : {}) };
105
+ return { kind: value === null ? "null" : typeof value, ...(typeof value === "string" ? { length: value.length } : {}) };
106
+ }
107
+ function rowFields(row, fields) {
108
+ if (!fields?.length)
109
+ return row;
110
+ if (row === null || typeof row !== "object" || Array.isArray(row))
111
+ throw new Error("Select row fields only on objects; inspect the retained result shape.");
112
+ return objectFields(row, fields);
113
+ }
114
+ function objectFields(row, fields) {
115
+ const picked = {};
116
+ for (const key of fields) {
117
+ const own = ownKey(row, key);
118
+ if (own !== undefined) {
119
+ picked[own] = row[own];
120
+ continue;
121
+ }
122
+ const parts = [];
123
+ let value = row;
124
+ for (const part of key.split(".")) {
125
+ const child = childAt(value, part);
126
+ value = child.value;
127
+ parts.push(child.name ?? part);
128
+ }
129
+ if (parts.length < 2 || value === undefined)
130
+ continue;
131
+ let target = picked;
132
+ for (const part of parts.slice(0, -1)) {
133
+ const existing = target[part];
134
+ const copy = existing !== null && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
135
+ target[part] = copy;
136
+ target = copy;
137
+ }
138
+ target[parts[parts.length - 1]] = value;
139
+ }
140
+ return picked;
141
+ }
142
+ function cell(picked, field) {
143
+ const own = childAt(picked, field);
144
+ if (own.name !== undefined)
145
+ return own.value ?? null;
146
+ let value = picked;
147
+ for (const part of field.split("."))
148
+ value = childAt(value, part).value;
149
+ return value ?? null;
150
+ }
151
+ const TABLE_AFTER_ROWS = 20;
152
+ function rowWindow(row, path, index) {
153
+ const fields = {}, fragments = [], references = [];
154
+ for (const [key, value] of Object.entries(row)) {
155
+ const target = [...path, String(index), key];
156
+ if (typeof value === "string" && value.length > 512) {
157
+ let end = 512;
158
+ if (/[\uD800-\uDBFF]/.test(value[end - 1] ?? "") && /[\uDC00-\uDFFF]/.test(value[end] ?? ""))
159
+ end++;
160
+ fragments.push({ path: target, text: value.slice(0, end), offset: 0, totalChars: value.length, nextOffset: end });
161
+ }
162
+ else if (value !== null && typeof value === "object" && Buffer.byteLength(JSON.stringify(value)) > 512) {
163
+ references.push({ path: target, shape: shape(value) });
164
+ }
165
+ else
166
+ fields[key] = value;
167
+ }
168
+ return { index, fields, ...(fragments.length ? { fragments } : {}), ...(references.length ? { references } : {}) };
169
+ }
170
+ function findInText(text, terms, offset, pageBytes) {
171
+ const pattern = new RegExp(terms.map(term => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "giu");
172
+ const matches = [];
173
+ let used = 1, position = offset;
174
+ while (position < text.length) {
175
+ const newline = text.indexOf("\n", position), end = newline < 0 ? text.length : newline;
176
+ const line = text.slice(position, end);
177
+ for (let cursor = 0; cursor < line.length;) {
178
+ pattern.lastIndex = cursor;
179
+ const hit = pattern.exec(line);
180
+ if (!hit)
181
+ break;
182
+ let from = line.length > 600 ? Math.max(cursor, hit.index - 200) : 0, to = Math.min(line.length, from + 600);
183
+ if (/[\uDC00-\uDFFF]/.test(line[from] ?? ""))
184
+ from--;
185
+ if (/[\uD800-\uDBFF]/.test(line[to - 1] ?? ""))
186
+ to++;
187
+ const match = { offset: position + from, text: line.slice(from, to) }, size = Buffer.byteLength(JSON.stringify(match)) + 1;
188
+ if (used + size > pageBytes && matches.length)
189
+ return { matches, total: text.length, nextOffset: position + from };
190
+ matches.push(match);
191
+ used += size;
192
+ cursor = to;
193
+ }
194
+ position = end + 1;
195
+ }
196
+ return { matches, total: text.length, nextOffset: null };
197
+ }
198
+ function pathSlice(value, requested) {
199
+ const path = requested.map((segment, index) => segment === "*" && Array.isArray(at(value, requested.slice(0, index)).value) ? ":" : segment);
200
+ const isSlice = (segment) => /^(-?\d+)?:(-?\d+)?$/.test(segment);
201
+ const sliceAt = path.findIndex((segment, index) => isSlice(segment) && path.slice(index + 1).every(part => !isSlice(part) && !part.includes(".")));
202
+ const slice = sliceAt < 0 ? null : /^(-?\d+)?:(-?\d+)?$/.exec(path[sliceAt]);
203
+ if (!slice)
204
+ return undefined;
205
+ const sliced = at(value, path.slice(0, sliceAt)).value, length = Array.isArray(sliced) ? sliced.length : 0;
206
+ const bound = (text) => text === undefined ? undefined : Number(text) < 0 ? Math.max(0, length + Number(text)) : Number(text);
207
+ return { path: path.slice(0, sliceAt), start: bound(slice[1]) ?? 0, end: bound(slice[2]), ...(sliceAt < path.length - 1 ? { field: path.slice(sliceAt + 1).join(".") } : {}) };
208
+ }
209
+ function rowPicks(value, picks) {
210
+ let path;
211
+ const relative = {};
212
+ for (const [label, pick] of Object.entries(picks)) {
213
+ const index = pick.findIndex((segment, position) => (segment === "*" || /^(-?\d+)?:(-?\d+)?$/.test(segment)) && Array.isArray(at(value, pick.slice(0, position)).value));
214
+ const prefix = index < 0 ? undefined : [...pick.slice(0, index), pick[index] === "*" ? ":" : pick[index]];
215
+ if (!prefix || path && JSON.stringify(prefix) !== JSON.stringify(path))
216
+ return undefined;
217
+ path = prefix;
218
+ relative[label] = pick.slice(index + 1);
219
+ }
220
+ return path && { path, picks: relative };
221
+ }
222
+ export function agentResultView(value, raw, picks, rowsOnly = false) {
223
+ const rows = picks && !rowsOnly && !raw?.path?.length ? rowPicks(value, picks) : undefined;
224
+ if (rows)
225
+ return agentResultView(value, { ...raw, path: rows.path }, rows.picks, true);
226
+ const requested = selectionSchema.parse(raw ?? {});
227
+ const parsed = { ...requested, limit: Math.min(requested.limit ?? 20, TOOL_RESULT_PAGE_MAX_ITEMS) };
228
+ const textChars = Math.min(requested.maxChars ?? requested.limit ?? TOOL_RESULT_PAGE_MAX_CHARS, TEXT_PAGE_MAX_CHARS);
229
+ const slice = pathSlice(value, parsed.path);
230
+ const input = slice ? { ...parsed, path: slice.path, offset: slice.start, ...(slice.field ? { fields: [slice.field] } : {}),
231
+ limit: slice.end !== undefined ? Math.min(Math.max(0, slice.end - slice.start), TOOL_RESULT_PAGE_MAX_ITEMS) : parsed.limit } : parsed;
232
+ const located = at(value, input.path);
233
+ if (!located.found)
234
+ return { found: false, path: input.path, shape: shape(value), retained: true };
235
+ const source = located.value ?? null;
236
+ if (slice && Array.isArray(source) && slice.start >= Math.min(slice.end ?? source.length, source.length))
237
+ return { value: [], path: input.path, total: source.length, nextOffset: null, retained: true };
238
+ const bytes = (item) => Buffer.byteLength(JSON.stringify(item));
239
+ if (!picks && !input.fields?.length && input.offset === 0 && input.path.length < 16 && source !== null && typeof source === "object" && !Array.isArray(source) && bytes(source) > 8000) {
240
+ const collections = Object.entries(source).filter(([, child]) => child !== null && typeof child === "object");
241
+ const metadata = Object.fromEntries(Object.entries(source).filter(([, child]) => child === null || typeof child !== "object"));
242
+ if (collections.length === 1 && bytes(metadata) <= 512)
243
+ return { ...agentResultView(value, { ...input, limit: requested.limit, path: [...input.path, collections[0][0]] }), autoSelected: true, envelopeShape: shape(source) };
244
+ const texts = Object.entries(source).filter(([, child]) => typeof child === "string" && bytes(child) > 512);
245
+ const fields = Object.fromEntries(Object.entries(source).filter(([key]) => key !== texts[0]?.[0]));
246
+ if (!collections.length && texts.length === 1 && bytes(fields) <= 512)
247
+ return { ...agentResultView(value, { ...input, limit: undefined, path: [...input.path, texts[0][0]] }), autoSelected: true, fields };
248
+ }
249
+ if (!picks && typeof source === "string" && requested.find?.length)
250
+ return { ...findInText(source, requested.find, input.offset, Math.max(8000, textChars)), path: input.path, retained: true,
251
+ instruction: "Each match is a line containing a term; offset is where its text starts. Read around a match with offset and maxChars, or search on from nextOffset." };
252
+ if (!picks && Array.isArray(source) && input.fields?.length && input.limit > TABLE_AFTER_ROWS) {
253
+ const columns = input.fields, rows = source.slice(input.offset, input.offset + input.limit).map(row => { const picked = rowFields(row, columns); return columns.map(field => cell(picked, field)); });
254
+ let lower = 1, upper = rows.length, count = 0;
255
+ while (lower <= upper) {
256
+ const middle = Math.floor((lower + upper) / 2);
257
+ if (bytes({ columns, rows: rows.slice(0, middle) }) <= TEXT_PAGE_MAX_CHARS) {
258
+ count = middle;
259
+ lower = middle + 1;
260
+ }
261
+ else
262
+ upper = middle - 1;
263
+ }
264
+ if (!count)
265
+ return { offloaded: true, path: input.path, shape: shape(source), retained: true, instruction: "Read narrower paths or fewer rows from the same execution; do not regenerate." };
266
+ return { table: { columns, rows: rows.slice(0, count) }, path: input.path, total: source.length, returned: count,
267
+ nextOffset: input.offset + count < source.length ? input.offset + count : null, retained: true };
268
+ }
269
+ let output, total = 1, returned = 1, paged = !picks;
270
+ const missing = [];
271
+ if (picks) {
272
+ const choose = (base, found) => Object.fromEntries(Object.entries(picks).flatMap(([label, path]) => {
273
+ const item = at(base, path);
274
+ if (!item.found)
275
+ return [];
276
+ found.add(label);
277
+ return [[label, item.value ?? null]];
278
+ }));
279
+ let found = new Set();
280
+ output = rowsOnly ? {} : choose(value, found);
281
+ if (!found.size && (rowsOnly || input.path.length)) {
282
+ const relative = new Set(), rows = Array.isArray(source) ? source.slice(input.offset, input.offset + input.limit) : undefined;
283
+ const chosen = rows ? rows.map(row => choose(row, relative)) : choose(source, relative);
284
+ if (relative.size) {
285
+ output = chosen;
286
+ found = relative;
287
+ if (rows && Array.isArray(source)) {
288
+ total = source.length;
289
+ returned = rows.length;
290
+ paged = true;
291
+ }
292
+ }
293
+ }
294
+ missing.push(...Object.keys(picks).filter(label => !found.has(label)));
295
+ }
296
+ else if (Array.isArray(source)) {
297
+ total = source.length;
298
+ const rows = source.slice(input.offset, input.offset + input.limit);
299
+ output = rows.map(row => rowFields(row, input.fields));
300
+ returned = rows.length;
301
+ }
302
+ else if (source !== null && typeof source === "object") {
303
+ const fields = input.fields?.length ? input.fields : undefined, keys = fields ?? Object.keys(source);
304
+ const present = fields ? fields.filter(field => Object.keys(objectFields(source, [field])).length) : keys;
305
+ missing.push(...(fields ?? []).filter(field => !present.includes(field)));
306
+ total = present.length;
307
+ const page = present.slice(input.offset, input.offset + input.limit);
308
+ output = fields ? objectFields(source, page) : Object.fromEntries(page.map(key => [key, source[key]]));
309
+ returned = page.length;
310
+ }
311
+ else if (typeof source === "string") {
312
+ total = source.length;
313
+ let end = Math.min(source.length, input.offset + textChars);
314
+ if (end < source.length && /[\uD800-\uDBFF]/.test(source[end - 1] ?? "") && /[\uDC00-\uDFFF]/.test(source[end] ?? ""))
315
+ end++;
316
+ output = source.slice(input.offset, end);
317
+ returned = output.length;
318
+ }
319
+ else
320
+ output = input.offset ? null : source;
321
+ if (!picks && Array.isArray(output) && bytes(output) > 8000 && output.some(row => bytes(row) > 4000)) {
322
+ const windows = [];
323
+ for (const [index, row] of output.entries()) {
324
+ if (row === null || typeof row !== "object" || Array.isArray(row))
325
+ break;
326
+ windows.push(rowWindow(row, input.path, input.offset + index));
327
+ }
328
+ if (windows.length === output.length) {
329
+ const page = fitToolResultPage({ path: input.path, kind: "array", offset: input.offset, total, nextOffset: input.offset + windows.length < total ? input.offset + windows.length : null,
330
+ context: { offloaded: false, preview: windows, totalBytes: bytes(windows) } }, candidate => bytes(candidate.context.preview) <= 8000);
331
+ if (bytes(page.context.preview) <= 8000)
332
+ return { rowWindows: page.context.preview, path: input.path, total, nextOffset: page.nextOffset,
333
+ retained: true, instruction: "fields are exact complete values; fragments are incomplete text excerpts with exact source paths/cursors. Read needed missing details from the same execution; never treat a fragment as a complete URL or identifier." };
334
+ }
335
+ }
336
+ const pageBytes = typeof output === "string" ? Math.max(8000, textChars) : requested.limit !== undefined || slice?.end !== undefined ? TEXT_PAGE_MAX_CHARS : 8000;
337
+ if (paged && (Array.isArray(output) || typeof output === "string") && bytes(output) > pageBytes) {
338
+ const page = fitToolResultPage({ path: input.path, kind: Array.isArray(output) ? "array" : "string", offset: input.offset, total, nextOffset: null,
339
+ context: { offloaded: false, preview: output, totalBytes: bytes(output) } }, candidate => bytes(candidate.context.preview) <= pageBytes);
340
+ output = page.context.preview;
341
+ if (typeof output === "string" || Array.isArray(output))
342
+ returned = output.length;
343
+ }
344
+ if (bytes(output) > pageBytes)
345
+ return { offloaded: true, path: input.path, shape: shape(source), retained: true,
346
+ instruction: "Read narrower paths or fewer rows from the same execution; do not regenerate." };
347
+ const short = paged && Array.isArray(output) && returned < Math.min(input.limit, total - input.offset);
348
+ return { value: output, ...(missing.length ? { missing } : {}), path: input.path, total, ...(short ? { returned } : {}),
349
+ nextOffset: !paged || input.offset + returned >= total ? null : input.offset + returned, retained: true };
350
+ }
351
+ export function resultFilesFor(env, cwd = process.cwd()) {
352
+ return env.RELVO_RESULT_DIRECTORY ? { directory: env.RELVO_RESULT_DIRECTORY } : { directory: join(cwd, ".relvo", "results"), within: cwd };
353
+ }
354
+ export class AgentRun {
355
+ client;
356
+ directory;
357
+ resultFiles;
358
+ topUp;
359
+ constructor(client, directory = join(tmpdir(), `relvo-runs-${process.getuid?.() ?? "user"}`), resultFiles, topUp) {
360
+ this.client = client;
361
+ this.directory = directory;
362
+ this.resultFiles = resultFiles;
363
+ this.topUp = topUp;
364
+ }
365
+ async resultFolder() {
366
+ if (!this.resultFiles) {
367
+ const path = join(await this.folder(), "results");
368
+ await mkdir(path, { recursive: true, mode: 0o700 });
369
+ return path;
370
+ }
371
+ const { directory, within } = this.resultFiles;
372
+ const escape = new Error("The result directory must not lead outside the working directory.");
373
+ let current = within;
374
+ for (const part of within ? relative(within, directory).split(sep) : []) {
375
+ current = join(current ?? "", part);
376
+ const info = await lstat(current).catch(error => { if (error instanceof Error && "code" in error && error.code === "ENOENT")
377
+ return null; throw error; });
378
+ if (!info)
379
+ break;
380
+ if (info.isSymbolicLink())
381
+ throw escape;
382
+ }
383
+ await mkdir(directory, { recursive: true, mode: 0o700 });
384
+ const info = await lstat(directory);
385
+ if (!info.isDirectory() || info.isSymbolicLink() || process.getuid && info.uid !== process.getuid())
386
+ throw new Error("The result directory must be a directory that belongs to this user.");
387
+ if (within && await realpath(directory) !== join(await realpath(within), relative(within, directory)))
388
+ throw escape;
389
+ await writeFile(join(directory, ".gitignore"), "*\n", { flag: "wx", mode: 0o600 }).catch(error => {
390
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
391
+ throw error;
392
+ });
393
+ return directory;
394
+ }
395
+ async alternatives(tool, maximumChargeMicros) {
396
+ const results = [];
397
+ try {
398
+ const definition = (await this.client.tool(tool)).data;
399
+ const providers = (definition.providers ?? []).filter(item => item.available && item.id !== "auto"), plans = [];
400
+ const family = AUTO_CAPABILITY_VARIANTS[tool];
401
+ if (family) {
402
+ for (const name of family) {
403
+ const provider = providers.find(item => name.startsWith(item.id + "_"));
404
+ if (provider)
405
+ plans.push({ name, provider: provider.id });
406
+ }
407
+ }
408
+ else
409
+ for (const provider of providers.slice(0, 4)) {
410
+ const candidates = (await this.client.searchTools({ query: definition.name.replaceAll("_", " "), provider: provider.id, limit: 12, summary: true })).data.tools;
411
+ plans.push(...candidates.filter(item => item.name !== tool && item.name.startsWith(provider.id + "_")).map(item => ({ name: item.name, provider: provider.id })));
412
+ }
413
+ for (const plan of plans.slice(0, 12)) {
414
+ if (results.some(item => item.tool === plan.name))
415
+ continue;
416
+ try {
417
+ const candidate = (await this.client.tool(plan.name)).data;
418
+ if (!candidate.available)
419
+ continue;
420
+ const quote = (await this.client.quote({ tool: candidate.name, arguments: candidate.example, provider: plan.provider })).data;
421
+ if (quote.maximumChargeMicros > maximumChargeMicros || quote.funding === "provider" || quote.funding === "mixed" || quote.externalBillingProviders?.length)
422
+ continue;
423
+ const core = { tool: candidate.name, provider: plan.provider, description: candidate.description.slice(0, 400), example: candidate.example,
424
+ quotedExampleCeilingMicros: quote.maximumChargeMicros };
425
+ const inline = candidate.inputSchema !== undefined && Buffer.byteLength(JSON.stringify([...results, { ...core, inputSchema: candidate.inputSchema }])) <= 7500;
426
+ results.push({ ...core, quoteBasis: "example", schemaComplete: inline, ...(inline ? { inputSchema: candidate.inputSchema } : { inspectWith: "tool" }) });
427
+ if (results.length === 3)
428
+ return results;
429
+ }
430
+ catch { }
431
+ }
432
+ }
433
+ catch { }
434
+ return results;
435
+ }
436
+ async folder() {
437
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
438
+ const root = await lstat(this.directory);
439
+ if (!root.isDirectory() || root.isSymbolicLink() || root.mode & 0o077 || process.getuid && root.uid !== process.getuid())
440
+ throw new Error("The private run directory must belong to this user and allow owner access only.");
441
+ const path = join(this.directory, await this.client.requestJournalScope());
442
+ await mkdir(path, { recursive: true, mode: 0o700 });
443
+ const scoped = await lstat(path);
444
+ if (!scoped.isDirectory() || scoped.isSymbolicLink() || scoped.mode & 0o077 || process.getuid && scoped.uid !== process.getuid())
445
+ throw new Error("Invalid private run directory.");
446
+ return path;
447
+ }
448
+ async read(runId) {
449
+ const path = join(await this.folder(), digestSchema.parse(runId) + ".json");
450
+ try {
451
+ const info = await lstat(path);
452
+ if (!info.isFile() || info.isSymbolicLink() || info.mode & 0o077)
453
+ throw new Error("Invalid saved run file.");
454
+ return savedSchema.parse(JSON.parse(await readFile(path, "utf8")));
455
+ }
456
+ catch (error) {
457
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
458
+ return null;
459
+ throw error;
460
+ }
461
+ }
462
+ async persist(runId, plan, replace = false) {
463
+ const directory = await this.folder(), destination = join(directory, runId + ".json"), temporary = join(directory, randomUUID() + ".tmp");
464
+ const file = await open(temporary, "wx", 0o600);
465
+ try {
466
+ await file.writeFile(JSON.stringify(plan));
467
+ await file.sync();
468
+ }
469
+ finally {
470
+ await file.close();
471
+ }
472
+ if (replace)
473
+ await rename(temporary, destination);
474
+ else {
475
+ try {
476
+ await link(temporary, destination);
477
+ }
478
+ catch (error) {
479
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
480
+ throw error;
481
+ }
482
+ finally {
483
+ await unlink(temporary);
484
+ }
485
+ }
486
+ const stored = await this.read(runId);
487
+ if (!stored || stored.requestHash !== plan.requestHash)
488
+ throw new Error("This run key is bound to another request. Retain the original purchase.");
489
+ return stored;
490
+ }
491
+ async run(raw) {
492
+ const input = agentRunSchema.parse(raw), { waitSeconds, selection, pick, file, idempotencyKey, maximumChargeMicros: requestedCeiling, externalBillingAccepted, intent: _intent, ...sent } = input;
493
+ const maximumChargeMicros = requestedCeiling ?? undefined;
494
+ if (file && pick)
495
+ throw new Error("file writes one selected path; use selection, not pick.");
496
+ const requestHash = hash(canonical(platformJsonObjectSchema.parse({ tool: sent.tool, arguments: sent.arguments,
497
+ ...(maximumChargeMicros === undefined ? {} : { maximumChargeMicros }),
498
+ ...(sent.provider === undefined ? {} : { provider: sent.provider }), ...(sent.relevanceCheck === undefined ? {} : { relevanceCheck: sent.relevanceCheck }),
499
+ ...(sent.sessionId === undefined ? {} : { sessionId: sent.sessionId }) }))), runId = hash(idempotencyKey);
500
+ let saved = await this.read(runId);
501
+ if (saved && saved.requestHash !== requestHash)
502
+ throw new Error("This run key is bound to another request. Recover the original run instead.");
503
+ const purchase = async () => {
504
+ const quoted = (body) => {
505
+ const quoteInput = { ...body, ...(input.intent ? { intent: input.intent } : {}) };
506
+ return this.client.quote({ ...quoteInput, ...(maximumChargeMicros === undefined ? {} : { maximumChargeMicros }) }).catch(error => {
507
+ if (maximumChargeMicros !== undefined && isApiError(error, "platform_invalid_tool_request"))
508
+ return this.client.quote(quoteInput);
509
+ throw error;
510
+ });
511
+ };
512
+ let request = sent;
513
+ const quotedRequest = await quoted(request).catch(error => {
514
+ const moved = withRunFieldsMoved(error, request);
515
+ if (!moved)
516
+ throw error;
517
+ request = moved;
518
+ return quoted(moved);
519
+ }).catch(async (error) => {
520
+ const recovered = await this.recover(idempotencyKey, input, requestHash).catch(lookup => {
521
+ if (isApiError(lookup, "platform_idempotency_key_used_for_another_request"))
522
+ throw lookup;
523
+ return null;
524
+ });
525
+ if (recovered)
526
+ return recovered;
527
+ throw error;
528
+ });
529
+ if ("admitted" in quotedRequest)
530
+ return quotedRequest;
531
+ const quote = quotedRequest.data;
532
+ const external = quote.funding === "provider" || quote.funding === "mixed" || Boolean(quote.externalBillingProviders?.length);
533
+ const overBudget = maximumChargeMicros !== undefined && quote.maximumChargeMicros > maximumChargeMicros;
534
+ if (overBudget || external && !externalBillingAccepted) {
535
+ const recovered = await this.recover(idempotencyKey, input, requestHash);
536
+ if (recovered)
537
+ return recovered;
538
+ const alternatives = !request.provider && overBudget && maximumChargeMicros !== undefined ? await this.alternatives(request.tool, maximumChargeMicros) : [];
539
+ return { runId: await this.reference(idempotencyKey), admitted: false, quote,
540
+ reason: overBudget ? "The quote exceeds the authorized Relvo ceiling. No execution was submitted." : "External account usage has not been accepted. No execution was submitted.",
541
+ ...(external && !externalBillingAccepted ? { next: "If the user already authorized this connected-account or BYOK usage and its external costs or quotas fit that authorization, repeat with externalBillingAccepted:true. Otherwise obtain the missing authorization. maximumChargeMicros caps only Relvo charges. You may also use platform_quote and platform_execute for an explicitly accepted quote; retain the same request and purchase key." } : {}),
542
+ ...(alternatives.length ? { alternatives, next: "These are candidate tools, not guaranteed equivalents. Reuse included complete schemas; inspect only missing constraints. Choose only a matching capability and quote/run actual inputs with the listed provider pinned. Example prices never authorize a purchase or a larger task budget." } : {}) };
543
+ }
544
+ return savedSchema.parse({ version: 1, requestHash, idempotencyKey, quote,
545
+ purchase: { ...request, ...quote.plan, ...(input.intent ? { intent: input.intent } : {}), maximumChargeMicros: maximumChargeMicros ?? quote.maximumChargeMicros, priceVersion: quote.priceVersion, runRequestHash: requestHash } });
546
+ };
547
+ if (!saved) {
548
+ const planned = await purchase();
549
+ if ("admitted" in planned)
550
+ return planned;
551
+ saved = await this.persist(runId, planned);
552
+ }
553
+ try {
554
+ return await this.submit(saved, input);
555
+ }
556
+ catch (error) {
557
+ if (!isApiError(error, "platform_quote_changed"))
558
+ throw error;
559
+ const planned = await purchase();
560
+ if ("admitted" in planned)
561
+ return planned;
562
+ return this.submit(await this.persist(runId, planned, true), input);
563
+ }
564
+ }
565
+ async resume(raw) {
566
+ const input = agentResumeSchema.parse(raw);
567
+ if (input.file && input.pick)
568
+ throw new Error("file writes one selected path; use selection, not pick.");
569
+ const reference = recoveryReferencePattern.exec(input.runId), idempotencyKey = reference?.[2];
570
+ if (reference && reference[1] !== await this.client.requestJournalScope())
571
+ throw new Error("Saved purchase unavailable in this credential scope. Inspect project history; do not replace unknown work.");
572
+ if (!reference && !digestSchema.safeParse(input.runId).success)
573
+ return this.result({ id: input.runId, waitSeconds: input.waitSeconds, ...input.selection, ...(input.pick ? { pick: input.pick } : {}), ...(input.file ? { file: input.file } : {}) });
574
+ const saved = await this.read(idempotencyKey ? hash(idempotencyKey) : input.runId);
575
+ if (!saved && idempotencyKey) {
576
+ const recovered = await this.recover(idempotencyKey, input);
577
+ if (recovered)
578
+ return recovered;
579
+ }
580
+ if (!saved)
581
+ throw new Error("Saved purchase unavailable in this credential scope. Inspect project history; do not replace unknown work.");
582
+ try {
583
+ return await this.submit(saved, input);
584
+ }
585
+ catch (error) {
586
+ if (!isApiError(error, "platform_quote_changed"))
587
+ throw error;
588
+ throw new Error("The price changed before this purchase was admitted, so nothing was bought. Call run again with the same request and idempotency key: it quotes the new price and retains any supplied ceiling.");
589
+ }
590
+ }
591
+ async reference(idempotencyKey) { return `run:${await this.client.requestJournalScope()}:${idempotencyKey}`; }
592
+ async recover(idempotencyKey, view, requestHash) {
593
+ const execution = (await this.client.recoverExecution({ idempotencyKey, requestHash })).data.execution;
594
+ return execution ? this.finish(await this.reference(idempotencyKey), execution, null, view) : null;
595
+ }
596
+ async submit(saved, view) {
597
+ let execution, toppedUp = null;
598
+ try {
599
+ execution = (await this.client.createExecution(saved.purchase, saved.idempotencyKey)).data;
600
+ }
601
+ catch (error) {
602
+ if (isApiError(error, "platform_idempotency_key_used_for_another_request")) {
603
+ const recovered = await this.recover(saved.idempotencyKey, view, saved.requestHash);
604
+ if (recovered)
605
+ return recovered;
606
+ throw error;
607
+ }
608
+ const shortfall = error instanceof Error && error.name === "PlatformApiError" && "shortfall" in error ? platformFundingShortfallSchema.safeParse(error.shortfall).data : undefined;
609
+ toppedUp = shortfall && this.topUp ? await this.topUp(shortfall) : null;
610
+ if (!toppedUp)
611
+ throw error;
612
+ execution = (await this.client.createExecution(saved.purchase, saved.idempotencyKey)).data;
613
+ }
614
+ return this.finish(await this.reference(saved.idempotencyKey), execution, saved.quote, view, toppedUp);
615
+ }
616
+ async finish(runId, execution, quote, { waitSeconds, selection, pick, file }, toppedUp = null) {
617
+ let timedOut = false;
618
+ if (PENDING.includes(execution.status)) {
619
+ const waited = await this.client.waitExecution(execution.id, waitSeconds * 1000);
620
+ timedOut = waited.timedOut;
621
+ execution = waited.data ?? execution;
622
+ }
623
+ const view = { ...this.view(runId, execution, quote, timedOut, selection, pick), ...(toppedUp ? { toppedUp } : {}) };
624
+ return file && execution.status === "succeeded" ? { ...view, file: await this.exportFile(execution.id, execution.output, selection?.path ?? [], selection?.fields) } : view;
625
+ }
626
+ view(runId, execution, quote, timedOut, selection, pick) {
627
+ return { runId, admitted: true, ...(quote?.plan ? { quote: { plan: quote.plan } } : {}), id: execution.id, status: execution.status, timedOut,
628
+ reservedMicros: execution.reservedMicros, chargedMicros: ["succeeded", "failed", "cancelled"].includes(execution.status) ? execution.chargedMicros : null,
629
+ errorCode: execution.errorCode, ...(execution.status === "succeeded" ? { result: agentResultView(execution.output, selection, pick) } : {}),
630
+ ...(PENDING.includes(execution.status) ? { next: "Still running and paid for: operation:result {id} waits for it again. Answer from it only after it succeeds or fails." } : {}),
631
+ retained: { readWith: "result", input: { id: execution.id } } };
632
+ }
633
+ async exportFile(id, output, requested, requestedFields) {
634
+ const slice = pathSlice(output, requested), selected = slice?.path ?? requested, fields = slice?.field ? [slice.field] : requestedFields;
635
+ const located = at(output, selected);
636
+ if (!located.found)
637
+ return { found: false, path: selected, shape: shape(output) };
638
+ const found = located.value ?? null;
639
+ let value = slice && Array.isArray(found) ? found.slice(slice.start, slice.end) : found, path = selected;
640
+ if (found !== null && typeof found === "object" && !Array.isArray(found) && !fields?.some(key => Object.hasOwn(found, key))) {
641
+ const arrays = Object.entries(found).filter(([, child]) => Array.isArray(child));
642
+ if (arrays.length === 1) {
643
+ path = [...requested, arrays[0][0]];
644
+ value = arrays[0][1];
645
+ }
646
+ }
647
+ const rows = Array.isArray(value) ? value.map(row => rowFields(row, fields)) : null;
648
+ const text = rows ? rows.map(row => JSON.stringify(row) + "\n").join("") : JSON.stringify(rowFields(value, fields)) + "\n";
649
+ const directory = await this.resultFolder();
650
+ const rowsOf = slice && Array.isArray(found) && (slice.start > 0 || slice.end !== undefined && slice.end < found.length) ? { rows: [slice.start, slice.end ?? null] } : {};
651
+ const destination = join(directory, `${hash(id)}-${hash(JSON.stringify({ path, fields: fields ?? [], ...rowsOf })).slice(0, 16)}.${rows ? "jsonl" : "json"}`);
652
+ const temporary = join(directory, randomUUID() + ".tmp"), file = await open(temporary, "wx", 0o600);
653
+ try {
654
+ await file.writeFile(text);
655
+ await file.sync();
656
+ }
657
+ finally {
658
+ await file.close();
659
+ }
660
+ await rename(temporary, destination);
661
+ return { path: destination, format: rows ? "jsonl" : "json", ...(rows ? { rows: rows.length } : {}), bytes: Buffer.byteLength(text), selectedPath: path,
662
+ use: rows ? "One JSON object per line: filter with jq -c 'select(...)', search with grep, or page with Read." : "Filter with jq or read it with Read." };
663
+ }
664
+ async result(raw) {
665
+ const { id, pick, file, waitSeconds, ...selection } = agentResultSchema.parse(raw);
666
+ if (file && pick)
667
+ throw new Error("file writes one selected path; use path and fields, not pick.");
668
+ let execution = (await this.client.execution(id)).data, timedOut = false;
669
+ if (PENDING.includes(execution.status)) {
670
+ const waited = await this.client.waitExecution(id, waitSeconds * 1000);
671
+ timedOut = waited.timedOut;
672
+ execution = waited.data ?? execution;
673
+ }
674
+ if (execution.status === "succeeded" && file)
675
+ return { id: execution.id, status: execution.status, file: await this.exportFile(execution.id, execution.output, selection.path, selection.fields) };
676
+ if (execution.status === "succeeded")
677
+ return { id: execution.id, status: execution.status, result: agentResultView(execution.output, selection, pick) };
678
+ const { runId: _runId, admitted: _admitted, timedOut: _timedOut, ...view } = this.view("", execution, null, false);
679
+ return timedOut ? { ...view, timedOut } : view;
680
+ }
681
+ }