pi-graphnav 0.1.0 → 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.
package/README.md CHANGED
@@ -16,24 +16,53 @@ A [pi](https://github.com/earendil-works/pi-coding-agent) extension.
16
16
 
17
17
  * **Keeps the index current.** At session start, in the background: `codegraph init` for a git repo
18
18
  without an index, a full `codegraph index` when the index is stale or was built by another
19
- CodeGraph version, `codegraph sync` for pending changes. While pi runs, the server's file
20
- watcher follows your edits.
19
+ CodeGraph version, `codegraph sync` for pending changes. An index a killed run left half-built
20
+ is rebuilt (unless another process is indexing right now), and a failure on a stale lock gets
21
+ `codegraph unlock` and one more try. While pi runs, the server's file watcher follows your edits.
21
22
  * **The `codegraph_*` tools** (`explore`, `search`, `node`, `callers`, `callees`, `impact`,
22
23
  `files`, `status`) on one long-lived `codegraph serve --mcp` per project: no process start per
23
- call, and the watcher stays alive.
24
+ call, and the watcher stays alive. Resilient, with
25
+ [arrow-resilience-kit](https://www.npmjs.com/package/arrow-resilience-kit): a server that dies
26
+ under a call is restarted and the call retried once; after 3 failed calls in a row CodeGraph
27
+ counts as unavailable for a minute, so calls fail fast and the gate stops sending the agent to
28
+ it (status line: `codegraph: unavailable`).
24
29
  * **Steers symbol searches to CodeGraph.** `rg parseConfig`, `grep -rn UserService src`,
25
30
  `git grep`, `ag`, `ack` and pi's `grep` tool with a symbol-like pattern are blocked once with
26
31
  the CodeGraph call that answers it (`enforce`), or run with that hint appended (`nudge`).
27
32
  Running the same command again is allowed: CodeGraph cannot answer everything. Searches for
28
33
  phrases, string literals or in docs and config (`*.md`, `--type yaml`, …) are never touched,
29
- nor symbols already looked up with CodeGraph.
34
+ nor symbols already looked up with CodeGraph. Searches a codemode script makes with literal
35
+ `tools.bash({ command })` / `tools.grep({ pattern })` are gated the same way.
36
+ * **Nudges file-name lookups.** `fd UserService`, `find . -name '*ParseConfig*'` and pi's `find`
37
+ tool with a camelCase or snake_case name run, with a hint to `codegraph_search` (never blocked).
30
38
  * **Hints after blind reading.** Reading several source files before any CodeGraph call adds
31
- one hint to use `codegraph_explore`.
39
+ one hint to use `codegraph_explore`; reading a whole source file over 400 lines adds one to
40
+ fetch just the symbol with `codegraph_node`.
41
+ * **Shows whether it helps.** `/codegraph status` counts blocked searches, how many the agent
42
+ answered with CodeGraph and how many it re-ran instead.
32
43
  * **Prompt guidance** that stays the same every turn, so the prompt cache survives.
33
44
 
34
45
  Needs the CodeGraph CLI: `npm i -g @colbymchenry/codegraph`. The project is the nearest
35
46
  directory with `.codegraph/`, else the git work tree; never your home directory.
36
47
 
48
+ ## Demos
49
+
50
+ A repo without an index: indexed in the background, then a symbol `rg` is blocked and the
51
+ answer comes from `codegraph_search` and `codegraph_callers`.
52
+
53
+ ![Overview: background indexing, a blocked rg, CodeGraph answers](examples/vhs/generated/overview.gif)
54
+
55
+ `nudge` mode: the search runs, and its output points at CodeGraph.
56
+
57
+ ![Nudge mode: rg runs and its output carries the CodeGraph hint](examples/vhs/generated/nudge.gif)
58
+
59
+ The `/codegraph` command: status, mode, off/on, sync.
60
+
61
+ ![The /codegraph command](examples/vhs/generated/commands.gif)
62
+
63
+ Recorded with [VHS](https://github.com/charmbracelet/vhs) against a throw-away sample project and a
64
+ scripted local model (`just vhs`, `scripts/vhs.nu`, `examples/vhs/`): a real pi with only this plugin.
65
+
37
66
  ## Install
38
67
 
39
68
  ```bash
@@ -64,6 +93,10 @@ Settings live in `<pi agent dir>/codegraph.json` (`PI_CODING_AGENT_DIR`, else `~
64
93
  | `readHintAfter` | `3` | source files read before the CodeGraph hint (`0`: never) |
65
94
  | `timeoutMs` | `30000` | per-call timeout of the CodeGraph server |
66
95
 
96
+ A project can override `enabled`, `mode`, `autoInit`, `autoSync` and `readHintAfter` for itself in
97
+ `<project>/.pi/codegraph.json` (commit it to share), e.g. `{ "mode": "nudge" }` for a repo full of
98
+ generated code. A `/codegraph` command still wins for the session.
99
+
67
100
  Replaces `@vndv/pi-codegraph` (same tool names): uninstall that one first.
68
101
 
69
102
  ## Development
@@ -82,10 +115,12 @@ Layout:
82
115
  | `extensions/core/gate.ts` | spotting symbol searches in shell commands; the gate decision |
83
116
  | `extensions/core/project.ts` | project root, `codegraph status -j`, what keeps the index current |
84
117
  | `extensions/core/mcp.ts` | the long-lived `codegraph serve --mcp` client |
118
+ | `extensions/core/resilience.ts` | circuit breaker and crash retry (arrow-resilience-kit) |
85
119
  | `extensions/core/tools.ts` | tool definitions and prompt guidelines |
86
120
  | `extensions/core/config.ts`, `extensions/command/parse.ts` | settings, `/codegraph` parsing |
87
121
  | `tests/` | `bun test`: one file per module, `extension` (hooks), `fuzz` (randomised properties) |
88
122
  | `scripts/` | Nushell only, each with a test in `scripts/tests/` |
123
+ | `examples/vhs/` | demo tapes, the scripted model, the rendered GIFs |
89
124
  | `.github/`, `.gitea/` | CI, release, nightlies on both hosts |
90
125
 
91
126
  Releases: `just release patch` bumps `package.json`, regenerates `CHANGELOG.md` with git-cliff, tags and
@@ -101,3 +101,35 @@ export async function saveConfig(config: Config, path = configPath()): Promise<v
101
101
  throw e;
102
102
  }
103
103
  }
104
+
105
+ /** What a project may override for itself, in `<project>/.pi/codegraph.json` (committable). */
106
+ export type ProjectOverrides = Partial<Pick<Config, "enabled" | "mode" | "autoInit" | "autoSync" | "readHintAfter">>;
107
+
108
+ /** Keep only the valid override keys; anything else in the file is ignored. */
109
+ export function sanitizeOverrides(raw: unknown): ProjectOverrides {
110
+ const r = (typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {}) as Record<string, unknown>;
111
+ const out: ProjectOverrides = {};
112
+ for (const key of ["enabled", "autoInit", "autoSync"] as const) if (typeof r[key] === "boolean") out[key] = r[key] as boolean;
113
+ if (MODES.includes(r.mode as Mode)) out.mode = r.mode as Mode;
114
+ if (typeof r.readHintAfter === "number" && Number.isFinite(r.readHintAfter)) out.readHintAfter = clampInt(r.readHintAfter, 0, 100, 0);
115
+ return out;
116
+ }
117
+
118
+ export const projectConfigPath = (root: string): string => join(root, ".pi", "codegraph.json");
119
+
120
+ /** A project's overrides; none when the file is missing, a `problem` when it is unusable. */
121
+ export async function readProjectConfig(root: string): Promise<{ overrides: ProjectOverrides; problem?: string }> {
122
+ const path = projectConfigPath(root);
123
+ let raw: string;
124
+ try {
125
+ raw = await readFile(path, "utf8");
126
+ } catch (e) {
127
+ const code = (e as { code?: string } | null)?.code;
128
+ return code === "ENOENT" ? { overrides: {} } : { overrides: {}, problem: `cannot read ${path} (${code ?? "error"})` };
129
+ }
130
+ try {
131
+ return { overrides: sanitizeOverrides(JSON.parse(raw)) };
132
+ } catch {
133
+ return { overrides: {}, problem: `${path} is not valid JSON` };
134
+ }
135
+ }
@@ -13,6 +13,8 @@ export interface Search {
13
13
  paths: string[];
14
14
  /** `--type`/`--glob`/`--include` filters, as written. */
15
15
  filters: string[];
16
+ /** A file-name lookup (`fd Foo`, `find -name '*Foo*'`): only ever nudged, never blocked. */
17
+ files?: true;
16
18
  }
17
19
 
18
20
  const OPERATORS = new Set(["|", "||", "&", "&&", ";", "(", ")", "\n", "|&"]);
@@ -93,6 +95,14 @@ VALUE_FLAGS["git grep"] = new Set("-A -B -C -m -e -f --after-context --before-co
93
95
  const PATTERN_FLAGS = new Set(["-e", "--regexp"]);
94
96
  const FILTER_FLAGS = new Set(["-g", "-t", "-T", "--glob", "--iglob", "--type", "--type-not", "--include", "-G", "--file-search-regex"]);
95
97
  const WRAPPERS = new Set(["sudo", "command", "exec", "time", "nice", "nohup", "env", "xargs", "timeout"]);
98
+ /** Wrapper flags that take a separate value (`timeout -s KILL 5 rg …`, `nice -n 5 rg …`). */
99
+ const WRAPPER_VALUE_FLAGS: Record<string, Set<string>> = {
100
+ timeout: new Set(["-s", "--signal", "-k", "--kill-after"]),
101
+ nice: new Set(["-n", "--adjustment"]),
102
+ sudo: new Set(["-u", "--user", "-g", "--group", "-C", "-D", "--chdir"]),
103
+ xargs: new Set(["-I", "-n", "-P", "-L", "-d", "-E", "-s", "-a"]),
104
+ env: new Set(["-u", "--unset", "-C", "--chdir"]),
105
+ };
96
106
 
97
107
  /** The program of a simple command, skipping `VAR=x` assignments and wrappers like `xargs -0`. */
98
108
  function programIndex(words: string[]): number {
@@ -101,9 +111,10 @@ function programIndex(words: string[]): number {
101
111
  while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!)) i++;
102
112
  const w = words[i];
103
113
  if (w === undefined || !WRAPPERS.has(baseName(w))) return i;
114
+ const wrapper = baseName(w);
104
115
  i++;
105
- while (i < words.length && words[i]!.startsWith("-")) i++;
106
- if (baseName(words[i - 1] ?? "") === "timeout" && /^\d/.test(words[i] ?? "")) i++;
116
+ while (i < words.length && words[i]!.startsWith("-")) i += WRAPPER_VALUE_FLAGS[wrapper]?.has(words[i]!) ? 2 : 1;
117
+ if (wrapper === "timeout" && /^\d/.test(words[i] ?? "")) i++;
107
118
  }
108
119
  }
