blume 1.1.4 → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/dist/cli/index.js +1286 -63
  4. package/dist/cli/index.js.map +32 -21
  5. package/dist/types/core/config-input.d.ts +18 -0
  6. package/dist/types/core/config.d.ts +4 -0
  7. package/dist/types/core/data.d.ts +1 -0
  8. package/dist/types/core/schema.d.ts +132 -17
  9. package/dist/types/core/types.d.ts +5 -3
  10. package/dist/types/openapi/references.d.ts +6 -0
  11. package/docs/advanced/api-reference.mdx +27 -0
  12. package/docs/advanced/changelog.mdx +10 -0
  13. package/docs/configuration/ai.mdx +38 -2
  14. package/docs/configuration/customization.mdx +27 -0
  15. package/docs/configuration/index.mdx +5 -0
  16. package/docs/content/navigation.mdx +12 -0
  17. package/docs/reference/cli.mdx +17 -13
  18. package/docs/reference/eval.mdx +106 -0
  19. package/docs/reference/meta.ts +1 -1
  20. package/package.json +1 -1
  21. package/src/ai/agent-readability.ts +19 -1
  22. package/src/ai/llms.ts +9 -4
  23. package/src/ai/mcp/server.ts +19 -8
  24. package/src/ai/mcp/stdio.ts +35 -0
  25. package/src/astro/generate.ts +25 -2
  26. package/src/astro/templates.ts +114 -22
  27. package/src/cli/commands/eval.ts +291 -0
  28. package/src/cli/commands/init.ts +9 -4
  29. package/src/cli/commands/mcp-stdio.ts +36 -0
  30. package/src/cli/index.ts +4 -0
  31. package/src/cli/required-secrets.ts +1 -1
  32. package/src/components/content/AccordionItem.astro +2 -2
  33. package/src/components/content/TreeFolder.astro +1 -2
  34. package/src/components/islands/AskAI.astro +9 -2
  35. package/src/components/islands/ask-ai.tsx +4 -2
  36. package/src/components/islands/hooks.ts +10 -4
  37. package/src/components/layout/NavTree.astro +37 -19
  38. package/src/components/layout/ReferenceLayout.astro +4 -0
  39. package/src/components/layout/RootLayout.astro +1 -1
  40. package/src/components/openapi/SchemaProperty.astro +3 -3
  41. package/src/core/config-input.ts +18 -0
  42. package/src/core/config.ts +4 -0
  43. package/src/core/data.ts +1 -0
  44. package/src/core/graph.ts +1 -0
  45. package/src/core/navigation.ts +9 -2
  46. package/src/core/schema.ts +51 -4
  47. package/src/core/server-features.ts +1 -1
  48. package/src/core/types.ts +5 -3
  49. package/src/eval/agents.ts +340 -0
  50. package/src/eval/findings.ts +103 -0
  51. package/src/eval/prompts.ts +78 -0
  52. package/src/eval/report.ts +214 -0
  53. package/src/eval/run.ts +290 -0
  54. package/src/eval/schema.ts +124 -0
  55. package/src/openapi/references.ts +23 -2
  56. package/src/openapi/render-mdx.ts +27 -4
  57. package/src/openapi/scalar.ts +1 -0
  58. package/src/openapi/source.ts +11 -4
  59. package/src/registry/eject.ts +23 -1
  60. package/src/search/build.ts +4 -3
