pi-graphnav 0.0.0-stage → 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,172 @@
1
+ /**
2
+ * Which project CodeGraph serves, and what keeping its index current takes.
3
+ *
4
+ * Pure, except where a function takes the file system or a process runner as a parameter.
5
+ */
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join, parse, resolve } from "node:path";
9
+
10
+ export interface Project {
11
+ root: string;
12
+ /** A `.codegraph/` directory exists at the root. */
13
+ hasIndexDir: boolean;
14
+ /** The root is a git work tree (so `init` there is expected, not a surprise). */
15
+ isRepo: boolean;
16
+ }
17
+
18
+ /**
19
+ * The project for `cwd`: the nearest directory with `.codegraph/`, else the nearest git
20
+ * work tree. Never the home directory or a file-system root: indexing those is never wanted.
21
+ */
22
+ export function findProject(cwd: string, exists: (p: string) => boolean = existsSync, home: string = homedir()): Project | undefined {
23
+ let gitRoot: string | undefined;
24
+ let dir = resolve(cwd);
25
+ for (;;) {
26
+ if (dir === home || dir === parse(dir).root) break;
27
+ if (exists(join(dir, ".codegraph"))) return { root: dir, hasIndexDir: true, isRepo: exists(join(dir, ".git")) };
28
+ if (gitRoot === undefined && exists(join(dir, ".git"))) gitRoot = dir;
29
+ const up = dirname(dir);
30
+ if (up === dir) break;
31
+ dir = up;
32
+ }
33
+ return gitRoot === undefined ? undefined : { root: gitRoot, hasIndexDir: false, isRepo: true };
34
+ }
35
+
36
+ /** What `codegraph status -j` says, reduced to what matters here. */
37
+ export interface IndexStatus {
38
+ initialized: boolean;
39
+ /** `complete`, `indexing`, … (`unknown` when the field is missing). */
40
+ state: string;
41
+ files: number;
42
+ pending: number;
43
+ reindexRecommended: boolean;
44
+ /** The index was built by another CodeGraph version than the one installed. */
45
+ versionMismatch: boolean;
46
+ }
47
+
48
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
49
+
50
+ /** Parse `codegraph status -j`. Garbage (an old CodeGraph, a crash) reads as "not initialized". */
51
+ export function parseStatus(json: string): IndexStatus {
52
+ let raw: any;
53
+ try {
54
+ raw = JSON.parse(json);
55
+ } catch {
56
+ raw = undefined;
57
+ }
58
+ const r = typeof raw === "object" && raw !== null ? raw : {};
59
+ const index = typeof r.index === "object" && r.index !== null ? r.index : {};
60
+ const pending = typeof r.pendingChanges === "object" && r.pendingChanges !== null ? r.pendingChanges : {};
61
+ return {
62
+ initialized: r.initialized === true,
63
+ state: typeof index.state === "string" ? index.state : "unknown",
64
+ files: num(r.fileCount),
65
+ pending: num(pending.added) + num(pending.modified) + num(pending.removed),
66
+ reindexRecommended: index.reindexRecommended === true,
67
+ versionMismatch:
68
+ typeof index.builtWithVersion === "string" && typeof r.version === "string" && index.builtWithVersion !== r.version,
69
+ };
70
+ }
71
+
72
+ export type IndexAction = "init" | "index" | "sync" | "none" | "skip";
73
+
74
+ /** Is a live process holding `.codegraph/codegraph.lock` (an index run in progress right now)? */
75
+ export function lockHeld(
76
+ root: string,
77
+ read: (p: string) => string | undefined = (p) => {
78
+ try {
79
+ return readFileSync(p, "utf8");
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ },
84
+ alive: (pid: number) => boolean = pidAlive,
85
+ ): boolean {
86
+ const pid = Number.parseInt(read(join(root, ".codegraph", "codegraph.lock")) ?? "", 10);
87
+ return Number.isInteger(pid) && pid > 0 && alive(pid);
88
+ }
89
+
90
+ export function pidAlive(pid: number): boolean {
91
+ try {
92
+ process.kill(pid, 0);
93
+ return true;
94
+ } catch (e) {
95
+ // EPERM: it exists, it is just not ours.
96
+ return (e as NodeJS.ErrnoException).code === "EPERM";
97
+ }
98
+ }
99
+
100
+ /**
101
+ * What brings the index up to date: `init` when there is none (only in a git work tree, and
102
+ * only when allowed), a full `index` when it is stale or broken, `sync` for pending changes.
103
+ *
104
+ * State `indexing` with no live lock holder is a run that was killed half-way: the index is
105
+ * truncated and only a full `index` repairs it. With a live holder another process is
106
+ * indexing right now: leave it alone.
107
+ */
108
+ export function planIndex(status: IndexStatus, project: Project, opts: { autoInit: boolean; locked?: boolean }): IndexAction {
109
+ if (!status.initialized) return opts.autoInit && project.isRepo ? "init" : "skip";
110
+ if (status.state === "indexing") return opts.locked ? "none" : "index";
111
+ if (status.reindexRecommended || status.versionMismatch || status.state === "partial" || status.state === "failed") return "index";
112
+ if (status.pending > 0) return "sync";
113
+ return "none";
114
+ }
115
+
116
+ /** A failure that a stale lock explains: `codegraph unlock`, then one more try. */
117
+ export const lockProblem = (output: string): boolean => /\block(ed)?\b|lock file|database is locked/i.test(output);
118
+
119
+ /** The CLI arguments for an action. */
120
+ export function actionArgs(action: Exclude<IndexAction, "none" | "skip">): string[] {
121
+ switch (action) {
122
+ case "init":
123
+ return ["init", "-i", "-y"];
124
+ case "index":
125
+ return ["index", "-q"];
126
+ case "sync":
127
+ return ["sync", "-q"];
128
+ }
129
+ }
130
+
131
+ /** Runs `codegraph <args>` in `cwd`; resolves with the exit code and output, never rejects. */
132
+ export type Runner = (args: string[], cwd: string) => Promise<{ code: number; stdout: string; stderr: string }>;
133
+
134
+ export interface EnsureResult {
135
+ action: IndexAction;
136
+ status: IndexStatus;
137
+ /** Set when CodeGraph is missing or a step failed. */
138
+ error?: string;
139
+ }
140
+
141
+ /** Bring the index of `project` up to date: status, then init/index/sync as planned, then status again. */
142
+ export async function ensureIndex(
143
+ project: Project,
144
+ run: Runner,
145
+ opts: { autoInit: boolean; locked?: (root: string) => boolean },
146
+ ): Promise<EnsureResult> {
147
+ const first = await run(["status", "-j"], project.root);
148
+ if (first.code === 127) return { action: "skip", status: parseStatus(""), error: "the codegraph CLI is not installed (npm i -g @colbymchenry/codegraph)" };
149
+ const status = parseStatus(first.stdout);
150
+ const action = planIndex(status, project, { autoInit: opts.autoInit, locked: (opts.locked ?? lockHeld)(project.root) });
151
+ if (action === "none" || action === "skip") return { action, status };
152
+ const res = await runUnlocking(run, actionArgs(action), project.root);
153
+ const after = parseStatus((await run(["status", "-j"], project.root)).stdout);
154
+ if (res.code !== 0) return { action, status: after, error: `codegraph ${action} failed (exit ${res.code}): ${lastLine(res.stderr || res.stdout)}` };
155
+ return { action, status: after };
156
+ }
157
+
158
+ /** Run an index action; when it fails on a stale lock, `codegraph unlock` and try once more. */
159
+ export async function runUnlocking(run: Runner, args: string[], root: string): ReturnType<Runner> {
160
+ const res = await run(args, root);
161
+ if (res.code === 0 || res.code === 127 || !lockProblem(res.stderr + res.stdout)) return res;
162
+ await run(["unlock"], root);
163
+ return run(args, root);
164
+ }
165
+
166
+ const lastLine = (s: string): string =>
167
+ s
168
+ .replace(/\u001b\[[0-9;]*m/g, "")
169
+ .trim()
170
+ .split("\n")
171
+ .pop()!
172
+ .slice(0, 300);
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Resilience around the CodeGraph server, built on arrow-resilience-kit:
3
+ *
4
+ * - a circuit breaker per project: after `threshold` failed calls in a row CodeGraph counts
5
+ * as unavailable for `resetMs`; calls then fail fast, and the gate stops sending the agent
6
+ * to a tool that does not answer;
7
+ * - one retry when the server process died under a call (a crash, not a slow query): the
8
+ * retry starts a fresh process.
9
+ *
10
+ * Aborts and CodeGraph's own "no such symbol" answers are not failures.
11
+ */
12
+ import { CircuitBreaker, Retry } from "arrow-resilience-kit";
13
+ import { ServerExitError } from "./mcp.ts";
14
+
15
+ export const UNAVAILABLE =
16
+ "CodeGraph is unavailable for this project right now (its last calls failed); use rg/read meanwhile. It is retried automatically.";
17
+
18
+ /** The server process went away under the call: worth one retry with a fresh process. */
19
+ export const isCrash = (e: unknown): boolean => e instanceof ServerExitError || (e instanceof Error && /\bEPIPE\b|\bECONNRESET\b/.test(e.message));
20
+
21
+ /** Failures that count against CodeGraph's health. An abort is the user's doing, not CodeGraph's. */
22
+ export const countsAsFailure = (e: unknown): boolean => !(e instanceof Error && /^Aborted$/.test(e.message));
23
+
24
+ const isOpenError = (e: unknown): boolean =>
25
+ (e as { constructor?: { name?: string } })?.constructor?.name === "CircuitBreakerOpenException" || /circuit breaker is open/i.test(String((e as Error)?.message));
26
+
27
+ export interface GuardOptions {
28
+ /** Failed calls in a row before CodeGraph counts as unavailable. */
29
+ threshold: number;
30
+ /** How long it then stays unavailable before a trial call. */
31
+ resetMs: number;
32
+ /** Called when availability changes (to refresh the status line). */
33
+ onChange?: (available: boolean) => void;
34
+ now?: () => number;
35
+ }
36
+
37
+ export class Guard {
38
+ private openedAt: number | undefined;
39
+
40
+ private constructor(
41
+ private readonly breaker: CircuitBreaker,
42
+ private readonly opts: GuardOptions,
43
+ ) {
44
+ breaker.onStateChange((from, to) => {
45
+ if (to === "Open") {
46
+ this.openedAt = this.clock();
47
+ opts.onChange?.(false);
48
+ // Tell the status line when the pause is over, even if nothing calls CodeGraph meanwhile.
49
+ const t = setTimeout(() => opts.onChange?.(!this.open), opts.resetMs);
50
+ (t as { unref?: () => void }).unref?.();
51
+ } else if (to === "Closed" && from !== "Closed") {
52
+ this.openedAt = undefined;
53
+ opts.onChange?.(true);
54
+ }
55
+ });
56
+ }
57
+
58
+ static async create(opts: GuardOptions): Promise<Guard> {
59
+ return new Guard(await CircuitBreaker.create(opts.threshold, opts.resetMs, 1, 1), opts);
60
+ }
61
+
62
+ private clock(): number {
63
+ return (this.opts.now ?? Date.now)();
64
+ }
65
+
66
+ /** CodeGraph counts as unavailable: its calls failed `threshold` times in a row, and `resetMs` has not passed. */
67
+ get open(): boolean {
68
+ return this.openedAt !== undefined && this.clock() - this.openedAt < this.opts.resetMs;
69
+ }
70
+
71
+ /** Run a call: retried once after a crash, counted by the breaker, failing fast while unavailable. */
72
+ async run<T>(block: () => Promise<T>): Promise<T> {
73
+ let excused: { error: unknown } | undefined;
74
+ try {
75
+ return await this.breaker.execute(async () => {
76
+ try {
77
+ return await Retry.withBackoff(block, { retries: 1, baseMillis: 0, shouldRetry: isCrash });
78
+ } catch (e) {
79
+ if (countsAsFailure(e)) throw e;
80
+ excused = { error: e };
81
+ return undefined as T;
82
+ }
83
+ });
84
+ } catch (e) {
85
+ if (isOpenError(e)) throw new Error(UNAVAILABLE);
86
+ throw e;
87
+ } finally {
88
+ if (excused) throw excused.error;
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The `codegraph_*` tools: names, parameters and the guidance the model sees.
3
+ *
4
+ * The names match the CodeGraph MCP server's tools, so calls pass straight through.
5
+ */
6
+ import { Type } from "@earendil-works/pi-ai";
7
+
8
+ const projectPath = Type.Optional(
9
+ Type.String({ description: "Absolute path of another indexed project. Defaults to the current project." }),
10
+ );
11
+
12
+ const kind = Type.Optional(
13
+ Type.Union(
14
+ ["function", "method", "class", "interface", "type", "variable", "route", "component"].map((k) => Type.Literal(k)),
15
+ { description: "Only symbols of this kind." },
16
+ ),
17
+ );
18
+
19
+ export const TOOLS = [
20
+ {
21
+ name: "codegraph_explore",
22
+ label: "CodeGraph Explore",
23
+ description:
24
+ "Start here for any question about how code works, where something lives or what to change: returns the source of the relevant symbols, grouped by file, with the call paths between them. Accepts symbol names, file names or plain code terms.",
25
+ snippet: "Explore an area of the codebase: relevant symbols' source and call paths in one call",
26
+ parameters: Type.Object({
27
+ query: Type.String({ description: "Symbols, files or code terms, e.g. \"parseConfig loadSettings\"." }),
28
+ maxFiles: Type.Optional(Type.Number({ description: "Most files to return (default 12)." })),
29
+ projectPath,
30
+ }),
31
+ },
32
+ {
33
+ name: "codegraph_search",
34
+ label: "CodeGraph Search",
35
+ description: "Find symbols by name (or part of one): kind, file and line of each definition. Use instead of grep for a symbol.",
36
+ snippet: "Find where a symbol is defined (instead of rg/grep)",
37
+ parameters: Type.Object({
38
+ query: Type.String({ description: "Symbol name or part of one." }),
39
+ kind,
40
+ limit: Type.Optional(Type.Number({ description: "Most results (default 10)." })),
41
+ projectPath,
42
+ }),
43
+ },
44
+ {
45
+ name: "codegraph_node",
46
+ label: "CodeGraph Node",
47
+ description: "One symbol in detail: signature, location, callers and callees; with includeCode its source.",
48
+ snippet: "One symbol's details, callers and callees",
49
+ parameters: Type.Object({
50
+ symbol: Type.String({ description: "Symbol name." }),
51
+ includeCode: Type.Optional(Type.Boolean({ description: "Include the source (default false)." })),
52
+ projectPath,
53
+ }),
54
+ },
55
+ {
56
+ name: "codegraph_callers",
57
+ label: "CodeGraph Callers",
58
+ description: "Every function or method that calls a symbol: its usages, without grepping.",
59
+ snippet: "Who calls a symbol (its usages)",
60
+ parameters: Type.Object({
61
+ symbol: Type.String({ description: "Symbol name." }),
62
+ limit: Type.Optional(Type.Number({ description: "Most results (default 20)." })),
63
+ projectPath,
64
+ }),
65
+ },
66
+ {
67
+ name: "codegraph_callees",
68
+ label: "CodeGraph Callees",
69
+ description: "Every function or method a symbol calls.",
70
+ snippet: "What a symbol calls",
71
+ parameters: Type.Object({
72
+ symbol: Type.String({ description: "Symbol name." }),
73
+ limit: Type.Optional(Type.Number({ description: "Most results (default 20)." })),
74
+ projectPath,
75
+ }),
76
+ },
77
+ {
78
+ name: "codegraph_impact",
79
+ label: "CodeGraph Impact",
80
+ description: "What a change to a symbol affects, transitively. Call it before changing a signature or behaviour.",
81
+ snippet: "What breaks if a symbol changes",
82
+ parameters: Type.Object({
83
+ symbol: Type.String({ description: "Symbol name." }),
84
+ depth: Type.Optional(Type.Number({ description: "How many levels to follow (default 2)." })),
85
+ projectPath,
86
+ }),
87
+ },
88
+ {
89
+ name: "codegraph_files",
90
+ label: "CodeGraph Files",
91
+ description: "The project's indexed files as a tree, flat list or grouped by language, with symbol counts.",
92
+ snippet: "Project file structure from the index",
93
+ parameters: Type.Object({
94
+ path: Type.Optional(Type.String({ description: "Only files under this root-relative prefix, e.g. \"src/core\"." })),
95
+ pattern: Type.Optional(Type.String({ description: "Glob on file paths, e.g. \"*.test.ts\"." })),
96
+ format: Type.Optional(Type.Union([Type.Literal("tree"), Type.Literal("flat"), Type.Literal("grouped")])),
97
+ includeMetadata: Type.Optional(Type.Boolean()),
98
+ maxDepth: Type.Optional(Type.Number()),
99
+ projectPath,
100
+ }),
101
+ },
102
+ {
103
+ name: "codegraph_status",
104
+ label: "CodeGraph Status",
105
+ description: "Index statistics for the project: files, symbols, last update.",
106
+ snippet: "Index status",
107
+ parameters: Type.Object({ projectPath }),
108
+ },
109
+ ] as const;
110
+
111
+ export type ToolName = (typeof TOOLS)[number]["name"];
112
+ export const TOOL_NAMES: readonly string[] = TOOLS.map((t) => t.name);
113
+ export const isCodeGraphTool = (name: string): boolean => TOOL_NAMES.includes(name);
114
+
115
+ /** Guidelines added to the system prompt while the tools are active (static: they never break the prompt cache). */
116
+ export const GUIDELINES = [
117
+ "Understand code through CodeGraph first: codegraph_explore for how something works or where to change it, codegraph_search to find a definition, codegraph_callers/codegraph_callees for relations, codegraph_impact before changing a signature, codegraph_files for structure.",
118
+ "Do not grep (rg, grep, git grep) for symbol names: such searches are redirected to CodeGraph. Text search is for string literals, comments, config and docs, or after CodeGraph came up empty.",
119
+ "Read a file once CodeGraph has told you where to look, with offset/limit around the reported lines.",
120
+ ];
121
+
122
+ /** Largest result handed to the model; the rest is cut with a note. */
123
+ export const MAX_RESULT_CHARS = 60_000;
124
+
125
+ export function resultText(result: { content?: { type: string; text?: string }[] } | undefined): string {
126
+ const text = (result?.content ?? [])
127
+ .filter((p) => p?.type === "text" && typeof p.text === "string")
128
+ .map((p) => p.text)
129
+ .join("\n");
130
+ if (text.length <= MAX_RESULT_CHARS) return text || "(no result)";
131
+ return `${text.slice(0, MAX_RESULT_CHARS)}\n\n[cut at ${MAX_RESULT_CHARS} characters: narrow the query, lower maxFiles/limit or pass a path]`;
132
+ }
133
+
134
+ /** `codegraph_files` wants a root-relative POSIX prefix; accept an absolute path inside the project too. */
135
+ export function normalizeFilesPath(path: unknown, root: string): string | undefined {
136
+ if (typeof path !== "string" || path.trim() === "") return undefined;
137
+ let p = path.trim().replace(/\\/g, "/");
138
+ const r = root.replace(/\\/g, "/").replace(/\/$/, "");
139
+ if (p === r) return undefined;
140
+ if (p.startsWith(`${r}/`)) p = p.slice(r.length + 1).replace(/^(?:\.?\/)+/, "");
141
+ return p.replace(/^\.\/(?:\.?\/)*/, "").replace(/\/$/, "") || undefined;
142
+ }