@polderlabs/bizar 4.8.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 (129) 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-DmpSFPJY.js +9 -0
  4. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
  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-DX_Jh8Wc.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-Chvf9u_B.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/memory-lightrag.mjs +109 -0
  25. package/bizar-dash/src/server/memory-store.mjs +121 -0
  26. package/bizar-dash/src/server/ocr.mjs +55 -0
  27. package/bizar-dash/src/server/otel.mjs +133 -0
  28. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  29. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  30. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  31. package/bizar-dash/src/server/routes/chat.mjs +246 -170
  32. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  33. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  34. package/bizar-dash/src/server/routes/memory.mjs +46 -0
  35. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  36. package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
  37. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  38. package/bizar-dash/src/server/routes/users.mjs +84 -0
  39. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  40. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  41. package/bizar-dash/src/server/server.mjs +40 -0
  42. package/bizar-dash/src/server/voice-store.mjs +202 -0
  43. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  44. package/bizar-dash/src/server/workspaces.mjs +626 -0
  45. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  46. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  47. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  48. package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
  49. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  50. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  51. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  52. package/bizar-dash/src/web/lib/search.ts +115 -0
  53. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
  54. package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
  55. package/bizar-dash/src/web/styles/memory.css +166 -1
  56. package/bizar-dash/src/web/styles/settings.css +80 -0
  57. package/bizar-dash/src/web/views/Memory.tsx +22 -2
  58. package/bizar-dash/src/web/views/Settings.tsx +99 -0
  59. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  60. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  61. package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
  62. package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
  63. package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
  64. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  65. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  66. package/bizar-dash/tests/bundle-analysis.test.mjs +73 -0
  67. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  68. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  69. package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
  70. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  71. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  72. package/bizar-dash/tests/docker-build.test.mjs +96 -0
  73. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  74. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  75. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  76. package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
  77. package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
  78. package/bizar-dash/tests/memory-graph.test.mjs +95 -0
  79. package/bizar-dash/tests/ocr.test.mjs +87 -0
  80. package/bizar-dash/tests/otel.test.mjs +188 -0
  81. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  82. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  83. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  84. package/bizar-dash/tests/users.test.mjs +108 -0
  85. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  86. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  87. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  88. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  89. package/cli/bin.mjs +72 -2
  90. package/cli/commands/clip.mjs +146 -0
  91. package/cli/commands/dash.mjs +6 -0
  92. package/cli/commands/deploy/cloudflare.mjs +250 -0
  93. package/cli/commands/deploy/docker.mjs +221 -0
  94. package/cli/commands/deploy/fly.mjs +161 -0
  95. package/cli/commands/deploy/vercel.mjs +225 -0
  96. package/cli/commands/deploy.mjs +240 -0
  97. package/cli/commands/eval.mjs +378 -0
  98. package/cli/commands/marketplace.mjs +64 -0
  99. package/cli/commands/ocr.mjs +165 -0
  100. package/cli/commands/plugin.mjs +358 -0
  101. package/cli/commands/voice.mjs +211 -0
  102. package/cli/commands/workspace.mjs +247 -0
  103. package/package.json +12 -2
  104. package/templates/deploy/cloudflare/README.md +32 -0
  105. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  106. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  107. package/templates/deploy/docker/.env.template +16 -0
  108. package/templates/deploy/docker/README.md +58 -0
  109. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  110. package/templates/deploy/fly/README.md +35 -0
  111. package/templates/deploy/fly/fly.toml.template +28 -0
  112. package/templates/deploy/vercel/README.md +29 -0
  113. package/templates/deploy/vercel/api-index.template.js +18 -0
  114. package/templates/deploy/vercel/vercel.json.template +16 -0
  115. package/templates/eval-fixtures/README.md +58 -0
  116. package/templates/eval-fixtures/code-search-basic.json +28 -0
  117. package/templates/eval-fixtures/latency-bounds.json +16 -0
  118. package/templates/eval-fixtures/regression-suite.json +79 -0
  119. package/templates/eval-fixtures/response-format.json +30 -0
  120. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  121. package/templates/plugin-template/README.md +121 -0
  122. package/templates/plugin-template/index.js +66 -0
  123. package/templates/plugin-template/plugin.json +42 -0
  124. package/templates/plugin-template/tests/plugin.test.js +83 -0
  125. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
  126. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
  127. package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
  128. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
  129. package/bizar-dash/dist/assets/mobile-Chvf9u_B.js +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
+ }
@@ -1051,6 +1051,115 @@ function statMaybe(path) {
1051
1051
  }
1052
1052
  }
1053
1053
 
