wicked-brain 0.1.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/install.mjs +57 -8
  2. package/package.json +1 -1
  3. package/server/bin/wicked-brain-server.mjs +54 -7
  4. package/server/lib/file-watcher.mjs +102 -5
  5. package/server/lib/lsp-client.mjs +278 -0
  6. package/server/lib/lsp-helpers.mjs +133 -0
  7. package/server/lib/lsp-manager.mjs +164 -0
  8. package/server/lib/lsp-protocol.mjs +123 -0
  9. package/server/lib/lsp-servers.mjs +290 -0
  10. package/server/lib/sqlite-search.mjs +216 -10
  11. package/server/lib/wikilinks.mjs +20 -4
  12. package/server/package.json +1 -1
  13. package/skills/wicked-brain-agent/SKILL.md +52 -0
  14. package/skills/wicked-brain-agent/agents/consolidate.md +138 -0
  15. package/skills/wicked-brain-agent/agents/context.md +88 -0
  16. package/skills/wicked-brain-agent/agents/onboard.md +88 -0
  17. package/skills/wicked-brain-agent/agents/session-teardown.md +84 -0
  18. package/skills/wicked-brain-agent/hooks/claude-hooks.json +12 -0
  19. package/skills/wicked-brain-agent/hooks/copilot-hooks.json +10 -0
  20. package/skills/wicked-brain-agent/hooks/gemini-hooks.json +12 -0
  21. package/skills/wicked-brain-agent/platform/antigravity/wicked-brain-consolidate.md +103 -0
  22. package/skills/wicked-brain-agent/platform/antigravity/wicked-brain-context.md +67 -0
  23. package/skills/wicked-brain-agent/platform/antigravity/wicked-brain-onboard.md +74 -0
  24. package/skills/wicked-brain-agent/platform/antigravity/wicked-brain-session-teardown.md +72 -0
  25. package/skills/wicked-brain-agent/platform/claude/wicked-brain-consolidate.md +106 -0
  26. package/skills/wicked-brain-agent/platform/claude/wicked-brain-context.md +70 -0
  27. package/skills/wicked-brain-agent/platform/claude/wicked-brain-onboard.md +77 -0
  28. package/skills/wicked-brain-agent/platform/claude/wicked-brain-session-teardown.md +75 -0
  29. package/skills/wicked-brain-agent/platform/codex/wicked-brain-consolidate.toml +104 -0
  30. package/skills/wicked-brain-agent/platform/codex/wicked-brain-context.toml +68 -0
  31. package/skills/wicked-brain-agent/platform/codex/wicked-brain-onboard.toml +75 -0
  32. package/skills/wicked-brain-agent/platform/codex/wicked-brain-session-teardown.toml +73 -0
  33. package/skills/wicked-brain-agent/platform/copilot/wicked-brain-consolidate.agent.md +105 -0
  34. package/skills/wicked-brain-agent/platform/copilot/wicked-brain-context.agent.md +69 -0
  35. package/skills/wicked-brain-agent/platform/copilot/wicked-brain-onboard.agent.md +76 -0
  36. package/skills/wicked-brain-agent/platform/copilot/wicked-brain-session-teardown.agent.md +74 -0
  37. package/skills/wicked-brain-agent/platform/cursor/wicked-brain-consolidate.md +104 -0
  38. package/skills/wicked-brain-agent/platform/cursor/wicked-brain-context.md +68 -0
  39. package/skills/wicked-brain-agent/platform/cursor/wicked-brain-onboard.md +75 -0
  40. package/skills/wicked-brain-agent/platform/cursor/wicked-brain-session-teardown.md +73 -0
  41. package/skills/wicked-brain-agent/platform/gemini/wicked-brain-consolidate.md +107 -0
  42. package/skills/wicked-brain-agent/platform/gemini/wicked-brain-context.md +71 -0
  43. package/skills/wicked-brain-agent/platform/gemini/wicked-brain-onboard.md +78 -0
  44. package/skills/wicked-brain-agent/platform/gemini/wicked-brain-session-teardown.md +76 -0
  45. package/skills/wicked-brain-agent/platform/kiro/wicked-brain-consolidate.json +17 -0
  46. package/skills/wicked-brain-agent/platform/kiro/wicked-brain-context.json +16 -0
  47. package/skills/wicked-brain-agent/platform/kiro/wicked-brain-onboard.json +17 -0
  48. package/skills/wicked-brain-agent/platform/kiro/wicked-brain-session-teardown.json +17 -0
  49. package/skills/wicked-brain-compile/SKILL.md +8 -0
  50. package/skills/wicked-brain-configure/SKILL.md +99 -0
  51. package/skills/wicked-brain-enhance/SKILL.md +19 -0
  52. package/skills/wicked-brain-ingest/SKILL.md +68 -5
  53. package/skills/wicked-brain-lint/SKILL.md +14 -0
  54. package/skills/wicked-brain-lsp/SKILL.md +172 -0
  55. package/skills/wicked-brain-memory/SKILL.md +144 -0
  56. package/skills/wicked-brain-query/SKILL.md +78 -1
  57. package/skills/wicked-brain-retag/SKILL.md +79 -0
  58. package/skills/wicked-brain-search/SKILL.md +3 -11
  59. package/skills/wicked-brain-status/SKILL.md +7 -0
  60. package/skills/wicked-brain-update/SKILL.md +20 -1
