clembot-doorman 0.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.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +951 -0
  4. package/WALKTHROUGH.md +224 -0
  5. package/doorman/.claude/hooks/mcp-gate.sh +205 -0
  6. package/doorman/.claude/settings.json +16 -0
  7. package/doorman/.claude-plugin/plugin.json +22 -0
  8. package/doorman/.mcp.json +24 -0
  9. package/doorman/README.md +259 -0
  10. package/doorman/agents/doorman.md +104 -0
  11. package/doorman/cli/agents.mjs +128 -0
  12. package/doorman/cli/allow.mjs +128 -0
  13. package/doorman/cli/cost.mjs +119 -0
  14. package/doorman/cli/discover.mjs +265 -0
  15. package/doorman/cli/doctor.mjs +282 -0
  16. package/doorman/cli/doorman.mjs +345 -0
  17. package/doorman/cli/eval.mjs +320 -0
  18. package/doorman/cli/harness.mjs +179 -0
  19. package/doorman/cli/install.mjs +175 -0
  20. package/doorman/cli/needs.mjs +116 -0
  21. package/doorman/cli/report.mjs +89 -0
  22. package/doorman/cli/sandbox.mjs +177 -0
  23. package/doorman/cli/task.mjs +239 -0
  24. package/doorman/cli/verdict.mjs +199 -0
  25. package/doorman/cli/watch.mjs +218 -0
  26. package/doorman/commands/doorman.md +116 -0
  27. package/doorman/commands/vet.md +69 -0
  28. package/doorman/hooks/hooks.json +30 -0
  29. package/doorman/install.sh +186 -0
  30. package/doorman/package.json +38 -0
  31. package/doorman/recipes/README.md +36 -0
  32. package/doorman/recipes/deepwiki.md +10 -0
  33. package/doorman/recipes/planted-bad.md +27 -0
  34. package/doorman/recipes/scorecard.md +10 -0
  35. package/doorman/registry/allowlist.json +37 -0
  36. package/doorman/registry/denylist.json +23 -0
  37. package/doorman/registry/ledger.jsonl +1 -0
  38. package/doorman/scripts/poller.mjs +292 -0
  39. package/doorman/scripts/resolve-cli.sh +58 -0
  40. package/doorman/scripts/vet.mjs +190 -0
  41. package/doorman/skills/doorman-guide/SKILL.md +69 -0
  42. package/doorman/src/budget.mjs +236 -0
  43. package/doorman/src/candidate.mjs +132 -0
  44. package/doorman/src/fit-review.mjs +255 -0
  45. package/doorman/src/injection.mjs +189 -0
  46. package/doorman/src/instructions.mjs +134 -0
  47. package/doorman/src/inventory.mjs +411 -0
  48. package/doorman/src/llm.mjs +87 -0
  49. package/doorman/src/needs.mjs +491 -0
  50. package/doorman/src/note.mjs +213 -0
  51. package/doorman/src/reviews.mjs +120 -0
  52. package/doorman/src/scorecard.mjs +123 -0
  53. package/doorman/src/vet.mjs +174 -0
  54. package/package.json +54 -0
