@polderlabs/bizar 5.4.0 → 5.5.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 (99) hide show
  1. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js → EnvVarsSection-L413CQEI.js} +3 -3
  2. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js.map → EnvVarsSection-L413CQEI.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js → MobileChat-0kGQb1S6.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js.map → MobileChat-0kGQb1S6.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +1 -0
  6. package/bizar-dash/dist/assets/{MobileSettings-CVW9VhEo.js.map → MobileSettings-Cq1oFIdO.js.map} +1 -1
  7. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js → Toast-C6f1zFJ2.js} +1 -1
  8. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js.map → Toast-C6f1zFJ2.js.map} +1 -1
  9. package/bizar-dash/dist/assets/{icons-OMEGx5KA.js → icons-BsWXWkEF.js} +121 -126
  10. package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-BklYy02e.css +1 -0
  12. package/bizar-dash/dist/assets/main-CBTgYSeG.js +18 -0
  13. package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +1 -0
  14. package/bizar-dash/dist/assets/mobile-CsaN4CxH.js +1 -0
  15. package/bizar-dash/dist/assets/{mobile-XN2P7YfM.js.map → mobile-CsaN4CxH.js.map} +1 -1
  16. package/bizar-dash/dist/assets/{mobile-layout-D6V-7WnA.css → mobile-layout-CJnZNLy3.css} +1 -1
  17. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js +2 -0
  18. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js.map +1 -0
  19. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js → useSlashCommands-DGJcC9iQ.js} +2 -2
  20. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js.map → useSlashCommands-DGJcC9iQ.js.map} +1 -1
  21. package/bizar-dash/dist/index.html +8 -8
  22. package/bizar-dash/dist/mobile.html +4 -4
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/server/backup-store.mjs +6 -1
  25. package/bizar-dash/src/server/bg-spawner.mjs +574 -0
  26. package/bizar-dash/src/server/memory-lightrag.mjs +242 -0
  27. package/bizar-dash/src/server/memory-store.mjs +43 -3
  28. package/bizar-dash/src/server/routes/background.mjs +143 -0
  29. package/bizar-dash/src/server/routes/lightrag.mjs +10 -0
  30. package/bizar-dash/src/server/routes/memory.mjs +8 -0
  31. package/bizar-dash/src/server/server.mjs +22 -0
  32. package/bizar-dash/src/web/App.tsx +3 -0
  33. package/bizar-dash/src/web/MobileApp.tsx +1 -10
  34. package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +1 -1
  35. package/bizar-dash/src/web/components/SettingsNav.tsx +1 -10
  36. package/bizar-dash/src/web/lib/types.ts +39 -1
  37. package/bizar-dash/src/web/styles/main.css +43 -0
  38. package/bizar-dash/src/web/styles/mobile-layout.css +7 -5
  39. package/bizar-dash/src/web/styles/settings.css +46 -0
  40. package/bizar-dash/src/web/views/BackgroundAgents.tsx +399 -30
  41. package/bizar-dash/src/web/views/Settings.tsx +12 -12
  42. package/bizar-dash/src/web/views/SpawnAgentModal.tsx +221 -0
  43. package/bizar-dash/tests/BackgroundAgents.test.tsx +160 -0
  44. package/bizar-dash/tests/background-pause-resume.test.mjs +46 -0
  45. package/bizar-dash/tests/background-spawn-from-ui.test.mjs +36 -0
  46. package/bizar-dash/tests/background-steer.test.mjs +42 -0
  47. package/bizar-dash/tests/lightrag-startup-hook.test.mjs +206 -0
  48. package/bizar-dash/tests/memory-auto-reindex.test.mjs +196 -0
  49. package/bizar-dash/tests/memory-cli.test.mjs +1 -1
  50. package/bizar-dash/tests/memory-default-vault.test.mjs +90 -3
  51. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +131 -0
  52. package/bizar-dash/tests/mobile-layout.test.tsx +36 -0
  53. package/bizar-dash/tests/settings-layout.test.tsx +3 -3
  54. package/bizar-dash/tests/settings-nav.test.tsx +7 -5
  55. package/cli/bin.mjs +16 -0
  56. package/cli/commands/deploy/docker.mjs +2 -1
  57. package/cli/commands/lightrag.mjs +171 -0
  58. package/cli/commands/service.mjs +7 -0
  59. package/cli/commands/util.mjs +8 -1
  60. package/cli/init.mjs +1 -1
  61. package/cli/post-install-smoke.mjs +230 -0
  62. package/cli/provision.mjs +177 -0
  63. package/cli/provision.test.mjs +180 -0
  64. package/cli/service-controller.mjs +378 -28
  65. package/cli/service-controller.test.mjs +100 -0
  66. package/cli/service-env.mjs +139 -0
  67. package/cli/service.mjs +23 -0
  68. package/config/agents/_shared/AGENT_BASELINE.md +6 -6
  69. package/install.sh +87 -6
  70. package/package.json +1 -1
  71. package/plugins/bizar/index.ts +147 -0
  72. package/plugins/bizar/src/background-state.ts +84 -5
  73. package/plugins/bizar/src/background.ts +388 -10
  74. package/plugins/bizar/src/hooks/memory-inject.ts +247 -0
  75. package/plugins/bizar/src/hooks/memory-write-on-end.ts +188 -0
  76. package/plugins/bizar/src/opencode-runner.ts +87 -1
  77. package/plugins/bizar/src/tools/bg-pause.ts +77 -0
  78. package/plugins/bizar/src/tools/bg-report-progress.ts +101 -0
  79. package/plugins/bizar/src/tools/bg-resume.ts +72 -0
  80. package/plugins/bizar/src/tools/bg-send-message.ts +105 -0
  81. package/plugins/bizar/src/tools/bg-spawn.ts +13 -6
  82. package/plugins/bizar/src/tools/bg-status.ts +10 -0
  83. package/plugins/bizar/src/tools/memory-list.ts +135 -0
  84. package/plugins/bizar/src/tools/memory-read.ts +142 -0
  85. package/plugins/bizar/src/tools/memory-search.ts +228 -0
  86. package/plugins/bizar/src/tools/memory-write.ts +183 -0
  87. package/plugins/bizar/tests/memory-write-on-end.test.ts +92 -0
  88. package/plugins/bizar/tests/tools/bg-pause.test.ts +64 -0
  89. package/plugins/bizar/tests/tools/bg-report-progress.test.ts +82 -0
  90. package/plugins/bizar/tests/tools/bg-resume.test.ts +43 -0
  91. package/plugins/bizar/tests/tools/bg-send-message.test.ts +46 -0
  92. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js +0 -1
  93. package/bizar-dash/dist/assets/icons-OMEGx5KA.js.map +0 -1
  94. package/bizar-dash/dist/assets/main-CMAGZouj.js +0 -16
  95. package/bizar-dash/dist/assets/main-CMAGZouj.js.map +0 -1
  96. package/bizar-dash/dist/assets/main-O2tZVdHm.css +0 -1
  97. package/bizar-dash/dist/assets/mobile-XN2P7YfM.js +0 -1
  98. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js +0 -2
  99. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js.map +0 -1
