anpord 0.1.2 → 0.1.4

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/dist/cli.mjs CHANGED
@@ -1,20 +1,200 @@
1
1
  #!/usr/bin/env node
2
- import { a as ChannelName, o as PromptId, r as layer, s as VersionNumber, t as AnpordApi } from "./client-bJBNwTKq.mjs";
2
+ import { a as ChannelName, o as PromptId, r as layer, s as VersionNumber, t as AnpordApi } from "./client-BfhmGRQl.mjs";
3
3
  import { clientOptionsConfig } from "./config.mjs";
4
- import { r as asAnpordError } from "./errors-DEfS6oaQ.mjs";
4
+ import { i as tokenMatcher, r as asAnpordError } from "./errors-C9bQUcA3.mjs";
5
+ import { n as compileEvalEffect } from "./compiler-CKiewwUc.mjs";
5
6
  import { FileSystem } from "@effect/platform";
6
- import { Cause, Console, Effect, Layer, Option } from "effect";
7
+ import { Cause, Clock, Console, Data, Duration, Effect, Layer, Option, Ref } from "effect";
7
8
  import { Args, Command, Options } from "@effect/cli";
8
9
  import { NodeContext, NodeRuntime } from "@effect/platform-node";
10
+ //#region package.json
11
+ var version$1 = "0.1.4";
12
+ //#endregion
13
+ //#region ../template/src/extract.ts
14
+ /**
15
+ * Distinct variable names in source order, so callers can count or list them.
16
+ *
17
+ * Escaped braces are read here too, so a name the renderer will treat as
18
+ * literal text is never reported as a variable the editor should draw.
19
+ */
20
+ function extractVariables(template) {
21
+ const names = [];
22
+ for (const [, open, close, name] of template.matchAll(tokenMatcher())) if (open === void 0 && close === void 0 && name !== void 0) names.push(name);
23
+ return [...new Set(names)];
24
+ }
25
+ //#endregion
26
+ //#region src/cli/declarations.ts
27
+ /**
28
+ * The generated file has to open by importing the module it augments.
29
+ *
30
+ * Without it `declare module` shadows the module rather than adding to it, and
31
+ * every import of the SDK stops resolving with an error naming the missing
32
+ * export rather than the file that hid it.
33
+ */
34
+ const PREAMBLE = [
35
+ "// Generated by `anpord generate`. Do not edit.",
36
+ "// Run it again after changing which variables a prompt uses.",
37
+ "import \"anpord\";",
38
+ "",
39
+ "declare module \"anpord\" {",
40
+ " interface AnpordPromptVariables {"
41
+ ].join("\n");
42
+ /** Quoted, because an id may carry characters an identifier cannot. */
43
+ const entry = (id, names) => {
44
+ return ` "${id}": ${names.length === 0 ? "Record<string, never>" : `{ ${names.map((name) => `"${name}": string`).join("; ")} }`};`;
45
+ };
46
+ const declarationFile = (prompts) => [
47
+ PREAMBLE,
48
+ ...[...prompts].sort(([left], [right]) => left.localeCompare(right)).map(([id, names]) => entry(id, names)),
49
+ " }",
50
+ "}",
51
+ ""
52
+ ].join("\n");
53
+ //#endregion
54
+ //#region src/cli/eval-files.ts
55
+ const SUFFIX = ".eval.ts";
56
+ const SKIPPED = /* @__PURE__ */ new Set([
57
+ "node_modules",
58
+ "dist",
59
+ "build",
60
+ ".git"
61
+ ]);
62
+ const hidden = (segment) => segment.startsWith(".");
63
+ const wanted = (path) => {
64
+ const segments = path.split("/");
65
+ return path.endsWith(SUFFIX) && segments.every((segment) => !(SKIPPED.has(segment) || hidden(segment)));
66
+ };
67
+ const evalFilesIn = (directory) => Effect.gen(function* () {
68
+ return (yield* (yield* FileSystem.FileSystem).readDirectory(directory, { recursive: true })).filter(wanted).sort();
69
+ }).pipe(Effect.withSpan("Cli.evalFilesIn"));
70
+ //#endregion
71
+ //#region src/cli/eval-gate.ts
72
+ const regressions = (run) => run.cells.filter((cell) => cell.comparison?.verdict === "regressed");
73
+ const unscored = (run) => run.cells.filter((cell) => (cell.distribution?.scored ?? 0) === 0);
74
+ const problemsWith = (run, failOn) => {
75
+ if (run.status === "failed") return [run.failure ?? "The run failed."];
76
+ if (failOn === "never") return [];
77
+ const found = regressions(run).map((cell) => `${cell.caseName} regressed against its baseline.`);
78
+ return failOn === "unscored" ? [...found, ...unscored(run).map((cell) => `${cell.caseName} produced no scored trials.`)] : found;
79
+ };
80
+ const failWhen = (problems) => problems.length === 0 ? Effect.void : Effect.fail(new EvalGateFailed({ problems }));
81
+ var EvalGateFailed = class extends Data.TaggedError("EvalGateFailed") {
82
+ get message() {
83
+ return this.problems.join("\n");
84
+ }
85
+ };
86
+ var NoEvalFiles = class extends Data.TaggedError("NoEvalFiles") {
87
+ get message() {
88
+ return "No *.eval.ts file here. Name one, or pass a file to run.";
89
+ }
90
+ };
91
+ //#endregion
9
92
  //#region src/cli/render.ts
