pi-graphnav 0.1.0 → 0.2.1
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 +40 -5
- package/extensions/core/config.ts +32 -0
- package/extensions/core/gate.ts +111 -10
- package/extensions/core/mcp.ts +9 -1
- package/extensions/core/project.ts +52 -6
- package/extensions/core/resilience.ts +91 -0
- package/extensions/graphnav.ts +101 -16
- package/package.json +4 -1
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.
|
|
20
|
-
|
|
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
|
+

|
|
54
|
+
|
|
55
|
+
`nudge` mode: the search runs, and its output points at CodeGraph.
|
|
56
|
+
|
|
57
|
+

|
|
58
|
+
|
|
59
|
+
The `/codegraph` command: status, mode, off/on, sync.
|
|
60
|
+
|
|
61
|
+

|
|
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
|
+
}
|
package/extensions/core/gate.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/extensions/core/mcp.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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(
|
|
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
|
|
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
|
+
}
|
package/extensions/graphnav.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
}
|