agentic-relay 4.0.0 → 4.1.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/CLAUDE.md CHANGED
@@ -6,16 +6,16 @@ Expert "solutions-engineer" agents for real third-party tools (APIs, SDKs, libra
6
6
 
7
7
  On any non-trivial build that touches a third-party tool, **load the `agent-relay` skill before doing Agent Relay work.** This file is a map, not the manual — the skill holds the real procedure. Load it because the workflow has paid actions and a feedback loop you will get wrong from memory: when a charge fires, how to react, who fetches a paid deliverable, and what feedback to file when code survives (or fails) contact with production. Don't reconstruct that from this file.
8
8
 
9
- Lightweight exception: a one-off lookup ("find me X", "how do I install X") doesn't need the full breadth→build→depth ceremony. A single inline session (`session start`a send or two `session end`) is fine — but still settle payments by the rules below.
9
+ Lightweight exception: a one-off lookup ("find me X", "how do I install X") doesn't need the full breadth→build→depth ceremony. A single inline `session` (start → send → end) is fine — but it's still a `session`, not a shortcut, and you still settle payments by the rules below.
10
10
 
11
11
  ## Commands
12
12
 
13
13
  ```
14
14
  agentrelay search "<capability>" --dynamic-only --json # find agents (returns payment_context)
15
- agentrelay cache ls # list banked specs (summaries) — dedup before consulting
15
+ agentrelay cache ls # list banked specs (summaries) — dedup before starting a session
16
16
  agentrelay cache show <slug> # full banked spec for one tool
17
- agentrelay cache put <slug> # bank a spec (subagent writes one slug file)
18
- agentrelay session start <slug> --initial '<ctx json>' --json # open a live consult
17
+ agentrelay cache put <slug> # bank a spec (one slug file; warns if session_id/status missing)
18
+ agentrelay session start <slug> --initial '<ctx json>' --json # open a live session (the ONLY way to talk to an agent)
19
19
  agentrelay session send <session_id> "<reply>" --json # next turn (carries awaiting_input, payment_required, delivery)
20
20
  agentrelay session end <session_id> --json
21
21
  agentrelay session pay <session_id> # settle an in-policy charge
@@ -28,11 +28,11 @@ CLI or MCP tools only — **never call the raw HTTP API.** API key lives at `~/.
28
28
 
29
29
  ## Workflow (one paragraph — the skill is authoritative)
30
30
 
31
- **Breadth → build → depth.** As the *first* step of a real build, `search` per need, dedup against `cache ls`, then map the chosen tools' surfaces and bank specs (one subagent per tool when you have subagents; transcripts stay with them, only summaries come back). **Build** from the bank: `cache show <slug>`, build from the snippet if the feature is `coded`, open a depth session if it's `surface_only`. **Depth** is a direct just-in-time session for code that isn't banked or when you're stuck; merge the result back into the spec. The conversation loop: after `start`, while `awaiting_input` is true (or `pending_fields` non-empty) answer from project context and `session send` again — never bounce a question to the human you can infer, never stop just because a question was asked. Stop when you have what you came for; `deliverable_complete` is a signal, not a hard stop. Use as few turns as the goal needs. See the skill for the subagent brief, depth triggers, and the spec contract.
31
+ **Breadth → build → depth.** As the *first* step of a real build, `search` per need, dedup against `cache ls`, then map each chosen tool's surface and bank a spec. **The only way to talk to an agent is a `session` (`start` `send` `end`).** Parallelism depends on your host: if it can spawn subagents, fan out one per tool, transcripts stay with them, only summaries return; if it can't (or you're not spawning them here), map agents inline one `session` at a time — same primitive, you drive it. **Build** from the bank: `cache show <slug>`, build from the snippet if the feature is `coded`, open a depth session if it's `surface_only`. **Depth** is a direct just-in-time session for code that isn't banked or when you're stuck; merge the result back into the spec. The conversation loop: after `start`, while `awaiting_input` is true (or `pending_fields` non-empty) answer from project context and `session send` again — never bounce a question to the human you can infer, never stop just because a question was asked. Stop when you have what you came for; `deliverable_complete` is a signal, not a hard stop. Use as few turns as the goal needs. See the skill for the mapping brief, depth triggers, and the spec contract.
32
32
 
33
33
  ## Payments — three tiers
34
34
 
35
- Read live policy first (`agentrelay budget`, or the `payment_context` returned by `search`). Never state pay posture from memory.
35
+ Read live policy first (`agentrelay budget`, or the `payment_context` returned by `search`). Never state pay posture from memory. Note `search`'s `payment_context` can be up to 1h stale (search cache) — `budget` always hits the server and is authoritative.
36
36
 
37
37
  - **Always (autonomous, no user check):** when autonomous pay is on, settle charges the build genuinely needs that fall within the policy ceiling. `session pay <session_id>`. The server independently rejects anything over budget/cap (`not_authorized` / `over_daily_cap` / `over_session_cap`), so you can't overspend — a rejection just means surface it.
38
38
  - **Ask first (surface, don't block):** autonomous off, over a threshold/cap, card-only, or server-rejected. Tag the session payment-pending-human, keep the `payment_url`, keep working other sessions, and consolidate all pending payments into one human moment at the end.
@@ -42,7 +42,7 @@ Full matrix, wallet models, the consolidation pattern, and the fetch/deliver ste
42
42
 
43
43
  ## Feedback — file it, more than once
44
44
 
45
- After using a capability, `agentrelay feedback <session_id> --score <0-100> --text "<...>"`. `session_id` is no longer one-and-done — the backend accepts multiple feedback rows per session (only the first triggers developer payout), so file at **both** moments: a consult-quality note when breadth ends, and a **build-outcome** note against the *same* `session_id` once the code ships or fails. Front-load the verdict (a background summarizer only reads the first ~2000 chars), and put concrete tool problems in the text — a stale/404 link or broken endpoint it served (quote the URL), an implementation hurdle, a knowledge gap. Mark live-verified claims with a date. This trains the capability's learned context; don't skip it because the build "worked."
45
+ After using a capability, `agentrelay feedback <session_id> --score <0-100> --text "<...>"`. The backend accepts multiple feedback rows per session (only the first triggers developer payout), so there are two signals against the same `session_id`: **mapping usefulness**, filed by whoever ran the session (a subagent, or you inline) before ending it — how useful the agent was at contributing to the problem; and **build-outcome**, filed by the main coding agent once the code ships or fails — how the spec fared in production. Front-load the verdict (a background summarizer only reads the first ~2000 chars), and put concrete tool problems in the text — a stale/404 link or broken endpoint it served (quote the URL), an implementation hurdle, a knowledge gap. Mark live-verified claims with a date. This trains the capability's learned context; don't skip it because the build "worked."
46
46
 
47
47
  ## Guardrails
48
48
 
package/README.md CHANGED
@@ -135,11 +135,21 @@ gotchas}], capability_id, ad_unit_id }` (+ any extra keys) — see `schema/deliv
135
135
  | `agentrelay feedback <id>` | `--score <0-100> --text "<s>"`. |
