@crowandraven/evalkeeper 0.0.0-stage → 0.1.1

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 (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/bin/evalkeeper.mjs +9 -0
  4. package/dist/attention.d.ts +49 -0
  5. package/dist/attention.js +48 -0
  6. package/dist/attention.js.map +1 -0
  7. package/dist/board.d.ts +55 -0
  8. package/dist/board.js +2 -0
  9. package/dist/board.js.map +1 -0
  10. package/dist/cli/addPages.d.ts +79 -0
  11. package/dist/cli/addPages.js +181 -0
  12. package/dist/cli/addPages.js.map +1 -0
  13. package/dist/cli/main.d.ts +18 -0
  14. package/dist/cli/main.js +115 -0
  15. package/dist/cli/main.js.map +1 -0
  16. package/dist/common.d.ts +72 -0
  17. package/dist/common.js +276 -0
  18. package/dist/common.js.map +1 -0
  19. package/dist/compare.d.ts +85 -0
  20. package/dist/compare.js +185 -0
  21. package/dist/compare.js.map +1 -0
  22. package/dist/decisions.d.ts +84 -0
  23. package/dist/decisions.js +146 -0
  24. package/dist/decisions.js.map +1 -0
  25. package/dist/feedback.d.ts +114 -0
  26. package/dist/feedback.js +116 -0
  27. package/dist/feedback.js.map +1 -0
  28. package/dist/format.d.ts +6 -0
  29. package/dist/format.js +22 -0
  30. package/dist/format.js.map +1 -0
  31. package/dist/glossary.d.ts +46 -0
  32. package/dist/glossary.js +45 -0
  33. package/dist/glossary.js.map +1 -0
  34. package/dist/health.d.ts +40 -0
  35. package/dist/health.js +101 -0
  36. package/dist/health.js.map +1 -0
  37. package/dist/index.d.ts +31 -0
  38. package/dist/index.js +34 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/labels.d.ts +8 -0
  41. package/dist/labels.js +15 -0
  42. package/dist/labels.js.map +1 -0
  43. package/dist/legs.d.ts +23 -0
  44. package/dist/legs.js +22 -0
  45. package/dist/legs.js.map +1 -0
  46. package/dist/memoryStore.d.ts +21 -0
  47. package/dist/memoryStore.js +228 -0
  48. package/dist/memoryStore.js.map +1 -0
  49. package/dist/models.d.ts +50 -0
  50. package/dist/models.js +39 -0
  51. package/dist/models.js.map +1 -0
  52. package/dist/prune.d.ts +48 -0
  53. package/dist/prune.js +77 -0
  54. package/dist/prune.js.map +1 -0
  55. package/dist/quickSet.d.ts +26 -0
  56. package/dist/quickSet.js +44 -0
  57. package/dist/quickSet.js.map +1 -0
  58. package/dist/runner.d.ts +193 -0
  59. package/dist/runner.js +706 -0
  60. package/dist/runner.js.map +1 -0
  61. package/dist/settings.d.ts +157 -0
  62. package/dist/settings.js +183 -0
  63. package/dist/settings.js.map +1 -0
  64. package/dist/stableJson.d.ts +11 -0
  65. package/dist/stableJson.js +15 -0
  66. package/dist/stableJson.js.map +1 -0
  67. package/dist/stats.d.ts +98 -0
  68. package/dist/stats.js +119 -0
  69. package/dist/stats.js.map +1 -0
  70. package/dist/store.d.ts +146 -0
  71. package/dist/store.js +3 -0
  72. package/dist/store.js.map +1 -0
  73. package/dist/suggest.d.ts +75 -0
  74. package/dist/suggest.js +150 -0
  75. package/dist/suggest.js.map +1 -0
  76. package/dist/summary.d.ts +51 -0
  77. package/dist/summary.js +61 -0
  78. package/dist/summary.js.map +1 -0
  79. package/dist/testing.d.ts +26 -0
  80. package/dist/testing.js +341 -0
  81. package/dist/testing.js.map +1 -0
  82. package/dist/timeout.d.ts +2 -0
  83. package/dist/timeout.js +15 -0
  84. package/dist/timeout.js.map +1 -0
  85. package/dist/types.d.ts +219 -0
  86. package/dist/types.js +46 -0
  87. package/dist/types.js.map +1 -0
  88. package/dist/upkeepRules.d.ts +28 -0
  89. package/dist/upkeepRules.js +40 -0
  90. package/dist/upkeepRules.js.map +1 -0
  91. package/dist/util.d.ts +8 -0
  92. package/dist/util.js +18 -0
  93. package/dist/util.js.map +1 -0
  94. package/dist/weekly.d.ts +80 -0
  95. package/dist/weekly.js +111 -0
  96. package/dist/weekly.js.map +1 -0
  97. package/package.json +81 -4
  98. package/src/attention.ts +88 -0
  99. package/src/board.ts +56 -0
  100. package/src/cli/addPages.ts +256 -0
  101. package/src/cli/main.ts +125 -0
  102. package/src/common.ts +338 -0
  103. package/src/compare.ts +261 -0
  104. package/src/decisions.ts +230 -0
  105. package/src/feedback.ts +225 -0
  106. package/src/format.ts +19 -0
  107. package/src/glossary.ts +75 -0
  108. package/src/health.ts +147 -0
  109. package/src/index.ts +33 -0
  110. package/src/labels.ts +18 -0
  111. package/src/legs.ts +38 -0
  112. package/src/memoryStore.ts +268 -0
  113. package/src/models.ts +82 -0
  114. package/src/prune.ts +95 -0
  115. package/src/quickSet.ts +55 -0
  116. package/src/runner.ts +971 -0
  117. package/src/settings.ts +331 -0
  118. package/src/stableJson.ts +24 -0
  119. package/src/stats.ts +215 -0
  120. package/src/store.ts +167 -0
  121. package/src/suggest.ts +245 -0
  122. package/src/summary.ts +109 -0
  123. package/src/testing.ts +392 -0
  124. package/src/timeout.ts +14 -0
  125. package/src/types.ts +252 -0
  126. package/src/upkeepRules.ts +46 -0
  127. package/src/util.ts +17 -0
  128. package/src/weekly.ts +167 -0
  129. package/starter/index.ts +86 -0
  130. package/templates/app/api/cron/evals-weekly/route.ts +24 -0
  131. package/templates/app/evals/[suite]/page.tsx +449 -0
  132. package/templates/app/evals/[suite]/runs/[runId]/page.tsx +95 -0
  133. package/templates/app/evals/feedback/page.tsx +219 -0
  134. package/templates/app/evals/page.tsx +313 -0
  135. package/templates/components/evalkeeper/AddCase.tsx +62 -0
  136. package/templates/components/evalkeeper/AnswerKey.tsx +360 -0
  137. package/templates/components/evalkeeper/CaseMenu.tsx +122 -0
  138. package/templates/components/evalkeeper/Fields.tsx +110 -0
  139. package/templates/components/evalkeeper/RefreshWhile.tsx +25 -0
  140. package/templates/components/evalkeeper/SettingsHistory.tsx +191 -0
  141. package/templates/components/evalkeeper/Submit.tsx +58 -0
  142. package/templates/components/evalkeeper/Thumbs.tsx +117 -0
  143. package/templates/components/evalkeeper/Tip.tsx +98 -0
  144. package/templates/components/evalkeeper/WeeklyCheck.tsx +124 -0
  145. package/templates/components/evalkeeper/evalkeeper.css +71 -0
  146. package/templates/components/evalkeeper/ui.tsx +103 -0
  147. package/templates/lib/evalkeeper/actions.ts +263 -0
  148. package/templates/lib/evalkeeper/cronAuth.ts +18 -0
  149. package/templates/lib/evalkeeper/paths.ts +11 -0
  150. package/templates/lib/evalkeeper/time.ts +4 -0
  151. package/templates/lib/evalkeeper/weekly.ts +51 -0
@@ -0,0 +1,256 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { dirname, join, relative } from "node:path";
9
+
10
+ /**
11
+ * `npx @crowandraven/evalkeeper add pages`: copies the eval pages, components, and their
12
+ * glue into a Next.js app, fitted to its folders and import alias. It never
13
+ * overwrites a file unless told to, and writes the app's wiring module only
14
+ * when there isn't one. Node-only (it reads and writes files).
15
+ */
16
+
17
+ /** What gets copied, from the template (the example app's layout). */
18
+ export const TEMPLATE_FILES = {
19
+ /** Copied as they are, with imports rewritten to the app's alias. */
20
+ always: [
21
+ "components/evalkeeper",
22
+ "app/evals",
23
+ "lib/evalkeeper/actions.ts",
24
+ "lib/evalkeeper/weekly.ts",
25
+ "lib/evalkeeper/cronAuth.ts",
26
+ "lib/evalkeeper/time.ts",
27
+ "lib/evalkeeper/paths.ts",
28
+ ],
29
+ /** The weekly check's cron route; skipped with --no-cron. */
30
+ cron: ["app/api/cron/evals-weekly/route.ts"],
31
+ } as const;
32
+
33
+ /**
34
+ * What `addPages` needs: the app's folder, the templates, and the choices from the command line.
35
+ */
36
+ export type AddPagesOptions = {
37
+ /** The app's folder. */
38
+ cwd: string;
39
+ /** The built templates (the example app's files). */
40
+ templatesDir: string;
41
+ /** The starter wiring module. */
42
+ starterFile: string;
43
+ /** Where the pages live, under the app: "evals", "admin/evals". */
44
+ base?: string;
45
+ /** The import alias (`@`); read from tsconfig.json when absent. */
46
+ alias?: string;
47
+ /** Where the alias points ("src", "."); read from tsconfig.json. */
48
+ root?: string;
49
+ cron?: boolean;
50
+ force?: boolean;
51
+ dryRun?: boolean;
52
+ };
53
+
54
+ /** One file the command would write, overwrite, or leave alone. */
55
+ export type PlannedFile = {
56
+ /** Relative to the app's folder. */
57
+ path: string;
58
+ action: "write" | "overwrite" | "skip";
59
+ contents: string;
60
+ };
61
+
62
+ /**
63
+ * JSON with comments and trailing commas (what tsconfig.json allows) as
64
+ * plain JSON. Scans character by character so "/*" inside a string, like
65
+ * `"~/*"` or `"**\/*.ts"`, is never mistaken for a comment.
66
+ */
67
+ export function withoutJsonComments(text: string): string {
68
+ let out = "";
69
+ let i = 0;
70
+ while (i < text.length) {
71
+ const c = text.charAt(i);
72
+ const next = text.charAt(i + 1);
73
+ if (c === '"') {
74
+ // A string, copied as it is, escapes included.
75
+ let j = i + 1;
76
+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
77
+ out += text.slice(i, j + 1);
78
+ i = j + 1;
79
+ } else if (c === "/" && next === "/") {
80
+ while (i < text.length && text[i] !== "\n") i++;
81
+ } else if (c === "/" && next === "*") {
82
+ const end = text.indexOf("*/", i + 2);
83
+ i = end < 0 ? text.length : end + 2;
84
+ } else {
85
+ out += c;
86
+ i++;
87
+ }
88
+ }
89
+ // Trailing commas, now that no comment can hide one.
90
+ return out.replace(/,(\s*[}\]])/g, "$1");
91
+ }
92
+
93
+ /** The app's import alias and where it points, from tsconfig.json. */
94
+ export function readAlias(cwd: string): { alias: string; root: string } | null {
95
+ const file = join(cwd, "tsconfig.json");
96
+ if (!existsSync(file)) return null;
97
+ const text = withoutJsonComments(readFileSync(file, "utf8"));
98
+ let parsed: unknown;
99
+ try {
100
+ parsed = JSON.parse(text);
101
+ } catch {
102
+ return null;
103
+ }
104
+ const paths = (parsed as { compilerOptions?: { paths?: unknown } })
105
+ .compilerOptions?.paths;
106
+ if (!paths || typeof paths !== "object") return null;
107
+ for (const [key, value] of Object.entries(paths)) {
108
+ const target = Array.isArray(value) ? (value[0] as unknown) : undefined;
109
+ if (!key.endsWith("/*") || typeof target !== "string") continue;
110
+ if (!target.endsWith("/*")) continue;
111
+ const root = target.slice(0, -2).replace(/^\.\//, "") || ".";
112
+ return { alias: key.slice(0, -2), root };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** "admin/evals" from "/admin/evals/" or "admin/evals". */
118
+ export function cleanBase(base: string): string {
119
+ const b = base.trim().replace(/^\/+|\/+$/g, "");
120
+ if (!b || !/^[a-z0-9_\-/[\]()]+$/i.test(b))
121
+ throw new Error(`"${base}" isn't a usable path for the eval pages`);
122
+ return b;
123
+ }
124
+
125
+ function listFiles(dir: string): string[] {
126
+ return readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
127
+ e.isDirectory() ? listFiles(join(dir, e.name)) : [join(dir, e.name)],
128
+ );
129
+ }
130
+
131
+ /** A template file's text, fitted to the app. */
132
+ export function fitFile(
133
+ templatePath: string,
134
+ text: string,
135
+ o: { alias: string; base: string },
136
+ ): string {
137
+ let out = text;
138
+ if (/\.(ts|tsx)$/.test(templatePath) && o.alias !== "@")
139
+ out = out.replace(/(["'])@\//g, `$1${o.alias}/`);
140
+ if (templatePath === "lib/evalkeeper/paths.ts")
141
+ out = out.replace(
142
+ /export const EVALS_BASE = "[^"]*";/,
143
+ `export const EVALS_BASE = "/${o.base}";`,
144
+ );
145
+ return out;
146
+ }
147
+
148
+ /** Where a template file goes in the app, relative to the app's root folder. */
149
+ function destinationOf(templatePath: string, base: string): string {
150
+ return templatePath.startsWith("app/evals/")
151
+ ? `app/${base}/${templatePath.slice("app/evals/".length)}`
152
+ : templatePath;
153
+ }
154
+
155
+ /** Works out every file to write, without writing anything. */
156
+ export function planAddPages(o: AddPagesOptions): {
157
+ files: PlannedFile[];
158
+ alias: string;
159
+ root: string;
160
+ base: string;
161
+ } {
162
+ const base = cleanBase(o.base ?? "evals");
163
+ const found = readAlias(o.cwd);
164
+ const alias = o.alias ?? found?.alias;
165
+ const root = o.root ?? found?.root ?? (alias ? "." : undefined);
166
+ if (!alias || root === undefined)
167
+ throw new Error(
168
+ 'No import alias found. Add one to tsconfig.json ("paths": { "@/*": ["./*"] }), or pass --alias and --root.',
169
+ );
170
+ const rootDir = join(o.cwd, root);
171
+ if (!existsSync(join(rootDir, "app")))
172
+ throw new Error(
173
+ `No app folder at ${relative(o.cwd, join(rootDir, "app")) || "app"}: Evalkeeper's pages are for the Next.js App Router.`,
174
+ );
175
+
176
+ const sources = [
177
+ ...TEMPLATE_FILES.always,
178
+ ...(o.cron === false ? [] : TEMPLATE_FILES.cron),
179
+ ].flatMap((entry) => {
180
+ const full = join(o.templatesDir, entry);
181
+ if (!existsSync(full))
182
+ throw new Error(
183
+ `The templates are missing ${entry}; rebuild the package.`,
184
+ );
185
+ return entry.endsWith(".ts") || entry.endsWith(".tsx")
186
+ ? [entry]
187
+ : listFiles(full).map((f) => relative(o.templatesDir, f));
188
+ });
189
+
190
+ const plan = (path: string, contents: string, alwaysSkipExisting = false) => {
191
+ const exists = existsSync(join(o.cwd, path));
192
+ const action: PlannedFile["action"] = !exists
193
+ ? "write"
194
+ : o.force && !alwaysSkipExisting
195
+ ? "overwrite"
196
+ : "skip";
197
+ return { path, action, contents };
198
+ };
199
+
200
+ const files: PlannedFile[] = sources.map((t) =>
201
+ plan(
202
+ join(root, destinationOf(t, base)),
203
+ fitFile(t, readFileSync(join(o.templatesDir, t), "utf8"), {
204
+ alias,
205
+ base,
206
+ }),
207
+ ),
208
+ );
209
+ // The wiring module is the app's own: written once, never overwritten.
210
+ files.push(
211
+ plan(
212
+ join(root, "lib/evalkeeper/index.ts"),
213
+ fitFile("lib/evalkeeper/index.ts", readFileSync(o.starterFile, "utf8"), {
214
+ alias,
215
+ base,
216
+ }),
217
+ true,
218
+ ),
219
+ );
220
+ return { files, alias, root, base };
221
+ }
222
+
223
+ /** Writes the plan (unless it's a dry run) and returns it. */
224
+ export function addPages(o: AddPagesOptions) {
225
+ const result = planAddPages(o);
226
+ if (!o.dryRun)
227
+ for (const f of result.files) {
228
+ if (f.action === "skip") continue;
229
+ const dest = join(o.cwd, f.path);
230
+ mkdirSync(dirname(dest), { recursive: true });
231
+ writeFileSync(dest, f.contents);
232
+ }
233
+ return result;
234
+ }
235
+
236
+ /** What to do next, in order, for the summary the CLI prints. */
237
+ export function nextSteps(r: {
238
+ root: string;
239
+ alias: string;
240
+ base: string;
241
+ files: PlannedFile[];
242
+ }): string[] {
243
+ const wiring = r.files.find((f) =>
244
+ f.path.endsWith("lib/evalkeeper/index.ts"),
245
+ );
246
+ const css = join(r.root, "components/evalkeeper/evalkeeper.css");
247
+ return [
248
+ "npm install @crowandraven/evalkeeper @crowandraven/evalkeeper-prisma @crowandraven/evalkeeper-ai-sdk server-only lucide-react",
249
+ "Add Evalkeeper's models to your Prisma schema (node_modules/@crowandraven/evalkeeper-prisma/schema/evalkeeper.prisma), then write a migration from the schema file.",
250
+ `Import the colors into your global CSS, right after Tailwind: @import "${css}"; (a path relative to that CSS file).`,
251
+ wiring?.action === "skip"
252
+ ? `Check ${wiring.path} exports what the pages use: MODELS, availableModels, modelsFor, SUITES, SUITE_BY_KEY, store, feedbackStore, settings, routeFor, runner, and requireAdmin.`
253
+ : `Fill in ${wiring?.path ?? "lib/evalkeeper/index.ts"}: your Prisma client, models, suites, and requireAdmin (until then, nobody can open the pages).`,
254
+ `Open /${r.base} in your app.`,
255
+ ];
256
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The `evalkeeper` command: `npx @crowandraven/evalkeeper add pages`.
3
+ *
4
+ * @module evalkeeper/cli
5
+ */
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { addPages, nextSteps, type AddPagesOptions } from "./addPages.js";
9
+
10
+ /** The `evalkeeper` command. */
11
+
12
+ /** The command's help text. */
13
+ export const HELP = `Usage: npx @crowandraven/evalkeeper add pages [options]
14
+
15
+ Copies Evalkeeper's eval pages, components, and their glue into your
16
+ Next.js app, fitted to its folders and import alias.
17
+
18
+ Options:
19
+ --base <path> Where the pages live in your app (default: evals)
20
+ --alias <name> Your import alias, e.g. "@" or "~" (default: from tsconfig.json)
21
+ --root <dir> Where the alias points, e.g. "src" (default: from tsconfig.json)
22
+ --no-cron Leave out the weekly check's cron route
23
+ --force Overwrite files that already exist (never your lib/evalkeeper/index.ts)
24
+ --dry-run Show what would be written, and write nothing
25
+ --help Show this
26
+ `;
27
+
28
+ /** The package's own folder, where the templates and starter ship. */
29
+ function packageDir(): string {
30
+ // dist/cli/main.js → the package root.
31
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "..");
32
+ }
33
+
34
+ /**
35
+ * Reads the command line into a command and options. Throws on an unknown option or a missing value.
36
+ */
37
+ export function parseArgs(argv: string[]): Partial<AddPagesOptions> & {
38
+ command: string[];
39
+ help: boolean;
40
+ } {
41
+ const out: Partial<AddPagesOptions> & { command: string[]; help: boolean } = {
42
+ command: [],
43
+ help: false,
44
+ };
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const a = argv[i] ?? "";
47
+ const value = () => {
48
+ const v = argv[++i];
49
+ if (v === undefined || v.startsWith("--"))
50
+ throw new Error(`${a} needs a value`);
51
+ return v;
52
+ };
53
+ if (a === "--help" || a === "-h") out.help = true;
54
+ else if (a === "--base") out.base = value();
55
+ else if (a === "--alias") out.alias = value();
56
+ else if (a === "--root") out.root = value();
57
+ else if (a === "--no-cron") out.cron = false;
58
+ else if (a === "--force") out.force = true;
59
+ else if (a === "--dry-run") out.dryRun = true;
60
+ else if (a.startsWith("--")) throw new Error(`Unknown option ${a}`);
61
+ else out.command.push(a);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** Runs the command; returns the exit code. */
67
+ export function run(
68
+ argv: string[],
69
+ io: {
70
+ cwd: string;
71
+ log: (line: string) => void;
72
+ error: (line: string) => void;
73
+ pkgDir?: string;
74
+ },
75
+ ): number {
76
+ let args: ReturnType<typeof parseArgs>;
77
+ try {
78
+ args = parseArgs(argv);
79
+ } catch (e) {
80
+ io.error(e instanceof Error ? e.message : String(e));
81
+ io.error(HELP);
82
+ return 2;
83
+ }
84
+ if (args.help || args.command.length === 0) {
85
+ io.log(HELP);
86
+ return 0;
87
+ }
88
+ if (args.command.join(" ") !== "add pages") {
89
+ io.error(`Unknown command "${args.command.join(" ")}".`);
90
+ io.error(HELP);
91
+ return 2;
92
+ }
93
+ const pkg = io.pkgDir ?? packageDir();
94
+ try {
95
+ const { command: _command, help: _help, ...rest } = args;
96
+ const result = addPages({
97
+ ...rest,
98
+ cwd: io.cwd,
99
+ templatesDir: join(pkg, "templates"),
100
+ starterFile: join(pkg, "starter", "index.ts"),
101
+ });
102
+ const verb = { write: "wrote", overwrite: "overwrote", skip: "skipped" };
103
+ io.log(
104
+ args.dryRun
105
+ ? "Dry run: nothing written. This would happen:"
106
+ : `Added Evalkeeper's pages at /${result.base}:`,
107
+ );
108
+ for (const f of result.files)
109
+ io.log(
110
+ ` ${(args.dryRun ? f.action : verb[f.action]).padEnd(10)} ${f.path}${f.action === "skip" ? " (exists)" : ""}`,
111
+ );
112
+ const skipped = result.files.filter((f) => f.action === "skip").length;
113
+ if (skipped && !args.force)
114
+ io.log(
115
+ `\n${skipped} file${skipped === 1 ? "" : "s"} already existed and stayed as they are. --force overwrites them (your lib/evalkeeper/index.ts never).`,
116
+ );
117
+ io.log("\nNext:");
118
+ for (const [i, step] of nextSteps(result).entries())
119
+ io.log(` ${i + 1}. ${step}`);
120
+ return 0;
121
+ } catch (e) {
122
+ io.error(e instanceof Error ? e.message : String(e));
123
+ return 1;
124
+ }
125
+ }