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.
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Command implementations for the agentrelay CLI. Each returns an envelope payload
3
+ * (the entry adds `ok`/`errors` and chooses the exit code).
4
+ *
5
+ * Two modes (see SKILL.md):
6
+ * - `session start|send|end` — thin api_v1 wrappers; the caller (a host
7
+ * subagent, or the main agent in direct mode) drives the conversation and
8
+ * reads the agent's RAW turns. This is the primary, LLM-driven path.
9
+ * - `consult` / `consult-batch` — the code-driven TEMPLATE fallback for
10
+ * no-LLM/scripted use; returns a distilled result, transcript stays out.
11
+ */
12
+
13
+ import { readFileSync } from "fs";
14
+ import {
15
+ search as apiSearch,
16
+ sessionStart,
17
+ sessionMessage,
18
+ sessionEnd,
19
+ sessionPay,
20
+ feedback as apiFeedback,
21
+ getPaymentPolicy,
22
+ patchPaymentPolicy,
23
+ } from "./api.mjs";
24
+ import { slugify } from "./config.mjs";
25
+ import { Cache } from "./cache.mjs";
26
+ import { consultOne } from "./driver.mjs";
27
+
28
+ /** Normalize one upstream capability into the spec §4 menu shape. */
29
+ function normalizeCapability(c) {
30
+ const name = c.name || "";
31
+ return {
32
+ slug: slugify(name),
33
+ name,
34
+ type: c.type || "dynamic_capability",
35
+ relevance: typeof c.relevance_score === "number" ? c.relevance_score : null,
36
+ clearing_price_cents:
37
+ typeof c.clearing_price_cents === "number" ? c.clearing_price_cents : 0,
38
+ capability_id: c.capability_id || null,
39
+ ad_unit_id: c.ad_unit_id || null,
40
+ description: c.description || "",
41
+ click_url: c.click_url || null,
42
+ };
43
+ }
44
+
45
+ /** Disambiguate duplicate slugs by appending -2, -3, … (stable order). */
46
+ function dedupeSlugs(caps) {
47
+ const seen = new Map();
48
+ for (const c of caps) {
49
+ const n = seen.get(c.slug) || 0;
50
+ seen.set(c.slug, n + 1);
51
+ if (n > 0) c.slug = `${c.slug}-${n + 1}`;
52
+ }
53
+ return caps;
54
+ }
55
+
56
+ export async function cmdSearch(query, opts) {
57
+ const cache = new Cache(opts.cacheDir, { enabled: !opts.noCache });
58
+ const maxResults = opts.max || 25;
59
+
60
+ // The buyer-side spend policy snapshot the search API returns alongside the
61
+ // menu — the agent decides auto-pay-vs-bubble against it (SKILL.md Pay). Pass
62
+ // it through here so it survives to the CLI's JSON output and gets cached.
63
+ let paymentContext = opts.refresh ? null : cache.readSearchPaymentContext(query);
64
+
65
+ let capabilities = opts.refresh ? null : cache.readSearch(query);
66
+ if (!capabilities) {
67
+ const data = await apiSearch(query, {
68
+ apiKey: opts.apiKey,
69
+ userContext: opts.userContext,
70
+ maxResults,
71
+ timeoutMs: opts.timeoutMs,
72
+ });
73
+ paymentContext = data.payment_context ?? null;
74
+ capabilities = dedupeSlugs((data.capabilities || []).map(normalizeCapability));
75
+ cache.writeSearch(query, capabilities, paymentContext);
76
+ // Seed the index so consult can resolve ids without re-searching.
77
+ for (const c of capabilities) {
78
+ if (c.capability_id && c.ad_unit_id) {
79
+ cache.updateIndex(c.slug, {
80
+ capability_id: c.capability_id,
81
+ ad_unit_id: c.ad_unit_id,
82
+ clearing_price_cents: c.clearing_price_cents,
83
+ name: c.name,
84
+ ts: new Date().toISOString(),
85
+ });
86
+ }
87
+ }
88
+ }
89
+
90
+ if (opts.dynamicOnly) {
91
+ capabilities = capabilities.filter((c) => c.type === "dynamic_capability");
92
+ }
93
+ capabilities = capabilities.slice(0, maxResults);
94
+
95
+ return { query, count: capabilities.length, capabilities, payment_context: paymentContext };
96
+ }
97
+
98
+ /**
99
+ * Resolve a slug (or capability_id) to { capabilityId, adUnitId,
100
+ * clearingPriceCents, slug, name }. Order: cache index → banked spec file →
101
+ * fresh search keyed on the de-kebabed slug. The spec-file fallback lets a
102
+ * depth session resolve even when the cache was filled only by `cache put`
103
+ * during the breadth pass (which doesn't write the index).
104
+ */
105
+ export async function resolveCapability(slugOrId, opts) {
106
+ const cache = new Cache(opts.cacheDir, { enabled: !opts.noCache });
107
+ const slug = slugify(slugOrId);
108
+
109
+ const entry = cache.getIndexEntry(slug) || cache.getIndexEntry(slugOrId);
110
+ if (entry && entry.capability_id && entry.ad_unit_id && !opts.refresh) {
111
+ return {
112
+ capabilityId: entry.capability_id,
113
+ adUnitId: entry.ad_unit_id,
114
+ clearingPriceCents: entry.clearing_price_cents || 0,
115
+ slug,
116
+ name: entry.name || slug,
117
+ };
118
+ }
119
+
120
+ // Banked spec fallback — a breadth subagent stored ids here via `cache put`.
121
+ if (!opts.refresh) {
122
+ const spec = cache.readSpec(slug, { ttlMs: Infinity });
123
+ if (spec && spec.capability_id && spec.ad_unit_id) {
124
+ return {
125
+ capabilityId: spec.capability_id,
126
+ adUnitId: spec.ad_unit_id,
127
+ clearingPriceCents: spec.clearing_price_cents || 0,
128
+ slug,
129
+ name: spec.name || slug,
130
+ };
131
+ }
132
+ }
133
+
134
+ // Fresh search keyed on the human form of the slug.
135
+ const queryGuess = String(slugOrId).replace(/-/g, " ");
136
+ const found = await cmdSearch(queryGuess, { ...opts, refresh: true, max: 25 });
137
+ const match =
138
+ found.capabilities.find((c) => c.slug === slug) ||
139
+ found.capabilities.find((c) => c.capability_id === slugOrId) ||
140
+ found.capabilities.find((c) => c.slug.startsWith(slug));
141
+ if (!match || !match.capability_id || !match.ad_unit_id) {
142
+ throw new Error(
143
+ `could not resolve "${slugOrId}" — run \`agentrelay search\` first or pass a known slug`,
144
+ );
145
+ }
146
+ return {
147
+ capabilityId: match.capability_id,
148
+ adUnitId: match.ad_unit_id,
149
+ clearingPriceCents: match.clearing_price_cents || 0,
150
+ slug: match.slug,
151
+ name: match.name,
152
+ };
153
+ }
154
+
155
+ async function consultResolved(resolved, want, opts) {
156
+ const cache = new Cache(opts.cacheDir, { enabled: !opts.noCache });
157
+
158
+ // Cache hit: return the stored spec unless --refresh.
159
+ if (!opts.refresh) {
160
+ const cached = cache.readSpec(resolved.slug);
161
+ if (cached) return { ...cached, cached: true };
162
+ }
163
+
164
+ const { deliverable, sessionId, turns } = await consultOne(resolved, {
165
+ want,
166
+ context: opts.context,
167
+ deliverNow: opts.deliverNow,
168
+ raw: opts.raw,
169
+ maxTurns: opts.maxTurns,
170
+ timeoutMs: opts.timeoutMs,
171
+ apiKey: opts.apiKey,
172
+ });
173
+ deliverable.turns = turns;
174
+ cache.writeSpec(resolved.slug, deliverable);
175
+ cache.updateIndex(resolved.slug, {
176
+ last_session_id: sessionId,
177
+ capability_id: resolved.capabilityId,
178
+ ad_unit_id: resolved.adUnitId,
179
+ clearing_price_cents: resolved.clearingPriceCents,
180
+ name: resolved.name,
181
+ });
182
+ return deliverable;
183
+ }
184
+
185
+ /**
186
+ * `consult`/`consult-batch` only run the code-driven TEMPLATE driver. The
187
+ * LLM-driven path is a host subagent driving the `session` primitives — there's
188
+ * no CLI-internal LLM. Reject `--driver llm` with a pointer rather than
189
+ * silently degrading.
190
+ */
191
+ function assertTemplateDriver(opts) {
192
+ const d = opts.driver || "template";
193
+ if (d !== "template") {
194
+ const e = new Error(
195
+ `--driver "${d}" is not available. consult runs the template driver only; ` +
196
+ "for LLM-driven conversation, drive `agentrelay session start|send|end` from a subagent (see SKILL.md).",
197
+ );
198
+ e.usage = true;
199
+ throw e;
200
+ }
201
+ }
202
+
203
+ export async function cmdConsult(slugOrId, opts) {
204
+ assertTemplateDriver(opts);
205
+ if (!opts.want) {
206
+ const e = new Error("--want is required");
207
+ e.usage = true;
208
+ throw e;
209
+ }
210
+ const resolved = await resolveCapability(slugOrId, opts);
211
+ const deliverable = await consultResolved(resolved, opts.want, opts);
212
+ return { deliverable };
213
+ }
214
+
215
+ function loadWantFile(path) {
216
+ try {
217
+ return JSON.parse(readFileSync(path, "utf-8"));
218
+ } catch (err) {
219
+ throw new Error(`could not read --want-file ${path}: ${err.message}`);
220
+ }
221
+ }
222
+
223
+ export async function cmdConsultBatch(opts) {
224
+ assertTemplateDriver(opts);
225
+ if (!opts.agents || opts.agents.length === 0) {
226
+ const e = new Error("--agents <a,b,c> is required");
227
+ e.usage = true;
228
+ throw e;
229
+ }
230
+ const wantMap = opts.wantFile ? loadWantFile(opts.wantFile) : null;
231
+ if (!wantMap && !opts.want) {
232
+ const e = new Error("provide --want <str> or --want-file <map.json>");
233
+ e.usage = true;
234
+ throw e;
235
+ }
236
+
237
+ const { pool } = await import("./pool.mjs");
238
+ const t0 = Date.now();
239
+
240
+ const results = await pool(opts.agents, opts.concurrency || 5, async (slug) => {
241
+ const want = (wantMap && wantMap[slug]) || opts.want;
242
+ if (!want) throw new Error(`no want for "${slug}" (missing from --want-file)`);
243
+ const resolved = await resolveCapability(slug, opts);
244
+ return consultResolved(resolved, want, opts);
245
+ });
246
+
247
+ const deliverables = [];
248
+ const failures = [];
249
+ results.forEach((r, i) => {
250
+ if (r.ok) deliverables.push(r.value);
251
+ else failures.push({ slug: opts.agents[i], reason: String(r.error.message || r.error) });
252
+ });
253
+
254
+ const totals = {
255
+ consulted: opts.agents.length,
256
+ ok: deliverables.length,
257
+ failed: failures.length,
258
+ wall_seconds: Math.round((Date.now() - t0) / 1000),
259
+ tokens_estimate: deliverables.reduce((s, d) => s + (d.tokens_estimate || 0), 0),
260
+ };
261
+
262
+ return { deliverables, failures, totals };
263
+ }
264
+
265
+ // ── session primitives (thin api_v1 wrappers — the LLM-driven path) ─────────
266
+ function parseJsonFlag(s, name) {
267
+ if (!s) return undefined;
268
+ try {
269
+ return JSON.parse(s);
270
+ } catch (err) {
271
+ const e = new Error(`--${name} must be valid JSON: ${err.message}`);
272
+ e.usage = true;
273
+ throw e;
274
+ }
275
+ }
276
+
277
+ /** Shape a raw session turn for output — the agent's message is passed through. */
278
+ export function shapeSessionTurn(r) {
279
+ return {
280
+ session_id: r.session_id,
281
+ message: r.message,
282
+ awaiting_input: r.awaiting_input ?? null,
283
+ deliverable_complete: r.deliverable_complete ?? null,
284
+ pending_fields: r.pending_fields ?? [],
285
+ structured_data: r.structured_data ?? {},
286
+ ...(r.expires_at ? { expires_at: r.expires_at } : {}),
287
+ // Advisory: surfaced by api-v1 when the seller owes a charge. Never blocks the turn;
288
+ // decide auto-pay vs bubble against the cached payment_context (see SKILL.md Pay).
289
+ ...(r.payment_required ? { payment_required: r.payment_required } : {}),
290
+ };
291
+ }
292
+
293
+ export async function cmdSession(sub, positionals, opts) {
294
+ const net = { apiKey: opts.apiKey, timeoutMs: opts.timeoutMs };
295
+
296
+ if (sub === "start") {
297
+ const slugOrId = positionals[0];
298
+ if (!slugOrId) {
299
+ const e = new Error("usage: agentrelay session start <slug|capability_id> [--initial json] [--message m]");
300
+ e.usage = true;
301
+ throw e;
302
+ }
303
+ const resolved = await resolveCapability(slugOrId, opts);
304
+ const start = await sessionStart(
305
+ {
306
+ capabilityId: resolved.capabilityId,
307
+ adUnitId: resolved.adUnitId,
308
+ clearingPriceCents: resolved.clearingPriceCents,
309
+ initialData: parseJsonFlag(opts.initial, "initial"),
310
+ },
311
+ net,
312
+ );
313
+ // NOTE: intentionally no `cache.updateIndex` here. `session start` is called
314
+ // by parallel breadth subagents (separate processes); a shared index.json
315
+ // read-modify-write would race. The session_id is returned to the caller
316
+ // below, and ids resolve via search-seeded index or the banked spec file.
317
+ // --message: send the first turn in the same call for convenience.
318
+ if (opts.message) {
319
+ const turn = await sessionMessage({ sessionId: start.session_id, message: opts.message }, net);
320
+ return {
321
+ slug: resolved.slug,
322
+ greeting: start.message,
323
+ ...shapeSessionTurn({ ...turn, session_id: start.session_id, expires_at: start.expires_at }),
324
+ };
325
+ }
326
+ return { slug: resolved.slug, ...shapeSessionTurn(start) };
327
+ }
328
+
329
+ if (sub === "send") {
330
+ const sessionId = positionals[0];
331
+ const message = positionals.slice(1).join(" ").trim();
332
+ if (!sessionId || !message) {
333
+ const e = new Error('usage: agentrelay session send <session_id> "<message>" [--data json]');
334
+ e.usage = true;
335
+ throw e;
336
+ }
337
+ const turn = await sessionMessage(
338
+ { sessionId, message, structuredData: parseJsonFlag(opts.data, "data") },
339
+ net,
340
+ );
341
+ return shapeSessionTurn({ ...turn, session_id: sessionId });
342
+ }
343
+
344
+ if (sub === "end") {
345
+ const sessionId = positionals[0];
346
+ if (!sessionId) {
347
+ const e = new Error("usage: agentrelay session end <session_id>");
348
+ e.usage = true;
349
+ throw e;
350
+ }
351
+ return sessionEnd({ sessionId }, net);
352
+ }
353
+
354
+ if (sub === "pay") {
355
+ const sessionId = positionals[0];
356
+ if (!sessionId) {
357
+ const e = new Error("usage: agentrelay session pay <session_id> [--credential <mpp_credential>]");
358
+ e.usage = true;
359
+ throw e;
360
+ }
361
+ // Server-side grant when no --credential; client-side MPP wallet when provided.
362
+ return sessionPay({ sessionId, paymentCredential: opts.credential || null }, net);
363
+ }
364
+
365
+ const e = new Error(`unknown session subcommand "${sub}" (start|send|end|pay)`);
366
+ e.usage = true;
367
+ throw e;
368
+ }
369
+
370
+ export async function cmdFeedback(sessionId, opts) {
371
+ if (!sessionId || opts.score == null) {
372
+ const e = new Error("usage: agentrelay feedback <session_id> --score <0-100> --text <str>");
373
+ e.usage = true;
374
+ throw e;
375
+ }
376
+ return apiFeedback(
377
+ { sessionId, feedbackText: opts.text || "", score: Number(opts.score) },
378
+ { apiKey: opts.apiKey, timeoutMs: opts.timeoutMs },
379
+ );
380
+ }
381
+
382
+ /** Convert a dollar string/number to integer cents; throws a usage error on garbage. */
383
+ export function dollarsToCents(label, value) {
384
+ const n = Number(value);
385
+ if (!Number.isFinite(n) || n < 0) {
386
+ const e = new Error(`${label} must be a non-negative dollar amount (e.g. 5 or 2.50)`);
387
+ e.usage = true;
388
+ throw e;
389
+ }
390
+ return Math.round(n * 100);
391
+ }
392
+
393
+ /**
394
+ * Read or update the account's server-canonical autonomous-spend policy.
395
+ * No flags → GET (print current policy). Any flag → PATCH then return the new policy.
396
+ * Dollar flags are converted to cents; the server is canonical (nothing stored locally).
397
+ */
398
+ export async function cmdBudget(opts) {
399
+ const patch = {};
400
+ if (opts.autoApprove != null) patch.auto_approve_under_cents = dollarsToCents("--auto-approve", opts.autoApprove);
401
+ if (opts.dailyCap != null) patch.daily_cap_cents = dollarsToCents("--daily-cap", opts.dailyCap);
402
+ if (opts.requireHumanOver != null) patch.require_human_over_cents = dollarsToCents("--require-human-over", opts.requireHumanOver);
403
+ if (opts.perSessionCap != null) patch.per_session_cap_cents = dollarsToCents("--per-session-cap", opts.perSessionCap);
404
+ if (opts.enable && opts.disable) {
405
+ const e = new Error("pass only one of --enable / --disable");
406
+ e.usage = true;
407
+ throw e;
408
+ }
409
+ if (opts.enable) patch.autonomous_enabled = true;
410
+ if (opts.disable) patch.autonomous_enabled = false;
411
+
412
+ const policy = Object.keys(patch).length > 0
413
+ ? await patchPaymentPolicy(patch, { apiKey: opts.apiKey, timeoutMs: opts.timeoutMs })
414
+ : await getPaymentPolicy({ apiKey: opts.apiKey, timeoutMs: opts.timeoutMs });
415
+ return { policy };
416
+ }
417
+
418
+ /**
419
+ * Stub: real wallet connection is the (deferred) dashboard OAuth flow. The CLI can't
420
+ * provision a wallet, so this just points the user at the dashboard.
421
+ */
422
+ export function cmdConnectWallet() {
423
+ return {
424
+ connected: false,
425
+ message:
426
+ "Wallet connection is set up from the AttentionMarket dashboard (OAuth flow). " +
427
+ "The CLI can't provision a wallet yet. Once a wallet is connected, run " +
428
+ "`agentrelay budget --enable --auto-approve=<$>` so the agent can auto-pay within your limits.",
429
+ };
430
+ }
431
+
432
+ export function cmdCache(sub, slug, opts) {
433
+ const cache = new Cache(opts.cacheDir, { enabled: true });
434
+ if (sub === "ls" || !sub) {
435
+ // Triage view read from the spec files themselves (race-safe under the
436
+ // parallel breadth pass; includes each summary so the main agent can pick
437
+ // what to `cache show` without opening every file).
438
+ return {
439
+ cache_dir: opts.cacheDir,
440
+ specs: cache.listSpecHeadlines(),
441
+ };
442
+ }
443
+ if (sub === "show") {
444
+ if (!slug) {
445
+ const e = new Error("usage: agentrelay cache show <slug>");
446
+ e.usage = true;
447
+ throw e;
448
+ }
449
+ const spec = cache.readSpec(slug, { ttlMs: Infinity });
450
+ if (!spec) throw new Error(`no cached spec for "${slug}"`);
451
+ return { deliverable: spec };
452
+ }
453
+ if (sub === "put") {
454
+ // Persist a distilled result a subagent/main agent produced, so it can be
455
+ // reviewed (`cache show`) or reused later. JSON from --file or stdin.
456
+ if (!slug) {
457
+ const e = new Error("usage: agentrelay cache put <slug> (--file f.json | JSON on stdin)");
458
+ e.usage = true;
459
+ throw e;
460
+ }
461
+ let raw;
462
+ try {
463
+ raw = opts.file ? readFileSync(opts.file, "utf-8") : readFileSync(0, "utf-8");
464
+ } catch (err) {
465
+ throw new Error(`could not read deliverable input: ${err.message}`);
466
+ }
467
+ let deliverable;
468
+ try {
469
+ deliverable = JSON.parse(raw);
470
+ } catch (err) {
471
+ const e = new Error(`deliverable must be valid JSON: ${err.message}`);
472
+ e.usage = true;
473
+ throw e;
474
+ }
475
+ if (!deliverable || typeof deliverable !== "object") {
476
+ const e = new Error("deliverable must be a JSON object");
477
+ e.usage = true;
478
+ throw e;
479
+ }
480
+ deliverable.slug = deliverable.slug || slug;
481
+ deliverable.ts = deliverable.ts || new Date().toISOString();
482
+ // Write ONLY the per-slug spec file — no shared index.json mutation — so N
483
+ // breadth subagents can `cache put` concurrently without clobbering each
484
+ // other. `cache ls` reads the spec files directly.
485
+ cache.writeSpec(slug, deliverable);
486
+ return { stored: true, slug, cache_dir: opts.cacheDir };
487
+ }
488
+ if (sub === "clear") {
489
+ cache.clear();
490
+ return { cleared: true, cache_dir: opts.cacheDir };
491
+ }
492
+ const e = new Error(`unknown cache subcommand "${sub}" (ls|show|put|clear)`);
493
+ e.usage = true;
494
+ throw e;
495
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Config + credential resolution for the agent-relay CLI.
3
+ *
4
+ * Key resolution order (spec §2): --api-key flag → AGENT_RELAY_API_KEY env →
5
+ * ~/.config/agent-relay/credentials.json → legacy ~/.config/penguin/credentials.json
6
+ * (read-only fallback so pre-rename keys keep working). File shape:
7
+ * {"api_key":"am_live_..."}.
8
+ *
9
+ * The key is NEVER printed or logged. Callers receive it only to set the
10
+ * X-AM-API-Key header.
11
+ */
12
+
13
+ import { readFileSync, existsSync } from "fs";
14
+ import { homedir } from "os";
15
+ import { join } from "path";
16
+
17
+ export const API_BASE =
18
+ "https://peruwnbrqkvmrldhpoom.supabase.co/functions/v1/api-v1";
19
+
20
+ export const CREDENTIALS_PATH = join(
21
+ homedir(),
22
+ ".config",
23
+ "agent-relay",
24
+ "credentials.json",
25
+ );
26
+
27
+ // Pre-rename location — read as a fallback only; never written.
28
+ export const LEGACY_CREDENTIALS_PATH = join(
29
+ homedir(),
30
+ ".config",
31
+ "penguin",
32
+ "credentials.json",
33
+ );
34
+
35
+ function readKeyFile(path) {
36
+ if (!existsSync(path)) return null;
37
+ try {
38
+ const creds = JSON.parse(readFileSync(path, "utf-8"));
39
+ if (creds.api_key && String(creds.api_key).trim()) {
40
+ return String(creds.api_key).trim();
41
+ }
42
+ } catch {
43
+ // fall through — treated as "no key"
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Resolve the API key. `flagKey` is the value of --api-key (or null).
50
+ * Returns the key string, or null if none is configured.
51
+ */
52
+ export function resolveApiKey(flagKey) {
53
+ if (flagKey && flagKey.trim()) return flagKey.trim();
54
+
55
+ const envKey = process.env.AGENT_RELAY_API_KEY;
56
+ if (envKey && envKey.trim()) return envKey.trim();
57
+
58
+ return readKeyFile(CREDENTIALS_PATH) || readKeyFile(LEGACY_CREDENTIALS_PATH);
59
+ }
60
+
61
+ /** kebab-case a capability name into a stable slug used for cache + consult. */
62
+ export function slugify(name) {
63
+ return String(name || "")
64
+ .toLowerCase()
65
+ .normalize("NFKD")
66
+ .replace(/[^a-z0-9]+/g, "-")
67
+ .replace(/^-+|-+$/g, "")
68
+ .slice(0, 60) || "capability";
69
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Deliverable schema (spec §5) — the structured contract every consult resolves
3
+ * to. Agents are *prompted* to return this JSON (see driver.mjs output
4
+ * contract); this module validates/normalizes whatever comes back so the
5
+ * orchestrator always gets a predictable, compact object. Hand-rolled (no zod)
6
+ * to keep the package zero-dependency. Mirrors schema/deliverable.json.
7
+ */
8
+
9
+ function asStringArray(v) {
10
+ if (Array.isArray(v)) return v.filter((x) => typeof x === "string");
11
+ if (typeof v === "string" && v.trim()) return [v.trim()];
12
+ return [];
13
+ }
14
+
15
+ function asObjectArray(v) {
16
+ return Array.isArray(v) ? v.filter((x) => x && typeof x === "object") : [];
17
+ }
18
+
19
+ /** chars/4 token estimate (spec §6 step 7). */
20
+ export function estimateTokens(obj) {
21
+ try {
22
+ return Math.round(JSON.stringify(obj).length / 4);
23
+ } catch {
24
+ return 0;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Normalize a raw object (parsed from the agent's final message) into a
30
+ * Deliverable. `meta` carries CLI-known fields (slug, ids, turns, cached, raw).
31
+ */
32
+ export function normalizeDeliverable(raw, meta = {}) {
33
+ const r = raw && typeof raw === "object" ? raw : {};
34
+ const d = {
35
+ slug: meta.slug || r.slug || "capability",
36
+ name: meta.name || r.name || meta.slug || "Capability",
37
+ capability_id: meta.capability_id || r.capability_id || null,
38
+ ad_unit_id: meta.ad_unit_id || r.ad_unit_id || null,
39
+ verdict: typeof r.verdict === "string" ? r.verdict : "",
40
+ install: asStringArray(r.install),
41
+ env: asObjectArray(r.env),
42
+ endpoints: asObjectArray(r.endpoints),
43
+ snippets: asObjectArray(r.snippets),
44
+ gotchas: asStringArray(r.gotchas),
45
+ free_tier: typeof r.free_tier === "string" ? r.free_tier : null,
46
+ docs: asStringArray(r.docs),
47
+ turns: meta.turns ?? 0,
48
+ cached: meta.cached ?? false,
49
+ ts: meta.ts || new Date().toISOString(),
50
+ };
51
+ d.tokens_estimate = estimateTokens(d);
52
+ if (meta.raw_excerpt) d.raw_excerpt = meta.raw_excerpt;
53
+ else d.raw_excerpt = null;
54
+ return d;
55
+ }
56
+
57
+ /**
58
+ * True if the object carries enough substance to count as a real deliverable
59
+ * (so we don't cache an empty shell from a turn that only asked a question).
60
+ */
61
+ export function hasSubstance(d) {
62
+ return Boolean(
63
+ (d.verdict && d.verdict.length > 0) ||
64
+ d.install.length ||
65
+ d.endpoints.length ||
66
+ d.snippets.length ||
67
+ d.gotchas.length ||
68
+ d.docs.length,
69
+ );
70
+ }