agentic-relay 4.3.1 → 5.0.1
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 +16 -14
- package/bin/cli.mjs +15 -13
- package/bin/install.mjs +90 -58
- package/bin/lib/api.mjs +2 -75
- package/bin/lib/cache.mjs +7 -74
- package/bin/lib/commands.mjs +10 -183
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/sysenv.mjs +29 -44
- package/bin/relay-core.mjs +15 -106
- package/bin/test/session.test.mjs +3 -28
- package/bin/test/sysenv.test.mjs +30 -0
- package/package.json +2 -3
- package/skill/SKILL.md +225 -165
- package/bin/lib/doctor.mjs +0 -222
- package/bin/test/budget.test.mjs +0 -31
- package/bin/test/cache.test.mjs +0 -111
- package/bin/test/doctor.test.mjs +0 -76
- package/schema/deliverable.json +0 -45
package/bin/lib/commands.mjs
CHANGED
|
@@ -2,22 +2,17 @@
|
|
|
2
2
|
* Command implementations for the agentrelay CLI. Each returns an envelope payload
|
|
3
3
|
* (the entry adds `ok`/`errors` and chooses the exit code).
|
|
4
4
|
*
|
|
5
|
-
* `session start|send|
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (`cache put`/`ls`/`show`) so transcripts stay out of the main context.
|
|
5
|
+
* `session start|send|end` are thin api_v1 wrappers; the caller (a host subagent,
|
|
6
|
+
* or the main agent in direct mode) drives the conversation and reads the agent's
|
|
7
|
+
* RAW turns. Search results are cached on disk so a repeat search is near-free.
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
|
-
import { readFileSync } from "fs";
|
|
12
10
|
import {
|
|
13
11
|
search as apiSearch,
|
|
14
12
|
sessionStart,
|
|
15
13
|
sessionMessage,
|
|
16
14
|
sessionEnd,
|
|
17
|
-
sessionPay,
|
|
18
15
|
feedback as apiFeedback,
|
|
19
|
-
getPaymentPolicy,
|
|
20
|
-
patchPaymentPolicy,
|
|
21
16
|
} from "./api.mjs";
|
|
22
17
|
import { slugify } from "./config.mjs";
|
|
23
18
|
import { Cache } from "./cache.mjs";
|
|
@@ -30,7 +25,6 @@ function normalizeCapability(c) {
|
|
|
30
25
|
return {
|
|
31
26
|
slug: slugify(name),
|
|
32
27
|
name,
|
|
33
|
-
type: "dynamic_capability", // single type now; kept for the --dynamic-only filter
|
|
34
28
|
agent_id: c.agent_id || null,
|
|
35
29
|
relevance: typeof c.relevance_score === "number" ? c.relevance_score : null,
|
|
36
30
|
reputation: typeof c.reputation_score === "number" ? c.reputation_score : null,
|
|
@@ -60,13 +54,9 @@ export async function cmdSearch(query, opts) {
|
|
|
60
54
|
const cache = new Cache(opts.cacheDir, { enabled: !opts.noCache });
|
|
61
55
|
const maxResults = opts.max || 25;
|
|
62
56
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// it through here so it survives to the CLI's JSON output and gets cached.
|
|
66
|
-
// On a cache hit the snapshot can be up to the search TTL (1h) stale, so the
|
|
67
|
-
// payload carries from_cache/cached_at for the caller's pay decision.
|
|
57
|
+
// A repeat search inside the cache TTL (1h) is served from disk; the payload
|
|
58
|
+
// carries from_cache/cached_at so the caller knows how fresh the menu is.
|
|
68
59
|
const cachedEntry = opts.refresh ? null : cache.readSearchEntry(query);
|
|
69
|
-
let paymentContext = cachedEntry ? (cachedEntry.payment_context ?? null) : null;
|
|
70
60
|
let capabilities = cachedEntry ? cachedEntry.capabilities : null;
|
|
71
61
|
const fromCache = Boolean(cachedEntry);
|
|
72
62
|
const cachedAt = cachedEntry ? (cachedEntry.ts ?? null) : null;
|
|
@@ -74,13 +64,11 @@ export async function cmdSearch(query, opts) {
|
|
|
74
64
|
if (!capabilities) {
|
|
75
65
|
const data = await apiSearch(query, {
|
|
76
66
|
apiKey: opts.apiKey,
|
|
77
|
-
userContext: opts.userContext,
|
|
78
67
|
maxResults,
|
|
79
68
|
timeoutMs: opts.timeoutMs,
|
|
80
69
|
});
|
|
81
|
-
paymentContext = data.payment_context ?? null;
|
|
82
70
|
capabilities = dedupeSlugs((data.capabilities || []).map(normalizeCapability));
|
|
83
|
-
cache.writeSearch(query, capabilities
|
|
71
|
+
cache.writeSearch(query, capabilities);
|
|
84
72
|
// Seed the index so session start can resolve agent_id without re-searching.
|
|
85
73
|
for (const c of capabilities) {
|
|
86
74
|
if (c.agent_id) {
|
|
@@ -93,9 +81,6 @@ export async function cmdSearch(query, opts) {
|
|
|
93
81
|
}
|
|
94
82
|
}
|
|
95
83
|
|
|
96
|
-
if (opts.dynamicOnly) {
|
|
97
|
-
capabilities = capabilities.filter((c) => c.type === "dynamic_capability");
|
|
98
|
-
}
|
|
99
84
|
capabilities = capabilities.slice(0, maxResults);
|
|
100
85
|
|
|
101
86
|
return {
|
|
@@ -104,15 +89,13 @@ export async function cmdSearch(query, opts) {
|
|
|
104
89
|
from_cache: fromCache,
|
|
105
90
|
cached_at: cachedAt,
|
|
106
91
|
capabilities,
|
|
107
|
-
payment_context: paymentContext,
|
|
108
92
|
};
|
|
109
93
|
}
|
|
110
94
|
|
|
111
95
|
/**
|
|
112
|
-
* Resolve a slug (or agent_id) to { agentId, slug, name }. Order: cache index
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* `cache put` during the breadth pass (which doesn't write the index).
|
|
96
|
+
* Resolve a slug (or agent_id) to { agentId, slug, name }. Order: cache index (seeded
|
|
97
|
+
* by a prior `search`) → fresh live search keyed on the de-kebabed slug. A cold
|
|
98
|
+
* `session start` with no prior search still resolves via the live search below.
|
|
116
99
|
* agent_id is the only id needed — the ad_unit + price are resolved server-side.
|
|
117
100
|
*/
|
|
118
101
|
export async function resolveCapability(slugOrId, opts) {
|
|
@@ -124,14 +107,6 @@ export async function resolveCapability(slugOrId, opts) {
|
|
|
124
107
|
return { agentId: entry.agent_id, slug, name: entry.name || slug };
|
|
125
108
|
}
|
|
126
109
|
|
|
127
|
-
// Banked spec fallback — a breadth subagent stored the agent_id here via `cache put`.
|
|
128
|
-
if (!opts.refresh) {
|
|
129
|
-
const spec = cache.readSpec(slug, { ttlMs: Infinity });
|
|
130
|
-
if (spec && spec.agent_id) {
|
|
131
|
-
return { agentId: spec.agent_id, slug, name: spec.name || slug };
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
110
|
// Fresh search keyed on the human form of the slug.
|
|
136
111
|
const queryGuess = String(slugOrId).replace(/-/g, " ");
|
|
137
112
|
const found = await cmdSearch(queryGuess, { ...opts, refresh: true, max: 25 });
|
|
@@ -260,18 +235,7 @@ export async function cmdSession(sub, positionals, opts) {
|
|
|
260
235
|
return sessionEnd({ sessionId }, net);
|
|
261
236
|
}
|
|
262
237
|
|
|
263
|
-
|
|
264
|
-
const sessionId = positionals[0];
|
|
265
|
-
if (!sessionId) {
|
|
266
|
-
const e = new Error("usage: agentrelay session pay <session_id> [--credential <mpp_credential>]");
|
|
267
|
-
e.usage = true;
|
|
268
|
-
throw e;
|
|
269
|
-
}
|
|
270
|
-
// Server-side grant when no --credential; client-side MPP wallet when provided.
|
|
271
|
-
return sessionPay({ sessionId, paymentCredential: opts.credential || null }, net);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const e = new Error(`unknown session subcommand "${sub}" (start|send|end|pay)`);
|
|
238
|
+
const e = new Error(`unknown session subcommand "${sub}" (start|send|end)`);
|
|
275
239
|
e.usage = true;
|
|
276
240
|
throw e;
|
|
277
241
|
}
|
|
@@ -320,140 +284,3 @@ export async function cmdFeedback(sessionId, opts) {
|
|
|
320
284
|
{ apiKey: opts.apiKey, timeoutMs: opts.timeoutMs },
|
|
321
285
|
);
|
|
322
286
|
}
|
|
323
|
-
|
|
324
|
-
/** Convert a dollar string/number to integer cents; throws a usage error on garbage. */
|
|
325
|
-
export function dollarsToCents(label, value) {
|
|
326
|
-
const n = Number(value);
|
|
327
|
-
if (!Number.isFinite(n) || n < 0) {
|
|
328
|
-
const e = new Error(`${label} must be a non-negative dollar amount (e.g. 5 or 2.50)`);
|
|
329
|
-
e.usage = true;
|
|
330
|
-
throw e;
|
|
331
|
-
}
|
|
332
|
-
return Math.round(n * 100);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Read or update the account's server-canonical autonomous-spend policy.
|
|
337
|
-
* No flags → GET (print current policy). Any flag → PATCH then return the new policy.
|
|
338
|
-
* Dollar flags are converted to cents; the server is canonical (nothing stored locally).
|
|
339
|
-
*/
|
|
340
|
-
export async function cmdBudget(opts) {
|
|
341
|
-
const patch = {};
|
|
342
|
-
if (opts.autoApprove != null) patch.auto_approve_under_cents = dollarsToCents("--auto-approve", opts.autoApprove);
|
|
343
|
-
if (opts.dailyCap != null) patch.daily_cap_cents = dollarsToCents("--daily-cap", opts.dailyCap);
|
|
344
|
-
if (opts.requireHumanOver != null) patch.require_human_over_cents = dollarsToCents("--require-human-over", opts.requireHumanOver);
|
|
345
|
-
if (opts.perSessionCap != null) patch.per_session_cap_cents = dollarsToCents("--per-session-cap", opts.perSessionCap);
|
|
346
|
-
if (opts.enable && opts.disable) {
|
|
347
|
-
const e = new Error("pass only one of --enable / --disable");
|
|
348
|
-
e.usage = true;
|
|
349
|
-
throw e;
|
|
350
|
-
}
|
|
351
|
-
if (opts.enable) patch.autonomous_enabled = true;
|
|
352
|
-
if (opts.disable) patch.autonomous_enabled = false;
|
|
353
|
-
|
|
354
|
-
const policy = Object.keys(patch).length > 0
|
|
355
|
-
? await patchPaymentPolicy(patch, { apiKey: opts.apiKey, timeoutMs: opts.timeoutMs })
|
|
356
|
-
: await getPaymentPolicy({ apiKey: opts.apiKey, timeoutMs: opts.timeoutMs });
|
|
357
|
-
return { policy };
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Stub: real wallet connection is the (deferred) dashboard OAuth flow. The CLI can't
|
|
362
|
-
* provision a wallet, so this just points the user at the dashboard.
|
|
363
|
-
*/
|
|
364
|
-
export function cmdConnectWallet() {
|
|
365
|
-
return {
|
|
366
|
-
connected: false,
|
|
367
|
-
message:
|
|
368
|
-
"Wallet connection is set up from the AttentionMarket dashboard (OAuth flow). " +
|
|
369
|
-
"The CLI can't provision a wallet yet. Once a wallet is connected, run " +
|
|
370
|
-
"`agentrelay budget --enable --auto-approve=<$>` so the agent can auto-pay within your limits.",
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
export function cmdCache(sub, slug, opts) {
|
|
375
|
-
const cache = new Cache(opts.cacheDir, { enabled: true });
|
|
376
|
-
if (sub === "ls" || !sub) {
|
|
377
|
-
// Triage view read from the spec files themselves (race-safe under the
|
|
378
|
-
// parallel breadth pass; includes each summary so the main agent can pick
|
|
379
|
-
// what to `cache show` without opening every file).
|
|
380
|
-
return {
|
|
381
|
-
cache_dir: opts.cacheDir,
|
|
382
|
-
specs: cache.listSpecHeadlines(),
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
if (sub === "show") {
|
|
386
|
-
if (!slug) {
|
|
387
|
-
const e = new Error("usage: agentrelay cache show <slug>");
|
|
388
|
-
e.usage = true;
|
|
389
|
-
throw e;
|
|
390
|
-
}
|
|
391
|
-
const spec = cache.readSpec(slug, { ttlMs: Infinity });
|
|
392
|
-
if (!spec) throw new Error(`no cached spec for "${slug}"`);
|
|
393
|
-
return { deliverable: spec };
|
|
394
|
-
}
|
|
395
|
-
if (sub === "put") {
|
|
396
|
-
// Persist a distilled result a subagent/main agent produced, so it can be
|
|
397
|
-
// reviewed (`cache show`) or reused later. JSON from --file or stdin.
|
|
398
|
-
if (!slug) {
|
|
399
|
-
const e = new Error("usage: agentrelay cache put <slug> (--file f.json | JSON on stdin)");
|
|
400
|
-
e.usage = true;
|
|
401
|
-
throw e;
|
|
402
|
-
}
|
|
403
|
-
let raw;
|
|
404
|
-
try {
|
|
405
|
-
raw = opts.file ? readFileSync(opts.file, "utf-8") : readFileSync(0, "utf-8");
|
|
406
|
-
} catch (err) {
|
|
407
|
-
throw new Error(`could not read deliverable input: ${err.message}`);
|
|
408
|
-
}
|
|
409
|
-
let deliverable;
|
|
410
|
-
try {
|
|
411
|
-
deliverable = JSON.parse(raw);
|
|
412
|
-
} catch (err) {
|
|
413
|
-
const e = new Error(`deliverable must be valid JSON: ${err.message}`);
|
|
414
|
-
e.usage = true;
|
|
415
|
-
throw e;
|
|
416
|
-
}
|
|
417
|
-
if (!deliverable || typeof deliverable !== "object") {
|
|
418
|
-
const e = new Error("deliverable must be a JSON object");
|
|
419
|
-
e.usage = true;
|
|
420
|
-
throw e;
|
|
421
|
-
}
|
|
422
|
-
deliverable.slug = deliverable.slug || slug;
|
|
423
|
-
deliverable.ts = deliverable.ts || new Date().toISOString();
|
|
424
|
-
// Soft contract check (schema/deliverable.json spine). Warn-only: a spec
|
|
425
|
-
// with gaps is still worth banking, and concurrent breadth subagents must
|
|
426
|
-
// never be blocked by validation.
|
|
427
|
-
const warnings = [];
|
|
428
|
-
if (!deliverable.session_id || !String(deliverable.session_id).trim()) {
|
|
429
|
-
warnings.push("no session_id — build-outcome feedback can't be filed against this consult later");
|
|
430
|
-
}
|
|
431
|
-
if (!deliverable.summary || !String(deliverable.summary).trim()) {
|
|
432
|
-
warnings.push("no summary — `cache ls` triage will show a blank line for this spec");
|
|
433
|
-
}
|
|
434
|
-
if (!Array.isArray(deliverable.features) || deliverable.features.length === 0) {
|
|
435
|
-
warnings.push("no features[] — build-time has no surface map; depth sessions will have to rediscover it");
|
|
436
|
-
} else {
|
|
437
|
-
const missingStatus = deliverable.features.filter(
|
|
438
|
-
(f) => !f || (f.status !== "coded" && f.status !== "surface_only"),
|
|
439
|
-
).length;
|
|
440
|
-
if (missingStatus > 0) {
|
|
441
|
-
warnings.push(
|
|
442
|
-
`${missingStatus} feature(s) missing status ("coded"|"surface_only") — build-time can't tell banked code from surface-only`,
|
|
443
|
-
);
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
// Write ONLY the per-slug spec file — no shared index.json mutation — so N
|
|
447
|
-
// breadth subagents can `cache put` concurrently without clobbering each
|
|
448
|
-
// other. `cache ls` reads the spec files directly.
|
|
449
|
-
cache.writeSpec(slug, deliverable);
|
|
450
|
-
return { stored: true, slug, cache_dir: opts.cacheDir, warnings };
|
|
451
|
-
}
|
|
452
|
-
if (sub === "clear") {
|
|
453
|
-
cache.clear();
|
|
454
|
-
return { cleared: true, cache_dir: opts.cacheDir };
|
|
455
|
-
}
|
|
456
|
-
const e = new Error(`unknown cache subcommand "${sub}" (ls|show|put|clear)`);
|
|
457
|
-
e.usage = true;
|
|
458
|
-
throw e;
|
|
459
|
-
}
|
package/bin/lib/config.mjs
CHANGED
|
@@ -58,7 +58,7 @@ export function resolveApiKey(flagKey) {
|
|
|
58
58
|
return readKeyFile(CREDENTIALS_PATH) || readKeyFile(LEGACY_CREDENTIALS_PATH);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/** kebab-case a capability name into a stable slug
|
|
61
|
+
/** kebab-case a capability name into a stable slug (search cache key + session resolution). */
|
|
62
62
|
export function slugify(name) {
|
|
63
63
|
return String(name || "")
|
|
64
64
|
.toLowerCase()
|
package/bin/lib/sysenv.mjs
CHANGED
|
@@ -1,60 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* System-environment probes
|
|
2
|
+
* System-environment probes used by the installer's global-CLI linking.
|
|
3
3
|
*
|
|
4
4
|
* These exist because the failure modes they detect are exactly the ones that
|
|
5
5
|
* made "successful" installs produce `command not found` in the field:
|
|
6
|
-
* - npx prepends its ephemeral cache .bin to PATH, shadowing (or faking) a
|
|
7
|
-
* real global install for the lifetime of the npx process
|
|
8
6
|
* - npm's global prefix may not be writable (system Node → EACCES)
|
|
9
7
|
* - ~/.npm may contain root-owned files (old sudo npm) which kills BOTH
|
|
10
8
|
* `npm install -g` and npx itself
|
|
11
9
|
* - npm's global bin dir may not be on the user's PATH
|
|
12
10
|
*/
|
|
13
11
|
|
|
14
|
-
import { existsSync,
|
|
12
|
+
import { existsSync, accessSync, statSync, constants, readdirSync, rmSync } from "fs";
|
|
15
13
|
import { homedir } from "os";
|
|
16
|
-
import { join,
|
|
14
|
+
import { join, dirname } from "path";
|
|
17
15
|
import { execSync } from "child_process";
|
|
18
16
|
|
|
19
|
-
/** Where a command actually resolves on this PATH, or null. */
|
|
20
|
-
export function resolveCommandPath(cmd) {
|
|
21
|
-
try {
|
|
22
|
-
const probe = process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`;
|
|
23
|
-
const out = execSync(probe, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
24
|
-
return out.split("\n")[0].trim() || null;
|
|
25
|
-
} catch {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function realpathSafe(p) {
|
|
31
|
-
try {
|
|
32
|
-
return realpathSync(p);
|
|
33
|
-
} catch {
|
|
34
|
-
return p;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* True when a resolved bin path is an ephemeral npx/npm-exec shim (or a copy
|
|
40
|
-
* inside the running package itself), not a real install. A dangling symlink
|
|
41
|
-
* also counts as not-a-usable-install.
|
|
42
|
-
*/
|
|
43
|
-
export function isEphemeralBin(binPath, pkgRoot) {
|
|
44
|
-
if (!binPath) return false;
|
|
45
|
-
let real = binPath;
|
|
46
|
-
try {
|
|
47
|
-
real = realpathSync(binPath);
|
|
48
|
-
} catch {
|
|
49
|
-
return true;
|
|
50
|
-
}
|
|
51
|
-
return (
|
|
52
|
-
real.includes(`${sep}_npx${sep}`) ||
|
|
53
|
-
binPath.includes(`${sep}_npx${sep}`) ||
|
|
54
|
-
(pkgRoot ? real.startsWith(realpathSafe(pkgRoot)) : false)
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
17
|
/** npm's global prefix (e.g. /opt/homebrew, %AppData%\npm), or null. */
|
|
59
18
|
export function npmGlobalPrefix() {
|
|
60
19
|
try {
|
|
@@ -140,6 +99,32 @@ export function dirOnPath(dir, pathVar = process.env.PATH || "") {
|
|
|
140
99
|
return pathVar.split(delim).map(norm).includes(norm(dir));
|
|
141
100
|
}
|
|
142
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Remove ONLY this package's entries from npm's npx cache, so the next
|
|
104
|
+
* `npx <pkg>` re-resolves from the registry instead of replaying a stale cached
|
|
105
|
+
* copy (npx reuses its cache for a bare name and won't auto-upgrade). Scoped to
|
|
106
|
+
* `<npm cache>/_npx/<hash>` dirs whose node_modules contains pkgName — other
|
|
107
|
+
* tools' npx caches are left alone. Best-effort; never throws.
|
|
108
|
+
*/
|
|
109
|
+
export function clearNpxCacheFor(pkgName) {
|
|
110
|
+
try {
|
|
111
|
+
const cacheDir = execSync("npm config get cache", {
|
|
112
|
+
encoding: "utf-8",
|
|
113
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
114
|
+
}).trim();
|
|
115
|
+
if (!cacheDir) return;
|
|
116
|
+
const npxDir = join(cacheDir, "_npx");
|
|
117
|
+
if (!existsSync(npxDir)) return;
|
|
118
|
+
for (const entry of readdirSync(npxDir)) {
|
|
119
|
+
if (existsSync(join(npxDir, entry, "node_modules", pkgName))) {
|
|
120
|
+
try {
|
|
121
|
+
rmSync(join(npxDir, entry), { recursive: true, force: true });
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
|
|
143
128
|
/** The official npm user-level-prefix recipe for EACCES on the global prefix. */
|
|
144
129
|
export const USER_PREFIX_RECIPE = [
|
|
145
130
|
"mkdir -p ~/.npm-global",
|
package/bin/relay-core.mjs
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* agentrelay (CLI core) — live agent sessions
|
|
4
|
+
* agentrelay (CLI core) — live agent sessions and deliverable fetch.
|
|
5
5
|
*
|
|
6
6
|
* Wraps the AttentionMarket REST API so a coding agent can converse with expert
|
|
7
7
|
* "solutions-engineer" capability agents. The caller (a host subagent, or the
|
|
8
|
-
* main agent directly) drives the conversation via the session primitives
|
|
9
|
-
*
|
|
10
|
-
* main context.
|
|
8
|
+
* main agent directly) drives the conversation via the session primitives; raw
|
|
9
|
+
* transcripts stay out of the main context.
|
|
11
10
|
*
|
|
12
11
|
* Commands:
|
|
13
12
|
* agentrelay search <query> broad discovery (cheap; safe in main context)
|
|
14
|
-
* agentrelay session start|send|
|
|
15
|
-
* agentrelay
|
|
13
|
+
* agentrelay session start|send|end drive a live consult
|
|
14
|
+
* agentrelay feedback <id> rate a session
|
|
16
15
|
* agentrelay fetch download + verify + unpack a paid deliverable
|
|
17
16
|
*
|
|
18
17
|
* Output: a `{ ok, ...payload, errors: [] }` envelope. --json forces machine
|
|
@@ -29,13 +28,9 @@ import {
|
|
|
29
28
|
cmdSearch,
|
|
30
29
|
cmdSession,
|
|
31
30
|
cmdFeedback,
|
|
32
|
-
cmdCache,
|
|
33
|
-
cmdBudget,
|
|
34
|
-
cmdConnectWallet,
|
|
35
31
|
cmdFetchSession,
|
|
36
32
|
} from "./lib/commands.mjs";
|
|
37
33
|
import { cmdFetch } from "./lib/fetch.mjs";
|
|
38
|
-
import { cmdDoctor, fmtDoctor } from "./lib/doctor.mjs";
|
|
39
34
|
import { maybeNotifyUpdate } from "./lib/update-check.mjs";
|
|
40
35
|
|
|
41
36
|
const EXIT = { OK: 0, PARTIAL: 1, USAGE: 2, AUTH: 3 };
|
|
@@ -54,7 +49,6 @@ const FLAGS_WITH_VALUE = new Set([
|
|
|
54
49
|
"--cache-dir",
|
|
55
50
|
"--timeout",
|
|
56
51
|
"--max",
|
|
57
|
-
"--user-context",
|
|
58
52
|
"--initial",
|
|
59
53
|
"--task-context",
|
|
60
54
|
"--message",
|
|
@@ -62,11 +56,6 @@ const FLAGS_WITH_VALUE = new Set([
|
|
|
62
56
|
"--score",
|
|
63
57
|
"--text",
|
|
64
58
|
"--file",
|
|
65
|
-
"--auto-approve",
|
|
66
|
-
"--daily-cap",
|
|
67
|
-
"--require-human-over",
|
|
68
|
-
"--per-session-cap",
|
|
69
|
-
"--credential",
|
|
70
59
|
"--checksum",
|
|
71
60
|
"--filename",
|
|
72
61
|
"--session",
|
|
@@ -155,54 +144,19 @@ const fmtSearch = (e) => {
|
|
|
155
144
|
lines.push(` offers paid deliverable${dp}`);
|
|
156
145
|
}
|
|
157
146
|
}
|
|
158
|
-
// Surface the spend policy in human output too — agents reading --json get
|
|
159
|
-
// the full payment_context object; humans shouldn't be blind to it.
|
|
160
|
-
const p = e.payment_context;
|
|
161
|
-
if (p) {
|
|
162
|
-
const dol = (c) => (c == null ? "—" : `$${(c / 100).toFixed(2)}`);
|
|
163
|
-
lines.push(
|
|
164
|
-
`pay policy: autonomous ${p.autonomous_enabled ? "ON" : "off"}, auto-approve under ${dol(p.auto_approve_under_cents)},` +
|
|
165
|
-
` daily cap left ${dol(p.daily_cap_remaining_cents)}, wallet: ${p.wallet?.methods?.length ? p.wallet.methods.join(",") : "none"}` +
|
|
166
|
-
`${e.from_cache ? " (cached snapshot — `agentrelay budget` for live)" : ""}`,
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
147
|
return lines.join("\n");
|
|
170
148
|
};
|
|
171
149
|
|
|
172
|
-
const fmtBudget = (e) => {
|
|
173
|
-
const p = e.policy || {};
|
|
174
|
-
const dol = (c) => (c == null ? "—" : `$${(c / 100).toFixed(2)}`);
|
|
175
|
-
return [
|
|
176
|
-
`autonomous pay: ${p.autonomous_enabled ? "ENABLED" : "disabled (ask every time)"}`,
|
|
177
|
-
` auto-approve under: ${dol(p.auto_approve_under_cents)}`,
|
|
178
|
-
` require human over: ${dol(p.require_human_over_cents)}`,
|
|
179
|
-
` daily cap: ${dol(p.daily_cap_cents)}`,
|
|
180
|
-
` per-session cap: ${dol(p.per_session_cap_cents)}`,
|
|
181
|
-
` wallet methods: ${(p.wallet_methods && p.wallet_methods.length) ? p.wallet_methods.join(", ") : "none connected"}`,
|
|
182
|
-
].join("\n");
|
|
183
|
-
};
|
|
184
|
-
|
|
185
150
|
const HELP = `agentrelay — converse with capability agents while you build.
|
|
186
151
|
|
|
187
152
|
Conversation (LLM-driven — drive these from a subagent, or directly when you're stuck):
|
|
188
|
-
agentrelay session start <slug> [--initial json] [--message "<
|
|
153
|
+
agentrelay session start <slug> [--initial json] [--task-context <s>] [--message "<m>"]
|
|
189
154
|
agentrelay session send <id> "<m>" [--data json]
|
|
190
|
-
agentrelay session pay <id> [--credential <mpp>] settle a payment_required
|
|
191
155
|
agentrelay session end <id>
|
|
192
156
|
agentrelay feedback <id> --score <0-100> --text "<s>"
|
|
193
157
|
|
|
194
|
-
Discovery
|
|
195
|
-
agentrelay search <query> [--max 25]
|
|
196
|
-
agentrelay cache ls | show <slug> | put <slug> [--file f.json] | clear
|
|
197
|
-
|
|
198
|
-
Payments (autonomous spend policy — server-canonical):
|
|
199
|
-
agentrelay budget show current policy
|
|
200
|
-
agentrelay budget --enable|--disable [--auto-approve=<$>] [--daily-cap=<$>]
|
|
201
|
-
[--require-human-over=<$>] [--per-session-cap=<$>]
|
|
202
|
-
agentrelay connect-wallet (wallet connect is set up in the dashboard)
|
|
203
|
-
|
|
204
|
-
Diagnostics:
|
|
205
|
-
agentrelay doctor check node/key/API/npm-prefix/PATH health (--json)
|
|
158
|
+
Discovery:
|
|
159
|
+
agentrelay search <query> [--max 25]
|
|
206
160
|
|
|
207
161
|
Deliverables (after a paid session returns a delivery object):
|
|
208
162
|
agentrelay fetch --session <id> [--message "<m>"] [dest] ONE-STEP after payment:
|
|
@@ -226,14 +180,6 @@ async function main() {
|
|
|
226
180
|
process.stdout.write(HELP + "\n");
|
|
227
181
|
process.exit(cmd ? EXIT.OK : EXIT.USAGE);
|
|
228
182
|
}
|
|
229
|
-
if (cmd === "consult" || cmd === "consult-batch") {
|
|
230
|
-
return fail(
|
|
231
|
-
`"${cmd}" was removed in v4 — drive a live session instead: ` +
|
|
232
|
-
"`agentrelay session start <slug>` → `session send` → `session end` (see SKILL.md)",
|
|
233
|
-
EXIT.USAGE,
|
|
234
|
-
Boolean(flags.json) || !process.stdout.isTTY,
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
183
|
if (cmd === "version" || flags.version) {
|
|
238
184
|
process.stdout.write(`agentrelay ${packageVersion()}\n`);
|
|
239
185
|
process.exit(EXIT.OK);
|
|
@@ -254,9 +200,12 @@ async function main() {
|
|
|
254
200
|
}
|
|
255
201
|
const json = shared.json;
|
|
256
202
|
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
203
|
+
// Raw-URL fetch downloads a signed URL directly and needs no key. Everything
|
|
204
|
+
// else (including `fetch --session`, which calls the session API) does. An
|
|
205
|
+
// UNKNOWN command needs no key either — it should get the clean "unknown
|
|
206
|
+
// command" error below, not a misleading "no API key" one.
|
|
207
|
+
const KNOWN_COMMANDS = new Set(["search", "session", "feedback", "fetch"]);
|
|
208
|
+
const keylessCmd = (cmd === "fetch" && !flags.session) || !KNOWN_COMMANDS.has(cmd);
|
|
260
209
|
if (!keylessCmd && !shared.apiKey) {
|
|
261
210
|
return fail(
|
|
262
211
|
"no API key — set --api-key, $AGENT_RELAY_API_KEY, or ~/.config/agent-relay/credentials.json",
|
|
@@ -273,8 +222,6 @@ async function main() {
|
|
|
273
222
|
const payload = await cmdSearch(query, {
|
|
274
223
|
...shared,
|
|
275
224
|
max: Number(flags.max) || 25,
|
|
276
|
-
userContext: flags["user-context"] || null,
|
|
277
|
-
dynamicOnly: Boolean(flags["dynamic-only"]),
|
|
278
225
|
});
|
|
279
226
|
return emit(payload, { json, human: fmtSearch });
|
|
280
227
|
}
|
|
@@ -286,7 +233,6 @@ async function main() {
|
|
|
286
233
|
taskContext: flags["task-context"] || null,
|
|
287
234
|
message: flags.message || null,
|
|
288
235
|
data: flags.data || null,
|
|
289
|
-
credential: flags.credential || null,
|
|
290
236
|
});
|
|
291
237
|
return emit(payload, { json });
|
|
292
238
|
}
|
|
@@ -300,24 +246,6 @@ async function main() {
|
|
|
300
246
|
return emit(payload, { json });
|
|
301
247
|
}
|
|
302
248
|
|
|
303
|
-
case "budget": {
|
|
304
|
-
const payload = await cmdBudget({
|
|
305
|
-
...shared,
|
|
306
|
-
autoApprove: flags["auto-approve"] != null ? flags["auto-approve"] : null,
|
|
307
|
-
dailyCap: flags["daily-cap"] != null ? flags["daily-cap"] : null,
|
|
308
|
-
requireHumanOver: flags["require-human-over"] != null ? flags["require-human-over"] : null,
|
|
309
|
-
perSessionCap: flags["per-session-cap"] != null ? flags["per-session-cap"] : null,
|
|
310
|
-
enable: Boolean(flags.enable),
|
|
311
|
-
disable: Boolean(flags.disable),
|
|
312
|
-
});
|
|
313
|
-
return emit(payload, { json, human: fmtBudget });
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
case "connect-wallet": {
|
|
317
|
-
const payload = cmdConnectWallet();
|
|
318
|
-
return emit(payload, { json, human: (e) => e.message });
|
|
319
|
-
}
|
|
320
|
-
|
|
321
249
|
case "fetch": {
|
|
322
250
|
// --session: one-step deliverable retrieval for a PAID session — drive a turn
|
|
323
251
|
// to mint a fresh signed download_url, then download+unpack. Needs the key.
|
|
@@ -353,28 +281,9 @@ async function main() {
|
|
|
353
281
|
});
|
|
354
282
|
}
|
|
355
283
|
|
|
356
|
-
case "doctor": {
|
|
357
|
-
const payload = await cmdDoctor({ ...shared, apiKey: flags["api-key"] || null });
|
|
358
|
-
const exitCode = payload.exit_code;
|
|
359
|
-
return emit(payload, { json, ok: payload.doctor_ok, exitCode, human: fmtDoctor });
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
case "cache": {
|
|
363
|
-
const payload = cmdCache(positionals[0], positionals[1], { ...shared, file: flags.file || null });
|
|
364
|
-
const human = Array.isArray(payload.specs)
|
|
365
|
-
? (e) =>
|
|
366
|
-
[`${e.specs.length} banked specs (${e.cache_dir})`, ...e.specs.map((s) => {
|
|
367
|
-
const feat = s.features != null ? ` (${s.features} features)` : "";
|
|
368
|
-
const sum = s.summary ? ` — ${s.summary.length > 100 ? s.summary.slice(0, 100) + "…" : s.summary}` : "";
|
|
369
|
-
return ` ${s.slug}${feat}${sum}`;
|
|
370
|
-
})].join("\n")
|
|
371
|
-
: undefined;
|
|
372
|
-
return emit(payload, { json, human });
|
|
373
|
-
}
|
|
374
|
-
|
|
375
284
|
default:
|
|
376
285
|
return fail(
|
|
377
|
-
`unknown command "${cmd}" (session|
|
|
286
|
+
`unknown command "${cmd}" (search|session|feedback|fetch)`,
|
|
378
287
|
EXIT.USAGE,
|
|
379
288
|
json,
|
|
380
289
|
);
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for the session-primitive output shaping
|
|
3
|
-
*
|
|
4
|
-
* `node --test bin/test/session.test.mjs`.
|
|
2
|
+
* Tests for the session-primitive output shaping (the LLM-driven path's
|
|
3
|
+
* transport). No network. Run: `node --test bin/test/session.test.mjs`.
|
|
5
4
|
*/
|
|
6
5
|
import { test } from "node:test";
|
|
7
6
|
import assert from "node:assert/strict";
|
|
8
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
7
|
import { shapeSessionTurn, deliveryToFetchArgs } from "../lib/commands.mjs";
|
|
12
|
-
import { Cache } from "../lib/cache.mjs";
|
|
13
8
|
import { slugify } from "../lib/config.mjs";
|
|
14
9
|
|
|
15
10
|
test("shapeSessionTurn: passes the agent message through verbatim + surfaces flags", () => {
|
|
@@ -81,27 +76,7 @@ test("deliveryToFetchArgs: maps a delivery object to cmdFetch args (tolerant of
|
|
|
81
76
|
assert.equal(deliveryToFetchArgs(null), null);
|
|
82
77
|
});
|
|
83
78
|
|
|
84
|
-
test("
|
|
85
|
-
const dir = mkdtempSync(join(tmpdir(), "agent-relay-cache-"));
|
|
86
|
-
try {
|
|
87
|
-
const cache = new Cache(dir);
|
|
88
|
-
// A non-dev shape an agent's distillation might use — must survive verbatim.
|
|
89
|
-
const distilled = {
|
|
90
|
-
summary: "Quote obtained",
|
|
91
|
-
quote_usd: 1240,
|
|
92
|
-
coverages: ["liability", "collision"],
|
|
93
|
-
next_steps: ["sign", "pay deposit"],
|
|
94
|
-
};
|
|
95
|
-
cache.writeSpec("acme-insurance", distilled);
|
|
96
|
-
const got = cache.readSpec("acme-insurance", { ttlMs: Infinity });
|
|
97
|
-
assert.deepEqual(got, distilled);
|
|
98
|
-
assert.deepEqual(cache.listSpecs(), ["acme-insurance"]);
|
|
99
|
-
} finally {
|
|
100
|
-
rmSync(dir, { recursive: true, force: true });
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("slugify: stable kebab-case keys for cache/consult", () => {
|
|
79
|
+
test("slugify: stable kebab-case keys for slugs", () => {
|
|
105
80
|
assert.equal(slugify("Open-Meteo Weather API Solutions Engineer"), "open-meteo-weather-api-solutions-engineer");
|
|
106
81
|
assert.equal(slugify("Acme Insurance ✨ (Auto)"), "acme-insurance-auto");
|
|
107
82
|
assert.equal(slugify(""), "capability");
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the pure sysenv probes used by the installer's global-CLI linking.
|
|
3
|
+
* No network, no real npm install. Run: `node --test bin/test/sysenv.test.mjs`.
|
|
4
|
+
*/
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { dirOnPath, npmGlobalBinDir, globalBinShimPath } from "../lib/sysenv.mjs";
|
|
8
|
+
|
|
9
|
+
test("dirOnPath: exact component match, trailing-slash tolerant", () => {
|
|
10
|
+
const delim = process.platform === "win32" ? ";" : ":";
|
|
11
|
+
const pathVar = ["/usr/bin", "/opt/homebrew/bin/"].join(delim);
|
|
12
|
+
assert.equal(dirOnPath("/opt/homebrew/bin", pathVar), true);
|
|
13
|
+
assert.equal(dirOnPath("/usr/bin", pathVar), true);
|
|
14
|
+
assert.equal(dirOnPath("/usr/local/bin", pathVar), false);
|
|
15
|
+
assert.equal(dirOnPath(null, pathVar), false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("npmGlobalBinDir/globalBinShimPath shape per platform", () => {
|
|
19
|
+
const prefix = process.platform === "win32" ? "C:\\npm" : "/opt/homebrew";
|
|
20
|
+
const binDir = npmGlobalBinDir(prefix);
|
|
21
|
+
const shim = globalBinShimPath(prefix, "agentrelay");
|
|
22
|
+
if (process.platform === "win32") {
|
|
23
|
+
assert.equal(binDir, prefix);
|
|
24
|
+
assert.ok(shim.endsWith("agentrelay.cmd"));
|
|
25
|
+
} else {
|
|
26
|
+
assert.equal(binDir, `${prefix}/bin`);
|
|
27
|
+
assert.ok(shim.endsWith("/bin/agentrelay"));
|
|
28
|
+
}
|
|
29
|
+
assert.equal(npmGlobalBinDir(null), null);
|
|
30
|
+
});
|