109
120
 
@@ -196,8 +207,12 @@ export function targetsOnlyNonCode(s: Search): boolean {
196
207
  const nonCode = s.filters.every((f) => {
197
208
  const neg = f.startsWith("!");
198
209
  if (neg) return false;
199
- const ext = /\.([A-Za-z0-9]+)\}?$/.exec(f)?.[1]?.toLowerCase() ?? (f.includes("*") || f.includes(".") ? undefined : f.toLowerCase());
200
- return ext !== undefined && !CODE_EXTENSIONS.has(ext) && !CODE_TYPES.has(ext);
210
+ const bare = f.replace(/^\{([^,{}]*)\}$/, "$1");
211
+ const braces = /\.\{([A-Za-z0-9,]+)\}$/.exec(bare)?.[1];
212
+ const exts = braces
213
+ ? braces.split(",").map((e) => e.toLowerCase())
214
+ : [/\.([A-Za-z0-9]+)$/.exec(bare)?.[1]?.toLowerCase() ?? (bare.includes("*") || bare.includes(".") ? undefined : bare.toLowerCase())];
215
+ return exts.every((ext) => ext !== undefined && ext !== "" && !CODE_EXTENSIONS.has(ext) && !CODE_TYPES.has(ext));
201
216
  });
202
217
  if (nonCode) return true;
