@isac322/pi-codegraph 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createRequire } from "node:module";
3
+ import { spawn, execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { access, lstat, mkdir, readFile, realpath, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
7
+ import os from "node:os";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const require = createRequire(import.meta.url);
11
+ const metadataName = ".pi-codegraph.json";
12
+ const emptyFilesMarker = "No files found matching the criteria.";
13
+
14
+ export function sanitizeDiagnostic(value, maxLength = 2_000) {
15
+ const clean = String(value || "")
16
+ .replace(/\u001b\[[0-9;]*m/g, "")
17
+ .replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|APIKEY|AUTH)[A-Z0-9_]*=)\S+/gi, "$1[redacted]")
18
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
19
+ .replace(/--(?:token|secret|password|api-key|apikey|otp)(?:=|\s+)\S+/gi, "--[redacted]");
20
+ return clean.length > maxLength ? `${clean.slice(0, maxLength)}...` : clean;
21
+ }
22
+
23
+ export function truncateText(value, maxChars) {
24
+ const text = String(value ?? "");
25
+ if (text.length <= maxChars) return { text, truncated: false };
26
+ const marker = "\n\n[pi-codegraph output truncated]\n\n";
27
+ const headLength = Math.floor((maxChars - marker.length) * 0.75);
28
+ const tailLength = Math.max(0, maxChars - marker.length - headLength);
29
+ return { text: `${text.slice(0, headLength)}${marker}${text.slice(-tailLength)}`, truncated: true };
30
+ }
31
+
32
+ export function normalizeFilesPath(inputPath, projectCwd) {
33
+ if (typeof inputPath !== "string" || !inputPath.trim()) return undefined;
34
+ let expanded = inputPath.trim();
35
+ if (expanded === "~" || expanded.startsWith("~/")) expanded = join(os.homedir(), expanded.slice(1));
36
+ if (projectCwd && isAbsolute(expanded)) {
37
+ const rel = relative(projectCwd, expanded);
38
+ if (!rel) return undefined;
39
+ if (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)) return rel.split(sep).join("/");
40
+ }
41
+ return expanded.split(sep).join("/");
42
+ }
43
+
44
+ export function annotateFilesResult(text, originalPath) {
45
+ if (!originalPath || !String(text).includes(emptyFilesMarker)) return text;
46
+ return `${text}\n\nHint: codegraph_files expects a repo-relative POSIX prefix such as \"src/components\". The path \"${originalPath}\" did not match the index.`;
47
+ }
48
+
49
+ async function existingDirectory(input) {
50
+ const resolved = await realpath(resolve(input));
51
+ const info = await stat(resolved);
52
+ if (!info.isDirectory()) throw new Error(`Project path is not a directory: ${input}`);
53
+ return resolved;
54
+ }
55
+
56
+ async function gitValue(cwd, args) {
57
+ try {
58
+ const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { timeout: 5_000 });
59
+ return stdout.trim();
60
+ } catch {
61
+ return "";
62
+ }
63
+ }
64
+
65
+ export async function gitIdentity(input) {
66
+ const sourcePath = await existingDirectory(input);
67
+ const top = await gitValue(sourcePath, ["rev-parse", "--show-toplevel"]);
68
+ if (!top) {
69
+ const info = await stat(sourcePath);
70
+ return {
71
+ sourcePath,
72
+ repoRoot: sourcePath,
73
+ repoIdentity: `directory:${sourcePath}:${info.dev}:${info.ino}:${info.birthtimeMs}`,
74
+ worktreeIdentity: `directory:${sourcePath}:${info.dev}:${info.ino}`,
75
+ gitCommonDir: "",
76
+ };
77
+ }
78
+ const repoRoot = await realpath(top);
79
+ const commonRaw = await gitValue(repoRoot, ["rev-parse", "--git-common-dir"]);
80
+ const gitDirRaw = await gitValue(repoRoot, ["rev-parse", "--git-dir"]);
81
+ const common = await realpath(isAbsolute(commonRaw) ? commonRaw : resolve(repoRoot, commonRaw));
82
+ const gitDir = await realpath(isAbsolute(gitDirRaw) ? gitDirRaw : resolve(repoRoot, gitDirRaw));
83
+ const [rootCommits, remote, commonInfo, gitDirInfo, sourceInfo] = await Promise.all([
84
+ gitValue(repoRoot, ["rev-list", "--max-parents=0", "HEAD"]),
85
+ gitValue(repoRoot, ["config", "--get", "remote.origin.url"]),
86
+ stat(common),
87
+ stat(gitDir),
88
+ stat(repoRoot),
89
+ ]);
90
+ const repoIdentity = createHash("sha256").update([common, rootCommits, remote, commonInfo.dev, commonInfo.ino, commonInfo.birthtimeMs].join("\0")).digest("hex");
91
+ const worktreeIdentity = createHash("sha256").update([gitDir, gitDirInfo.dev, gitDirInfo.ino, repoRoot, sourceInfo.dev, sourceInfo.ino].join("\0")).digest("hex");
92
+ return { sourcePath: repoRoot, repoRoot, repoIdentity, worktreeIdentity, gitCommonDir: common };
93
+ }
94
+
95
+ function isWithin(child, parent) {
96
+ const rel = relative(parent, child);
97
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
98
+ }
99
+
100
+ export class ProjectGuard {
101
+ static async create(baseRoot, settings) {
102
+ if (process.env.PI_CODEGRAPH_TRUSTED === "0") throw new Error("CodeGraph is disabled for an untrusted project.");
103
+ const base = await gitIdentity(baseRoot);
104
+ const allowedRoots = [];
105
+ for (const root of [base.sourcePath, ...settings.allowedProjectRoots]) {
106
+ try {
107
+ allowedRoots.push(await existingDirectory(root));
108
+ } catch {
109
+ // Ignore unavailable optional roots.
110
+ }
111
+ }
112
+ return new ProjectGuard(base, allowedRoots);
113
+ }
114
+
115
+ constructor(base, allowedRoots) {
116
+ this.base = base;
117
+ this.allowedRoots = allowedRoots;
118
+ }
119
+
120
+ async resolve(requestedPath) {
121
+ if (requestedPath && !isAbsolute(requestedPath)) throw new Error("CodeGraph projectPath must be absolute.");
122
+ const identity = await gitIdentity(requestedPath || this.base.sourcePath);
123
+ if (this.allowedRoots.some((root) => isWithin(identity.sourcePath, root))) return identity;
124
+ if (this.base.gitCommonDir && identity.gitCommonDir === this.base.gitCommonDir) return identity;
125
+ throw new Error(`CodeGraph projectPath is outside the trusted workspace and registered worktrees: ${identity.sourcePath}`);
126
+ }
127
+ }
128
+
129
+ async function locatePackageJson(entry) {
130
+ let current = dirname(entry);
131
+ for (;;) {
132
+ const candidate = join(current, "package.json");
133
+ try {
134
+ const parsed = JSON.parse(await readFile(candidate, "utf8"));
135
+ if (parsed.name === "@colbymchenry/codegraph") return { path: candidate, parsed };
136
+ } catch {
137
+ // Continue toward the filesystem root.
138
+ }
139
+ const parent = dirname(current);
140
+ if (parent === current) return undefined;
141
+ current = parent;
142
+ }
143
+ }
144
+
145
+ export async function resolveCodeGraphLaunch(settings, args = []) {
146
+ if (settings.codegraphExecutable) return { command: settings.codegraphExecutable, args };
147
+ try {
148
+ let packageJson;
149
+ try {
150
+ const packagePath = require.resolve("@colbymchenry/codegraph/package.json");
151
+ packageJson = { path: packagePath, parsed: JSON.parse(await readFile(packagePath, "utf8")) };
152
+ } catch {
153
+ packageJson = await locatePackageJson(require.resolve("@colbymchenry/codegraph"));
154
+ }
155
+ const binValue = typeof packageJson?.parsed?.bin === "string" ? packageJson.parsed.bin : packageJson?.parsed?.bin?.codegraph;
156
+ if (binValue) return { command: process.execPath, args: [resolve(dirname(packageJson.path), binValue), ...args] };
157
+ } catch {
158
+ // Fall through to a global executable.
159
+ }
160
+ return { command: "codegraph", args };
161
+ }
162
+
163
+ export async function runCodeGraph(settings, cwd, args, options = {}) {
164
+ const launch = await resolveCodeGraphLaunch(settings, args);
165
+ return new Promise((resolvePromise, reject) => {
166
+ const child = spawn(launch.command, launch.args, { cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
167
+ let stdout = "";
168
+ let stderr = "";
169
+ let settled = false;
170
+ const finish = (error, value) => {
171
+ if (settled) return;
172
+ settled = true;
173
+ clearTimeout(timer);
174
+ options.signal?.removeEventListener("abort", onAbort);
175
+ error ? reject(error) : resolvePromise(value);
176
+ };
177
+ const onAbort = () => {
178
+ child.kill();
179
+ const error = new Error(`codegraph ${args[0]} aborted`);
180
+ error.name = "AbortError";
181
+ finish(error);
182
+ };
183
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
184
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
185
+ child.on("error", finish);
186
+ child.on("exit", (code) => {
187
+ if (code === 0) finish(undefined, { stdout, stderr });
188
+ else finish(new Error(sanitizeDiagnostic(stderr) || `codegraph ${args[0]} exited with code ${code}`));
189
+ });
190
+ const timeoutMs = options.timeoutMs || settings.requestTimeoutMs;
191
+ const timer = setTimeout(() => {
192
+ child.kill();
193
+ const error = new Error(`codegraph ${args[0]} timed out after ${timeoutMs}ms`);
194
+ error.code = "ETIMEDOUT";
195
+ finish(error);
196
+ }, timeoutMs);
197
+ timer.unref?.();
198
+ options.signal?.addEventListener("abort", onAbort, { once: true });
199
+ });
200
+ }
201
+
202
+ async function exists(input) {
203
+ try {
204
+ await access(input);
205
+ return true;
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ async function readMetadata(indexPath) {
212
+ try {
213
+ return JSON.parse(await readFile(join(indexPath, metadataName), "utf8"));
214
+ } catch {
215
+ return undefined;
216
+ }
217
+ }
218
+
219
+ async function writeMetadata(indexPath, metadata) {
220
+ const target = join(indexPath, metadataName);
221
+ const temporary = `${target}.${process.pid}.tmp`;
222
+ await writeFile(temporary, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
223
+ await rename(temporary, target);
224
+ }
225
+
226
+ async function acquireLock(lockPath, timeoutMs = 30_000) {
227
+ await mkdir(dirname(lockPath), { recursive: true });
228
+ const started = Date.now();
229
+ for (;;) {
230
+ try {
231
+ await mkdir(lockPath);
232
+ return async () => rm(lockPath, { recursive: true, force: true });
233
+ } catch (error) {
234
+ if (error?.code !== "EEXIST") throw error;
235
+ try {
236
+ const info = await stat(lockPath);
237
+ if (Date.now() - info.mtimeMs > timeoutMs * 2) await rm(lockPath, { recursive: true, force: true });
238
+ } catch {
239
+ // Another owner released the lock.
240
+ }
241
+ if (Date.now() - started >= timeoutMs) throw new Error(`Timed out waiting for CodeGraph index lock: ${lockPath}`);
242
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
243
+ }
244
+ }
245
+ }
246
+
247
+ export class WorkspaceManager {
248
+ constructor(settings) {
249
+ this.settings = settings;
250
+ this.lastSync = new Map();
251
+ this.lastGc = 0;
252
+ }
253
+
254
+ async prepare(identity, options = {}) {
255
+ await existingDirectory(identity.sourcePath);
256
+ const key = createHash("sha256").update(`${identity.repoIdentity}\0${identity.worktreeIdentity}\0${identity.sourcePath}`).digest("hex");
257
+ const managedIndex = join(this.settings.indexStore, "projects", key);
258
+ const lockPath = join(this.settings.indexStore, "locks", `${key}.lock`);
259
+ const release = await acquireLock(lockPath, this.settings.requestTimeoutMs);
260
+ try {
261
+ await existingDirectory(identity.sourcePath);
262
+ const binding = await this.#bind(identity, managedIndex);
263
+ const database = join(binding.indexPath, "codegraph.db");
264
+ if (!(await exists(database))) await runCodeGraph(this.settings, identity.sourcePath, ["init", "-i"], options);
265
+ const lastSync = this.lastSync.get(identity.sourcePath) || 0;
266
+ const shouldSync = (options.forceSync || this.settings.autoSync) && (options.forceSync || Date.now() - lastSync >= this.settings.syncMinIntervalMs);
267
+ if (shouldSync) {
268
+ await existingDirectory(identity.sourcePath);
269
+ await runCodeGraph(this.settings, identity.sourcePath, ["sync"], options);
270
+ this.lastSync.set(identity.sourcePath, Date.now());
271
+ }
272
+ const metadata = {
273
+ schemaVersion: 2,
274
+ sourcePath: identity.sourcePath,
275
+ repoIdentity: identity.repoIdentity,
276
+ worktreeIdentity: identity.worktreeIdentity,
277
+ managed: binding.managed,
278
+ lastPreparedAt: new Date().toISOString(),
279
+ lastSyncAt: this.lastSync.get(identity.sourcePath) || null,
280
+ };
281
+ await writeMetadata(binding.indexPath, metadata);
282
+ return { ...metadata, indexPath: binding.indexPath, state: "ready" };
283
+ } finally {
284
+ await release();
285
+ }
286
+ }
287
+
288
+ async #bind(identity, managedIndex) {
289
+ const linkPath = join(identity.sourcePath, ".codegraph");
290
+ await mkdir(managedIndex, { recursive: true });
291
+ try {
292
+ const info = await lstat(linkPath);
293
+ if (info.isSymbolicLink()) {
294
+ let target;
295
+ try { target = await realpath(linkPath); } catch { target = ""; }
296
+ if (target === managedIndex) return { indexPath: managedIndex, managed: true };
297
+ const metadata = target ? await readMetadata(target) : undefined;
298
+ if (metadata?.managed && metadata.sourcePath === identity.sourcePath) {
299
+ await rm(linkPath, { force: true });
300
+ } else {
301
+ throw new Error(`Refusing to replace an unmanaged .codegraph symlink at ${identity.sourcePath}`);
302
+ }
303
+ } else if (info.isDirectory()) {
304
+ const metadata = await readMetadata(linkPath);
305
+ if (metadata && (metadata.repoIdentity !== identity.repoIdentity || metadata.worktreeIdentity !== identity.worktreeIdentity)) {
306
+ throw new Error(`Existing .codegraph belongs to a different repository identity: ${linkPath}`);
307
+ }
308
+ return { indexPath: linkPath, managed: false };
309
+ } else {
310
+ throw new Error(`Expected .codegraph to be a directory or symlink: ${linkPath}`);
311
+ }
312
+ } catch (error) {
313
+ if (error?.code !== "ENOENT") throw error;
314
+ }
315
+ await existingDirectory(identity.sourcePath);
316
+ const temporary = `${linkPath}.pi-codegraph-${process.pid}`;
317
+ await rm(temporary, { force: true });
318
+ await symlink(managedIndex, temporary, "dir");
319
+ try {
320
+ await rename(temporary, linkPath);
321
+ } catch (error) {
322
+ await rm(temporary, { force: true });
323
+ if (error?.code !== "EEXIST" && error?.code !== "ENOTEMPTY") throw error;
324
+ }
325
+ return { indexPath: await realpath(linkPath), managed: true };
326
+ }
327
+
328
+ async status(identity) {
329
+ const linkPath = join(identity.sourcePath, ".codegraph");
330
+ try {
331
+ const indexPath = await realpath(linkPath);
332
+ const metadata = await readMetadata(indexPath);
333
+ return {
334
+ state: (await exists(join(indexPath, "codegraph.db"))) ? "ready" : "uninitialized",
335
+ sourcePath: identity.sourcePath,
336
+ indexPath,
337
+ managed: metadata?.managed ?? false,
338
+ lastSyncAt: metadata?.lastSyncAt || null,
339
+ identityMatches: !metadata || (metadata.repoIdentity === identity.repoIdentity && metadata.worktreeIdentity === identity.worktreeIdentity),
340
+ };
341
+ } catch {
342
+ return { state: "missing", sourcePath: identity.sourcePath, indexPath: null, managed: false, lastSyncAt: null, identityMatches: true };
343
+ }
344
+ }
345
+
346
+ async doctor(identity) {
347
+ const version = await runCodeGraph(this.settings, identity.sourcePath, ["--version"]);
348
+ return { executable: version.stdout.trim() || version.stderr.trim(), workspace: await this.status(identity), settings: publicSettings(this.settings) };
349
+ }
350
+
351
+ async gc(activeProjects = new Set(), force = false) {
352
+ if (!this.settings.autoGc && !force) return { removed: [] };
353
+ if (!force && Date.now() - this.lastGc < 60 * 60_000) return { removed: [] };
354
+ this.lastGc = Date.now();
355
+ const projectsRoot = join(this.settings.indexStore, "projects");
356
+ let entries = [];
357
+ try { entries = await readdir(projectsRoot, { withFileTypes: true }); } catch { return { removed: [] }; }
358
+ const removed = [];
359
+ for (const entry of entries) {
360
+ if (!entry.isDirectory()) continue;
361
+ const indexPath = join(projectsRoot, entry.name);
362
+ const metadata = await readMetadata(indexPath);
363
+ if (!metadata?.managed || activeProjects.has(metadata.sourcePath)) continue;
364
+ let stale = !(await exists(metadata.sourcePath));
365
+ if (!stale) {
366
+ try {
367
+ const identity = await gitIdentity(metadata.sourcePath);
368
+ stale = identity.repoIdentity !== metadata.repoIdentity || identity.worktreeIdentity !== metadata.worktreeIdentity;
369
+ } catch {
370
+ stale = true;
371
+ }
372
+ }
373
+ if (stale) {
374
+ await rm(indexPath, { recursive: true, force: true });
375
+ removed.push(indexPath);
376
+ }
377
+ }
378
+ return { removed };
379
+ }
380
+ }
381
+
382
+ export async function workspaceSummary(cwd) {
383
+ try {
384
+ const identity = await gitIdentity(cwd);
385
+ const linkPath = join(identity.sourcePath, ".codegraph");
386
+ const indexPath = await realpath(linkPath);
387
+ const metadata = await readMetadata(indexPath);
388
+ return {
389
+ state: (await exists(join(indexPath, "codegraph.db"))) ? "ready" : "uninitialized",
390
+ sourcePath: identity.sourcePath,
391
+ indexPath,
392
+ lastSyncAt: metadata?.lastSyncAt || null,
393
+ identityMatches: !metadata || (metadata.repoIdentity === identity.repoIdentity && metadata.worktreeIdentity === identity.worktreeIdentity),
394
+ };
395
+ } catch {
396
+ return { state: "missing", sourcePath: resolve(cwd), indexPath: null, lastSyncAt: null, identityMatches: true };
397
+ }
398
+ }
399
+
400
+ export function publicSettings(settings) {
401
+ return {
402
+ autoSync: settings.autoSync,
403
+ autoGc: settings.autoGc,
404
+ indexStore: settings.indexStore,
405
+ workerIdleTimeoutMs: settings.workerIdleTimeoutMs,
406
+ maxWorkers: settings.maxWorkers,
407
+ requestTimeoutMs: settings.requestTimeoutMs,
408
+ syncMinIntervalMs: settings.syncMinIntervalMs,
409
+ maxOutputChars: settings.maxOutputChars,
410
+ allowedProjectRoots: settings.allowedProjectRoots,
411
+ promptInjection: settings.promptInjection,
412
+ codegraphExecutable: settings.codegraphExecutable || "auto",
413
+ configFile: settings.configFile,
414
+ };
415
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,103 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { readFile } from "node:fs/promises";
4
+
5
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
6
+ const cacheHome = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
7
+
8
+ export const defaultSettings = Object.freeze({
9
+ autoSync: true,
10
+ autoGc: true,
11
+ indexStore: path.join(cacheHome, "pi-codegraph"),
12
+ workerIdleTimeoutMs: 5 * 60_000,
13
+ maxWorkers: 6,
14
+ requestTimeoutMs: 30_000,
15
+ syncMinIntervalMs: 15_000,
16
+ maxOutputChars: 60_000,
17
+ allowedProjectRoots: [],
18
+ promptInjection: true,
19
+ codegraphExecutable: "",
20
+ configFile: path.join(configHome, "pi-codegraph", "config.json"),
21
+ });
22
+
23
+ function booleanValue(value, fallback) {
24
+ if (typeof value === "boolean") return value;
25
+ if (typeof value !== "string") return fallback;
26
+ if (["1", "true", "yes", "on"].includes(value.toLowerCase())) return true;
27
+ if (["0", "false", "no", "off"].includes(value.toLowerCase())) return false;
28
+ return fallback;
29
+ }
30
+
31
+ function integerValue(value, fallback, minimum = 0) {
32
+ const parsed = Number.parseInt(String(value ?? ""), 10);
33
+ return Number.isFinite(parsed) && parsed >= minimum ? parsed : fallback;
34
+ }
35
+
36
+ function stringArray(value, fallback = []) {
37
+ if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim());
38
+ if (typeof value === "string" && value.trim()) return value.split(path.delimiter).map((item) => item.trim()).filter(Boolean);
39
+ return fallback;
40
+ }
41
+
42
+ function normalizeSettings(input) {
43
+ return {
44
+ autoSync: booleanValue(input.autoSync, defaultSettings.autoSync),
45
+ autoGc: booleanValue(input.autoGc, defaultSettings.autoGc),
46
+ indexStore: path.resolve(String(input.indexStore || defaultSettings.indexStore)),
47
+ workerIdleTimeoutMs: integerValue(input.workerIdleTimeoutMs, defaultSettings.workerIdleTimeoutMs, 1_000),
48
+ maxWorkers: integerValue(input.maxWorkers, defaultSettings.maxWorkers, 1),
49
+ requestTimeoutMs: integerValue(input.requestTimeoutMs, defaultSettings.requestTimeoutMs, 1_000),
50
+ syncMinIntervalMs: integerValue(input.syncMinIntervalMs, defaultSettings.syncMinIntervalMs, 0),
51
+ maxOutputChars: integerValue(input.maxOutputChars, defaultSettings.maxOutputChars, 4_000),
52
+ allowedProjectRoots: stringArray(input.allowedProjectRoots).map((root) => path.resolve(root)),
53
+ promptInjection: booleanValue(input.promptInjection, defaultSettings.promptInjection),
54
+ codegraphExecutable: typeof input.codegraphExecutable === "string" ? input.codegraphExecutable.trim() : "",
55
+ configFile: String(input.configFile || defaultSettings.configFile),
56
+ };
57
+ }
58
+
59
+ export async function loadSettings(overrides = {}) {
60
+ const configFile = process.env.PI_CODEGRAPH_CONFIG || overrides.configFile || defaultSettings.configFile;
61
+ let fileSettings = {};
62
+ try {
63
+ fileSettings = JSON.parse(await readFile(configFile, "utf8"));
64
+ } catch (error) {
65
+ if (error?.code !== "ENOENT") throw new Error(`Invalid pi-codegraph config at ${configFile}: ${error.message}`);
66
+ }
67
+
68
+ const environment = {
69
+ autoSync: process.env.PI_CODEGRAPH_AUTO_SYNC,
70
+ autoGc: process.env.PI_CODEGRAPH_AUTO_GC,
71
+ indexStore: process.env.PI_CODEGRAPH_INDEX_STORE,
72
+ workerIdleTimeoutMs: process.env.PI_CODEGRAPH_WORKER_IDLE_MS,
73
+ maxWorkers: process.env.PI_CODEGRAPH_MAX_WORKERS,
74
+ requestTimeoutMs: process.env.PI_CODEGRAPH_REQUEST_TIMEOUT_MS,
75
+ syncMinIntervalMs: process.env.PI_CODEGRAPH_SYNC_MIN_INTERVAL_MS,
76
+ maxOutputChars: process.env.PI_CODEGRAPH_MAX_OUTPUT_CHARS,
77
+ allowedProjectRoots: process.env.PI_CODEGRAPH_ALLOWED_ROOTS,
78
+ promptInjection: process.env.PI_CODEGRAPH_PROMPT_INJECTION,
79
+ codegraphExecutable: process.env.PI_CODEGRAPH_EXECUTABLE,
80
+ configFile,
81
+ };
82
+
83
+ const compactEnvironment = Object.fromEntries(Object.entries(environment).filter(([, value]) => value !== undefined));
84
+ return normalizeSettings({ ...defaultSettings, ...fileSettings, ...compactEnvironment, ...overrides, configFile });
85
+ }
86
+
87
+ export function settingsEnvironment(settings, baseRoot, trusted = true) {
88
+ return {
89
+ PI_CODEGRAPH_BASE_ROOT: baseRoot,
90
+ PI_CODEGRAPH_TRUSTED: trusted ? "1" : "0",
91
+ PI_CODEGRAPH_AUTO_SYNC: String(settings.autoSync),
92
+ PI_CODEGRAPH_AUTO_GC: String(settings.autoGc),
93
+ PI_CODEGRAPH_INDEX_STORE: settings.indexStore,
94
+ PI_CODEGRAPH_WORKER_IDLE_MS: String(settings.workerIdleTimeoutMs),
95
+ PI_CODEGRAPH_MAX_WORKERS: String(settings.maxWorkers),
96
+ PI_CODEGRAPH_REQUEST_TIMEOUT_MS: String(settings.requestTimeoutMs),
97
+ PI_CODEGRAPH_SYNC_MIN_INTERVAL_MS: String(settings.syncMinIntervalMs),
98
+ PI_CODEGRAPH_MAX_OUTPUT_CHARS: String(settings.maxOutputChars),
99
+ PI_CODEGRAPH_ALLOWED_ROOTS: settings.allowedProjectRoots.join(path.delimiter),
100
+ PI_CODEGRAPH_PROMPT_INJECTION: String(settings.promptInjection),
101
+ ...(settings.codegraphExecutable ? { PI_CODEGRAPH_EXECUTABLE: settings.codegraphExecutable } : {}),
102
+ };
103
+ }
@@ -0,0 +1,90 @@
1
+ export class JsonRpcPeer {
2
+ constructor(readable, writable, options = {}) {
3
+ this.readable = readable;
4
+ this.writable = writable;
5
+ this.name = options.name || "JSON-RPC peer";
6
+ this.nextId = 1;
7
+ this.pending = new Map();
8
+ this.buffer = "";
9
+ this.closed = false;
10
+ readable.on("data", (chunk) => this.#onData(chunk));
11
+ readable.on("error", (error) => this.close(error));
12
+ readable.on("end", () => this.close(new Error(`${this.name} closed`)));
13
+ }
14
+
15
+ request(method, params = {}, options = {}) {
16
+ if (this.closed) return Promise.reject(new Error(`${this.name} is closed`));
17
+ const id = this.nextId++;
18
+ const timeoutMs = options.timeoutMs || 30_000;
19
+ return new Promise((resolve, reject) => {
20
+ let timer;
21
+ const finish = (error, value) => {
22
+ clearTimeout(timer);
23
+ options.signal?.removeEventListener("abort", onAbort);
24
+ this.pending.delete(id);
25
+ error ? reject(error) : resolve(value);
26
+ };
27
+ const onAbort = () => {
28
+ this.notify("notifications/cancelled", { requestId: id, reason: "aborted" });
29
+ const error = new Error(`${method} aborted`);
30
+ error.name = "AbortError";
31
+ finish(error);
32
+ };
33
+ timer = setTimeout(() => {
34
+ this.notify("notifications/cancelled", { requestId: id, reason: "timeout" });
35
+ const error = new Error(`${method} timed out after ${timeoutMs}ms`);
36
+ error.code = "ETIMEDOUT";
37
+ finish(error);
38
+ }, timeoutMs);
39
+ timer.unref?.();
40
+ options.signal?.addEventListener("abort", onAbort, { once: true });
41
+ this.pending.set(id, { finish });
42
+ this.#write({ jsonrpc: "2.0", id, method, params });
43
+ });
44
+ }
45
+
46
+ notify(method, params = {}) {
47
+ if (!this.closed) this.#write({ jsonrpc: "2.0", method, params });
48
+ }
49
+
50
+ close(error = new Error(`${this.name} closed`)) {
51
+ if (this.closed) return;
52
+ this.closed = true;
53
+ for (const pending of this.pending.values()) pending.finish(error);
54
+ this.pending.clear();
55
+ }
56
+
57
+ #write(message) {
58
+ try {
59
+ this.writable.write(`${JSON.stringify(message)}\n`);
60
+ } catch (error) {
61
+ this.close(error);
62
+ }
63
+ }
64
+
65
+ #onData(chunk) {
66
+ this.buffer += chunk.toString("utf8");
67
+ let newline;
68
+ while ((newline = this.buffer.indexOf("\n")) !== -1) {
69
+ const line = this.buffer.slice(0, newline).trim();
70
+ this.buffer = this.buffer.slice(newline + 1);
71
+ if (!line) continue;
72
+ let message;
73
+ try {
74
+ message = JSON.parse(line);
75
+ } catch {
76
+ continue;
77
+ }
78
+ if (message.id === undefined || (!Object.hasOwn(message, "result") && !Object.hasOwn(message, "error"))) continue;
79
+ const pending = this.pending.get(message.id);
80
+ if (!pending) continue;
81
+ if (message.error) {
82
+ const error = new Error(message.error.message || JSON.stringify(message.error));
83
+ error.code = message.error.code;
84
+ pending.finish(error);
85
+ } else {
86
+ pending.finish(undefined, message.result);
87
+ }
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,68 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { JsonRpcPeer } from "./jsonrpc.mjs";
4
+ import { sanitizeDiagnostic } from "./codegraph.mjs";
5
+ import { settingsEnvironment } from "./config.mjs";
6
+
7
+ const serverPath = fileURLToPath(new URL("../bin/codegraph-mcp.mjs", import.meta.url));
8
+
9
+ export class PiCodeGraphClient {
10
+ constructor(settings, baseRoot) {
11
+ this.settings = settings;
12
+ this.baseRoot = baseRoot;
13
+ this.startPromise = undefined;
14
+ this.child = undefined;
15
+ this.peer = undefined;
16
+ }
17
+
18
+ async start() {
19
+ if (this.peer) return;
20
+ if (this.startPromise) return this.startPromise;
21
+ this.startPromise = this.#start().finally(() => { this.startPromise = undefined; });
22
+ return this.startPromise;
23
+ }
24
+
25
+ async #start() {
26
+ const child = spawn(process.execPath, [serverPath], {
27
+ cwd: this.baseRoot,
28
+ env: { ...process.env, ...settingsEnvironment(this.settings, this.baseRoot, true) },
29
+ stdio: ["pipe", "pipe", "pipe"],
30
+ });
31
+ let stderr = "";
32
+ child.stderr.on("data", (chunk) => { stderr = `${stderr}${chunk.toString("utf8")}`.slice(-8_000); });
33
+ const peer = new JsonRpcPeer(child.stdout, child.stdin, { name: "pi-codegraph MCP facade" });
34
+ child.on("error", (error) => peer.close(error));
35
+ child.on("exit", (code) => peer.close(new Error(sanitizeDiagnostic(stderr) || `pi-codegraph MCP facade exited with code ${code}`)));
36
+ this.child = child;
37
+ this.peer = peer;
38
+ try {
39
+ await peer.request("initialize", {
40
+ protocolVersion: "2024-11-05",
41
+ capabilities: {},
42
+ clientInfo: { name: "pi-codegraph-pi", version: "0.2.0" },
43
+ }, { timeoutMs: this.settings.requestTimeoutMs });
44
+ peer.notify("initialized", {});
45
+ } catch (error) {
46
+ await this.close();
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ async request(method, params = {}, signal) {
52
+ await this.start();
53
+ return this.peer.request(method, params, { signal, timeoutMs: this.settings.requestTimeoutMs });
54
+ }
55
+
56
+ async callTool(name, args, signal) {
57
+ return this.request("tools/call", { name, arguments: args }, signal);
58
+ }
59
+
60
+ async close() {
61
+ const peer = this.peer;
62
+ const child = this.child;
63
+ this.peer = undefined;
64
+ this.child = undefined;
65
+ peer?.close(new Error("Pi session closed"));
66
+ if (child && !child.killed) child.kill();
67
+ }
68
+ }