@@ -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
+ };
@@ -0,0 +1,103 @@
1
+ import type { BlumeProject } from "../core/project-graph.ts";
2
+ import type { Diagnostic } from "../core/types.ts";
3
+ import { locateQuestion } from "./schema.ts";
4
+ import type { EvalQuestion } from "./schema.ts";
5
+
6
+ const DOCS_URL = "https://useblume.dev/docs/reference/eval";
7
+
8
+ /** Where a finding should anchor when no route hint matches a page. */
9
+ export interface EvalsAnchor {
10
+ path: string;
11
+ raw: string;
12
+ }
13
+
14
+ /** The manifest route a hint names, if any. */
15
+ const hintedRoute = (question: EvalQuestion, project: BlumeProject) => {
16
+ for (const hint of question.routes) {
17
+ const route = project.manifest.routes.find(
18
+ (candidate) => candidate.path === hint
19
+ );
20
+ if (route) {
21
+ return route;
22
+ }
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Warnings for route hints that name no manifest route — docs move, and a
28
+ * finding that silently anchors to the evals file instead of the page it
29
+ * used to name is a debugging session; a warning is a one-line fix.
30
+ */
31
+ export const routeFindings = (
32
+ question: EvalQuestion,
33
+ project: BlumeProject,
34
+ anchor: EvalsAnchor
35
+ ): Diagnostic[] => {
36
+ const known = new Set(project.manifest.routes.map((route) => route.path));
37
+ return question.routes
38
+ .filter((hint) => !known.has(hint))
39
+ .map((hint) => ({
40
+ code: "BLUME_EVAL_ROUTE_UNKNOWN",
41
+ docsUrl: DOCS_URL,
42
+ file: anchor.path,
43
+ line: locateQuestion(anchor.raw, question.id),
44
+ message: `Question "${question.id}" hints at route "${hint}", which matches no page.`,
45
+ severity: "warning" as const,
46
+ suggestion:
47
+ "Update the question's `routes` to the page's current route, or remove the hint.",
48
+ }));
49
+ };
50
+
51
+ /** What the finding needs to know about how the question went. */
52
+ export interface QuestionOutcome {
53
+ /** Why the run errored, when it did. */
54
+ detail?: string;
55
+ /** The expected facts the judge found absent or contradicted. */
56
+ missing: string[];
57
+ status: "error" | "fail";
58
+ }
59
+
60
+ /**
61
+ * The diagnostic for a failed or errored question, anchored to the page that
62
+ * should have answered it (via the first matching route hint) or, failing
63
+ * that, to the question's line in the evals file.
64
+ */
65
+ export const questionFinding = (
66
+ question: EvalQuestion,
67
+ outcome: QuestionOutcome,
68
+ project: BlumeProject,
69
+ anchor: EvalsAnchor
70
+ ): Diagnostic => {
71
+ const route = hintedRoute(question, project);
72
+ const site = route
73
+ ? { file: route.sourcePath, url: route.path }
74
+ : { file: anchor.path, line: locateQuestion(anchor.raw, question.id) };
75
+
76
+ if (outcome.status === "error") {
77
+ return {
78
+ code: "BLUME_EVAL_QUESTION_ERROR",
79
+ docsUrl: DOCS_URL,
80
+ message: `Eval run failed for "${question.question}"${
81
+ outcome.detail ? ` — ${outcome.detail}` : ""
82
+ }.`,
83
+ severity: question.severity,
84
+ suggestion:
85
+ "Rerun `blume eval`; if it persists, check the agent CLI installation and the failure detail.",
86
+ ...site,
87
+ };
88
+ }
89
+
90
+ const missing =
91
+ outcome.missing.length > 0
92
+ ? ` — missing: ${outcome.missing.join("; ")}`
93
+ : "";
94
+ return {
95
+ code: "BLUME_EVAL_QUESTION_FAILED",
96
+ docsUrl: DOCS_URL,
97
+ message: `Docs could not answer: "${question.question}"${missing}`,
98
+ severity: question.severity,
99
+ suggestion:
100
+ "State the missing facts on this page, then rerun `blume eval`.",
101
+ ...site,
102
+ };
103
+ };
@@ -0,0 +1,78 @@
1
+ import type { EvalQuestion } from "./schema.ts";
2
+
3
+ /**
4
+ * The reader's instructions. The agent is spawned in an empty directory with
5
+ * its file/shell/web tools disabled, so the prompt's job is to direct it at
6
+ * the MCP docs tools and forbid the one escape hatch that remains: answering
7
+ * from prior knowledge of the product.
8
+ */
9
+ export const readerPrompt = (question: EvalQuestion): string =>
10
+ `You are evaluating whether a product's documentation can answer a user's question.
11
+
12
+ Answer the question below using ONLY the connected documentation tools (search_docs, get_page, list_pages, get_navigation). Rules:
13
+ - Do not use prior knowledge about the product. Do not guess.
14
+ - Do not read files, run commands, or access the network.
15
+ - Search first, then read the most relevant pages with get_page.
16
+ - If the documentation does not contain the answer, say exactly what information is missing instead of inventing one.
17
+
18
+ Question: ${question.question}
19
+
20
+ Reply with a concise answer containing the specific facts the documentation provides. Plain text only.`;
21
+
22
+ /** The judge's instructions: grade the answer against the expected facts. */
23
+ export const judgePrompt = (question: EvalQuestion, answer: string): string => {
24
+ const facts = question.expected.map((fact) => `- ${fact}`).join("\n");
25
+ return `You are grading an answer against expected facts. Do not use any tools.
26
+
27
+ Question: ${question.question}
28
+
29
+ Expected facts — each must be present in substance (paraphrase is fine, contradiction is not):
30
+ ${facts}
31
+
32
+ Answer to grade:
33
+ """
34
+ ${answer}
35
+ """
36
+
37
+ An answer that states the documentation lacks the information FAILS.
38
+
39
+ Reply with ONLY this JSON object on a single line, no markdown fences:
40
+ {"pass": true|false, "score": 0.0-1.0, "missing": ["expected facts absent or contradicted"], "notes": "one sentence"}`;
41
+ };
42
+
43
+ /** The `--fix` handoff prompt: where the report is and the ground rules. */
44
+ export const evalFixPrompt = (reportPath: string): string =>
45
+ `Fix the documentation gaps found by \`blume eval\` in this project.
46
+
47
+ The full report is at ${reportPath}. It is JSON: each entry in \`eval.results\` with status "fail" is one question the documentation could not answer. Each carries the \`question\`, the \`expected\` facts, the judge's \`missing\` facts, and the reader agent's \`answer\` (what the docs currently convey). The matching \`diagnostics\` entry names the source \`file\` of the page that should answer it.
48
+
49
+ Work through every failed question:
50
+ 1. Read the page named in the finding (or choose the best page when none is named).
51
+ 2. Edit the documentation so it states the missing facts explicitly. Add prose, not filler; keep the page's voice.
52
+ 3. Never delete questions from the evals file or weaken expected facts.
53
+
54
+ When you are done, run \`blume eval\` to verify, and repeat until every question passes.`;
55
+
56
+ /** The `eval init` prompt: draft a starter evals file from the docs. */
57
+ export const initPrompt = (evalsPath: string): string =>
58
+ `Draft a starter evals file for \`blume eval\` in this documentation project.
59
+
60
+ Read the documentation source pages in this project and write ${evalsPath} with about 10 high-value questions a real user would ask — installation, configuration, deployment, and the project's headline features. For each question, list the expected facts a correct answer must state, grounded in what the documentation actually promises (never invent facts the docs don't state).
61
+
62
+ The file format is YAML:
63
+
64
+ questions:
65
+ - id: kebab-case-slug
66
+ question: One user question?
67
+ expected:
68
+ - a fact the answer must contain
69
+ - another required fact
70
+ routes:
71
+ - /route/of/the/page/that/answers/it
72
+
73
+ Rules:
74
+ - Every \`expected\` fact must be verifiable in the docs today.
75
+ - Prefer questions whose answers live on one page; set \`routes\` to that page.
76
+ - Keep ids unique and questions short.
77
+
78
+ When you are done, print the file and suggest running \`blume eval\` to try it.`;