@@ -0,0 +1,206 @@
1
+ /**
2
+ * tests/lightrag-startup-hook.test.mjs
3
+ *
4
+ * v5.x — Tests for the lightragStartupHook() introduced in issue #6.
5
+ *
6
+ * Strategy: drive lightragStartupHook() against a temp project root that
7
+ * has a `.bizar/memory.json` (or no config at all). The hook is async
8
+ * and must:
9
+ * - resolve the project's lightrag config
10
+ * - honour `lightrag.enabled: false` → return ok=true, reason='disabled'
11
+ * - honour BIZAR_LIGHTRAG_AUTOSTART=0 → return ok=true, reason='env-disabled'
12
+ * - return ok=false, reason='no-project' when no projectRoot is passed
13
+ * - gracefully handle missing binary / errors → return ok=false, error=...
14
+ * - return ok=true, reason='already-running' when the server is up
15
+ *
16
+ * Tests do NOT require `lightrag-server` to be installed. When it isn't,
17
+ * the "auto-start" paths return ok=false with a clear error, which is
18
+ * exactly the contract the dashboard relies on.
19
+ */
20
+
21
+ import { test, describe, beforeEach, afterEach } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { join } from 'node:path';
26
+
27
+ const {
28
+ lightragStartupHook,
29
+ findLightragBinary,
30
+ resolveLightRAGConfig,
31
+ } = await import('../src/server/memory-lightrag.mjs');
32
+
33
+ let tmpRoot;
34
+ const ORIG_AUTOSTART = process.env.BIZAR_LIGHTRAG_AUTOSTART;
35
+ const ORIG_VAULT = process.env.BIZAR_MEMORY_VAULT;
36
+
37
+ beforeEach(() => {
38
+ tmpRoot = mkdtempSync(join(tmpdir(), 'bizar-lrh-'));
39
+ mkdirSync(join(tmpRoot, '.bizar'), { recursive: true });
40
+ delete process.env.BIZAR_LIGHTRAG_AUTOSTART;
41
+ });
42
+
43
+ afterEach(() => {
44
+ if (tmpRoot && existsSync(tmpRoot)) rmSync(tmpRoot, { recursive: true, force: true });
45
+ if (ORIG_AUTOSTART === undefined) delete process.env.BIZAR_LIGHTRAG_AUTOSTART;
46
+ else process.env.BIZAR_LIGHTRAG_AUTOSTART = ORIG_AUTOSTART;
47
+ if (ORIG_VAULT === undefined) delete process.env.BIZAR_MEMORY_VAULT;
48
+ else process.env.BIZAR_MEMORY_VAULT = ORIG_VAULT;
49
+ });
50
+
51
+ function writeMemoryJson(root, patch = {}) {
52
+ const cfg = { ...patch };
53
+ writeFileSync(join(root, '.bizar', 'memory.json'), JSON.stringify(cfg));
54
+ }
55
+
56
+ describe('lightragStartupHook — no project', () => {
57
+ test('returns ok=false, reason=no-project when projectRoot is missing', async () => {
58
+ const r = await lightragStartupHook(null);
59
+ assert.equal(r.ok, false);
60
+ assert.equal(r.started, false);
61
+ assert.equal(r.reason, 'no-project');
62
+ });
63
+
64
+ test('returns ok=false, reason=no-project when projectRoot is empty string', async () => {
65
+ const r = await lightragStartupHook('');
66
+ assert.equal(r.ok, false);
67
+ assert.equal(r.reason, 'no-project');
68
+ });
69
+ });
70
+
71
+ describe('lightragStartupHook — disabled paths', () => {
72
+ test('returns ok=true, reason=disabled when lightrag.enabled is false', async () => {
73
+ writeMemoryJson(tmpRoot, { lightrag: { enabled: false, port: 9621 } });
74
+ const r = await lightragStartupHook(tmpRoot);
75
+ assert.equal(r.ok, true, 'hook succeeds (skips) when explicitly disabled');
76
+ assert.equal(r.started, false);
77
+ assert.equal(r.reason, 'disabled');
78
+ });
79
+
80
+ test('BIZAR_LIGHTRAG_AUTOSTART=0 short-circuits the hook', async () => {
81
+ writeMemoryJson(tmpRoot, {}); // lightrag.enabled defaults to true
82
+ process.env.BIZAR_LIGHTRAG_AUTOSTART = '0';
83
+ const r = await lightragStartupHook(tmpRoot);
84
+ assert.equal(r.ok, true);
85
+ assert.equal(r.started, false);
86
+ assert.equal(r.reason, 'env-disabled');
87
+ });
88
+
89
+ test('BIZAR_LIGHTRAG_AUTOSTART=false short-circuits the hook', async () => {
90
+ writeMemoryJson(tmpRoot, {});
91
+ process.env.BIZAR_LIGHTRAG_AUTOSTART = 'false';
92
+ const r = await lightragStartupHook(tmpRoot);
93
+ assert.equal(r.ok, true);
94
+ assert.equal(r.reason, 'env-disabled');
95
+ });
96
+ });
97
+
98
+ describe('lightragStartupHook — start path', () => {
99
+ test('returns a structured result without throwing', async () => {
100
+ // Use a config that points at a bogus port. If lightrag-server is
101
+ // installed locally, the hook may start it; if not, it will fail
102
+ // with a clean error. Either way, the contract is: the hook
103
+ // returns a structured result and never throws. We don't run the
104
+ // full 30s startup probe here — that's covered by the live
105
+ // memory-lightrag.test.mjs.
106
+ writeMemoryJson(tmpRoot, {
107
+ lightrag: {
108
+ enabled: true,
109
+ host: '127.0.0.1',
110
+ // Use a high port that's very unlikely to be open. The
111
+ // startup probe is bounded by resolveLightRAGConfig's
112
+ // startupTimeoutMs which defaults to 30s — too long for a
113
+ // unit test. We instead pre-create a "running" PID file to
114
+ // short-circuit the probe to "already-running" and assert
115
+ // the hook returns ok=true, reason='already-running'.
116
+ port: 29999,
117
+ workingDir: join(tmpRoot, '.bizar', 'lightrag'),
118
+ },
119
+ });
120
+ // Pre-create a fake PID file pointing at a live parent PID.
121
+ // The hook's readPidAlive() will return alive=true, so the hook
122
+ // short-circuits to "already-running" without spawning a process.
123
+ mkdirSync(join(tmpRoot, '.bizar', 'lightrag'), { recursive: true });
124
+ writeFileSync(join(tmpRoot, '.bizar', 'lightrag', 'lightrag.pid'), String(process.pid));
125
+
126
+ try {
127
+ const r = await lightragStartupHook(tmpRoot);
128
+ assert.equal(r.ok, true, `hook should not error when server is already running`);
129
+ assert.equal(r.started, false);
130
+ assert.equal(r.reason, 'already-running');
131
+ } finally {
132
+ rmSync(join(tmpRoot, '.bizar', 'lightrag'), { recursive: true, force: true });
133
+ }
134
+ });
135
+
136
+ test('never throws even when the config is corrupt', async () => {
137
+ // Write a memory.json that is not valid JSON.
138
+ writeFileSync(join(tmpRoot, '.bizar', 'memory.json'), '{ not valid json');
139
+ // Pre-create a "running" PID file to short-circuit the start
140
+ // probe. Without this, the hook would attempt a real start.
141
+ mkdirSync(join(tmpRoot, '.bizar', 'lightrag'), { recursive: true });
142
+ writeFileSync(join(tmpRoot, '.bizar', 'lightrag', 'lightrag.pid'), String(process.pid));
143
+ try {
144
+ const r = await lightragStartupHook(tmpRoot);
145
+ // resolveLightRAGConfig falls back to defaults on parse error, so
146
+ // the hook will see the running PID and return already-running.
147
+ assert.ok(r);
148
+ assert.equal(typeof r.ok, 'boolean');
149
+ // We don't assert on r.reason — depends on whether the corrupt
150
+ // config parse happens before the PID check.
151
+ } finally {
152
+ rmSync(join(tmpRoot, '.bizar', 'lightrag'), { recursive: true, force: true });
153
+ }
154
+ });
155
+ });
156
+
157
+ describe('lightragStartupHook — env var override', () => {
158
+ test('BIZAR_LIGHTRAG_AUTOSTART=1 / empty does NOT block (default behaviour)', async () => {
159
+ // Without an explicit "disable" value, env should not block startup.
160
+ // We pre-create a PID file so the hook short-circuits to
161
+ // "already-running" without spawning a process.
162
+ writeMemoryJson(tmpRoot, {
163
+ lightrag: {
164
+ enabled: true,
165
+ port: 29998,
166
+ workingDir: join(tmpRoot, '.bizar', 'lightrag'),
167
+ },
168
+ });
169
+ mkdirSync(join(tmpRoot, '.bizar', 'lightrag'), { recursive: true });
170
+ writeFileSync(join(tmpRoot, '.bizar', 'lightrag', 'lightrag.pid'), String(process.pid));
171
+ // Don't set the env var at all (already deleted in beforeEach).
172
+ delete process.env.BIZAR_LIGHTRAG_AUTOSTART;
173
+ try {
174
+ const r = await lightragStartupHook(tmpRoot);
175
+ assert.notEqual(r.reason, 'env-disabled', 'no env-var override → env-disabled is not the reason');
176
+ } finally {
177
+ rmSync(join(tmpRoot, '.bizar', 'lightrag'), { recursive: true, force: true });
178
+ }
179
+ });
180
+ });
181
+
182
+ describe('lightragStartupHook — config resolution', () => {
183
+ test('resolveLightRAGConfig still works for the temp project', () => {
184
+ writeMemoryJson(tmpRoot, {
185
+ lightrag: {
186
+ enabled: true,
187
+ port: 12345,
188
+ host: '0.0.0.0',
189
+ },
190
+ });
191
+ const cfg = resolveLightRAGConfig(tmpRoot);
192
+ assert.equal(cfg.enabled, true);
193
+ assert.equal(cfg.port, 12345);
194
+ assert.equal(cfg.host, '0.0.0.0');
195
+ });
196
+
197
+ test('findLightRAGBinary returns null or a path (does not throw)', () => {
198
+ const bin = findLightragBinary();
199
+ // Either we found it or we didn't. We only care that the call
200
+ // didn't throw — proves the startup hook's "binary missing" path
201
+ // is well-behaved.
202
+ assert.ok(bin === null || typeof bin === 'string');
203
+ });
204
+ });
205
+
206
+ console.log(' lightrag-startup-hook tests loaded');
@@ -0,0 +1,196 @@
1
+ /**
2
+ * tests/memory-auto-reindex.test.mjs
3
+ *
4
+ * v5.x — Tests for the auto-reindex-on-write feature.
5
+ *
6
+ * POST /memory/notes and PUT /memory/notes/* must return the note response
7
+ * promptly WITHOUT waiting for reindexSingleNote to complete (fire-and-forget).
8
+ *
9
+ * These tests verify:
10
+ * 1. POST /memory/notes returns 201 and the note is persisted immediately
11
+ * 2. PUT /memory/notes/* returns 200 and the note is updated immediately
12
+ * 3. Both handlers are non-blocking (reindex is async, fire-and-forget)
13
+ */
14
+
15
+ import { test, describe, beforeEach, afterEach } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
20
+ import http from 'node:http';
21
+
22
+ const httpPost = (port, path, body) =>
23
+ new Promise((resolve) => {
24
+ const data = JSON.stringify(body);
25
+ const req = http.request(
26
+ { hostname: 'localhost', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
27
+ (res) => {
28
+ let respBody = '';
29
+ res.on('data', (c) => (respBody += c));
30
+ res.on('end', () => {
31
+ try { resolve({ status: res.statusCode, body: JSON.parse(respBody || '{}') }); }
32
+ catch { resolve({ status: res.statusCode, body: respBody }); }
33
+ });
34
+ },
35
+ );
36
+ req.write(data);
37
+ req.end();
38
+ });
39
+
40
+ const httpPut = (port, path, body) =>
41
+ new Promise((resolve) => {
42
+ const data = JSON.stringify(body);
43
+ const req = http.request(
44
+ { hostname: 'localhost', port, path, method: 'PUT', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
45
+ (res) => {
46
+ let respBody = '';
47
+ res.on('data', (c) => (respBody += c));
48
+ res.on('end', () => {
49
+ try { resolve({ status: res.statusCode, body: JSON.parse(respBody || '{}') }); }
50
+ catch { resolve({ status: res.statusCode, body: respBody }); }
51
+ });
52
+ },
53
+ );
54
+ req.write(data);
55
+ req.end();
56
+ });
57
+
58
+ async function buildApp(projectRoot) {
59
+ const { createMemoryRouter } = await import('../src/server/routes/memory.mjs');
60
+ const express = (await import('express')).default;
61
+ const app = express();
62
+ app.use(express.json());
63
+ app.use('/api', createMemoryRouter({ projectRoot }));
64
+ return app;
65
+ }
66
+
67
+ async function withApp(projectRoot, fn) {
68
+ const app = await buildApp(projectRoot);
69
+ const server = app.listen(0);
70
+ const port = server.address().port;
71
+ try {
72
+ return await fn(port);
73
+ } finally {
74
+ server.close();
75
+ }
76
+ }
77
+
78
+ function makeTmp() {
79
+ const root = join(tmpdir(), `bizar-auto-reindex-${Date.now()}-${Math.random().toString(36).slice(2)}`);
80
+ mkdirSync(root, { recursive: true });
81
+ mkdirSync(join(root, '.bizar'), { recursive: true });
82
+ // Write a minimal memory.json so the router doesn't fail on init.
83
+ writeFileSync(join(root, '.bizar', 'memory.json'), JSON.stringify({
84
+ version: 1,
85
+ backend: 'bizar-local',
86
+ projectId: 'reindex-test',
87
+ memoryRepo: { mode: 'local-only', path: join(root, '.obsidian') },
88
+ namespaces: { project: 'projects/reindex-test', global: 'global/bizar', user: 'users/tester' },
89
+ lightrag: { enabled: false },
90
+ git: { autoPullOnSessionStart: false, autoCommitOnMemoryWrite: false, autoPushOnSessionEnd: false },
91
+ }));
92
+ return root;
93
+ }
94
+
95
+ let tmpRoot;
96
+
97
+ beforeEach(() => {
98
+ tmpRoot = makeTmp();
99
+ });
100
+
101
+ afterEach(() => {
102
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
103
+ });
104
+
105
+ describe('POST /memory/notes — auto-reindex', () => {
106
+ test('returns 201 and the written note immediately', async () => {
107
+ const result = await withApp(tmpRoot, async (port) => {
108
+ const start = Date.now();
109
+ const r = await httpPost(port, '/api/memory/notes', {
110
+ path: 'sessions/test-post.md',
111
+ frontmatter: {
112
+ memory_id: 'test-mid-001',
113
+ type: 'session_summary',
114
+ project_id: 'reindex-test',
115
+ status: 'active',
116
+ confidence: 'verified',
117
+ created: '2026-07-06T00:00:00Z',
118
+ updated: '2026-07-06T00:00:00Z',
119
+ tags: ['auto-generated'],
120
+ },
121
+ body: '# Test note\n\nContent here.',
122
+ });
123
+ const elapsed = Date.now() - start;
124
+ return { r, elapsed };
125
+ });
126
+
127
+ assert.equal(result.r.status, 201, `expected 201, got ${result.r.status}: ${JSON.stringify(result.r.body)}`);
128
+ assert.equal(result.r.body.relPath, 'sessions/test-post.md');
129
+ // Response must be fast — reindex is fire-and-forget (no lightrag-server in tests).
130
+ assert.ok(result.elapsed < 2000, `expected <2s response, got ${result.elapsed}ms`);
131
+ // Note must be persisted on disk.
132
+ const notePath = join(tmpRoot, '.obsidian', 'sessions', 'test-post.md');
133
+ assert.ok(existsSync(notePath), 'note should be written to disk immediately');
134
+ const content = readFileSync(notePath, 'utf8');
135
+ assert.ok(content.includes('Test note'));
136
+ });
137
+
138
+ test('rejects notes not ending in .md', async () => {
139
+ const result = await withApp(tmpRoot, async (port) =>
140
+ httpPost(port, '/api/memory/notes', {
141
+ path: 'sessions/test.txt',
142
+ frontmatter: {
143
+ memory_id: 'test-mid-002',
144
+ type: 'task_summary',
145
+ project_id: 'reindex-test',
146
+ status: 'active',
147
+ confidence: 'verified',
148
+ created: '2026-07-06T00:00:00Z',
149
+ updated: '2026-07-06T00:00:00Z',
150
+ tags: [],
151
+ },
152
+ body: 'content',
153
+ }),
154
+ );
155
+ assert.equal(result.status, 400);
156
+ });
157
+ });
158
+
159
+ describe('PUT /memory/notes/* — auto-reindex', () => {
160
+ test('returns 200 and the updated note immediately', async () => {
161
+ // Pre-create the note with valid frontmatter.
162
+ mkdirSync(join(tmpRoot, '.obsidian', 'sessions'), { recursive: true });
163
+ writeFileSync(
164
+ join(tmpRoot, '.obsidian', 'sessions', 'test-put.md'),
165
+ '---\nmemory_id: test-mid-put\ntype: session_summary\nproject_id: reindex-test\nstatus: active\nconfidence: verified\ncreated: "2026-07-06T00:00:00Z"\nupdated: "2026-07-06T00:00:00Z"\ntags: []\n---\n\nOriginal content.',
166
+ );
167
+
168
+ const result = await withApp(tmpRoot, async (port) => {
169
+ const start = Date.now();
170
+ const r = await httpPut(port, '/api/memory/notes/sessions/test-put.md', {
171
+ frontmatter: {
172
+ memory_id: 'test-mid-put',
173
+ type: 'session_summary',
174
+ project_id: 'reindex-test',
175
+ status: 'active',
176
+ confidence: 'verified',
177
+ created: '2026-07-06T00:00:00Z',
178
+ updated: '2026-07-06T00:00:00Z',
179
+ tags: ['auto-generated'],
180
+ },
181
+ body: '# Updated note\n\nNew content.',
182
+ });
183
+ const elapsed = Date.now() - start;
184
+ return { r, elapsed };
185
+ });
186
+
187
+ assert.equal(result.r.status, 200, `expected 200, got ${result.r.status}: ${JSON.stringify(result.r.body)}`);
188
+ assert.equal(result.r.body.relPath, 'sessions/test-put.md');
189
+ assert.ok(result.elapsed < 2000, `expected <2s response, got ${result.elapsed}ms`);
190
+ // Note must be updated on disk.
191
+ const notePath = join(tmpRoot, '.obsidian', 'sessions', 'test-put.md');
192
+ const content = readFileSync(notePath, 'utf8');
193
+ assert.ok(content.includes('Updated note'));
194
+ assert.ok(!content.includes('Original content'));
195
+ });
196
+ });
@@ -390,7 +390,7 @@ describe('bizar memory setup CLI', () => {
390
390
  `memoryRepo.remote should match the requested URL — got ${cfg.memoryRepo.remote}`);
391
391
  assert.strictEqual(cfg.gitRemote, remote,
392
392
  `top-level gitRemote should also be set so cmdPush works — got ${cfg.gitRemote}`);
393
- // Vault path lives under ~/.local/share/bizar/memory/<repo-name>
393
+ // Vault path lives under ~/.bizar_memory/<repo-name> (v5.x — was ~/.local/share/bizar/memory)
394
394
  assert.ok(cfg.memoryRepo.path.endsWith(repo), `vault path should end with ${repo}`);
395
395
  });
396
396
 
@@ -2,21 +2,34 @@
2
2
  * tests/memory-default-vault.test.mjs
3
3
  *
4
4
  * Tests for the default vault location, currentVault(), and ensureVaultExists().
5
+ *
6
+ * v5.x — DEFAULT_MEMORY_VAULT relocated from `~/.local/share/bizar/memory` to
7
+ * `~/.bizar_memory` (issue #5). The legacy path is exported as
8
+ * `LEGACY_MEMORY_VAULT` for migration detection.
5
9
  */
6
10
 
7
11
  import { describe, it, beforeEach, afterEach } from 'node:test';
8
12
  import assert from 'node:assert';
9
13
  import { tmpdir, homedir } from 'node:os';
10
14
  import { join } from 'node:path';
11
- import { rmSync, existsSync, readFileSync } from 'node:fs';
15
+ import { rmSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
12
16
 
13
17
  const TEST_STORE = await import('../src/server/memory-store.mjs').then((m) => m);
14
18
 
15
19
  describe('DEFAULT_MEMORY_VAULT', () => {
16
- it('is ~/.local/share/bizar/memory', () => {
17
- const expected = join(homedir(), '.local', 'share', 'bizar', 'memory');
20
+ it('is ~/.bizar_memory', () => {
21
+ const expected = join(homedir(), '.bizar_memory');
18
22
  assert.strictEqual(TEST_STORE.DEFAULT_MEMORY_VAULT, expected);
19
23
  });
24
+
25
+ it('LEGACY_MEMORY_VAULT keeps the previous path for migration detection', () => {
26
+ const expected = join(homedir(), '.local', 'share', 'bizar', 'memory');
27
+ assert.strictEqual(TEST_STORE.LEGACY_MEMORY_VAULT, expected);
28
+ });
29
+
30
+ it('default vault differs from legacy vault (relocation confirmed)', () => {
31
+ assert.notStrictEqual(TEST_STORE.DEFAULT_MEMORY_VAULT, TEST_STORE.LEGACY_MEMORY_VAULT);
32
+ });
20
33
  });
21
34
 
22
35
  describe('DEFAULT_GIT_REMOTE', () => {
@@ -96,3 +109,77 @@ describe('ensureVaultExists', () => {
96
109
  assert.match(content, /bizar@localhost/);
97
110
  });
98
111
  });
112
+
113
+ describe('ensureVaultExists — legacy path migration notice (v5.x)', () => {
114
+ let fakeHome;
115
+ let legacyPath;
116
+ let newPath;
117
+ let origHome;
118
+ let origVault;
119
+ let origWarn;
120
+ const captured = [];
121
+ const fakeConsole = { ...console, warn: (...args) => captured.push(args.join(' ')), log: () => {} };
122
+
123
+ beforeEach(() => {
124
+ origHome = process.env.HOME;
125
+ origVault = process.env.BIZAR_MEMORY_VAULT;
126
+ origWarn = console.warn;
127
+ console.warn = fakeConsole.warn;
128
+
129
+ // Build a synthetic home with both the legacy path (with content) and
130
+ // a new default path. We can't override `homedir()` in pure JS so we
131
+ // re-import the module via a fresh dynamic import after setting HOME.
132
+ fakeHome = join(tmpdir(), `bizar-memlegacy-${Date.now()}-${Math.random().toString(36).slice(2)}`);
133
+ process.env.HOME = fakeHome;
134
+ delete process.env.BIZAR_MEMORY_VAULT;
135
+ legacyPath = join(fakeHome, '.local', 'share', 'bizar', 'memory');
136
+ newPath = join(fakeHome, '.bizar_memory');
137
+ mkdirSync(legacyPath, { recursive: true });
138
+ writeFileSync(join(legacyPath, 'marker.md'), '# legacy note');
139
+ });
140
+
141
+ afterEach(() => {
142
+ console.warn = origWarn;
143
+ if (origHome === undefined) delete process.env.HOME;
144
+ else process.env.HOME = origHome;
145
+ if (origVault === undefined) delete process.env.BIZAR_MEMORY_VAULT;
146
+ else process.env.BIZAR_MEMORY_VAULT = origVault;
147
+ if (fakeHome && existsSync(fakeHome)) rmSync(fakeHome, { recursive: true, force: true });
148
+ });
149
+
150
+ it('logs a one-time migration notice when legacy vault exists and new default does not', async () => {
151
+ // Re-import the module after env change so HOME is picked up.
152
+ const fresh = await import(`../src/server/memory-store.mjs?v=${Date.now()}`);
153
+ captured.length = 0;
154
+ const result = fresh.ensureVaultExists();
155
+ assert.strictEqual(result, newPath, 'returns new default path');
156
+ assert.strictEqual(existsSync(newPath), true, 'new vault was created');
157
+ // The legacy path was NOT touched.
158
+ assert.strictEqual(existsSync(legacyPath), true, 'legacy path still exists');
159
+ assert.strictEqual(existsSync(join(legacyPath, 'marker.md')), true, 'legacy contents preserved');
160
+ // The notice mentions the legacy path + new path + the manual mv command.
161
+ const all = captured.join('\n');
162
+ assert.match(all, /legacy memory vault/);
163
+ assert.match(all, new RegExp(legacyPath.replace(/[/.]/g, '\\$&')));
164
+ assert.match(all, new RegExp(newPath.replace(/[/.]/g, '\\$&')));
165
+ });
166
+
167
+ it('does not log the notice when BIZAR_MEMORY_VAULT is set (explicit override)', async () => {
168
+ process.env.BIZAR_MEMORY_VAULT = join(fakeHome, 'explicit-vault');
169
+ const fresh = await import(`../src/server/memory-store.mjs?v=${Date.now()}-${Math.random()}`);
170
+ captured.length = 0;
171
+ const result = fresh.ensureVaultExists();
172
+ assert.strictEqual(result, process.env.BIZAR_MEMORY_VAULT);
173
+ assert.strictEqual(captured.length, 0, 'no notice when BIZAR_MEMORY_VAULT is explicit');
174
+ });
175
+
176
+ it('does not log the notice when new default already exists (idempotent)', async () => {
177
+ // Pre-create the new vault so the migration notice is skipped.
178
+ mkdirSync(newPath, { recursive: true });
179
+ const fresh = await import(`../src/server/memory-store.mjs?v=${Date.now()}-${Math.random()}-1`);
180
+ captured.length = 0;
181
+ fresh.ensureVaultExists();
182
+ // Even with legacy present, the new vault already exists → no notice.
183
+ assert.strictEqual(captured.length, 0, 'no notice when new default already exists');
184
+ });
185
+ });
@@ -19,6 +19,8 @@ const {
19
19
  stats,
20
20
  recordQuery,
21
21
  rebuildGraph,
22
+ detectAvailableLLM,
23
+ reindexSingleNote,
22
24
  } = await import('../src/server/memory-lightrag.mjs');
23
25
 
24
26
  let tmpRoot;
@@ -159,4 +161,133 @@ describe('rebuildGraph', () => {
159
161
  assert.equal(typeof result.ok, 'boolean');
160
162
  // workingDir should have been wiped and re-created (or attempted).
161
163
  });
164
+ });
165
+
166
+ describe('detectAvailableLLM', () => {
167
+ const ORIG_OPENAI = process.env.OPENAI_API_KEY;
168
+ const ORIG_ANTHROPIC = process.env.ANTHROPIC_API_KEY;
169
+ const ORIG_MINIMAX = process.env.MINIMAX_API_KEY;
170
+
171
+ afterEach(() => {
172
+ if (ORIG_OPENAI === undefined) delete process.env.OPENAI_API_KEY;
173
+ else process.env.OPENAI_API_KEY = ORIG_OPENAI;
174
+ if (ORIG_ANTHROPIC === undefined) delete process.env.ANTHROPIC_API_KEY;
175
+ else process.env.ANTHROPIC_API_KEY = ORIG_ANTHROPIC;
176
+ if (ORIG_MINIMAX === undefined) delete process.env.MINIMAX_API_KEY;
177
+ else process.env.MINIMAX_API_KEY = ORIG_MINIMAX;
178
+ });
179
+
180
+ test('returns null when llmBinding is ollama, no API keys set, and ollama is unreachable', async () => {
181
+ delete process.env.OPENAI_API_KEY;
182
+ delete process.env.ANTHROPIC_API_KEY;
183
+ delete process.env.MINIMAX_API_KEY;
184
+ const cfg = resolveLightRAGConfig(tmpRoot);
185
+ cfg.llmBinding = 'ollama';
186
+ cfg.llmBindingHost = 'http://localhost:59999'; // nothing listening
187
+ const result = await detectAvailableLLM(cfg);
188
+ // When ollama is the binding but unreachable, and no API keys are set, returns null.
189
+ assert.equal(result, null);
190
+ });
191
+
192
+ test('returns openai-available when llmBinding is ollama but OPENAI_API_KEY is set (ollama fallback)', async () => {
193
+ delete process.env.ANTHROPIC_API_KEY;
194
+ delete process.env.MINIMAX_API_KEY;
195
+ process.env.OPENAI_API_KEY = 'sk-test-key';
196
+ const cfg = resolveLightRAGConfig(tmpRoot);
197
+ cfg.llmBinding = 'ollama';
198
+ cfg.llmBindingHost = 'http://localhost:59999'; // unreachable
199
+ const result = await detectAvailableLLM(cfg);
200
+ assert.ok(result);
201
+ assert.equal(result.provider, 'openai');
202
+ assert.equal(result.available, true);
203
+ });
204
+
205
+ test('returns anthropic-available when llmBinding is anthropic and ANTHROPIC_API_KEY is set', async () => {
206
+ delete process.env.OPENAI_API_KEY;
207
+ delete process.env.MINIMAX_API_KEY;
208
+ process.env.ANTHROPIC_API_KEY = 'sk-ant-test';
209
+ const cfg = resolveLightRAGConfig(tmpRoot);
210
+ cfg.llmBinding = 'anthropic';
211
+ const result = await detectAvailableLLM(cfg);
212
+ assert.ok(result);
213
+ assert.equal(result.provider, 'anthropic');
214
+ assert.equal(result.available, true);
215
+ });
216
+
217
+ test('returns minimax-available when llmBinding is minimax and MINIMAX_API_KEY is set', async () => {
218
+ delete process.env.OPENAI_API_KEY;
219
+ delete process.env.ANTHROPIC_API_KEY;
220
+ process.env.MINIMAX_API_KEY = 'minimax-test-key';
221
+ const cfg = resolveLightRAGConfig(tmpRoot);
222
+ cfg.llmBinding = 'minimax';
223
+ const result = await detectAvailableLLM(cfg);
224
+ assert.ok(result);
225
+ assert.equal(result.provider, 'minimax');
226
+ assert.equal(result.available, true);
227
+ });
228
+
229
+ test('returns null when llmBinding is openai but no API key is set', async () => {
230
+ delete process.env.OPENAI_API_KEY;
231
+ delete process.env.ANTHROPIC_API_KEY;
232
+ delete process.env.MINIMAX_API_KEY;
233
+ const cfg = resolveLightRAGConfig(tmpRoot);
234
+ cfg.llmBinding = 'openai';
235
+ const result = await detectAvailableLLM(cfg);
236
+ assert.equal(result, null);
237
+ });
238
+ });
239
+
240
+ describe('reindexSingleNote', () => {
241
+ test('returns error when memory not initialized', async () => {
242
+ mkdirSync(join(tmpRoot, '.bizar'), { recursive: true });
243
+ // No memory.json
244
+ const r = await reindexSingleNote(tmpRoot, 'notes/test.md');
245
+ assert.equal(r.ok, false);
246
+ assert.ok(r.error.includes('not initialized'));
247
+ });
248
+
249
+ test('returns error when note does not exist', async () => {
250
+ mkdirSync(join(tmpRoot, '.bizar'), { recursive: true });
251
+ writeFileSync(join(tmpRoot, '.bizar', 'memory.json'), JSON.stringify({
252
+ projectId: 'testproj',
253
+ memoryRepo: { mode: 'local-only' },
254
+ }));
255
+ const r = await reindexSingleNote(tmpRoot, 'notes/nonexistent.md');
256
+ assert.equal(r.ok, false);
257
+ assert.ok(r.error.includes('not found'));
258
+ });
259
+
260
+ test('returns error on path traversal attempt', async () => {
261
+ mkdirSync(join(tmpRoot, '.bizar', 'memory', 'projects', 'testproj'), { recursive: true });
262
+ writeFileSync(join(tmpRoot, '.bizar', 'memory.json'), JSON.stringify({
263
+ projectId: 'testproj',
264
+ memoryRepo: {
265
+ mode: 'managed',
266
+ path: join(tmpRoot, '.bizar', 'memory'),
267
+ },
268
+ }));
269
+ // Attempt path traversal
270
+ const r = await reindexSingleNote(tmpRoot, '../../../etc/passwd');
271
+ assert.equal(r.ok, false);
272
+ assert.ok(r.error.includes('traversal'));
273
+ });
274
+
275
+ test('returns structured result when note exists and lightrag disabled', async () => {
276
+ mkdirSync(join(tmpRoot, '.bizar', 'memory', 'projects', 'testproj'), { recursive: true });
277
+ writeFileSync(join(tmpRoot, '.bizar', 'memory.json'), JSON.stringify({
278
+ projectId: 'testproj',
279
+ memoryRepo: {
280
+ mode: 'managed',
281
+ path: join(tmpRoot, '.bizar', 'memory'),
282
+ },
283
+ lightrag: { enabled: false },
284
+ }));
285
+ writeFileSync(
286
+ join(tmpRoot, '.bizar', 'memory', 'projects', 'testproj', 'test.md'),
287
+ '---\ntitle: Test\n---\n\nBody.',
288
+ );
289
+ const r = await reindexSingleNote(tmpRoot, 'test.md');
290
+ assert.equal(r.ok, false);
291
+ assert.ok(r.error.includes('disabled'));
292
+ });
162
293
  });