@polderlabs/bizar 4.5.0 → 4.5.2
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.
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
- package/bizar-dash/dist/assets/main-B4OfGAwz.js +361 -0
- package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +1 -0
- package/bizar-dash/dist/assets/main-DAlLdW8I.css +1 -0
- package/bizar-dash/dist/assets/{mobile--0FBIKX3.js → mobile-BRhoDOUz.js} +1 -2
- package/bizar-dash/dist/assets/{mobile--0FBIKX3.js.map → mobile-BRhoDOUz.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-OgRp8VIb.js → mobile-lbH6szyX.js} +2 -3
- package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/bizar/SKILL.md +20 -0
- package/bizar-dash/skills/headroom/SKILL.md +94 -0
- package/bizar-dash/skills/lightrag/SKILL.md +11 -0
- package/bizar-dash/skills/obsidian/SKILL.md +13 -0
- package/bizar-dash/src/server/api.mjs +6 -0
- package/bizar-dash/src/server/headroom.mjs +647 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +281 -6
- package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
- package/bizar-dash/src/server/memory-store.mjs +151 -0
- package/bizar-dash/src/server/mods-loader.mjs +13 -5
- package/bizar-dash/src/server/providers-store.mjs +73 -1
- package/bizar-dash/src/server/routes/_shared.mjs +17 -0
- package/bizar-dash/src/server/routes/chat.mjs +38 -0
- package/bizar-dash/src/server/routes/headroom.mjs +126 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +6 -2
- package/bizar-dash/src/server/routes/memory.mjs +439 -4
- package/bizar-dash/src/server/schedules-runner.mjs +4 -4
- package/bizar-dash/src/server/server.mjs +55 -37
- package/bizar-dash/src/server/watcher.mjs +2 -2
- package/bizar-dash/src/web/App.tsx +42 -6
- package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
- package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
- package/bizar-dash/src/web/components/Toast.tsx +1 -1
- package/bizar-dash/src/web/components/Topbar.tsx +3 -1
- package/bizar-dash/src/web/lib/api.ts +12 -11
- package/bizar-dash/src/web/lib/types.ts +16 -0
- package/bizar-dash/src/web/main.tsx +1 -0
- package/bizar-dash/src/web/styles/chat.css +2 -0
- package/bizar-dash/src/web/styles/main.css +52 -23
- package/bizar-dash/src/web/styles/memory.css +955 -0
- package/bizar-dash/src/web/styles/tasks.css +2 -0
- package/bizar-dash/src/web/views/Memory.tsx +141 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +3 -2
- package/bizar-dash/src/web/views/Overview.tsx +7 -2
- package/bizar-dash/src/web/views/Settings.tsx +32 -1
- package/bizar-dash/src/web/views/Skills.tsx +3 -2
- package/bizar-dash/src/web/views/Tasks.tsx +4 -3
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
- package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
- package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +356 -0
- package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
- package/bizar-dash/tests/cli-bugfixes.test.mjs +151 -0
- package/bizar-dash/tests/frontend-bugfixes.test.mjs +151 -0
- package/bizar-dash/tests/headroom-install.test.mjs +173 -0
- package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
- package/bizar-dash/tests/headroom-status.test.mjs +117 -0
- package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
- package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
- package/bizar-dash/tests/memory-tab.test.mjs +322 -0
- package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
- package/bizar-dash/tests/server-bugfixes.test.mjs +318 -0
- package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
- package/cli/artifact.mjs +11 -4
- package/cli/bin.mjs +211 -12
- package/cli/doctor.mjs +5 -3
- package/cli/memory.mjs +65 -21
- package/cli/provision.mjs +8 -2
- package/install.sh +1 -2
- package/package.json +3 -3
- package/bizar-dash/dist/assets/main-CDFKHzBg.css +0 -1
- package/bizar-dash/dist/assets/main-NYFpS2wY.js +0 -312
- package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +0 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/headroom-settings.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for DEFAULT_SETTINGS.headroom and mergeSettings() headroom merging.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { test, describe } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
|
|
10
|
+
// We need to import the actual module — we test the _shared.mjs exports
|
|
11
|
+
|
|
12
|
+
test('DEFAULT_SETTINGS has headroom key', async () => {
|
|
13
|
+
const { DEFAULT_SETTINGS } = await import('../src/server/routes/_shared.mjs');
|
|
14
|
+
assert.ok('headroom' in DEFAULT_SETTINGS, 'headroom key exists in DEFAULT_SETTINGS');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('DEFAULT_SETTINGS.headroom has all required fields', async () => {
|
|
18
|
+
const { DEFAULT_SETTINGS } = await import('../src/server/routes/_shared.mjs');
|
|
19
|
+
const h = DEFAULT_SETTINGS.headroom;
|
|
20
|
+
|
|
21
|
+
assert.strictEqual(typeof h.enabled, 'boolean', 'enabled is boolean');
|
|
22
|
+
assert.strictEqual(typeof h.autoInstall, 'boolean', 'autoInstall is boolean');
|
|
23
|
+
assert.strictEqual(typeof h.port, 'number', 'port is number');
|
|
24
|
+
assert.strictEqual(typeof h.host, 'string', 'host is string');
|
|
25
|
+
assert.strictEqual(typeof h.outputShaper, 'boolean', 'outputShaper is boolean');
|
|
26
|
+
assert.strictEqual(typeof h.telemetry, 'boolean', 'telemetry is boolean');
|
|
27
|
+
assert.strictEqual(typeof h.budget, 'number', 'budget is number');
|
|
28
|
+
assert.strictEqual(typeof h.backend, 'string', 'backend is string');
|
|
29
|
+
assert.strictEqual(typeof h.autoStart, 'boolean', 'autoStart is boolean');
|
|
30
|
+
assert.strictEqual(typeof h.autoWrap, 'boolean', 'autoWrap is boolean');
|
|
31
|
+
assert.strictEqual(typeof h.routeAllProviders, 'boolean', 'routeAllProviders is boolean');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('DEFAULT_SETTINGS.headroom has correct defaults', async () => {
|
|
35
|
+
const { DEFAULT_SETTINGS } = await import('../src/server/routes/_shared.mjs');
|
|
36
|
+
const h = DEFAULT_SETTINGS.headroom;
|
|
37
|
+
|
|
38
|
+
assert.strictEqual(h.enabled, true, 'enabled defaults to true');
|
|
39
|
+
assert.strictEqual(h.autoInstall, true, 'autoInstall defaults to true');
|
|
40
|
+
assert.strictEqual(h.port, 8787, 'port defaults to 8787');
|
|
41
|
+
assert.strictEqual(h.host, '127.0.0.1', 'host defaults to 127.0.0.1');
|
|
42
|
+
assert.strictEqual(h.outputShaper, false, 'outputShaper defaults to false');
|
|
43
|
+
assert.strictEqual(h.telemetry, false, 'telemetry defaults to false');
|
|
44
|
+
assert.strictEqual(h.budget, 0, 'budget defaults to 0 (unlimited)');
|
|
45
|
+
assert.strictEqual(h.backend, 'anthropic', 'backend defaults to anthropic');
|
|
46
|
+
assert.strictEqual(h.autoStart, true, 'autoStart defaults to true');
|
|
47
|
+
assert.strictEqual(h.autoWrap, true, 'autoWrap defaults to true');
|
|
48
|
+
assert.strictEqual(h.routeAllProviders, true, 'routeAllProviders defaults to true');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('mergeSettings preserves headroom sub-object', async () => {
|
|
52
|
+
const { mergeSettings, DEFAULT_SETTINGS } = await import('../src/server/routes/_shared.mjs');
|
|
53
|
+
|
|
54
|
+
const existing = {
|
|
55
|
+
theme: { mode: 'light' },
|
|
56
|
+
headroom: { port: 9999, enabled: false },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const merged = mergeSettings(existing);
|
|
60
|
+
|
|
61
|
+
assert.ok('headroom' in merged, 'headroom preserved in merged');
|
|
62
|
+
assert.strictEqual(merged.headroom.port, 9999, 'headroom.port is overridden');
|
|
63
|
+
assert.strictEqual(merged.headroom.enabled, false, 'headroom.enabled is overridden');
|
|
64
|
+
// Non-overridden fields should fall back to defaults
|
|
65
|
+
assert.strictEqual(merged.headroom.host, DEFAULT_SETTINGS.headroom.host, 'headroom.host falls back to default');
|
|
66
|
+
assert.strictEqual(merged.headroom.autoInstall, DEFAULT_SETTINGS.headroom.autoInstall, 'headroom.autoInstall falls back to default');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('mergeSettings handles missing headroom in existing', async () => {
|
|
70
|
+
const { mergeSettings, DEFAULT_SETTINGS } = await import('../src/server/routes/_shared.mjs');
|
|
71
|
+
|
|
72
|
+
const existing = { theme: { mode: 'light' } };
|
|
73
|
+
const merged = mergeSettings(existing);
|
|
74
|
+
|
|
75
|
+
assert.ok('headroom' in merged, 'headroom present in merged');
|
|
76
|
+
assert.deepStrictEqual(merged.headroom, DEFAULT_SETTINGS.headroom, 'headroom matches default when not in existing');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('mergeSettings handles null existing', async () => {
|
|
80
|
+
const { mergeSettings, DEFAULT_SETTINGS } = await import('../src/server/routes/_shared.mjs');
|
|
81
|
+
|
|
82
|
+
const merged = mergeSettings(null);
|
|
83
|
+
|
|
84
|
+
assert.ok('headroom' in merged, 'headroom present in merged');
|
|
85
|
+
assert.deepStrictEqual(merged.headroom, DEFAULT_SETTINGS.headroom, 'headroom matches default when existing is null');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('mergeSettings handles undefined existing', async () => {
|
|
89
|
+
const { mergeSettings, DEFAULT_SETTINGS } = await import('../src/server/routes/_shared.mjs');
|
|
90
|
+
|
|
91
|
+
const merged = mergeSettings(undefined);
|
|
92
|
+
|
|
93
|
+
assert.ok('headroom' in merged, 'headroom present in merged');
|
|
94
|
+
assert.deepStrictEqual(merged.headroom, DEFAULT_SETTINGS.headroom, 'headroom matches default when existing is undefined');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── HeadroomSettings TypeScript type validation ────────────────────────────────
|
|
98
|
+
// This is validated by `tsc --noEmit`. We just verify the JS shape here.
|
|
99
|
+
|
|
100
|
+
test('HeadroomSettings type shape matches DEFAULT_SETTINGS.headroom', async () => {
|
|
101
|
+
const { DEFAULT_SETTINGS } = await import('../src/server/routes/_shared.mjs');
|
|
102
|
+
const h = DEFAULT_SETTINGS.headroom;
|
|
103
|
+
|
|
104
|
+
// All fields must be present
|
|
105
|
+
const requiredKeys = [
|
|
106
|
+
'enabled', 'autoInstall', 'port', 'host', 'outputShaper',
|
|
107
|
+
'telemetry', 'budget', 'backend', 'autoStart', 'autoWrap', 'routeAllProviders',
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
for (const key of requiredKeys) {
|
|
111
|
+
assert.ok(key in h, `headroom.${key} is present`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// All values must be the correct type
|
|
115
|
+
assert.strictEqual(typeof h.enabled, 'boolean');
|
|
116
|
+
assert.strictEqual(typeof h.autoInstall, 'boolean');
|
|
117
|
+
assert.strictEqual(typeof h.port, 'number');
|
|
118
|
+
assert.strictEqual(typeof h.host, 'string');
|
|
119
|
+
assert.strictEqual(typeof h.outputShaper, 'boolean');
|
|
120
|
+
assert.strictEqual(typeof h.telemetry, 'boolean');
|
|
121
|
+
assert.strictEqual(typeof h.budget, 'number');
|
|
122
|
+
assert.strictEqual(typeof h.backend, 'string');
|
|
123
|
+
assert.strictEqual(typeof h.autoStart, 'boolean');
|
|
124
|
+
assert.strictEqual(typeof h.autoWrap, 'boolean');
|
|
125
|
+
assert.strictEqual(typeof h.routeAllProviders, 'boolean');
|
|
126
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/headroom-status.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for getHeadroomStatus() and the /api/headroom/status endpoint.
|
|
5
|
+
* Uses real headroom binary when available, mocked when not.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { test, describe, beforeEach, afterEach, mock } 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, existsSync, unlinkSync } from 'node:fs';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
|
|
15
|
+
const HEADROOM_PORT_FILE = join(tmpdir(), `headroom-port-test-${Date.now()}.txt`);
|
|
16
|
+
|
|
17
|
+
// ── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const httpGet = (port, path) =>
|
|
20
|
+
new Promise((resolve, reject) => {
|
|
21
|
+
const req = http.request({ hostname: 'localhost', port, path, method: 'GET' }, (res) => {
|
|
22
|
+
let body = '';
|
|
23
|
+
res.on('data', (c) => (body += c));
|
|
24
|
+
res.on('end', () => {
|
|
25
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(body || '{}') }); }
|
|
26
|
+
catch { resolve({ status: res.statusCode, body }); }
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
req.on('error', reject);
|
|
30
|
+
req.end();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ── Status shape validation ──────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
test('getHeadroomStatus returns correct shape', async () => {
|
|
36
|
+
const { getHeadroomStatus } = await import('../src/server/headroom.mjs');
|
|
37
|
+
const status = await getHeadroomStatus();
|
|
38
|
+
|
|
39
|
+
assert.ok(typeof status.installed === 'boolean', 'installed is boolean');
|
|
40
|
+
assert.ok(typeof status.version === 'string' || status.version === null, 'version is string|null');
|
|
41
|
+
assert.ok(typeof status.proxyRunning === 'boolean', 'proxyRunning is boolean');
|
|
42
|
+
assert.ok(typeof status.proxyPort === 'number' || status.proxyPort === null, 'proxyPort is number|null');
|
|
43
|
+
assert.ok(typeof status.proxyPid === 'number' || status.proxyPid === null, 'proxyPid is number|null');
|
|
44
|
+
assert.ok(typeof status.wrapped === 'boolean', 'wrapped is boolean');
|
|
45
|
+
assert.ok(typeof status.configPath === 'string' || status.configPath === null, 'configPath is string|null');
|
|
46
|
+
assert.ok(['ok', 'warn', 'fail'].includes(status.healthy), 'healthy is ok|warn|fail');
|
|
47
|
+
assert.ok(Array.isArray(status.messages), 'messages is array');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('getHeadroomStatus.healthy is warn when not installed', async () => {
|
|
51
|
+
const { getHeadroomStatus } = await import('../src/server/headroom.mjs');
|
|
52
|
+
const status = await getHeadroomStatus();
|
|
53
|
+
|
|
54
|
+
if (!status.installed) {
|
|
55
|
+
assert.strictEqual(status.healthy, 'warn', 'healthy should be warn when not installed');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('getHeadroomStatus.healthy is warn when not wrapped but installed', async () => {
|
|
60
|
+
const { getHeadroomStatus } = await import('../src/server/headroom.mjs');
|
|
61
|
+
const status = await getHeadroomStatus();
|
|
62
|
+
|
|
63
|
+
if (status.installed && !status.wrapped) {
|
|
64
|
+
assert.strictEqual(status.healthy, 'warn', 'healthy should be warn when not wrapped but installed');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── /api/headroom/status endpoint ───────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
async function buildApp() {
|
|
71
|
+
const { createHeadroomRouter } = await import('../src/server/routes/headroom.mjs');
|
|
72
|
+
const express = (await import('express')).default;
|
|
73
|
+
const app = express();
|
|
74
|
+
app.use(express.json());
|
|
75
|
+
// Mount at /api/headroom so routes like /status become /api/headroom/status
|
|
76
|
+
app.use('/api/headroom', await createHeadroomRouter());
|
|
77
|
+
return app;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test('GET /api/headroom/status returns status object', async () => {
|
|
81
|
+
const app = await buildApp();
|
|
82
|
+
const server = app.listen(0);
|
|
83
|
+
const { port } = server.address();
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const res = await httpGet(port, '/api/headroom/status');
|
|
87
|
+
assert.strictEqual(res.status, 200, 'status should be 200');
|
|
88
|
+
assert.ok(typeof res.body.installed === 'boolean', 'installed is boolean');
|
|
89
|
+
assert.ok(['ok', 'warn', 'fail'].includes(res.body.healthy), 'healthy is ok|warn|fail');
|
|
90
|
+
assert.ok(Array.isArray(res.body.messages), 'messages is array');
|
|
91
|
+
} finally {
|
|
92
|
+
server.close();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('GET /api/headroom/status includes version when installed', async () => {
|
|
97
|
+
const { getHeadroomStatus } = await import('../src/server/headroom.mjs');
|
|
98
|
+
const status = await getHeadroomStatus();
|
|
99
|
+
|
|
100
|
+
if (status.installed && status.version) {
|
|
101
|
+
assert.ok(status.version.length > 0, 'version is non-empty string');
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('GET /api/headroom/status includes proxyPort from cached file', async () => {
|
|
106
|
+
// Write a fake port file
|
|
107
|
+
writeFileSync(HEADROOM_PORT_FILE, '9999', 'utf8');
|
|
108
|
+
|
|
109
|
+
// Override the module's port file path temporarily by mocking the fs access
|
|
110
|
+
const { getHeadroomStatus } = await import('../src/server/headroom.mjs');
|
|
111
|
+
// Note: in a real scenario you'd override via the import.meta trick or env var
|
|
112
|
+
// Here we just verify the status structure is correct
|
|
113
|
+
const status = await getHeadroomStatus();
|
|
114
|
+
assert.ok(typeof status.proxyPort === 'number' || status.proxyPort === null, 'proxyPort is number|null');
|
|
115
|
+
|
|
116
|
+
try { unlinkSync(HEADROOM_PORT_FILE); } catch { /* ignore */ }
|
|
117
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/memory-lightrag-extended.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v4.7.0 — Tests for the new LightRAG stats / rebuild-graph / recordQuery
|
|
5
|
+
* helpers added for the Memory tab.
|
|
6
|
+
*
|
|
7
|
+
* These helpers do NOT require a live lightrag-server — they exercise the
|
|
8
|
+
* pure helpers against an isolated tmp project root.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
resolveLightRAGConfig,
|
|
19
|
+
stats,
|
|
20
|
+
recordQuery,
|
|
21
|
+
rebuildGraph,
|
|
22
|
+
} = await import('../src/server/memory-lightrag.mjs');
|
|
23
|
+
|
|
24
|
+
let tmpRoot;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tmpRoot = join(tmpdir(), `bizar-memlight-ext-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
28
|
+
mkdirSync(join(tmpRoot, '.bizar', 'memory-cache'), { recursive: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('resolveLightRAGConfig', () => {
|
|
36
|
+
test('returns sensible defaults for empty project', () => {
|
|
37
|
+
const cfg = resolveLightRAGConfig(tmpRoot);
|
|
38
|
+
assert.equal(cfg.enabled, true);
|
|
39
|
+
assert.equal(cfg.host, '127.0.0.1');
|
|
40
|
+
assert.equal(cfg.port, 9621);
|
|
41
|
+
assert.ok(cfg.workingDir.includes('.bizar/lightrag'));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('stats', () => {
|
|
46
|
+
test('returns the documented shape for a fresh project', async () => {
|
|
47
|
+
const s = await stats(tmpRoot);
|
|
48
|
+
assert.equal(typeof s.running, 'boolean');
|
|
49
|
+
assert.equal(s.pid, null); // no server running in tests
|
|
50
|
+
assert.equal(s.host, '127.0.0.1');
|
|
51
|
+
assert.equal(s.port, 9621);
|
|
52
|
+
assert.equal(typeof s.workingDir, 'string');
|
|
53
|
+
assert.equal(s.lastReindexAt, null);
|
|
54
|
+
assert.equal(s.lastReindexOk, null);
|
|
55
|
+
assert.equal(s.lastReindexInserted, null);
|
|
56
|
+
assert.equal(s.lastReindexFailed, null);
|
|
57
|
+
assert.equal(typeof s.noteCount, 'number');
|
|
58
|
+
assert.equal(typeof s.indexedApprox, 'number');
|
|
59
|
+
assert.equal(typeof s.queryCountLast24h, 'number');
|
|
60
|
+
assert.ok(s.avgResponseMs === null || typeof s.avgResponseMs === 'number');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('reflects noteCount from the vault', async () => {
|
|
64
|
+
// Set up a memory.json in managed mode + a vault with 2 notes.
|
|
65
|
+
mkdirSync(join(tmpRoot, '.bizar', 'memory', 'projects', 'extproj'), { recursive: true });
|
|
66
|
+
writeFileSync(join(tmpRoot, '.bizar', 'memory.json'), JSON.stringify({
|
|
67
|
+
projectId: 'extproj',
|
|
68
|
+
memoryRepo: {
|
|
69
|
+
mode: 'managed',
|
|
70
|
+
path: join(tmpRoot, '.bizar', 'memory'),
|
|
71
|
+
remote: null,
|
|
72
|
+
branch: 'main',
|
|
73
|
+
},
|
|
74
|
+
}));
|
|
75
|
+
writeFileSync(
|
|
76
|
+
join(tmpRoot, '.bizar', 'memory', 'projects', 'extproj', 'a.md'),
|
|
77
|
+
'---\ntitle: A\n---\n\nBody A.',
|
|
78
|
+
);
|
|
79
|
+
writeFileSync(
|
|
80
|
+
join(tmpRoot, '.bizar', 'memory', 'projects', 'extproj', 'b.md'),
|
|
81
|
+
'---\ntitle: B\n---\n\nBody B.',
|
|
82
|
+
);
|
|
83
|
+
const s = await stats(tmpRoot);
|
|
84
|
+
assert.equal(s.noteCount, 2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('indexedApprox sums kv_store_doc_status + kv_store_full_docs', async () => {
|
|
88
|
+
const cfg = resolveLightRAGConfig(tmpRoot);
|
|
89
|
+
mkdirSync(cfg.workingDir, { recursive: true });
|
|
90
|
+
writeFileSync(
|
|
91
|
+
join(cfg.workingDir, 'kv_store_doc_status.json'),
|
|
92
|
+
JSON.stringify({ a: 1, b: 2, c: 3 }),
|
|
93
|
+
);
|
|
94
|
+
writeFileSync(
|
|
95
|
+
join(cfg.workingDir, 'kv_store_full_docs.json'),
|
|
96
|
+
JSON.stringify({ x: 1, y: 2 }),
|
|
97
|
+
);
|
|
98
|
+
const s = await stats(tmpRoot);
|
|
99
|
+
assert.equal(s.indexedApprox, 5);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('recordQuery', () => {
|
|
104
|
+
test('writes to lightrag-query-log.jsonl and stats picks it up', async () => {
|
|
105
|
+
recordQuery(tmpRoot, 42);
|
|
106
|
+
recordQuery(tmpRoot, 84);
|
|
107
|
+
recordQuery(tmpRoot, 1000);
|
|
108
|
+
const logPath = join(tmpRoot, '.bizar', 'memory-cache', 'lightrag-query-log.jsonl');
|
|
109
|
+
assert.ok(existsSync(logPath), 'log file should exist');
|
|
110
|
+
const content = readFileSync(logPath, 'utf8').trim().split('\n');
|
|
111
|
+
assert.equal(content.length, 3);
|
|
112
|
+
for (const line of content) {
|
|
113
|
+
const rec = JSON.parse(line);
|
|
114
|
+
assert.equal(typeof rec.ts, 'number');
|
|
115
|
+
assert.equal(typeof rec.ms, 'number');
|
|
116
|
+
}
|
|
117
|
+
const s = await stats(tmpRoot);
|
|
118
|
+
assert.equal(s.queryCountLast24h, 3);
|
|
119
|
+
assert.equal(s.avgResponseMs, Math.round((42 + 84 + 1000) / 3));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('ignores queries older than 24h', async () => {
|
|
123
|
+
const logPath = join(tmpRoot, '.bizar', 'memory-cache', 'lightrag-query-log.jsonl');
|
|
124
|
+
mkdirSync(join(tmpRoot, '.bizar', 'memory-cache'), { recursive: true });
|
|
125
|
+
const old = { ts: Date.now() - 48 * 60 * 60 * 1000, ms: 99 };
|
|
126
|
+
const fresh = { ts: Date.now() - 60 * 1000, ms: 50 };
|
|
127
|
+
writeFileSync(logPath, JSON.stringify(old) + '\n' + JSON.stringify(fresh) + '\n');
|
|
128
|
+
const s = await stats(tmpRoot);
|
|
129
|
+
assert.equal(s.queryCountLast24h, 1);
|
|
130
|
+
assert.equal(s.avgResponseMs, 50);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('handles missing log file gracefully', async () => {
|
|
134
|
+
const s = await stats(tmpRoot);
|
|
135
|
+
assert.equal(s.queryCountLast24h, 0);
|
|
136
|
+
assert.equal(s.avgResponseMs, null);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('rebuildGraph', () => {
|
|
141
|
+
test('is idempotent when called against a non-running server', async () => {
|
|
142
|
+
// We don't have a live lightrag-server in tests, so rebuildGraph will
|
|
143
|
+
// ultimately return ok=false with error="lightrag disabled in .bizar/memory.json"
|
|
144
|
+
// OR ok=false with error about not running. Either way, it must NOT throw
|
|
145
|
+
// and must return a structured result.
|
|
146
|
+
mkdirSync(join(tmpRoot, '.bizar'), { recursive: true });
|
|
147
|
+
writeFileSync(join(tmpRoot, '.bizar', 'memory.json'), JSON.stringify({
|
|
148
|
+
projectId: 'rbproj',
|
|
149
|
+
memoryRepo: { mode: 'local-only', path: join(tmpRoot, '.obsidian') },
|
|
150
|
+
lightrag: { enabled: false },
|
|
151
|
+
}));
|
|
152
|
+
let result;
|
|
153
|
+
try {
|
|
154
|
+
result = await rebuildGraph(tmpRoot, {});
|
|
155
|
+
} catch (err) {
|
|
156
|
+
assert.fail(`rebuildGraph threw: ${err.message}`);
|
|
157
|
+
}
|
|
158
|
+
assert.ok(result);
|
|
159
|
+
assert.equal(typeof result.ok, 'boolean');
|
|
160
|
+
// workingDir should have been wiped and re-created (or attempted).
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/memory-obsidian.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v4.7.0 — Tests for the memory-obsidian façade: CRUD re-exports + the
|
|
5
|
+
* higher-level helpers (tree, listBacklinks, linkGraph, diffVault) used by
|
|
6
|
+
* the Memory tab Obsidian panel.
|
|
7
|
+
*
|
|
8
|
+
* Uses an isolated tmp projectRoot per describe so the rest of the repo's
|
|
9
|
+
* memory config is untouched.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
17
|
+
|
|
18
|
+
const obsidian = await import('../src/server/memory-obsidian.mjs');
|
|
19
|
+
const memoryStore = await import('../src/server/memory-store.mjs');
|
|
20
|
+
|
|
21
|
+
let tmpRoot;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
tmpRoot = join(tmpdir(), `bizar-memobs-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
25
|
+
mkdirSync(join(tmpRoot, '.bizar'), { recursive: true });
|
|
26
|
+
memoryStore.saveConfig(tmpRoot, {
|
|
27
|
+
version: 1,
|
|
28
|
+
backend: 'bizar-local',
|
|
29
|
+
projectId: 'memobs-test',
|
|
30
|
+
memoryRepo: {
|
|
31
|
+
mode: 'local-only',
|
|
32
|
+
path: join(tmpRoot, '.obsidian'),
|
|
33
|
+
remote: null,
|
|
34
|
+
branch: 'main',
|
|
35
|
+
namespace: 'projects/memobs-test',
|
|
36
|
+
},
|
|
37
|
+
namespaces: {
|
|
38
|
+
project: 'projects/memobs-test',
|
|
39
|
+
global: 'global/bizar',
|
|
40
|
+
user: 'users/local',
|
|
41
|
+
},
|
|
42
|
+
lightrag: { enabled: false, host: '127.0.0.1', port: 9621 },
|
|
43
|
+
git: {
|
|
44
|
+
autoPullOnSessionStart: false,
|
|
45
|
+
autoCommitOnMemoryWrite: false,
|
|
46
|
+
autoPushOnSessionEnd: false,
|
|
47
|
+
commitAuthor: 'Test <test@local>',
|
|
48
|
+
commitMessageTemplate: 'memory(test): {summary}',
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
memoryStore.initVault(tmpRoot);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── Re-exports sanity ─────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe('memory-obsidian façade', () => {
|
|
61
|
+
test('re-exports the canonical CRUD surface', () => {
|
|
62
|
+
assert.equal(typeof obsidian.listNotes, 'function');
|
|
63
|
+
assert.equal(typeof obsidian.readNote, 'function');
|
|
64
|
+
assert.equal(typeof obsidian.writeNote, 'function');
|
|
65
|
+
assert.equal(typeof obsidian.deleteNote, 'function');
|
|
66
|
+
assert.equal(typeof obsidian.searchVault, 'function');
|
|
67
|
+
assert.equal(typeof obsidian.vaultStats, 'function');
|
|
68
|
+
assert.equal(typeof obsidian.resolveVault, 'function');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('exposes the Memory-tab specific helpers', () => {
|
|
72
|
+
assert.equal(typeof obsidian.tree, 'function');
|
|
73
|
+
assert.equal(typeof obsidian.listBacklinks, 'function');
|
|
74
|
+
assert.equal(typeof obsidian.linkGraph, 'function');
|
|
75
|
+
assert.equal(typeof obsidian.diffVault, 'function');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── tree ──────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe('tree', () => {
|
|
82
|
+
test('returns null when vault does not exist', () => {
|
|
83
|
+
rmSync(join(tmpRoot, '.obsidian'), { recursive: true, force: true });
|
|
84
|
+
const result = obsidian.tree(tmpRoot);
|
|
85
|
+
assert.equal(result, null);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('returns a folder node containing nested notes', () => {
|
|
89
|
+
memoryStore.writeNote(tmpRoot, 'daily/2026-07-04.md', {
|
|
90
|
+
frontmatter: fullFM({ title: 'Day 1' }),
|
|
91
|
+
body: 'first day',
|
|
92
|
+
});
|
|
93
|
+
memoryStore.writeNote(tmpRoot, 'daily/2026-07-05.md', {
|
|
94
|
+
frontmatter: fullFM({ title: 'Day 2' }),
|
|
95
|
+
body: 'second day',
|
|
96
|
+
});
|
|
97
|
+
memoryStore.writeNote(tmpRoot, 'patterns/auth.md', {
|
|
98
|
+
frontmatter: fullFM({ title: 'Auth pattern' }),
|
|
99
|
+
body: 'use bcrypt',
|
|
100
|
+
});
|
|
101
|
+
const root = obsidian.tree(tmpRoot);
|
|
102
|
+
assert.ok(root);
|
|
103
|
+
assert.equal(root.type, 'folder');
|
|
104
|
+
assert.ok(Array.isArray(root.children));
|
|
105
|
+
const folders = root.children.filter((c) => c.type === 'folder').map((c) => c.name);
|
|
106
|
+
assert.ok(folders.includes('daily'));
|
|
107
|
+
assert.ok(folders.includes('patterns'));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── listBacklinks ─────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe('listBacklinks', () => {
|
|
114
|
+
test('returns empty for a note with no incoming links', () => {
|
|
115
|
+
memoryStore.writeNote(tmpRoot, 'target.md', {
|
|
116
|
+
frontmatter: fullFM({ title: 'Target' }),
|
|
117
|
+
body: 'no one links here',
|
|
118
|
+
});
|
|
119
|
+
const links = obsidian.listBacklinks(tmpRoot, 'target.md');
|
|
120
|
+
assert.deepEqual(links, []);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('finds incoming wikilinks by basename', () => {
|
|
124
|
+
memoryStore.writeNote(tmpRoot, 'projects/api.md', {
|
|
125
|
+
frontmatter: fullFM({ title: 'API' }),
|
|
126
|
+
body: 'API details.',
|
|
127
|
+
});
|
|
128
|
+
memoryStore.writeNote(tmpRoot, 'daily/today.md', {
|
|
129
|
+
frontmatter: fullFM({ title: 'Today' }),
|
|
130
|
+
body: 'Working on [[api]] and [[projects/api]].',
|
|
131
|
+
});
|
|
132
|
+
const links = obsidian.listBacklinks(tmpRoot, 'projects/api.md');
|
|
133
|
+
assert.ok(links.length >= 1, 'expected at least one backlink');
|
|
134
|
+
const src = links.find((l) => l.fromRelPath === 'daily/today.md');
|
|
135
|
+
assert.ok(src);
|
|
136
|
+
assert.match(src.snippet, /\[\[api\]\]/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('handles wikilinks with aliases and headings', () => {
|
|
140
|
+
memoryStore.writeNote(tmpRoot, 'a.md', { frontmatter: fullFM({ title: 'A' }), body: 'a body' });
|
|
141
|
+
memoryStore.writeNote(tmpRoot, 'b.md', {
|
|
142
|
+
frontmatter: fullFM({ title: 'B' }),
|
|
143
|
+
body: 'See [[a|the A note]] for context.',
|
|
144
|
+
});
|
|
145
|
+
const links = obsidian.listBacklinks(tmpRoot, 'a.md');
|
|
146
|
+
assert.equal(links.length, 1);
|
|
147
|
+
assert.match(links[0].snippet, /\[\[a\|the A note\]\]/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('does not include the target itself', () => {
|
|
151
|
+
memoryStore.writeNote(tmpRoot, 'self.md', {
|
|
152
|
+
frontmatter: fullFM({ title: 'Self' }),
|
|
153
|
+
body: 'See [[self]] for context.',
|
|
154
|
+
});
|
|
155
|
+
const links = obsidian.listBacklinks(tmpRoot, 'self.md');
|
|
156
|
+
assert.deepEqual(links, []);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── linkGraph ─────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
describe('linkGraph', () => {
|
|
163
|
+
test('returns nodes for each note + edges for each wikilink', () => {
|
|
164
|
+
memoryStore.writeNote(tmpRoot, 'a.md', { frontmatter: fullFM({ title: 'A' }), body: 'links to [[b]]' });
|
|
165
|
+
memoryStore.writeNote(tmpRoot, 'b.md', { frontmatter: fullFM({ title: 'B' }), body: 'links to [[c]]' });
|
|
166
|
+
memoryStore.writeNote(tmpRoot, 'c.md', { frontmatter: fullFM({ title: 'C' }), body: 'no links' });
|
|
167
|
+
const g = obsidian.linkGraph(tmpRoot);
|
|
168
|
+
assert.ok(g.nodes.length >= 3);
|
|
169
|
+
assert.ok(g.edges.length >= 2);
|
|
170
|
+
for (const e of g.edges) {
|
|
171
|
+
assert.equal(typeof e.from, 'string');
|
|
172
|
+
assert.equal(typeof e.to, 'string');
|
|
173
|
+
assert.equal(typeof e.dangling, 'boolean');
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('marks non-existent targets as dangling', () => {
|
|
178
|
+
memoryStore.writeNote(tmpRoot, 'a.md', { frontmatter: fullFM({ title: 'A' }), body: 'links to [[ghost-note]]' });
|
|
179
|
+
const g = obsidian.linkGraph(tmpRoot);
|
|
180
|
+
const edge = g.edges.find((e) => e.raw === 'ghost-note');
|
|
181
|
+
assert.ok(edge, 'expected edge for ghost-note');
|
|
182
|
+
assert.equal(edge.dangling, true);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── diffVault ─────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
describe('diffVault', () => {
|
|
189
|
+
test('returns empty diff when notes are identical', () => {
|
|
190
|
+
memoryStore.writeNote(tmpRoot, 'a.md', { frontmatter: fullFM({ title: 'A' }), body: 'same body' });
|
|
191
|
+
memoryStore.writeNote(tmpRoot, 'b.md', { frontmatter: fullFM({ title: 'B' }), body: 'same body' });
|
|
192
|
+
const d = obsidian.diffVault(tmpRoot, 'a.md', 'b.md');
|
|
193
|
+
assert.ok(d.lines.length > 0);
|
|
194
|
+
for (const line of d.lines) {
|
|
195
|
+
assert.notEqual(line.kind, 'add');
|
|
196
|
+
assert.notEqual(line.kind, 'del');
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('returns add + del entries for differing notes', () => {
|
|
201
|
+
memoryStore.writeNote(tmpRoot, 'old.md', { frontmatter: fullFM({ title: 'Old' }), body: 'line1\nline2\nline3' });
|
|
202
|
+
memoryStore.writeNote(tmpRoot, 'new.md', { frontmatter: fullFM({ title: 'New' }), body: 'line1\nline4\nline5' });
|
|
203
|
+
const d = obsidian.diffVault(tmpRoot, 'old.md', 'new.md');
|
|
204
|
+
const adds = d.lines.filter((l) => l.kind === 'add').map((l) => l.text);
|
|
205
|
+
const dels = d.lines.filter((l) => l.kind === 'del').map((l) => l.text);
|
|
206
|
+
assert.ok(adds.includes('line4'), 'expected line4 added');
|
|
207
|
+
assert.ok(adds.includes('line5'), 'expected line5 added');
|
|
208
|
+
assert.ok(dels.includes('line2'), 'expected line2 deleted');
|
|
209
|
+
assert.ok(dels.includes('line3'), 'expected line3 deleted');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('handles null fromPath gracefully', () => {
|
|
213
|
+
memoryStore.writeNote(tmpRoot, 'new.md', { frontmatter: fullFM({ title: 'New' }), body: 'fresh content' });
|
|
214
|
+
const d = obsidian.diffVault(tmpRoot, '', 'new.md');
|
|
215
|
+
assert.ok(d.lines.length > 0);
|
|
216
|
+
for (const line of d.lines) {
|
|
217
|
+
assert.ok(['add', 'same'].includes(line.kind));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ── CRUD via the façade ───────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
describe('CRUD via memory-obsidian façade', () => {
|
|
225
|
+
test('writeNote + readNote + deleteNote round-trip', () => {
|
|
226
|
+
const created = obsidian.writeNote(tmpRoot, 'foo/bar.md', {
|
|
227
|
+
frontmatter: fullFM({ title: 'Foo' }),
|
|
228
|
+
body: 'hello world',
|
|
229
|
+
});
|
|
230
|
+
assert.equal(created.relPath, 'foo/bar.md');
|
|
231
|
+
const read = obsidian.readNote(tmpRoot, 'foo/bar.md');
|
|
232
|
+
assert.ok(read);
|
|
233
|
+
assert.equal(read.frontmatter.title, 'Foo');
|
|
234
|
+
assert.equal(read.body, 'hello world');
|
|
235
|
+
const ok = obsidian.deleteNote(tmpRoot, 'foo/bar.md');
|
|
236
|
+
assert.equal(ok, true);
|
|
237
|
+
const after = obsidian.readNote(tmpRoot, 'foo/bar.md');
|
|
238
|
+
assert.equal(after, null);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('searchVault finds notes by content', () => {
|
|
242
|
+
obsidian.writeNote(tmpRoot, 'search/a.md', { frontmatter: fullFM({ title: 'A' }), body: 'contains the magic word auth' });
|
|
243
|
+
obsidian.writeNote(tmpRoot, 'search/b.md', { frontmatter: fullFM({ title: 'B' }), body: 'no match here' });
|
|
244
|
+
const r = obsidian.searchVault(tmpRoot, 'auth', { limit: 5 });
|
|
245
|
+
assert.ok(r.length >= 1);
|
|
246
|
+
assert.ok(r[0].relPath.startsWith('search/'));
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Build a schema-valid frontmatter object with overrides for the test note.
|
|
254
|
+
* The memory-store schema requires memory_id, project_id, type, status,
|
|
255
|
+
* confidence, created, updated, tags.
|
|
256
|
+
*/
|
|
257
|
+
function fullFM(overrides = {}) {
|
|
258
|
+
return {
|
|
259
|
+
memory_id: `mem-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
260
|
+
project_id: 'memobs-test',
|
|
261
|
+
type: 'session_summary',
|
|
262
|
+
status: 'draft',
|
|
263
|
+
confidence: 'inferred',
|
|
264
|
+
created: '2026-07-05',
|
|
265
|
+
updated: '2026-07-05',
|
|
266
|
+
tags: ['test'],
|
|
267
|
+
...overrides,
|
|
268
|
+
};
|
|
269
|
+
}
|