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.
- package/install.mjs +57 -8
- package/package.json +1 -1
- package/server/bin/wicked-brain-server.mjs +54 -7
- package/server/lib/file-watcher.mjs +102 -5
- package/server/lib/lsp-client.mjs +278 -0
- package/server/lib/lsp-helpers.mjs +133 -0
- package/server/lib/lsp-manager.mjs +164 -0
- package/server/lib/lsp-protocol.mjs +123 -0
- package/server/lib/lsp-servers.mjs +290 -0
- package/server/lib/sqlite-search.mjs +216 -10
- package/server/lib/wikilinks.mjs +20 -4
- package/server/package.json +1 -1
- package/skills/wicked-brain-agent/SKILL.md +52 -0
- package/skills/wicked-brain-agent/agents/consolidate.md +138 -0
- package/skills/wicked-brain-agent/agents/context.md +88 -0
- package/skills/wicked-brain-agent/agents/onboard.md +88 -0
- package/skills/wicked-brain-agent/agents/session-teardown.md +84 -0
- package/skills/wicked-brain-agent/hooks/claude-hooks.json +12 -0
- package/skills/wicked-brain-agent/hooks/copilot-hooks.json +10 -0
- package/skills/wicked-brain-agent/hooks/gemini-hooks.json +12 -0
- package/skills/wicked-brain-agent/platform/antigravity/wicked-brain-consolidate.md +103 -0
- package/skills/wicked-brain-agent/platform/antigravity/wicked-brain-context.md +67 -0
- package/skills/wicked-brain-agent/platform/antigravity/wicked-brain-onboard.md +74 -0
- package/skills/wicked-brain-agent/platform/antigravity/wicked-brain-session-teardown.md +72 -0
- package/skills/wicked-brain-agent/platform/claude/wicked-brain-consolidate.md +106 -0
- package/skills/wicked-brain-agent/platform/claude/wicked-brain-context.md +70 -0
- package/skills/wicked-brain-agent/platform/claude/wicked-brain-onboard.md +77 -0
- package/skills/wicked-brain-agent/platform/claude/wicked-brain-session-teardown.md +75 -0
- package/skills/wicked-brain-agent/platform/codex/wicked-brain-consolidate.toml +104 -0
- package/skills/wicked-brain-agent/platform/codex/wicked-brain-context.toml +68 -0
- package/skills/wicked-brain-agent/platform/codex/wicked-brain-onboard.toml +75 -0
- package/skills/wicked-brain-agent/platform/codex/wicked-brain-session-teardown.toml +73 -0
- package/skills/wicked-brain-agent/platform/copilot/wicked-brain-consolidate.agent.md +105 -0
- package/skills/wicked-brain-agent/platform/copilot/wicked-brain-context.agent.md +69 -0
- package/skills/wicked-brain-agent/platform/copilot/wicked-brain-onboard.agent.md +76 -0
- package/skills/wicked-brain-agent/platform/copilot/wicked-brain-session-teardown.agent.md +74 -0
- package/skills/wicked-brain-agent/platform/cursor/wicked-brain-consolidate.md +104 -0
- package/skills/wicked-brain-agent/platform/cursor/wicked-brain-context.md +68 -0
- package/skills/wicked-brain-agent/platform/cursor/wicked-brain-onboard.md +75 -0
- package/skills/wicked-brain-agent/platform/cursor/wicked-brain-session-teardown.md +73 -0
- package/skills/wicked-brain-agent/platform/gemini/wicked-brain-consolidate.md +107 -0
- package/skills/wicked-brain-agent/platform/gemini/wicked-brain-context.md +71 -0
- package/skills/wicked-brain-agent/platform/gemini/wicked-brain-onboard.md +78 -0
- package/skills/wicked-brain-agent/platform/gemini/wicked-brain-session-teardown.md +76 -0
- package/skills/wicked-brain-agent/platform/kiro/wicked-brain-consolidate.json +17 -0
- package/skills/wicked-brain-agent/platform/kiro/wicked-brain-context.json +16 -0
- package/skills/wicked-brain-agent/platform/kiro/wicked-brain-onboard.json +17 -0
- package/skills/wicked-brain-agent/platform/kiro/wicked-brain-session-teardown.json +17 -0
- package/skills/wicked-brain-compile/SKILL.md +8 -0
- package/skills/wicked-brain-configure/SKILL.md +99 -0
- package/skills/wicked-brain-enhance/SKILL.md +19 -0
- package/skills/wicked-brain-ingest/SKILL.md +68 -5
- package/skills/wicked-brain-lint/SKILL.md +14 -0
- package/skills/wicked-brain-lsp/SKILL.md +172 -0
- package/skills/wicked-brain-memory/SKILL.md +144 -0
- package/skills/wicked-brain-query/SKILL.md +78 -1
- package/skills/wicked-brain-retag/SKILL.md +79 -0
- package/skills/wicked-brain-search/SKILL.md +3 -11
- package/skills/wicked-brain-status/SKILL.md +7 -0
- 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
|
|
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
|
@@ -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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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())
|
|
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
|
+
}
|