agentic-relay 3.8.5 → 4.1.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,68 @@
1
+ /**
2
+ * Tests for installer argument parsing (bin/lib/install-args.mjs) — focused on
3
+ * the `--api-key= <key>` space-typo rescue observed in the field.
4
+ * Run: `node --test bin/test/install-args.test.mjs`.
5
+ */
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { parseInstallArgs } from "../lib/install-args.mjs";
9
+
10
+ const KEY = "am_live_abc123def456";
11
+
12
+ test("--api-key=KEY parses", () => {
13
+ const { flags, warnings } = parseInstallArgs([`--api-key=${KEY}`, "--all"]);
14
+ assert.equal(flags.apiKey, KEY);
15
+ assert.equal(flags.all, true);
16
+ assert.deepEqual(warnings, []);
17
+ });
18
+
19
+ test("--api-key KEY (space-separated) parses", () => {
20
+ const { flags, warnings } = parseInstallArgs(["--api-key", KEY]);
21
+ assert.equal(flags.apiKey, KEY);
22
+ assert.deepEqual(warnings, []);
23
+ });
24
+
25
+ test("--api-key= KEY (the space typo) rescues the bare key with a warning", () => {
26
+ const { flags, warnings } = parseInstallArgs(["--all", "--api-key=", KEY]);
27
+ assert.equal(flags.apiKey, KEY);
28
+ assert.equal(warnings.length, 1);
29
+ assert.match(warnings[0], /no space/);
30
+ });
31
+
32
+ test("bare am_live_* positional alone is rescued with a warning", () => {
33
+ const { flags, warnings } = parseInstallArgs([KEY, "--all"]);
34
+ assert.equal(flags.apiKey, KEY);
35
+ assert.equal(warnings.length, 1);
36
+ });
37
+
38
+ test("empty --api-key= with no rescue positional warns and stays null", () => {
39
+ const { flags, warnings } = parseInstallArgs(["--api-key="]);
40
+ assert.equal(flags.apiKey, "");
41
+ assert.equal(warnings.length, 1);
42
+ assert.match(warnings[0], /no value/);
43
+ });
44
+
45
+ test("non-key positionals are not adopted", () => {
46
+ const { flags, warnings } = parseInstallArgs(["hello", "--all"]);
47
+ assert.equal(flags.apiKey, null);
48
+ assert.deepEqual(warnings, []);
49
+ });
50
+
51
+ test("space-form value is not double-counted as a rescue positional", () => {
52
+ const { flags, warnings } = parseInstallArgs(["--api-key", KEY]);
53
+ assert.equal(flags.apiKey, KEY);
54
+ assert.deepEqual(warnings, []);
55
+ });
56
+
57
+ test("targets / librechat / legacy flags are unchanged", () => {
58
+ const { flags } = parseInstallArgs([
59
+ "--target=claude, codex",
60
+ "--librechat=/tmp/librechat.yaml",
61
+ "--claude",
62
+ "--project",
63
+ ]);
64
+ assert.deepEqual(flags.targets, ["claude", "codex"]);
65
+ assert.equal(flags.librechat, "/tmp/librechat.yaml");
66
+ assert.equal(flags.legacyClaude, true);
67
+ assert.equal(flags.project, true);
68
+ });
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { test } from "node:test";
6
6
  import assert from "node:assert/strict";
7
- import { lenientParse, extractJsonObject, extractFields } from "../lib/parse.mjs";
7
+ import { lenientParse } from "../lib/parse.mjs";
8
8
 
