pi-graphnav 0.2.1 → 0.2.2
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 +7 -0
- package/extensions/core/gate.ts +76 -7
- package/extensions/core/tools.ts +1 -1
- package/extensions/graphnav.ts +33 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,6 +33,13 @@ A [pi](https://github.com/earendil-works/pi-coding-agent) extension.
|
|
|
33
33
|
phrases, string literals or in docs and config (`*.md`, `--type yaml`, …) are never touched,
|
|
34
34
|
nor symbols already looked up with CodeGraph. Searches a codemode script makes with literal
|
|
35
35
|
`tools.bash({ command })` / `tools.grep({ pattern })` are gated the same way.
|
|
36
|
+
A pattern only counts as a symbol when it has code shape (`parseConfig`, `ParseConfig`,
|
|
37
|
+
`start_scan`, `Foo::new`) or symbol syntax (`fn summary`, `summary(`); plain words like
|
|
38
|
+
`summary`, `cache` or `Success` are text. Before blocking, the gate asks the index (one
|
|
39
|
+
`codegraph_search`, 2 s limit, fails open) and lets the search through when the index does
|
|
40
|
+
not define the name. When it does, the block message already says where (`path:line
|
|
41
|
+
(kind)`), so one blocked call still answers the question. A search inside named files
|
|
42
|
+
(`rg start_scan src/app.rs`) is only nudged: the agent has already found the code.
|
|
36
43
|
* **Nudges file-name lookups.** `fd UserService`, `find . -name '*ParseConfig*'` and pi's `find`
|
|
37
44
|
tool with a camelCase or snake_case name run, with a hint to `codegraph_search` (never blocked).
|
|
38
45
|
* **Hints after blind reading.** Reading several source files before any CodeGraph call adds
|
package/extensions/core/gate.ts
CHANGED
|
@@ -228,7 +228,7 @@ const QUALIFIED = /^[A-Za-z_$][\w$]*(?:(?:::|\.|#|->)[A-Za-z_$][\w$]*)*$/;
|
|
|
228
228
|
const NOT_SYMBOLS = new Set(["todo", "fixme", "hack", "xxx", "note", "bug", "deprecated", "error", "warn", "warning", "info", "debug"]);
|
|
229
229
|
|
|
230
230
|
/** Strip regex syntax that does not change what is being looked for. */
|
|
231
|
-
function normalizeAlternative(raw: string): string {
|
|
231
|
+
function normalizeAlternative(raw: string): { s: string; declared: boolean } {
|
|
232
232
|
let s = raw.trim();
|
|
233
233
|
s = s.replace(/\\[bB<>]/g, "").replace(/\[\[:<:\]\]|\[\[:>:\]\]/g, "");
|
|
234
234
|
s = s.replace(/^\^/, "").replace(/\$$/, "");
|
|
@@ -236,10 +236,23 @@ function normalizeAlternative(raw: string): string {
|
|
|
236
236
|
prev = s;
|
|
237
237
|
s = s.replace(/^\((?:\?:)?/, "").replace(/\)$/, "");
|
|
238
238
|
}
|
|
239
|
+
const bare = s;
|
|
239
240
|
s = s.replace(DECL, "");
|
|
241
|
+
const declared = s !== bare;
|
|
240
242
|
s = s.replace(/(?:\\?\(|\\?<|\s*\\s\*\\?\(|\s*\\s\*)$/, "");
|
|
243
|
+
const called = s !== bare && !declared && /\(\s*$|\\\(/.test(bare);
|
|
241
244
|
s = s.replace(/^(?:\.\*|\\s\*|\\s\+)+|(?:\.\*|\\s\*|\\s\+)+$/g, "");
|
|
242
|
-
return s.trim();
|
|
245
|
+
return { s: s.trim(), declared: declared || called };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Does a bare identifier look like code rather than a word? A plain `summary`, `cache`,
|
|
250
|
+
* `Success` or `ERROR` is as likely prose or a log string as a symbol, so it only counts when
|
|
251
|
+
* the search says "symbol": a declaration keyword (`fn summary`) or call syntax (`summary(`).
|
|
252
|
+
* Otherwise it needs shape: camelCase/PascalCase humps, an inner underscore, or a qualifier.
|
|
253
|
+
*/
|
|
254
|
+
export function isSymbolShaped(s: string): boolean {
|
|
255
|
+
return /::|\.|#|->/.test(s) || /[a-z0-9][A-Z]/.test(s) || /^[A-Z][a-z0-9]+[A-Z]/.test(s) || /[A-Za-z0-9]_[A-Za-z0-9]/.test(s);
|
|
243
256
|
}
|
|
244
257
|
|
|
245
258
|
/**
|
|
@@ -252,11 +265,13 @@ export function symbolsIn(pattern: string | undefined): string[] | undefined {
|
|
|
252
265
|
const alternatives = pattern.split(/\\?\|/);
|
|
253
266
|
const out: string[] = [];
|
|
254
267
|
for (const alt of alternatives) {
|
|
255
|
-
const s = normalizeAlternative(alt);
|
|
268
|
+
const { s, declared } = normalizeAlternative(alt);
|
|
256
269
|
if (s.length < 3 || !QUALIFIED.test(s) || KEYWORDS.has(s)) return undefined;
|
|
270
|
+
if (!declared && !isSymbolShaped(s)) return undefined;
|
|
257
271
|
const parts = s.split(/::|\.|#|->/);
|
|
258
272
|
if (parts.some((p) => NOT_SYMBOLS.has(p.toLowerCase()))) return undefined;
|
|
259
273
|
if (/^[A-Z0-9_]+$/.test(s) && s.length <= 4) return undefined;
|
|
274
|
+
if (/^_+$|^[a-z]+_$/.test(s)) return undefined;
|
|
260
275
|
out.push(s);
|
|
261
276
|
}
|
|
262
277
|
return out.length > 0 ? out : undefined;
|
|
@@ -284,21 +299,64 @@ export interface GateMemory {
|
|
|
284
299
|
lookedUp: Set<string>;
|
|
285
300
|
/** Commands blocked once: running the same command again is the agent's explicit override. */
|
|
286
301
|
blocked: Set<string>;
|
|
302
|
+
/** Lower-cased words the index was asked about and does not define: text, not symbols. */
|
|
303
|
+
unindexed: Set<string>;
|
|
304
|
+
/** Lower-cased leaf → where the index defines it (`path:line (kind)`), from the gate's probe. */
|
|
305
|
+
defined: Map<string, string[]>;
|
|
287
306
|
}
|
|
288
307
|
|
|
289
|
-
export const newMemory = (): GateMemory => ({ lookedUp: new Set(), blocked: new Set() });
|
|
308
|
+
export const newMemory = (): GateMemory => ({ lookedUp: new Set(), blocked: new Set(), unindexed: new Set(), defined: new Map() });
|
|
309
|
+
|
|
310
|
+
/** Last segment of a qualified name, lower-cased: what the index is keyed by. */
|
|
311
|
+
export const leafOf = (symbol: string): string => symbol.toLowerCase().split(/::|\.|#|->/).pop()!;
|
|
290
312
|
|
|
291
313
|
const known = (symbol: string, mem: GateMemory) => {
|
|
292
314
|
const s = symbol.toLowerCase();
|
|
315
|
+
if (mem.unindexed.has(leafOf(s))) return true;
|
|
293
316
|
if (mem.lookedUp.has(s)) return true;
|
|
294
317
|
const last = s.split(/::|\.|#|->/).pop()!;
|
|
295
318
|
return mem.lookedUp.has(last);
|
|
296
319
|
};
|
|
297
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Does a `codegraph_search` answer define `symbol`? Reads the `**name** (kind)` headings and
|
|
323
|
+
* compares the last path segment, case-insensitively.
|
|
324
|
+
*/
|
|
325
|
+
export function indexDefines(searchText: string, symbol: string): boolean {
|
|
326
|
+
return definitionsIn(searchText, symbol).length > 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Where a `codegraph_search` answer defines `symbol`: `path:line (kind)`, at most `max`. */
|
|
330
|
+
export function definitionsIn(searchText: string, symbol: string, max = 5): string[] {
|
|
331
|
+
const leaf = leafOf(symbol);
|
|
332
|
+
const out: string[] = [];
|
|
333
|
+
for (const m of searchText.matchAll(/^\*\*(.+?)\*\* \(([^)\n]*)\)[ \t]*\n([^\n`*]*)/gm)) {
|
|
334
|
+
if (m[1]!.toLowerCase() !== leaf) continue;
|
|
335
|
+
const at = m[3]!.trim();
|
|
336
|
+
out.push(at ? `${at} (${m[2]})` : `(${m[2]})`);
|
|
337
|
+
if (out.length >= max) break;
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Does every search name only files (not directories), so it reads code already pinned down? */
|
|
343
|
+
const onlyNamedFiles = (s: Search): boolean => s.paths.length > 0 && s.paths.every((p) => extOf(p) !== undefined && !/[*?{[]/.test(p));
|
|
344
|
+
|
|
345
|
+
/** Symbols a search set would be gated on (not yet looked up, not known to be text). */
|
|
346
|
+
export function candidateSymbols(searches: Search[], mem: GateMemory): string[] {
|
|
347
|
+
const out = new Set<string>();
|
|
348
|
+
for (const s of searches) {
|
|
349
|
+
if (s.files || targetsOnlyNonCode(s)) continue;
|
|
350
|
+
for (const sym of symbolsIn(s.pattern) ?? []) if (!known(sym, mem)) out.add(sym);
|
|
351
|
+
}
|
|
352
|
+
return [...out];
|
|
353
|
+
}
|
|
354
|
+
|
|
298
355
|
export function suggestion(symbols: string[]): string {
|
|
299
356
|
const q = symbols.join(" ");
|
|
300
357
|
const one = symbols[0]!;
|
|
301
358
|
return [
|
|
359
|
+
`codegraph_node({ symbol: "${one}", includeCode: true }) for its source`,
|
|
302
360
|
`codegraph_search({ query: "${one}" }) for the definition`,
|
|
303
361
|
`codegraph_callers({ symbol: "${one}" }) for usages`,
|
|
304
362
|
`codegraph_explore({ query: "${q}" }) for the code around it`,
|
|
@@ -313,9 +371,14 @@ export function decide(mode: Mode, searches: Search[], key: string, mem: GateMem
|
|
|
313
371
|
if (mode === "off") return { action: "allow" };
|
|
314
372
|
const symbols = new Set<string>();
|
|
315
373
|
const names = new Set<string>();
|
|
374
|
+
let scoped = true;
|
|
316
375
|
for (const s of searches) {
|
|
317
376
|
if (targetsOnlyNonCode(s)) continue;
|
|
318
|
-
for (const sym of (s.files ? fileSymbolIn(s.pattern) : symbolsIn(s.pattern)) ?? [])
|
|
377
|
+
for (const sym of (s.files ? fileSymbolIn(s.pattern) : symbolsIn(s.pattern)) ?? []) {
|
|
378
|
+
if (known(sym, mem)) continue;
|
|
379
|
+
(s.files ? names : symbols).add(sym);
|
|
380
|
+
if (!s.files && !onlyNamedFiles(s)) scoped = false;
|
|
381
|
+
}
|
|
319
382
|
}
|
|
320
383
|
if (symbols.size === 0 && names.size === 0) return { action: "allow" };
|
|
321
384
|
if (symbols.size === 0) {
|
|
@@ -326,8 +389,14 @@ export function decide(mode: Mode, searches: Search[], key: string, mem: GateMem
|
|
|
326
389
|
};
|
|
327
390
|
}
|
|
328
391
|
const list = [...symbols];
|
|
329
|
-
const
|
|
330
|
-
|
|
392
|
+
const where = list.flatMap((sym) => {
|
|
393
|
+
const at = mem.defined.get(leafOf(sym));
|
|
394
|
+
return at && at.length > 0 ? [`${sym} is defined at ${at.join(", ")}`] : [];
|
|
395
|
+
});
|
|
396
|
+
const found = where.length > 0 ? ` ${where.join("; ")}.` : "";
|
|
397
|
+
const hint = `"${list.join('", "')}" ${list.length === 1 ? "looks like a symbol" : "look like symbols"}: CodeGraph answers this structurally.${found} Use ${suggestion(list)}.`;
|
|
398
|
+
// A search inside named files reads code the agent already pinned down: hint, do not block.
|
|
399
|
+
if (mode === "nudge" || scoped) return { action: "nudge", hint: `[codegraph] ${hint}` };
|
|
331
400
|
if (mem.blocked.has(key)) return { action: "allow", override: true };
|
|
332
401
|
mem.blocked.add(key);
|
|
333
402
|
return {
|
package/extensions/core/tools.ts
CHANGED
|
@@ -114,7 +114,7 @@ export const isCodeGraphTool = (name: string): boolean => TOOL_NAMES.includes(na
|
|
|
114
114
|
|
|
115
115
|
/** Guidelines added to the system prompt while the tools are active (static: they never break the prompt cache). */
|
|
116
116
|
export const GUIDELINES = [
|
|
117
|
-
"Understand code through CodeGraph first: codegraph_explore for how something works or where to change it, codegraph_search to find a definition, codegraph_callers/codegraph_callees for relations, codegraph_impact before changing a signature, codegraph_files for structure.",
|
|
117
|
+
"Understand code through CodeGraph first: codegraph_explore for how something works or where to change it, codegraph_search to find a definition, codegraph_node with includeCode for a symbol's body (instead of rg -A), codegraph_callers/codegraph_callees for relations, codegraph_impact before changing a signature, codegraph_files for structure.",
|
|
118
118
|
"Do not grep (rg, grep, git grep) for symbol names: such searches are redirected to CodeGraph. Text search is for string literals, comments, config and docs, or after CodeGraph came up empty.",
|
|
119
119
|
"Read a file once CodeGraph has told you where to look, with offset/limit around the reported lines.",
|
|
120
120
|
];
|
package/extensions/graphnav.ts
CHANGED
|
@@ -17,10 +17,13 @@ import { completions, parseCommand } from "./command/parse.ts";
|
|
|
17
17
|
import { type Config, DEFAULT_CONFIG, type ProjectOverrides, readConfig, readProjectConfig, saveConfig } from "./core/config.ts";
|
|
18
18
|
import {
|
|
19
19
|
codemodeSearches,
|
|
20
|
+
candidateSymbols,
|
|
20
21
|
decide,
|
|
21
22
|
findFileSearches,
|
|
22
23
|
findSearches,
|
|
23
24
|
type GateMemory,
|
|
25
|
+
definitionsIn,
|
|
26
|
+
leafOf,
|
|
24
27
|
isSourceFile,
|
|
25
28
|
lookupWords,
|
|
26
29
|
newMemory,
|
|
@@ -131,6 +134,15 @@ export interface Deps {
|
|
|
131
134
|
breaker?: { threshold: number; resetMs: number };
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
|
|
138
|
+
new Promise((resolve, reject) => {
|
|
139
|
+
const t = setTimeout(() => reject(new Error("timeout")), ms);
|
|
140
|
+
p.then(
|
|
141
|
+
(v) => (clearTimeout(t), resolve(v)),
|
|
142
|
+
(e) => (clearTimeout(t), reject(e)),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
134
146
|
export function createExtension(deps: Deps = {}) {
|
|
135
147
|
const run = deps.run ?? defaultRunner;
|
|
136
148
|
return function (pi: ExtensionAPI) {
|
|
@@ -198,6 +210,9 @@ export function createExtension(deps: Deps = {}) {
|
|
|
198
210
|
index = status;
|
|
199
211
|
lastError = error;
|
|
200
212
|
state = error ? "error" : status.initialized ? "ready" : "missing";
|
|
213
|
+
// the index changed: earlier probe answers may be stale
|
|
214
|
+
mem.unindexed.clear();
|
|
215
|
+
mem.defined.clear();
|
|
201
216
|
})
|
|
202
217
|
.finally(() => {
|
|
203
218
|
job = undefined;
|
|
@@ -300,6 +315,24 @@ export function createExtension(deps: Deps = {}) {
|
|
|
300
315
|
if (!config.enabled || config.mode === "off" || state !== "ready" || unavailable()) return;
|
|
301
316
|
const searches = searchesOf(event.toolName, event.input ?? {});
|
|
302
317
|
if (searches.length === 0) return;
|
|
318
|
+
// Gate only what the index defines: a word it has never heard of is text, not a symbol.
|
|
319
|
+
const root = project?.root;
|
|
320
|
+
if (root) {
|
|
321
|
+
const unknown = candidateSymbols(searches, mem).filter((sym) => !mem.defined.has(leafOf(sym)));
|
|
322
|
+
await Promise.all(
|
|
323
|
+
unknown.map(async (sym) => {
|
|
324
|
+
try {
|
|
325
|
+
const res = await withTimeout(server(root).call("codegraph_search", { query: leafOf(sym), limit: 25, projectPath: root }), 2000);
|
|
326
|
+
if (res.isError) return;
|
|
327
|
+
const at = definitionsIn(resultText(res), sym);
|
|
328
|
+
if (at.length === 0) mem.unindexed.add(leafOf(sym));
|
|
329
|
+
else mem.defined.set(leafOf(sym), at);
|
|
330
|
+
} catch {
|
|
331
|
+
// index unreachable or slow: fail open to the usual gate rather than guess
|
|
332
|
+
}
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
303
336
|
const d = decide(config.mode, searches, `${event.toolName}\u0000${JSON.stringify(event.input)}`, mem);
|
|
304
337
|
if (d.action === "block") {
|
|
305
338
|
counts.blocked++;
|
package/package.json
CHANGED