@@ -0,0 +1,411 @@
1
+ /**
2
+ * What the fit review is allowed to know about your system.
3
+ *
4
+ * FRONTMATTER ONLY, and that is a hard constraint rather than an optimisation.
5
+ * Measured on this vault: reading agent, skill and command BODIES is 642 KB.
6
+ * Reading their frontmatter is about 30 KB. Only one of those fits in a single
7
+ * model call, so the shape of this module is decided by arithmetic.
8
+ *
9
+ * The consequence is worth stating plainly: a fit review reasons about what
10
+ * your agents and skills SAY THEY DO, not about what they actually do. That is
11
+ * the same class of claim this whole project exists to distrust in MCP servers.
12
+ * It is acceptable here only because the fit verdict is free, reversible, and
13
+ * gates nothing on its own: a human reads it and flips a status by hand.
14
+ *
15
+ * Nothing here makes a network request or writes a file.
16
+ */
17
+
18
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+
21
+ /** CLAUDE.md is 29 KB in this vault. Cap it and SAY that it was capped. */
22
+ export const CLAUDE_MD_CAP = 8000;
23
+
24
+ /**
25
+ * Find the project whose inventory we should read.
26
+ *
27
+ * This repo is the giveaway. The agents and skills a fit review reasons about
28
+ * live in whatever project someone dropped it into, which is why this walks up
29
+ * rather than resolving anything relative to itself.
30
+ */
31
+ /**
32
+ * Every file a project can declare an MCP server in.
33
+ *
34
+ * Kept beside the reader rather than inline so the list is one thing, and
35
+ * exported so `doctor` and this module can be asserted equal. They were not:
36
+ * doctor read five, this read one, and the gap showed up as a Cursor user
37
+ * being told to adopt servers they already ran.
38
+ */
39
+ export const MCP_CONFIG_SOURCES = [
40
+ '.mcp.json',
41
+ '.claude/settings.json',
42
+ '.claude/settings.local.json',
43
+ '.cursor/mcp.json',
44
+ '.vscode/mcp.json',
45
+ ];
46
+
47
+ /**
48
+ * Anything that marks a directory as an agent project.
49
+ *
50
+ * `.claude/agents` used to be the only one, which meant a Cursor project had NO
51
+ * root at all: the whole reader was skipped and the caller received an empty
52
+ * inventory that looked exactly like a project with nothing installed. `watch`
53
+ * then told that user to consider adopting two servers listed in their own
54
+ * `.cursor/mcp.json`.
55
+ */
56
+ export const SKILL_SOURCES = [
57
+ '.claude/skills',
58
+ '.agents/skills',
59
+ 'skills',
60
+ ];
61
+
62
+ export const ROOT_MARKERS = [
63
+ '.claude/agents',
64
+ '.claude/skills',
65
+ '.agents/skills',
66
+ '.claude/settings.json',
67
+ '.mcp.json',
68
+ '.cursor/mcp.json',
69
+ '.vscode/mcp.json',
70
+ 'CLAUDE.md',
71
+ 'AGENTS.md',
72
+ ];
73
+
74
+ export function findInventoryRoot(startDir, { env = process.env, fs = { existsSync } } = {}) {
75
+ const configured = env.DOORMAN_INVENTORY_ROOT;
76
+ if (configured) return resolve(configured);
77
+
78
+ let dir = resolve(startDir);
79
+ for (;;) {
80
+ for (const marker of ROOT_MARKERS) {
81
+ // join() with a '/'-containing marker is fine on both platforms.
82
+ if (fs.existsSync(join(dir, marker))) return dir;
83
+ }
84
+ const parent = dirname(dir);
85
+ if (parent === dir) return null; // hit the filesystem root
86
+ dir = parent;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * A deliberately small frontmatter reader.
92
+ *
93
+ * Handles `key: value` on one line, plus `>` and `|` block scalars, which is
94
+ * every shape the real rosters actually use. It does NOT handle nested maps or
95
+ * block lists, and it returns what it understood rather than throwing: a skill
96
+ * with an exotic header should cost us that one skill, not the whole inventory.
97
+ * Callers that need a field must check for it.
98
+ */
99
+ export function parseFrontmatter(text) {
100
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
101
+ if (!m) return null;
102
+ const out = {};
103
+ const lines = m[1].split(/\r?\n/);
104
+
105
+ for (let i = 0; i < lines.length; i++) {
106
+ const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(lines[i]);
107
+ if (!kv) continue; // indented, list item, or continuation
108
+ let value = kv[2].trim();
109
+
110
+ /* A block scalar: `key: >` or `key: |`, optionally chomped (`>-`, `|+`),
111
+ with the value on the following INDENTED lines.
112
+ Reading the marker as the value is not a harmless parse gap. People fold
113
+ precisely because a description is long, so this silently discarded the
114
+ most informative descriptions in the inventory and handed the fit review
115
+ the string ">" as though that were what the agent does. Four items in the
116
+ real Clembot roster, including three skills. */
117
+ const block = /^([>|])([-+]?)$/.exec(value);
118
+ if (block) {
119
+ const folded = block[1] === '>';
120
+ const body = [];
121
+ while (i + 1 < lines.length) {
122
+ const next = lines[i + 1];
123
+ if (next.trim() !== '' && !/^\s/.test(next)) break; // back to column 0
124
+ body.push(next.trim());
125
+ i++;
126
+ }
127
+ while (body.length && body[body.length - 1] === '') body.pop();
128
+ // Folded joins with spaces and treats a blank line as a paragraph break;
129
+ // literal keeps the line breaks.
130
+ value = folded
131
+ ? body.reduce((acc, ln) => (ln === '' ? acc + '\n' : (acc && !acc.endsWith('\n') ? acc + ' ' : acc) + ln), '')
132
+ : body.join('\n');
133
+ out[kv[1]] = value.trim();
134
+ continue;
135
+ }
136
+
137
+ if (
138
+ (value.startsWith('"') && value.endsWith('"')) ||
139
+ (value.startsWith("'") && value.endsWith("'"))
140
+ ) {
141
+ value = value.slice(1, -1);
142
+ }
143
+ out[kv[1]] = value;
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * Every MCP server an agent can actually reach, named the way the gate names it.
150
+ *
151
+ * A tool reads `mcp__<server>__<tool>`, so the server identity is sitting in the
152
+ * agent's own frontmatter. Collecting only `.mcp.json` missed it entirely: the
153
+ * real Clembot project has ZERO `.mcp.json` files and one agent holding fifteen
154
+ * `mcp__claude_ai_Canva__*` tools.
155
+ *
156
+ * That was not a cosmetic gap. `validateVerdict` builds its known-names set from
157
+ * this list, so a fit review correctly answering "you already have Canva" was
158
+ * rejected as an invented overlap and then failed loudly. A guard built against
159
+ * a fixture refusing a true statement about reality.
160
+ */
161
+ export function mcpServersFromTools(agents) {
162
+ const seen = new Map();
163
+ for (const a of agents) {
164
+ for (const t of a.tools ?? []) {
165
+ const m = /^mcp__([A-Za-z0-9_.-]+?)__/.exec(t);
166
+ if (!m) continue;
167
+ const name = m[1];
168
+ if (!seen.has(name)) seen.set(name, { name, url: null, heldBy: [], tools: 0, sources: ['agent tools'] });
169
+ const entry = seen.get(name);
170
+ entry.tools++;
171
+ if (!entry.heldBy.includes(a.name)) entry.heldBy.push(a.name);
172
+ }
173
+ }
174
+ return [...seen.values()];
175
+ }
176
+
177
+ /** `tools: Read, Grep, mcp__x__y` -> ['Read', 'Grep', 'mcp__x__y'] */
178
+ function splitList(value) {
179
+ if (!value) return [];
180
+ const trimmed = value.trim();
181
+ if (trimmed === '[]' || trimmed === '') return [];
182
+ return trimmed
183
+ .replace(/^\[|\]$/g, '')
184
+ .split(',')
185
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
186
+ .filter(Boolean);
187
+ }
188
+
189
+ function readDirSafe(dir) {
190
+ try {
191
+ return readdirSync(dir);
192
+ } catch {
193
+ return [];
194
+ }
195
+ }
196
+
197
+ function readFileSafe(path) {
198
+ try {
199
+ return readFileSync(path, 'utf8');
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Collect the inventory.
207
+ *
208
+ * `registryDir` is separate from `root` because the registry belongs to the
209
+ * doorman and the agents belong to the host project. In the giveaway those are
210
+ * different directories, and conflating them is how you end up reading an
211
+ * allowlist that is not the one the gate reads.
212
+ */
213
+ export function gatherInventory({ root, registryDir, claudeMdCap = CLAUDE_MD_CAP } = {}) {
214
+ const agents = [];
215
+ const skills = [];
216
+ const mcpServers = [];
217
+ const allowlisted = [];
218
+ const notes = [];
219
+
220
+ // What this reader could actually SEE here. `0 agents` in a Claude Code
221
+ // project is a fact; the same 0 in a Cursor project is an artefact of asking
222
+ // the wrong question, and a caller must be able to tell those apart.
223
+ const coverage = { agents: 'unknown', skills: 'unknown', mcpServers: 'unknown' };
224
+
225
+ if (!root) {
226
+ notes.push('no inventory root was found: agents and skills are UNKNOWN, not empty');
227
+ } else {
228
+ const hasAgentLayout = existsSync(join(root, '.claude', 'agents')) ||
229
+ existsSync(join(root, '.agents'));
230
+ const hasSkillLayout = SKILL_SOURCES.some((rel) => existsSync(join(root, rel)));
231
+ if (hasAgentLayout) coverage.agents = 'read';
232
+ if (hasSkillLayout) coverage.skills = 'read';
233
+ if (!hasAgentLayout && !hasSkillLayout) {
234
+ notes.push(
235
+ 'this project has no .claude/agents or known skills directory, so its subagents and ' +
236
+ 'skills are UNKNOWN rather than absent. Only the MCP config was read.',
237
+ );
238
+ }
239
+ const agentDir = join(root, '.claude', 'agents');
240
+ for (const file of readDirSafe(agentDir).filter((f) => f.endsWith('.md'))) {
241
+ const text = readFileSafe(join(agentDir, file));
242
+ const fm = text && parseFrontmatter(text);
243
+ if (!fm) {
244
+ notes.push(`agent file ${file} has no readable frontmatter and was skipped`);
245
+ continue;
246
+ }
247
+ agents.push({
248
+ name: fm.name ?? file.replace(/\.md$/, ''),
249
+ description: fm.description ?? '',
250
+ tools: splitList(fm.tools),
251
+ skills: splitList(fm.skills),
252
+ });
253
+ }
254
+
255
+ const seenSkills = new Set();
256
+ for (const rel of SKILL_SOURCES) {
257
+ const skillsDir = join(root, rel);
258
+ if (!existsSync(skillsDir)) continue;
259
+ for (const entry of readDirSafe(skillsDir)) {
260
+ const skillFile = join(skillsDir, entry, 'SKILL.md');
261
+ const text = readFileSafe(skillFile);
262
+ const fm = text && parseFrontmatter(text);
263
+ if (!fm) continue; // not every directory is a skill
264
+ const name = fm.name ?? entry;
265
+ if (!seenSkills.has(name)) {
266
+ seenSkills.add(name);
267
+ skills.push({ name, description: fm.description ?? '', source: rel });
268
+ }
269
+ }
270
+ }
271
+
272
+ /* Every place a server gets configured, not just the Claude Code one.
273
+ `doctor` has read all of these since it shipped; this did not, so `watch`
274
+ told a Cursor user that two servers listed in their own
275
+ `.cursor/mcp.json` were "new to this build". Both surfaces read the same
276
+ list now. `MCP_CONFIG_SOURCES` is exported so the drift is visible if the
277
+ two ever diverge again. */
278
+ for (const rel of MCP_CONFIG_SOURCES) {
279
+ const mcpText = readFileSafe(join(root, rel));
280
+ if (!mcpText) continue;
281
+ try {
282
+ const doc = JSON.parse(mcpText);
283
+ // `mcpServers` is the Claude/Cursor spelling, `servers` the VS Code one.
284
+ const declared = { ...(doc.mcpServers ?? {}), ...(doc.servers ?? {}) };
285
+ for (const [name, cfg] of Object.entries(declared)) {
286
+ const url = cfg?.url ?? cfg?.serverUrl ?? null;
287
+ const existing = mcpServers.find((m) => m.name === name);
288
+ if (existing) {
289
+ // Two files can declare the same server. Keep the first url found
290
+ // rather than letting a later file with no url erase it.
291
+ if (!existing.url && url) existing.url = url;
292
+ if (!existing.sources.includes(rel)) existing.sources.push(rel);
293
+ } else {
294
+ mcpServers.push({ name, url, heldBy: [], tools: 0, sources: [rel] });
295
+ }
296
+ }
297
+ coverage.mcpServers = 'read';
298
+ } catch {
299
+ notes.push(`${rel} exists but did not parse; configured servers are UNKNOWN`);
300
+ }
301
+ }
302
+
303
+ /* Union with what the agents actually hold. `.mcp.json` is one way a server
304
+ gets configured and it is not the only one. */
305
+ for (const derived of mcpServersFromTools(agents)) {
306
+ const existing = mcpServers.find((m) => m.name === derived.name);
307
+ if (existing) {
308
+ existing.heldBy = derived.heldBy;
309
+ existing.tools = derived.tools;
310
+ } else {
311
+ mcpServers.push(derived);
312
+ }
313
+ }
314
+ }
315
+
316
+ if (registryDir) {
317
+ const text = readFileSafe(join(registryDir, 'allowlist.json'));
318
+ if (text) {
319
+ try {
320
+ const doc = JSON.parse(text);
321
+ for (const [key, entry] of Object.entries(doc.servers ?? {})) {
322
+ allowlisted.push({ key, url: entry?.url ?? null, grade: entry?.grade ?? null });
323
+ }
324
+ } catch {
325
+ notes.push('allowlist.json did not parse; already-graded servers are UNKNOWN');
326
+ }
327
+ }
328
+ }
329
+
330
+ let claudeMd = '';
331
+ let claudeMdTruncated = false;
332
+ if (root) {
333
+ const text = readFileSafe(join(root, 'CLAUDE.md'));
334
+ if (text) {
335
+ claudeMd = text.slice(0, claudeMdCap);
336
+ claudeMdTruncated = text.length > claudeMdCap;
337
+ }
338
+ }
339
+
340
+ return {
341
+ root: root ?? null,
342
+ agents,
343
+ skills,
344
+ mcpServers,
345
+ allowlisted,
346
+ claudeMd,
347
+ claudeMdTruncated,
348
+ coverage,
349
+ notes,
350
+ };
351
+ }
352
+
353
+ /**
354
+ * Render the inventory as the context block the model sees.
355
+ *
356
+ * Deterministic ordering, because a fit verdict at temperature 0 should not
357
+ * change because a directory listing came back in a different order.
358
+ *
359
+ * Truncation is ANNOUNCED. A model told it is seeing a partial CLAUDE.md can
360
+ * hedge; a model shown a silently clipped one cannot.
361
+ */
362
+ export function renderInventory(inv) {
363
+ const lines = [];
364
+ const by = (a, b) => (a.name ?? a.key).localeCompare(b.name ?? b.key);
365
+
366
+ lines.push('# SUBAGENTS');
367
+ if (inv.agents.length === 0) lines.push('(none found)');
368
+ for (const a of [...inv.agents].sort(by)) {
369
+ lines.push(`- ${a.name}: ${a.description}`);
370
+ if (a.tools.length) lines.push(` tools: ${a.tools.join(', ')}`);
371
+ if (a.skills.length) lines.push(` skills: ${a.skills.join(', ')}`);
372
+ }
373
+
374
+ lines.push('', '# SKILLS');
375
+ if (inv.skills.length === 0) lines.push('(none found)');
376
+ for (const s of [...inv.skills].sort(by)) lines.push(`- ${s.name}: ${s.description}`);
377
+
378
+ lines.push('', '# MCP SERVERS ALREADY CONFIGURED');
379
+ if (inv.mcpServers.length === 0) lines.push('(none)');
380
+ for (const m of [...inv.mcpServers].sort(by)) {
381
+ const held = m.heldBy && m.heldBy.length ? ` (held by ${m.heldBy.join(', ')}, ${m.tools} tools)` : '';
382
+ lines.push(`- ${m.name}: ${m.url ?? 'no url'}${held}`);
383
+ }
384
+
385
+ lines.push('', '# MCP SERVERS ALREADY GRADED AND ALLOWLISTED');
386
+ if (inv.allowlisted.length === 0) lines.push('(none)');
387
+ for (const a of [...inv.allowlisted].sort((x, y) => x.key.localeCompare(y.key))) {
388
+ lines.push(`- ${a.key} (${a.grade ?? 'ungraded'}): ${a.url ?? 'no url'}`);
389
+ }
390
+
391
+ lines.push('', '# PROJECT CLAUDE.md');
392
+ lines.push(inv.claudeMd || '(not found)');
393
+ if (inv.claudeMdTruncated) {
394
+ lines.push('', `[TRUNCATED at ${CLAUDE_MD_CAP} bytes. You are seeing the beginning only.]`);
395
+ }
396
+
397
+ if (inv.notes.length) {
398
+ lines.push('', '# GAPS IN THIS INVENTORY');
399
+ for (const n of inv.notes) lines.push(`- ${n}`);
400
+ }
401
+
402
+ return lines.join('\n');
403
+ }
404
+
405
+ /** Convenience for callers that just want the inventory for a directory. */
406
+ export function inventoryFor(startDir, { registryDir, env = process.env } = {}) {
407
+ const root = findInventoryRoot(startDir, { env });
408
+ return gatherInventory({ root, registryDir });
409
+ }
410
+
411
+ export const __testing = { splitList, readDirSafe, statSync };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The only thing in the fit-review path allowed to touch the network.
3
+ *
4
+ * Everything else receives this object and can do nothing it does not expose,
5
+ * which is what lets every test run offline with a stub. Same seam the
6
+ * scorecard's probe runner uses, for the same reason.
7
+ *
8
+ * Pinned model, temperature 0, both from config rather than from a caller.
9
+ * A fit verdict is relative to the model that produced it exactly as a grade
10
+ * is, so letting a caller pass a different model per call would silently make
11
+ * two reviews incomparable.
12
+ */
13
+
14
+ export const DEFAULT_MODEL = 'claude-sonnet-5';
15
+ export const ANTHROPIC_VERSION = '2023-06-01';
16
+
17
+ export class MissingApiKeyError extends Error {}
18
+
19
+ /**
20
+ * @param {object} opts
21
+ * @param {string} [opts.apiKey] from ANTHROPIC_API_KEY
22
+ * @param {string} [opts.model] from DOORMAN_MODEL
23
+ * @param {Function} [opts.fetch] injectable for tests
24
+ */
25
+ export function anthropicClient({ apiKey, model = DEFAULT_MODEL, fetch: f = fetch, timeoutMs = 60_000 } = {}) {
26
+ if (!apiKey) {
27
+ // Loud, and at construction rather than at call time. A doorman that
28
+ // quietly skipped the free fit check and went straight to the paid grade
29
+ // would invert the entire feature.
30
+ throw new MissingApiKeyError(
31
+ 'ANTHROPIC_API_KEY is not set, so the fit review cannot run.\n' +
32
+ 'The fit review is the FREE check that decides whether the paid grade is ' +
33
+ 'worth running at all. Skipping it to reach the paid path is backwards, ' +
34
+ 'so this stops here instead.\n' +
35
+ 'Set ANTHROPIC_API_KEY, or use --dry-run to inspect the candidate and the ' +
36
+ 'inventory without running either phase.',
37
+ );
38
+ }
39
+
40
+ return {
41
+ model,
42
+ temperature: 0,
43
+
44
+ /**
45
+ * One turn. No tools, no streaming, no conversation: the fit review is a
46
+ * single question with a single JSON answer.
47
+ * @returns {Promise<{text: string, stop_reason: string, raw: unknown}>}
48
+ */
49
+ async complete({ system, user, max_tokens = 1024 }) {
50
+ const ac = new AbortController();
51
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
52
+ let res;
53
+ try {
54
+ res = await f('https://api.anthropic.com/v1/messages', {
55
+ method: 'POST',
56
+ headers: {
57
+ 'content-type': 'application/json',
58
+ 'x-api-key': apiKey,
59
+ 'anthropic-version': ANTHROPIC_VERSION,
60
+ },
61
+ body: JSON.stringify({
62
+ model,
63
+ max_tokens,
64
+ temperature: 0,
65
+ system,
66
+ messages: [{ role: 'user', content: user }],
67
+ }),
68
+ signal: ac.signal,
69
+ });
70
+ } finally {
71
+ clearTimeout(timer);
72
+ }
73
+
74
+ if (!res.ok) {
75
+ const body = await res.text().catch(() => '');
76
+ throw new Error(`Anthropic HTTP ${res.status}: ${body.slice(0, 300)}`);
77
+ }
78
+
79
+ const json = await res.json();
80
+ const text = (json.content ?? [])
81
+ .filter((b) => b.type === 'text')
82
+ .map((b) => b.text)
83
+ .join('');
84
+ return { text, stop_reason: json.stop_reason, raw: json };
85
+ },
86
+ };
87
+ }