@polderlabs/bizar 4.0.0 → 4.2.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 (38) hide show
  1. package/README.md +11 -14
  2. package/bizar-dash/CHANGELOG.md +1 -1
  3. package/bizar-dash/src/server/api.mjs +2 -2
  4. package/bizar-dash/src/server/artifacts-store.mjs +4 -4
  5. package/bizar-dash/src/server/memory-git.mjs +80 -0
  6. package/bizar-dash/src/server/memory-lightrag.mjs +767 -0
  7. package/bizar-dash/src/server/memory-store.mjs +47 -0
  8. package/bizar-dash/src/server/mod-security.mjs +2 -2
  9. package/bizar-dash/src/server/routes/memory.mjs +174 -15
  10. package/bizar-dash/src/server/server.mjs +1 -1
  11. package/bizar-dash/src/server/state.mjs +2 -2
  12. package/bizar-dash/src/web/views/Config.tsx +461 -1
  13. package/bizar-dash/tests/memory-cli.test.mjs +542 -0
  14. package/bizar-dash/tests/memory-config.test.mjs +422 -0
  15. package/bizar-dash/tests/memory-git.test.mjs +109 -1
  16. package/bizar-dash/tests/memory-lightrag.test.mjs +153 -0
  17. package/bizar-dash/tests/memory-protocol-drift.test.mjs +45 -0
  18. package/cli/banner.mjs +1 -1
  19. package/cli/bin.mjs +4 -4
  20. package/cli/bootstrap.mjs +1 -1
  21. package/cli/copy.mjs +22 -16
  22. package/cli/doctor.mjs +4 -4
  23. package/cli/doctor.test.mjs +2 -2
  24. package/cli/init.mjs +2 -2
  25. package/cli/install.mjs +21 -16
  26. package/cli/memory.mjs +710 -31
  27. package/cli/utils.mjs +6 -3
  28. package/config/AGENTS.md +7 -7
  29. package/config/agents/_shared/AGENT_BASELINE.md +59 -61
  30. package/config/opencode.json +13 -38
  31. package/config/skills/memory-protocol/SKILL.md +105 -0
  32. package/config/skills/obsidian/SKILL.md +58 -1
  33. package/install.sh +11 -1
  34. package/package.json +2 -2
  35. package/plugins/bizar/index.ts +7 -0
  36. package/plugins/bizar/src/commands.ts +42 -1
  37. package/plugins/bizar/src/tools/open-kb.ts +191 -0
  38. package/plugins/bizar/tests/commands.test.ts +36 -0