203
218
  }
@@ -217,7 +232,10 @@ function normalizeAlternative(raw: string): string {
217
232
  let s = raw.trim();
218
233
  s = s.replace(/\\[bB<>]/g, "").replace(/\[\[:<:\]\]|\[\[:>:\]\]/g, "");
219
234
  s = s.replace(/^\^/, "").replace(/\$$/, "");
220
- s = s.replace(/^\(\?:?/, "").replace(/\)$/, "");
235
+ for (let prev = ""; prev !== s; ) {
236
+ prev = s;
237
+ s = s.replace(/^\((?:\?:)?/, "").replace(/\)$/, "");
238
+ }
221
239
  s = s.replace(DECL, "");
222
240
  s = s.replace(/(?:\\?\(|\\?<|\s*\\s\*\\?\(|\s*\\s\*)$/, "");
223
241
  s = s.replace(/^(?:\.\*|\\s\*|\\s\+)+|(?:\.\*|\\s\*|\\s\+)+$/g, "");
@@ -234,7 +252,7 @@ export function symbolsIn(pattern: string | undefined): string[] | undefined {
234
252
  const alternatives = pattern.split(/\\?\|/);
235
253
  const out: string[] = [];
236
254
  for (const alt of alternatives) {
237
- const s = normalizeAlternative(alt.replace(/^\(|\)$/g, ""));
255
+ const s = normalizeAlternative(alt);
238
256
  if (s.length < 3 || !QUALIFIED.test(s) || KEYWORDS.has(s)) return undefined;
239
257
  const parts = s.split(/::|\.|#|->/);
240
258
  if (parts.some((p) => NOT_SYMBOLS.has(p.toLowerCase()))) return undefined;
@@ -257,7 +275,7 @@ export function lookupWords(params: Record<string, unknown>): string[] {
257
275
  export type Mode = "enforce" | "nudge" | "off";
258
276
 
259
277
  export type Decision =
260
- | { action: "allow" }
278
+ | { action: "allow"; override?: true }
261
279
  | { action: "block"; reason: string }
262
280
  | { action: "nudge"; hint: string };
263
281
 
@@ -294,15 +312,23 @@ export function suggestion(symbols: string[]): string {
294
312
  export function decide(mode: Mode, searches: Search[], key: string, mem: GateMemory): Decision {
295
313
  if (mode === "off") return { action: "allow" };
296
314
  const symbols = new Set<string>();
315
+ const names = new Set<string>();
297
316
  for (const s of searches) {
298
317
  if (targetsOnlyNonCode(s)) continue;
299
- for (const sym of symbolsIn(s.pattern) ?? []) if (!known(sym, mem)) symbols.add(sym);
318
+ for (const sym of (s.files ? fileSymbolIn(s.pattern) : symbolsIn(s.pattern)) ?? []) if (!known(sym, mem)) (s.files ? names : symbols).add(sym);
319
+ }
320
+ if (symbols.size === 0 && names.size === 0) return { action: "allow" };
321
+ if (symbols.size === 0) {
322
+ const [one] = [...names];
323
+ return {
324
+ action: "nudge",
325
+ hint: `[codegraph] "${one}" looks like a symbol name: codegraph_search({ query: "${one}" }) finds where it is defined (file and line), codegraph_files lists the indexed files.`,
326
+ };
300
327
  }
301
- if (symbols.size === 0) return { action: "allow" };
302
328
  const list = [...symbols];
303
329
  const hint = `"${list.join('", "')}" ${list.length === 1 ? "looks like a symbol" : "look like symbols"}: CodeGraph answers this structurally. Use ${suggestion(list)}.`;
304
330
  if (mode === "nudge") return { action: "nudge", hint: `[codegraph] ${hint}` };
305
- if (mem.blocked.has(key)) return { action: "allow" };
331
+ if (mem.blocked.has(key)) return { action: "allow", override: true };
306
332
  mem.blocked.add(key);
307
333
  return {
308
334
  action: "block",
@@ -315,3 +341,78 @@ export const isSourceFile = (path: string): boolean => {
315
341
  const ext = extOf(path);
316
342
  return ext !== undefined && CODE_EXTENSIONS.has(ext);
317
343
  };
344
+
345
+ const FD_VALUE_FLAGS = new Set(
346
+ "-e -t -E -d -x -X -S -c -j -g --extension --type --exclude --max-depth --min-depth --exact-depth --exec --exec-batch --size --changed-within --changed-before --owner --color --threads --max-results --base-directory --search-path --format --path-separator --batch-size --ignore-file".split(" "),
347
+ );
348
+ const FIND_NAME_FLAGS = new Set(["-name", "-iname", "-path", "-ipath", "-wholename", "-iwholename"]);
349
+
350
+ /** File-name lookups in a shell command: `fd <pattern>`, `find … -name <glob>`. */
351
+ export function findFileSearches(command: string): Search[] {
352
+ const out: Search[] = [];
353
+ for (const { words } of splitPipeline(command)) {
354
+ const at = programIndex(words);
355
+ const prog = baseName(words[at] ?? "");
356
+ const args = words.slice(at + 1);
357
+ if (prog === "fd" || prog === "fdfind") {
358
+ const positional: string[] = [];
359
+ for (let i = 0; i < args.length; i++) {
360
+ const a = args[i]!;
361
+ if (!a.startsWith("-")) positional.push(a);
362
+ else if (FD_VALUE_FLAGS.has(a)) i++;
363
+ }
364
+ if (positional.length > 0) out.push({ tool: prog, pattern: positional[0], paths: positional.slice(1), filters: [], files: true });
365
+ } else if (prog === "find") {
366
+ for (let i = 0; i < args.length - 1; i++) {
367
+ if (FIND_NAME_FLAGS.has(args[i]!)) out.push({ tool: "find", pattern: args[i + 1], paths: [], filters: [], files: true });
368
+ }
369
+ }
370
+ }
371
+ return out;
372
+ }
373
+
374
+ /**
375
+ * The symbol a file-name pattern looks for (`*ParseConfig*`, `user_service.rs`,
376
+ * `^AuthController`), stricter than `symbolsIn`: a plain word like `config` names files, not
377
+ * symbols, so it needs a camelCase/PascalCase hump or snake_case (`Readme`, `Makefile` are file names).
378
+ */
379
+ export function fileSymbolIn(pattern: string | undefined): string[] | undefined {
380
+ if (pattern === undefined) return undefined;
381
+ const base = baseName(pattern)
382
+ .replace(/^\^?\.\*|\.\*\$?$/g, "")
383
+ .replace(/^[*^]+|[*$]+$/g, "")
384
+ .replace(/\\?\.[A-Za-z0-9]{1,5}$/, "")
385
+ .replace(/^[*^]+|[*$]+$/g, "");
386
+ if (base.length < 4 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(base) || !/[a-z][A-Z]|[a-z0-9]_[a-z]/.test(base)) return undefined;
387
+ if (NOT_SYMBOLS.has(base.toLowerCase())) return undefined;
388
+ return [base];
389
+ }
390
+
391
+ const unquote = (quote: string, body: string): string => {
392
+ if (quote === '"') {
393
+ try {
394
+ return JSON.parse(`"${body}"`);
395
+ } catch {
396
+ // not JSON-compatible: fall through to the plain unescape
397
+ }
398
+ }
399
+ return body.replace(/\\(.)/g, "$1");
400
+ };
401
+
402
+ const STRING = String.raw`(["'\`])((?:\\.|(?!\1)[^\\])*)\1`;
403
+ const CODEMODE_BASH = new RegExp(String.raw`tools\.bash\(\s*\{[^}]*?\bcommand\s*:\s*` + STRING, "g");
404
+ const CODEMODE_GREP = new RegExp(String.raw`tools\.grep\(\s*\{[^}]*?\bpattern\s*:\s*` + STRING, "g");
405
+
406
+ /**
407
+ * Searches a codemode script makes through `tools.bash({ command: "…" })` and
408
+ * `tools.grep({ pattern: "…" })` with literal strings (a computed command is not knowable).
409
+ */
410
+ export function codemodeSearches(code: string): Search[] {
411
+ const out: Search[] = [];
412
+ for (const m of code.matchAll(CODEMODE_BASH)) {
413
+ const command = unquote(m[1]!, m[2]!);
414
+ out.push(...findSearches(command), ...findFileSearches(command));
415
+ }
416
+ for (const m of code.matchAll(CODEMODE_GREP)) out.push({ tool: "grep", pattern: unquote(m[1]!, m[2]!), paths: [], filters: [] });
417
+ return out;
418
+ }
@@ -48,6 +48,14 @@ export function sanitize(text: string): string {
48
48
  .slice(-MAX_STDERR);
49
49
  }
50
50
 
51
+ /** The server process exited under a call (a crash, not a slow or failed query). */
52
+ export class ServerExitError extends Error {
53
+ constructor(message: string) {
54
+ super(message);
55
+ this.name = "ServerExitError";
56
+ }
57
+ }
58
+
51
59
  interface Pending {
52
60
  resolve: (v: any) => void;
53
61
  reject: (e: Error) => void;
@@ -81,7 +89,7 @@ export class CodeGraphServer {
81
89
  this.stderr = (this.stderr + String(chunk)).slice(-MAX_STDERR);
82
90
  });
83
91
  proc.on("error", (err) => this.fail(proc, err));
84
- proc.on("exit", (code) => this.fail(proc, new Error(sanitize(this.stderr) || `codegraph serve exited with code ${code}`)));
92
+ proc.on("exit", (code) => this.fail(proc, new ServerExitError(sanitize(this.stderr) || `codegraph serve exited with code ${code}`)));
85
93
  const rootUri = pathToFileURL(this.root).href;
86
94
  return this.request("initialize", {
87
95
  protocolVersion: "2024-11-05",
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Pure, except where a function takes the file system or a process runner as a parameter.
5
5
  */
6
- import { existsSync } from "node:fs";
6
+ import { existsSync, readFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { dirname, join, parse, resolve } from "node:path";
9
9
 
@@ -71,17 +71,51 @@ export function parseStatus(json: string): IndexStatus {
71
71
 
72
72
  export type IndexAction = "init" | "index" | "sync" | "none" | "skip";
73
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
+
74
100
  /**
75
101
  * What brings the index up to date: `init` when there is none (only in a git work tree, and
76
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.
77
107
  */
78
- export function planIndex(status: IndexStatus, project: Project, opts: { autoInit: boolean }): IndexAction {
108
+ export function planIndex(status: IndexStatus, project: Project, opts: { autoInit: boolean; locked?: boolean }): IndexAction {
79
109
  if (!status.initialized) return opts.autoInit && project.isRepo ? "init" : "skip";
80
- if (status.reindexRecommended || status.versionMismatch || (status.state !== "complete" && status.state !== "indexing")) return "index";
110
+ if (status.state === "indexing") return opts.locked ? "none" : "index";
111
+ if (status.reindexRecommended || status.versionMismatch || status.state === "partial" || status.state === "failed") return "index";
81
112
  if (status.pending > 0) return "sync";
82
113
  return "none";
83
114
  }
84
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
+
85
119
  /** The CLI arguments for an action. */
86
120
  export function actionArgs(action: Exclude<IndexAction, "none" | "skip">): string[] {
87
121
  switch (action) {
@@ -105,18 +139,30 @@ export interface EnsureResult {
105
139
  }
106
140
 
107
141
  /** Bring the index of `project` up to date: status, then init/index/sync as planned, then status again. */
108
- export async function ensureIndex(project: Project, run: Runner, opts: { autoInit: boolean }): Promise<EnsureResult> {
142
+ export async function ensureIndex(
143
+ project: Project,
144
+ run: Runner,
145
+ opts: { autoInit: boolean; locked?: (root: string) => boolean },
146
+ ): Promise<EnsureResult> {
109
147
  const first = await run(["status", "-j"], project.root);
110
148
  if (first.code === 127) return { action: "skip", status: parseStatus(""), error: "the codegraph CLI is not installed (npm i -g @colbymchenry/codegraph)" };
111
149
  const status = parseStatus(first.stdout);
112
- const action = planIndex(status, project, opts);
150
+ const action = planIndex(status, project, { autoInit: opts.autoInit, locked: (opts.locked ?? lockHeld)(project.root) });
113
151
  if (action === "none" || action === "skip") return { action, status };
114
- const res = await run(actionArgs(action), project.root);
152
+ const res = await runUnlocking(run, actionArgs(action), project.root);
115
153
  const after = parseStatus((await run(["status", "-j"], project.root)).stdout);
116
154
  if (res.code !== 0) return { action, status: after, error: `codegraph ${action} failed (exit ${res.code}): ${lastLine(res.stderr || res.stdout)}` };
117
155
  return { action, status: after };
118
156
  }
119
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
+
120
166
  const lastLine = (s: string): string =>
121
167
  s
122
168
  .replace(/\u001b\[[0-9;]*m/g, "")
@@ -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
+ }
@@ -14,10 +14,21 @@ import { homedir } from "node:os";
14
14
  import { resolve } from "node:path";
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
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";
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";
19
29
  import { CodeGraphServer, type Spawner } from "./core/mcp.ts";
20
- import { actionArgs, ensureIndex, findProject, type IndexStatus, type Project, parseStatus, type Runner } from "./core/project.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";
21
32
  import { GUIDELINES, isCodeGraphTool, normalizeFilesPath, resultText, TOOLS } from "./core/tools.ts";
22
33
 
23
34
  export type IndexState = "none" | "checking" | "indexing" | "ready" | "missing" | "error";
@@ -29,11 +40,20 @@ export interface Status {
29
40
  index?: IndexStatus;
30
41
  blocked: number;
31
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;
32
51
  error?: string;
33
52
  }
34
53
 
35
54
  export function statusText(s: Status): string {
36
55
  if (!s.config.enabled) return "codegraph: off";
56
+ if (s.unavailable) return `codegraph: unavailable · ${s.config.mode}`;
37
57
  const files = s.index && s.state === "ready" ? ` · ${s.index.files} files` : "";
38
58
  return `codegraph: ${s.state}${files} · ${s.config.mode}`;
39
59
  }
@@ -43,7 +63,13 @@ export function statusReport(s: Status): string {
43
63
  statusText(s),
44
64
  `project: ${s.project?.root ?? "(none: not in a git repo or indexed directory)"}`,
45
65
  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`,
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,
47
73
  s.error ? `last error: ${s.error}` : undefined,
48
74
  ]
49
75
  .filter(Boolean)
@@ -70,7 +96,10 @@ export const defaultRunner: Runner = (args, cwd) =>
70
96
 
71
97
  /** The searches a tool call performs, if it is one the gate looks at. */
72
98
  export function searchesOf(toolName: string, input: Record<string, unknown>): Search[] {
73
- if ((toolName === "bash" || toolName === "powershell") && typeof input.command === "string") return findSearches(input.command);
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 }];
74
103
  if (toolName === "grep" && typeof input.pattern === "string") {
75
104
  return [
76
105
  {
@@ -87,22 +116,36 @@ export function searchesOf(toolName: string, input: Record<string, unknown>): Se
87
116
  export const READ_HINT =
88
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.";
89
118
 
90
- /** Seams for tests: the CLI runner and the server process. */
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. */
91
128
  export interface Deps {
92
129
  run?: Runner;
93
130
  spawn?: Spawner;
131
+ breaker?: { threshold: number; resetMs: number };
94
132
  }
95
133
 
96
134
  export function createExtension(deps: Deps = {}) {
97
135
  const run = deps.run ?? defaultRunner;
98
136
  return function (pi: ExtensionAPI) {
137
+ let base: Config = { ...DEFAULT_CONFIG };
138
+ let overrides: ProjectOverrides = {};
99
139
  let config: Config = { ...DEFAULT_CONFIG };
100
140
  let project: Project | undefined;
101
141
  let state: IndexState = "none";
102
142
  let index: IndexStatus | undefined;
103
143
  let lastError: string | undefined;
104
144
  let mem: GateMemory = newMemory();
105
- let counts = { blocked: 0, nudged: 0 };
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>>();
106
149
  let sourceReads = 0;
107
150
  let usedCodeGraph = false;
108
151
  let readHinted = false;
@@ -112,7 +155,9 @@ export function createExtension(deps: Deps = {}) {
112
155
  let jobRoot: string | undefined;
113
156
  let ui: { setStatus(key: string, text: string): void } | undefined;
114
157
 
115
- const snapshot = (): Status => ({ config, project, state, index, error: lastError, ...counts });
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 });
116
161
  const refresh = () => ui?.setStatus("codegraph", config.enabled && config.showStatus ? statusText(snapshot()) : "");
117
162
 
118
163
  const server = (root: string) => {
@@ -123,6 +168,15 @@ export function createExtension(deps: Deps = {}) {
123
168
  }
124
169
  return s;
125
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
+ };
126
180
  const closeAll = () => {
127
181
  for (const s of servers.values()) s.close();
128
182
  servers.clear();
@@ -154,7 +208,7 @@ export function createExtension(deps: Deps = {}) {
154
208
 
155
209
  const runAction = (action: "init" | "index" | "sync") =>
156
210
  indexJob(async (p) => {
157
- const res = await run(actionArgs(action), p.root);
211
+ const res = await runUnlocking(run, actionArgs(action), p.root);
158
212
  const status = parseStatus((await run(["status", "-j"], p.root)).stdout);
159
213
  const error =
160
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;
@@ -171,17 +225,23 @@ export function createExtension(deps: Deps = {}) {
171
225
 
172
226
  pi.on("session_start", async (_event, ctx) => {
173
227
  const { config: loaded, problem } = await readConfig();
174
- config = loaded;
228
+ base = loaded;
175
229
  ui = ctx.ui;
176
230
  if (problem) ctx.ui.notify(`codegraph: ${problem}; using the defaults`, "warning");
177
231
  closeAll();
178
232
  mem = newMemory();
179
- counts = { blocked: 0, nudged: 0 };
233
+ counts = { blocked: 0, nudged: 0, followed: 0, overridden: 0 };
234
+ awaitingFollow = false;
235
+ largeReads.clear();
180
236
  sourceReads = 0;
181
237
  usedCodeGraph = false;
182
238
  readHinted = false;
183
239
  nudges.clear();
184
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 };
185
245
  index = undefined;
186
246
  lastError = undefined;
187
247
  state = project ? "checking" : "none";
@@ -215,8 +275,12 @@ export function createExtension(deps: Deps = {}) {
215
275
  else args.path = path;
216
276
  }
217
277
  usedCodeGraph = true;
278
+ if (awaitingFollow) {
279
+ awaitingFollow = false;
280
+ counts.followed++;
281
+ }
218
282
  for (const w of lookupWords(params)) mem.lookedUp.add(w);
219
- const result = await server(root).call(tool.name, args, signal);
283
+ const result = await (await guard(root)).run(() => server(root).call(tool.name, args, signal));
220
284
  const text = resultText(result);
221
285
  if (result.isError) throw new Error(text);
222
286
  return { content: [{ type: "text" as const, text }], details: { root } };
@@ -233,15 +297,20 @@ export function createExtension(deps: Deps = {}) {
233
297
  });
234
298
 
235
299
  pi.on("tool_call", async (event: any) => {
236
- if (!config.enabled || config.mode === "off" || state !== "ready") return;
300
+ if (!config.enabled || config.mode === "off" || state !== "ready" || unavailable()) return;
237
301
  const searches = searchesOf(event.toolName, event.input ?? {});
238
302
  if (searches.length === 0) return;
239
303
  const d = decide(config.mode, searches, `${event.toolName}\u0000${JSON.stringify(event.input)}`, mem);
240
304
  if (d.action === "block") {
241
305
  counts.blocked++;
306
+ awaitingFollow = true;
242
307
  refresh();
243
308
  return { block: true, reason: d.reason };
244
309
  }
310
+ if (d.action === "allow" && d.override) {
311
+ counts.overridden++;
312
+ awaitingFollow = false;
313
+ }
245
314
  if (d.action === "nudge") {
246
315
  counts.nudged++;
247
316
  nudges.set(event.toolCallId, d.hint);
@@ -268,6 +337,22 @@ export function createExtension(deps: Deps = {}) {
268
337
  readHinted = true;
269
338
  hint = READ_HINT;
270
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
+ }
271
356
  if (!hint) return;
272
357
  return { content: [...(event.content ?? []), { type: "text", text: `\n${hint}` }] };
273
358
  });
@@ -281,7 +366,7 @@ export function createExtension(deps: Deps = {}) {
281
366
  // The setting applies to this session either way; a failed save only means it is not remembered.
282
367
  const persist = async () => {
283
368
  try {
284
- await saveConfig(config);
369
+ await saveConfig(base);
285
370
  } catch (error) {
286
371
  ctx.ui.notify(`codegraph: could not save the setting (${(error as Error)?.message ?? String(error)}); it applies to this session only`, "warning");
287
372
  }
@@ -293,13 +378,13 @@ export function createExtension(deps: Deps = {}) {
293
378
  return;
294
379
  case "on":
295
380
  case "off":
296
- config.enabled = cmd.kind === "on";
381
+ config.enabled = base.enabled = cmd.kind === "on";
297
382
  if (config.enabled && project && state === "checking") startIndexing();
298
383
  await persist();
299
384
  ctx.ui.notify(`codegraph ${config.enabled ? "on" : "off"}`, "info");
300
385
  return;
301
386
  case "mode":
302
- config.mode = cmd.mode;
387
+ config.mode = base.mode = cmd.mode;
303
388
  await persist();
304
389
  ctx.ui.notify(`codegraph: mode ${cmd.mode}`, "info");
305
390
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-graphnav",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CodeGraph-first code navigation for pi: persistent index, auto init/sync, and a gate that steers structural searches to codegraph_* tools.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -46,5 +46,8 @@
46
46
  "@types/bun": "^1.4.4",
47
47
  "typescript": "^7.0.2",
48
48
  "@earendil-works/pi-ai": "^1.4.0"
49
+ },
50
+ "dependencies": {
51
+ "arrow-resilience-kit": "^0.10.2"
49
52
  }
50
53
  }