package/install.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // wicked-brain installer — detects CLIs and installs skills
2
+ // wicked-brain installer — detects CLIs and installs skills + agents
3
3
 
4
4
  import { existsSync, mkdirSync, cpSync, readdirSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
@@ -12,11 +12,13 @@ const skillsSource = join(__dirname, "skills");
12
12
  const home = homedir();
13
13
 
14
14
  const CLI_TARGETS = [
15
- { name: "claude", dir: join(home, ".claude", "skills") },
16
- { name: "gemini", dir: join(home, ".gemini", "skills") },
17
- { name: "copilot", dir: join(home, ".github", "skills") },
18
- { name: "codex", dir: join(home, ".codex", "skills") },
19
- { name: "cursor", dir: join(home, ".cursor", "skills") },
15
+ { name: "claude", dir: join(home, ".claude", "skills"), agentDir: join(home, ".claude", "agents"), platform: "claude" },
16
+ { name: "gemini", dir: join(home, ".gemini", "skills"), agentDir: join(home, ".gemini", "agents"), platform: "gemini" },
17
+ { name: "copilot", dir: join(home, ".github", "skills"), agentDir: join(home, ".github", "agents"), platform: "copilot" },
18
+ { name: "codex", dir: join(home, ".codex", "skills"), agentDir: join(home, ".codex", "agents"), platform: "codex" },
19
+ { name: "cursor", dir: join(home, ".cursor", "skills"), agentDir: join(home, ".cursor", "agents"), platform: "cursor" },
20
+ { name: "kiro", dir: join(home, ".kiro", "skills"), agentDir: join(home, ".kiro", "agents"), platform: "kiro" },
21
+ { name: "antigravity", dir: join(home, ".antigravity", "skills"), agentDir: join(home, ".antigravity", "rules"), platform: "antigravity" },
20
22
  ];
21
23
 
22
24
  // Detect which CLIs are installed by checking if parent dir exists
@@ -28,7 +30,7 @@ const detected = CLI_TARGETS.filter((t) => {
28
30
  console.log("wicked-brain installer\n");
29
31
 
30
32
  if (detected.length === 0) {
31
- console.log("No supported AI CLIs detected. Supported: claude, gemini, copilot, codex, cursor");
33
+ console.log("No supported AI CLIs detected. Supported: claude, gemini, copilot, codex, cursor, kiro, antigravity");
32
34
  console.log("Install skills manually by copying the skills/ directory.");
33
35
  process.exit(1);
34
36
  }
@@ -36,7 +38,8 @@ if (detected.length === 0) {
36
38
  console.log(`Detected CLIs: ${detected.map((d) => d.name).join(", ")}\n`);
37
39
 
38
40
  // Allow filtering via --cli flag
39
- const cliArg = argv.find((a) => a.startsWith("--cli="));
41
+ const args = argv.slice(2);
42
+ const cliArg = args.find((a) => a.startsWith("--cli="));
40
43
  const cliFilter = cliArg ? cliArg.split("=")[1].split(",") : null;
41
44
  const targets = cliFilter
42
45
  ? detected.filter((d) => cliFilter.includes(d.name))
@@ -58,6 +61,52 @@ for (const target of targets) {
58
61
  console.log(` ${skillDirs.length} skills installed`);
59
62
  }
60
63
 
64
+ // Copy platform-specific agents
65
+ const agentsSource = join(__dirname, "skills", "wicked-brain-agent", "platform");
66
+
67
+ for (const target of targets) {
68
+ const platformDir = join(agentsSource, target.platform);
69
+ if (!existsSync(platformDir)) {
70
+ console.log(` No agent definitions for ${target.name}, skipping agents`);
71
+ continue;
72
+ }
73
+
74
+ if (!target.agentDir) continue;
75
+
76
+ mkdirSync(target.agentDir, { recursive: true });
77
+ const agentFiles = readdirSync(platformDir);
78
+ let agentCount = 0;
79
+
80
+ for (const file of agentFiles) {
81
+ const src = join(platformDir, file);
82
+ const dest = join(target.agentDir, file);
83
+ cpSync(src, dest, { force: true });
84
+ agentCount++;
85
+ }
86
+
87
+ console.log(` Installed ${agentCount} agents to ${target.agentDir}`);
88
+ }
89
+
90
+ // Optional hook installation (--hooks flag)
91
+ const installHooks = args.includes("--hooks");
92
+
93
+ if (installHooks) {
94
+ console.log("\nInstalling hooks...");
95
+ const hooksSource = join(__dirname, "skills", "wicked-brain-agent", "hooks");
96
+
97
+ for (const target of targets) {
98
+ const hookFile = join(hooksSource, `${target.platform}-hooks.json`);
99
+ if (!existsSync(hookFile)) {
100
+ console.log(` No hook template for ${target.name}, skipping`);
101
+ continue;
102
+ }
103
+ // Note: hook installation is platform-specific and may need merging
104
+ // with existing hooks. For now, just report what would be installed.
105
+ console.log(` Hook template available for ${target.name}: ${hookFile}`);
106
+ console.log(` To install: merge into your ${target.name} hook config manually`);
107
+ }
108
+ }
109
+
61
110
  // Server binary is bundled — npx wicked-brain-server works automatically
62
111
  // Skills reference it as: npx wicked-brain-server --brain {path} --port {port}
63
112
  console.log("\nServer: bundled (use 'npx wicked-brain-server' to start)");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain",
3
- "version": "0.1.2",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Digital brain as skills for AI coding CLIs — no vector DB, no embeddings, no infrastructure",
6
6
  "keywords": [
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
5
5
  import { argv, pid, exit } from "node:process";
6
6
  import { FileWatcher } from "../lib/file-watcher.mjs";
7
7
  import { SqliteSearch } from "../lib/sqlite-search.mjs";
8
+ import { LspClient } from "../lib/lsp-client.mjs";
8
9
 
9
10
  // Parse args
10
11
  const args = argv.slice(2);
@@ -26,25 +27,32 @@ try {
26
27
  console.error(`Warning: Could not read brain.json at ${configPath}`);
27
28
  }
28
29
 
30
+ // Ensure required directories exist
31
+ mkdirSync(join(brainPath, "_meta"), { recursive: true });
32
+ mkdirSync(join(brainPath, "memory"), { recursive: true });
33
+
29
34
  // Initialize SQLite
30
35
  const dbPath = join(brainPath, ".brain.db");
31
- mkdirSync(join(brainPath, "_meta"), { recursive: true });
32
36
  const db = new SqliteSearch(dbPath, brainId);
33
37
 
34
38
  // PID file
35
39
  const pidPath = join(brainPath, "_meta", "server.pid");
36
40
  writeFileSync(pidPath, String(pid));
37
41
 
42
+ // LSP client
43
+ const lsp = new LspClient(brainPath, db);
44
+
38
45
  // Graceful shutdown
39
- function shutdown() {
46
+ async function shutdown() {
40
47
  console.log("Shutting down...");
41
48
  try { unlinkSync(pidPath); } catch {}
42
49
  watcher.stop();
50
+ await lsp.shutdown();
43
51
  db.close();
44
52
  exit(0);
45
53
  }
46
- process.on("SIGTERM", shutdown);
47
- process.on("SIGINT", shutdown);
54
+ process.on("SIGTERM", () => shutdown());
55
+ process.on("SIGINT", () => shutdown());
48
56
 
49
57
  // Action dispatch
50
58
  const actions = {
@@ -57,6 +65,21 @@ const actions = {
57
65
  backlinks: (p) => ({ links: db.backlinks(p.id) }),
58
66
  forward_links: (p) => ({ links: db.forwardLinks(p.id) }),
59
67
  stats: () => db.stats(),
68
+ candidates: (p) => ({ candidates: db.candidates(p) }),
69
+ access_log: (p) => db.accessLog(p.id),
70
+ recent_memories: (p) => ({ memories: db.recentMemories(p) }),
71
+ contradictions: () => ({ links: db.contradictions() }),
72
+ // LSP actions
73
+ "lsp-health": () => lsp.health(),
74
+ "lsp-symbols": (p) => lsp.symbols(p),
75
+ "lsp-definition": (p) => lsp.definition(p),
76
+ "lsp-references": (p) => lsp.references(p),
77
+ "lsp-hover": (p) => lsp.hover(p),
78
+ "lsp-implementation": (p) => lsp.implementation(p),
79
+ "lsp-workspace-symbols": (p) => lsp.workspaceSymbols(p),
80
+ "lsp-call-hierarchy-in": (p) => lsp.callHierarchyIn(p),
81
+ "lsp-call-hierarchy-out": (p) => lsp.callHierarchyOut(p),
82
+ "lsp-diagnostics": (p) => lsp.diagnostics(p),
60
83
  };
61
84
 
62
85
  // HTTP server
@@ -77,9 +100,21 @@ const server = createServer((req, res) => {
77
100
  res.end(JSON.stringify({ error: `Unknown action: ${action}` }));
78
101
  return;
79
102
  }
103
+ // Handle both sync and async results
80
104
  const result = handler(params);
81
- res.writeHead(200, { "Content-Type": "application/json" });
82
- res.end(JSON.stringify(result ?? { ok: true }));
105
+ Promise.resolve(result)
106
+ .then(r => {
107
+ res.writeHead(200, { "Content-Type": "application/json" });
108
+ res.end(JSON.stringify(r ?? { ok: true }));
109
+ })
110
+ .catch(err => {
111
+ res.writeHead(200, { "Content-Type": "application/json" });
112
+ res.end(JSON.stringify({
113
+ error: err.message,
114
+ language: err.language,
115
+ install: err.install,
116
+ }));
117
+ });
83
118
  } catch (err) {
84
119
  res.writeHead(500, { "Content-Type": "application/json" });
85
120
  res.end(JSON.stringify({ error: err.message }));
@@ -87,7 +122,19 @@ const server = createServer((req, res) => {
87
122
  });
88
123
  });
89
124
 
90
- const watcher = new FileWatcher(brainPath, db, brainId);
125
+ // Read project directories from config
126
+ let projects = [];
127
+ try {
128
+ const metaConfig = JSON.parse(readFileSync(join(brainPath, "_meta", "config.json"), "utf-8"));
129
+ projects = metaConfig.projects || [];
130
+ } catch {}
131
+
132
+ const watcher = new FileWatcher(brainPath, db, brainId, projects);
133
+
134
+ // Wire file changes to LSP client for didOpen/didChange/didClose
135
+ watcher.onFileChange((relPath, absPath, content, eventType) => {
136
+ lsp.handleFileChange(relPath, absPath, content, eventType);
137
+ });
91
138
 
92
139
  server.listen(port, () => {
93
140
  console.log(`wicked-brain-server running on port ${port} (brain: ${brainId}, pid: ${pid})`);
@@ -6,6 +6,13 @@ function normalizePath(p) {
6
6
  return p.replace(/\\/g, "/");
7
7
  }
8
8
 
9
+ const IGNORE_DIRS = new Set([
10
+ "node_modules", ".git", "__pycache__", ".venv", "venv",
11
+ "target", "dist", "build", ".next", ".nuxt", "coverage",
12
+ ".idea", ".vscode", ".vs", "bin", "obj", ".cache",
13
+ ".gradle", ".mvn", ".terraform"
14
+ ]);
15
+
9
16
  export class FileWatcher {
10
17
  #brainPath;
11
18
  #db;
@@ -14,20 +21,28 @@ export class FileWatcher {
14
21
  #watchers = [];
15
22
  #debounceTimers = new Map();
16
23
  #pollInterval = null;
24
+ #onChangeCallbacks = [];
25
+ #projects = [];
17
26
 
18
- constructor(brainPath, db, brainId) {
27
+ constructor(brainPath, db, brainId, projects = []) {
19
28
  this.#brainPath = brainPath;
20
29
  this.#db = db;
21
30
  this.#brainId = brainId;
31
+ this.#projects = projects;
32
+ }
33
+
34
+ onFileChange(callback) {
35
+ this.#onChangeCallbacks.push(callback);
22
36
  }
23
37
 
24
38
  start() {
25
39
  // Build initial hash map
26
40
  this.#scanAndHash("chunks");
27
41
  this.#scanAndHash("wiki");
42
+ this.#scanAndHash("memory");
28
43
 
29
44
  // Watch directories
30
- for (const dir of ["chunks", "wiki"]) {
45
+ for (const dir of ["chunks", "wiki", "memory"]) {
31
46
  const absDir = join(this.#brainPath, dir);
32
47
  if (!existsSync(absDir)) continue;
33
48
 
@@ -43,11 +58,29 @@ export class FileWatcher {
43
58
  }
44
59
  }
45
60
 
61
+ // Watch registered project directories
62
+ for (const project of this.#projects) {
63
+ if (!existsSync(project.path)) continue;
64
+ this.#scanAndHashProject(project);
65
+ try {
66
+ const watcher = watch(project.path, { recursive: true }, (eventType, filename) => {
67
+ if (!filename) return;
68
+ const parts = filename.split(/[/\\]/);
69
+ if (parts.some(p => IGNORE_DIRS.has(p))) return;
70
+ const relPath = normalizePath(`projects/${project.name}/${filename}`);
71
+ this.#debounce(relPath, () => this.#handleProjectChange(project, filename));
72
+ });
73
+ this.#watchers.push(watcher);
74
+ } catch {
75
+ // recursive watch not supported — polling fallback already handles this
76
+ }
77
+ }
78
+
46
79
  // If no watchers were set up (Linux), use polling fallback
47
80
  if (this.#watchers.length === 0) {
48
81
  this.#startPolling();
49
82
  } else {
50
- console.log(`File watcher active on chunks/ and wiki/`);
83
+ console.log(`File watcher active on chunks/, wiki/, and memory/`);
51
84
  }
52
85
  }
53
86
 
@@ -72,10 +105,25 @@ export class FileWatcher {
72
105
  });
73
106
  }
74
107
 
108
+ #scanAndHashProject(project) {
109
+ this.#walkDir(project.path, (absPath) => {
110
+ const parts = absPath.split(/[/\\]/);
111
+ if (parts.some(p => IGNORE_DIRS.has(p))) return;
112
+ if (!absPath.endsWith(".md") && !this.#isCodeFile(absPath)) return;
113
+ const relPath = normalizePath(`projects/${project.name}/${relative(project.path, absPath)}`);
114
+ const content = readFileSync(absPath, "utf-8");
115
+ const hash = this.#hash(content);
116
+ this.#hashes.set(relPath, hash);
117
+ });
118
+ }
119
+
75
120
  #walkDir(dir, callback) {
76
121
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
77
122
  const full = join(dir, entry.name);
78
- if (entry.isDirectory()) this.#walkDir(full, callback);
123
+ if (entry.isDirectory()) {
124
+ if (IGNORE_DIRS.has(entry.name)) continue;
125
+ this.#walkDir(full, callback);
126
+ }
79
127
  else if (entry.isFile()) callback(full);
80
128
  }
81
129
  }
@@ -89,6 +137,9 @@ export class FileWatcher {
89
137
  this.#hashes.delete(relPath);
90
138
  this.#db.remove(relPath);
91
139
  console.log(`[watcher] Removed from index: ${relPath}`);
140
+ for (const cb of this.#onChangeCallbacks) {
141
+ try { cb(relPath, absPath, null, "delete"); } catch {}
142
+ }
92
143
  }
93
144
  return;
94
145
  }
@@ -108,15 +159,61 @@ export class FileWatcher {
108
159
  brain_id: this.#brainId,
109
160
  });
110
161
  console.log(`[watcher] Reindexed: ${relPath}`);
162
+ for (const cb of this.#onChangeCallbacks) {
163
+ try { cb(relPath, absPath, content, "change"); } catch {}
164
+ }
111
165
  } catch {
112
166
  // File might be mid-write, ignore
113
167
  }
114
168
  }
115
169
 
170
+ #handleProjectChange(project, filename) {
171
+ const absPath = join(project.path, filename);
172
+ const relPath = normalizePath(`projects/${project.name}/${filename}`);
173
+
174
+ if (!existsSync(absPath)) {
175
+ if (this.#hashes.has(relPath)) {
176
+ this.#hashes.delete(relPath);
177
+ this.#db.remove(relPath);
178
+ console.log(`[watcher] Removed from index: ${relPath}`);
179
+ for (const cb of this.#onChangeCallbacks) {
180
+ try { cb(relPath, absPath, null, "delete"); } catch {}
181
+ }
182
+ }
183
+ return;
184
+ }
185
+
186
+ try {
187
+ const content = readFileSync(absPath, "utf-8");
188
+ const newHash = this.#hash(content);
189
+ const oldHash = this.#hashes.get(relPath);
190
+ if (newHash === oldHash) return;
191
+
192
+ this.#hashes.set(relPath, newHash);
193
+ this.#db.index({
194
+ id: relPath,
195
+ path: relPath,
196
+ content,
197
+ brain_id: this.#brainId,
198
+ });
199
+ console.log(`[watcher] Reindexed: ${relPath}`);
200
+ for (const cb of this.#onChangeCallbacks) {
201
+ try { cb(relPath, absPath, content, "change"); } catch {}
202
+ }
203
+ } catch {}
204
+ }
205
+
206
+ #isCodeFile(absPath) {
207
+ const dot = absPath.lastIndexOf(".");
208
+ if (dot === -1) return false;
209
+ const ext = absPath.slice(dot);
210
+ return ext.length <= 6;
211
+ }
212
+
116
213
  #startPolling() {
