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,414 @@
1
+ /**
2
+ * pi-graphnav: CodeGraph-first code navigation for pi.
3
+ *
4
+ * - keeps the project's index current (init/index/sync at session start, then the server's watcher);
5
+ * - registers the codegraph_* tools on one long-lived server per project;
6
+ * - steers symbol searches done with rg/grep to those tools (enforce, nudge or off);
7
+ * - hints at CodeGraph when the agent reads source file after source file without it.
8
+ *
9
+ * Pure logic lives in core/ and command/ (tested directly). This file wires it to pi.
10
+ * Settings persist in <agent dir>/codegraph.json.
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ import { homedir } from "node:os";
14
+ import { resolve } from "node:path";
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { completions, parseCommand } from "./command/parse.ts";
17
+ import { type Config, DEFAULT_CONFIG, type ProjectOverrides, readConfig, readProjectConfig, saveConfig } from "./core/config.ts";
18
+ import {
19
+ codemodeSearches,
20
+ decide,
21
+ findFileSearches,
22
+ findSearches,
23
+ type GateMemory,
24
+ isSourceFile,
25
+ lookupWords,
26
+ newMemory,
27
+ type Search,
28
+ } from "./core/gate.ts";
29
+ import { CodeGraphServer, type Spawner } from "./core/mcp.ts";
30
+ import { actionArgs, ensureIndex, findProject, type IndexStatus, type Project, parseStatus, type Runner, runUnlocking } from "./core/project.ts";
31
+ import { Guard } from "./core/resilience.ts";
32
+ import { GUIDELINES, isCodeGraphTool, normalizeFilesPath, resultText, TOOLS } from "./core/tools.ts";
33
+
34
+ export type IndexState = "none" | "checking" | "indexing" | "ready" | "missing" | "error";
35
+
36
+ export interface Status {
37
+ config: Config;
38
+ project?: Project;
39
+ state: IndexState;
40
+ index?: IndexStatus;
41
+ blocked: number;
42
+ nudged: number;
43
+ /** Blocks the agent answered with a CodeGraph call. */
44
+ followed: number;
45
+ /** Blocks the agent overrode by running the same search again. */
46
+ overridden: number;
47
+ /** CodeGraph's recent calls failed: the gate stands aside until it recovers. */
48
+ unavailable?: boolean;
49
+ /** Settings this project overrides (`.pi/codegraph.json`). */
50
+ overrides?: ProjectOverrides;
51
+ error?: string;
52
+ }
53
+
54
+ export function statusText(s: Status): string {
55
+ if (!s.config.enabled) return "codegraph: off";
56
+ if (s.unavailable) return `codegraph: unavailable · ${s.config.mode}`;
57
+ const files = s.index && s.state === "ready" ? ` · ${s.index.files} files` : "";
58
+ return `codegraph: ${s.state}${files} · ${s.config.mode}`;
59
+ }
60
+
61
+ export function statusReport(s: Status): string {
62
+ return [
63
+ statusText(s),
64
+ `project: ${s.project?.root ?? "(none: not in a git repo or indexed directory)"}`,
65
+ s.index ? `index: ${s.index.state}, ${s.index.files} files, ${s.index.pending} pending changes` : undefined,
66
+ s.overrides && Object.keys(s.overrides).length > 0
67
+ ? `project settings (.pi/codegraph.json): ${Object.entries(s.overrides)
68
+ .map(([k, v]) => `${k} ${v}`)
69
+ .join(", ")}`
70
+ : undefined,
71
+ `this session: ${s.blocked} searches blocked (${s.followed} answered with CodeGraph, ${s.overridden} re-run), ${s.nudged} nudged`,
72
+ s.unavailable ? "CodeGraph is unavailable (recent calls failed): searches are not gated until it recovers" : undefined,
73
+ s.error ? `last error: ${s.error}` : undefined,
74
+ ]
75
+ .filter(Boolean)
76
+ .join("\n");
77
+ }
78
+
79
+ /** On Windows the CLI runs through a shell, which reports a missing program itself: map that to 127 too. */
80
+ export function exitCode(code: number | null, stderr: string): number {
81
+ if (code === 9009 || (code !== 0 && /is not recognized as an internal or external command|command not found/i.test(stderr))) return 127;
82
+ return code ?? 1;
83
+ }
84
+
85
+ /** Runs the CodeGraph CLI. Exit code 127 means it is not installed. */
86
+ export const defaultRunner: Runner = (args, cwd) =>
87
+ new Promise((done) => {
88
+ let stdout = "";
89
+ let stderr = "";
90
+ const child = spawn("codegraph", args, { cwd, env: process.env, windowsHide: true, shell: process.platform === "win32" });
91
+ child.stdout?.on("data", (c) => (stdout += String(c)));
92
+ child.stderr?.on("data", (c) => (stderr += String(c)));
93
+ child.on("error", (e: NodeJS.ErrnoException) => done({ code: e.code === "ENOENT" ? 127 : 1, stdout, stderr: stderr || e.message }));
94
+ child.on("close", (code) => done({ code: exitCode(code, stderr), stdout, stderr }));
95
+ });
96
+
97
+ /** The searches a tool call performs, if it is one the gate looks at. */
98
+ export function searchesOf(toolName: string, input: Record<string, unknown>): Search[] {
99
+ if ((toolName === "bash" || toolName === "powershell") && typeof input.command === "string")
100
+ return [...findSearches(input.command), ...findFileSearches(input.command)];
101
+ if (toolName === "codemode" && typeof input.code === "string") return codemodeSearches(input.code);
102
+ if (toolName === "find" && typeof input.pattern === "string") return [{ tool: "find", pattern: input.pattern, paths: [], filters: [], files: true }];
103
+ if (toolName === "grep" && typeof input.pattern === "string") {
104
+ return [
105
+ {
106
+ tool: "grep",
107
+ pattern: input.pattern,
108
+ paths: typeof input.path === "string" ? [input.path] : [],
109
+ filters: typeof input.glob === "string" ? [input.glob] : [],
110
+ },
111
+ ];
112
+ }
113
+ return [];
114
+ }
115
+
116
+ export const READ_HINT =
117
+ "[codegraph] Several source files read without CodeGraph. codegraph_explore returns the relevant symbols' source and the call paths between them in one call; codegraph_callers/codegraph_impact show relations.";
118
+
119
+ export const LARGE_READ_LINES = 400;
120
+ export const LARGE_READ_HINT =
121
+ "[codegraph] That was a whole large source file. To work on part of it, codegraph_node({ symbol, includeCode: true }) returns just that symbol's source, and codegraph_explore the relevant symbols across files.";
122
+
123
+ /** Lines in a tool result's text. */
124
+ const lineCount = (content: unknown): number =>
125
+ Array.isArray(content) ? content.reduce((n, c) => n + (typeof c?.text === "string" ? c.text.split("\n").length : 0), 0) : 0;
126
+
127
+ /** Seams for tests: the CLI runner, the server process, the circuit breaker's settings. */
128
+ export interface Deps {
129
+ run?: Runner;
130
+ spawn?: Spawner;
131
+ breaker?: { threshold: number; resetMs: number };
132
+ }
133
+
134
+ export function createExtension(deps: Deps = {}) {
135
+ const run = deps.run ?? defaultRunner;
136
+ return function (pi: ExtensionAPI) {
137
+ let base: Config = { ...DEFAULT_CONFIG };
138
+ let overrides: ProjectOverrides = {};
139
+ let config: Config = { ...DEFAULT_CONFIG };
140
+ let project: Project | undefined;
141
+ let state: IndexState = "none";
142
+ let index: IndexStatus | undefined;
143
+ let lastError: string | undefined;
144
+ let mem: GateMemory = newMemory();
145
+ let counts = { blocked: 0, nudged: 0, followed: 0, overridden: 0 };
146
+ let awaitingFollow = false;
147
+ const largeReads = new Set<string>();
148
+ const guards = new Map<string, Promise<Guard>>();
149
+ let sourceReads = 0;
150
+ let usedCodeGraph = false;
151
+ let readHinted = false;
152
+ const nudges = new Map<string, string>();
153
+ const servers = new Map<string, CodeGraphServer>();
154
+ let job: Promise<void> | undefined;
155
+ let jobRoot: string | undefined;
156
+ let ui: { setStatus(key: string, text: string): void } | undefined;
157
+
158
+ const guardNow = new Map<string, Guard>();
159
+ const unavailable = (root = project?.root) => (root ? (guardNow.get(root)?.open ?? false) : false);
160
+ const snapshot = (): Status => ({ config, project, state, index, error: lastError, ...counts, unavailable: unavailable(), overrides });
161
+ const refresh = () => ui?.setStatus("codegraph", config.enabled && config.showStatus ? statusText(snapshot()) : "");
162
+
163
+ const server = (root: string) => {
164
+ let s = servers.get(root);
165
+ if (!s) {
166
+ s = new CodeGraphServer(root, { watch: config.watch, timeoutMs: config.timeoutMs, spawn: deps.spawn });
167
+ servers.set(root, s);
168
+ }
169
+ return s;
170
+ };
171
+ const guard = (root: string): Promise<Guard> => {
172
+ let g = guards.get(root);
173
+ if (!g) {
174
+ g = Guard.create({ threshold: deps.breaker?.threshold ?? 3, resetMs: deps.breaker?.resetMs ?? 60_000, onChange: () => refresh() });
175
+ void g.then((x) => guardNow.set(root, x));
176
+ guards.set(root, g);
177
+ }
178
+ return g;
179
+ };
180
+ const closeAll = () => {
181
+ for (const s of servers.values()) s.close();
182
+ servers.clear();
183
+ };
184
+
185
+ /** Run an index job for the current project; one at a time. */
186
+ const indexJob = (fn: (p: Project) => Promise<{ status: IndexStatus; error?: string }>): Promise<void> => {
187
+ const p = project;
188
+ if (!p) return Promise.resolve();
189
+ // A job for another project (the session moved on): run after it, for the current one.
190
+ if (job && jobRoot !== p.root) return job.then(() => indexJob(fn));
191
+ if (job) return job;
192
+ jobRoot = p.root;
193
+ state = "indexing";
194
+ refresh();
195
+ job = fn(p)
196
+ .then(({ status, error }) => {
197
+ if (project?.root !== p.root) return;
198
+ index = status;
199
+ lastError = error;
200
+ state = error ? "error" : status.initialized ? "ready" : "missing";
201
+ })
202
+ .finally(() => {
203
+ job = undefined;
204
+ refresh();
205
+ });
206
+ return job;
207
+ };
208
+
209
+ const runAction = (action: "init" | "index" | "sync") =>
210
+ indexJob(async (p) => {
211
+ const res = await runUnlocking(run, actionArgs(action), p.root);
212
+ const status = parseStatus((await run(["status", "-j"], p.root)).stdout);
213
+ const error =
214
+ res.code === 127 ? "the codegraph CLI is not installed (npm i -g @colbymchenry/codegraph)" : res.code !== 0 ? `codegraph ${action} failed (exit ${res.code})` : undefined;
215
+ return { status, error };
216
+ });
217
+
218
+ /** Bring the index up to date in the background: a first index of a big repo takes minutes. */
219
+ const startIndexing = () =>
220
+ void indexJob((p) =>
221
+ config.autoSync
222
+ ? ensureIndex(p, run, { autoInit: config.autoInit })
223
+ : run(["status", "-j"], p.root).then((r) => ({ status: parseStatus(r.stdout) })),
224
+ );
225
+
226
+ pi.on("session_start", async (_event, ctx) => {
227
+ const { config: loaded, problem } = await readConfig();
228
+ base = loaded;
229
+ ui = ctx.ui;
230
+ if (problem) ctx.ui.notify(`codegraph: ${problem}; using the defaults`, "warning");
231
+ closeAll();
232
+ mem = newMemory();
233
+ counts = { blocked: 0, nudged: 0, followed: 0, overridden: 0 };
234
+ awaitingFollow = false;
235
+ largeReads.clear();
236
+ sourceReads = 0;
237
+ usedCodeGraph = false;
238
+ readHinted = false;
239
+ nudges.clear();
240
+ project = findProject(ctx.cwd);
241
+ const local = project ? await readProjectConfig(project.root) : { overrides: {} };
242
+ if (local.problem) ctx.ui.notify(`codegraph: ${local.problem}; ignoring it`, "warning");
243
+ overrides = local.overrides;
244
+ config = { ...base, ...overrides };
245
+ index = undefined;
246
+ lastError = undefined;
247
+ state = project ? "checking" : "none";
248
+ refresh();
249
+ if (!project || !config.enabled) return;
250
+ startIndexing();
251
+ });
252
+
253
+ pi.on("session_shutdown", async () => {
254
+ closeAll();
255
+ });
256
+
257
+ for (const tool of TOOLS) {
258
+ pi.registerTool({
259
+ name: tool.name,
260
+ label: tool.label,
261
+ description: tool.description,
262
+ promptSnippet: tool.snippet,
263
+ parameters: tool.parameters,
264
+ async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: { cwd: string }) {
265
+ const requested =
266
+ typeof params.projectPath === "string" && params.projectPath.trim() ? resolve(ctx.cwd, params.projectPath) : undefined;
267
+ const root = requested ? (findProject(requested)?.root ?? requested) : (project?.root ?? findProject(ctx.cwd)?.root);
268
+ if (!root) throw new Error("No project: the working directory is not inside a git repo or an indexed directory. Pass projectPath.");
269
+ if (root === project?.root && state === "indexing")
270
+ throw new Error("CodeGraph is still building the index for this project; use rg/read for now and try again shortly.");
271
+ const args: Record<string, unknown> = { ...params, projectPath: root };
272
+ if (tool.name === "codegraph_files") {
273
+ const path = normalizeFilesPath(params.path, root);
274
+ if (path === undefined) delete args.path;
275
+ else args.path = path;
276
+ }
277
+ usedCodeGraph = true;
278
+ if (awaitingFollow) {
279
+ awaitingFollow = false;
280
+ counts.followed++;
281
+ }
282
+ for (const w of lookupWords(params)) mem.lookedUp.add(w);
283
+ const result = await (await guard(root)).run(() => server(root).call(tool.name, args, signal));
284
+ const text = resultText(result);
285
+ if (result.isError) throw new Error(text);
286
+ return { content: [{ type: "text" as const, text }], details: { root } };
287
+ },
288
+ } as any);
289
+ }
290
+
291
+ pi.on("before_agent_start", async (event: any) => {
292
+ if (!config.enabled) return;
293
+ const guidelines: unknown = event?.systemPromptOptions?.promptGuidelines;
294
+ if (!Array.isArray(guidelines)) return;
295
+ // Static text, so the prompt cache survives turn to turn.
296
+ for (const g of GUIDELINES) if (!guidelines.includes(g)) guidelines.push(g);
297
+ });
298
+
299
+ pi.on("tool_call", async (event: any) => {
300
+ if (!config.enabled || config.mode === "off" || state !== "ready" || unavailable()) return;
301
+ const searches = searchesOf(event.toolName, event.input ?? {});
302
+ if (searches.length === 0) return;
303
+ const d = decide(config.mode, searches, `${event.toolName}\u0000${JSON.stringify(event.input)}`, mem);
304
+ if (d.action === "block") {
305
+ counts.blocked++;
306
+ awaitingFollow = true;
307
+ refresh();
308
+ return { block: true, reason: d.reason };
309
+ }
310
+ if (d.action === "allow" && d.override) {
311
+ counts.overridden++;
312
+ awaitingFollow = false;
313
+ }
314
+ if (d.action === "nudge") {
315
+ counts.nudged++;
316
+ nudges.set(event.toolCallId, d.hint);
317
+ }
318
+ return;
319
+ });
320
+
321
+ pi.on("tool_result", async (event: any) => {
322
+ if (isCodeGraphTool(event.toolName)) return;
323
+ let hint = nudges.get(event.toolCallId);
324
+ nudges.delete(event.toolCallId);
325
+ if (
326
+ !hint &&
327
+ config.enabled &&
328
+ config.readHintAfter > 0 &&
329
+ state === "ready" &&
330
+ event.toolName === "read" &&
331
+ !usedCodeGraph &&
332
+ !readHinted &&
333
+ typeof event.input?.path === "string" &&
334
+ isSourceFile(event.input.path) &&
335
+ ++sourceReads >= config.readHintAfter
336
+ ) {
337
+ readHinted = true;
338
+ hint = READ_HINT;
339
+ }
340
+ if (
341
+ !hint &&
342
+ config.enabled &&
343
+ config.mode !== "off" &&
344
+ state === "ready" &&
345
+ event.toolName === "read" &&
346
+ typeof event.input?.path === "string" &&
347
+ event.input.offset === undefined &&
348
+ event.input.limit === undefined &&
349
+ isSourceFile(event.input.path) &&
350
+ !largeReads.has(event.input.path) &&
351
+ lineCount(event.content) > LARGE_READ_LINES
352
+ ) {
353
+ largeReads.add(event.input.path);
354
+ hint = LARGE_READ_HINT;
355
+ }
356
+ if (!hint) return;
357
+ return { content: [...(event.content ?? []), { type: "text", text: `\n${hint}` }] };
358
+ });
359
+
360
+ pi.registerCommand("codegraph", {
361
+ description: "CodeGraph: status, on, off, mode, sync, reindex, init",
362
+ getArgumentCompletions: (prefix: string) => completions(prefix),
363
+ handler: async (args, ctx) => {
364
+ const cmd = parseCommand(args);
365
+ ui = ctx.ui;
366
+ // The setting applies to this session either way; a failed save only means it is not remembered.
367
+ const persist = async () => {
368
+ try {
369
+ await saveConfig(base);
370
+ } catch (error) {
371
+ ctx.ui.notify(`codegraph: could not save the setting (${(error as Error)?.message ?? String(error)}); it applies to this session only`, "warning");
372
+ }
373
+ refresh();
374
+ };
375
+ switch (cmd.kind) {
376
+ case "status":
377
+ ctx.ui.notify(statusReport(snapshot()), "info");
378
+ return;
379
+ case "on":
380
+ case "off":
381
+ config.enabled = base.enabled = cmd.kind === "on";
382
+ if (config.enabled && project && state === "checking") startIndexing();
383
+ await persist();
384
+ ctx.ui.notify(`codegraph ${config.enabled ? "on" : "off"}`, "info");
385
+ return;
386
+ case "mode":
387
+ config.mode = base.mode = cmd.mode;
388
+ await persist();
389
+ ctx.ui.notify(`codegraph: mode ${cmd.mode}`, "info");
390
+ return;
391
+ case "sync":
392
+ case "reindex":
393
+ case "init": {
394
+ if (!project && cmd.kind === "init" && resolve(ctx.cwd) !== homedir()) project = { root: resolve(ctx.cwd), hasIndexDir: false, isRepo: false };
395
+ if (!project) {
396
+ ctx.ui.notify("codegraph: no project here (not inside a git repo or an indexed directory)", "warning");
397
+ return;
398
+ }
399
+ const action = cmd.kind === "reindex" ? "index" : cmd.kind;
400
+ ctx.ui.notify(`codegraph: ${action} started for ${project.root}`, "info");
401
+ await runAction(action);
402
+ ctx.ui.notify(lastError ? `codegraph: ${lastError}` : statusReport(snapshot()), lastError ? "warning" : "info");
403
+ return;
404
+ }
405
+ case "invalid":
406
+ ctx.ui.notify(cmd.message, "warning");
407
+ return;
408
+ }
409
+ },
410
+ });
411
+ };
412
+ }
413
+
414
+ export default createExtension();
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "pi-graphnav",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "CodeGraph-first code navigation for pi: persistent index, auto init/sync, and a gate that steers structural searches to codegraph_* tools.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "codegraph",
9
+ "code-intelligence",
10
+ "mcp"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Sorin Albu-Irimies",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/sorinirimies/pi-graphnav.git"
17
+ },
18
+ "homepage": "https://github.com/sorinirimies/pi-graphnav#readme",
19
+ "files": [
20
+ "extensions",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "pi": {
25
+ "extensions": [
26
+ "./extensions/graphnav.ts"
27
+ ]
28
+ },
29
+ "scripts": {
30
+ "test": "bun test",
31
+ "typecheck": "tsc --noEmit",
32
+ "audit": "bun audit",
33
+ "fuzz": "nu scripts/ci/fuzz.nu",
34
+ "check": "nu scripts/ci/quality_gate.nu"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-ai": "*"
39
+ },
40
+ "overrides": {
41
+ "undici": "8.12.3",
42
+ "minimatch": "11.0.3"
43
+ },
44
+ "devDependencies": {
45
+ "@earendil-works/pi-coding-agent": "^1.4.0",
46
+ "@types/bun": "^1.4.4",
47
+ "typescript": "^7.0.2",
48
+ "@earendil-works/pi-ai": "^1.4.0"
49
+ },
50
+ "dependencies": {
51
+ "arrow-resilience-kit": "^0.10.2"
52
+ }
53
+ }