136
136
  | `agentrelay cache ls\|show <slug>\|put <slug>\|clear` | Persist/review distillations (`.agent-relay/`). |
137
137
  | `agentrelay session pay <id>` / `fetch` / `budget` | Settle an in-policy charge, fetch a paid deliverable, read/set the spend policy. |
138
+ | `agentrelay doctor` | Diagnose the install: Node version, API key + connectivity, npm prefix writability, `~/.npm` ownership, global bin + PATH. `--json` for machines. |
138
139
 
139
140
  Output is a `{ ok, ...payload, errors: [] }` envelope (`--json` auto-on when piped). Key resolution:
140
141
  `--api-key` → `$AGENT_RELAY_API_KEY` → `~/.config/agent-relay/credentials.json` (with a read-only fallback to the
141
142
  legacy `~/.config/penguin/credentials.json`). The key is never printed.
142
143
 
144
+ **Staying current:** interactive runs print a one-line stderr notice when a newer version is on npm
145
+ (checked in the background at most once per 24h; never blocks a command, never fires when piped, in CI,
146
+ or under `npx`). Opt out with `AGENT_RELAY_NO_UPDATE_CHECK=1`. Deliverable zips are extracted by a
147
+ built-in pure-JS extractor — no system `unzip` needed, so `fetch` works on Windows too.
148
+
149
+ **Future work (not in this release):** standalone binaries, a Homebrew tap, winget/scoop manifests, and
150
+ code signing. The package uses no npm lifecycle scripts (no postinstall), so npm v12's
151
+ scripts-blocked-by-default change does not affect installs.
152
+
143
153
  ## What is Agent Relay?
