@polderlabs/bizar 4.9.0 → 5.0.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 (107) hide show
  1. package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
  2. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
  3. package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
  4. package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
  5. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
  6. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/skills/eval/SKILL.md +237 -0
  20. package/bizar-dash/src/server/api.mjs +28 -0
  21. package/bizar-dash/src/server/auth.mjs +155 -1
  22. package/bizar-dash/src/server/eval-store.mjs +226 -0
  23. package/bizar-dash/src/server/eval.mjs +347 -0
  24. package/bizar-dash/src/server/ocr.mjs +55 -0
  25. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  26. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  27. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  28. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  29. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  30. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  31. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  32. package/bizar-dash/src/server/routes/users.mjs +84 -0
  33. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  34. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  35. package/bizar-dash/src/server/voice-store.mjs +202 -0
  36. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  37. package/bizar-dash/src/server/workspaces.mjs +626 -0
  38. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  39. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  40. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  41. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  42. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  43. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  44. package/bizar-dash/src/web/styles/memory.css +82 -0
  45. package/bizar-dash/src/web/views/Memory.tsx +17 -2
  46. package/bizar-dash/src/web/views/Settings.tsx +3 -0
  47. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  48. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  49. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  50. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  51. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
  52. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  53. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  54. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  55. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  56. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  57. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  58. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  59. package/bizar-dash/tests/ocr.test.mjs +87 -0
  60. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  61. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  62. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  63. package/bizar-dash/tests/users.test.mjs +108 -0
  64. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  65. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  66. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  67. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  68. package/cli/bin.mjs +72 -2
  69. package/cli/commands/clip.mjs +146 -0
  70. package/cli/commands/deploy/cloudflare.mjs +250 -0
  71. package/cli/commands/deploy/docker.mjs +221 -0
  72. package/cli/commands/deploy/fly.mjs +161 -0
  73. package/cli/commands/deploy/vercel.mjs +225 -0
  74. package/cli/commands/deploy.mjs +240 -0
  75. package/cli/commands/eval.mjs +378 -0
  76. package/cli/commands/marketplace.mjs +64 -0
  77. package/cli/commands/ocr.mjs +165 -0
  78. package/cli/commands/plugin.mjs +358 -0
  79. package/cli/commands/voice.mjs +211 -0
  80. package/cli/commands/workspace.mjs +247 -0
  81. package/package.json +12 -8
  82. package/templates/deploy/cloudflare/README.md +32 -0
  83. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  84. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  85. package/templates/deploy/docker/.env.template +16 -0
  86. package/templates/deploy/docker/README.md +58 -0
  87. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  88. package/templates/deploy/fly/README.md +35 -0
  89. package/templates/deploy/fly/fly.toml.template +28 -0
  90. package/templates/deploy/vercel/README.md +29 -0
  91. package/templates/deploy/vercel/api-index.template.js +18 -0
  92. package/templates/deploy/vercel/vercel.json.template +16 -0
  93. package/templates/eval-fixtures/README.md +58 -0
  94. package/templates/eval-fixtures/code-search-basic.json +28 -0
  95. package/templates/eval-fixtures/latency-bounds.json +16 -0
  96. package/templates/eval-fixtures/regression-suite.json +79 -0
  97. package/templates/eval-fixtures/response-format.json +30 -0
  98. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  99. package/templates/plugin-template/README.md +121 -0
  100. package/templates/plugin-template/index.js +66 -0
  101. package/templates/plugin-template/plugin.json +42 -0
  102. package/templates/plugin-template/tests/plugin.test.js +83 -0
  103. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
  104. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
  105. package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
  106. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
  107. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
