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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nano-brain",
3
- "version": "2026.7.2",
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 = DEFAULT_HTTP_PORT): Promise<boolean> {
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://localhost:${port}/health`, { signal: controller.signal });
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 resp = await fetch(`http://localhost:${port}${path}`);
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 resp = await fetch(`http://localhost:${port}${path}`, {
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
- async function detectRunningServerContainer(port: number = DEFAULT_HTTP_PORT): Promise<boolean> {
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('Error: Daemon not running. Start it on the host:');
1415
- cliError(' npx nano-brain serve install && launchctl load ~/Library/LaunchAgents/com.nano-brain.server.plist');
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('Error: Daemon not running. Start it on the host:');
1559
- cliError(' npx nano-brain serve install && launchctl load ~/Library/LaunchAgents/com.nano-brain.server.plist');
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('Error: Daemon not running. Start it on the host:');
1726
- cliError(' npx nano-brain serve install && launchctl load ~/Library/LaunchAgents/com.nano-brain.server.plist');
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('Error: Daemon not running. Start it on the host:');
2323
- cliError(' npx nano-brain serve install && launchctl load ~/Library/LaunchAgents/com.nano-brain.server.plist');
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 === 'serve' || (command === 'mcp' && commandArgs.includes('--daemon'));
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 (watcher) {
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
- if (vecCount === 0 && cvCount > 0) {
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 sinceSec = Math.round((Date.now() - lastFileChangeAt) / 1000)
474
- log('watcher', `Embedding skipped: quiet period active (${sinceSec}s since last change, need ${Math.round(embedQuietPeriodMs / 1000)}s)`)
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
  }
@@ -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
- }