nano-brain 2026.7.2 → 2026.7.3
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/package.json +1 -1
- package/src/index.ts +23 -320
- package/src/server.ts +11 -1
- package/src/store.ts +7 -1
- package/src/watcher.ts +48 -43
- package/src/service-installer.ts +0 -260
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nano-brain",
|
|
3
|
-
"version": "2026.7.
|
|
3
|
+
"version": "2026.7.3",
|
|
4
4
|
"description": "Persistent memory and code intelligence for AI coding agents. Local MCP server with self-learning hybrid search (BM25 + vector + knowledge graph + LLM reranking), automatic session ingestion, codebase indexing, and 22 tools. Learns your preferences over time. Works with OpenCode, Claude, Cursor, Windsurf, and any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,6 @@ import { findCycles } from './graph.js';
|
|
|
10
10
|
import { handleBench } from './bench.js';
|
|
11
11
|
import { resolveHostUrl } from './host.js';
|
|
12
12
|
import { SymbolGraph } from './symbol-graph.js';
|
|
13
|
-
import { installService, uninstallService } from './service-installer.js';
|
|
14
13
|
import { isTreeSitterAvailable } from './treesitter.js';
|
|
15
14
|
import { QdrantVecStore } from './providers/qdrant.js';
|
|
16
15
|
import { createVectorStore } from './vector-store.js';
|
|
@@ -30,11 +29,12 @@ import { log, initLogger, cliOutput, cliError, setStdioMode } from './logger.js'
|
|
|
30
29
|
|
|
31
30
|
const DEFAULT_HTTP_PORT = 3100;
|
|
32
31
|
|
|
33
|
-
async function detectRunningServer(port: number =
|
|
32
|
+
async function detectRunningServer(port: number = getHttpPort()): Promise<boolean> {
|
|
33
|
+
const host = getHttpHost();
|
|
34
34
|
try {
|
|
35
35
|
const controller = new AbortController();
|
|
36
36
|
const timeout = setTimeout(() => controller.abort(), 1000);
|
|
37
|
-
const resp = await fetch(`http
|
|
37
|
+
const resp = await fetch(`http://${host}:${port}/health`, { signal: controller.signal });
|
|
38
38
|
clearTimeout(timeout);
|
|
39
39
|
return resp.ok;
|
|
40
40
|
} catch {
|
|
@@ -42,51 +42,16 @@ async function detectRunningServer(port: number = DEFAULT_HTTP_PORT): Promise<bo
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
const UNSAFE_SERVE_STOP_PATTERNS = [
|
|
46
|
-
/docker/i,
|
|
47
|
-
/docker-proxy/i,
|
|
48
|
-
/com\.docker/i,
|
|
49
|
-
/vpnkit/i,
|
|
50
|
-
/containerd/i,
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
function getProcessCommand(pid: number): string {
|
|
54
|
-
if (!Number.isInteger(pid) || pid <= 0) return '';
|
|
55
|
-
try {
|
|
56
|
-
return execSync(`ps -p ${pid} -o command=`, { encoding: 'utf-8' }).trim();
|
|
57
|
-
} catch {
|
|
58
|
-
return '';
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function isUnsafeServeStopTarget(command: string): boolean {
|
|
63
|
-
if (!command) return true;
|
|
64
|
-
return UNSAFE_SERVE_STOP_PATTERNS.some((pattern) => pattern.test(command));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function isLikelyNanoBrainServerCommand(command: string): boolean {
|
|
68
|
-
if (!command) return false;
|
|
69
|
-
const normalized = command.toLowerCase();
|
|
70
|
-
const hasNanoBrainMarker =
|
|
71
|
-
normalized.includes('nano-brain') ||
|
|
72
|
-
normalized.includes('/bin/cli.js') ||
|
|
73
|
-
normalized.includes('/src/index.ts');
|
|
74
|
-
|
|
75
|
-
const hasServerModeMarker =
|
|
76
|
-
normalized.includes(' mcp') ||
|
|
77
|
-
normalized.includes(' serve') ||
|
|
78
|
-
normalized.includes('--daemon');
|
|
79
|
-
|
|
80
|
-
return hasNanoBrainMarker && hasServerModeMarker;
|
|
81
|
-
}
|
|
82
45
|
|
|
83
46
|
async function proxyGet(port: number, path: string): Promise<any> {
|
|
84
|
-
const
|
|
47
|
+
const host = getHttpHost();
|
|
48
|
+
const resp = await fetch(`http://${host}:${port}${path}`);
|
|
85
49
|
return resp.json();
|
|
86
50
|
}
|
|
87
51
|
|
|
88
52
|
async function proxyPost(port: number, path: string, body: any): Promise<any> {
|
|
89
|
-
const
|
|
53
|
+
const host = getHttpHost();
|
|
54
|
+
const resp = await fetch(`http://${host}:${port}${path}`, {
|
|
90
55
|
method: 'POST',
|
|
91
56
|
headers: { 'Content-Type': 'application/json' },
|
|
92
57
|
body: JSON.stringify(body),
|
|
@@ -103,10 +68,16 @@ function isRunningInContainer(): boolean {
|
|
|
103
68
|
}
|
|
104
69
|
|
|
105
70
|
function getHttpHost(): string {
|
|
71
|
+
if (process.env.NANO_BRAIN_HOST) return process.env.NANO_BRAIN_HOST;
|
|
106
72
|
return isRunningInContainer() ? 'host.docker.internal' : 'localhost';
|
|
107
73
|
}
|
|
108
74
|
|
|
109
|
-
|
|
75
|
+
function getHttpPort(): number {
|
|
76
|
+
if (process.env.NANO_BRAIN_PORT) return parseInt(process.env.NANO_BRAIN_PORT, 10);
|
|
77
|
+
return DEFAULT_HTTP_PORT;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function detectRunningServerContainer(port: number = getHttpPort()): Promise<boolean> {
|
|
110
81
|
const host = getHttpHost();
|
|
111
82
|
try {
|
|
112
83
|
const controller = new AbortController();
|
|
@@ -220,14 +191,6 @@ nano-brain - Memory system with hybrid search
|
|
|
220
191
|
--host=<addr> Bind address (default: 127.0.0.1)
|
|
221
192
|
--daemon Run as background daemon
|
|
222
193
|
stop Stop running daemon
|
|
223
|
-
serve Start SSE server as background daemon (shortcut)
|
|
224
|
-
--port=<n> HTTP port (default: 3100)
|
|
225
|
-
--foreground Run in foreground instead of detaching
|
|
226
|
-
stop [--force] Stop running server (safe PID checks)
|
|
227
|
-
status Show server status
|
|
228
|
-
install Install as system service (launchd on macOS, systemd on Linux)
|
|
229
|
-
--force Overwrite existing service file
|
|
230
|
-
uninstall Remove system service
|
|
231
194
|
status Show index health, embedding server status, and stats
|
|
232
195
|
--all Show status for all workspaces
|
|
233
196
|
collection Manage collections
|
|
@@ -431,264 +394,6 @@ async function handleMcp(globalOpts: GlobalOptions, commandArgs: string[]): Prom
|
|
|
431
394
|
});
|
|
432
395
|
}
|
|
433
396
|
|
|
434
|
-
async function handleServe(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
|
|
435
|
-
const SERVE_PID_FILE = path.join(NANO_BRAIN_HOME, 'serve.pid');
|
|
436
|
-
const SERVE_LOG_FILE = path.join(NANO_BRAIN_HOME, 'logs', 'server.log');
|
|
437
|
-
|
|
438
|
-
let port = 3100;
|
|
439
|
-
let foreground = false;
|
|
440
|
-
let subcommand: string | undefined;
|
|
441
|
-
let root: string | undefined;
|
|
442
|
-
let force = false;
|
|
443
|
-
|
|
444
|
-
for (const arg of commandArgs) {
|
|
445
|
-
if (arg.startsWith('--port=')) {
|
|
446
|
-
port = parseInt(arg.substring(7), 10);
|
|
447
|
-
} else if (arg.startsWith('--root=')) {
|
|
448
|
-
root = arg.substring(7);
|
|
449
|
-
} else if (arg === '--foreground' || arg === '-f') {
|
|
450
|
-
foreground = true;
|
|
451
|
-
} else if (arg === '--force') {
|
|
452
|
-
force = true;
|
|
453
|
-
} else if (arg === 'stop' || arg === 'status' || arg === 'install' || arg === 'uninstall') {
|
|
454
|
-
subcommand = arg;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// serve stop
|
|
459
|
-
if (subcommand === 'stop') {
|
|
460
|
-
let stopped = false;
|
|
461
|
-
const skippedUnsafe: Array<{ pid: number; command: string; source: string }> = [];
|
|
462
|
-
|
|
463
|
-
const tryStopPid = (pid: number, source: string): boolean => {
|
|
464
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
465
|
-
|
|
466
|
-
const command = getProcessCommand(pid);
|
|
467
|
-
if (!force && (isUnsafeServeStopTarget(command) || !isLikelyNanoBrainServerCommand(command))) {
|
|
468
|
-
skippedUnsafe.push({ pid, command, source });
|
|
469
|
-
return false;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
try {
|
|
473
|
-
process.kill(pid, 'SIGTERM');
|
|
474
|
-
cliOutput(`Stopped nano-brain server (${source}, PID: ${pid})`);
|
|
475
|
-
return true;
|
|
476
|
-
} catch {
|
|
477
|
-
return false;
|
|
478
|
-
}
|
|
479
|
-
};
|
|
480
|
-
|
|
481
|
-
// Try stopping via PID file
|
|
482
|
-
try {
|
|
483
|
-
if (fs.existsSync(SERVE_PID_FILE)) {
|
|
484
|
-
const pidText = fs.readFileSync(SERVE_PID_FILE, 'utf-8').trim();
|
|
485
|
-
const pid = parseInt(pidText, 10);
|
|
486
|
-
if (tryStopPid(pid, 'PID file')) {
|
|
487
|
-
stopped = true;
|
|
488
|
-
fs.unlinkSync(SERVE_PID_FILE);
|
|
489
|
-
} else if (fs.existsSync(SERVE_PID_FILE)) {
|
|
490
|
-
// Remove stale/invalid PID file and continue with safe fallback
|
|
491
|
-
fs.unlinkSync(SERVE_PID_FILE);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
} catch {
|
|
495
|
-
// Process might already be dead
|
|
496
|
-
if (fs.existsSync(SERVE_PID_FILE)) fs.unlinkSync(SERVE_PID_FILE);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// Secondary stop: try to find process by port if PID file failed or was missing
|
|
500
|
-
if (!stopped) {
|
|
501
|
-
try {
|
|
502
|
-
const isPortActive = await detectRunningServer(port);
|
|
503
|
-
if (isPortActive) {
|
|
504
|
-
const platform = process.platform;
|
|
505
|
-
if (platform === 'darwin' || platform === 'linux') {
|
|
506
|
-
try {
|
|
507
|
-
const cmd = platform === 'darwin'
|
|
508
|
-
? `lsof -ti tcp:${port}`
|
|
509
|
-
: `lsof -ti tcp:${port} 2>/dev/null || fuser ${port}/tcp 2>/dev/null`;
|
|
510
|
-
const raw = execSync(cmd, { encoding: 'utf-8' }).trim();
|
|
511
|
-
const candidatePids = Array.from(
|
|
512
|
-
new Set(
|
|
513
|
-
(raw.match(/\d+/g) || [])
|
|
514
|
-
.map((value) => parseInt(value, 10))
|
|
515
|
-
.filter((value) => Number.isInteger(value) && value > 0 && value !== port)
|
|
516
|
-
)
|
|
517
|
-
);
|
|
518
|
-
|
|
519
|
-
const stoppedPids: number[] = [];
|
|
520
|
-
for (const pid of candidatePids) {
|
|
521
|
-
if (tryStopPid(pid, `port ${port}`)) {
|
|
522
|
-
stoppedPids.push(pid);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
if (stoppedPids.length > 0) {
|
|
527
|
-
cliOutput(`Stopped nano-brain server on port ${port} (PIDs: ${stoppedPids.join(', ')})`);
|
|
528
|
-
stopped = true;
|
|
529
|
-
}
|
|
530
|
-
} catch {
|
|
531
|
-
// Ignore command failures
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
} catch {
|
|
536
|
-
// Ignore health check failures
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
if (!stopped) {
|
|
541
|
-
if (force) {
|
|
542
|
-
cliOutput('No running server found');
|
|
543
|
-
} else if (skippedUnsafe.length > 0) {
|
|
544
|
-
cliOutput('No safe nano-brain server PID found to stop.');
|
|
545
|
-
for (const item of skippedUnsafe) {
|
|
546
|
-
const details = item.command ? ` (${item.command})` : '';
|
|
547
|
-
cliOutput(` skipped ${item.source} PID ${item.pid}${details}`);
|
|
548
|
-
}
|
|
549
|
-
cliOutput('Use `npx nano-brain serve stop --force` only if you verified the target PID manually.');
|
|
550
|
-
} else {
|
|
551
|
-
cliOutput('No running server found');
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// serve status
|
|
558
|
-
if (subcommand === 'status') {
|
|
559
|
-
let pidAlive = false;
|
|
560
|
-
let pid: number | null = null;
|
|
561
|
-
try {
|
|
562
|
-
pid = parseInt(fs.readFileSync(SERVE_PID_FILE, 'utf-8').trim(), 10);
|
|
563
|
-
process.kill(pid, 0);
|
|
564
|
-
pidAlive = true;
|
|
565
|
-
} catch {}
|
|
566
|
-
const portActive = await detectRunningServer(port);
|
|
567
|
-
if (pidAlive && pid) {
|
|
568
|
-
cliOutput(`nano-brain server is running (PID: ${pid}, port: ${port})`);
|
|
569
|
-
} else if (portActive) {
|
|
570
|
-
cliOutput(`nano-brain server is responding on port ${port} but PID file is stale. Run: npx nano-brain serve stop --force`);
|
|
571
|
-
} else {
|
|
572
|
-
cliOutput('nano-brain server is not running');
|
|
573
|
-
}
|
|
574
|
-
return;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// serve install
|
|
578
|
-
if (subcommand === 'install') {
|
|
579
|
-
const result = installService({ force, port });
|
|
580
|
-
if (result.success) {
|
|
581
|
-
cliOutput(`✅ ${result.message}`);
|
|
582
|
-
cliOutput(` The server will start automatically on login.`);
|
|
583
|
-
cliOutput(` Port: ${port}`);
|
|
584
|
-
} else {
|
|
585
|
-
cliError(`❌ ${result.message}`);
|
|
586
|
-
process.exit(1);
|
|
587
|
-
}
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// serve uninstall
|
|
592
|
-
if (subcommand === 'uninstall') {
|
|
593
|
-
const result = uninstallService();
|
|
594
|
-
if (result.success) {
|
|
595
|
-
cliOutput(`✅ ${result.message}`);
|
|
596
|
-
} else {
|
|
597
|
-
cliError(`❌ ${result.message}`);
|
|
598
|
-
process.exit(1);
|
|
599
|
-
}
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// serve (start)
|
|
604
|
-
if (foreground) {
|
|
605
|
-
return handleMcp(globalOpts, ['--http', `--port=${port}`, '--host=0.0.0.0', '--daemon', ...(root ? [`--root=${root}`] : [])]);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// --force: stop existing server before starting
|
|
609
|
-
if (force) {
|
|
610
|
-
try {
|
|
611
|
-
if (fs.existsSync(SERVE_PID_FILE)) {
|
|
612
|
-
const existingPid = parseInt(fs.readFileSync(SERVE_PID_FILE, 'utf-8').trim(), 10);
|
|
613
|
-
try { process.kill(existingPid, 'SIGTERM'); } catch {}
|
|
614
|
-
cliOutput(`Stopped existing server (PID: ${existingPid})`);
|
|
615
|
-
fs.unlinkSync(SERVE_PID_FILE);
|
|
616
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
617
|
-
}
|
|
618
|
-
} catch {}
|
|
619
|
-
const portBusy = await detectRunningServer(port);
|
|
620
|
-
if (portBusy) {
|
|
621
|
-
const platform = process.platform;
|
|
622
|
-
if (platform === 'darwin' || platform === 'linux') {
|
|
623
|
-
try {
|
|
624
|
-
const cmd = platform === 'darwin' ? `lsof -ti tcp:${port}` : `lsof -ti tcp:${port} 2>/dev/null || fuser ${port}/tcp 2>/dev/null`;
|
|
625
|
-
const raw = execSync(cmd, { encoding: 'utf-8' }).trim();
|
|
626
|
-
for (const pidStr of raw.split(/\s+/)) {
|
|
627
|
-
const pid = parseInt(pidStr, 10);
|
|
628
|
-
if (pid > 0) { try { process.kill(pid, 'SIGKILL'); } catch {} }
|
|
629
|
-
}
|
|
630
|
-
await new Promise(r => setTimeout(r, 500));
|
|
631
|
-
} catch {}
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// Check if already running via PID file
|
|
637
|
-
let pidAlive = false;
|
|
638
|
-
try {
|
|
639
|
-
if (fs.existsSync(SERVE_PID_FILE)) {
|
|
640
|
-
const existingPid = parseInt(fs.readFileSync(SERVE_PID_FILE, 'utf-8').trim(), 10);
|
|
641
|
-
process.kill(existingPid, 0);
|
|
642
|
-
pidAlive = true;
|
|
643
|
-
cliOutput(`Server already running (PID: ${existingPid}). Stop first or use: npx nano-brain serve start --force`);
|
|
644
|
-
return;
|
|
645
|
-
}
|
|
646
|
-
} catch {
|
|
647
|
-
try { fs.unlinkSync(SERVE_PID_FILE); } catch {}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// Secondary check: verify if port is already in use by another instance
|
|
651
|
-
const isPortActive = await detectRunningServer(port);
|
|
652
|
-
if (isPortActive) {
|
|
653
|
-
cliOutput(`Port ${port} is in use by an orphaned process. Run: npx nano-brain serve start --force`);
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// Spawn detached child
|
|
658
|
-
const logsDir = path.join(NANO_BRAIN_HOME, 'logs');
|
|
659
|
-
fs.mkdirSync(logsDir, { recursive: true });
|
|
660
|
-
|
|
661
|
-
const cliPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../bin/cli.js');
|
|
662
|
-
const args = [cliPath, 'mcp', '--http', `--port=${port}`, '--host=0.0.0.0', '--daemon'];
|
|
663
|
-
if (root) {
|
|
664
|
-
args.push(`--root=${root}`);
|
|
665
|
-
}
|
|
666
|
-
if (globalOpts.configPath !== DEFAULT_CONFIG) {
|
|
667
|
-
args.push(`--config=${globalOpts.configPath}`);
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const logFd = fs.openSync(SERVE_LOG_FILE, 'a');
|
|
671
|
-
const child = spawn(process.argv[0], args, {
|
|
672
|
-
detached: true,
|
|
673
|
-
stdio: ['ignore', logFd, logFd],
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
if (child.pid) {
|
|
677
|
-
fs.writeFileSync(SERVE_PID_FILE, String(child.pid));
|
|
678
|
-
child.unref();
|
|
679
|
-
cliOutput(`nano-brain server started on http://0.0.0.0:${port} (PID: ${child.pid})`);
|
|
680
|
-
cliOutput(` SSE endpoint: http://localhost:${port}/sse`);
|
|
681
|
-
cliOutput(` Health check: http://localhost:${port}/health`);
|
|
682
|
-
cliOutput(` Logs: ${SERVE_LOG_FILE}`);
|
|
683
|
-
cliOutput(` Stop: npx nano-brain serve stop`);
|
|
684
|
-
} else {
|
|
685
|
-
cliError('Failed to start server');
|
|
686
|
-
process.exit(1);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
fs.closeSync(logFd);
|
|
690
|
-
process.exit(0);
|
|
691
|
-
}
|
|
692
397
|
|
|
693
398
|
async function handleCollection(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
|
|
694
399
|
const subcommand = commandArgs[0];
|
|
@@ -1411,8 +1116,8 @@ async function handleEmbed(globalOpts: GlobalOptions, commandArgs: string[]): Pr
|
|
|
1411
1116
|
if (isRunningInContainer()) {
|
|
1412
1117
|
const serverRunning = await detectRunningServerContainer(DEFAULT_HTTP_PORT);
|
|
1413
1118
|
if (!serverRunning) {
|
|
1414
|
-
cliError(
|
|
1415
|
-
cliError('
|
|
1119
|
+
cliError(`Error: nano-brain server not reachable at ${getHttpHost()}:${getHttpPort()}. Ensure the Docker container is running:`);
|
|
1120
|
+
cliError(' docker start nano-brain');
|
|
1416
1121
|
process.exit(1);
|
|
1417
1122
|
}
|
|
1418
1123
|
try {
|
|
@@ -1555,8 +1260,8 @@ async function handleSearch(
|
|
|
1555
1260
|
: await detectRunningServer(DEFAULT_HTTP_PORT);
|
|
1556
1261
|
|
|
1557
1262
|
if (inContainer && !serverRunning) {
|
|
1558
|
-
cliError(
|
|
1559
|
-
cliError('
|
|
1263
|
+
cliError(`Error: nano-brain server not reachable at ${getHttpHost()}:${getHttpPort()}. Ensure the Docker container is running:`);
|
|
1264
|
+
cliError(' docker start nano-brain');
|
|
1560
1265
|
process.exit(1);
|
|
1561
1266
|
}
|
|
1562
1267
|
|
|
@@ -1722,8 +1427,8 @@ async function handleWrite(globalOpts: GlobalOptions, commandArgs: string[]): Pr
|
|
|
1722
1427
|
if (isRunningInContainer()) {
|
|
1723
1428
|
const serverRunning = await detectRunningServerContainer(DEFAULT_HTTP_PORT);
|
|
1724
1429
|
if (!serverRunning) {
|
|
1725
|
-
cliError(
|
|
1726
|
-
cliError('
|
|
1430
|
+
cliError(`Error: nano-brain server not reachable at ${getHttpHost()}:${getHttpPort()}. Ensure the Docker container is running:`);
|
|
1431
|
+
cliError(' docker start nano-brain');
|
|
1727
1432
|
process.exit(1);
|
|
1728
1433
|
}
|
|
1729
1434
|
try {
|
|
@@ -2319,8 +2024,8 @@ async function handleReindex(globalOpts: GlobalOptions, commandArgs: string[]):
|
|
|
2319
2024
|
if (isRunningInContainer()) {
|
|
2320
2025
|
const serverRunning = await detectRunningServerContainer(DEFAULT_HTTP_PORT);
|
|
2321
2026
|
if (!serverRunning) {
|
|
2322
|
-
cliError(
|
|
2323
|
-
cliError('
|
|
2027
|
+
cliError(`Error: nano-brain server not reachable at ${getHttpHost()}:${getHttpPort()}. Ensure the Docker container is running:`);
|
|
2028
|
+
cliError(' docker start nano-brain');
|
|
2324
2029
|
process.exit(1);
|
|
2325
2030
|
}
|
|
2326
2031
|
try {
|
|
@@ -4094,7 +3799,7 @@ async function main() {
|
|
|
4094
3799
|
// Resolve per-workspace DB path.
|
|
4095
3800
|
// Daemon mode (serve or mcp --daemon) skips early resolution — startServer() resolves
|
|
4096
3801
|
// using the correct workspace root from config.yml instead of process.cwd()
|
|
4097
|
-
const isDaemonMode = command === '
|
|
3802
|
+
const isDaemonMode = command === 'mcp' && commandArgs.includes('--daemon');
|
|
4098
3803
|
if (command !== 'init' && command !== 'docker' && !isDaemonMode) {
|
|
4099
3804
|
globalOpts.dbPath = resolveDbPath(globalOpts.dbPath, process.cwd());
|
|
4100
3805
|
}
|
|
@@ -4102,8 +3807,6 @@ async function main() {
|
|
|
4102
3807
|
switch (command) {
|
|
4103
3808
|
case 'mcp':
|
|
4104
3809
|
return handleMcp(globalOpts, commandArgs);
|
|
4105
|
-
case 'serve':
|
|
4106
|
-
return handleServe(globalOpts, commandArgs);
|
|
4107
3810
|
case 'init':
|
|
4108
3811
|
return handleInit(globalOpts, commandArgs);
|
|
4109
3812
|
case 'collection':
|
package/src/server.ts
CHANGED
|
@@ -2908,10 +2908,19 @@ export async function startServer(options: ServerOptions): Promise<void> {
|
|
|
2908
2908
|
const server = createMcpServer(deps);
|
|
2909
2909
|
|
|
2910
2910
|
let watcher: ReturnType<typeof startWatcher> | null = null;
|
|
2911
|
+
let watcherStarted = false;
|
|
2911
2912
|
const startFileWatcher = () => {
|
|
2912
|
-
if (
|
|
2913
|
+
if (watcherStarted) {
|
|
2913
2914
|
return;
|
|
2914
2915
|
}
|
|
2916
|
+
watcherStarted = true;
|
|
2917
|
+
|
|
2918
|
+
// Stop any existing watcher before creating a new one (defensive cleanup)
|
|
2919
|
+
if (watcher) {
|
|
2920
|
+
try { watcher.stop(); } catch {}
|
|
2921
|
+
watcher = null;
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2915
2924
|
log('server', 'Starting file watcher');
|
|
2916
2925
|
|
|
2917
2926
|
// Detect overlapping workspaces
|
|
@@ -3314,6 +3323,7 @@ export async function startServer(options: ServerOptions): Promise<void> {
|
|
|
3314
3323
|
watcher.stop();
|
|
3315
3324
|
watcher = null;
|
|
3316
3325
|
}
|
|
3326
|
+
watcherStarted = false;
|
|
3317
3327
|
try {
|
|
3318
3328
|
symbolGraphDb.pragma('wal_checkpoint(TRUNCATE)');
|
|
3319
3329
|
} catch (err) {
|
package/src/store.ts
CHANGED
|
@@ -1567,11 +1567,17 @@ export function createStore(dbPath: string): Store {
|
|
|
1567
1567
|
// Table exists with correct dimensions — check consistency
|
|
1568
1568
|
const vecCount = (db.prepare('SELECT COUNT(*) as count FROM vectors_vec').get() as { count: number }).count;
|
|
1569
1569
|
const cvCount = (db.prepare('SELECT COUNT(*) as count FROM content_vectors').get() as { count: number }).count;
|
|
1570
|
-
|
|
1570
|
+
// When an external vector provider (e.g. Qdrant) is active, vectors_vec is always
|
|
1571
|
+
// empty by design — vectors live in the external store, not sqlite-vec.
|
|
1572
|
+
// Only treat empty vectors_vec as stale when using sqlite-vec as the provider.
|
|
1573
|
+
const usingExternalVectorStore = vectorStore && !(vectorStore instanceof SqliteVecStore);
|
|
1574
|
+
if (vecCount === 0 && cvCount > 0 && !usingExternalVectorStore) {
|
|
1571
1575
|
// vectors_vec was rebuilt but content_vectors has stale tracking rows
|
|
1572
1576
|
log('store', 'ensureVecTable clearing stale content_vectors count=' + cvCount);
|
|
1573
1577
|
log('store', `vectors_vec empty but content_vectors has ${cvCount} stale rows, clearing for re-embedding`, 'error');
|
|
1574
1578
|
db.exec(`DELETE FROM content_vectors`);
|
|
1579
|
+
} else if (vecCount === 0 && cvCount > 0 && usingExternalVectorStore) {
|
|
1580
|
+
log('store', `ensureVecTable: vectors_vec empty but external vector store active, skipping content_vectors clear (${cvCount} rows preserved)`);
|
|
1575
1581
|
}
|
|
1576
1582
|
return;
|
|
1577
1583
|
} catch {
|
package/src/watcher.ts
CHANGED
|
@@ -174,7 +174,7 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
174
174
|
|
|
175
175
|
const handleFileChange = (filePath: string) => {
|
|
176
176
|
if (stopped) return
|
|
177
|
-
|
|
177
|
+
|
|
178
178
|
log('watcher', 'File change detected: ' + filePath)
|
|
179
179
|
dirty = true
|
|
180
180
|
lastFileChangeAt = Date.now()
|
|
@@ -199,17 +199,17 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
199
199
|
|
|
200
200
|
const triggerReindex = async (force?: boolean): Promise<void> => {
|
|
201
201
|
if (isReindexing || stopped) return
|
|
202
|
-
|
|
202
|
+
|
|
203
203
|
if (!force && lastReindexAt && Date.now() - lastReindexAt < reindexCooldownMs) {
|
|
204
204
|
const remainingMs = reindexCooldownMs - (Date.now() - lastReindexAt)
|
|
205
205
|
const remainingMin = Math.ceil(remainingMs / 60000)
|
|
206
206
|
log('watcher', `Reindex skipped: cooldown active (${remainingMin}m remaining)`)
|
|
207
207
|
return
|
|
208
208
|
}
|
|
209
|
-
|
|
209
|
+
|
|
210
210
|
isReindexing = true
|
|
211
211
|
log('watcher', 'Starting reindex')
|
|
212
|
-
|
|
212
|
+
|
|
213
213
|
try {
|
|
214
214
|
for (const collection of collections) {
|
|
215
215
|
try {
|
|
@@ -218,10 +218,10 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
218
218
|
for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
|
|
219
219
|
const filePath = files[fileIdx];
|
|
220
220
|
if (!fs.existsSync(filePath)) continue
|
|
221
|
-
|
|
221
|
+
|
|
222
222
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
223
223
|
const hash = computeHash(content)
|
|
224
|
-
|
|
224
|
+
|
|
225
225
|
const existingDoc = store.findDocument(filePath)
|
|
226
226
|
if (!existingDoc || existingDoc.hash !== hash) {
|
|
227
227
|
const title = extractTitle(content)
|
|
@@ -230,12 +230,12 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
230
230
|
: projectHash;
|
|
231
231
|
indexDocument(store, collection.name, filePath, content, title, effectiveProjectHash)
|
|
232
232
|
}
|
|
233
|
-
|
|
233
|
+
|
|
234
234
|
activePaths.push(filePath)
|
|
235
|
-
|
|
235
|
+
|
|
236
236
|
if (fileIdx % 20 === 0) await yieldToEventLoop();
|
|
237
237
|
}
|
|
238
|
-
|
|
238
|
+
|
|
239
239
|
await yieldToEventLoop();
|
|
240
240
|
store.bulkDeactivateExcept(collection.name, activePaths)
|
|
241
241
|
await yieldToEventLoop();
|
|
@@ -243,7 +243,7 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
243
243
|
log('watcher', `Collection scan failed for ${collection.name}: ${err}`)
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
|
-
|
|
246
|
+
|
|
247
247
|
await yieldToEventLoop();
|
|
248
248
|
if (codebaseConfig?.enabled) {
|
|
249
249
|
try {
|
|
@@ -259,7 +259,7 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
259
259
|
log('watcher', `Embedding failed for primary workspace: ${err}`)
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
|
-
|
|
262
|
+
|
|
263
263
|
if (allWorkspaces && dataDir) {
|
|
264
264
|
for (const [wsPath, wsConfig] of Object.entries(allWorkspaces)) {
|
|
265
265
|
if (!wsConfig.codebase?.enabled) continue;
|
|
@@ -283,7 +283,7 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
283
283
|
}
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
|
-
|
|
286
|
+
|
|
287
287
|
dirty = false
|
|
288
288
|
pendingPaths.clear()
|
|
289
289
|
lastReindexAt = Date.now()
|
|
@@ -296,33 +296,33 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
296
296
|
const startupIntegrityCheck = async () => {
|
|
297
297
|
const health = store.getIndexHealth();
|
|
298
298
|
let mismatches = 0;
|
|
299
|
-
|
|
299
|
+
|
|
300
300
|
for (const collectionInfo of health.collections) {
|
|
301
301
|
const collection = collections.find(c => c.name === collectionInfo.name);
|
|
302
302
|
if (!collection) continue;
|
|
303
|
-
|
|
303
|
+
|
|
304
304
|
const files = await scanCollectionFiles(collection);
|
|
305
|
-
|
|
305
|
+
|
|
306
306
|
for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
|
|
307
307
|
const filePath = files[fileIdx];
|
|
308
308
|
if (!fs.existsSync(filePath)) continue;
|
|
309
|
-
|
|
309
|
+
|
|
310
310
|
const existingDoc = store.findDocument(filePath);
|
|
311
311
|
if (!existingDoc) continue;
|
|
312
|
-
|
|
312
|
+
|
|
313
313
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
314
314
|
const hash = computeHash(content);
|
|
315
|
-
|
|
315
|
+
|
|
316
316
|
if (existingDoc.hash !== hash) {
|
|
317
317
|
mismatches++;
|
|
318
318
|
dirty = true;
|
|
319
319
|
pendingPaths.add(filePath);
|
|
320
320
|
}
|
|
321
|
-
|
|
321
|
+
|
|
322
322
|
if (fileIdx % 20 === 0) await yieldToEventLoop();
|
|
323
323
|
}
|
|
324
324
|
}
|
|
325
|
-
|
|
325
|
+
|
|
326
326
|
if (mismatches > 0) {
|
|
327
327
|
log('watcher', 'Integrity check found ' + mismatches + ' mismatches')
|
|
328
328
|
log('watcher', `Integrity check: ${mismatches} file(s) need re-indexing`);
|
|
@@ -401,7 +401,7 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
401
401
|
await triggerReindex();
|
|
402
402
|
}
|
|
403
403
|
}, pollIntervalMs);
|
|
404
|
-
|
|
404
|
+
|
|
405
405
|
sessionPollInterval = setInterval(async () => {
|
|
406
406
|
if (stopped) return;
|
|
407
407
|
if (storageConfig) {
|
|
@@ -411,32 +411,32 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
411
411
|
return;
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
|
-
|
|
414
|
+
|
|
415
415
|
try {
|
|
416
416
|
const sessions = await harvestSessions({
|
|
417
417
|
sessionDir: sessionStorageDir,
|
|
418
418
|
outputDir,
|
|
419
419
|
});
|
|
420
|
-
|
|
420
|
+
|
|
421
421
|
if (sessions.length > 0) {
|
|
422
422
|
log('watcher', 'Session harvest: ' + sessions.length + ' session(s) harvested')
|
|
423
423
|
await triggerReindex();
|
|
424
424
|
}
|
|
425
|
-
|
|
425
|
+
|
|
426
426
|
if (storageConfig && dbPath) {
|
|
427
427
|
const expiredCount = evictExpiredSessions(outputDir, storageConfig.retention, store);
|
|
428
428
|
if (expiredCount > 0) {
|
|
429
429
|
log('watcher', 'Storage eviction: ' + expiredCount + ' expired session(s)')
|
|
430
430
|
log('storage', `Evicted ${expiredCount} expired session(s)`);
|
|
431
431
|
}
|
|
432
|
-
|
|
432
|
+
|
|
433
433
|
const sizeEvictedCount = evictBySize(outputDir, dbPath, storageConfig.maxSize, store);
|
|
434
434
|
if (sizeEvictedCount > 0) {
|
|
435
435
|
log('watcher', 'Storage eviction: ' + sizeEvictedCount + ' session(s) due to size limit')
|
|
436
436
|
log('storage', `Evicted ${sizeEvictedCount} session(s) due to size limit`);
|
|
437
437
|
}
|
|
438
438
|
}
|
|
439
|
-
|
|
439
|
+
|
|
440
440
|
harvestCycleCount++;
|
|
441
441
|
if (harvestCycleCount % 10 === 0) {
|
|
442
442
|
const orphansDeleted = store.cleanOrphanedEmbeddings();
|
|
@@ -445,7 +445,7 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
445
445
|
log('storage', `Cleaned ${orphansDeleted} orphaned embedding(s)`);
|
|
446
446
|
}
|
|
447
447
|
}
|
|
448
|
-
|
|
448
|
+
|
|
449
449
|
try {
|
|
450
450
|
const purged = store.purgeTelemetry(90);
|
|
451
451
|
if (purged > 0) {
|
|
@@ -460,6 +460,7 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
460
460
|
}, sessionPollMs);
|
|
461
461
|
|
|
462
462
|
if (embedder) {
|
|
463
|
+
let lastQuietSkipLogAt = 0;
|
|
463
464
|
const scheduleNextEmbedCycle = () => {
|
|
464
465
|
if (stopped) return;
|
|
465
466
|
embeddingTimeout = setTimeout(async () => {
|
|
@@ -470,8 +471,12 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
470
471
|
isEmbedding = true;
|
|
471
472
|
try {
|
|
472
473
|
if (lastFileChangeAt > 0 && Date.now() - lastFileChangeAt < embedQuietPeriodMs) {
|
|
473
|
-
const
|
|
474
|
-
|
|
474
|
+
const now = Date.now();
|
|
475
|
+
if (now - lastQuietSkipLogAt >= 60_000) {
|
|
476
|
+
const sinceSec = Math.round((now - lastFileChangeAt) / 1000)
|
|
477
|
+
log('watcher', `Embedding skipped: quiet period active (${sinceSec}s since last change, need ${Math.round(embedQuietPeriodMs / 1000)}s)`)
|
|
478
|
+
lastQuietSkipLogAt = now;
|
|
479
|
+
}
|
|
475
480
|
isEmbedding = false;
|
|
476
481
|
scheduleNextEmbedCycle();
|
|
477
482
|
return;
|
|
@@ -486,7 +491,7 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
486
491
|
for (const [wsPath, wsConfig] of Object.entries(allWorkspaces)) {
|
|
487
492
|
if (!wsConfig.codebase?.enabled) continue;
|
|
488
493
|
if (wsPath === workspaceRoot) continue;
|
|
489
|
-
|
|
494
|
+
|
|
490
495
|
try {
|
|
491
496
|
const wsStore = openWorkspaceStore(dataDir, wsPath);
|
|
492
497
|
if (!wsStore) {
|
|
@@ -549,7 +554,7 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
549
554
|
if (stopped) return;
|
|
550
555
|
learningTimeout = setTimeout(async () => {
|
|
551
556
|
if (stopped) return;
|
|
552
|
-
|
|
557
|
+
|
|
553
558
|
try {
|
|
554
559
|
const banditState = sampler.getState();
|
|
555
560
|
const flatStats = banditState.flatMap(config =>
|
|
@@ -561,11 +566,11 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
561
566
|
}))
|
|
562
567
|
);
|
|
563
568
|
store.saveBanditStats(flatStats, projectHash);
|
|
564
|
-
|
|
569
|
+
|
|
565
570
|
const configJson = JSON.stringify(sampler.selectSearchConfig());
|
|
566
571
|
const telemetryCount = store.getTelemetryCount();
|
|
567
572
|
store.saveConfigVersion(configJson, telemetryCount > 0 ? telemetryCount : null);
|
|
568
|
-
|
|
573
|
+
|
|
569
574
|
const latestVersion = store.getLatestConfigVersion();
|
|
570
575
|
if (latestVersion && latestVersion.expand_rate !== null) {
|
|
571
576
|
const prevVersion = store.getConfigVersion(latestVersion.version_id - 1);
|
|
@@ -577,7 +582,7 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
577
582
|
}
|
|
578
583
|
}
|
|
579
584
|
}
|
|
580
|
-
|
|
585
|
+
|
|
581
586
|
lastLearningRun = Date.now();
|
|
582
587
|
log('watcher', 'Learning cycle complete: saved bandit stats and config version');
|
|
583
588
|
|
|
@@ -763,17 +768,17 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
763
768
|
return {
|
|
764
769
|
stop() {
|
|
765
770
|
stopped = true;
|
|
766
|
-
|
|
771
|
+
|
|
767
772
|
if (debounceTimer) {
|
|
768
773
|
clearTimeout(debounceTimer);
|
|
769
774
|
debounceTimer = null;
|
|
770
775
|
}
|
|
771
|
-
|
|
776
|
+
|
|
772
777
|
if (pollInterval) {
|
|
773
778
|
clearInterval(pollInterval);
|
|
774
779
|
pollInterval = null;
|
|
775
780
|
}
|
|
776
|
-
|
|
781
|
+
|
|
777
782
|
if (sessionPollInterval) {
|
|
778
783
|
clearInterval(sessionPollInterval);
|
|
779
784
|
sessionPollInterval = null;
|
|
@@ -823,21 +828,21 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
823
828
|
clearTimeout(mergeTimeout);
|
|
824
829
|
mergeTimeout = null;
|
|
825
830
|
}
|
|
826
|
-
|
|
831
|
+
|
|
827
832
|
if (watcher) {
|
|
828
833
|
watcher.close();
|
|
829
834
|
watcher = null;
|
|
830
835
|
}
|
|
831
836
|
},
|
|
832
|
-
|
|
837
|
+
|
|
833
838
|
isDirty() {
|
|
834
839
|
return dirty;
|
|
835
840
|
},
|
|
836
|
-
|
|
841
|
+
|
|
837
842
|
async triggerReindex(force?: boolean) {
|
|
838
843
|
await triggerReindex(force);
|
|
839
844
|
},
|
|
840
|
-
|
|
845
|
+
|
|
841
846
|
getStats(): WatcherStats {
|
|
842
847
|
return {
|
|
843
848
|
filesWatched: watchedPaths.size,
|
|
@@ -852,13 +857,13 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
852
857
|
|
|
853
858
|
function extractTitle(content: string): string {
|
|
854
859
|
const lines = content.split('\n');
|
|
855
|
-
|
|
860
|
+
|
|
856
861
|
for (const line of lines) {
|
|
857
862
|
const trimmed = line.trim();
|
|
858
863
|
if (trimmed.startsWith('# ')) {
|
|
859
864
|
return trimmed.substring(2).trim();
|
|
860
865
|
}
|
|
861
866
|
}
|
|
862
|
-
|
|
867
|
+
|
|
863
868
|
return 'Untitled';
|
|
864
869
|
}
|
package/src/service-installer.ts
DELETED
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
|
-
import * as path from 'path';
|
|
3
|
-
import * as os from 'os';
|
|
4
|
-
import { execSync } from 'child_process';
|
|
5
|
-
|
|
6
|
-
export type Platform = 'macos' | 'linux' | 'unsupported';
|
|
7
|
-
|
|
8
|
-
export function detectPlatform(): Platform {
|
|
9
|
-
if (process.platform === 'darwin') return 'macos';
|
|
10
|
-
if (process.platform === 'linux') return 'linux';
|
|
11
|
-
return 'unsupported';
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface ServiceConfig {
|
|
15
|
-
port: number;
|
|
16
|
-
nodePath: string;
|
|
17
|
-
cliPath: string;
|
|
18
|
-
homeDir: string;
|
|
19
|
-
logsDir: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function getDefaultServiceConfig(): ServiceConfig {
|
|
23
|
-
const homeDir = os.homedir();
|
|
24
|
-
const logsDir = path.join(homeDir, '.nano-brain', 'logs');
|
|
25
|
-
|
|
26
|
-
// Resolve a stable npx path — avoid ephemeral npx cache paths
|
|
27
|
-
let npxPath: string;
|
|
28
|
-
try {
|
|
29
|
-
npxPath = execSync('which npx', { encoding: 'utf-8' }).trim();
|
|
30
|
-
} catch {
|
|
31
|
-
npxPath = path.join(path.dirname(process.execPath), 'npx');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
port: 3100,
|
|
36
|
-
nodePath: npxPath,
|
|
37
|
-
cliPath: 'nano-brain', // npx will resolve this to the latest installed version
|
|
38
|
-
homeDir,
|
|
39
|
-
logsDir,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function generateLaunchdPlist(config: ServiceConfig): string {
|
|
44
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
45
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
46
|
-
<plist version="1.0">
|
|
47
|
-
<dict>
|
|
48
|
-
<key>Label</key>
|
|
49
|
-
<string>com.nano-brain.server</string>
|
|
50
|
-
<key>ProgramArguments</key>
|
|
51
|
-
<array>
|
|
52
|
-
<string>${config.nodePath}</string>
|
|
53
|
-
<string>${config.cliPath}</string>
|
|
54
|
-
<string>serve</string>
|
|
55
|
-
<string>--port</string>
|
|
56
|
-
<string>${config.port}</string>
|
|
57
|
-
</array>
|
|
58
|
-
<key>KeepAlive</key>
|
|
59
|
-
<true/>
|
|
60
|
-
<key>RunAtLoad</key>
|
|
61
|
-
<true/>
|
|
62
|
-
<key>StandardOutPath</key>
|
|
63
|
-
<string>${config.logsDir}/server.log</string>
|
|
64
|
-
<key>StandardErrorPath</key>
|
|
65
|
-
<string>${config.logsDir}/server.err</string>
|
|
66
|
-
<key>WorkingDirectory</key>
|
|
67
|
-
<string>${config.homeDir}</string>
|
|
68
|
-
<key>EnvironmentVariables</key>
|
|
69
|
-
<dict>
|
|
70
|
-
<key>PATH</key>
|
|
71
|
-
<string>/usr/local/bin:/usr/bin:/bin:${path.dirname(config.nodePath)}</string>
|
|
72
|
-
</dict>
|
|
73
|
-
<key>ThrottleInterval</key>
|
|
74
|
-
<integer>30</integer>
|
|
75
|
-
</dict>
|
|
76
|
-
</plist>
|
|
77
|
-
`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function generateSystemdService(config: ServiceConfig): string {
|
|
81
|
-
return `[Unit]
|
|
82
|
-
Description=nano-brain MCP server
|
|
83
|
-
After=network.target
|
|
84
|
-
|
|
85
|
-
[Service]
|
|
86
|
-
Type=simple
|
|
87
|
-
ExecStart=${config.nodePath} ${config.cliPath} serve --port ${config.port}
|
|
88
|
-
Restart=always
|
|
89
|
-
RestartSec=2
|
|
90
|
-
StartLimitBurst=5
|
|
91
|
-
StartLimitIntervalSec=600
|
|
92
|
-
WorkingDirectory=${config.homeDir}
|
|
93
|
-
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${path.dirname(config.nodePath)}
|
|
94
|
-
StandardOutput=append:${config.logsDir}/server.log
|
|
95
|
-
StandardError=append:${config.logsDir}/server.err
|
|
96
|
-
|
|
97
|
-
[Install]
|
|
98
|
-
WantedBy=default.target
|
|
99
|
-
`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function getLaunchdPlistPath(): string {
|
|
103
|
-
return path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.nano-brain.server.plist');
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function getSystemdServicePath(): string {
|
|
107
|
-
return path.join(os.homedir(), '.config', 'systemd', 'user', 'nano-brain.service');
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export interface InstallResult {
|
|
111
|
-
success: boolean;
|
|
112
|
-
path: string;
|
|
113
|
-
message: string;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function installService(options: { force?: boolean; port?: number } = {}): InstallResult {
|
|
117
|
-
const platform = detectPlatform();
|
|
118
|
-
|
|
119
|
-
if (platform === 'unsupported') {
|
|
120
|
-
return {
|
|
121
|
-
success: false,
|
|
122
|
-
path: '',
|
|
123
|
-
message: `Unsupported platform: ${process.platform}. Only macOS and Linux are supported.`,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const config = getDefaultServiceConfig();
|
|
128
|
-
if (options.port) {
|
|
129
|
-
config.port = options.port;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
fs.mkdirSync(config.logsDir, { recursive: true });
|
|
133
|
-
|
|
134
|
-
if (platform === 'macos') {
|
|
135
|
-
const plistPath = getLaunchdPlistPath();
|
|
136
|
-
|
|
137
|
-
if (fs.existsSync(plistPath) && !options.force) {
|
|
138
|
-
return {
|
|
139
|
-
success: false,
|
|
140
|
-
path: plistPath,
|
|
141
|
-
message: `Service already installed at ${plistPath}. Use --force to overwrite.`,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
146
|
-
const plistContent = generateLaunchdPlist(config);
|
|
147
|
-
fs.writeFileSync(plistPath, plistContent);
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
const uid = execSync('id -u', { encoding: 'utf-8' }).trim();
|
|
151
|
-
execSync(`launchctl bootstrap gui/${uid} "${plistPath}"`, { stdio: 'pipe' });
|
|
152
|
-
} catch {
|
|
153
|
-
try {
|
|
154
|
-
execSync(`launchctl load "${plistPath}"`, { stdio: 'pipe' });
|
|
155
|
-
} catch {}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return {
|
|
159
|
-
success: true,
|
|
160
|
-
path: plistPath,
|
|
161
|
-
message: `Service installed at ${plistPath}`,
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const servicePath = getSystemdServicePath();
|
|
166
|
-
|
|
167
|
-
if (fs.existsSync(servicePath) && !options.force) {
|
|
168
|
-
return {
|
|
169
|
-
success: false,
|
|
170
|
-
path: servicePath,
|
|
171
|
-
message: `Service already installed at ${servicePath}. Use --force to overwrite.`,
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
fs.mkdirSync(path.dirname(servicePath), { recursive: true });
|
|
176
|
-
const serviceContent = generateSystemdService(config);
|
|
177
|
-
fs.writeFileSync(servicePath, serviceContent);
|
|
178
|
-
|
|
179
|
-
try {
|
|
180
|
-
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
|
|
181
|
-
execSync('systemctl --user enable nano-brain', { stdio: 'pipe' });
|
|
182
|
-
execSync('systemctl --user start nano-brain', { stdio: 'pipe' });
|
|
183
|
-
} catch {
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
success: true,
|
|
188
|
-
path: servicePath,
|
|
189
|
-
message: `Service installed at ${servicePath}`,
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export function uninstallService(): InstallResult {
|
|
194
|
-
const platform = detectPlatform();
|
|
195
|
-
|
|
196
|
-
if (platform === 'unsupported') {
|
|
197
|
-
return {
|
|
198
|
-
success: false,
|
|
199
|
-
path: '',
|
|
200
|
-
message: `Unsupported platform: ${process.platform}. Only macOS and Linux are supported.`,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (platform === 'macos') {
|
|
205
|
-
const plistPath = getLaunchdPlistPath();
|
|
206
|
-
|
|
207
|
-
if (!fs.existsSync(plistPath)) {
|
|
208
|
-
return {
|
|
209
|
-
success: false,
|
|
210
|
-
path: plistPath,
|
|
211
|
-
message: `Service not installed at ${plistPath}`,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
try {
|
|
216
|
-
const uid = execSync('id -u', { encoding: 'utf-8' }).trim();
|
|
217
|
-
execSync(`launchctl bootout gui/${uid}/com.nano-brain.server`, { stdio: 'pipe' });
|
|
218
|
-
} catch {
|
|
219
|
-
try {
|
|
220
|
-
execSync(`launchctl unload "${plistPath}"`, { stdio: 'pipe' });
|
|
221
|
-
} catch {}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
fs.unlinkSync(plistPath);
|
|
225
|
-
|
|
226
|
-
return {
|
|
227
|
-
success: true,
|
|
228
|
-
path: plistPath,
|
|
229
|
-
message: `Service uninstalled from ${plistPath}`,
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const servicePath = getSystemdServicePath();
|
|
234
|
-
|
|
235
|
-
if (!fs.existsSync(servicePath)) {
|
|
236
|
-
return {
|
|
237
|
-
success: false,
|
|
238
|
-
path: servicePath,
|
|
239
|
-
message: `Service not installed at ${servicePath}`,
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
try {
|
|
244
|
-
execSync('systemctl --user disable --now nano-brain', { stdio: 'pipe' });
|
|
245
|
-
} catch {
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
fs.unlinkSync(servicePath);
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
|
|
252
|
-
} catch {
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return {
|
|
256
|
-
success: true,
|
|
257
|
-
path: servicePath,
|
|
258
|
-
message: `Service uninstalled from ${servicePath}`,
|
|
259
|
-
};
|
|
260
|
-
}
|