@@ -0,0 +1,422 @@
1
+ /**
2
+ * tests/memory-config.test.mjs
3
+ *
4
+ * Tests for writeLightRAGConfig, patch-mode POST /memory/config,
5
+ * redact behaviour, and LightRAG lifecycle endpoints.
6
+ */
7
+
8
+ import { test, describe, beforeEach, afterEach } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
13
+ import http from 'node:http';
14
+
15
+ const httpGet = (port, path) =>
16
+ new Promise((resolve) => {
17
+ const req = http.request({ hostname: 'localhost', port, path, method: 'GET' }, (res) => {
18
+ let body = '';
19
+ res.on('data', (c) => (body += c));
20
+ res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(body || '{}') }));
21
+ });
22
+ req.end();
23
+ });
24
+
25
+ const httpPost = (port, path, body) =>
26
+ new Promise((resolve) => {
27
+ const data = JSON.stringify(body);
28
+ const req = http.request(
29
+ { hostname: 'localhost', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
30
+ (res) => {
31
+ let respBody = '';
32
+ res.on('data', (c) => (respBody += c));
33
+ res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(respBody || '{}') }));
34
+ },
35
+ );
36
+ req.write(data);
37
+ req.end();
38
+ });
39
+
40
+ const {
41
+ writeLightRAGConfig,
42
+ resolveLightRAGConfig,
43
+ } = await import('../src/server/memory-lightrag.mjs');
44
+
45
+ // ── Helpers ────────────────────────────────────────────────────────────────────
46
+
47
+ function makeTmp() {
48
+ const root = join(tmpdir(), `bizar-lightrag-cfg-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
49
+ mkdirSync(root, { recursive: true });
50
+ mkdirSync(join(root, '.bizar'), { recursive: true });
51
+ return root;
52
+ }
53
+
54
+ async function buildApp(projectRoot) {
55
+ const { createMemoryRouter } = await import('../src/server/routes/memory.mjs');
56
+ const express = (await import('express')).default;
57
+ const app = express();
58
+ app.use(express.json());
59
+ app.use('/api', createMemoryRouter({ projectRoot }));
60
+ return app;
61
+ }
62
+
63
+ async function withApp(projectRoot, fn) {
64
+ const app = await buildApp(projectRoot);
65
+ const server = app.listen(0);
66
+ const port = server.address().port;
67
+ try {
68
+ return await fn(port);
69
+ } finally {
70
+ server.close();
71
+ }
72
+ }
73
+
74
+ // ── writeLightRAGConfig — unit tests ─────────────────────────────────────────
75
+
76
+ describe('writeLightRAGConfig', () => {
77
+ let root;
78
+
79
+ beforeEach(() => {
80
+ root = makeTmp();
81
+ });
82
+
83
+ afterEach(() => {
84
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
85
+ });
86
+
87
+ test('deep-merges into existing lightrag block', () => {
88
+ writeFileSync(join(root, '.bizar', 'memory.json'), JSON.stringify({
89
+ version: 1,
90
+ lightrag: { enabled: false, port: 8000, llmModel: 'gpt-4o' },
91
+ }));
92
+
93
+ const result = writeLightRAGConfig(root, { enabled: true, port: 9000 });
94
+ assert.equal(result.ok, true);
95
+
96
+ const mem = JSON.parse(readFileSync(join(root, '.bizar', 'memory.json'), 'utf8'));
97
+ assert.equal(mem.lightrag.enabled, true);
98
+ assert.equal(mem.lightrag.port, 9000);
99
+ assert.equal(mem.lightrag.llmModel, 'gpt-4o'); // preserved
100
+ });
101
+
102
+ test('validates llmBinding is in known set', () => {
103
+ const result = writeLightRAGConfig(root, { llmBinding: 'not-a-binding' });
104
+ assert.equal(result.ok, false);
105
+ assert.ok(result.error.includes('llmBinding'));
106
+ });
107
+
108
+ test('validates embeddingBinding is in known set', () => {
109
+ const result = writeLightRAGConfig(root, { embeddingBinding: 'bad-embedding' });
110
+ assert.equal(result.ok, false);
111
+ assert.ok(result.error.includes('embeddingBinding'));
112
+ });
113
+
114
+ test('rejects port out of range (0)', () => {
115
+ const result = writeLightRAGConfig(root, { port: 0 });
116
+ assert.equal(result.ok, false);
117
+ assert.ok(result.error.includes('port'));
118
+ });
119
+
120
+ test('rejects port out of range (66666)', () => {
121
+ const result = writeLightRAGConfig(root, { port: 66666 });
122
+ assert.equal(result.ok, false);
123
+ assert.ok(result.error.includes('port'));
124
+ });
125
+
126
+ test('accepts port 1 and 65535', () => {
127
+ let r = writeLightRAGConfig(root, { port: 1 });
128
+ assert.equal(r.ok, true);
129
+ r = writeLightRAGConfig(root, { port: 65535 });
130
+ assert.equal(r.ok, true);
131
+ });
132
+
133
+ test('canonicalises <empty> to empty string for apiKey', () => {
134
+ const r = writeLightRAGConfig(root, { apiKey: '<empty>' });
135
+ assert.equal(r.ok, true);
136
+ const mem = JSON.parse(readFileSync(join(root, '.bizar', 'memory.json'), 'utf8'));
137
+ assert.equal(mem.lightrag.apiKey, '');
138
+ });
139
+
140
+ test('atomic — old or new file present after concurrent writes', () => {
141
+ writeLightRAGConfig(root, { port: 10000 });
142
+ for (let i = 0; i < 20; i++) {
143
+ writeLightRAGConfig(root, { port: i + 1 });
144
+ }
145
+ const content = readFileSync(join(root, '.bizar', 'memory.json'), 'utf8');
146
+ const parsed = JSON.parse(content);
147
+ assert.equal(parsed.lightrag.port, 20);
148
+ });
149
+
150
+ test('creates .bizar/memory.json if missing', () => {
151
+ const fresh = join(tmpdir(), `bizar-fresh-${Date.now()}-${Math.random().toString(36).slice(2)}`);
152
+ mkdirSync(fresh, { recursive: true });
153
+ try {
154
+ const r = writeLightRAGConfig(fresh, { port: 5000 });
155
+ assert.equal(r.ok, true);
156
+ assert.ok(existsSync(join(fresh, '.bizar', 'memory.json')));
157
+ const mem = JSON.parse(readFileSync(join(fresh, '.bizar', 'memory.json'), 'utf8'));
158
+ assert.equal(mem.lightrag.port, 5000);
159
+ } finally {
160
+ rmSync(fresh, { recursive: true, force: true });
161
+ }
162
+ });
163
+
164
+ test('preserves non-lightrag fields when merging', () => {
165
+ writeFileSync(join(root, '.bizar', 'memory.json'), JSON.stringify({
166
+ version: 1,
167
+ projectId: 'test-proj',
168
+ memoryRepo: { mode: 'local-only' },
169
+ lightrag: { port: 7000 },
170
+ }));
171
+ const r = writeLightRAGConfig(root, { llmModel: 'claude-3' });
172
+ assert.equal(r.ok, true);
173
+ const mem = JSON.parse(readFileSync(join(root, '.bizar', 'memory.json'), 'utf8'));
174
+ assert.equal(mem.projectId, 'test-proj');
175
+ assert.equal(mem.lightrag.llmModel, 'claude-3');
176
+ assert.equal(mem.lightrag.port, 7000);
177
+ });
178
+ });
179
+
180
+ // ── resolveLightRAGConfig — apiKey defaults ───────────────────────────────────
181
+
182
+ describe('resolveLightRAGConfig apiKey defaults', () => {
183
+ let root;
184
+
185
+ beforeEach(() => {
186
+ root = makeTmp();
187
+ });
188
+
189
+ afterEach(() => {
190
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
191
+ });
192
+
193
+ test('defaults apiKey to "env" when not set', () => {
194
+ writeFileSync(join(root, '.bizar', 'memory.json'), JSON.stringify({
195
+ lightrag: { port: 9621 },
196
+ }));
197
+ const cfg = resolveLightRAGConfig(root);
198
+ assert.equal(cfg.apiKey, 'env');
199
+ assert.equal(cfg.apiKeySource, 'env');
200
+ });
201
+
202
+ test('reads existing apiKeySource', () => {
203
+ writeFileSync(join(root, '.bizar', 'memory.json'), JSON.stringify({
204
+ lightrag: { port: 9621, apiKey: 'sk-123', apiKeySource: 'file' },
205
+ }));
206
+ const cfg = resolveLightRAGConfig(root);
207
+ assert.equal(cfg.apiKey, 'sk-123');
208
+ assert.equal(cfg.apiKeySource, 'file');
209
+ });
210
+ });
211
+
212
+ // ── HTTP route integration tests ─────────────────────────────────────────────
213
+
214
+ describe('POST /api/memory/config patch mode', () => {
215
+ let root;
216
+
217
+ beforeEach(() => {
218
+ root = makeTmp();
219
+ writeFileSync(join(root, '.bizar', 'memory.json'), JSON.stringify({
220
+ version: 1,
221
+ lightrag: { port: 9621, llmModel: 'original-model' },
222
+ }));
223
+ });
224
+
225
+ afterEach(() => {
226
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
227
+ });
228
+
229
+ test('returns merged config with redacted apiKey', async () => {
230
+ writeLightRAGConfig(root, { apiKey: 'sk-secret123', apiKeySource: 'file' });
231
+
232
+ await withApp(root, async (port) => {
233
+ const res = await httpPost(port, '/api/memory/config', {
234
+ patch: true,
235
+ lightrag: { port: 5000 },
236
+ });
237
+ assert.equal(res.status, 200);
238
+ assert.equal(res.body.ok, true);
239
+ assert.equal(res.body.config.lightrag.port, 5000);
240
+ assert.equal(res.body.config.lightrag.llmModel, 'original-model');
241
+ // apiKey must be redacted
242
+ assert.equal(res.body.config.lightrag.apiKey, '***');
243
+ });
244
+ });
245
+
246
+ test('rejects patch without lightrag field', async () => {
247
+ await withApp(root, async (port) => {
248
+ const res = await httpPost(port, '/api/memory/config', { patch: true, foo: 'bar' });
249
+ assert.equal(res.status, 400);
250
+ });
251
+ });
252
+
253
+ test('rejects unknown top-level fields in patch', async () => {
254
+ await withApp(root, async (port) => {
255
+ const res = await httpPost(port, '/api/memory/config', { patch: true, lightrag: { port: 1 }, mode: 'managed' });
256
+ assert.equal(res.status, 400);
257
+ });
258
+ });
259
+
260
+ test('validates port range through patch', async () => {
261
+ await withApp(root, async (port) => {
262
+ const res = await httpPost(port, '/api/memory/config', { patch: true, lightrag: { port: 99999 } });
263
+ assert.equal(res.status, 400);
264
+ assert.ok(res.body.message.includes('port'));
265
+ });
266
+ });
267
+
268
+ test('validates llmBinding through patch', async () => {
269
+ await withApp(root, async (port) => {
270
+ const res = await httpPost(port, '/api/memory/config', { patch: true, lightrag: { llmBinding: 'invalid' } });
271
+ assert.equal(res.status, 400);
272
+ assert.ok(res.body.message.includes('llmBinding'));
273
+ });
274
+ });
275
+ });
276
+
277
+ describe('GET /api/memory/config — redaction', () => {
278
+ let root;
279
+
280
+ beforeEach(() => {
281
+ root = makeTmp();
282
+ writeFileSync(join(root, '.bizar', 'memory.json'), JSON.stringify({
283
+ version: 1,
284
+ lightrag: { port: 9621, apiKey: 'sk-abc123', apiKeySource: 'file' },
285
+ }));
286
+ });
287
+
288
+ afterEach(() => {
289
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
290
+ });
291
+
292
+ test('GET /memory/config redacts apiKey to ***', async () => {
293
+ await withApp(root, async (port) => {
294
+ const res = await httpGet(port, '/api/memory/config');
295
+ assert.equal(res.status, 200);
296
+ assert.equal(res.body.config.lightrag.apiKey, '***');
297
+ assert.equal(res.body.config.lightrag.apiKeySource, 'file');
298
+ });
299
+ });
300
+ });
301
+
302
+ describe('GET /api/memory/lightrag/status', () => {
303
+ let root;
304
+
305
+ beforeEach(() => {
306
+ root = makeTmp();
307
+ writeFileSync(join(root, '.bizar', 'memory.json'), JSON.stringify({
308
+ version: 1,
309
+ lightrag: { port: 9621, llmBinding: 'ollama', embeddingBinding: 'ollama', llmModel: 'test-model', embeddingModel: 'test-embed' },
310
+ }));
311
+ mkdirSync(join(root, '.bizar', 'lightrag'), { recursive: true });
312
+ });
313
+
314
+ afterEach(() => {
315
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
316
+ });
317
+
318
+ test('returns running:false when server not running', async () => {
319
+ await withApp(root, async (port) => {
320
+ const res = await httpGet(port, '/api/memory/lightrag/status');
321
+ assert.equal(res.status, 200);
322
+ assert.equal(res.body.running, false);
323
+ assert.equal(res.body.pid, null);
324
+ assert.equal(res.body.port, 9621);
325
+ assert.equal(res.body.llmBinding, 'ollama');
326
+ assert.equal(res.body.embeddingBinding, 'ollama');
327
+ });
328
+ });
329
+ });
330
+
331
+ describe('POST /api/memory/lightrag/start', () => {
332
+ let root;
333
+
334
+ beforeEach(() => {
335
+ root = makeTmp();
336
+ writeFileSync(join(root, '.bizar', 'memory.json'), JSON.stringify({
337
+ version: 1,
338
+ lightrag: { port: 9621 },
339
+ }));
340
+ mkdirSync(join(root, '.bizar', 'lightrag'), { recursive: true });
341
+ });
342
+
343
+ afterEach(() => {
344
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
345
+ });
346
+
347
+ test('returns ok:false with error when lightrag-server not installed (or ok:true if it is)', async () => {
348
+ await withApp(root, async (port) => {
349
+ const res = await httpPost(port, '/api/memory/lightrag/start', {});
350
+ assert.equal(res.status, 200);
351
+ assert.equal(typeof res.body.ok, 'boolean');
352
+ if (!res.body.ok) {
353
+ assert.ok(res.body.error != null);
354
+ }
355
+ });
356
+ });
357
+ });
358
+
359
+ describe('POST /api/memory/lightrag/stop', () => {
360
+ let root;
361
+
362
+ beforeEach(() => {
363
+ root = makeTmp();
364
+ writeFileSync(join(root, '.bizar', 'memory.json'), JSON.stringify({
365
+ version: 1,
366
+ lightrag: { port: 9621 },
367
+ }));
368
+ mkdirSync(join(root, '.bizar', 'lightrag'), { recursive: true });
369
+ });
370
+
371
+ afterEach(() => {
372
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
373
+ });
374
+
375
+ test('returns ok:true when nothing is running', async () => {
376
+ await withApp(root, async (port) => {
377
+ const res = await httpPost(port, '/api/memory/lightrag/stop', {});
378
+ assert.equal(res.status, 200);
379
+ assert.equal(res.body.ok, true);
380
+ });
381
+ });
382
+ });
383
+
384
+ describe('GET /api/memory/lightrag/log', () => {
385
+ let root;
386
+
387
+ beforeEach(() => {
388
+ root = makeTmp();
389
+ writeFileSync(join(root, '.bizar', 'memory.json'), JSON.stringify({
390
+ version: 1,
391
+ lightrag: { port: 9621 },
392
+ }));
393
+ mkdirSync(join(root, '.bizar', 'lightrag'), { recursive: true });
394
+ // Write a log file with 60 lines
395
+ const lines = Array.from({ length: 60 }, (_, i) => `log line ${i + 1}`).join('\n');
396
+ writeFileSync(join(root, '.bizar', 'lightrag', 'lightrag.log'), lines);
397
+ });
398
+
399
+ afterEach(() => {
400
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
401
+ });
402
+
403
+ test('returns last 200 lines', async () => {
404
+ await withApp(root, async (port) => {
405
+ const res = await httpGet(port, '/api/memory/lightrag/log');
406
+ assert.equal(res.status, 200);
407
+ assert.ok(Array.isArray(res.body.lines));
408
+ assert.ok(res.body.lines.length <= 200);
409
+ });
410
+ });
411
+
412
+ test('returns empty lines when no log file', async () => {
413
+ rmSync(join(root, '.bizar', 'lightrag', 'lightrag.log'), { force: true });
414
+
415
+ await withApp(root, async (port) => {
416
+ const res = await httpGet(port, '/api/memory/lightrag/log');
417
+ assert.equal(res.status, 200);
418
+ assert.ok(Array.isArray(res.body.lines));
419
+ assert.equal(res.body.lines.length, 0);
420
+ });
421
+ });
422
+ });
@@ -9,7 +9,7 @@ import assert from 'node:assert';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
12
- import { execSync } from 'node:child_process';
12
+ import { execSync, execFileSync } from 'node:child_process';
13
13
 
14
14
  const TEST_GIT = await import('../src/server/memory-git.mjs').then((m) => m);
15
15
  const GIT_INSTALLED = TEST_GIT.isGitInstalled();
@@ -135,4 +135,112 @@ describe('memory-git', () => {
135
135
  const result = TEST_GIT.pull(workingDir);
136
136
  assert.strictEqual(result.ok, true);
137
137
  });
138
+
139
+ describe('addRemote', () => {
140
+ (GIT_INSTALLED ? it : it.skip)('registers a new remote on a fresh repo', () => {
141
+ const dir = join(tmpdir(), `bizar-addremote-fresh-${Date.now()}-${Math.random().toString(36).slice(2)}`);
142
+ mkdirSync(dir, { recursive: true });
143
+ try {
144
+ execFileSync('git', ['init', '-b', 'main'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
145
+ execFileSync('git', ['config', 'user.email', 't@t'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
146
+ execFileSync('git', ['config', 'user.name', 't'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
147
+
148
+ const result = TEST_GIT.addRemote(dir, 'origin', 'git@github.com:user/repo.git');
149
+ assert.deepEqual(result, { ok: true, action: 'added' });
150
+
151
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: dir, encoding: 'utf8' }).trim();
152
+ assert.strictEqual(url, 'git@github.com:user/repo.git');
153
+ } finally {
154
+ rmSync(dir, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ (GIT_INSTALLED ? it : it.skip)('is idempotent when same URL is set twice', () => {
159
+ const dir = join(tmpdir(), `bizar-addremote-idempotent-${Date.now()}-${Math.random().toString(36).slice(2)}`);
160
+ mkdirSync(dir, { recursive: true });
161
+ try {
162
+ execFileSync('git', ['init', '-b', 'main'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
163
+ execFileSync('git', ['config', 'user.email', 't@t'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
164
+ execFileSync('git', ['config', 'user.name', 't'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
165
+
166
+ const first = TEST_GIT.addRemote(dir, 'origin', 'git@github.com:user/repo.git');
167
+ assert.deepEqual(first, { ok: true, action: 'added' });
168
+
169
+ const second = TEST_GIT.addRemote(dir, 'origin', 'git@github.com:user/repo.git');
170
+ assert.deepEqual(second, { ok: true, action: 'unchanged' });
171
+ } finally {
172
+ rmSync(dir, { recursive: true, force: true });
173
+ }
174
+ });
175
+
176
+ (GIT_INSTALLED ? it : it.skip)('throws when remote exists with different URL and overwrite is false', () => {
177
+ const dir = join(tmpdir(), `bizar-addremote-noverwrite-${Date.now()}-${Math.random().toString(36).slice(2)}`);
178
+ mkdirSync(dir, { recursive: true });
179
+ try {
180
+ execFileSync('git', ['init', '-b', 'main'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
181
+ execFileSync('git', ['config', 'user.email', 't@t'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
182
+ execFileSync('git', ['config', 'user.name', 't'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
183
+
184
+ TEST_GIT.addRemote(dir, 'origin', 'git@github.com:user/repo.git');
185
+ assert.throws(
186
+ () => TEST_GIT.addRemote(dir, 'origin', 'git@github.com:other/repo.git'),
187
+ /already exists/,
188
+ );
189
+ } finally {
190
+ rmSync(dir, { recursive: true, force: true });
191
+ }
192
+ });
193
+
194
+ (GIT_INSTALLED ? it : it.skip)('overwrites remote URL when overwrite flag is set', () => {
195
+ const dir = join(tmpdir(), `bizar-addremote-overwrite-${Date.now()}-${Math.random().toString(36).slice(2)}`);
196
+ mkdirSync(dir, { recursive: true });
197
+ try {
198
+ execFileSync('git', ['init', '-b', 'main'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
199
+ execFileSync('git', ['config', 'user.email', 't@t'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
200
+ execFileSync('git', ['config', 'user.name', 't'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
201
+
202
+ TEST_GIT.addRemote(dir, 'origin', 'git@github.com:user/repo.git');
203
+ const result = TEST_GIT.addRemote(dir, 'origin', 'git@github.com:other/repo.git', { overwrite: true });
204
+ assert.deepEqual(result, { ok: true, action: 'updated' });
205
+
206
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: dir, encoding: 'utf8' }).trim();
207
+ assert.strictEqual(url, 'git@github.com:other/repo.git');
208
+ } finally {
209
+ rmSync(dir, { recursive: true, force: true });
210
+ }
211
+ });
212
+ });
213
+
214
+ describe('lsRemote', () => {
215
+ (GIT_INSTALLED ? it : it.skip)('returns empty string for an unreachable remote', () => {
216
+ const dir = join(tmpdir(), `bizar-lsremote-unreachable-${Date.now()}-${Math.random().toString(36).slice(2)}`);
217
+ mkdirSync(dir, { recursive: true });
218
+ try {
219
+ execFileSync('git', ['init', '-b', 'main'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
220
+ execFileSync('git', ['config', 'user.email', 't@t'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
221
+ execFileSync('git', ['config', 'user.name', 't'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
222
+
223
+ TEST_GIT.addRemote(dir, 'origin', 'git@127.0.0.1:1/nope.git');
224
+ const result = TEST_GIT.lsRemote(dir, 'origin', { timeoutMs: 1000 });
225
+ assert.strictEqual(result, '');
226
+ } finally {
227
+ rmSync(dir, { recursive: true, force: true });
228
+ }
229
+ });
230
+
231
+ (GIT_INSTALLED ? it : it.skip)('returns empty string when the remote name does not exist', () => {
232
+ const dir = join(tmpdir(), `bizar-lsremote-nonexistent-${Date.now()}-${Math.random().toString(36).slice(2)}`);
233
+ mkdirSync(dir, { recursive: true });
234
+ try {
235
+ execFileSync('git', ['init', '-b', 'main'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
236
+ execFileSync('git', ['config', 'user.email', 't@t'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
237
+ execFileSync('git', ['config', 'user.name', 't'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' });
238
+
239
+ const result = TEST_GIT.lsRemote(dir, 'nonexistent', { timeoutMs: 1000 });
240
+ assert.strictEqual(result, '');
241
+ } finally {
242
+ rmSync(dir, { recursive: true, force: true });
243
+ }
244
+ });
245
+ });
138
246
  });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * tests/memory-lightrag.test.mjs
3
+ *
4
+ * v4.1.0 — Tests for the LightRAG orchestrator.
5
+ *
6
+ * Skips entirely if `lightrag-server` is not installed (the user can
7
+ * install it with `uv tool install "lightrag-hku[api]"`).
8
+ */
9
+
10
+ import { test, describe, before } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { tmpdir } from 'node:os';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = import.meta.dirname || join(__filename, '..');
19
+
20
+ const {
21
+ findLightragBinary,
22
+ isInstalled,
23
+ resolveLightRAGConfig,
24
+ reindexVault,
25
+ query,
26
+ isRunning,
27
+ } = await import('../src/server/memory-lightrag.mjs');
28
+
29
+ const lightragAvailable = findLightragBinary() !== null;
30
+
31
+ describe('memory-lightrag — preflight', { skip: !lightragAvailable }, () => {
32
+ test('findLightragBinary returns a path', () => {
33
+ const bin = findLightragBinary();
34
+ assert.ok(bin, 'expected binary path');
35
+ assert.ok(existsSync(bin), `expected ${bin} to exist`);
36
+ });
37
+
38
+ test('isInstalled returns true', async () => {
39
+ const ok = await isInstalled();
40
+ assert.equal(ok, true);
41
+ });
42
+ });
43
+
44
+ describe('memory-lightrag — config', { skip: !lightragAvailable }, () => {
45
+ test('resolveLightRAGConfig returns defaults when no memory.json', () => {
46
+ const fakeRoot = join(tmpdir(), 'bizar-memlight-test-no-config-' + Date.now());
47
+ mkdirSync(fakeRoot, { recursive: true });
48
+ try {
49
+ const cfg = resolveLightRAGConfig(fakeRoot);
50
+ assert.equal(cfg.enabled, true);
51
+ assert.equal(cfg.host, '127.0.0.1');
52
+ assert.equal(cfg.port, 9621);
53
+ assert.ok(cfg.workingDir.includes('.bizar/lightrag'));
54
+ } finally {
55
+ rmSync(fakeRoot, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test('resolveLightRAGConfig reads user memory.json', () => {
60
+ const fakeRoot = join(tmpdir(), 'bizar-memlight-test-cfg-' + Date.now());
61
+ mkdirSync(join(fakeRoot, '.bizar'), { recursive: true });
62
+ writeFileSync(join(fakeRoot, '.bizar', 'memory.json'), JSON.stringify({
63
+ lightrag: { enabled: false, port: 9999, host: '0.0.0.0' },
64
+ }));
65
+ try {
66
+ const cfg = resolveLightRAGConfig(fakeRoot);
67
+ assert.equal(cfg.enabled, false);
68
+ assert.equal(cfg.port, 9999);
69
+ assert.equal(cfg.host, '0.0.0.0');
70
+ } finally {
71
+ rmSync(fakeRoot, { recursive: true, force: true });
72
+ }
73
+ });
74
+ });
75
+
76
+ describe('memory-lightrag — reindex (live)', { skip: !lightragAvailable }, () => {
77
+ let tmpRoot;
78
+ let noteCount = 0;
79
+
80
+ before(async () => {
81
+ tmpRoot = join(tmpdir(), 'bizar-memlight-test-' + Date.now());
82
+ const bizarDir = join(tmpRoot, '.bizar');
83
+ mkdirSync(join(bizarDir, 'memory'), { recursive: true });
84
+ mkdirSync(join(bizarDir, 'memory', 'projects', 'testproj'), { recursive: true });
85
+
86
+ // Write a minimal memory.json (managed mode).
87
+ writeFileSync(join(bizarDir, 'memory.json'), JSON.stringify({
88
+ version: 1,
89
+ backend: 'bizar-local',
90
+ projectId: 'testproj',
91
+ memoryRepo: {
92
+ mode: 'managed',
93
+ path: join(bizarDir, 'memory'),
94
+ remote: null,
95
+ branch: 'main',
96
+ namespace: 'projects/testproj',
97
+ },
98
+ namespaces: {
99
+ project: 'projects/testproj',
100
+ global: 'global/bizar',
101
+ user: 'users/local',
102
+ },
103
+ lightrag: {
104
+ enabled: true,
105
+ host: '127.0.0.1',
106
+ port: 9621,
107
+ workingDir: join(bizarDir, 'lightrag'),
108
+ },
109
+ }));
110
+
111
+ // Write a couple of test notes.
112
+ const noteA = `---
113
+ memory_id: mem_test_a
114
+ type: architecture_decision
115
+ project_id: testproj
116
+ status: active
117
+ confidence: verified
118
+ created: 2026-06-29T00:00:00Z
119
+ updated: 2026-06-29T00:00:00Z
120
+ tags: [test]
121
+ ---
122
+
123
+ # Test decision A
124
+
125
+ LightRAG should be reachable.`;
126
+ writeFileSync(join(bizarDir, 'memory', 'projects', 'testproj', 'a.md'), noteA);
127
+ noteCount = 1;
128
+ });
129
+
130
+ test('reindexVault writes a marker file', async () => {
131
+ const result = await reindexVault(tmpRoot, {});
132
+ // LightRAG server may not start if no LLM creds are present (default
133
+ // binding is ollama). We assert the orchestrator produced a result
134
+ // and wrote a marker file regardless of insert success.
135
+ assert.ok(result, 'reindexVault returned a result');
136
+ assert.ok(result.noteCount >= 1, `should report at least one note, got ${result.noteCount}`);
137
+ assert.ok(result.markerPath, 'markerPath must be set');
138
+ assert.ok(existsSync(result.markerPath), 'marker file must exist on disk');
139
+ const marker = JSON.parse(readFileSync(result.markerPath, 'utf8'));
140
+ assert.ok(marker.attemptedAt, 'marker has attemptedAt');
141
+ assert.equal(marker.noteCount, noteCount, 'marker counts all notes');
142
+ assert.equal(typeof marker.durationMs, 'number');
143
+ assert.equal(typeof marker.inserted, 'number');
144
+ assert.equal(typeof marker.failed, 'number');
145
+ }, { timeout: 60_000 });
146
+
147
+ test('isRunning reports server health', async () => {
148
+ const cfg = resolveLightRAGConfig(tmpRoot);
149
+ const running = await isRunning(cfg);
150
+ // We don't assert running=true — the server may not be up in CI.
151
+ assert.equal(typeof running, 'boolean');
152
+ });
153
+ });