9
9
  test("lenientParse: clean JSON parses strictly", () => {
10
10
  const r = lenientParse('{"a":1,"b":"hi"}');
@@ -42,29 +42,3 @@ test("lenientParse: truly broken input fails gracefully (no throw)", () => {
42
42
  assert.equal(r.ok, false);
43
43
  assert.ok(r.error);
44
44
  });
45
-
46
- test("extractJsonObject: pulls a fenced ```json block out of prose", () => {
47
- const msg =
48
- "Sure! Here is your deliverable:\n```json\n{\"verdict\":\"use it\",\"install\":[\"npm i x\"]}\n```\nLet me know!";
49
- const obj = extractJsonObject(msg);
50
- assert.equal(obj.verdict, "use it");
51
- assert.deepEqual(obj.install, ["npm i x"]);
52
- });
53
-
54
- test("extractJsonObject: pulls a bare embedded object", () => {
55
- const msg = 'prefix {"a":1,"nested":{"b":2}} suffix';
56
- const obj = extractJsonObject(msg);
57
- assert.deepEqual(obj, { a: 1, nested: { b: 2 } });
58
- });
59
-
60
- test("extractJsonObject: returns null when there's no object", () => {
61
- assert.equal(extractJsonObject("just a question?"), null);
62
- });
63
-
64
- test("extractFields: last-resort flat string extraction", () => {
65
- const raw = '{"session_id":"abc-123","status":"active","message":"hi \\"there\\""}';
66
- const got = extractFields(raw, ["session_id", "status", "message"]);
67
- assert.equal(got.session_id, "abc-123");
68
- assert.equal(got.status, "active");
69
- assert.equal(got.message, 'hi "there"');
70
- });
@@ -65,8 +65,12 @@ test("deliveryToFetchArgs: maps a delivery object to cmdFetch args (tolerant of
65
65
  filename: "x.zip",
66
66
  slug: "gstack",
67
67
  }),
68
- { url: "https://store/x.zip", checksum: "abc123", filename: "x.zip", dest: null },
68
+ // dest defaults to ./<delivery.slug>/ (the documented contract); an
69
+ // explicit dest always wins.
70
+ { url: "https://store/x.zip", checksum: "abc123", filename: "x.zip", dest: "./gstack" },
69
71
  );
72
+ // a hostile slug is slugified — it can't traverse out of the working dir
73
+ assert.equal(deliveryToFetchArgs({ download_url: "https://s/x", slug: "../../etc" }).dest, "./etc");
70
74
  // variant keys + dest override