10
93
  const json = (value) => Console.log(JSON.stringify(value, null, 2));
11
94
  const promptContent = (prompt) => Effect.sync(() => {
12
95
  process.stdout.write(prompt.content);
13
96
  if (!prompt.content.endsWith("\n")) process.stdout.write("\n");
14
97
  });
98
+ /** A result the caller may pipe into another tool, so it belongs on stdout
99
+ * alongside the prompt content rather than beside the status messages. */
100
+ const row = (line) => Effect.sync(() => {
101
+ process.stdout.write(`${line}\n`);
102
+ });
15
103
  const note = (message) => Effect.sync(() => {
16
104
  process.stderr.write(`${message}\n`);
17
105
  });
106
+ const attended = Effect.sync(() => globalThis.process?.stdout?.isTTY === true);
107
+ //#endregion
108
+ //#region src/cli/eval-grid.ts
109
+ const DIM = "\x1B[2m";
110
+ const BOLD = "\x1B[1m";
111
+ const GREEN = "\x1B[32m";
112
+ const RED = "\x1B[31m";
113
+ const YELLOW = "\x1B[33m";
114
+ const RESET = "\x1B[0m";
115
+ const DONE = "●";
116
+ const RUNNING = "◐";
117
+ const FILLED = "▰";
118
+ const HOLLOW = "▱";
119
+ const PERCENT = 100;
120
+ const SECONDS = 1e3;
121
+ const MINUTE = 60;
122
+ const paint = (colour, text) => `${colour}${text}${RESET}`;
123
+ const elapsedOf = (ms) => {
124
+ const total = Math.floor(ms / SECONDS);
125
+ const minutes = Math.floor(total / MINUTE);
126
+ return minutes === 0 ? `${total}s` : `${minutes}m${String(total % MINUTE).padStart(2, "0")}s`;
127
+ };
128
+ const markOf = (cell) => {
129
+ if (cell.status === "finished") return paint(GREEN, DONE);
130
+ return cell.status === "failed" ? paint(RED, DONE) : paint(YELLOW, RUNNING);
131
+ };
132
+ const trialsOf = (cell, trials) => {
133
+ const settled = cell.trials.filter((trial) => trial.status !== "queued" && trial.status !== "running").length;
134
+ return `${FILLED.repeat(settled)}${paint(DIM, HOLLOW.repeat(Math.max(0, trials - settled)))}`;
135
+ };
136
+ const rateOf = (cell) => {
137
+ const rate = cell.distribution?.passRate;
138
+ if (rate === void 0 || cell.distribution?.scored === 0) return paint(DIM, "—");
139
+ const shown = `${Math.round(rate * PERCENT)}%`;
140
+ return paint(rate === 1 ? GREEN : RED, shown);
141
+ };
142
+ const variantOf = (run, cell) => {
143
+ const task = run.tasks[cell.taskIndex];
144
+ return task === void 0 ? "?" : `${task.harness}/${task.model}`;
145
+ };
146
+ const widest = (run) => run.cells.reduce((width, cell) => Math.max(width, variantOf(run, cell).length), 0);
147
+ const gridOf = (run, trials, elapsedMs) => {
148
+ const width = widest(run);
149
+ const lines = [];
150
+ for (const caseName of run.cases) {
151
+ lines.push(` ${BOLD}${caseName}${RESET}`);
152
+ for (const cell of run.cells.filter((one) => one.caseName === caseName)) lines.push(` ${markOf(cell)} ${variantOf(run, cell).padEnd(width)} ${trialsOf(cell, trials)} ${rateOf(cell)}`);
153
+ lines.push("");
154
+ }
155
+ lines.push(paint(DIM, ` ${elapsedOf(elapsedMs)} elapsed`));
156
+ return lines;
157
+ };
158
+ const up = (rows) => `[${rows}A`;
159
+ const liveGrid = (trials, interactive) => Effect.gen(function* () {
160
+ const drawn = yield* Ref.make(0);
161
+ return (run, elapsedMs) => Effect.gen(function* () {
162
+ if (!interactive) return;
163
+ const rows = yield* Ref.getAndSet(drawn, 0);
164
+ const lines = gridOf(run, trials, elapsedMs);
165
+ yield* note(`${rows === 0 ? "" : up(rows)}${lines.join("\n")}`);
166
+ yield* Ref.set(drawn, lines.length);
167
+ });
168
+ });
169
+ const summaryOf = (run, trials, drawn) => drawn ? "" : gridOf(run, trials, 0).join("\n");
170
+ //#endregion
171
+ //#region src/cli/eval-run.ts
172
+ const FIRST_POLL = 2e3;
173
+ const SLOWEST_POLL = 1e4;
174
+ const WIDENING = 1.5;
175
+ const running = ({ run }) => run === null || run.status === "running";
176
+ const widen = (gap) => Math.min(Math.round(gap * WIDENING), SLOWEST_POLL);
177
+ const waitForRun = (id, onProgress) => Effect.gen(function* () {
178
+ const api = yield* AnpordApi;
179
+ const startedAt = yield* Clock.currentTimeMillis;
180
+ const step = ({ gap, run }) => Effect.gen(function* () {
181
+ if (run !== null) yield* Effect.sleep(Duration.millis(gap));
182
+ const next = yield* api.evals.get({ payload: { id } });
183
+ yield* onProgress(next, (yield* Clock.currentTimeMillis) - startedAt);
184
+ return {
185
+ gap: widen(gap),
186
+ run: next
187
+ };
188
+ });
189
+ const { run } = yield* Effect.iterate({
190
+ gap: FIRST_POLL,
191
+ run: null
192
+ }, {
193
+ body: step,
194
+ while: running
195
+ });
196
+ return run ?? (yield* api.evals.get({ payload: { id } }));
197
+ }).pipe(Effect.withSpan("Cli.waitForRun", { attributes: { runId: id } }));
18
198
  //#endregion
