pi-graphnav 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +95 -2
- package/extensions/command/parse.ts +75 -0
- package/extensions/core/config.ts +103 -0
- package/extensions/core/gate.ts +317 -0
- package/extensions/core/mcp.ts +183 -0
- package/extensions/core/project.ts +126 -0
- package/extensions/core/tools.ts +142 -0
- package/extensions/graphnav.ts +329 -0
- package/package.json +48 -4
|
@@ -0,0 +1,329 @@
|
|
|
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, readConfig, saveConfig } from "./core/config.ts";
|
|
18
|
+
import { decide, findSearches, type GateMemory, isSourceFile, lookupWords, newMemory, type Search } from "./core/gate.ts";
|
|
19
|
+
import { CodeGraphServer, type Spawner } from "./core/mcp.ts";
|
|
20
|
+
import { actionArgs, ensureIndex, findProject, type IndexStatus, type Project, parseStatus, type Runner } from "./core/project.ts";
|
|
21
|
+
import { GUIDELINES, isCodeGraphTool, normalizeFilesPath, resultText, TOOLS } from "./core/tools.ts";
|
|
22
|
+
|
|
23
|
+
export type IndexState = "none" | "checking" | "indexing" | "ready" | "missing" | "error";
|
|
24
|
+
|
|
25
|
+
export interface Status {
|
|
26
|
+
config: Config;
|
|
27
|
+
project?: Project;
|
|
28
|
+
state: IndexState;
|
|
29
|
+
index?: IndexStatus;
|
|
30
|
+
blocked: number;
|
|
31
|
+
nudged: number;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function statusText(s: Status): string {
|
|
36
|
+
if (!s.config.enabled) return "codegraph: off";
|
|
37
|
+
const files = s.index && s.state === "ready" ? ` · ${s.index.files} files` : "";
|
|
38
|
+
return `codegraph: ${s.state}${files} · ${s.config.mode}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function statusReport(s: Status): string {
|
|
42
|
+
return [
|
|
43
|
+
statusText(s),
|
|
44
|
+
`project: ${s.project?.root ?? "(none: not in a git repo or indexed directory)"}`,
|
|
45
|
+
s.index ? `index: ${s.index.state}, ${s.index.files} files, ${s.index.pending} pending changes` : undefined,
|
|
46
|
+
`this session: ${s.blocked} searches blocked, ${s.nudged} nudged`,
|
|
47
|
+
s.error ? `last error: ${s.error}` : undefined,
|
|
48
|
+
]
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** On Windows the CLI runs through a shell, which reports a missing program itself: map that to 127 too. */
|
|
54
|
+
export function exitCode(code: number | null, stderr: string): number {
|
|
55
|
+
if (code === 9009 || (code !== 0 && /is not recognized as an internal or external command|command not found/i.test(stderr))) return 127;
|
|
56
|
+
return code ?? 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Runs the CodeGraph CLI. Exit code 127 means it is not installed. */
|
|
60
|
+
export const defaultRunner: Runner = (args, cwd) =>
|
|
61
|
+
new Promise((done) => {
|
|
62
|
+
let stdout = "";
|
|
63
|
+
let stderr = "";
|
|
64
|
+
const child = spawn("codegraph", args, { cwd, env: process.env, windowsHide: true, shell: process.platform === "win32" });
|
|
65
|
+
child.stdout?.on("data", (c) => (stdout += String(c)));
|
|
66
|
+
child.stderr?.on("data", (c) => (stderr += String(c)));
|
|
67
|
+
child.on("error", (e: NodeJS.ErrnoException) => done({ code: e.code === "ENOENT" ? 127 : 1, stdout, stderr: stderr || e.message }));
|
|
68
|
+
child.on("close", (code) => done({ code: exitCode(code, stderr), stdout, stderr }));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** The searches a tool call performs, if it is one the gate looks at. */
|
|
72
|
+
export function searchesOf(toolName: string, input: Record<string, unknown>): Search[] {
|
|
73
|
+
if ((toolName === "bash" || toolName === "powershell") && typeof input.command === "string") return findSearches(input.command);
|
|
74
|
+
if (toolName === "grep" && typeof input.pattern === "string") {
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
tool: "grep",
|
|
78
|
+
pattern: input.pattern,
|
|
79
|
+
paths: typeof input.path === "string" ? [input.path] : [],
|
|
80
|
+
filters: typeof input.glob === "string" ? [input.glob] : [],
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const READ_HINT =
|
|
88
|
+
"[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.";
|
|
89
|
+
|
|
90
|
+
/** Seams for tests: the CLI runner and the server process. */
|
|
91
|
+
export interface Deps {
|
|
92
|
+
run?: Runner;
|
|
93
|
+
spawn?: Spawner;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createExtension(deps: Deps = {}) {
|
|
97
|
+
const run = deps.run ?? defaultRunner;
|
|
98
|
+
return function (pi: ExtensionAPI) {
|
|
99
|
+
let config: Config = { ...DEFAULT_CONFIG };
|
|
100
|
+
let project: Project | undefined;
|
|
101
|
+
let state: IndexState = "none";
|
|
102
|
+
let index: IndexStatus | undefined;
|
|
103
|
+
let lastError: string | undefined;
|
|
104
|
+
let mem: GateMemory = newMemory();
|
|
105
|
+
let counts = { blocked: 0, nudged: 0 };
|
|
106
|
+
let sourceReads = 0;
|
|
107
|
+
let usedCodeGraph = false;
|
|
108
|
+
let readHinted = false;
|
|
109
|
+
const nudges = new Map<string, string>();
|
|
110
|
+
const servers = new Map<string, CodeGraphServer>();
|
|
111
|
+
let job: Promise<void> | undefined;
|
|
112
|
+
let jobRoot: string | undefined;
|
|
113
|
+
let ui: { setStatus(key: string, text: string): void } | undefined;
|
|
114
|
+
|
|
115
|
+
const snapshot = (): Status => ({ config, project, state, index, error: lastError, ...counts });
|
|
116
|
+
const refresh = () => ui?.setStatus("codegraph", config.enabled && config.showStatus ? statusText(snapshot()) : "");
|
|
117
|
+
|
|
118
|
+
const server = (root: string) => {
|
|
119
|
+
let s = servers.get(root);
|
|
120
|
+
if (!s) {
|
|
121
|
+
s = new CodeGraphServer(root, { watch: config.watch, timeoutMs: config.timeoutMs, spawn: deps.spawn });
|
|
122
|
+
servers.set(root, s);
|
|
123
|
+
}
|
|
124
|
+
return s;
|
|
125
|
+
};
|
|
126
|
+
const closeAll = () => {
|
|
127
|
+
for (const s of servers.values()) s.close();
|
|
128
|
+
servers.clear();
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Run an index job for the current project; one at a time. */
|
|
132
|
+
const indexJob = (fn: (p: Project) => Promise<{ status: IndexStatus; error?: string }>): Promise<void> => {
|
|
133
|
+
const p = project;
|
|
134
|
+
if (!p) return Promise.resolve();
|
|
135
|
+
// A job for another project (the session moved on): run after it, for the current one.
|
|
136
|
+
if (job && jobRoot !== p.root) return job.then(() => indexJob(fn));
|
|
137
|
+
if (job) return job;
|
|
138
|
+
jobRoot = p.root;
|
|
139
|
+
state = "indexing";
|
|
140
|
+
refresh();
|
|
141
|
+
job = fn(p)
|
|
142
|
+
.then(({ status, error }) => {
|
|
143
|
+
if (project?.root !== p.root) return;
|
|
144
|
+
index = status;
|
|
145
|
+
lastError = error;
|
|
146
|
+
state = error ? "error" : status.initialized ? "ready" : "missing";
|
|
147
|
+
})
|
|
148
|
+
.finally(() => {
|
|
149
|
+
job = undefined;
|
|
150
|
+
refresh();
|
|
151
|
+
});
|
|
152
|
+
return job;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const runAction = (action: "init" | "index" | "sync") =>
|
|
156
|
+
indexJob(async (p) => {
|
|
157
|
+
const res = await run(actionArgs(action), p.root);
|
|
158
|
+
const status = parseStatus((await run(["status", "-j"], p.root)).stdout);
|
|
159
|
+
const error =
|
|
160
|
+
res.code === 127 ? "the codegraph CLI is not installed (npm i -g @colbymchenry/codegraph)" : res.code !== 0 ? `codegraph ${action} failed (exit ${res.code})` : undefined;
|
|
161
|
+
return { status, error };
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/** Bring the index up to date in the background: a first index of a big repo takes minutes. */
|
|
165
|
+
const startIndexing = () =>
|
|
166
|
+
void indexJob((p) =>
|
|
167
|
+
config.autoSync
|
|
168
|
+
? ensureIndex(p, run, { autoInit: config.autoInit })
|
|
169
|
+
: run(["status", "-j"], p.root).then((r) => ({ status: parseStatus(r.stdout) })),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
173
|
+
const { config: loaded, problem } = await readConfig();
|
|
174
|
+
config = loaded;
|
|
175
|
+
ui = ctx.ui;
|
|
176
|
+
if (problem) ctx.ui.notify(`codegraph: ${problem}; using the defaults`, "warning");
|
|
177
|
+
closeAll();
|
|
178
|
+
mem = newMemory();
|
|
179
|
+
counts = { blocked: 0, nudged: 0 };
|
|
180
|
+
sourceReads = 0;
|
|
181
|
+
usedCodeGraph = false;
|
|
182
|
+
readHinted = false;
|
|
183
|
+
nudges.clear();
|
|
184
|
+
project = findProject(ctx.cwd);
|
|
185
|
+
index = undefined;
|
|
186
|
+
lastError = undefined;
|
|
187
|
+
state = project ? "checking" : "none";
|
|
188
|
+
refresh();
|
|
189
|
+
if (!project || !config.enabled) return;
|
|
190
|
+
startIndexing();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
pi.on("session_shutdown", async () => {
|
|
194
|
+
closeAll();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
for (const tool of TOOLS) {
|
|
198
|
+
pi.registerTool({
|
|
199
|
+
name: tool.name,
|
|
200
|
+
label: tool.label,
|
|
201
|
+
description: tool.description,
|
|
202
|
+
promptSnippet: tool.snippet,
|
|
203
|
+
parameters: tool.parameters,
|
|
204
|
+
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: { cwd: string }) {
|
|
205
|
+
const requested =
|
|
206
|
+
typeof params.projectPath === "string" && params.projectPath.trim() ? resolve(ctx.cwd, params.projectPath) : undefined;
|
|
207
|
+
const root = requested ? (findProject(requested)?.root ?? requested) : (project?.root ?? findProject(ctx.cwd)?.root);
|
|
208
|
+
if (!root) throw new Error("No project: the working directory is not inside a git repo or an indexed directory. Pass projectPath.");
|
|
209
|
+
if (root === project?.root && state === "indexing")
|
|
210
|
+
throw new Error("CodeGraph is still building the index for this project; use rg/read for now and try again shortly.");
|
|
211
|
+
const args: Record<string, unknown> = { ...params, projectPath: root };
|
|
212
|
+
if (tool.name === "codegraph_files") {
|
|
213
|
+
const path = normalizeFilesPath(params.path, root);
|
|
214
|
+
if (path === undefined) delete args.path;
|
|
215
|
+
else args.path = path;
|
|
216
|
+
}
|
|
217
|
+
usedCodeGraph = true;
|
|
218
|
+
for (const w of lookupWords(params)) mem.lookedUp.add(w);
|
|
219
|
+
const result = await server(root).call(tool.name, args, signal);
|
|
220
|
+
const text = resultText(result);
|
|
221
|
+
if (result.isError) throw new Error(text);
|
|
222
|
+
return { content: [{ type: "text" as const, text }], details: { root } };
|
|
223
|
+
},
|
|
224
|
+
} as any);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
pi.on("before_agent_start", async (event: any) => {
|
|
228
|
+
if (!config.enabled) return;
|
|
229
|
+
const guidelines: unknown = event?.systemPromptOptions?.promptGuidelines;
|
|
230
|
+
if (!Array.isArray(guidelines)) return;
|
|
231
|
+
// Static text, so the prompt cache survives turn to turn.
|
|
232
|
+
for (const g of GUIDELINES) if (!guidelines.includes(g)) guidelines.push(g);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
pi.on("tool_call", async (event: any) => {
|
|
236
|
+
if (!config.enabled || config.mode === "off" || state !== "ready") return;
|
|
237
|
+
const searches = searchesOf(event.toolName, event.input ?? {});
|
|
238
|
+
if (searches.length === 0) return;
|
|
239
|
+
const d = decide(config.mode, searches, `${event.toolName}\u0000${JSON.stringify(event.input)}`, mem);
|
|
240
|
+
if (d.action === "block") {
|
|
241
|
+
counts.blocked++;
|
|
242
|
+
refresh();
|
|
243
|
+
return { block: true, reason: d.reason };
|
|
244
|
+
}
|
|
245
|
+
if (d.action === "nudge") {
|
|
246
|
+
counts.nudged++;
|
|
247
|
+
nudges.set(event.toolCallId, d.hint);
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
pi.on("tool_result", async (event: any) => {
|
|
253
|
+
if (isCodeGraphTool(event.toolName)) return;
|
|
254
|
+
let hint = nudges.get(event.toolCallId);
|
|
255
|
+
nudges.delete(event.toolCallId);
|
|
256
|
+
if (
|
|
257
|
+
!hint &&
|
|
258
|
+
config.enabled &&
|
|
259
|
+
config.readHintAfter > 0 &&
|
|
260
|
+
state === "ready" &&
|
|
261
|
+
event.toolName === "read" &&
|
|
262
|
+
!usedCodeGraph &&
|
|
263
|
+
!readHinted &&
|
|
264
|
+
typeof event.input?.path === "string" &&
|
|
265
|
+
isSourceFile(event.input.path) &&
|
|
266
|
+
++sourceReads >= config.readHintAfter
|
|
267
|
+
) {
|
|
268
|
+
readHinted = true;
|
|
269
|
+
hint = READ_HINT;
|
|
270
|
+
}
|
|
271
|
+
if (!hint) return;
|
|
272
|
+
return { content: [...(event.content ?? []), { type: "text", text: `\n${hint}` }] };
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
pi.registerCommand("codegraph", {
|
|
276
|
+
description: "CodeGraph: status, on, off, mode, sync, reindex, init",
|
|
277
|
+
getArgumentCompletions: (prefix: string) => completions(prefix),
|
|
278
|
+
handler: async (args, ctx) => {
|
|
279
|
+
const cmd = parseCommand(args);
|
|
280
|
+
ui = ctx.ui;
|
|
281
|
+
// The setting applies to this session either way; a failed save only means it is not remembered.
|
|
282
|
+
const persist = async () => {
|
|
283
|
+
try {
|
|
284
|
+
await saveConfig(config);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
ctx.ui.notify(`codegraph: could not save the setting (${(error as Error)?.message ?? String(error)}); it applies to this session only`, "warning");
|
|
287
|
+
}
|
|
288
|
+
refresh();
|
|
289
|
+
};
|
|
290
|
+
switch (cmd.kind) {
|
|
291
|
+
case "status":
|
|
292
|
+
ctx.ui.notify(statusReport(snapshot()), "info");
|
|
293
|
+
return;
|
|
294
|
+
case "on":
|
|
295
|
+
case "off":
|
|
296
|
+
config.enabled = cmd.kind === "on";
|
|
297
|
+
if (config.enabled && project && state === "checking") startIndexing();
|
|
298
|
+
await persist();
|
|
299
|
+
ctx.ui.notify(`codegraph ${config.enabled ? "on" : "off"}`, "info");
|
|
300
|
+
return;
|
|
301
|
+
case "mode":
|
|
302
|
+
config.mode = cmd.mode;
|
|
303
|
+
await persist();
|
|
304
|
+
ctx.ui.notify(`codegraph: mode ${cmd.mode}`, "info");
|
|
305
|
+
return;
|
|
306
|
+
case "sync":
|
|
307
|
+
case "reindex":
|
|
308
|
+
case "init": {
|
|
309
|
+
if (!project && cmd.kind === "init" && resolve(ctx.cwd) !== homedir()) project = { root: resolve(ctx.cwd), hasIndexDir: false, isRepo: false };
|
|
310
|
+
if (!project) {
|
|
311
|
+
ctx.ui.notify("codegraph: no project here (not inside a git repo or an indexed directory)", "warning");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const action = cmd.kind === "reindex" ? "index" : cmd.kind;
|
|
315
|
+
ctx.ui.notify(`codegraph: ${action} started for ${project.root}`, "info");
|
|
316
|
+
await runAction(action);
|
|
317
|
+
ctx.ui.notify(lastError ? `codegraph: ${lastError}` : statusReport(snapshot()), lastError ? "warning" : "info");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
case "invalid":
|
|
321
|
+
ctx.ui.notify(cmd.message, "warning");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export default createExtension();
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-graphnav",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.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
|
+
}
|