117
214
  console.log("File watcher using polling mode (recursive watch not available)");
118
215
  this.#pollInterval = setInterval(() => {
119
- for (const dir of ["chunks", "wiki"]) {
216
+ for (const dir of ["chunks", "wiki", "memory"]) {
120
217
  const absDir = join(this.#brainPath, dir);
121
218
  if (!existsSync(absDir)) continue;
122
219
  this.#walkDir(absDir, (absPath) => {
@@ -0,0 +1,278 @@
1
+ /**
2
+ * LSP Client — orchestrates language server actions, file sync, and caching.
3
+ * Uses LspManager for server lifecycle and RpcClient for protocol.
4
+ */
5
+
6
+ import { extname } from "node:path";
7
+ import { readFileSync } from "node:fs";
8
+ import { pathToFileURL } from "node:url";
9
+ import { LspManager } from "./lsp-manager.mjs";
10
+ import { resolveServer, loadUserConfig } from "./lsp-servers.mjs";
11
+ import {
12
+ normalizeLocations, normalizeSymbols, symbolKindName,
13
+ severityName, buildSymbolChunk, buildDiagnosticsChunk,
14
+ } from "./lsp-helpers.mjs";
15
+
16
+ export class LspClient {
17
+ #brainPath;
18
+ #db;
19
+ #manager;
20
+ #userConfig;
21
+ #diagnostics = new Map(); // filePath → Diagnostic[]
22
+ #diagnosticsSetup = new Set(); // server keys with diagnostics wired
23
+
24
+ constructor(brainPath, db) {
25
+ this.#brainPath = brainPath;
26
+ this.#db = db;
27
+ this.#manager = new LspManager(brainPath);
28
+ this.#userConfig = loadUserConfig(brainPath);
29
+ }
30
+
31
+ /** Resolve file extension to server config, or throw. */
32
+ #resolveFile(file) {
33
+ const ext = extname(file);
34
+ const server = resolveServer(ext, this.#userConfig);
35
+ if (!server) {
36
+ throw Object.assign(new Error("unsupported_language"), { extension: ext });
37
+ }
38
+ return server;
39
+ }
40
+
41
+ /** Ensure server is running and file is opened. */
42
+ async #ensureReady(file) {
43
+ const server = this.#resolveFile(file);
44
+ const entry = await this.#manager.ensureServer(server.key, server);
45
+
46
+ // Wire diagnostics once per server
47
+ if (!this.#diagnosticsSetup.has(server.key)) {
48
+ this.#setupDiagnostics(server.key, entry);
49
+ this.#diagnosticsSetup.add(server.key);
50
+ }
51
+
52
+ // Open file if not already opened
53
+ if (!entry.openFiles.has(file)) {
54
+ const content = readFileSync(file, "utf-8");
55
+ const uri = pathToFileURL(file).href;
56
+ entry.client.notify("textDocument/didOpen", {
57
+ textDocument: { uri, languageId: server.key, version: 1, text: content }
58
+ });
59
+ entry.openFiles.add(file);
60
+ }
61
+ return { entry, server };
62
+ }
63
+
64
+ /** Handle file change from FileWatcher. */
65
+ handleFileChange(relPath, absPath, content, eventType) {
66
+ const ext = extname(absPath);
67
+ const serverConfig = resolveServer(ext, this.#userConfig);
68
+ if (!serverConfig) return;
69
+
70
+ const entry = this.#manager.getServer(serverConfig.key);
71
+ if (!entry || entry.state !== "ready") return;
72
+
73
+ const uri = pathToFileURL(absPath).href;
74
+ if (eventType === "delete") {
75
+ if (entry.openFiles.has(absPath)) {
76
+ entry.client.notify("textDocument/didClose", { textDocument: { uri } });
77
+ entry.openFiles.delete(absPath);
78
+ }
79
+ } else {
80
+ if (entry.openFiles.has(absPath)) {
81
+ entry.client.notify("textDocument/didChange", {
82
+ textDocument: { uri, version: Date.now() },
83
+ contentChanges: [{ text: content }]
84
+ });
85
+ } else {
86
+ entry.client.notify("textDocument/didOpen", {
87
+ textDocument: { uri, languageId: serverConfig.key, version: 1, text: content }
88
+ });
89
+ entry.openFiles.add(absPath);
90
+ }
91
+ entry.client.notify("textDocument/didSave", { textDocument: { uri } });
92
+
93
+ // Invalidate cached symbol chunk
94
+ if (this.#db) {
95
+ const safePath = relPath.replace(/[/\\]/g, "_").replace(/\./g, "_");
96
+ this.#db.remove(`lsp/symbols/${safePath}`);
97
+ }
98
+ }
99
+ }
100
+
101
+ // --- Diagnostics Setup ---
102
+
103
+ #setupDiagnostics(key, entry) {
104
+ entry.client.onNotification("textDocument/publishDiagnostics", ({ uri, diagnostics }) => {
105
+ const filePath = decodeURIComponent(uri.replace("file://", ""));
106
+ this.#diagnostics.set(filePath, diagnostics.map(d => ({
107
+ line: d.range?.start?.line ?? 0,
108
+ col: d.range?.start?.character ?? 0,
109
+ endLine: d.range?.end?.line ?? 0,
110
+ endCol: d.range?.end?.character ?? 0,
111
+ severity: severityName(d.severity),
112
+ message: d.message,
113
+ source: d.source || key,
114
+ })));
115
+
116
+ if (this.#db) {
117
+ const diags = this.#diagnostics.get(filePath);
118
+ const safePath = filePath.replace(/[/\\]/g, "_").replace(/\./g, "_");
119
+ const cacheId = `lsp/diagnostics/${safePath}`;
120
+ if (diags.length === 0) {
121
+ this.#db.remove(cacheId);
122
+ } else {
123
+ const content = buildDiagnosticsChunk(filePath, key, diags);
124
+ this.#db.index({ id: cacheId, path: cacheId, content, brain_id: "lsp" });
125
+ }
126
+ }
127
+ });
128
+ }
129
+
130
+ // --- Actions ---
131
+
132
+ health() {
133
+ return this.#manager.health();
134
+ }
135
+
136
+ async symbols({ file }) {
137
+ const safePath = file.replace(/[/\\]/g, "_").replace(/\./g, "_");
138
+ const cacheId = `lsp/symbols/${safePath}`;
139
+
140
+ const { entry, server } = await this.#ensureReady(file);
141
+ const uri = pathToFileURL(file).href;
142
+ const result = await entry.client.request("textDocument/documentSymbol", {
143
+ textDocument: { uri }
144
+ });
145
+
146
+ const symbols = normalizeSymbols(result || []);
147
+
148
+ if (this.#db && symbols.length > 0) {
149
+ const content = buildSymbolChunk(file, server.key, symbols);
150
+ this.#db.index({ id: cacheId, path: cacheId, content, brain_id: "lsp" });
151
+ }
152
+
153
+ return { symbols, cached: true };
154
+ }
155
+
156
+ async definition({ file, line, col }) {
157
+ const { entry } = await this.#ensureReady(file);
158
+ const result = await entry.client.request("textDocument/definition", {
159
+ textDocument: { uri: pathToFileURL(file).href },
160
+ position: { line, character: col }
161
+ });
162
+ return { locations: normalizeLocations(result) };
163
+ }
164
+
165
+ async references({ file, line, col }) {
166
+ const { entry } = await this.#ensureReady(file);
167
+ const result = await entry.client.request("textDocument/references", {
168
+ textDocument: { uri: pathToFileURL(file).href },
169
+ position: { line, character: col },
170
+ context: { includeDeclaration: true }
171
+ });
172
+ return { locations: normalizeLocations(result) };
173
+ }
174
+
175
+ async hover({ file, line, col }) {
176
+ const { entry, server } = await this.#ensureReady(file);
177
+ const result = await entry.client.request("textDocument/hover", {
178
+ textDocument: { uri: pathToFileURL(file).href },
179
+ position: { line, character: col }
180
+ });
181
+ if (!result || !result.contents) return { content: null, language: server.key };
182
+ const content = typeof result.contents === "string"
183
+ ? result.contents
184
+ : result.contents.value || JSON.stringify(result.contents);
185
+ return { content, language: result.contents.language || server.key };
186
+ }
187
+
188
+ async implementation({ file, line, col }) {
189
+ const { entry } = await this.#ensureReady(file);
190
+ const result = await entry.client.request("textDocument/implementation", {
191
+ textDocument: { uri: pathToFileURL(file).href },
192
+ position: { line, character: col }
193
+ });
194
+ return { locations: normalizeLocations(result) };
195
+ }
196
+
197
+ async workspaceSymbols({ query }) {
198
+ const health = this.#manager.health();
199
+ const runningKey = Object.keys(health.servers).find(k => health.servers[k].status === "ready");
200
+ if (!runningKey) return { symbols: [], error: "no_running_server" };
201
+
202
+ const entry = this.#manager.getServer(runningKey);
203
+ const result = await entry.client.request("workspace/symbol", { query });
204
+ return {
205
+ symbols: (result || []).map(s => ({
206
+ name: s.name,
207
+ kind: symbolKindName(s.kind),
208
+ file: s.location?.uri ? decodeURIComponent(s.location.uri.replace("file://", "")) : null,
209
+ line: s.location?.range?.start?.line ?? 0,
210
+ }))
211
+ };
212
+ }
213
+
214
+ async callHierarchyIn({ file, line, col }) {
215
+ const { entry } = await this.#ensureReady(file);
216
+ const uri = pathToFileURL(file).href;
217
+ const items = await entry.client.request("textDocument/prepareCallHierarchy", {
218
+ textDocument: { uri }, position: { line, character: col }
219
+ });
220
+ if (!items || items.length === 0) return { calls: [] };
221
+
222
+ const result = await entry.client.request("callHierarchy/incomingCalls", { item: items[0] });
223
+ return {
224
+ calls: (result || []).map(c => ({
225
+ from: {
226
+ name: c.from.name,
227
+ file: c.from.uri ? decodeURIComponent(c.from.uri.replace("file://", "")) : null,
228
+ line: c.from.selectionRange?.start?.line ?? c.from.range?.start?.line ?? 0,
229
+ }
230
+ }))
231
+ };
232
+ }
233
+
234
+ async callHierarchyOut({ file, line, col }) {
235
+ const { entry } = await this.#ensureReady(file);
236
+ const uri = pathToFileURL(file).href;
237
+ const items = await entry.client.request("textDocument/prepareCallHierarchy", {
238
+ textDocument: { uri }, position: { line, character: col }
239
+ });
240
+ if (!items || items.length === 0) return { calls: [] };
241
+
242
+ const result = await entry.client.request("callHierarchy/outgoingCalls", { item: items[0] });
243
+ return {
244
+ calls: (result || []).map(c => ({
245
+ to: {
246
+ name: c.to.name,
247
+ file: c.to.uri ? decodeURIComponent(c.to.uri.replace("file://", "")) : null,
248
+ line: c.to.selectionRange?.start?.line ?? c.to.range?.start?.line ?? 0,
249
+ }
250
+ }))
251
+ };
252
+ }
253
+
254
+ diagnostics({ file } = {}) {
255
+ if (file) {
256
+ const diags = this.#diagnostics.get(file) || [];
257
+ return {
258
+ diagnostics: diags,
259
+ errors: diags.filter(d => d.severity === "error").length,
260
+ warnings: diags.filter(d => d.severity === "warning").length,
261
+ info: diags.filter(d => d.severity === "info").length,
262
+ };
263
+ }
264
+ const all = {};
265
+ let totalErrors = 0, totalWarnings = 0, totalInfo = 0;
266
+ for (const [path, diags] of this.#diagnostics) {
267
+ all[path] = diags;
268
+ totalErrors += diags.filter(d => d.severity === "error").length;
269
+ totalWarnings += diags.filter(d => d.severity === "warning").length;
270
+ totalInfo += diags.filter(d => d.severity === "info").length;
271
+ }
272
+ return { diagnostics: all, errors: totalErrors, warnings: totalWarnings, info: totalInfo };
273
+ }
274
+
275
+ async shutdown() {
276
+ await this.#manager.shutdown();
277
+ }
278
+ }