71
75
  assert.deepEqual(
72
76
  deliveryToFetchArgs({ url: "https://store/y", checksum: "def" }, { dest: "./out" }),
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Tests for the async update notifier (bin/lib/update-check.mjs) — the pure
3
+ * pieces: semver compare, state round-trip, suppression predicate.
4
+ * Run: `node --test bin/test/update-check.test.mjs`.
5
+ */
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join, sep } from "node:path";
11
+ import { compareSemver, shouldSuppress, readState, writeState } from "../lib/update-check.mjs";
12
+
13
+ test("compareSemver orders numerically, not lexically", () => {
14
+ assert.equal(compareSemver("1.2.3", "1.2.3"), 0);
15
+ assert.equal(compareSemver("1.10.0", "1.2.0"), 1); // lexical would say 1.10 < 1.2
16
+ assert.equal(compareSemver("4.0.0", "3.9.9"), 1);
17
+ assert.equal(compareSemver("3.8.5", "4.0.0"), -1);
18
+ assert.equal(compareSemver("1.2", "1.2.0"), 0);
19
+ });
20
+
21
+ test("state round-trips through a custom path", () => {
22
+ const root = mkdtempSync(join(tmpdir(), "agent-relay-update-"));
23
+ try {
24
+ const p = join(root, "nested", "update-check.json");
25
+ const state = { last_check_ts: "2026-06-11T00:00:00.000Z", latest_version: "9.9.9" };
26
+ assert.equal(writeState(state, p), true);
27
+ assert.deepEqual(readState(p), state);
28
+ } finally {
29
+ rmSync(root, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ test("readState returns null for missing/corrupt files", () => {
34
+ assert.equal(readState("/nonexistent/path/update.json"), null);
35
+ });
36
+
37
+ const TTY_BASE = { env: {}, stderrIsTTY: true, binPath: "/usr/local/bin/agentrelay" };
38
+
39
+ test("suppression: env kill-switches", () => {
40
+ assert.equal(shouldSuppress({ ...TTY_BASE, env: { AGENT_RELAY_NO_UPDATE_CHECK: "1" } }), true);
41
+ assert.equal(shouldSuppress({ ...TTY_BASE, env: { NO_UPDATE_NOTIFIER: "1" } }), true);
42
+ assert.equal(shouldSuppress({ ...TTY_BASE, env: { CI: "true" } }), true);
43
+ });
44
+
45
+ test("suppression: non-TTY stderr (piped/scripted runs)", () => {
46
+ assert.equal(shouldSuppress({ ...TTY_BASE, stderrIsTTY: false }), true);
47
+ });
48
+
49
+ test("suppression: running from an npx cache", () => {
50
+ const npxPath = ["", "Users", "x", ".npm", "_npx", "abc123", "node_modules", ".bin", "agentrelay"].join(sep);
51
+ assert.equal(shouldSuppress({ ...TTY_BASE, binPath: npxPath }), true);
52
+ });
53
+
54
+ test("not suppressed: interactive TTY run from a real install", () => {
55
+ assert.equal(shouldSuppress(TTY_BASE), false);
56
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Tests for the pure-JS zip extractor (bin/lib/zip.mjs). Fixture zips are
3
+ * hand-assembled in memory (local headers + central directory + EOCD) so we
4
+ * can exercise shapes the system `zip` tool won't produce — data descriptors,
5
+ * zip64 markers, encrypted flags. Run: `node --test bin/test/zip.test.mjs`.
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, rmSync, readFileSync, existsSync, lstatSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { deflateRawSync } from "node:zlib";
13
+ import {
14
+ findEndOfCentralDirectory,
15
+ listZipEntries,
16
+ readEntryData,
17
+ extractZip,
18
+ } from "../lib/zip.mjs";
19
+
20
+ /**
21
+ * Assemble a zip from entries: [{name, data, method?, dataDescriptor?, flags?}].
22
+ * method: 0 store (default), 8 deflate. dataDescriptor: local sizes zeroed,
23
+ * flag bit 3 set, 16-byte descriptor appended after the data (the shape
24
+ * streaming writers produce — central directory holds the truth).
25
+ */
26
+ function buildZip(entries, { zip64Eocd = false } = {}) {
27
+ const parts = [];
28
+ const central = [];
29
+ let offset = 0;
30
+
31
+ for (const e of entries) {
32
+ const name = Buffer.from(e.name, "utf-8");
33
+ const raw = Buffer.from(e.data ?? "", "utf-8");
34
+ const method = e.method ?? 0;
35
+ const payload = method === 8 ? deflateRawSync(raw) : raw;
36
+ const useDD = Boolean(e.dataDescriptor);
37
+ const flags = (e.flags ?? 0) | (useDD ? 0x8 : 0);
38
+
39
+ const local = Buffer.alloc(30);
40
+ local.writeUInt32LE(0x04034b50, 0);
41
+ local.writeUInt16LE(20, 4); // version needed
42
+ local.writeUInt16LE(flags, 6);
43
+ local.writeUInt16LE(method, 8);
44
+ local.writeUInt16LE(0, 10); // time
45
+ local.writeUInt16LE(0, 12); // date
46
+ local.writeUInt32LE(0, 14); // crc (not verified by extractor)
47
+ local.writeUInt32LE(useDD ? 0 : payload.length, 18);
48
+ local.writeUInt32LE(useDD ? 0 : raw.length, 22);
49
+ local.writeUInt16LE(name.length, 26);
50
+ local.writeUInt16LE(0, 28); // extra len
51
+
52
+ const localOffset = offset;
53
+ parts.push(local, name, payload);
54
+ offset += local.length + name.length + payload.length;
55
+
56
+ if (useDD) {
57
+ const dd = Buffer.alloc(16);
58
+ dd.writeUInt32LE(0x08074b50, 0); // optional descriptor signature
59
+ dd.writeUInt32LE(0, 4); // crc
60
+ dd.writeUInt32LE(payload.length, 8);
61
+ dd.writeUInt32LE(raw.length, 12);
62
+ parts.push(dd);
63
+ offset += dd.length;
64
+ }
65
+
66
+ const cen = Buffer.alloc(46);
67
+ cen.writeUInt32LE(0x02014b50, 0);
68
+ cen.writeUInt16LE(20, 4); // version made by
69
+ cen.writeUInt16LE(20, 6); // version needed
70
+ cen.writeUInt16LE(flags, 8);
71
+ cen.writeUInt16LE(method, 10);
72
+ cen.writeUInt16LE(0, 12); // time
73
+ cen.writeUInt16LE(0, 14); // date
74
+ cen.writeUInt32LE(0, 16); // crc
75
+ cen.writeUInt32LE(payload.length, 20); // TRUE sizes live here
76
+ cen.writeUInt32LE(raw.length, 24);
77
+ cen.writeUInt16LE(name.length, 28);
78
+ cen.writeUInt16LE(0, 30); // extra
79
+ cen.writeUInt16LE(0, 32); // comment
80
+ cen.writeUInt16LE(0, 34); // disk start
81
+ cen.writeUInt16LE(0, 36); // internal attrs
82
+ cen.writeUInt32LE(e.externalAttrs ?? 0, 38);
83
+ cen.writeUInt32LE(localOffset, 42);
84
+ central.push(Buffer.concat([cen, name]));
85
+ }
86
+
87
+ const cd = Buffer.concat(central);
88
+ const cdOffset = offset;
89
+ const eocd = Buffer.alloc(22);
90
+ eocd.writeUInt32LE(0x06054b50, 0);
91
+ eocd.writeUInt16LE(0, 4); // disk
92
+ eocd.writeUInt16LE(0, 6); // cd disk
93
+ eocd.writeUInt16LE(zip64Eocd ? 0xffff : entries.length, 8);
94
+ eocd.writeUInt16LE(zip64Eocd ? 0xffff : entries.length, 10);
95
+ eocd.writeUInt32LE(cd.length, 12);
96
+ eocd.writeUInt32LE(zip64Eocd ? 0xffffffff : cdOffset, 16);
97
+ eocd.writeUInt16LE(0, 20); // comment len
98
+ return Buffer.concat([...parts, cd, eocd]);
99
+ }
100
+
101
+ function tmpDest(prefix) {
102
+ return mkdtempSync(join(tmpdir(), prefix));
103
+ }
104
+
105
+ test("stored entry round-trips", () => {
106
+ const buf = buildZip([{ name: "a.txt", data: "hello stored" }]);
107
+ const root = tmpDest("zip-stored-");
108
+ try {
109
+ const placed = extractZip(buf, join(root, "out"));
110
+ assert.equal(placed.length, 1);
111
+ assert.equal(readFileSync(placed[0], "utf-8"), "hello stored");
112
+ } finally {
113
+ rmSync(root, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ test("deflated entry round-trips", () => {
118
+ const text = "deflate me ".repeat(100);
119
+ const buf = buildZip([{ name: "b.txt", data: text, method: 8 }]);
120
+ const root = tmpDest("zip-deflate-");
121
+ try {
122
+ const placed = extractZip(buf, join(root, "out"));
123
+ assert.equal(readFileSync(placed[0], "utf-8"), text);
124
+ } finally {
125
+ rmSync(root, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test("data-descriptor zip (local sizes zeroed) extracts via central directory", () => {
130
+ const text = "streamed entry with data descriptor";
131
+ const buf = buildZip([{ name: "dd.txt", data: text, method: 8, dataDescriptor: true }]);
132
+ // Sanity: the local header really does carry zero sizes.
133
+ assert.equal(buf.readUInt32LE(18), 0);
134
+ const entries = listZipEntries(buf);
135
+ assert.equal(entries[0].compressedSize > 0, true);
136
+ const root = tmpDest("zip-dd-");
137
+ try {
138
+ const placed = extractZip(buf, join(root, "out"));
139
+ assert.equal(readFileSync(placed[0], "utf-8"), text);
140
+ } finally {
141
+ rmSync(root, { recursive: true, force: true });
142
+ }
143
+ });
144
+
145
+ test("nested directories are created; dir entries write no files", () => {
146
+ const buf = buildZip([
147
+ { name: "pkg/", data: "" },
148
+ { name: "pkg/deep/file.md", data: "# nested" },
149
+ ]);
150
+ const root = tmpDest("zip-nest-");
151
+ try {
152
+ const out = join(root, "out");
153
+ const placed = extractZip(buf, out);
154
+ assert.equal(placed.length, 1);
155
+ assert.ok(existsSync(join(out, "pkg", "deep", "file.md")));
156
+ } finally {
157
+ rmSync(root, { recursive: true, force: true });
158
+ }
159
+ });
160
+
161
+ test("zip-slip entry is rejected before any write", () => {
162
+ const buf = buildZip([
163
+ { name: "ok.txt", data: "fine" },
164
+ { name: "../evil.sh", data: "rm -rf" },
165
+ ]);
166
+ const root = tmpDest("zip-slip-");
167
+ try {
168
+ const out = join(root, "out");
169
+ assert.throws(() => extractZip(buf, out), /zip-slip|traversal/);
170
+ assert.equal(existsSync(join(out, "ok.txt")), false); // nothing written
171
+ } finally {
172
+ rmSync(root, { recursive: true, force: true });
173
+ }
174
+ });
175
+
176
+ test("zip64 EOCD markers are rejected with a clear error", () => {
177
+ const buf = buildZip([{ name: "x.txt", data: "x" }], { zip64Eocd: true });
178
+ assert.throws(() => findEndOfCentralDirectory(buf), /zip64/);
179
+ });
180
+
181
+ test("encrypted entries are rejected with a clear error", () => {
182
+ const buf = buildZip([{ name: "secret.txt", data: "x", flags: 0x1 }]);
183
+ assert.throws(() => listZipEntries(buf), /encrypted/);
184
+ });
185
+
186
+ test("unsupported compression method is rejected", () => {
187
+ const buf = buildZip([{ name: "lzma.bin", data: "x", method: 14 }]);
188
+ const entries = listZipEntries(buf);
189
+ assert.throws(() => readEntryData(buf, entries[0]), /unsupported zip compression method 14/);
190
+ });
191
+
192
+ test("symlink-flagged entry is materialized as a regular file", () => {
193
+ const S_IFLNK = 0o120000;
194
+ const buf = buildZip([
195
+ { name: "link", data: "/etc/passwd", externalAttrs: ((S_IFLNK | 0o777) << 16) >>> 0 },
196
+ ]);
197
+ const root = tmpDest("zip-symlink-");
198
+ try {
199
+ const out = join(root, "out");
200
+ extractZip(buf, out);
201
+ const st = lstatSync(join(out, "link"));
202
+ assert.equal(st.isSymbolicLink(), false);
203
+ assert.equal(st.isFile(), true);
204
+ assert.equal(readFileSync(join(out, "link"), "utf-8"), "/etc/passwd");
205
+ } finally {
206
+ rmSync(root, { recursive: true, force: true });
207
+ }
208
+ });
209
+
210
+ test("garbage buffer fails with a clear error", () => {
211
+ assert.throws(() => listZipEntries(Buffer.from("definitely not a zip file")), /end-of-central-directory/);
212
+ });
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "agentic-relay",
3
- "version": "3.8.5",
4
- "description": "Install Agent Relay AI search across 17+ agents — Claude Code, Codex, Cursor, Gemini CLI, Goose, Windsurf, Cline, BoltAI, Claude Desktop, VS Code, Amazon Q, Roo Code, Witsy, LibreChat, OpenClaw, Tome, Raycast — plus the `agentrelay` CLI for token-cheap parallel agent consulting",
3
+ "version": "4.1.0",
4
+ "description": "Install Agent Relay AI search across 17+ agents — Claude Code, Codex, Cursor, Gemini CLI, Goose, Windsurf, Cline, BoltAI, Claude Desktop, VS Code, Amazon Q, Roo Code, Witsy, LibreChat, OpenClaw, Tome, Raycast — plus the `agentrelay` CLI for live agent sessions, payments, and deliverable fetch",
5
5
  "bin": {
6
6
  "agentrelay": "bin/cli.mjs"
7
7
  },
8
8
  "scripts": {
9
- "test": "node --test \"bin/test/parse.test.mjs\" \"bin/test/driver.test.mjs\" \"bin/test/session.test.mjs\" \"bin/test/cache.test.mjs\" \"bin/test/budget.test.mjs\" \"bin/test/fetch.test.mjs\""
9
+ "test": "node --test bin/test/*.test.mjs"
10
10
  },
11
11
  "keywords": [
12
12
  "agent-relay",
package/skill/SKILL.md CHANGED
@@ -13,7 +13,7 @@ Agent Relay is a marketplace of **capability agents**: each is a live solutions
13
13
  - Whenever you're **about to choose** a library/API/service, or **stuck integrating** one.
14
14
  - **Before** web-searching or answering from your own knowledge about a tool — these agents are current and hand you working code, not approximate recollection.
15
15
 
16
- Skip it only for genuinely self-contained work with no external dependency (a small algorithm or one-off script), or a question answerable without integration. For a trivial lookup ("find me X", "how do I install X"), one inline consult is enough — skip the breadth→subagent→cache ceremony, but still settle any payment by the rules.
16
+ Skip it only for genuinely self-contained work with no external dependency (a small algorithm or one-off script), or a question answerable without integration. For a trivial lookup ("find me X", "how do I install X"), a single inline session (`session start` → a send or two → `session end`) is enough — skip the breadth→subagent→cache ceremony, but still settle any payment by the rules.
17
17
 
18
18
  ## How to call it
19
19
 
@@ -44,7 +44,7 @@ This loop keeps raw transcripts out of your main context (only compact summaries
44
44
  3. **After search, use judgment on whether to pause.** When the choice of agents is consequential or ambiguous — several plausible tools, paid capabilities in the mix, an architecture fork — surface the shortlist to the user and suggest which to consult (bias toward broad coverage to maximize breadth), then proceed on their pick. When the picks are obvious, just start breadth autonomously. There's no mandatory gate; the cost of a wrong-but-cheap consult is low, so don't over-ask.
45
45
  4. **Spawn one subagent per chosen agent** (Claude Code, Codex, OpenClaw, Cursor all support subagents). Run them concurrently and scaffold the project while they work. Each subagent follows the brief below.
46
46
 
47
- > No subagents on your host? Run consults inline one at a time, or use the `agentrelay consult <slug> --want "<…>"` fallback (generic, code-driven). Note: the fallback currently returns a different spec shape than the contract below — prefer real subagent sessions, and if you must use it, don't bank its output as a contract-shaped spec.
47
+ > No subagents on your host? Drive the sessions inline yourself, one at a time, using the same brief, and bank each spec with `agentrelay cache put <slug>` the same way.
48
48
 
49
49
  #### Subagent brief (hand each breadth subagent this)
50
50
 
@@ -229,6 +229,7 @@ Score rubric: a confidently-wrong claim (a 404'd endpoint served as live, a fals
229
229
  ## Known gotchas (seeded from real runs)
230
230
 
231
231
  - **Don't truncate `--json` search output.** Piping `agentrelay search … --json | head -N` cuts off `payment_context`, which sits after the (long) capabilities array — so the agent never sees the live pay policy and falls back to a wrong default. Read the whole JSON.
232
+ - **A cached search's `payment_context` is a snapshot, not live.** When the search result has `from_cache: true`, its `payment_context` dates from `cached_at` (search cache TTL is 1h). For an actual pay decision, read `agentrelay budget` or re-run the search with `--refresh`.
232
233
  - **Never assert pay posture from memory.** Run `agentrelay budget` or read `payment_context` before any statement about whether autonomous pay is on. (A test agent claimed "off by default" while the live setting was on with a $5 auto-approve.)
233
234
  - **`deliverable_complete` is not a stop command.** Continue the session if you still need something it didn't include.
234
235
  - **Don't reconstruct the workflow from CLAUDE.md.** It's a thin map; this skill is the manual. If you're doing Agent Relay work, you should have loaded this file.
@@ -1,70 +0,0 @@
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
- }
@@ -1,165 +0,0 @@
1
- /**
2
- * Conversation driver (spec §6) — runs ONE agent's full qualify→deliver loop in
3
- * isolation and returns a compact Deliverable. The agent's raw transcript never
4
- * leaves this module (only the distilled Deliverable does), which is the whole
5
- * point: keep transcripts out of the orchestrator's context.
6
- *
7
- * Completion detection prefers the backend flags (spec §10:
8
- * `deliverable_complete` / `awaiting_input`) and falls back to a prose heuristic
9
- * when an older backend doesn't send them.
10
- */
11
-
12
- import { sessionStart, sessionMessage, sessionEnd } from "./api.mjs";
13
- import { extractJsonObject } from "./parse.mjs";
14
- import { normalizeDeliverable, hasSubstance } from "./deliverable.mjs";
15
-
16
- const OFFER_PHRASES = [
17
- "reply with",
18
- "let me know",
19
- "want me to",
20
- "which would you like",
21
- "tell me",
22
- "could you",
23
- "can you provide",
24
- "do you want",
25
- "would you like",
26
- "please share",
27
- "please provide",
28
- "to confirm",
29
- ];
30
-
31
- /** Does this turn end expecting the user to respond before the agent proceeds? */
32
- export function turnAwaitsInput(turn) {
33
- if (turn.deliverable_complete === true) return false; // explicit: done
34
- if (turn.awaiting_input === true) return true; // explicit: waiting
35
- if (Array.isArray(turn.pending_fields) && turn.pending_fields.length > 0) return true;
36
- // Heuristic fallback on prose (spec §6 step 3).
37
- const msg = String(turn.message || "").trim().toLowerCase();
38
- if (msg.endsWith("?")) return true;
39
- return OFFER_PHRASES.some((p) => msg.includes(p));
40
- }
41
-
42
- /**
43
- * Decide whether the loop can stop. `hasDeliverable` = we've already parsed a
44
- * substantive Deliverable from this (or a prior) turn. Pure + unit-tested.
45
- */
46
- export function assessTurn(turn, hasDeliverable) {
47
- if (turn.deliverable_complete === true) {
48
- return { complete: true, reason: "flag:deliverable_complete" };
49
- }
50
- const awaiting = turnAwaitsInput(turn);
51
- if (!awaiting && hasDeliverable) {
52
- return { complete: true, reason: "deliverable+not-awaiting" };
53
- }
54
- return { complete: false, reason: awaiting ? "awaiting-input" : "no-deliverable" };
55
- }
56
-
57
- const SCHEMA_LINE =
58
- "{verdict, install:[], env:[{name,required,desc}], endpoints:[{name,method,url,notes}], " +
59
- "snippets:[{lang,path,desc,code}], gotchas:[], free_tier, docs:[]}";
60
-
61
- function buildPrimaryMessage(want, context, deliverNow) {
62
- const ctx = context ? JSON.stringify(context) : "(none provided)";
63
- return (
64
- "You are replying to an automated coding agent, NOT a human — your reply is parsed by a program. " +
65
- `Deliverable needed: ${want}. ` +
66
- `Stack/context: ${ctx}. ` +
67
- "Reply with ONLY one JSON object — no prose, no markdown, no ``` fences — matching this schema: " +
68
- SCHEMA_LINE +
69
- ". Hard rules: keep the ENTIRE reply under ~450 words; each `snippets[].code` ≤ 25 lines " +
70
- "(reference the docs URL instead of pasting full multi-file implementations); use real newlines in " +
71
- "code, never literal \\n; assume sensible defaults for the stack and do NOT ask questions." +
72
- (deliverNow ? " Deliver the complete JSON now." : "")
73
- );
74
- }
75
-
76
- function buildFollowup() {
77
- return (
78
- "Return the complete deliverable now as ONE JSON object only — no prose, no markdown, no fences — " +
79
- "under ~450 words, each snippet ≤ 25 lines. Assume sensible defaults; do not ask anything further."
80
- );
81
- }
82
-
83
- /** Trim a prose message to a short excerpt for --raw / fallback verdict. */
84
- function excerpt(msg, max = 1200) {
85
- const s = String(msg || "").trim();
86
- return s.length > max ? s.slice(0, max) + "…" : s;
87
- }
88
-
89
- /**
90
- * Run the full loop for one capability.
91
- *
92
- * @param resolved { capabilityId, adUnitId, clearingPriceCents, slug, name }
93
- * @param opts { want, context, deliverNow, raw, maxTurns, timeoutMs, apiKey }
94
- * @returns { deliverable, sessionId, turns }
95
- */
96
- export async function consultOne(resolved, opts) {
97
- const { capabilityId, adUnitId, clearingPriceCents, slug, name } = resolved;
98
- const { want, context, deliverNow, raw, maxTurns = 6, timeoutMs, apiKey } = opts;
99
- const meta = { slug, name, capability_id: capabilityId, ad_unit_id: adUnitId };
100
-
101
- let turns = 0;
102
- let sessionId = null;
103
- let bestDeliverable = null;
104
- let lastMessage = "";
105
-
106
- const consider = (turn) => {
107
- const obj = extractJsonObject(turn.message);
108
- if (obj) {
109
- const d = normalizeDeliverable(obj, { ...meta, turns });
110
- if (hasSubstance(d)) bestDeliverable = d;
111
- }
112
- };
113
-
114
- // 1. Start the session (context as initial_data so the greeting is relevant).
115
- const start = await sessionStart(
116
- {
117
- capabilityId,
118
- adUnitId,
119
- clearingPriceCents,
120
- initialData: context ? { ...context, task: want } : { task: want },
121
- },
122
- { apiKey, timeoutMs },
123
- );
124
- sessionId = start.session_id;
125
- turns = 1;
126
- lastMessage = start.message || "";
127
- consider(start);
128
-
129
- // 2. Drive: send the want + output contract, then loop until complete.
130
- let nextMessage = buildPrimaryMessage(want, context, deliverNow);
131
- try {
132
- while (turns < maxTurns) {
133
- const resp = await sessionMessage({ sessionId, message: nextMessage }, { apiKey, timeoutMs });
134
- turns += 1;
135
- lastMessage = resp.message || lastMessage;
136
- consider(resp);
137
-
138
- const { complete } = assessTurn(resp, Boolean(bestDeliverable));
139
- if (complete) break;
140
- nextMessage = buildFollowup();
141
- }
142
- } finally {
143
- // 3. Always release the session (best-effort; spec §6 step 6).
144
- if (sessionId) {
145
- try {
146
- await sessionEnd({ sessionId }, { apiKey, timeoutMs });
147
- } catch {
148
- /* non-fatal */
149
- }
150
- }
151
- }
152
-
153
- // 4. Finalize. If no structured deliverable surfaced, fall back to a prose
154
- // excerpt as the verdict (and raw_excerpt when --raw).
155
- if (!bestDeliverable) {
156
- bestDeliverable = normalizeDeliverable(
157
- { verdict: excerpt(lastMessage, 400) },
158
- { ...meta, turns, raw_excerpt: raw ? excerpt(lastMessage) : null },
159
- );
160
- } else if (raw) {
161
- bestDeliverable.raw_excerpt = excerpt(lastMessage);
162
- }
163
-
164
- return { deliverable: bestDeliverable, sessionId, turns };
165
- }