@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.
- package/README.md +11 -14
- package/bizar-dash/CHANGELOG.md +1 -1
- package/bizar-dash/src/server/api.mjs +2 -2
- package/bizar-dash/src/server/artifacts-store.mjs +4 -4
- package/bizar-dash/src/server/memory-git.mjs +80 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +767 -0
- package/bizar-dash/src/server/memory-store.mjs +47 -0
- package/bizar-dash/src/server/mod-security.mjs +2 -2
- package/bizar-dash/src/server/routes/memory.mjs +174 -15
- package/bizar-dash/src/server/server.mjs +1 -1
- package/bizar-dash/src/server/state.mjs +2 -2
- package/bizar-dash/src/web/views/Config.tsx +461 -1
- package/bizar-dash/tests/memory-cli.test.mjs +542 -0
- package/bizar-dash/tests/memory-config.test.mjs +422 -0
- package/bizar-dash/tests/memory-git.test.mjs +109 -1
- package/bizar-dash/tests/memory-lightrag.test.mjs +153 -0
- package/bizar-dash/tests/memory-protocol-drift.test.mjs +45 -0
- package/cli/banner.mjs +1 -1
- package/cli/bin.mjs +4 -4
- package/cli/bootstrap.mjs +1 -1
- package/cli/copy.mjs +22 -16
- package/cli/doctor.mjs +4 -4
- package/cli/doctor.test.mjs +2 -2
- package/cli/init.mjs +2 -2
- package/cli/install.mjs +21 -16
- package/cli/memory.mjs +710 -31
- package/cli/utils.mjs +6 -3
- package/config/AGENTS.md +7 -7
- package/config/agents/_shared/AGENT_BASELINE.md +59 -61
- package/config/opencode.json +13 -38
- package/config/skills/memory-protocol/SKILL.md +105 -0
- package/config/skills/obsidian/SKILL.md +58 -1
- package/install.sh +11 -1
- package/package.json +2 -2
- package/plugins/bizar/index.ts +7 -0
- package/plugins/bizar/src/commands.ts +42 -1
- package/plugins/bizar/src/tools/open-kb.ts +191 -0
- package/plugins/bizar/tests/commands.test.ts +36 -0
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/memory-lightrag.mjs
|
|
3
|
+
*
|
|
4
|
+
* v4.1.0 — LightRAG orchestrator for the Bizar Memory Service.
|
|
5
|
+
*
|
|
6
|
+
* Owns the local LightRAG server lifecycle (start, stop, health check,
|
|
7
|
+
* query, insert) and bridges it to the markdown vault. Designed so the
|
|
8
|
+
* dashboard, the CLI, and tests can all call the same surface.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle:
|
|
11
|
+
* - isInstalled() — checks `lightrag-server` is on PATH
|
|
12
|
+
* - isRunning() — GET /health
|
|
13
|
+
* - startServer() — spawn detached, poll /health for up to 30s
|
|
14
|
+
* - stopServer() — read PID file, SIGTERM then SIGKILL
|
|
15
|
+
* - ensureRunning() — idempotent wrapper: returns immediately if
|
|
16
|
+
* the server is already healthy
|
|
17
|
+
* - reindexVault() — top-level: resolve vault → list notes →
|
|
18
|
+
* ensure server → insert each → write marker
|
|
19
|
+
* - query() — POST /query on the running server
|
|
20
|
+
*
|
|
21
|
+
* The server is started as a detached child with `stdio: ['ignore',
|
|
22
|
+
* 'pipe', 'pipe']`; stdout + stderr are tee'd to
|
|
23
|
+
* `<workingDir>/lightrag.log`. The PID is written to
|
|
24
|
+
* `<workingDir>/lightrag.pid` for orphan detection + graceful shutdown.
|
|
25
|
+
*
|
|
26
|
+
* IDs are deterministic: a note at `decisions/foo.md` in projectId
|
|
27
|
+
* `bizarharness` gets id `bizar://bizarharness/decisions/foo.md`. Re-running
|
|
28
|
+
* reindexVault is idempotent because the server keys by this id.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
spawn,
|
|
33
|
+
execFileSync,
|
|
34
|
+
} from 'node:child_process';
|
|
35
|
+
import {
|
|
36
|
+
existsSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
writeFileSync,
|
|
39
|
+
mkdirSync,
|
|
40
|
+
openSync,
|
|
41
|
+
writeSync,
|
|
42
|
+
closeSync,
|
|
43
|
+
unlinkSync,
|
|
44
|
+
readdirSync,
|
|
45
|
+
} from 'node:fs';
|
|
46
|
+
import { join, dirname, basename } from 'node:path';
|
|
47
|
+
import { homedir } from 'node:os';
|
|
48
|
+
import http from 'node:http';
|
|
49
|
+
import https from 'node:https';
|
|
50
|
+
|
|
51
|
+
import { atomicWriteJson } from './routes/_shared.mjs';
|
|
52
|
+
import { parseFrontmatter } from './yaml.mjs';
|
|
53
|
+
|
|
54
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function deepMerge(target, source) {
|
|
57
|
+
const out = { ...target };
|
|
58
|
+
if (source === null || source === undefined) return out;
|
|
59
|
+
for (const key of Object.keys(source)) {
|
|
60
|
+
if (
|
|
61
|
+
source[key] !== null &&
|
|
62
|
+
typeof source[key] === 'object' &&
|
|
63
|
+
!Array.isArray(source[key]) &&
|
|
64
|
+
target[key] !== null &&
|
|
65
|
+
typeof target[key] === 'object' &&
|
|
66
|
+
!Array.isArray(target[key])
|
|
67
|
+
) {
|
|
68
|
+
out[key] = deepMerge(target[key], source[key]);
|
|
69
|
+
} else {
|
|
70
|
+
out[key] = source[key];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read the last `n` lines of a text file.
|
|
78
|
+
*/
|
|
79
|
+
function readLogTail(filePath, n = 30) {
|
|
80
|
+
if (!existsSync(filePath)) return [];
|
|
81
|
+
try {
|
|
82
|
+
const content = readFileSync(filePath, 'utf8');
|
|
83
|
+
const lines = content.split('\n');
|
|
84
|
+
return lines.slice(-n);
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const KNOWN_LLM_BINDINGS = new Set(['ollama', 'openai', 'lollms', 'azure_openai', 'bedrock', 'gemini']);
|
|
91
|
+
const KNOWN_EMBEDDING_BINDINGS = new Set(['ollama', 'openai', 'azure_openai', 'bedrock', 'jina', 'gemini', 'voyageai']);
|
|
92
|
+
|
|
93
|
+
export const LIGHTRAG_DEFAULTS = Object.freeze({
|
|
94
|
+
host: '127.0.0.1',
|
|
95
|
+
port: 9621,
|
|
96
|
+
workingDir: null, // resolved at runtime
|
|
97
|
+
timeoutMs: 30_000,
|
|
98
|
+
pollMs: 1_000,
|
|
99
|
+
startupTimeoutMs: 30_000,
|
|
100
|
+
llmBinding: 'ollama', // ollama | openai | lollms | azure_openai | bedrock | gemini
|
|
101
|
+
embeddingBinding: 'ollama', // ollama | openai | azure_openai | bedrock | jina | gemini | voyageai
|
|
102
|
+
llmModel: 'minimax/MiniMax-M3',
|
|
103
|
+
embeddingModel: 'text-embedding-3-small',
|
|
104
|
+
llmBindingHost: null, // for non-ollama bindings
|
|
105
|
+
embeddingBindingHost: null,
|
|
106
|
+
apiKey: 'env', // 'env' means read from env var; any other string is written to file
|
|
107
|
+
apiKeySource: 'env', // 'env' | 'file'
|
|
108
|
+
/** When true, reindex writes a marker file with full stats. */
|
|
109
|
+
writeMarker: true,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Deep-merge a patch into the lightrag block of .bizar/memory.json.
|
|
114
|
+
*
|
|
115
|
+
* Validates:
|
|
116
|
+
* - llmBinding must be in KNOWN_LLM_BINDINGS
|
|
117
|
+
* - embeddingBinding must be in KNOWN_EMBEDDING_BINDINGS
|
|
118
|
+
* - port must be 1–65535
|
|
119
|
+
*
|
|
120
|
+
* Returns { ok, config, error? }. The returned config is the full
|
|
121
|
+
* merged memory.json (not redacted).
|
|
122
|
+
*
|
|
123
|
+
* If apiKey is the literal string '<empty>' the field is set to ''.
|
|
124
|
+
* The apiKey field is never validated here — the UI decides what to send.
|
|
125
|
+
*/
|
|
126
|
+
export function writeLightRAGConfig(projectRoot, patch) {
|
|
127
|
+
const memPath = join(projectRoot, '.bizar', 'memory.json');
|
|
128
|
+
let mem = {};
|
|
129
|
+
if (existsSync(memPath)) {
|
|
130
|
+
try {
|
|
131
|
+
mem = JSON.parse(readFileSync(memPath, 'utf8'));
|
|
132
|
+
} catch {
|
|
133
|
+
return { ok: false, error: 'memory.json is corrupt', config: null };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resolve workingDir in patch before validation
|
|
138
|
+
const resolvedPatch = { ...patch };
|
|
139
|
+
if (resolvedPatch.workingDir) {
|
|
140
|
+
if (resolvedPatch.workingDir.startsWith('~')) {
|
|
141
|
+
resolvedPatch.workingDir = join(homedir(), resolvedPatch.workingDir.slice(1));
|
|
142
|
+
} else if (!resolvedPatch.workingDir.startsWith('/')) {
|
|
143
|
+
resolvedPatch.workingDir = join(projectRoot, resolvedPatch.workingDir);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Validate llmBinding
|
|
148
|
+
if (resolvedPatch.llmBinding !== undefined && !KNOWN_LLM_BINDINGS.has(resolvedPatch.llmBinding)) {
|
|
149
|
+
return { ok: false, error: `llmBinding must be one of: ${[...KNOWN_LLM_BINDINGS].join(', ')}`, config: null };
|
|
150
|
+
}
|
|
151
|
+
// Validate embeddingBinding
|
|
152
|
+
if (resolvedPatch.embeddingBinding !== undefined && !KNOWN_EMBEDDING_BINDINGS.has(resolvedPatch.embeddingBinding)) {
|
|
153
|
+
return { ok: false, error: `embeddingBinding must be one of: ${[...KNOWN_EMBEDDING_BINDINGS].join(', ')}`, config: null };
|
|
154
|
+
}
|
|
155
|
+
// Validate port
|
|
156
|
+
if (resolvedPatch.port !== undefined) {
|
|
157
|
+
const port = parseInt(resolvedPatch.port, 10);
|
|
158
|
+
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
159
|
+
return { ok: false, error: 'port must be an integer between 1 and 65535', config: null };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Canonicalise '<empty>' → ''
|
|
164
|
+
if (resolvedPatch.apiKey === '<empty>') resolvedPatch.apiKey = '';
|
|
165
|
+
|
|
166
|
+
const existing = mem.lightrag || {};
|
|
167
|
+
const merged = deepMerge(existing, resolvedPatch);
|
|
168
|
+
mem.lightrag = merged;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
mkdirSync(dirname(memPath), { recursive: true });
|
|
172
|
+
atomicWriteJson(memPath, mem);
|
|
173
|
+
return { ok: true, config: mem };
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return { ok: false, error: err.message, config: null };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Redact apiKey for safe return to the UI.
|
|
181
|
+
* If apiKey is non-empty, replace with '***'.
|
|
182
|
+
*/
|
|
183
|
+
function redactLightRAG(cfg) {
|
|
184
|
+
if (!cfg) return cfg;
|
|
185
|
+
return {
|
|
186
|
+
...cfg,
|
|
187
|
+
apiKey: cfg.apiKey && cfg.apiKey !== '' ? '***' : cfg.apiKey || undefined,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolve the LightRAG config for a project.
|
|
193
|
+
* Pulls from `.bizar/memory.json` (lightrag block) and applies defaults.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} projectRoot
|
|
196
|
+
* @returns {{ enabled: boolean, host: string, port: number, workingDir: string, timeoutMs: number, pollMs: number, startupTimeoutMs: number, llmModel: string, embeddingModel: string, writeMarker: boolean, llmBinding: string, embeddingBinding: string, llmBindingHost: string|null, embeddingBindingHost: string|null, apiKey: string, apiKeySource: 'env'|'file' }}
|
|
197
|
+
*/
|
|
198
|
+
export function resolveLightRAGConfig(projectRoot) {
|
|
199
|
+
const path = join(projectRoot, '.bizar', 'memory.json');
|
|
200
|
+
let userCfg = {};
|
|
201
|
+
if (existsSync(path)) {
|
|
202
|
+
try {
|
|
203
|
+
userCfg = JSON.parse(readFileSync(path, 'utf8')).lightrag || {};
|
|
204
|
+
} catch {
|
|
205
|
+
userCfg = {};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Resolve workingDir:
|
|
209
|
+
// - empty/missing → <projectRoot>/.bizar/lightrag (default)
|
|
210
|
+
// - starts with ~ → expand to $HOME
|
|
211
|
+
// - absolute → use as-is
|
|
212
|
+
// - relative → resolve against projectRoot
|
|
213
|
+
let workingDir;
|
|
214
|
+
if (!userCfg.workingDir) {
|
|
215
|
+
workingDir = join(projectRoot, '.bizar', 'lightrag');
|
|
216
|
+
} else if (userCfg.workingDir.startsWith('~')) {
|
|
217
|
+
workingDir = join(homedir(), userCfg.workingDir.slice(1));
|
|
218
|
+
} else if (userCfg.workingDir.startsWith('/')) {
|
|
219
|
+
workingDir = userCfg.workingDir;
|
|
220
|
+
} else {
|
|
221
|
+
workingDir = join(projectRoot, userCfg.workingDir);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
enabled: userCfg.enabled !== false,
|
|
226
|
+
host: userCfg.host || LIGHTRAG_DEFAULTS.host,
|
|
227
|
+
port: userCfg.port || LIGHTRAG_DEFAULTS.port,
|
|
228
|
+
workingDir,
|
|
229
|
+
timeoutMs: userCfg.timeoutMs || LIGHTRAG_DEFAULTS.timeoutMs,
|
|
230
|
+
pollMs: userCfg.pollMs || LIGHTRAG_DEFAULTS.pollMs,
|
|
231
|
+
startupTimeoutMs: userCfg.startupTimeoutMs || LIGHTRAG_DEFAULTS.startupTimeoutMs,
|
|
232
|
+
llmBinding: userCfg.llmBinding || LIGHTRAG_DEFAULTS.llmBinding,
|
|
233
|
+
embeddingBinding: userCfg.embeddingBinding || LIGHTRAG_DEFAULTS.embeddingBinding,
|
|
234
|
+
llmBindingHost: userCfg.llmBindingHost || null,
|
|
235
|
+
embeddingBindingHost: userCfg.embeddingBindingHost || null,
|
|
236
|
+
llmModel: userCfg.llmModel || LIGHTRAG_DEFAULTS.llmModel,
|
|
237
|
+
embeddingModel: userCfg.embeddingModel || LIGHTRAG_DEFAULTS.embeddingModel,
|
|
238
|
+
apiKey: userCfg.apiKey !== undefined ? userCfg.apiKey : LIGHTRAG_DEFAULTS.apiKey,
|
|
239
|
+
apiKeySource: userCfg.apiKeySource || LIGHTRAG_DEFAULTS.apiKeySource,
|
|
240
|
+
writeMarker: userCfg.writeMarker !== false,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Process management ─────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Detect whether `lightrag-server` is on PATH. Returns the absolute
|
|
248
|
+
* path or null. Uses `which`-style probing (POSIX `command -v`) and
|
|
249
|
+
* common install dirs.
|
|
250
|
+
*/
|
|
251
|
+
export function findLightragBinary() {
|
|
252
|
+
const candidates = [
|
|
253
|
+
`${homedir()}/.local/bin/lightrag-server`,
|
|
254
|
+
`${homedir()}/.cargo/bin/lightrag-server`,
|
|
255
|
+
'/usr/local/bin/lightrag-server',
|
|
256
|
+
'/usr/bin/lightrag-server',
|
|
257
|
+
];
|
|
258
|
+
for (const c of candidates) {
|
|
259
|
+
if (existsSync(c)) return c;
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const path = execFileSync('command', ['-v', 'lightrag-server'], {
|
|
263
|
+
encoding: 'utf8',
|
|
264
|
+
timeout: 3000,
|
|
265
|
+
}).trim();
|
|
266
|
+
if (path) return path;
|
|
267
|
+
} catch {
|
|
268
|
+
// command returned non-zero — fall through
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function isInstalled() {
|
|
274
|
+
return findLightragBinary() !== null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function httpGet(url, timeoutMs) {
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
const lib = url.startsWith('https:') ? https : http;
|
|
280
|
+
const req = lib.get(url, { timeout: timeoutMs }, (res) => {
|
|
281
|
+
let data = '';
|
|
282
|
+
res.on('data', (chunk) => (data += chunk));
|
|
283
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
|
284
|
+
});
|
|
285
|
+
req.on('error', reject);
|
|
286
|
+
req.on('timeout', () => {
|
|
287
|
+
req.destroy();
|
|
288
|
+
reject(new Error(`timeout after ${timeoutMs}ms`));
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function httpPost(url, body, timeoutMs) {
|
|
294
|
+
return new Promise((resolve, reject) => {
|
|
295
|
+
try {
|
|
296
|
+
const lib = url.startsWith('https:') ? https : http;
|
|
297
|
+
const u = new URL(url);
|
|
298
|
+
const data = typeof body === 'string' ? body : JSON.stringify(body);
|
|
299
|
+
const req = lib.request(
|
|
300
|
+
{
|
|
301
|
+
hostname: u.hostname,
|
|
302
|
+
port: u.port,
|
|
303
|
+
path: u.pathname + u.search,
|
|
304
|
+
method: 'POST',
|
|
305
|
+
timeout: timeoutMs,
|
|
306
|
+
headers: {
|
|
307
|
+
'Content-Type': 'application/json',
|
|
308
|
+
'Content-Length': Buffer.byteLength(data),
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
(res) => {
|
|
312
|
+
let buf = '';
|
|
313
|
+
res.on('data', (chunk) => (buf += chunk));
|
|
314
|
+
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
req.on('error', reject);
|
|
318
|
+
req.on('timeout', () => {
|
|
319
|
+
req.destroy();
|
|
320
|
+
reject(new Error(`timeout after ${timeoutMs}ms`));
|
|
321
|
+
});
|
|
322
|
+
req.write(data);
|
|
323
|
+
req.end();
|
|
324
|
+
} catch (err) {
|
|
325
|
+
reject(err);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function isRunning(config) {
|
|
331
|
+
try {
|
|
332
|
+
const res = await httpGet(`http://${config.host}:${config.port}/health`, 3000);
|
|
333
|
+
return res.status === 200;
|
|
334
|
+
} catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function killPid(pid, signal) {
|
|
340
|
+
try {
|
|
341
|
+
process.kill(pid, signal);
|
|
342
|
+
return true;
|
|
343
|
+
} catch (err) {
|
|
344
|
+
if (err.code === 'ESRCH') return false; // already dead
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Read PID file and check liveness. Returns { pid, alive }.
|
|
351
|
+
*/
|
|
352
|
+
function readPidAlive(pidFile) {
|
|
353
|
+
if (!existsSync(pidFile)) return { pid: null, alive: false };
|
|
354
|
+
try {
|
|
355
|
+
const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
|
|
356
|
+
if (!Number.isFinite(pid) || pid <= 0) return { pid: null, alive: false };
|
|
357
|
+
const alive = killPid(pid, 0); // signal 0 = probe
|
|
358
|
+
return { pid, alive };
|
|
359
|
+
} catch {
|
|
360
|
+
return { pid: null, alive: false };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Start the LightRAG server as a detached child.
|
|
366
|
+
* Returns { ok, pid, logPath, error? }.
|
|
367
|
+
*/
|
|
368
|
+
export async function startServer(config, { logger } = {}) {
|
|
369
|
+
const log = (...a) => logger?.info?.('[lightrag]', ...a) || logger?.log?.(...a) || console.log('[lightrag]', ...a);
|
|
370
|
+
const err = (...a) => logger?.warn?.('[lightrag]', ...a) || console.error('[lightrag]', ...a);
|
|
371
|
+
|
|
372
|
+
// Idempotent: if PID file has a live PID, do nothing.
|
|
373
|
+
const pidFile = join(config.workingDir, 'lightrag.pid');
|
|
374
|
+
const logFile = join(config.workingDir, 'lightrag.log');
|
|
375
|
+
mkdirSync(config.workingDir, { recursive: true });
|
|
376
|
+
const { pid: existing, alive: existingAlive } = readPidAlive(pidFile);
|
|
377
|
+
if (existingAlive) {
|
|
378
|
+
log(`server already running (pid ${existing})`);
|
|
379
|
+
return { ok: true, pid: existing, logPath: logFile, alreadyRunning: true };
|
|
380
|
+
}
|
|
381
|
+
// Stale PID file → clean up.
|
|
382
|
+
if (existing && !existingAlive) {
|
|
383
|
+
try {
|
|
384
|
+
unlinkSync(pidFile);
|
|
385
|
+
} catch {}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!findLightragBinary()) {
|
|
389
|
+
return {
|
|
390
|
+
ok: false,
|
|
391
|
+
error:
|
|
392
|
+
'lightrag-server not installed. Run `uv tool install "lightrag-hku[api]"` to install.',
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Spawn detached so the parent's lifetime doesn't drag the server down.
|
|
397
|
+
const bin = findLightragBinary();
|
|
398
|
+
log(`starting ${bin} on http://${config.host}:${config.port} (working-dir ${config.workingDir})`);
|
|
399
|
+
|
|
400
|
+
const child = spawn(
|
|
401
|
+
bin,
|
|
402
|
+
[
|
|
403
|
+
'--host', config.host,
|
|
404
|
+
'--port', String(config.port),
|
|
405
|
+
'--working-dir', config.workingDir,
|
|
406
|
+
'--llm-binding', config.llmBinding || 'ollama',
|
|
407
|
+
'--embedding-binding', config.embeddingBinding || 'ollama',
|
|
408
|
+
],
|
|
409
|
+
{
|
|
410
|
+
cwd: config.workingDir,
|
|
411
|
+
env: {
|
|
412
|
+
...process.env,
|
|
413
|
+
HOST: config.host,
|
|
414
|
+
PORT: String(config.port),
|
|
415
|
+
WORKING_DIR: config.workingDir,
|
|
416
|
+
LLM_MODEL: config.llmModel,
|
|
417
|
+
EMBEDDING_MODEL: config.embeddingModel,
|
|
418
|
+
LLM_BINDING: config.llmBinding || 'ollama',
|
|
419
|
+
EMBEDDING_BINDING: config.embeddingBinding || 'ollama',
|
|
420
|
+
...(config.llmBindingHost ? { LLM_BINDING_HOST: config.llmBindingHost } : {}),
|
|
421
|
+
...(config.embeddingBindingHost ? { EMBEDDING_BINDING_HOST: config.embeddingBindingHost } : {}),
|
|
422
|
+
},
|
|
423
|
+
detached: true,
|
|
424
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
425
|
+
},
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// Detach the child so it survives the parent exiting.
|
|
429
|
+
child.unref();
|
|
430
|
+
|
|
431
|
+
// Tee stdout + stderr to log file.
|
|
432
|
+
let fd = -1;
|
|
433
|
+
try {
|
|
434
|
+
fd = openSync(logFile, 'a');
|
|
435
|
+
child.stdout?.on('data', (d) => { try { writeSync(fd, d); } catch {} });
|
|
436
|
+
child.stderr?.on('data', (d) => { try { writeSync(fd, d); } catch {} });
|
|
437
|
+
child.on('exit', (code, signal) => {
|
|
438
|
+
try { closeSync(fd); } catch {}
|
|
439
|
+
try { unlinkSync(pidFile); } catch {}
|
|
440
|
+
log(`lightrag-server exited (code=${code}, signal=${signal})`);
|
|
441
|
+
});
|
|
442
|
+
} catch (openErr) {
|
|
443
|
+
err(`failed to open log file ${logFile}: ${openErr.message}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
writeFileSync(pidFile, String(child.pid));
|
|
447
|
+
|
|
448
|
+
// Poll /health until ready or timeout.
|
|
449
|
+
const deadline = Date.now() + config.startupTimeoutMs;
|
|
450
|
+
while (Date.now() < deadline) {
|
|
451
|
+
if (await isRunning(config)) {
|
|
452
|
+
log(`lightrag-server ready on http://${config.host}:${config.port}`);
|
|
453
|
+
return { ok: true, pid: child.pid, logPath: logFile };
|
|
454
|
+
}
|
|
455
|
+
await new Promise((r) => setTimeout(r, config.pollMs));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
pid: child.pid,
|
|
461
|
+
logPath: logFile,
|
|
462
|
+
error: `started but /health did not respond within ${config.startupTimeoutMs}ms`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Stop the LightRAG server. SIGTERM, then SIGKILL after 5s.
|
|
468
|
+
*/
|
|
469
|
+
export async function stopServer(config, { logger } = {}) {
|
|
470
|
+
const log = (...a) => logger?.info?.('[lightrag]', ...a) || console.log('[lightrag]', ...a);
|
|
471
|
+
const pidFile = join(config.workingDir, 'lightrag.pid');
|
|
472
|
+
const { pid, alive } = readPidAlive(pidFile);
|
|
473
|
+
if (!pid || !alive) {
|
|
474
|
+
try { unlinkSync(pidFile); } catch {}
|
|
475
|
+
return { ok: true, message: 'not running' };
|
|
476
|
+
}
|
|
477
|
+
log(`stopping lightrag-server (pid ${pid})`);
|
|
478
|
+
killPid(pid, 'SIGTERM');
|
|
479
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
480
|
+
if (killPid(pid, 0)) {
|
|
481
|
+
log(`escalating to SIGKILL (pid ${pid})`);
|
|
482
|
+
killPid(pid, 'SIGKILL');
|
|
483
|
+
}
|
|
484
|
+
try { unlinkSync(pidFile); } catch {}
|
|
485
|
+
return { ok: true, message: 'stopped' };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Idempotent: returns immediately if the server is healthy. Otherwise
|
|
490
|
+
* starts it. Returns { ok, started, pid?, error? }.
|
|
491
|
+
*/
|
|
492
|
+
export async function ensureRunning(config, opts) {
|
|
493
|
+
if (!config.enabled) {
|
|
494
|
+
return { ok: false, started: false, error: 'lightrag disabled in .bizar/memory.json' };
|
|
495
|
+
}
|
|
496
|
+
if (await isRunning(config)) {
|
|
497
|
+
return { ok: true, started: false };
|
|
498
|
+
}
|
|
499
|
+
const r = await startServer(config, opts);
|
|
500
|
+
return { ok: r.ok, started: r.ok, pid: r.pid, error: r.error };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── Document insertion ────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Build a deterministic id for a note: `bizar://<projectId>/<relPath>`.
|
|
507
|
+
* The `bizar://` URI scheme is internal — LightRAG uses it as a stable key.
|
|
508
|
+
*/
|
|
509
|
+
function buildDocId(projectId, relPath) {
|
|
510
|
+
const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
511
|
+
return `bizar://${projectId}/${normalized}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Build the document text LightRAG will index. We prefix with metadata
|
|
516
|
+
* lines so semantic queries can return "what kind of note was this?"
|
|
517
|
+
* context even when only a snippet survives the chunker.
|
|
518
|
+
*/
|
|
519
|
+
function buildDocText(projectId, relPath, note) {
|
|
520
|
+
const fm = note.frontmatter || {};
|
|
521
|
+
const tags = Array.isArray(fm.tags) ? fm.tags.join(', ') : (fm.tags || '');
|
|
522
|
+
const lines = [
|
|
523
|
+
`Source: bizar://${projectId}/${relPath.replace(/^\/+/, '')}`,
|
|
524
|
+
`Project: ${fm.project_id || projectId}`,
|
|
525
|
+
`Type: ${fm.type || 'unknown'}`,
|
|
526
|
+
`Status: ${fm.status || 'unknown'}`,
|
|
527
|
+
`Confidence: ${fm.confidence || 'unknown'}`,
|
|
528
|
+
`Tags: ${tags}`,
|
|
529
|
+
`Memory ID: ${fm.memory_id || ''}`,
|
|
530
|
+
'',
|
|
531
|
+
note.body || '',
|
|
532
|
+
];
|
|
533
|
+
return lines.join('\n');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Insert one note into LightRAG. Returns { ok, trackId, error? }.
|
|
538
|
+
*
|
|
539
|
+
* LightRAG's HTTP API for inserting a single document is
|
|
540
|
+
* `POST /documents` (text in body, optional `file_source` for tracking).
|
|
541
|
+
* We send: { text, file_source: docId }.
|
|
542
|
+
*/
|
|
543
|
+
export async function insertNote(config, projectId, note) {
|
|
544
|
+
if (!note?.relPath) return { ok: false, error: 'note.relPath required' };
|
|
545
|
+
const docId = buildDocId(projectId, note.relPath);
|
|
546
|
+
const text = buildDocText(projectId, note.relPath, note);
|
|
547
|
+
|
|
548
|
+
const url = `http://${config.host}:${config.port}/documents/text`;
|
|
549
|
+
const body = {
|
|
550
|
+
text,
|
|
551
|
+
file_source: docId,
|
|
552
|
+
};
|
|
553
|
+
try {
|
|
554
|
+
const res = await httpPost(url, body, config.timeoutMs);
|
|
555
|
+
if (res.status >= 200 && res.status < 300) {
|
|
556
|
+
let parsed;
|
|
557
|
+
try { parsed = JSON.parse(res.body); } catch { parsed = { raw: res.body }; }
|
|
558
|
+
return { ok: true, trackId: docId, response: parsed };
|
|
559
|
+
}
|
|
560
|
+
return { ok: false, trackId: docId, error: `HTTP ${res.status}: ${res.body.slice(0, 200)}` };
|
|
561
|
+
} catch (err) {
|
|
562
|
+
return { ok: false, trackId: docId, error: err.message };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Insert all notes. Returns aggregate { inserted, failed, durationMs }.
|
|
568
|
+
*
|
|
569
|
+
* Sequential insertion — LightRAG's API is request-per-document and
|
|
570
|
+
* concurrent inserts may overwhelm the embedder. For 100+ notes we
|
|
571
|
+
* could batch; for the BizarHarness KB (15-30 notes), sequential is fine.
|
|
572
|
+
*/
|
|
573
|
+
export async function insertAllNotes(config, projectId, notes, { logger } = {}) {
|
|
574
|
+
const log = (...a) => logger?.info?.('[lightrag]', ...a) || logger?.log?.(...a) || console.log('[lightrag]', ...a);
|
|
575
|
+
const start = Date.now();
|
|
576
|
+
let inserted = 0;
|
|
577
|
+
let failed = 0;
|
|
578
|
+
const failures = [];
|
|
579
|
+
for (let i = 0; i < notes.length; i++) {
|
|
580
|
+
const note = notes[i];
|
|
581
|
+
const r = await insertNote(config, projectId, note);
|
|
582
|
+
if (r.ok) {
|
|
583
|
+
inserted++;
|
|
584
|
+
} else {
|
|
585
|
+
failed++;
|
|
586
|
+
failures.push({ relPath: note.relPath, error: r.error });
|
|
587
|
+
}
|
|
588
|
+
if ((i + 1) % 10 === 0 || i === notes.length - 1) {
|
|
589
|
+
log(`progress: ${i + 1}/${notes.length} (inserted=${inserted}, failed=${failed})`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return { inserted, failed, failures, durationMs: Date.now() - start };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ── Top-level orchestrators ────────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Reindex the entire vault into LightRAG.
|
|
599
|
+
*
|
|
600
|
+
* 1. Resolve config + vault.
|
|
601
|
+
* 2. List notes from disk.
|
|
602
|
+
* 3. Ensure server is running (auto-start).
|
|
603
|
+
* 4. Insert each note.
|
|
604
|
+
* 5. Write marker file with stats.
|
|
605
|
+
*
|
|
606
|
+
* Returns { ok, started, inserted, failed, failures, durationMs, startedAt,
|
|
607
|
+
* markerPath, error? }.
|
|
608
|
+
*/
|
|
609
|
+
export async function reindexVault(projectRoot, opts = {}) {
|
|
610
|
+
const startedAt = new Date().toISOString();
|
|
611
|
+
// Resolve config + vault by reading memory.json directly to avoid
|
|
612
|
+
// pulling in memory-store (which itself imports this module — would cycle).
|
|
613
|
+
const config = resolveLightRAGConfig(projectRoot);
|
|
614
|
+
if (!config.enabled) {
|
|
615
|
+
return { ok: false, started: false, error: 'lightrag disabled in .bizar/memory.json' };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Ensure vault dir exists; list notes.
|
|
619
|
+
const memPath = join(projectRoot, '.bizar', 'memory.json');
|
|
620
|
+
if (!existsSync(memPath)) {
|
|
621
|
+
return { ok: false, error: 'memory not initialized — run `bizar memory init` first' };
|
|
622
|
+
}
|
|
623
|
+
const mem = JSON.parse(readFileSync(memPath, 'utf8'));
|
|
624
|
+
const mode = mem.memoryRepo?.mode || 'local-only';
|
|
625
|
+
let vaultRoot;
|
|
626
|
+
let projectId = mem.projectId || basename(projectRoot);
|
|
627
|
+
if (mode === 'local-only') {
|
|
628
|
+
vaultRoot = join(projectRoot, '.obsidian');
|
|
629
|
+
} else {
|
|
630
|
+
const rawPath = mem.memoryRepo?.path || '';
|
|
631
|
+
let expanded;
|
|
632
|
+
if (!rawPath) {
|
|
633
|
+
expanded = join(projectRoot, '.bizar', 'memory');
|
|
634
|
+
} else if (rawPath.startsWith('~')) {
|
|
635
|
+
expanded = join(homedir(), rawPath.slice(1));
|
|
636
|
+
} else if (rawPath.startsWith('/')) {
|
|
637
|
+
expanded = rawPath;
|
|
638
|
+
} else {
|
|
639
|
+
expanded = join(projectRoot, rawPath);
|
|
640
|
+
}
|
|
641
|
+
vaultRoot = join(expanded, 'projects', projectId);
|
|
642
|
+
}
|
|
643
|
+
if (!existsSync(vaultRoot)) {
|
|
644
|
+
return { ok: false, error: `vault not found at ${vaultRoot} — run \`bizar memory init\`` };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Collect all .md files in vault (skip dotfiles).
|
|
648
|
+
const notes = [];
|
|
649
|
+
function walk(dir, prefix) {
|
|
650
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
651
|
+
if (entry.name.startsWith('.')) continue;
|
|
652
|
+
const full = join(dir, entry.name);
|
|
653
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
654
|
+
if (entry.isDirectory()) {
|
|
655
|
+
walk(full, rel);
|
|
656
|
+
} else if (entry.name.endsWith('.md')) {
|
|
657
|
+
const raw = readFileSync(full, 'utf8');
|
|
658
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
659
|
+
notes.push({ relPath: rel, frontmatter, body });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
walk(vaultRoot, '');
|
|
664
|
+
|
|
665
|
+
// Always write a marker (even on failure) so `bizar memory status`
|
|
666
|
+
// can report when the last attempt was.
|
|
667
|
+
const cacheDir = join(projectRoot, '.bizar', 'memory-cache');
|
|
668
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
669
|
+
const markerPath = join(cacheDir, 'last-reindex.json');
|
|
670
|
+
const writeMarker = (extra) => {
|
|
671
|
+
if (!config.writeMarker) return;
|
|
672
|
+
const marker = {
|
|
673
|
+
attemptedAt: startedAt,
|
|
674
|
+
finishedAt: new Date().toISOString(),
|
|
675
|
+
durationMs: Date.now() - new Date(startedAt).getTime(),
|
|
676
|
+
ok: false,
|
|
677
|
+
noteCount: notes.length,
|
|
678
|
+
inserted: 0,
|
|
679
|
+
failed: 0,
|
|
680
|
+
failures: [],
|
|
681
|
+
lightrag: {
|
|
682
|
+
host: config.host,
|
|
683
|
+
port: config.port,
|
|
684
|
+
workingDir: config.workingDir,
|
|
685
|
+
},
|
|
686
|
+
...extra,
|
|
687
|
+
};
|
|
688
|
+
atomicWriteJson(markerPath, marker);
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
// Ensure server is up.
|
|
692
|
+
const runResult = await ensureRunning(config, opts);
|
|
693
|
+
if (!runResult.ok) {
|
|
694
|
+
const reason = runResult.error || 'lightrag not running';
|
|
695
|
+
writeMarker({ error: reason, ok: false });
|
|
696
|
+
return {
|
|
697
|
+
ok: false,
|
|
698
|
+
started: false,
|
|
699
|
+
error: reason,
|
|
700
|
+
noteCount: notes.length,
|
|
701
|
+
markerPath,
|
|
702
|
+
startedAt,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Insert all notes.
|
|
707
|
+
const result = await insertAllNotes(config, projectId, notes, opts);
|
|
708
|
+
|
|
709
|
+
// Write success marker.
|
|
710
|
+
writeMarker({
|
|
711
|
+
durationMs: result.durationMs,
|
|
712
|
+
ok: result.failed === 0,
|
|
713
|
+
started: runResult.started,
|
|
714
|
+
pid: runResult.pid,
|
|
715
|
+
projectId,
|
|
716
|
+
vaultRoot,
|
|
717
|
+
inserted: result.inserted,
|
|
718
|
+
failed: result.failed,
|
|
719
|
+
failures: result.failures.slice(0, 50),
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
return {
|
|
723
|
+
ok: result.failed === 0,
|
|
724
|
+
started: runResult.started,
|
|
725
|
+
pid: runResult.pid,
|
|
726
|
+
inserted: result.inserted,
|
|
727
|
+
failed: result.failed,
|
|
728
|
+
failures: result.failures,
|
|
729
|
+
durationMs: result.durationMs,
|
|
730
|
+
noteCount: notes.length,
|
|
731
|
+
markerPath,
|
|
732
|
+
startedAt,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Query LightRAG. Returns { ok, mode, response, error? }.
|
|
738
|
+
*
|
|
739
|
+
* LightRAG's `/query` endpoint accepts:
|
|
740
|
+
* { query: string, mode?: 'local'|'global'|'hybrid'|'mix', top_k?: number,
|
|
741
|
+
* conversation_history?: [], user_prompt?: string, response_type?: string }
|
|
742
|
+
*/
|
|
743
|
+
export async function query(config, question, { mode = 'mix', topK = 10 } = {}) {
|
|
744
|
+
if (!config.enabled) {
|
|
745
|
+
return { ok: false, error: 'lightrag disabled in .bizar/memory.json' };
|
|
746
|
+
}
|
|
747
|
+
if (!(await isRunning(config))) {
|
|
748
|
+
return { ok: false, error: 'lightrag server not running — run `bizar memory reindex` first' };
|
|
749
|
+
}
|
|
750
|
+
const url = `http://${config.host}:${config.port}/query`;
|
|
751
|
+
try {
|
|
752
|
+
const res = await httpPost(url, {
|
|
753
|
+
query: question,
|
|
754
|
+
mode,
|
|
755
|
+
top_k: topK,
|
|
756
|
+
response_type: 'Multiple Paragraphs',
|
|
757
|
+
}, config.timeoutMs);
|
|
758
|
+
if (res.status >= 200 && res.status < 300) {
|
|
759
|
+
let parsed;
|
|
760
|
+
try { parsed = JSON.parse(res.body); } catch { parsed = { response: res.body }; }
|
|
761
|
+
return { ok: true, mode, response: parsed };
|
|
762
|
+
}
|
|
763
|
+
return { ok: false, mode, error: `HTTP ${res.status}: ${res.body.slice(0, 200)}` };
|
|
764
|
+
} catch (err) {
|
|
765
|
+
return { ok: false, mode, error: err.message };
|
|
766
|
+
}
|
|
767
|
+
}
|