@@ -0,0 +1,226 @@
1
+ /**
2
+ * src/server/eval-store.mjs
3
+ *
4
+ * v5.0.0 — Persistent storage for eval runs and results.
5
+ *
6
+ * Storage: ~/.local/share/bizar/eval/
7
+ *
8
+ * Index: ~/.local/share/bizar/eval/.index.json
9
+ * { "runs": [{ id, startedAt, finishedAt, suitePath, total, passed, failed }] }
10
+ *
11
+ * Per-run: ~/.local/share/bizar/eval/<run-id>.json
12
+ * Full run object with results array.
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, renameSync, statSync } from 'node:fs';
16
+ import { join, basename } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+
19
+ // Allow test override
20
+ const STORE_HOME = process.env.BIZAR_STORE_HOME
21
+ ? process.env.BIZAR_STORE_HOME
22
+ : join(homedir(), '.local', 'share', 'bizar');
23
+ const EVAL_DIR = join(STORE_HOME, 'eval');
24
+ const INDEX_FILE = join(EVAL_DIR, '.index.json');
25
+
26
+ // ── Helpers ───────────────────────────────────────────────────────────────────
27
+
28
+ function ensureDir(dir) {
29
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
30
+ }
31
+
32
+ function atomicWriteJson(filePath, data) {
33
+ const tmp = `${filePath}.tmp.${process.pid}`;
34
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
35
+ renameSync(tmp, filePath);
36
+ }
37
+
38
+ function safeReadJSON(file, fallback = null) {
39
+ try {
40
+ if (!existsSync(file)) return fallback;
41
+ const text = readFileSync(file, 'utf8');
42
+ if (!text.trim()) return fallback;
43
+ return JSON.parse(text);
44
+ } catch {
45
+ return fallback;
46
+ }
47
+ }
48
+
49
+ function loadIndex() {
50
+ return safeReadJSON(INDEX_FILE, { runs: [] });
51
+ }
52
+
53
+ function saveIndex(index) {
54
+ ensureDir(EVAL_DIR);
55
+ atomicWriteJson(INDEX_FILE, index);
56
+ }
57
+
58
+ // ── Public API ────────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Save a completed run to disk.
62
+ *
63
+ * @param {object} run
64
+ * @param {string} run.id
65
+ * @param {string} run.startedAt
66
+ * @param {string} run.finishedAt
67
+ * @param {string} run.suitePath
68
+ * @param {number} run.total
69
+ * @param {number} run.passed
70
+ * @param {number} run.failed
71
+ * @param {object[]} run.results
72
+ * @returns {{ ok: boolean, path: string }}
73
+ */
74
+ export async function saveRun(run) {
75
+ ensureDir(EVAL_DIR);
76
+
77
+ // Write the run file
78
+ const runPath = join(EVAL_DIR, `${run.id}.json`);
79
+ atomicWriteJson(runPath, run);
80
+
81
+ // Update the index
82
+ const index = loadIndex();
83
+ const existingIdx = index.runs.findIndex((r) => r.id === run.id);
84
+ const indexEntry = {
85
+ id: run.id,
86
+ startedAt: run.startedAt,
87
+ finishedAt: run.finishedAt,
88
+ suitePath: run.suitePath,
89
+ total: run.total,
90
+ passed: run.passed,
91
+ failed: run.failed,
92
+ };
93
+ if (existingIdx >= 0) {
94
+ index.runs[existingIdx] = indexEntry;
95
+ } else {
96
+ index.runs.unshift(indexEntry); // newest first
97
+ }
98
+ saveIndex(index);
99
+
100
+ return { ok: true, path: runPath };
101
+ }
102
+
103
+ /**
104
+ * List recent runs, newest first.
105
+ *
106
+ * @param {{ limit?: number }} opts
107
+ * @returns {Promise<object[]>}
108
+ */
109
+ export async function listRuns({ limit = 20 } = {}) {
110
+ const index = loadIndex();
111
+ return index.runs.slice(0, Math.max(1, limit));
112
+ }
113
+
114
+ /**
115
+ * Get a full run by id.
116
+ *
117
+ * @param {string} id
118
+ * @returns {Promise<object | null>}
119
+ */
120
+ export async function getRun(id) {
121
+ if (!id) return null;
122
+ const runPath = join(EVAL_DIR, `${id}.json`);
123
+ return safeReadJSON(runPath, null);
124
+ }
125
+
126
+ /**
127
+ * Compare two runs and categorize fixtures by diff.
128
+ *
129
+ * @param {string} id1
130
+ * @param {string} id2
131
+ * @returns {Promise<{ improved: object[], regressed: object[], unchanged: object[] }>}
132
+ */
133
+ export async function compareRuns(id1, id2) {
134
+ const [run1, run2] = await Promise.all([getRun(id1), getRun(id2)]);
135
+
136
+ if (!run1 || !run2) {
137
+ return { improved: [], regressed: [], unchanged: [], error: 'run not found' };
138
+ }
139
+
140
+ // Build a map of fixtureId -> result for each run
141
+ const map1 = new Map(run1.results.map((r) => [r.fixtureId, r]));
142
+ const map2 = new Map(run2.results.map((r) => [r.fixtureId, r]));
143
+
144
+ /** @type {object[]} */
145
+ const improved = [];
146
+ /** @type {object[]} */
147
+ const regressed = [];
148
+ /** @type {object[]} */
149
+ const unchanged = [];
150
+
151
+ // Check all fixtures from run1
152
+ for (const [fixtureId, result1] of map1) {
153
+ const result2 = map2.get(fixtureId);
154
+ if (!result2) {
155
+ // Fixture disappeared
156
+ continue;
157
+ }
158
+
159
+ const entry = {
160
+ fixtureId,
161
+ run1Ok: result1.ok,
162
+ run2Ok: result2.ok,
163
+ run1LatencyMs: result1.latencyMs,
164
+ run2LatencyMs: result2.latencyMs,
165
+ };
166
+
167
+ if (result1.ok && !result2.ok) {
168
+ // Was passing, now failing = regressed
169
+ regressed.push({ ...entry, kind: 'regressed' });
170
+ } else if (!result1.ok && result2.ok) {
171
+ // Was failing, now passing = improved
172
+ improved.push({ ...entry, kind: 'improved' });
173
+ } else {
174
+ // Same result
175
+ unchanged.push({ ...entry, kind: 'unchanged' });
176
+ }
177
+ }
178
+
179
+ return { improved, regressed, unchanged };
180
+ }
181
+
182
+ /**
183
+ * Delete a run by id.
184
+ *
185
+ * @param {string} id
186
+ * @returns {{ ok: boolean }}
187
+ */
188
+ export async function deleteRun(id) {
189
+ if (!id) return { ok: false };
190
+ const runPath = join(EVAL_DIR, `${id}.json`);
191
+ try {
192
+ if (existsSync(runPath)) unlinkSync(runPath);
193
+ } catch { /* ignore */ }
194
+
195
+ const index = loadIndex();
196
+ index.runs = index.runs.filter((r) => r.id !== id);
197
+ saveIndex(index);
198
+
199
+ return { ok: true };
200
+ }
201
+
202
+ /**
203
+ * Build a unique run id.
204
+ *
205
+ * @returns {string}
206
+ */
207
+ export function buildRunId() {
208
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
209
+ const rand = Math.random().toString(36).slice(2, 6);
210
+ return `run_${ts}_${rand}`;
211
+ }
212
+
213
+ // ── Test reset ────────────────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * Wipe all eval data. For test isolation only.
217
+ */
218
+ export function __resetStoreForTests() {
219
+ try {
220
+ const index = loadIndex();
221
+ for (const r of index.runs) {
222
+ try { unlinkSync(join(EVAL_DIR, `${r.id}.json`)); } catch { /* ignore */ }
223
+ }
224
+ try { unlinkSync(INDEX_FILE); } catch { /* ignore */ }
225
+ } catch { /* ignore */ }
226
+ }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * src/server/eval.mjs
3
+ *
4
+ * v5.0.0 — Eval runner for evaluating AI agent outputs against golden fixtures.
5
+ *
6
+ * Supports:
7
+ * - contains/notContains substring checks
8
+ * - regex pattern matching
9
+ * - JSON schema validation
10
+ * - maxTokens bounds
11
+ * - maxLatencyMs bounds
12
+ * - Parallel fixture execution with configurable concurrency
13
+ */
14
+
15
+ import { readFileSync, readdirSync } from 'node:fs';
16
+ import { join, extname } from 'node:path';
17
+
18
+ /**
19
+ * @typedef {object} Fixture
20
+ * @property {string} id
21
+ * @property {string} name
22
+ * @property {string} description
23
+ * @property {string} agent
24
+ * @property {string} prompt
25
+ * @property {FixtureExpectations} expected
26
+ * @property {string[]} [tags]
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} FixtureExpectations
31
+ * @property {string[]} [contains]
32
+ * @property {string[]} [notContains]
33
+ * @property {string[]} [regex]
34
+ * @property {object} [jsonSchema]
35
+ * @property {number} [maxTokens]
36
+ * @property {number} [maxLatencyMs]
37
+ */
38
+
39
+ /**
40
+ * @typedef {object} CheckResult
41
+ * @property {string} kind
42
+ * @property {boolean} ok
43
+ * @property {string} [message]
44
+ */
45
+
46
+ /**
47
+ * @typedef {object} EvalResult
48
+ * @property {string} fixtureId
49
+ * @property {boolean} ok
50
+ * @property {CheckResult[]} checks
51
+ * @property {number} latencyMs
52
+ * @property {string} content
53
+ * @property {{ inputTokens: number, outputTokens: number, totalTokens: number }} usage
54
+ */
55
+
56
+ /**
57
+ * @typedef {object} SuiteResult
58
+ * @property {string} suitePath
59
+ * @property {number} total
60
+ * @property {number} passed
61
+ * @property {number} failed
62
+ * @property {EvalResult[]} results
63
+ */
64
+
65
+ // ── Check implementations ──────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * @param {string} content
69
+ * @param {string[]} needles
70
+ * @returns {CheckResult[]}
71
+ */
72
+ function checkContains(content, needles) {
73
+ return needles.map((needle) => {
74
+ const ok = content.includes(needle);
75
+ return {
76
+ kind: 'contains',
77
+ ok,
78
+ message: ok ? undefined : `expected to contain: "${needle}"`,
79
+ };
80
+ });
81
+ }
82
+
83
+ /**
84
+ * @param {string} content
85
+ * @param {string[]} needles
86
+ * @returns {CheckResult[]}
87
+ */
88
+ function checkNotContains(content, needles) {
89
+ return needles.map((needle) => {
90
+ const ok = !content.includes(needle);
91
+ return {
92
+ kind: 'notContains',
93
+ ok,
94
+ message: ok ? undefined : `expected NOT to contain: "${needle}"`,
95
+ };
96
+ });
97
+ }
98
+
99
+ /**
100
+ * @param {string} content
101
+ * @param {string[]} patterns
102
+ * @returns {CheckResult[]}
103
+ */
104
+ function checkRegex(content, patterns) {
105
+ return patterns.map((pattern) => {
106
+ let ok = false;
107
+ let message;
108
+ try {
109
+ const re = new RegExp(pattern);
110
+ ok = re.test(content);
111
+ message = ok ? undefined : `regex did not match: ${pattern}`;
112
+ } catch (err) {
113
+ ok = false;
114
+ message = `invalid regex: ${pattern} — ${err.message}`;
115
+ }
116
+ return { kind: 'regex', ok, message };
117
+ });
118
+ }
119
+
120
+ /**
121
+ * @param {string} content
122
+ * @param {object} schema
123
+ * @returns {CheckResult}
124
+ */
125
+ function checkJsonSchema(content, schema) {
126
+ if (!schema) return { kind: 'jsonSchema', ok: true };
127
+ let parsed;
128
+ try {
129
+ parsed = JSON.parse(content);
130
+ } catch {
131
+ return { kind: 'jsonSchema', ok: false, message: 'response is not valid JSON' };
132
+ }
133
+ // Basic schema validation — check required fields exist
134
+ const errors = [];
135
+ if (schema.type === 'object' && typeof parsed !== 'object' || Array.isArray(parsed)) {
136
+ errors.push(`expected object, got ${typeof parsed}`);
137
+ }
138
+ if (schema.properties) {
139
+ for (const [key, prop] of Object.entries(schema.properties)) {
140
+ if (parsed[key] === undefined) {
141
+ if (!schema.required?.includes(key)) continue;
142
+ errors.push(`missing required property: ${key}`);
143
+ } else if (prop.type && typeof parsed[key] !== prop.type) {
144
+ errors.push(`property "${key}" expected type ${prop.type}, got ${typeof parsed[key]}`);
145
+ }
146
+ }
147
+ }
148
+ return {
149
+ kind: 'jsonSchema',
150
+ ok: errors.length === 0,
151
+ message: errors.length > 0 ? errors.join('; ') : undefined,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * @param {{ inputTokens: number, outputTokens: number, totalTokens: number }} usage
157
+ * @param {number} maxTokens
158
+ * @returns {CheckResult}
159
+ */
160
+ function checkMaxTokens(usage, maxTokens) {
161
+ if (maxTokens == null) return { kind: 'maxTokens', ok: true };
162
+ const ok = usage.totalTokens <= maxTokens;
163
+ return {
164
+ kind: 'maxTokens',
165
+ ok,
166
+ message: ok ? undefined : `token count ${usage.totalTokens} exceeds max ${maxTokens}`,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * @param {number} latencyMs
172
+ * @param {number} maxLatencyMs
173
+ * @returns {CheckResult}
174
+ */
175
+ function checkMaxLatency(latencyMs, maxLatencyMs) {
176
+ if (maxLatencyMs == null) return { kind: 'maxLatencyMs', ok: true };
177
+ const ok = latencyMs <= maxLatencyMs;
178
+ return {
179
+ kind: 'maxLatencyMs',
180
+ ok,
181
+ message: ok ? undefined : `latency ${latencyMs}ms exceeds max ${maxLatencyMs}ms`,
182
+ };
183
+ }
184
+
185
+ // ── Core runner ───────────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Run a single fixture against an LLM.
189
+ *
190
+ * @param {Fixture} fixture
191
+ * @param {{ llmCall: (prompt: string, opts: {agent: string}) => Promise<{content: string, usage: object}>, timeoutMs?: number }} opts
192
+ * @returns {Promise<EvalResult>}
193
+ */
194
+ export async function runFixture(
195
+ fixture,
196
+ { llmCall, timeoutMs = 60000 } = {},
197
+ ) {
198
+ if (!llmCall) throw new Error('llmCall is required');
199
+ const start = Date.now();
200
+ let content = '';
201
+ let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
202
+ let timeoutHit = false;
203
+ let callError = null;
204
+
205
+ try {
206
+ const ctrl = new AbortController();
207
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
208
+ const result = await Promise.race([
209
+ llmCall(fixture.prompt, { agent: fixture.agent }),
210
+ new Promise((_, reject) => ctrl.signal.addEventListener('abort', () => reject(new Error('timeout')))),
211
+ ]);
212
+ clearTimeout(timer);
213
+ content = result.content || '';
214
+ usage = result.usage || { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
215
+ } catch (err) {
216
+ if (err.message === 'timeout') {
217
+ timeoutHit = true;
218
+ } else {
219
+ callError = err;
220
+ }
221
+ content = '';
222
+ usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
223
+ }
224
+
225
+ const latencyMs = Date.now() - start;
226
+ const checks = [];
227
+ const expected = fixture.expected || {};
228
+
229
+ // Timeout check
230
+ if (timeoutHit) {
231
+ checks.push({ kind: 'timeout', ok: false, message: `call exceeded ${timeoutMs}ms` });
232
+ }
233
+
234
+ // Error check (non-timeout error)
235
+ if (callError) {
236
+ checks.push({ kind: 'error', ok: false, message: callError.message });
237
+ }
238
+
239
+ // Content checks
240
+ if (expected.contains?.length) {
241
+ checks.push(...checkContains(content, expected.contains));
242
+ }
243
+ if (expected.notContains?.length) {
244
+ checks.push(...checkNotContains(content, expected.notContains));
245
+ }
246
+ if (expected.regex?.length) {
247
+ checks.push(...checkRegex(content, expected.regex));
248
+ }
249
+ if (expected.jsonSchema) {
250
+ checks.push(checkJsonSchema(content, expected.jsonSchema));
251
+ }
252
+ if (expected.maxTokens != null) {
253
+ checks.push(checkMaxTokens(usage, expected.maxTokens));
254
+ }
255
+ if (expected.maxLatencyMs != null) {
256
+ checks.push(checkMaxLatency(latencyMs, expected.maxLatencyMs));
257
+ }
258
+
259
+ const ok = checks.every((c) => c.ok);
260
+
261
+ return {
262
+ fixtureId: fixture.id,
263
+ ok,
264
+ checks,
265
+ latencyMs,
266
+ content,
267
+ usage,
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Load all JSON fixtures from a directory.
273
+ *
274
+ * @param {string} suitePath
275
+ * @returns {Fixture[]}
276
+ */
277
+ export function loadFixtures(suitePath) {
278
+ /** @type {Fixture[]} */
279
+ const fixtures = [];
280
+ try {
281
+ const entries = readdirSync(suitePath, { withFileTypes: true });
282
+ for (const entry of entries) {
283
+ if (entry.isFile() && extname(entry.name) === '.json') {
284
+ const full = join(suitePath, entry.name);
285
+ try {
286
+ const raw = readFileSync(full, 'utf8');
287
+ const parsed = JSON.parse(raw);
288
+ // Support both single fixture and suite of fixtures
289
+ if (parsed.id && parsed.prompt) {
290
+ fixtures.push(parsed);
291
+ } else if (Array.isArray(parsed.fixtures)) {
292
+ fixtures.push(...parsed.fixtures);
293
+ }
294
+ } catch { /* skip invalid JSON */ }
295
+ }
296
+ }
297
+ } catch { /* dir not found */ }
298
+ return fixtures;
299
+ }
300
+
301
+ /**
302
+ * Run a suite of fixtures.
303
+ *
304
+ * @param {string} suitePath
305
+ * @param {{ llmCall: Function, concurrency?: number, timeoutMs?: number }} opts
306
+ * @returns {Promise<SuiteResult>}
307
+ */
308
+ export async function runSuite(
309
+ suitePath,
310
+ { llmCall, concurrency = 5, timeoutMs = 60000 } = {},
311
+ ) {
312
+ const fixtures = loadFixtures(suitePath);
313
+ /** @type {EvalResult[]} */
314
+ const results = [];
315
+ let passed = 0;
316
+ let failed = 0;
317
+
318
+ // Run in batches of `concurrency`
319
+ for (let i = 0; i < fixtures.length; i += concurrency) {
320
+ const batch = fixtures.slice(i, i + concurrency);
321
+ const batchResults = await Promise.all(
322
+ batch.map((fixture) =>
323
+ runFixture(fixture, { llmCall, timeoutMs }).catch((err) => ({
324
+ fixtureId: fixture.id,
325
+ ok: false,
326
+ checks: [{ kind: 'error', ok: false, message: err.message }],
327
+ latencyMs: 0,
328
+ content: '',
329
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
330
+ }))
331
+ )
332
+ );
333
+ for (const r of batchResults) {
334
+ results.push(r);
335
+ if (r.ok) passed++;
336
+ else failed++;
337
+ }
338
+ }
339
+
340
+ return {
341
+ suitePath,
342
+ total: fixtures.length,
343
+ passed,
344
+ failed,
345
+ results,
346
+ };
347
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * src/server/ocr.mjs
3
+ *
4
+ * v5.0.0 — Tesseract.js wrapper for OCR text extraction.
5
+ *
6
+ * Provides a single `extractText(imageBuffer, options?)` function.
7
+ * The worker is created lazily and terminated after each call to avoid
8
+ * memory leaks in long-running server processes.
9
+ */
10
+
11
+ import { createWorker } from 'tesseract.js';
12
+
13
+ let worker = null;
14
+
15
+ /**
16
+ * Extract text from an image buffer using Tesseract.js OCR.
17
+ *
18
+ * @param {Buffer} imageBuffer - Raw image data (PNG, JPEG, etc.)
19
+ * @param {{ lang?: string }} [options] - Recognition options.
20
+ * `lang` defaults to 'eng' (English).
21
+ * @returns {Promise<string>} - The extracted text.
22
+ */
23
+ export async function extractText(imageBuffer, { lang = 'eng' } = {}) {
24
+ const w = worker || (worker = await createWorker(lang));
25
+
26
+ try {
27
+ const { data } = await w.recognize(imageBuffer);
28
+ return (data.text || '').trim();
29
+ } finally {
30
+ // Terminate and reset so a new worker picks up any config changes
31
+ // on the next call. This prevents the worker from accumulating state
32
+ // across requests in long-running servers.
33
+ try { await w.terminate(); } catch { /* ignore */ }
34
+ worker = null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Check whether Tesseract.js is available and can be initialised.
40
+ * Useful for health-check endpoints.
41
+ *
42
+ * @returns {Promise<{ ok: boolean, message: string }>}
43
+ */
44
+ export async function checkOcrHealth() {
45
+ try {
46
+ const w = await createWorker('eng');
47
+ await w.terminate();
48
+ return { ok: true, message: 'Tesseract.js worker initialised OK' };
49
+ } catch (err) {
50
+ return {
51
+ ok: false,
52
+ message: `Tesseract.js init failed: ${err instanceof Error ? err.message : String(err)}`,
53
+ };
54
+ }
55
+ }