pi-nebius 0.3.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 (73) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/CONTRIBUTING.md +39 -0
  3. package/LICENSE +21 -0
  4. package/README.md +239 -0
  5. package/SECURITY.md +47 -0
  6. package/benchmarks/add-api-endpoint/benchmark.yaml +13 -0
  7. package/benchmarks/add-api-endpoint/fixture/app.mjs +6 -0
  8. package/benchmarks/add-api-endpoint/fixture/app.test.mjs +8 -0
  9. package/benchmarks/add-api-endpoint/fixture/package.json +8 -0
  10. package/benchmarks/add-api-endpoint/validation/check.test.mjs +37 -0
  11. package/benchmarks/fix-auth-bug/benchmark.yaml +14 -0
  12. package/benchmarks/fix-auth-bug/fixture/auth.mjs +4 -0
  13. package/benchmarks/fix-auth-bug/fixture/auth.test.mjs +26 -0
  14. package/benchmarks/fix-auth-bug/fixture/package.json +8 -0
  15. package/benchmarks/fix-auth-bug/validation/check.test.mjs +25 -0
  16. package/benchmarks/multi-file-feature/benchmark.yaml +15 -0
  17. package/benchmarks/multi-file-feature/fixture/package.json +8 -0
  18. package/benchmarks/multi-file-feature/fixture/routes.mjs +11 -0
  19. package/benchmarks/multi-file-feature/fixture/routes.test.mjs +14 -0
  20. package/benchmarks/multi-file-feature/fixture/serialize.mjs +3 -0
  21. package/benchmarks/multi-file-feature/fixture/store.mjs +10 -0
  22. package/benchmarks/multi-file-feature/validation/check.test.mjs +52 -0
  23. package/benchmarks/refactor-module/benchmark.yaml +12 -0
  24. package/benchmarks/refactor-module/fixture/invoice.mjs +8 -0
  25. package/benchmarks/refactor-module/fixture/invoice.test.mjs +9 -0
  26. package/benchmarks/refactor-module/fixture/package.json +8 -0
  27. package/benchmarks/refactor-module/validation/check.test.mjs +36 -0
  28. package/dist/benchmark/cli.js +112 -0
  29. package/dist/benchmark/command.js +194 -0
  30. package/dist/benchmark/definition.js +109 -0
  31. package/dist/benchmark/host-worker.js +14 -0
  32. package/dist/benchmark/instrumentation.js +296 -0
  33. package/dist/benchmark/metrics.js +78 -0
  34. package/dist/benchmark/process.js +122 -0
  35. package/dist/benchmark/project.js +70 -0
  36. package/dist/benchmark/report.js +94 -0
  37. package/dist/benchmark/runner.js +376 -0
  38. package/dist/benchmark/types.js +1 -0
  39. package/dist/benchmark/worker.js +134 -0
  40. package/dist/benchmark/workspace.js +55 -0
  41. package/dist/discovery.js +154 -0
  42. package/dist/errors.js +32 -0
  43. package/dist/index.js +86 -0
  44. package/dist/model-settings-command.js +130 -0
  45. package/dist/model-settings.js +101 -0
  46. package/dist/models.js +62 -0
  47. package/dist/provider.js +48 -0
  48. package/docs/benchmark-research.md +35 -0
  49. package/docs/benchmarking.md +253 -0
  50. package/docs/security-review.md +49 -0
  51. package/docs/validation.md +51 -0
  52. package/examples/models.json +31 -0
  53. package/package.json +74 -0
  54. package/src/benchmark/cli.ts +118 -0
  55. package/src/benchmark/command.ts +218 -0
  56. package/src/benchmark/definition.ts +112 -0
  57. package/src/benchmark/host-worker.ts +14 -0
  58. package/src/benchmark/instrumentation.ts +298 -0
  59. package/src/benchmark/metrics.ts +101 -0
  60. package/src/benchmark/process.ts +120 -0
  61. package/src/benchmark/project.ts +71 -0
  62. package/src/benchmark/report.ts +111 -0
  63. package/src/benchmark/runner.ts +458 -0
  64. package/src/benchmark/types.ts +180 -0
  65. package/src/benchmark/worker.ts +150 -0
  66. package/src/benchmark/workspace.ts +64 -0
  67. package/src/discovery.ts +176 -0
  68. package/src/errors.ts +32 -0
  69. package/src/index.ts +96 -0
  70. package/src/model-settings-command.ts +151 -0
  71. package/src/model-settings.ts +129 -0
  72. package/src/models.ts +73 -0
  73. package/src/provider.ts +63 -0
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import { resolve } from "node:path";
3
+ import { test } from "node:test";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ const { handle } = await import(pathToFileURL(resolve("routes.mjs")));
7
+ const store = await import(pathToFileURL(resolve("store.mjs")));
8
+ const { serialize } = await import(pathToFileURL(resolve("serialize.mjs")));
9
+ test("completion persists through store, serializer and HTTP routes", async () => {
10
+ assert.equal(typeof store.setCompleted, "function");
11
+ assert.deepEqual(serialize({ id: "x", title: "X", completed: true }), {
12
+ id: "x",
13
+ title: "X",
14
+ completed: true,
15
+ });
16
+ const created = await handle(
17
+ new Request("http://local/tasks", { method: "POST", body: JSON.stringify({ title: "First" }) }),
18
+ );
19
+ assert.equal(created.status, 201);
20
+ const task = await created.json();
21
+ assert.equal(task.completed, false);
22
+ for (const completed of [true, false]) {
23
+ const patched = await handle(
24
+ new Request(`http://local/tasks/${task.id}`, {
25
+ method: "PATCH",
26
+ body: JSON.stringify({ completed }),
27
+ }),
28
+ );
29
+ assert.equal(patched.status, 200);
30
+ assert.equal((await patched.json()).completed, completed);
31
+ const list = await (await handle(new Request("http://local/tasks"))).json();
32
+ assert.equal(list.find((item) => item.id === task.id).completed, completed);
33
+ }
34
+ for (const body of ['{"completed":"true"}', "{}", "broken-json"]) {
35
+ assert.equal(
36
+ (await handle(new Request(`http://local/tasks/${task.id}`, { method: "PATCH", body })))
37
+ .status,
38
+ 400,
39
+ );
40
+ }
41
+ assert.equal(
42
+ (
43
+ await handle(
44
+ new Request("http://local/tasks/missing", { method: "PATCH", body: '{"completed":true}' }),
45
+ )
46
+ ).status,
47
+ 404,
48
+ );
49
+ assert.equal(store.setCompleted("missing", true), null);
50
+ assert.equal(store.setCompleted(task.id, true).completed, true);
51
+ assert.equal(store.list().find((item) => item.id === task.id).completed, true);
52
+ });
@@ -0,0 +1,12 @@
1
+ schemaVersion: 1
2
+ name: refactor-module
3
+ task: |
4
+ Refactor invoice.mjs to remove the duplicated line-subtotal calculation.
5
+ Export a lineSubtotal(lines) helper and have both regularTotal and priorityTotal
6
+ call it. Keep all existing numeric behavior, discounts and shipping charges.
7
+ There should be a single reduce-based subtotal implementation. Do not change tests.
8
+ validation:
9
+ - command: node
10
+ args: [--test, "{validation}/check.test.mjs"]
11
+ timeout: 120
12
+ validationTimeout: 15
@@ -0,0 +1,8 @@
1
+ export function regularTotal(lines, discount = 0) {
2
+ const subtotal = lines.reduce((sum, line) => sum + line.price * line.quantity, 0);
3
+ return subtotal * (1 - discount) + 5;
4
+ }
5
+ export function priorityTotal(lines, discount = 0) {
6
+ const subtotal = lines.reduce((sum, line) => sum + line.price * line.quantity, 0);
7
+ return subtotal * (1 - discount) + 15;
8
+ }
@@ -0,0 +1,9 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { priorityTotal, regularTotal } from "./invoice.mjs";
4
+
5
+ test("totals", () => {
6
+ const lines = [{ price: 10, quantity: 2 }];
7
+ assert.equal(regularTotal(lines, 0.1), 23);
8
+ assert.equal(priorityTotal(lines, 0.1), 33);
9
+ });
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "refactor-fixture",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "test": "node --test"
7
+ }
8
+ }
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { test } from "node:test";
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ const invoice = await import(pathToFileURL(resolve("invoice.mjs")));
8
+ test("shared subtotal and preserved behavior", async () => {
9
+ assert.equal(typeof invoice.lineSubtotal, "function");
10
+ for (const lines of [
11
+ [],
12
+ [{ price: 10, quantity: 2 }],
13
+ [
14
+ { price: 1.5, quantity: 3 },
15
+ { price: 20, quantity: 2 },
16
+ ],
17
+ ]) {
18
+ const before = JSON.stringify(lines);
19
+ const subtotal = lines.reduce((total, line) => total + line.price * line.quantity, 0);
20
+ assert.equal(invoice.lineSubtotal(lines), subtotal);
21
+ for (const discount of [0, 0.1, 1]) {
22
+ assert.equal(invoice.regularTotal(lines, discount), subtotal * (1 - discount) + 5);
23
+ assert.equal(invoice.priorityTotal(lines, discount), subtotal * (1 - discount) + 15);
24
+ }
25
+ assert.equal(JSON.stringify(lines), before);
26
+ }
27
+ const source = await readFile("invoice.mjs", "utf8");
28
+ assert.equal(
29
+ (source.match(/\.reduce\s*\(/g) ?? []).length,
30
+ 1,
31
+ "One shared reduce implementation",
32
+ );
33
+ for (const name of ["regularTotal", "priorityTotal"]) {
34
+ assert.match(invoice[name].toString(), /lineSubtotal\s*\(/, `${name} must call the helper`);
35
+ }
36
+ });
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { parseArgs } from "node:util";
7
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
+ import { discoverModels, MISSING_KEY } from "../discovery.js";
9
+ import { applyModelSettings, loadSettings, settingsPath } from "../model-settings.js";
10
+ import { loadDefinition, positive } from "./definition.js";
11
+ import { redactor } from "./instrumentation.js";
12
+ import { terminalReport } from "./report.js";
13
+ import { runBenchmark } from "./runner.js";
14
+ const HELP = `Usage: pi-nebius benchmark --benchmark DIRECTORY --models ID,ID [options]
15
+
16
+ --benchmark PATH Directory with benchmark.yaml, or YAML/JSON definition file
17
+ --models IDS Comma-separated, exact Nebius model IDs (no automatic selection)
18
+ --runs N Repetitions per model (default: 1)
19
+ --timeout SECONDS Per-agent deadline, including worker/session startup
20
+ --output PATH New result directory (must not already exist)
21
+ --concurrency N v1 supports 1 only: identical Pi prompts without cwd rewriting
22
+ --help Show this help
23
+
24
+ Each run receives a fresh fixture copy and an isolated Pi configuration.
25
+ Pi tools have normal host access: filesystem copies are not a security sandbox.
26
+ Use a disposable machine/container for untrusted fixtures or model-generated shell commands.
27
+ No API key or prompt/response/tool content is retained in request traces.
28
+ `;
29
+ async function main() {
30
+ const args = process.argv.slice(2);
31
+ if (args[0] === "--help" || args[0] === "-h" || args.length === 0) {
32
+ console.log(HELP);
33
+ return;
34
+ }
35
+ if (args.shift() !== "benchmark")
36
+ throw new Error("Expected the benchmark subcommand. Use --help.");
37
+ const { values } = parseArgs({
38
+ args,
39
+ strict: true,
40
+ options: {
41
+ benchmark: { type: "string" },
42
+ models: { type: "string" },
43
+ runs: { type: "string" },
44
+ timeout: { type: "string" },
45
+ output: { type: "string" },
46
+ concurrency: { type: "string" },
47
+ help: { type: "boolean", short: "h" },
48
+ },
49
+ });
50
+ if (values.help) {
51
+ console.log(HELP);
52
+ return;
53
+ }
54
+ if (!values.benchmark || !values.models)
55
+ throw new Error("--benchmark and --models are required");
56
+ const runs = positive(Number(values.runs ?? 1), "runs", 1000);
57
+ const concurrency = positive(Number(values.concurrency ?? 1), "concurrency", 1);
58
+ const ids = values.models.split(",").map((id) => id.trim());
59
+ if (ids.some((id) => !id) || new Set(ids).size !== ids.length)
60
+ throw new Error("--models must contain nonempty, unique exact IDs");
61
+ if (runs * ids.length > 10000)
62
+ throw new Error("At most 10,000 runs per invocation");
63
+ const { definition, directory } = await loadDefinition(values.benchmark);
64
+ if (values.timeout)
65
+ definition.timeout = positive(Number(values.timeout), "timeout");
66
+ const apiKey = process.env.NEBIUS_API_KEY?.trim();
67
+ if (!apiKey)
68
+ throw new Error(MISSING_KEY);
69
+ const cache = await mkdtemp(join(tmpdir(), "pi-nebius-benchmark-discovery-"));
70
+ const discovered = await discoverModels({ apiKey, agentDir: cache, force: true }).finally(() => rm(cache, { recursive: true, force: true }));
71
+ if (discovered.warning)
72
+ process.stderr.write(`${discovered.warning}\n`);
73
+ const modelSettings = await loadSettings(settingsPath(getAgentDir()));
74
+ const models = ids.map((id) => {
75
+ const model = discovered.models.find((candidate) => candidate.id === id);
76
+ if (!model)
77
+ throw new Error(`Requested model was not discovered: ${id}. Check its exact ID and your access.`);
78
+ return applyModelSettings(model, modelSettings[id]);
79
+ });
80
+ const output = resolve(values.output ??
81
+ join("benchmark-results", `${new Date().toISOString().replaceAll(":", "-")}-${randomUUID().slice(0, 8)}`));
82
+ const controller = new AbortController();
83
+ const abort = () => controller.abort();
84
+ process.once("SIGINT", abort);
85
+ process.once("SIGTERM", abort);
86
+ try {
87
+ const results = await runBenchmark({
88
+ definition,
89
+ directory,
90
+ models,
91
+ modelSettings,
92
+ runs,
93
+ concurrency,
94
+ output,
95
+ apiKey,
96
+ signal: controller.signal,
97
+ onRun: (run) => process.stderr.write(`${run.model} #${run.run}: ${run.success ? "PASS" : run.failure}\n`),
98
+ });
99
+ console.log(terminalReport(results));
100
+ console.log(`Results: ${join(output, "results.json")}`);
101
+ process.exitCode =
102
+ results.status === "cancelled" ? 130 : results.runs.every((run) => run.success) ? 0 : 1;
103
+ }
104
+ finally {
105
+ process.removeListener("SIGINT", abort);
106
+ process.removeListener("SIGTERM", abort);
107
+ }
108
+ }
109
+ void main().catch((error) => {
110
+ console.error(redactor([process.env.NEBIUS_API_KEY ?? ""])(String(error)));
111
+ process.exitCode = 2;
112
+ });
@@ -0,0 +1,194 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
3
+ import { findPackageJSON } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { parseArgs } from "node:util";
8
+ import { MISSING_KEY } from "../discovery.js";
9
+ import { applyModelSettings } from "../model-settings.js";
10
+ import { loadDefinition, positive } from "./definition.js";
11
+ import { redactor } from "./instrumentation.js";
12
+ import { snapshotProject } from "./project.js";
13
+ import { terminalReport } from "./report.js";
14
+ import { runBenchmark } from "./runner.js";
15
+ const tasks = ["fix-auth-bug", "add-api-endpoint", "refactor-module", "multi-file-feature"];
16
+ const help = `Usage: /nebius-benchmark [--models ID,ID] [--runs N]
17
+ Enter your task prompt in the editor. Each run gets a fresh copy of the current project.
18
+ Without --models, uses the selected Nebius model. Default: 1 run per model.
19
+ Optional: --task NAME uses a bundled task instead (${tasks.join(", ")}).
20
+ /nebius-benchmark cancel stops the active benchmark.
21
+ Results appear here and in benchmark-results/. Custom prompts have no correctness check.
22
+ Copies exclude Git-ignored files, dependencies, build output, and known credential files.
23
+ Runs use paid inference and tools with normal host access; copies are not a security sandbox.`;
24
+ export async function hostPiEntry() {
25
+ // The running Pi CLI can be a symlink into a global installation.
26
+ const base = process.argv[1]
27
+ ? pathToFileURL(await realpath(process.argv[1])).href
28
+ : import.meta.url;
29
+ const manifestPath = findPackageJSON("@earendil-works/pi-coding-agent", base);
30
+ if (!manifestPath)
31
+ throw new Error("Cannot locate the host Pi SDK. Use the Node.js Pi installation.");
32
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
33
+ return join(dirname(manifestPath), manifest.main);
34
+ }
35
+ export function registerBenchmarkCommand(pi, getSettings = () => ({})) {
36
+ let starting = false;
37
+ let active;
38
+ const show = (content) => pi.sendMessage({ customType: "nebius-benchmark", content, display: true }, { triggerTurn: false });
39
+ pi.registerCommand("nebius-benchmark", {
40
+ description: "Compare models on your prompt: --models ID,ID --runs N; help or cancel",
41
+ handler: async (args, ctx) => {
42
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
43
+ const action = tokens[0];
44
+ if (action === "help" || action === "list") {
45
+ show(help);
46
+ return;
47
+ }
48
+ if (action === "cancel") {
49
+ if (active) {
50
+ active.controller.abort();
51
+ ctx.ui.notify("Stopping benchmark…", "info");
52
+ }
53
+ else
54
+ ctx.ui.notify("No benchmark is running.", "info");
55
+ return;
56
+ }
57
+ if (active || starting) {
58
+ ctx.ui.notify("A benchmark is already running. Use /nebius-benchmark cancel.", "warning");
59
+ return;
60
+ }
61
+ const key = process.env.NEBIUS_API_KEY?.trim();
62
+ const redact = redactor([key ?? ""]);
63
+ starting = true;
64
+ let scratch;
65
+ try {
66
+ const { values } = parseArgs({
67
+ args: tokens,
68
+ options: {
69
+ models: { type: "string" },
70
+ runs: { type: "string" },
71
+ task: { type: "string" },
72
+ },
73
+ });
74
+ const runs = positive(Number(values.runs ?? 1), "runs", 1000);
75
+ if (values.task && !tasks.includes(values.task))
76
+ throw new Error(help);
77
+ let selected = ctx.model ? [ctx.model] : [];
78
+ if (values.models !== undefined) {
79
+ const ids = values.models.split(",");
80
+ if (ids.some((id) => !id) || new Set(ids).size !== ids.length)
81
+ throw new Error("--models requires unique, comma-separated model IDs.");
82
+ const available = ctx.modelRegistry.getAll();
83
+ selected = ids.map((id) => {
84
+ const model = available.find((candidate) => candidate.provider === "nebius" && candidate.id === id);
85
+ if (!model)
86
+ throw new Error(`Unknown Nebius model: ${id}. Run /nebius-refresh or check /model.`);
87
+ return model;
88
+ });
89
+ }
90
+ if (!selected.length ||
91
+ selected.some((model) => model.provider !== "nebius" || model.api !== "openai-completions"))
92
+ throw new Error("Select a Nebius model with /model first.");
93
+ const total = runs * selected.length;
94
+ if (total > 10000)
95
+ throw new Error("At most 10,000 runs per benchmark.");
96
+ if (!key)
97
+ throw new Error(MISSING_KEY);
98
+ const piEntry = await hostPiEntry();
99
+ let definition;
100
+ let directory;
101
+ const task = values.task ?? "custom-prompt";
102
+ if (values.task) {
103
+ const root = fileURLToPath(new URL("../../", import.meta.url));
104
+ ({ definition, directory } = await loadDefinition(join(root, "benchmarks", task)));
105
+ }
106
+ else {
107
+ if (!ctx.hasUI)
108
+ throw new Error("Custom prompts need interactive Pi. Use --task for a bundled task.");
109
+ const prompt = await ctx.ui.editor("Benchmark task — what should each model do?");
110
+ if (!prompt?.trim())
111
+ return;
112
+ scratch = await mkdtemp(join(tmpdir(), "pi-nebius-project-"));
113
+ directory = scratch;
114
+ const copied = await snapshotProject(ctx.cwd, join(directory, "fixture"));
115
+ await mkdir(join(directory, "validation"));
116
+ definition = {
117
+ schemaVersion: 1,
118
+ name: task,
119
+ task: prompt.trim(),
120
+ fixture: "fixture",
121
+ validationDirectory: "validation",
122
+ validation: [],
123
+ validationMode: "none",
124
+ setup: [],
125
+ timeout: 600,
126
+ validationTimeout: 60,
127
+ tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
128
+ };
129
+ ctx.ui.notify(`Copied ${copied} project files for comparison. Dependencies and ignored files are excluded.`, "info");
130
+ }
131
+ // Snapshot selection; changing the interactive model does not change an active run.
132
+ const modelSettings = structuredClone(getSettings());
133
+ const models = structuredClone(selected).map((model) => applyModelSettings(model, modelSettings[model.id]));
134
+ let completed = 0;
135
+ const output = join(ctx.cwd, "benchmark-results", `${task}-${randomUUID()}`);
136
+ const controller = new AbortController();
137
+ show(`Starting ${task}: ${models.map((model) => model.id).join(", ")}, ${runs} run(s) each. Uses paid inference; tools have normal host access.\nUse /nebius-benchmark cancel to stop.`);
138
+ ctx.ui.setStatus("nebius-benchmark", `Benchmark: ${task} (0/${total})`);
139
+ const snapshot = scratch;
140
+ scratch = undefined; // The background run owns cleanup from here.
141
+ const done = runBenchmark({
142
+ definition,
143
+ directory,
144
+ models,
145
+ modelSettings,
146
+ runs,
147
+ concurrency: 1,
148
+ output,
149
+ apiKey: key,
150
+ signal: controller.signal,
151
+ piEntry,
152
+ workerPath: fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./host-worker.ts" : "./host-worker.js", import.meta.url)),
153
+ workerArgs: [piEntry],
154
+ onRun: (run) => {
155
+ ctx.ui.setStatus("nebius-benchmark", `Benchmark: ${task} (${++completed}/${total})`);
156
+ ctx.ui.notify(`${run.model} #${run.run}: ${run.failure ?? (run.validation.checked ? "PASS" : "FINISHED (not validated)")}`, "info");
157
+ },
158
+ })
159
+ .then((results) => {
160
+ show(redact(`${terminalReport(results)}\nDetails: ${join(output, "results.json")}`));
161
+ })
162
+ .catch((error) => {
163
+ show(redact(`Benchmark failed: ${String(error)}`));
164
+ })
165
+ .finally(async () => {
166
+ try {
167
+ if (snapshot)
168
+ await rm(snapshot, { recursive: true, force: true });
169
+ }
170
+ catch (error) {
171
+ ctx.ui.notify(redact(`Could not remove temporary snapshot: ${String(error)}`), "warning");
172
+ }
173
+ finally {
174
+ active = undefined;
175
+ ctx.ui.setStatus("nebius-benchmark", undefined);
176
+ }
177
+ });
178
+ active = { controller, done };
179
+ }
180
+ catch (error) {
181
+ ctx.ui.notify(redact(String(error)), "error");
182
+ }
183
+ finally {
184
+ if (scratch)
185
+ await rm(scratch, { recursive: true, force: true });
186
+ starting = false;
187
+ }
188
+ },
189
+ });
190
+ pi.on("session_shutdown", async () => {
191
+ active?.controller.abort();
192
+ await active?.done;
193
+ });
194
+ }
@@ -0,0 +1,109 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { isRecord } from "../models.js";
5
+ export function positive(value, name, maximum = 86400) {
6
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > maximum)
7
+ throw new Error(`${name} must be an integer between 1 and ${maximum}`);
8
+ return value;
9
+ }
10
+ function text(value, name) {
11
+ if (typeof value !== "string" || !value.trim())
12
+ throw new Error(`${name} must be nonempty text`);
13
+ return value;
14
+ }
15
+ function fields(value, allowed) {
16
+ for (const key of Object.keys(value))
17
+ if (!allowed.includes(key))
18
+ throw new Error(`Unknown field: ${key}`);
19
+ }
20
+ function commands(value, name) {
21
+ if (!Array.isArray(value))
22
+ throw new Error(`${name} must be an array`);
23
+ return value.map((item) => {
24
+ if (typeof item === "string")
25
+ return { command: "/bin/sh", args: ["-c", text(item, name)] };
26
+ if (!isRecord(item))
27
+ throw new Error(`Invalid ${name} command`);
28
+ fields(item, ["command", "args"]);
29
+ if (item.args !== undefined &&
30
+ (!Array.isArray(item.args) || item.args.some((arg) => typeof arg !== "string")))
31
+ throw new Error(`${name} args must be strings`);
32
+ return { command: text(item.command, "command"), args: (item.args ?? []) };
33
+ });
34
+ }
35
+ function localPath(value, fallback) {
36
+ const path = value === undefined ? fallback : text(value, "path");
37
+ if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
38
+ throw new Error("Benchmark paths must be relative and inside its directory");
39
+ return path;
40
+ }
41
+ export function parseDefinition(source) {
42
+ const value = parse(source, { maxAliasCount: 0, uniqueKeys: true });
43
+ if (!isRecord(value))
44
+ throw new Error("Benchmark definition must be an object");
45
+ fields(value, [
46
+ "schemaVersion",
47
+ "name",
48
+ "task",
49
+ "fixture",
50
+ "validationDirectory",
51
+ "validation",
52
+ "setup",
53
+ "timeout",
54
+ "validationTimeout",
55
+ "tools",
56
+ "systemPrompt",
57
+ ]);
58
+ if (value.schemaVersion !== undefined && value.schemaVersion !== 1)
59
+ throw new Error("Unsupported benchmark schemaVersion");
60
+ const tools = value.tools ?? ["read", "bash", "edit", "write"];
61
+ const available = ["read", "bash", "edit", "write", "grep", "find", "ls"];
62
+ if (!Array.isArray(tools) ||
63
+ !tools.length ||
64
+ tools.some((tool) => !available.includes(tool)) ||
65
+ new Set(tools).size !== tools.length)
66
+ throw new Error("tools must be a nonempty, unique list of Pi built-in tools");
67
+ const validation = commands(value.validation, "validation");
68
+ if (!validation.length)
69
+ throw new Error("At least one validation command is required");
70
+ return {
71
+ schemaVersion: 1,
72
+ name: text(value.name, "name"),
73
+ task: text(value.task, "task"),
74
+ fixture: localPath(value.fixture, "fixture"),
75
+ validationDirectory: localPath(value.validationDirectory, "validation"),
76
+ validation,
77
+ setup: commands(value.setup ?? [], "setup"),
78
+ timeout: positive(value.timeout ?? 600, "timeout"),
79
+ validationTimeout: positive(value.validationTimeout ?? 60, "validationTimeout"),
80
+ tools,
81
+ ...(value.systemPrompt === undefined
82
+ ? {}
83
+ : { systemPrompt: text(value.systemPrompt, "systemPrompt") }),
84
+ };
85
+ }
86
+ export async function loadDefinition(path) {
87
+ const definitionFile = path.endsWith(".yaml") || path.endsWith(".yml") || path.endsWith(".json")
88
+ ? resolve(path)
89
+ : resolve(path, "benchmark.yaml");
90
+ const source = await readFile(definitionFile, "utf8");
91
+ if (Buffer.byteLength(source) > 1024 * 1024)
92
+ throw new Error("Definition exceeds 1 MiB");
93
+ const definition = parseDefinition(source);
94
+ const directory = await realpath(dirname(definitionFile));
95
+ const directories = [];
96
+ for (const child of [definition.fixture, definition.validationDirectory]) {
97
+ const actual = await realpath(join(directory, child));
98
+ directories.push(actual);
99
+ const rel = relative(directory, actual);
100
+ if (!rel || rel.startsWith("..") || isAbsolute(rel))
101
+ throw new Error("Fixture/validation must be separate directories inside the benchmark");
102
+ }
103
+ const [fixture, validators] = directories;
104
+ if (fixture &&
105
+ validators &&
106
+ [relative(fixture, validators), relative(validators, fixture)].some((path) => !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`)))
107
+ throw new Error("Fixture and validation directories must be separate and must not overlap");
108
+ return { definition, directory, source };
109
+ }
@@ -0,0 +1,14 @@
1
+ import { registerHooks } from "node:module";
2
+ import { pathToFileURL } from "node:url";
3
+ // Reuse the installed host's SDK; Git installs intentionally omit Pi dev dependencies.
4
+ const hostEntry = process.argv[2];
5
+ if (!hostEntry)
6
+ throw new Error("Missing host Pi entry");
7
+ registerHooks({
8
+ resolve(specifier, context, nextResolve) {
9
+ if (specifier.startsWith("@earendil-works/"))
10
+ return nextResolve(specifier, { ...context, parentURL: pathToFileURL(hostEntry).href });
11
+ return nextResolve(specifier, context);
12
+ },
13
+ });
14
+ await import("./worker.js");