blume 1.1.3 → 1.2.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.
- package/CHANGELOG.md +54 -0
- package/README.md +1 -1
- package/dist/cli/index.js +1473 -149
- package/dist/cli/index.js.map +47 -36
- package/dist/types/core/config-input.d.ts +18 -0
- package/dist/types/core/config.d.ts +4 -0
- package/dist/types/core/data.d.ts +3 -0
- package/dist/types/core/schema.d.ts +132 -17
- package/dist/types/core/types.d.ts +5 -3
- package/dist/types/openapi/references.d.ts +6 -0
- package/docs/advanced/api-reference.mdx +27 -0
- package/docs/advanced/changelog.mdx +10 -0
- package/docs/configuration/ai.mdx +38 -2
- package/docs/configuration/customization.mdx +27 -0
- package/docs/configuration/index.mdx +5 -0
- package/docs/content/navigation.mdx +12 -0
- package/docs/reference/cli.mdx +17 -13
- package/docs/reference/eval.mdx +106 -0
- package/docs/reference/meta.ts +1 -1
- package/package.json +1 -1
- package/src/ai/agent-readability.ts +19 -1
- package/src/ai/llms.ts +9 -4
- package/src/ai/mcp/server.ts +48 -14
- package/src/ai/mcp/stdio.ts +35 -0
- package/src/astro/generate.ts +119 -48
- package/src/astro/templates.ts +173 -37
- package/src/audit/checks/duplicates.ts +15 -6
- package/src/audit/checks/indexability.ts +11 -2
- package/src/audit/checks/network.ts +22 -8
- package/src/audit/checks/sitemap.ts +42 -16
- package/src/audit/redirects.ts +12 -1
- package/src/audit/run.ts +13 -3
- package/src/audit/url.ts +21 -2
- package/src/cli/commands/audit.ts +21 -6
- package/src/cli/commands/dev.ts +19 -2
- package/src/cli/commands/eval.ts +291 -0
- package/src/cli/commands/init.ts +9 -4
- package/src/cli/commands/mcp-stdio.ts +36 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/required-secrets.ts +1 -1
- package/src/components/content/AccordionItem.astro +2 -2
- package/src/components/content/Frame.astro +4 -1
- package/src/components/content/Prompt.astro +4 -1
- package/src/components/content/Tooltip.astro +4 -1
- package/src/components/content/TreeFolder.astro +1 -2
- package/src/components/content/Update.astro +45 -0
- package/src/components/islands/AskAI.astro +9 -2
- package/src/components/islands/ask-ai.tsx +23 -4
- package/src/components/islands/hooks.ts +48 -15
- package/src/components/layout/NavTree.astro +37 -19
- package/src/components/layout/ReferenceLayout.astro +4 -0
- package/src/components/layout/RootLayout.astro +14 -3
- package/src/components/layout/Search.astro +5 -1
- package/src/components/layout/head-scripts.ts +22 -5
- package/src/components/openapi/SchemaProperty.astro +3 -3
- package/src/core/config-input.ts +18 -0
- package/src/core/config.ts +4 -0
- package/src/core/data.ts +3 -0
- package/src/core/deployment-env.ts +7 -2
- package/src/core/graph.ts +8 -1
- package/src/core/i18n.ts +10 -2
- package/src/core/navigation.ts +16 -5
- package/src/core/schema.ts +51 -4
- package/src/core/server-features.ts +1 -1
- package/src/core/sources/normalize.ts +69 -8
- package/src/core/sources/notion.ts +4 -2
- package/src/core/sources/sanity.ts +5 -3
- package/src/core/types.ts +5 -3
- package/src/eval/agents.ts +340 -0
- package/src/eval/findings.ts +103 -0
- package/src/eval/prompts.ts +78 -0
- package/src/eval/report.ts +214 -0
- package/src/eval/run.ts +290 -0
- package/src/eval/schema.ts +124 -0
- package/src/markdown/code-title.ts +7 -1
- package/src/openapi/model.ts +31 -2
- package/src/openapi/references.ts +23 -2
- package/src/openapi/render-mdx.ts +39 -11
- package/src/openapi/scalar.ts +1 -0
- package/src/openapi/source.ts +11 -4
- package/src/registry/eject.ts +23 -1
- package/src/search/build.ts +4 -3
package/src/core/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AstroIntegration } from "astro";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
|
|
3
4
|
import type { ComponentMarkdown } from "../ai/component-markdown.ts";
|
|
@@ -78,6 +79,11 @@ const searchMetaSchema = z.strictObject({
|
|
|
78
79
|
tags: z.array(z.string()).optional(),
|
|
79
80
|
});
|
|
80
81
|
|
|
82
|
+
const aiMetaSchema = z.strictObject({
|
|
83
|
+
/** Exclude this page from llms.txt and llms-full.txt. */
|
|
84
|
+
exclude: z.boolean().default(false),
|
|
85
|
+
});
|
|
86
|
+
|
|
81
87
|
const changelogMetaSchema = z.strictObject({
|
|
82
88
|
category: z.string().optional(),
|
|
83
89
|
date: dateSchema.optional(),
|
|
@@ -105,6 +111,7 @@ const authorSchema = z.union([
|
|
|
105
111
|
|
|
106
112
|
/** Frontmatter accepted on any content page. */
|
|
107
113
|
const pageMetaBaseSchema = z.strictObject({
|
|
114
|
+
ai: aiMetaSchema.default({}),
|
|
108
115
|
/** Post author(s) for blog/changelog content; preserved, not yet rendered. */
|
|
109
116
|
authors: z.union([authorSchema, z.array(authorSchema)]).optional(),
|
|
110
117
|
changelog: changelogMetaSchema.optional(),
|
|
@@ -351,6 +358,10 @@ const contentConfigSchema = z.strictObject({
|
|
|
351
358
|
});
|
|
352
359
|
|
|
353
360
|
const navTabSchema = z.strictObject({
|
|
361
|
+
// Rejected empty rather than accepted: an empty `href` would render a link to
|
|
362
|
+
// nowhere, and it can't mean "resolve it for me" either — that's what
|
|
363
|
+
// omitting the field does.
|
|
364
|
+
href: z.string().min(1).optional(),
|
|
354
365
|
icon: iconName.optional(),
|
|
355
366
|
items: z
|
|
356
367
|
.array(
|
|
@@ -575,18 +586,44 @@ const mcpConfigSchema = z.strictObject({
|
|
|
575
586
|
route: z.string().default("/mcp").transform(normalizeRoute),
|
|
576
587
|
});
|
|
577
588
|
|
|
589
|
+
const askEndpointSchema = z
|
|
590
|
+
.string()
|
|
591
|
+
.trim()
|
|
592
|
+
.min(1)
|
|
593
|
+
.refine(
|
|
594
|
+
(value) => {
|
|
595
|
+
if (value.startsWith("/") && !value.startsWith("//")) {
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
const url = new URL(value);
|
|
600
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
601
|
+
} catch {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
message:
|
|
607
|
+
"ai.ask.endpoint must be an HTTP(S) URL or a root-relative path.",
|
|
608
|
+
}
|
|
609
|
+
);
|
|
610
|
+
|
|
578
611
|
const aiConfigSchema = z.strictObject({
|
|
579
612
|
ask: z
|
|
580
613
|
.strictObject({
|
|
581
614
|
// Name of the env var holding the provider's API key; each provider has
|
|
582
615
|
// a sensible default, so this only needs setting to override it.
|
|
583
616
|
apiKeyEnv: z.string().optional(),
|
|
584
|
-
// Base URL of the backend. Required for `openai-compatible
|
|
585
|
-
// named providers it overrides the
|
|
617
|
+
// Base URL of the backend. Required for `openai-compatible` only when no
|
|
618
|
+
// external endpoint is supplied; for named providers it overrides the preset.
|
|
586
619
|
// blume bundles Zod 3; top-level `z.url()` is undefined at runtime.
|
|
587
620
|
// oxlint-disable-next-line react-doctor/zod-v4-prefer-top-level-string-formats
|
|
588
621
|
baseUrl: z.string().url().optional(),
|
|
589
622
|
enabled: z.boolean().default(false),
|
|
623
|
+
// Optional external endpoint for projects that keep their docs static
|
|
624
|
+
// and host Ask AI in an existing backend. Absolute URLs and root-relative
|
|
625
|
+
// paths are both valid; the built-in request/stream contract is unchanged.
|
|
626
|
+
endpoint: askEndpointSchema.optional(),
|
|
590
627
|
model: z.string().default("openai/gpt-5.5"),
|
|
591
628
|
provider: z.enum(askAiProviders).default("gateway"),
|
|
592
629
|
// Empty-state prompts shown before the first question. Each renders as a
|
|
@@ -603,7 +640,10 @@ const aiConfigSchema = z.strictObject({
|
|
|
603
640
|
.superRefine((value, ctx) => {
|
|
604
641
|
// A generic OpenAI-compatible backend has no preset URL, so the user
|
|
605
642
|
// must supply one; the named providers fall back to their preset.
|
|
606
|
-
if (
|
|
643
|
+
if (
|
|
644
|
+
value.provider === "openai-compatible" &&
|
|
645
|
+
!(value.baseUrl || value.endpoint)
|
|
646
|
+
) {
|
|
607
647
|
ctx.addIssue({
|
|
608
648
|
code: z.ZodIssueCode.custom,
|
|
609
649
|
message:
|
|
@@ -1118,15 +1158,21 @@ const reactConfigSchema = z.strictObject({
|
|
|
1118
1158
|
* `http(s)` URL (OpenAPI for the Blume renderer; OpenAPI or AsyncAPI for Scalar).
|
|
1119
1159
|
*/
|
|
1120
1160
|
const openapiSourceSchema = z.strictObject({
|
|
1161
|
+
/** Include generated pages from this spec in llms.txt/llms-full.txt. */
|
|
1162
|
+
includeInLlms: z.boolean().default(true),
|
|
1163
|
+
/** Include generated pages from this spec in site search. */
|
|
1164
|
+
includeInSearch: z.boolean().default(true),
|
|
1121
1165
|
/** Nav/section label for this source. */
|
|
1122
1166
|
label: z.string().optional(),
|
|
1167
|
+
/** Emit noindex metadata and omit generated pages from the sitemap. */
|
|
1168
|
+
noindex: z.boolean().default(false),
|
|
1123
1169
|
/** Per-source route; defaults to the block's `route` (or a derived path). */
|
|
1124
1170
|
route: z.string().optional(),
|
|
1125
1171
|
/** Local path or `http(s)` URL to the spec. */
|
|
1126
1172
|
spec: z.string(),
|
|
1127
1173
|
});
|
|
1128
1174
|
|
|
1129
|
-
export type OpenApiSource = z.
|
|
1175
|
+
export type OpenApiSource = z.input<typeof openapiSourceSchema>;
|
|
1130
1176
|
|
|
1131
1177
|
/**
|
|
1132
1178
|
* Arbitrary Scalar API-reference options forwarded verbatim to the generated
|
|
@@ -1283,6 +1329,7 @@ export const blumeConfigSchema = z.strictObject({
|
|
|
1283
1329
|
frontmatter: frontmatterConfigSchema.default({}),
|
|
1284
1330
|
github: githubConfigSchema.optional(),
|
|
1285
1331
|
i18n: i18nConfigSchema.optional(),
|
|
1332
|
+
integrations: z.array(z.custom<AstroIntegration>()).default([]),
|
|
1286
1333
|
lastModified: lastModifiedConfigSchema.default(false),
|
|
1287
1334
|
logo: logoConfigSchema.optional(),
|
|
1288
1335
|
markdown: markdownConfigSchema.default({}),
|
|
@@ -7,7 +7,7 @@ import type { ResolvedConfig } from "./schema.ts";
|
|
|
7
7
|
*/
|
|
8
8
|
export const serverFeatures = (config: ResolvedConfig): string[] => {
|
|
9
9
|
const features: string[] = [];
|
|
10
|
-
if (config.ai.ask?.enabled) {
|
|
10
|
+
if (config.ai.ask?.enabled && !config.ai.ask.endpoint) {
|
|
11
11
|
features.push("Ask AI");
|
|
12
12
|
}
|
|
13
13
|
// The hosted MCP server is a live JSON-RPC endpoint, so it needs a runtime.
|
|
@@ -38,6 +38,15 @@ export const slugify = (text: string): string =>
|
|
|
38
38
|
.replaceAll(/-+/gu, "-")
|
|
39
39
|
.replaceAll(/^-|-$/gu, "");
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* {@link slugify} for a slug that may span path segments (`guides/setup`).
|
|
43
|
+
* `slugify` deletes `/` along with all other punctuation, which would mash
|
|
44
|
+
* `guides/setup` into `guidessetup` — and collide it with a genuine `guidessetup`
|
|
45
|
+
* document. Each segment is slugged on its own and the separators kept.
|
|
46
|
+
*/
|
|
47
|
+
export const slugifyPath = (text: string): string =>
|
|
48
|
+
text.split("/").map(slugify).filter(Boolean).join("/");
|
|
49
|
+
|
|
41
50
|
/** Title-case a slug segment for display. */
|
|
42
51
|
const titleCase = (value: string): string =>
|
|
43
52
|
value
|
|
@@ -104,13 +113,24 @@ type FenceState = "```" | "~~~" | null;
|
|
|
104
113
|
* the state untouched.
|
|
105
114
|
*/
|
|
106
115
|
const nextFenceState = (line: string, fence: FenceState): FenceState => {
|
|
107
|
-
const
|
|
116
|
+
const trimmed = line.trimStart();
|
|
117
|
+
const delimiter = trimmed.match(CODE_FENCE)?.groups?.delimiter as
|
|
108
118
|
| Exclude<FenceState, null>
|
|
109
119
|
| undefined;
|
|
110
120
|
if (delimiter === undefined) {
|
|
111
121
|
return fence;
|
|
112
122
|
}
|
|
113
123
|
if (fence === null) {
|
|
124
|
+
// A backtick fence's info string cannot itself contain a backtick
|
|
125
|
+
// (CommonMark) — a line-leading ```inline``` span is a paragraph, and
|
|
126
|
+
// opening a phantom fence on it would swallow every heading and link
|
|
127
|
+
// after it. Tilde fences carry no such rule.
|
|
128
|
+
if (delimiter === "```") {
|
|
129
|
+
const run = trimmed.match(/^`+/u)?.[0].length ?? 0;
|
|
130
|
+
if (trimmed.slice(run).includes("`")) {
|
|
131
|
+
return fence;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
114
134
|
return delimiter;
|
|
115
135
|
}
|
|
116
136
|
return fence === delimiter ? null : fence;
|
|
@@ -159,6 +179,13 @@ const linesWithoutFrontMatter = (body: string): string[] => {
|
|
|
159
179
|
if (!/^-{3}\s*$/u.test(lines[0] ?? "")) {
|
|
160
180
|
return lines;
|
|
161
181
|
}
|
|
182
|
+
// A blank line directly after the dashes means the body *opens* with a
|
|
183
|
+
// thematic break, not front matter — YAML metadata starts on the very next
|
|
184
|
+
// line. Treating it as an unclosed block ate everything up to the next
|
|
185
|
+
// `---`/`...` line of an already-stripped body.
|
|
186
|
+
if ((lines[1] ?? "").trim() === "") {
|
|
187
|
+
return lines;
|
|
188
|
+
}
|
|
162
189
|
const close = lines.findIndex(
|
|
163
190
|
(line, index) => index > 0 && FRONT_MATTER_CLOSE.test(line)
|
|
164
191
|
);
|
|
@@ -304,9 +331,26 @@ export const extractHeadings = (body: string): Heading[] => {
|
|
|
304
331
|
return headings;
|
|
305
332
|
};
|
|
306
333
|
|
|
307
|
-
|
|
334
|
+
// The label admits one level of nested brackets so an image-wrapped link
|
|
335
|
+
// (`[](/target)`) matches as the *outer* link — with a flat
|
|
336
|
+
// `[^\]]*` label the match stopped at the image's `]` and the outer target was
|
|
337
|
+
// never seen. The target admits one level of balanced parens so a Wikipedia-
|
|
338
|
+
// style URL (`/wiki/Foo_(bar)`) isn't truncated at its first `)`.
|
|
339
|
+
const MD_LINK =
|
|
340
|
+
/\[(?<label>(?:[^[\]]|\[[^\]]*\])*)\]\((?<target>(?:[^()\s]|\([^()\s]*\))+)(?<title>\s+"[^"]*")?\)/gu;
|
|
341
|
+
// An image inside a link label; its target was matched (and so validated) as a
|
|
342
|
+
// link of its own before labels admitted nesting, and still should be.
|
|
343
|
+
const MD_IMAGE =
|
|
344
|
+
/!\[[^\]]*\]\((?<target>(?:[^()\s]|\([^()\s]*\))+)(?<title>\s+"[^"]*")?\)/gu;
|
|
308
345
|
const INLINE_CODE = /`[^`]*`/gu;
|
|
309
346
|
|
|
347
|
+
/** Column (0-based, within `matched`) where a link/image match's target starts. */
|
|
348
|
+
const targetOffsetIn = (
|
|
349
|
+
matched: string,
|
|
350
|
+
target: string,
|
|
351
|
+
title: string | undefined
|
|
352
|
+
): number => matched.length - 1 - (title?.length ?? 0) - target.length;
|
|
353
|
+
|
|
310
354
|
/**
|
|
311
355
|
* Extract link targets from a markdown body for later validation, recording the
|
|
312
356
|
* 1-based line/column of each target. Skips fenced code blocks and inline code.
|
|
@@ -335,16 +379,33 @@ const scanLinkLine = (
|
|
|
335
379
|
if (target === undefined || match.index === undefined) {
|
|
336
380
|
continue;
|
|
337
381
|
}
|
|
338
|
-
// Locate the target from the
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
const targetOffset = match.index + match[0].indexOf("](") + "](".length;
|
|
382
|
+
// Locate the target by arithmetic from the match end rather than searching
|
|
383
|
+
// for its text — a label that contains the same text (e.g. `[/a/b](/a/b)`)
|
|
384
|
+
// would otherwise report the column inside the label.
|
|
385
|
+
const targetOffset = targetOffsetIn(match[0], target, match.groups?.title);
|
|
343
386
|
links.push({
|
|
344
|
-
column: targetOffset + 1,
|
|
387
|
+
column: match.index + targetOffset + 1,
|
|
345
388
|
line: lineNumber,
|
|
346
389
|
target,
|
|
347
390
|
});
|
|
391
|
+
// An image nested in the label (`[](/target)`) carries its
|
|
392
|
+
// own target; surface it too so a missing image is still caught.
|
|
393
|
+
const label = match[0].slice(0, targetOffset - "](".length);
|
|
394
|
+
for (const image of label.matchAll(MD_IMAGE)) {
|
|
395
|
+
const imageTarget = image.groups?.target;
|
|
396
|
+
if (imageTarget === undefined || image.index === undefined) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
links.push({
|
|
400
|
+
column:
|
|
401
|
+
match.index +
|
|
402
|
+
image.index +
|
|
403
|
+
targetOffsetIn(image[0], imageTarget, image.groups?.title) +
|
|
404
|
+
1,
|
|
405
|
+
line: lineNumber,
|
|
406
|
+
target: imageTarget,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
348
409
|
}
|
|
349
410
|
return next;
|
|
350
411
|
};
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
pollingWatch,
|
|
13
13
|
snapshotCache,
|
|
14
14
|
} from "./cache.ts";
|
|
15
|
-
import {
|
|
15
|
+
import { slugifyPath } from "./normalize.ts";
|
|
16
16
|
import type {
|
|
17
17
|
ContentSource,
|
|
18
18
|
SourceContext,
|
|
@@ -432,7 +432,9 @@ export const notionSource = (
|
|
|
432
432
|
const slugProp = richToMarkdown(
|
|
433
433
|
page.properties[props.slug ?? "Slug"]?.rich_text
|
|
434
434
|
);
|
|
435
|
-
|
|
435
|
+
// Path-aware: a `guides/setup` slug keeps its `/` (per-segment slugging)
|
|
436
|
+
// instead of mashing into `guidessetup`.
|
|
437
|
+
const slug = slugifyPath(slugProp || title) || page.id;
|
|
436
438
|
return { data, slug };
|
|
437
439
|
};
|
|
438
440
|
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
pollingWatch,
|
|
9
9
|
snapshotCache,
|
|
10
10
|
} from "./cache.ts";
|
|
11
|
-
import { slugify } from "./normalize.ts";
|
|
11
|
+
import { slugify, slugifyPath } from "./normalize.ts";
|
|
12
12
|
import { portableTextToMarkdown } from "./portable-text.ts";
|
|
13
13
|
import type { PortableTextBlock } from "./portable-text.ts";
|
|
14
14
|
import type {
|
|
@@ -142,9 +142,11 @@ export const sanitySource = (
|
|
|
142
142
|
"untitled";
|
|
143
143
|
// Fall back to the unique `_id` when a slug (e.g. a non-ASCII `slug.current`)
|
|
144
144
|
// slugifies to empty, so distinct documents don't all collapse to the same
|
|
145
|
-
// `untitled.md` ref and silently overwrite each other.
|
|
145
|
+
// `untitled.md` ref and silently overwrite each other. Path-aware: a
|
|
146
|
+
// `guides/setup` slug keeps its `/` (per-segment slugging) instead of
|
|
147
|
+
// mashing into `guidessetup`.
|
|
146
148
|
const slug =
|
|
147
|
-
|
|
149
|
+
slugifyPath(slugValue) || slugify(asString(doc._id) ?? "") || "untitled";
|
|
148
150
|
|
|
149
151
|
const data: Record<string, unknown> = {};
|
|
150
152
|
const title = asString(getPath(doc, fields.title ?? "title"));
|
package/src/core/types.ts
CHANGED
|
@@ -201,9 +201,11 @@ export interface NavTab {
|
|
|
201
201
|
*/
|
|
202
202
|
path: string;
|
|
203
203
|
/**
|
|
204
|
-
* The clickable target.
|
|
205
|
-
*
|
|
206
|
-
*
|
|
204
|
+
* The clickable target. Author-declared when the config sets it; otherwise
|
|
205
|
+
* equals `path` when the section has an index page, and resolves to the
|
|
206
|
+
* section's first page when it doesn't, so the tab doesn't link to a 404 as
|
|
207
|
+
* long as the section has a page to offer — a section with no linkable page at
|
|
208
|
+
* all keeps `path`. Absent when a resolved target matches `path`.
|
|
207
209
|
*/
|
|
208
210
|
href?: string;
|
|
209
211
|
icon?: string;
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { join } from "pathe";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import type { AgentKind } from "../audit/agent.ts";
|
|
8
|
+
|
|
9
|
+
/** How long the SIGTERM on timeout gets to work before SIGKILL follows. */
|
|
10
|
+
const KILL_GRACE_MS = 5000;
|
|
11
|
+
|
|
12
|
+
/** The MCP tools a reader run may use — nothing else. */
|
|
13
|
+
export const MCP_TOOL_NAMES = [
|
|
14
|
+
"search_docs",
|
|
15
|
+
"get_page",
|
|
16
|
+
"list_pages",
|
|
17
|
+
"get_navigation",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
/** The MCP server name in the generated config; tool ids derive from it. */
|
|
21
|
+
const MCP_SERVER_NAME = "docs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Claude Code built-ins that would let the agent escape the docs-only
|
|
25
|
+
* sandbox: the reader must not read the repo, run commands, or search the
|
|
26
|
+
* web — it sees the documentation the way a stranger does, through MCP.
|
|
27
|
+
*/
|
|
28
|
+
const DISALLOWED_TOOLS = [
|
|
29
|
+
"Bash",
|
|
30
|
+
"Read",
|
|
31
|
+
"Glob",
|
|
32
|
+
"Grep",
|
|
33
|
+
"Write",
|
|
34
|
+
"Edit",
|
|
35
|
+
"NotebookEdit",
|
|
36
|
+
"WebFetch",
|
|
37
|
+
"WebSearch",
|
|
38
|
+
"Task",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** The captured outcome of one headless agent invocation. */
|
|
42
|
+
export interface HeadlessResult {
|
|
43
|
+
code: number;
|
|
44
|
+
stderr: string;
|
|
45
|
+
stdout: string;
|
|
46
|
+
timedOut: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface HeadlessOptions {
|
|
50
|
+
cwd: string;
|
|
51
|
+
platform?: NodeJS.Platform;
|
|
52
|
+
prompt: string;
|
|
53
|
+
timeoutMs: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run an agent CLI headlessly: prompt over stdin (dodging argv limits and
|
|
58
|
+
* cmd.exe newline quoting alike), stdout and stderr captured, SIGTERM at the
|
|
59
|
+
* deadline with a SIGKILL follow-up. Resolves with the captured result;
|
|
60
|
+
* rejects only when the executable cannot be spawned at all (ENOENT).
|
|
61
|
+
*/
|
|
62
|
+
export const runAgentHeadless = (
|
|
63
|
+
bin: string,
|
|
64
|
+
args: string[],
|
|
65
|
+
options: HeadlessOptions
|
|
66
|
+
): Promise<HeadlessResult> =>
|
|
67
|
+
// oxlint-disable-next-line promise/avoid-new -- adapt spawn's event callbacks
|
|
68
|
+
new Promise((resolve, reject) => {
|
|
69
|
+
const platform = options.platform ?? process.platform;
|
|
70
|
+
// npm installs agent CLIs as `.cmd` shims on Windows, which Node refuses
|
|
71
|
+
// to spawn without a shell. Arguments are plain flags and absolute paths,
|
|
72
|
+
// so shell interpolation has nothing to mangle.
|
|
73
|
+
const child = spawn(bin, args, {
|
|
74
|
+
cwd: options.cwd,
|
|
75
|
+
shell: platform === "win32",
|
|
76
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let stdout = "";
|
|
80
|
+
let stderr = "";
|
|
81
|
+
let timedOut = false;
|
|
82
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
83
|
+
stdout += chunk.toString("utf-8");
|
|
84
|
+
});
|
|
85
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
86
|
+
stderr += chunk.toString("utf-8");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const deadline = setTimeout(() => {
|
|
90
|
+
timedOut = true;
|
|
91
|
+
child.kill("SIGTERM");
|
|
92
|
+
const hardKill = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
|
|
93
|
+
hardKill.unref();
|
|
94
|
+
}, options.timeoutMs);
|
|
95
|
+
deadline.unref();
|
|
96
|
+
|
|
97
|
+
child.once("error", (error) => {
|
|
98
|
+
clearTimeout(deadline);
|
|
99
|
+
reject(error);
|
|
100
|
+
});
|
|
101
|
+
child.once("close", (code) => {
|
|
102
|
+
clearTimeout(deadline);
|
|
103
|
+
resolve({ code: code ?? 1, stderr, stdout, timedOut });
|
|
104
|
+
});
|
|
105
|
+
// `close` waits for the stdio pipes, which a killed agent's own children
|
|
106
|
+
// (an MCP server, a shell) can hold open past the SIGTERM. A timed-out
|
|
107
|
+
// run's output is discarded anyway, so the process dying is enough.
|
|
108
|
+
child.once("exit", (code) => {
|
|
109
|
+
if (timedOut) {
|
|
110
|
+
clearTimeout(deadline);
|
|
111
|
+
resolve({ code: code ?? 1, stderr, stdout, timedOut });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
child.stdin.end(options.prompt);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** The spawn signature `runEval` accepts, injectable for tests. */
|
|
119
|
+
export type HeadlessRunner = typeof runAgentHeadless;
|
|
120
|
+
|
|
121
|
+
/** How the eval reaches the MCP stdio bridge from a spawned agent. */
|
|
122
|
+
export interface McpLaunch {
|
|
123
|
+
/** The generated MCP config file (claude's `--mcp-config`). */
|
|
124
|
+
configPath: string;
|
|
125
|
+
/** argv for the bridge process (codex's `-c mcp_servers` override). */
|
|
126
|
+
serverArgs: string[];
|
|
127
|
+
/** The executable launching the bridge. */
|
|
128
|
+
serverCommand: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Write the MCP config a reader run points its agent CLI at. The bridge is
|
|
133
|
+
* this same CLI relaunched (`blume mcp-stdio`), which resolves correctly from
|
|
134
|
+
* both a source checkout (bun + src/cli/index.ts) and an installed package
|
|
135
|
+
* (node + bin/blume.mjs).
|
|
136
|
+
*/
|
|
137
|
+
export const writeMcpConfig = async (
|
|
138
|
+
dir: string,
|
|
139
|
+
snapshotPath: string,
|
|
140
|
+
launcher?: { args: string[]; command: string }
|
|
141
|
+
): Promise<McpLaunch> => {
|
|
142
|
+
const resolved = launcher ?? {
|
|
143
|
+
args: [process.argv[1] ?? "", "mcp-stdio", "--data", snapshotPath],
|
|
144
|
+
command: process.execPath,
|
|
145
|
+
};
|
|
146
|
+
const configPath = join(dir, "mcp-config.json");
|
|
147
|
+
const config = {
|
|
148
|
+
mcpServers: {
|
|
149
|
+
[MCP_SERVER_NAME]: { args: resolved.args, command: resolved.command },
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
153
|
+
return {
|
|
154
|
+
configPath,
|
|
155
|
+
serverArgs: resolved.args,
|
|
156
|
+
serverCommand: resolved.command,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export interface InvocationContext {
|
|
161
|
+
/** Where codex writes its final message; unused by claude. */
|
|
162
|
+
lastMessagePath: string;
|
|
163
|
+
/** The MCP bridge for reader runs; omitted for the judge. */
|
|
164
|
+
mcp?: McpLaunch;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const CLAUDE_READER_MAX_TURNS = "25";
|
|
168
|
+
const CLAUDE_JUDGE_MAX_TURNS = "1";
|
|
169
|
+
|
|
170
|
+
const claudeArgs = (context: InvocationContext): string[] => {
|
|
171
|
+
const base = ["-p", "--output-format", "json", "--strict-mcp-config"];
|
|
172
|
+
if (context.mcp) {
|
|
173
|
+
const allowed = MCP_TOOL_NAMES.map(
|
|
174
|
+
(tool) => `mcp__${MCP_SERVER_NAME}__${tool}`
|
|
175
|
+
).join(",");
|
|
176
|
+
return [
|
|
177
|
+
...base,
|
|
178
|
+
"--mcp-config",
|
|
179
|
+
context.mcp.configPath,
|
|
180
|
+
"--allowedTools",
|
|
181
|
+
allowed,
|
|
182
|
+
"--disallowedTools",
|
|
183
|
+
DISALLOWED_TOOLS.join(","),
|
|
184
|
+
"--max-turns",
|
|
185
|
+
CLAUDE_READER_MAX_TURNS,
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
return [
|
|
189
|
+
...base,
|
|
190
|
+
"--disallowedTools",
|
|
191
|
+
DISALLOWED_TOOLS.join(","),
|
|
192
|
+
"--max-turns",
|
|
193
|
+
CLAUDE_JUDGE_MAX_TURNS,
|
|
194
|
+
];
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const codexArgs = (context: InvocationContext): string[] => {
|
|
198
|
+
const base = [
|
|
199
|
+
"exec",
|
|
200
|
+
"--skip-git-repo-check",
|
|
201
|
+
"--ignore-user-config",
|
|
202
|
+
"--ephemeral",
|
|
203
|
+
"--sandbox",
|
|
204
|
+
"read-only",
|
|
205
|
+
"--output-last-message",
|
|
206
|
+
context.lastMessagePath,
|
|
207
|
+
];
|
|
208
|
+
if (context.mcp) {
|
|
209
|
+
// `-c` values parse as TOML; JSON string/array literals are valid TOML
|
|
210
|
+
// values, so JSON.stringify produces exactly the quoting codex expects.
|
|
211
|
+
return [
|
|
212
|
+
...base,
|
|
213
|
+
"-c",
|
|
214
|
+
`mcp_servers.${MCP_SERVER_NAME}.command=${JSON.stringify(
|
|
215
|
+
context.mcp.serverCommand
|
|
216
|
+
)}`,
|
|
217
|
+
"-c",
|
|
218
|
+
`mcp_servers.${MCP_SERVER_NAME}.args=${JSON.stringify(
|
|
219
|
+
context.mcp.serverArgs
|
|
220
|
+
)}`,
|
|
221
|
+
"-",
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
return [...base, "-"];
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** Build the argv for one headless run; pass `mcp` for the reader role. */
|
|
228
|
+
export const agentArgs = (
|
|
229
|
+
kind: AgentKind,
|
|
230
|
+
context: InvocationContext
|
|
231
|
+
): string[] => (kind === "claude" ? claudeArgs(context) : codexArgs(context));
|
|
232
|
+
|
|
233
|
+
/** What one headless run produced, normalized across agent CLIs. */
|
|
234
|
+
export interface AgentOutput {
|
|
235
|
+
costUsd?: number;
|
|
236
|
+
detail?: string;
|
|
237
|
+
isError: boolean;
|
|
238
|
+
text: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const claudeResultSchema = z.object({
|
|
242
|
+
is_error: z.boolean().default(false),
|
|
243
|
+
result: z.string().default(""),
|
|
244
|
+
total_cost_usd: z.number().optional(),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const tail = (value: string, max = 300): string => {
|
|
248
|
+
const trimmed = value.trim();
|
|
249
|
+
return trimmed.length > max ? trimmed.slice(-max) : trimmed;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Normalize a finished run into the answer text. Claude prints one JSON
|
|
254
|
+
* object on stdout (`--output-format json`); codex writes the final message
|
|
255
|
+
* to `--output-last-message` because its stdout interleaves progress.
|
|
256
|
+
*/
|
|
257
|
+
export const readAgentOutput = async (
|
|
258
|
+
kind: AgentKind,
|
|
259
|
+
result: HeadlessResult,
|
|
260
|
+
lastMessagePath: string
|
|
261
|
+
): Promise<AgentOutput> => {
|
|
262
|
+
if (result.timedOut) {
|
|
263
|
+
return { detail: "timed out", isError: true, text: "" };
|
|
264
|
+
}
|
|
265
|
+
if (result.code !== 0) {
|
|
266
|
+
return {
|
|
267
|
+
detail: tail(result.stderr) || `exited with code ${result.code}`,
|
|
268
|
+
isError: true,
|
|
269
|
+
text: "",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (kind === "claude") {
|
|
274
|
+
let parsed: unknown;
|
|
275
|
+
try {
|
|
276
|
+
parsed = JSON.parse(result.stdout);
|
|
277
|
+
} catch {
|
|
278
|
+
return {
|
|
279
|
+
detail: "unparseable --output-format json payload",
|
|
280
|
+
isError: true,
|
|
281
|
+
text: "",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const payload = claudeResultSchema.safeParse(parsed);
|
|
285
|
+
if (!payload.success) {
|
|
286
|
+
return {
|
|
287
|
+
detail: "unexpected --output-format json shape",
|
|
288
|
+
isError: true,
|
|
289
|
+
text: "",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
costUsd: payload.data.total_cost_usd,
|
|
294
|
+
isError: payload.data.is_error,
|
|
295
|
+
text: payload.data.result,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let text: string;
|
|
300
|
+
try {
|
|
301
|
+
const raw = await readFile(lastMessagePath, "utf-8");
|
|
302
|
+
text = raw.trim();
|
|
303
|
+
} catch {
|
|
304
|
+
return { detail: "no last message written", isError: true, text: "" };
|
|
305
|
+
}
|
|
306
|
+
if (text === "") {
|
|
307
|
+
return { detail: "empty last message", isError: true, text: "" };
|
|
308
|
+
}
|
|
309
|
+
return { isError: false, text };
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const verdictSchema = z.object({
|
|
313
|
+
missing: z.array(z.string()).default([]),
|
|
314
|
+
notes: z.string().default(""),
|
|
315
|
+
pass: z.boolean(),
|
|
316
|
+
score: z.number().min(0).max(1).optional(),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
/** The judge's grade for one answer. */
|
|
320
|
+
export type Verdict = z.infer<typeof verdictSchema>;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Extract the verdict JSON from a judge reply. Tolerates markdown fences and
|
|
324
|
+
* surrounding prose; returns undefined when no valid verdict can be found.
|
|
325
|
+
*/
|
|
326
|
+
export const parseVerdict = (text: string): Verdict | undefined => {
|
|
327
|
+
const start = text.indexOf("{");
|
|
328
|
+
const end = text.lastIndexOf("}");
|
|
329
|
+
if (start === -1 || end <= start) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
let parsed: unknown;
|
|
333
|
+
try {
|
|
334
|
+
parsed = JSON.parse(text.slice(start, end + 1));
|
|
335
|
+
} catch {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const result = verdictSchema.safeParse(parsed);
|
|
339
|
+
return result.success ? result.data : undefined;
|
|
340
|
+
};
|