19
199
  //#region src/cli/commands.ts
20
200
  const promptId = Args.text({ name: "id" }).pipe(Args.withDescription("The prompt's id, such as support-reply"), Args.withSchema(PromptId));
@@ -38,7 +218,7 @@ const get = Command.make("get", {
38
218
  const list = Command.make("list", { asJson }, ({ asJson: wantsJson }) => Effect.gen(function* () {
39
219
  const { data } = yield* (yield* AnpordApi).prompts.list({ payload: {} });
40
220
  if (wantsJson) return yield* json(data);
41
- return yield* Effect.forEach(data, (row) => note(`${row.id}\tv${row.latestVersion ?? "-"}\t${row.name}`));
221
+ return yield* Effect.forEach(data, (summary) => row(`${summary.id}\tv${summary.latestVersion ?? "-"}\t${summary.name}`));
42
222
  })).pipe(Command.withDescription("List every prompt"));
43
223
  const versions = Command.make("versions", { promptId }, ({ promptId: id }) => Effect.gen(function* () {
44
224
  const prompt = yield* (yield* AnpordApi).prompts.get({ payload: {
@@ -59,25 +239,100 @@ const promote = Command.make("promote", {
59
239
  } });
60
240
  return yield* note(`${id} v${pin} is now ${to}`);
61
241
  })).pipe(Command.withDescription("Point a channel at a version"));
62
- const readStdin = Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString("/dev/stdin"));
242
+ /**
243
+ * Read from the stream rather than by opening `/dev/stdin` as a file. The path
244
+ * only names the pipe, so reading it races whoever is writing: a body arriving
245
+ * in more than one chunk, which is what a pipe does under load, was read as
246
+ * whatever had landed by then.
247
+ */
248
+ const readStdin = Effect.async((resume) => {
249
+ let body = "";
250
+ process.stdin.setEncoding("utf8");
251
+ process.stdin.on("data", (chunk) => {
252
+ body += chunk;
253
+ });
254
+ process.stdin.on("end", () => resume(Effect.succeed(body)));
255
+ process.stdin.on("error", (cause) => resume(Effect.fail(new Error("Could not read the content from stdin", { cause }))));
256
+ });
63
257
  const target = Args.all([promptId, Args.text({ name: "content" }).pipe(Args.withDescription("The new content, or - to read stdin"))]);
258
+ const push = Command.make("push", {
259
+ message,
260
+ target
261
+ }, ({ message: why, target: [id, content] }) => Effect.gen(function* () {
262
+ const api = yield* AnpordApi;
263
+ const body = content === "-" ? yield* readStdin : content;
264
+ const prompt = yield* api.prompts.update({ payload: {
265
+ content: body,
266
+ id,
267
+ message: Option.getOrUndefined(why)
268
+ } });
269
+ return yield* note(`${id} is now v${prompt.version}`);
270
+ })).pipe(Command.withDescription("Add a version to a prompt"));
271
+ const out = Options.file("out").pipe(Options.withDescription("Where to write the declarations"), Options.withDefault("anpord-env.d.ts"));
272
+ /** Reading one prompt at a time because the list carries no content, bounded
273
+ * so a large organisation does not open a connection per prompt. */
274
+ const READ_AT_ONCE = 8;
275
+ const writeDeclarations = ({ out: path }) => Effect.gen(function* () {
276
+ const api = yield* AnpordApi;
277
+ const fs = yield* FileSystem.FileSystem;
278
+ const { data } = yield* api.prompts.list({ payload: {} });
279
+ const prompts = yield* Effect.forEach(data, (summary) => api.prompts.get({ payload: { id: summary.id } }).pipe(Effect.map((prompt) => [prompt.id, extractVariables(prompt.content)])), { concurrency: READ_AT_ONCE });
280
+ yield* fs.writeFileString(path, declarationFile(prompts));
281
+ return yield* note(`Wrote ${prompts.length} ${prompts.length === 1 ? "prompt" : "prompts"} to ${path}`);
282
+ });
283
+ const DESCRIPTION = "Write TypeScript declarations for prompt variables";
284
+ const generate = Command.make("generate", { out }, writeDeclarations).pipe(Command.withDescription(DESCRIPTION));
285
+ /** A second command rather than an alias, because a command carries one name
286
+ * and the shorter one is what anybody types twice. */
287
+ const gen = Command.make("gen", { out }, writeDeclarations).pipe(Command.withDescription(DESCRIPTION));
288
+ const evalFile = Args.text({ name: "file" }).pipe(Args.withDescription("A TypeScript file that default exports defineEval(...); every *.eval.ts is run when omitted"), Args.optional);
289
+ const noWait = Options.boolean("no-wait").pipe(Options.withDescription("Start the run and print its id, without waiting"));
290
+ const failOn = Options.choice("fail-on", [
291
+ "never",
292
+ "regressed",
293
+ "unscored"
294
+ ]).pipe(Options.withDescription("What makes the command exit nonzero"), Options.withDefault("regressed"));
295
+ const runOneEval = (file, options) => Effect.gen(function* () {
296
+ const api = yield* AnpordApi;
297
+ const payload = yield* compileEvalEffect(file);
298
+ const started = yield* api.evals.start({ payload });
299
+ if (options.skipWait) {
300
+ yield* json(started);
301
+ return [];
302
+ }
303
+ const live = !options.wantsJson && (yield* attended);
304
+ if (live || options.label) yield* note(`${file} · run ${started.id}`);
305
+ const draw = yield* liveGrid(payload.trials, live);
306
+ const run = yield* waitForRun(started.id, draw);
307
+ yield* options.wantsJson ? json(run) : note(summaryOf(run, payload.trials, live));
308
+ return problemsWith(run, options.gate);
309
+ });
64
310
  const commands = [
311
+ Command.make("eval", {
312
+ asJson,
313
+ evalFile,
314
+ failOn,
315
+ noWait
316
+ }, ({ asJson: wantsJson, evalFile: file, failOn: gate, noWait: skipWait }) => Effect.gen(function* () {
317
+ const files = yield* Option.match(file, {
318
+ onNone: () => evalFilesIn("."),
319
+ onSome: (one) => Effect.succeed([one])
320
+ });
321
+ if (files.length === 0) return yield* Effect.fail(new NoEvalFiles());
322
+ const found = yield* Effect.forEach(files, (one) => runOneEval(one, {
323
+ gate,
324
+ label: files.length > 1,
325
+ skipWait,
326
+ wantsJson
327
+ }));
328
+ return yield* failWhen(found.flat());
329
+ })).pipe(Command.withDescription("Compile and run an eval from TypeScript")),
330
+ gen,
331
+ generate,
65
332
  get,
66
333
  list,
67
334
  promote,
68
- Command.make("push", {
69
- message,
70
- target
71
- }, ({ message: why, target: [id, content] }) => Effect.gen(function* () {
72
- const api = yield* AnpordApi;
73
- const body = content === "-" ? yield* readStdin : content;
74
- const prompt = yield* api.prompts.update({ payload: {
75
- content: body,
76
- id,
77
- message: Option.getOrUndefined(why)
78
- } });
79
- return yield* note(`${id} is now v${prompt.version}`);
80
- })).pipe(Command.withDescription("Add a version to a prompt")),
335
+ push,
81
336
  versions
82
337
  ];
83
338
  //#endregion
@@ -94,11 +349,11 @@ const reportFailure = (error) => Effect.sync(() => {
94
349
  });
95
350
  //#endregion
96
351
  //#region src/cli/main.ts
97
- const anpord = Command.make("anpord").pipe(Command.withDescription("Read and publish prompts from the terminal"), Command.withSubcommands(commands));
352
+ const anpord = Command.make("anpord").pipe(Command.withDescription("Run evals and manage prompts from the terminal"), Command.withSubcommands(commands));
98
353
  const ClientLayer = Layer.unwrapEffect(Effect.map(clientOptionsConfig, layer));
99
354
  Command.run(anpord, {
100
355
  name: "Anpord",
101
- version: "0.1.0"
356
+ version: version$1
102
357
  })(process.argv).pipe(Effect.provide(Layer.mergeAll(ClientLayer, NodeContext.layer)), Effect.catchAllCause((cause) => Cause.isInterruptedOnly(cause) ? Effect.void : reportFailure(Cause.failureOption(cause).pipe(Option.getOrElse(() => Cause.squash(cause))))), NodeRuntime.runMain({ disableErrorReporting: true }));
103
358
  //#endregion
104
359
  export {};