1054
+ /**
1055
+ * Get a graph representation of the LightRAG index.
1056
+ *
1057
+ * If `root` is given, fetches entities reachable within `depth` hops
1058
+ * from that entity via LightRAG's /graph endpoint. Otherwise returns
1059
+ * all entities up to `limit`.
1060
+ *
1061
+ * Falls back to reading .bizar/memory/lightrag/kv_store_*.json directly
1062
+ * when the LightRAG server is unavailable.
1063
+ *
1064
+ * @param {{ projectRoot: string, root?: string|null, depth?: number, limit?: number }}
1065
+ * @returns {Promise<{nodes: Array<{id:string,label:string,type:string,size:number,group:string}>, edges: Array<{source:string,target:string,type:string,weight:number}>}>}
1066
+ */
1067
+ export async function getLightRAGGraph({ projectRoot, root = null, depth = 2, limit = 200 }) {
1068
+ const config = resolveLightRAGConfig(projectRoot);
1069
+
1070
+ // Attempt to query the running server.
1071
+ if (await isRunning(config)) {
1072
+ try {
1073
+ const nodes = [];
1074
+ const edges = [];
1075
+
1076
+ if (root) {
1077
+ // Use LightRAG's /graph endpoint with root entity.
1078
+ const url = `http://${config.host}:${config.port}/graph?root=${encodeURIComponent(root)}&depth=${depth}&limit=${limit}`;
1079
+ const res = await httpGet(url, config.timeoutMs);
1080
+ if (res.status === 200) {
1081
+ let parsed;
1082
+ try { parsed = JSON.parse(res.body); } catch { parsed = {}; }
1083
+ // LightRAG /graph shape varies; normalise to our contract.
1084
+ // LightRAG returns { entities: [...], relations: [...] } or similar.
1085
+ const ents = parsed.entities || parsed.nodes || [];
1086
+ const rels = parsed.relations || parsed.edges || [];
1087
+ for (const e of ents.slice(0, limit)) {
1088
+ const id = String(e.id || e.name || JSON.stringify(e));
1089
+ nodes.push({
1090
+ id,
1091
+ label: String(e.label || e.name || id).slice(0, 60),
1092
+ type: String(e.type || 'entity'),
1093
+ size: Number(e.size || 1),
1094
+ group: String(e.group || e.type || 'default'),
1095
+ });
1096
+ }
1097
+ for (const r of rels) {
1098
+ edges.push({
1099
+ source: String(r.source || r.from || r.src || ''),
1100
+ target: String(r.target || r.to || r.dst || ''),
1101
+ type: String(r.type || 'related'),
1102
+ weight: Number(r.weight || 1),
1103
+ });
1104
+ }
1105
+ return { nodes, edges };
1106
+ }
1107
+ } else {
1108
+ // Return all entities from kv_store_full_docs.json.
1109
+ const docsPath = join(config.workingDir, 'kv_store_full_docs.json');
1110
+ if (existsSync(docsPath)) {
1111
+ try {
1112
+ const docs = JSON.parse(readFileSync(docsPath, 'utf8'));
1113
+ const keys = Object.keys(docs || {}).slice(0, limit);
1114
+ // Group by top-level path segment as a proxy for group.
1115
+ for (const k of keys) {
1116
+ const parts = k.replace(/^bizar:\/\//, '').split('/');
1117
+ const group = parts.length > 1 ? parts[0] : 'root';
1118
+ nodes.push({
1119
+ id: k,
1120
+ label: parts[parts.length - 1].replace(/[_-]/g, ' '),
1121
+ type: 'note',
1122
+ size: 1,
1123
+ group,
1124
+ });
1125
+ }
1126
+ return { nodes, edges };
1127
+ } catch (err) {
1128
+ console.warn('[lightrag] getLightRAGGraph fallback read failed:', err?.message);
1129
+ }
1130
+ }
1131
+ }
1132
+ } catch (err) {
1133
+ console.warn('[lightrag] getLightRAGGraph server query failed, falling back:', err?.message);
1134
+ }
1135
+ }
1136
+
1137
+ // Fallback: read kv_store files directly.
1138
+ const nodes = [];
1139
+ const edges = [];
1140
+ try {
1141
+ const docsPath = join(config.workingDir, 'kv_store_full_docs.json');
1142
+ if (existsSync(docsPath)) {
1143
+ const docs = JSON.parse(readFileSync(docsPath, 'utf8'));
1144
+ const keys = Object.keys(docs || {}).slice(0, limit);
1145
+ for (const k of keys) {
1146
+ const parts = k.replace(/^bizar:\/\//, '').split('/');
1147
+ const group = parts.length > 1 ? parts[0] : 'root';
1148
+ nodes.push({
1149
+ id: k,
1150
+ label: parts[parts.length - 1].replace(/[_-]/g, ' '),
1151
+ type: 'note',
1152
+ size: 1,
1153
+ group,
1154
+ });
1155
+ }
1156
+ }
1157
+ } catch (err) {
1158
+ console.warn('[lightrag] getLightRAGGraph fallback read error:', err?.message);
1159
+ }
1160
+ return { nodes, edges };
1161
+ }
1162
+
1054
1163
  /**
1055
1164
  * Rebuild the graph from scratch. Stops the server, wipes the working dir,
1056
1165
  * and re-runs reindexVault. Returns { ok, started, error?, markerPath? }.