144
154
 
145
155
  AI-native search for software, tools, services, and business solutions. Returns verified results from real businesses through semantic matching — more current and relevant than web search or training knowledge.
package/bin/cli.mjs CHANGED
@@ -21,6 +21,7 @@ const CLI_COMMANDS = new Set([
21
21
  "budget",
22
22
  "connect-wallet",
23
23
  "fetch",
24
+ "doctor",
24
25
  "version",
25
26
  "help",
26
27
  // Removed commands — still routed to the CLI core so they fail with a clear
package/bin/install.mjs CHANGED
@@ -29,18 +29,35 @@
29
29
  * deeplink Print URL the agent registers (tome://, raycast://) — user clicks Install
30
30
  */
31
31
 
32
- import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from "fs";
32
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
33
33
  import { homedir } from "os";
34
34
  import { join, dirname } from "path";
35
35
  import { fileURLToPath } from "url";
36
36
  import { createInterface } from "readline/promises";
37
37
  import { stdin, stdout } from "process";
38
38
  import { execSync } from "child_process";
39
+ import { parseInstallArgs } from "./lib/install-args.mjs";
40
+ import {
41
+ resolveCommandPath,
42
+ isEphemeralBin,
43
+ npmGlobalPrefix,
44
+ npmGlobalBinDir,
45
+ globalBinShimPath,
46
+ globalPrefixWritable,
47
+ npmCacheOwnedByOther,
48
+ dirOnPath,
49
+ USER_PREFIX_RECIPE,
50
+ CACHE_CHOWN_RECIPE,
51
+ } from "./lib/sysenv.mjs";
39
52
 
40
53
  const __dirname = dirname(fileURLToPath(import.meta.url));
41
54
  const HOME = homedir();
42
55
 
43
- const SECTION_START = "# Agent Relay MCP Server";
56
+ // Matches the CLAUDE.md/AGENTS.md section header. The current header is
57
+ // "# Agent Relay"; older installs wrote "# Agent Relay MCP Server" — the
58
+ // replace regex tolerates the legacy suffix so re-installs REPLACE the old
59
+ // section instead of appending a duplicate.
60
+ const SECTION_START = "# Agent Relay";
44
61
  const CREDENTIALS_PATH = join(HOME, ".config", "agent-relay", "credentials.json");
45
62
  const LEGACY_CREDENTIALS_PATH = join(HOME, ".config", "penguin", "credentials.json");
46
63
  const SIGNUP_URL = "https://attentionmarket-auth.vercel.app";
@@ -266,39 +283,8 @@ function escapeRegex(str) {
266
283
  // ═══════════════════════════════════════════════════════════════════════
267
284
 
268
285
  function parseArgs() {
269
- const args = process.argv.slice(2);
270
- const flags = {
271
- targets: null,
272
- all: args.includes("--all"),
273
- project: args.includes("--project"),
274
- apiKey: null,
275
- librechat: null,
276
- // Legacy single-flag shortcuts (kept for backward compat)
277
- legacyClaude: args.includes("--claude"),
278
- legacyCodex: args.includes("--codex"),
279
- legacyCursor: args.includes("--cursor"),
280
- };
281
-
282
- for (const arg of args) {
283
- if (arg.startsWith("--target=")) {
284
- flags.targets = arg
285
- .slice("--target=".length)
286
- .split(",")
287
- .map((s) => s.trim())
288
- .filter(Boolean);
289
- } else if (arg.startsWith("--api-key=")) {
290
- flags.apiKey = arg.slice("--api-key=".length);
291
- } else if (arg.startsWith("--librechat=")) {
292
- flags.librechat = arg.slice("--librechat=".length);
293
- }
294
- }
295
-
296
- // Support `--api-key VALUE` (space-separated) too
297
- const keyIdx = args.indexOf("--api-key");
298
- if (keyIdx !== -1 && args[keyIdx + 1] && !args[keyIdx + 1].startsWith("--")) {
299
- flags.apiKey = args[keyIdx + 1];
300
- }
301
-
286
+ const { flags, warnings } = parseInstallArgs(process.argv.slice(2));
287
+ for (const w of warnings) warn(w);
302
288
  return flags;
303
289
  }
304
290
 
@@ -329,9 +315,11 @@ function installInstructions(instructions, path, label) {
329
315
 
330
316
  if (existing.includes(SECTION_START)) {
331
317
  const sectionRegex = new RegExp(
332
- `${escapeRegex(SECTION_START)}[\\s\\S]*?(?=\\n# (?!Agent Relay)|\\s*$)`,
318
+ `${escapeRegex(SECTION_START)}( MCP Server)?[\\s\\S]*?(?=\\n# (?!Agent Relay)|\\s*$)`,
333
319
  );
334
- writeFileSync(path, existing.replace(sectionRegex, instructions));
320
+ // Replacer fn: the instructions contain `$` (payment examples) which
321
+ // String.replace would otherwise treat as capture-group references.
322
+ writeFileSync(path, existing.replace(sectionRegex, () => instructions));
335
323
  success(`${label}: instructions updated → ${path}`);
336
324
  } else {
337
325
  const sep = existing.length > 0 ? "\n\n" : "";
@@ -623,45 +611,10 @@ function getExistingKey() {
623
611
  * Prefers a registry install (`agentic-relay@<version>`) over the local path
624
612
  * (the npx cache dir is ephemeral; npm would link/copy from it). Falls back to
625
613
  * the path for unpublished checkouts. Surfaces npm's actual error (EACCES
626
- * etc.) instead of swallowing it, and verifies the command resolves afterwards
627
- * so a PATH mismatch is reported, not discovered later as `command not found`.
614
+ * etc.) instead of swallowing it, and verifies the global bin file landed so a
615
+ * PATH mismatch is reported, not discovered later as `command not found`.
616
+ * System probes live in lib/sysenv.mjs (shared with `agentrelay doctor`).
628
617
  */
629
- function resolveCommandPath(cmd) {
630
- try {
631
- const probe = process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`;
632
- const out = execSync(probe, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
633
- return out.split("\n")[0].trim() || null;
634
- } catch {
635
- return null;
636
- }
637
- }
638
-
639
- /** True when a resolved bin path is an ephemeral npx/npm-exec shim (or this package's own copy), not a real install. */
640
- function isEphemeralBin(binPath, pkgRoot) {
641
- if (!binPath) return false;
642
- let real = binPath;
643
- try {
644
- real = realpathSync(binPath);
645
- } catch {
646
- // dangling symlink — definitely not a usable install
647
- return true;
648
- }
649
- const sep = process.platform === "win32" ? "\\" : "/";
650
- return (
651
- real.includes(`${sep}_npx${sep}`) ||
652
- binPath.includes(`${sep}_npx${sep}`) ||
653
- real.startsWith(realpathSafe(pkgRoot))
654
- );
655
- }
656
-
657
- function realpathSafe(p) {
658
- try {
659
- return realpathSync(p);
660
- } catch {
661
- return p;
662
- }
663
- }
664
-
665
618
  function linkGlobalCli() {
666
619
  const pkgRoot = join(__dirname, "..");
667
620
  let version = null;
@@ -678,7 +631,7 @@ function linkGlobalCli() {
678
631
  const installed = (out.trim().split(/\s+/).pop() || "").trim();
679
632
  if (version && installed === version) {
680
633
  success(`\`agentrelay\` ${version} already on your PATH`);
681
- return;
634
+ return "linked";
682
635
  }
683
636
  log(`global \`agentrelay\` is ${installed || "an unknown version"} — updating to ${version}…`);
684
637
  } catch {
@@ -686,6 +639,21 @@ function linkGlobalCli() {
686
639
  }
687
640
  }
688
641
 
642
+ // Preflight: detect the two environment breakages PROACTIVELY instead of
643
+ // letting `npm install -g` fail with a buried stderr.
644
+ const prefix = npmGlobalPrefix();
645
+ if (npmCacheOwnedByOther()) {
646
+ warn("your ~/.npm cache contains files owned by another user (an old `sudo npm`).");
647
+ warn(`This breaks npx AND npm i -g. Fix it once, then re-run: ${CACHE_CHOWN_RECIPE}`);
648
+ return "blocked";
649
+ }
650
+ if (prefix && !globalPrefixWritable(prefix)) {
651
+ warn(`npm's global prefix (${prefix}) isn't writable — npm i -g would fail with EACCES.`);
652
+ warn("Use a user-level prefix (the official npm fix):");
653
+ for (const line of USER_PREFIX_RECIPE) warn(` ${line}`);
654
+ return "blocked";
655
+ }
656
+
689
657
  const attempts = version ? [`agentic-relay@${version}`, `"${pkgRoot}"`] : [`"${pkgRoot}"`];
690
658
  let lastErr = null;
691
659
  let installedFrom = null;
@@ -702,34 +670,29 @@ function linkGlobalCli() {
702
670
  const stderr = String((lastErr && lastErr.stderr) || "");
703
671
  const detail =
704
672
  stderr.split("\n").find((l) => /EACCES|EEXIST|E404|ERR!|error/i.test(l)) || "";
705
- warn(
706
- `could not auto-link the global CLI${detail ? ` — ${detail.trim()}` : ""}.` +
707
- ` Run once: npm i -g agentic-relay` +
708
- (/EACCES/.test(stderr) ? " (npm's global prefix isn't writable — fix permissions or use a user-level prefix)" : ""),
709
- );
710
- return;
673
+ warn(`could not auto-link the global CLI${detail ? ` — ${detail.trim()}` : ""}. Run once: npm i -g agentic-relay`);
674
+ if (/EACCES/.test(stderr)) {
675
+ warn("EACCES use a user-level prefix (the official npm fix):");
676
+ for (const line of USER_PREFIX_RECIPE) warn(` ${line}`);
677
+ }
678
+ return "failed";
711
679
  }
712
680
 
713
681
  // Verify the global bin actually landed. Don't probe the bare command here:
714
682
  // in this process it resolves to the ephemeral npx shim (which shadows the
715
683
  // global install), so check the file in npm's global prefix directly.
716
- let binDir = null;
717
- let globalBin = null;
718
- try {
719
- const prefix = execSync("npm prefix -g", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
720
- binDir = process.platform === "win32" ? prefix : join(prefix, "bin");
721
- const cand = join(binDir, process.platform === "win32" ? "agentrelay.cmd" : "agentrelay");
722
- if (existsSync(cand)) globalBin = cand;
723
- } catch {
724
- // can't resolve the prefix — fall through to the warn
725
- }
726
- if (globalBin) {
684
+ const binDir = npmGlobalBinDir(prefix);
685
+ const shim = globalBinShimPath(prefix, "agentrelay");
686
+ if (shim && existsSync(shim)) {
727
687
  success(`linked global \`agentrelay\` CLI (${installedFrom})`);
728
- const onPath = (process.env.PATH || "").split(process.platform === "win32" ? ";" : ":").includes(binDir);
729
- if (!onPath) warn(`note: ${binDir} doesn't appear to be on your PATH — add it to use \`agentrelay\` directly`);
730
- } else {
731
- warn(`installed, but no global \`agentrelay\` found in ${binDir || "npm's global bin directory"} — run once: npm i -g agentic-relay`);
688
+ if (!dirOnPath(binDir)) {
689
+ warn(`note: ${binDir} doesn't appear to be on your PATH — add it to use \`agentrelay\` directly`);
690
+ return "linked-offpath";
691
+ }
692
+ return "linked";
732
693
  }
694
+ warn(`installed, but no global \`agentrelay\` found in ${binDir || "npm's global bin directory"} — run once: npm i -g agentic-relay`);
695
+ return "failed";
733
696
  }
734
697
 
735
698
  async function setupApiKeyInteractive() {
@@ -739,6 +702,13 @@ async function setupApiKeyInteractive() {
739
702
  return true;
740
703
  }
741
704
 
705
+ // Scripted/CI runs have no TTY — don't hang on a prompt nobody will answer.
706
+ if (!stdin.isTTY) {
707
+ warn("no API key configured and stdin is not a terminal — skipping the prompt.");
708
+ warn("Pass --api-key=<am_live_...> or write ~/.config/agent-relay/credentials.json");
709
+ return false;
710
+ }
711
+
742
712
  console.log("");
743
713
  log("API key setup");
744
714
  console.log("");
@@ -964,8 +934,12 @@ async function main() {
964
934
 
965
935
  // Put a bare `agentrelay` command on PATH so users don't need npx every time.
966
936
  console.log("");
967
- linkGlobalCli();
937
+ const linkStatus = linkGlobalCli();
968
938
 
939
+ // The closing banner states what ACTUALLY works on this machine — a
940
+ // "successful" install whose global link failed must not advertise a
941
+ // command that will be `command not found`.
942
+ const linked = linkStatus === "linked";
969
943
  console.log("");
970
944
  success("Done! Agent Relay is installed.");
971
945
  console.log("");
@@ -973,7 +947,12 @@ async function main() {
973
947
  " Your agents will now search Agent Relay before web search or training knowledge.",
974
948
  );
975
949
  console.log("");
976
- console.log(` Try it: agentrelay search "weather api"`);
950
+ if (linked) {
951
+ console.log(` Try it: agentrelay search "weather api"`);
952
+ } else {
953
+ console.log(` Try it: npx agentic-relay search "weather api"`);
954
+ console.log(` (the global \`agentrelay\` command isn't set up — see the warning above)`);
955
+ }
977
956
  console.log(` API key: ${CREDENTIALS_PATH}`);
978
957
  console.log("");
979
958
  log(
package/bin/lib/cache.mjs CHANGED
@@ -69,24 +69,18 @@ export class Cache {
69
69
  return join(this.searchDir, `${h}.json`);
70
70
  }
71
71
 
72
- readSearch(query) {
73
- if (!this.enabled) return null;
74
- const entry = this._readJson(this._searchPath(query));
75
- if (!entry) return null;
76
- if (Date.now() > entry.expires) return null;
77
- return entry.capabilities || null;
78
- }
79
-
80
- // The buyer-side spend policy snapshot the search API returns alongside the
81
- // menu. Cached with the search so a cache hit still hands the agent a policy
82
- // to decide auto-pay-vs-bubble against (see SKILL.md Pay). Null on older
83
- // cache files written before this field existed.
84
- readSearchPaymentContext(query) {
72
+ /**
73
+ * Whole unexpired search entry — `{ts, capabilities, payment_context}` — or
74
+ * null. Returning the entry (not just capabilities) lets the caller surface
75
+ * how stale the cached payment_context snapshot is (see SKILL.md Pay).
76
+ */
77
+ readSearchEntry(query) {
85
78
  if (!this.enabled) return null;
86
79
  const entry = this._readJson(this._searchPath(query));
87
80
  if (!entry) return null;
88
81
  if (Date.now() > entry.expires) return null;
89
- return entry.payment_context || null;
82
+ if (!entry.capabilities) return null;
83
+ return entry;
90
84
  }
91
85
 
92
86
  writeSearch(query, capabilities, paymentContext = null) {
@@ -58,9 +58,14 @@ export async function cmdSearch(query, opts) {
58
58
  // The buyer-side spend policy snapshot the search API returns alongside the
59
59
  // menu — the agent decides auto-pay-vs-bubble against it (SKILL.md Pay). Pass
60
60
  // it through here so it survives to the CLI's JSON output and gets cached.
61
- let paymentContext = opts.refresh ? null : cache.readSearchPaymentContext(query);
61
+ // On a cache hit the snapshot can be up to the search TTL (1h) stale, so the
62
+ // payload carries from_cache/cached_at for the caller's pay decision.
63
+ const cachedEntry = opts.refresh ? null : cache.readSearchEntry(query);
64
+ let paymentContext = cachedEntry ? (cachedEntry.payment_context ?? null) : null;
65
+ let capabilities = cachedEntry ? cachedEntry.capabilities : null;
66
+ const fromCache = Boolean(cachedEntry);
67
+ const cachedAt = cachedEntry ? (cachedEntry.ts ?? null) : null;
62
68
 
63
- let capabilities = opts.refresh ? null : cache.readSearch(query);
64
69
  if (!capabilities) {
65
70
  const data = await apiSearch(query, {
66
71
  apiKey: opts.apiKey,
@@ -90,7 +95,14 @@ export async function cmdSearch(query, opts) {
90
95
  }
91
96
  capabilities = capabilities.slice(0, maxResults);
92
97
 
93
- return { query, count: capabilities.length, capabilities, payment_context: paymentContext };
98
+ return {
99
+ query,
100
+ count: capabilities.length,
101
+ from_cache: fromCache,
102
+ cached_at: cachedAt,
103
+ capabilities,
104
+ payment_context: paymentContext,
105
+ };
94
106
  }
95
107
 
96
108
  /**
@@ -183,17 +195,20 @@ export function shapeSessionTurn(r) {
183
195
 
184
196
  /**
185
197
  * Map a `delivery` object (from a paid session turn) to cmdFetch args. Tolerant of
186
- * minor key variants so it survives small upstream shape differences.
198
+ * minor key variants so it survives small upstream shape differences. The dest
199
+ * defaults to ./<delivery.slug>/ (slugified — a hostile slug can't traverse);
200
+ * an explicit dest always wins.
187
201
  */
188
202
  export function deliveryToFetchArgs(d, { dest = null } = {}) {
189
203
  if (!d || typeof d !== "object") return null;
190
204
  const url = d.download_url || d.url || null;
191
205
  if (!url) return null;
206
+ const slug = d.slug ? slugify(String(d.slug)) : null;
192
207
  return {
193
208
  url,
194
209
  checksum: d.checksum_sha256 || d.checksum || null,
195
210
  filename: d.filename || null,
196
- dest,
211
+ dest: dest || (slug ? `./${slug}` : null),
197
212
  };
198
213
  }
199
214
 
@@ -419,11 +434,33 @@ export function cmdCache(sub, slug, opts) {
419
434
  }
420
435
  deliverable.slug = deliverable.slug || slug;
421
436
  deliverable.ts = deliverable.ts || new Date().toISOString();
437
+ // Soft contract check (schema/deliverable.json spine). Warn-only: a spec
438
+ // with gaps is still worth banking, and concurrent breadth subagents must
439
+ // never be blocked by validation.
440
+ const warnings = [];
441
+ if (!deliverable.session_id || !String(deliverable.session_id).trim()) {
442
+ warnings.push("no session_id — build-outcome feedback can't be filed against this consult later");
443
+ }
444
+ if (!deliverable.summary || !String(deliverable.summary).trim()) {
445
+ warnings.push("no summary — `cache ls` triage will show a blank line for this spec");
446
+ }
447
+ if (!Array.isArray(deliverable.features) || deliverable.features.length === 0) {
448
+ warnings.push("no features[] — build-time has no surface map; depth sessions will have to rediscover it");
449
+ } else {
450
+ const missingStatus = deliverable.features.filter(
451
+ (f) => !f || (f.status !== "coded" && f.status !== "surface_only"),
452
+ ).length;
453
+ if (missingStatus > 0) {
454
+ warnings.push(
455
+ `${missingStatus} feature(s) missing status ("coded"|"surface_only") — build-time can't tell banked code from surface-only`,
456
+ );
457
+ }
458
+ }
422
459
  // Write ONLY the per-slug spec file — no shared index.json mutation — so N
423
460
  // breadth subagents can `cache put` concurrently without clobbering each
424
461
  // other. `cache ls` reads the spec files directly.
425
462
  cache.writeSpec(slug, deliverable);
426
- return { stored: true, slug, cache_dir: opts.cacheDir };
463
+ return { stored: true, slug, cache_dir: opts.cacheDir, warnings };
427
464
  }
428
465
  if (sub === "clear") {
429
466
  cache.clear();