pi-graphnav 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sorin Albu-Irimies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,131 @@
1
- # Temporary Holding Version
1
+ # pi-graphnav
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/pi-graphnav.svg)](https://www.npmjs.com/package/pi-graphnav)
4
+ [![CI](https://github.com/sorinirimies/pi-graphnav/actions/workflows/ci.yml/badge.svg)](https://github.com/sorinirimies/pi-graphnav/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![Audit](https://github.com/sorinirimies/pi-graphnav/actions/workflows/security.yml/badge.svg)](https://github.com/sorinirimies/pi-graphnav/actions/workflows/security.yml)
7
+ [![Fuzz](https://github.com/sorinirimies/pi-graphnav/actions/workflows/fuzz.yml/badge.svg)](https://github.com/sorinirimies/pi-graphnav/actions/workflows/fuzz.yml)
8
+
9
+ CodeGraph-first code navigation for pi: the agent understands code through
10
+ [CodeGraph](https://github.com/colbymchenry/codegraph)'s symbol graph instead of grepping and
11
+ reading file after file, without being told to.
12
+
13
+ A [pi](https://github.com/earendil-works/pi-coding-agent) extension.
14
+
15
+ ## What it does
16
+
17
+ * **Keeps the index current.** At session start, in the background: `codegraph init` for a git repo
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. 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.
22
+ * **The `codegraph_*` tools** (`explore`, `search`, `node`, `callers`, `callees`, `impact`,
23
+ `files`, `status`) on one long-lived `codegraph serve --mcp` per project: no process start per
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`).
29
+ * **Steers symbol searches to CodeGraph.** `rg parseConfig`, `grep -rn UserService src`,
30
+ `git grep`, `ag`, `ack` and pi's `grep` tool with a symbol-like pattern are blocked once with
31
+ the CodeGraph call that answers it (`enforce`), or run with that hint appended (`nudge`).
32
+ Running the same command again is allowed: CodeGraph cannot answer everything. Searches for
33
+ phrases, string literals or in docs and config (`*.md`, `--type yaml`, …) are never touched,
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).
38
+ * **Hints after blind reading.** Reading several source files before any CodeGraph call adds
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.
43
+ * **Prompt guidance** that stays the same every turn, so the prompt cache survives.
44
+
45
+ Needs the CodeGraph CLI: `npm i -g @colbymchenry/codegraph`. The project is the nearest
46
+ directory with `.codegraph/`, else the git work tree; never your home directory.
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
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pi install npm:pi-graphnav
70
+ ```
71
+
72
+ ## Use
73
+
74
+ | Command | |
75
+ |---|---|
76
+ | `/codegraph` or `/codegraph status` | project, index state, mode, blocked/nudged counts |
77
+ | `/codegraph [on\|off]` | switch it all (remembered across sessions) |
78
+ | `/codegraph mode <mode>` | symbol searches: `enforce` (default), `nudge` or `off` |
79
+ | `/codegraph sync` | sync the index with changed files |
80
+ | `/codegraph reindex` | rebuild the index from scratch |
81
+ | `/codegraph init` | create the index (for a directory that is not a git repo) |
82
+
83
+ Settings live in `<pi agent dir>/codegraph.json` (`PI_CODING_AGENT_DIR`, else `~/.pi/agent`):
84
+
85
+ | Key | Default | |
86
+ |---|---|---|
87
+ | `enabled` | `true` | master switch |
88
+ | `showStatus` | `true` | status line entry |
89
+ | `mode` | `"enforce"` | `enforce`, `nudge` or `off` |
90
+ | `autoInit` | `true` | init a git repo that has no index |
91
+ | `autoSync` | `true` | sync or rebuild a stale index at session start |
92
+ | `watch` | `true` | keep the server's file watcher running |
93
+ | `readHintAfter` | `3` | source files read before the CodeGraph hint (`0`: never) |
94
+ | `timeoutMs` | `30000` | per-call timeout of the CodeGraph server |
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
+
100
+ Replaces `@vndv/pi-codegraph` (same tool names): uninstall that one first.
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ bun install
106
+ just check # typecheck + tests (95% coverage gate) + pack check + nu script tests
107
+ just --list # everything else: fuzz, audit, release, remotes
108
+ ```
109
+
110
+ Layout:
111
+
112
+ | Path | What |
113
+ |---|---|
114
+ | `extensions/graphnav.ts` | wiring: hooks, tools, the command, the status line |
115
+ | `extensions/core/gate.ts` | spotting symbol searches in shell commands; the gate decision |
116
+ | `extensions/core/project.ts` | project root, `codegraph status -j`, what keeps the index current |
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) |
119
+ | `extensions/core/tools.ts` | tool definitions and prompt guidelines |
120
+ | `extensions/core/config.ts`, `extensions/command/parse.ts` | settings, `/codegraph` parsing |
121
+ | `tests/` | `bun test`: one file per module, `extension` (hooks), `fuzz` (randomised properties) |
122
+ | `scripts/` | Nushell only, each with a test in `scripts/tests/` |
123
+ | `examples/vhs/` | demo tapes, the scripted model, the rendered GIFs |
124
+ | `.github/`, `.gitea/` | CI, release, nightlies on both hosts |
125
+
126
+ Releases: `just release patch` bumps `package.json`, regenerates `CHANGELOG.md` with git-cliff, tags and
127
+ pushes; the Release workflow publishes to npm (secret `NPM_TOKEN`).
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,75 @@
1
+ /** `/codegraph ...`: parsing and argument completion. Pure: the handler in graphnav.ts acts on the result. */
2
+ import { MODES, type Mode } from "../core/config.ts";
3
+
4
+ export type Command =
5
+ | { kind: "status" }
6
+ | { kind: "on" }
7
+ | { kind: "off" }
8
+ | { kind: "mode"; mode: Mode }
9
+ | { kind: "sync" }
10
+ | { kind: "reindex" }
11
+ | { kind: "init" }
12
+ | { kind: "invalid"; message: string };
13
+
14
+ export const USAGE = "Usage: /codegraph [status] · on · off · mode <enforce|nudge|off> · sync · reindex · init";
15
+
16
+ export function parseCommand(args: string | undefined): Command {
17
+ const [cmd, ...rest] = (args ?? "").trim().split(/\s+/).filter(Boolean);
18
+ switch (cmd?.toLowerCase()) {
19
+ case undefined:
20
+ case "status":
21
+ return { kind: "status" };
22
+ case "on":
23
+ case "enable":
24
+ return { kind: "on" };
25
+ case "off":
26
+ case "disable":
27
+ return { kind: "off" };
28
+ case "mode": {
29
+ const m = rest[0]?.toLowerCase();
30
+ return MODES.includes(m as Mode) && rest.length === 1
31
+ ? { kind: "mode", mode: m as Mode }
32
+ : { kind: "invalid", message: "Usage: /codegraph mode <enforce|nudge|off>" };
33
+ }
34
+ case "sync":
35
+ return { kind: "sync" };
36
+ case "reindex":
37
+ case "index":
38
+ return { kind: "reindex" };
39
+ case "init":
40
+ return { kind: "init" };
41
+ default:
42
+ return { kind: "invalid", message: `Unknown option "${cmd}". ${USAGE}` };
43
+ }
44
+ }
45
+
46
+ export interface CompletionItem {
47
+ value: string;
48
+ /** Required by pi: it calls `label.endsWith(...)` when applying a completion. */
49
+ label: string;
50
+ description?: string;
51
+ }
52
+
53
+ const TOP_LEVEL: CompletionItem[] = [
54
+ { value: "status", label: "status", description: "Index state, mode and gate counts" },
55
+ { value: "on", label: "on", description: "Turn CodeGraph-first on" },
56
+ { value: "off", label: "off", description: "Turn CodeGraph-first off" },
57
+ { value: "mode ", label: "mode", description: "Symbol searches: enforce, nudge or off" },
58
+ { value: "sync", label: "sync", description: "Sync the index with changed files" },
59
+ { value: "reindex", label: "reindex", description: "Rebuild the index from scratch" },
60
+ { value: "init", label: "init", description: "Create the index for this project" },
61
+ ];
62
+
63
+ const MODE_ITEMS: CompletionItem[] = [
64
+ { value: "mode enforce", label: "enforce", description: "Block a symbol search once, pointing to CodeGraph" },
65
+ { value: "mode nudge", label: "nudge", description: "Let it run, add a CodeGraph hint to the result" },
66
+ { value: "mode off", label: "off", description: "Leave text searches alone" },
67
+ ];
68
+
69
+ /** `prefix` is the whole argument text typed so far (pi replaces it with `value`). */
70
+ export function completions(prefix: string): CompletionItem[] | null {
71
+ const text = (prefix ?? "").toLowerCase().replace(/^\s+/, "");
72
+ const pool = text.startsWith("mode ") ? MODE_ITEMS : TOP_LEVEL;
73
+ const out = pool.filter((i) => i.value.startsWith(text));
74
+ return out.length > 0 ? out : null;
75
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The persisted settings: <agent dir>/codegraph.json.
3
+ *
4
+ * A hand-edited file can hold anything, so `sanitizeConfig` keeps valid values and defaults the
5
+ * rest. A missing file is not a problem (defaults); a broken one is reported, never thrown.
6
+ */
7
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ export type Mode = "enforce" | "nudge" | "off";
12
+ export const MODES: readonly Mode[] = ["enforce", "nudge", "off"];
13
+
14
+ export interface Config {
15
+ /** Master switch: when false the extension adds no guidance, gates nothing and indexes nothing. */
16
+ enabled: boolean;
17
+ /** Show the status line entry. */
18
+ showStatus: boolean;
19
+ /** Symbol searches with rg/grep: block once (`enforce`), only add a hint (`nudge`) or leave alone (`off`). */
20
+ mode: Mode;
21
+ /** Create the index (`codegraph init`) for a git repo that has none. */
22
+ autoInit: boolean;
23
+ /** Sync or rebuild a stale index when the session starts. */
24
+ autoSync: boolean;
25
+ /** Keep the CodeGraph server's file watcher running, so the index follows edits. */
26
+ watch: boolean;
27
+ /** Source files read before any CodeGraph call that earn a hint (0 = never). */
28
+ readHintAfter: number;
29
+ /** Per-call timeout for the CodeGraph server, in ms. */
30
+ timeoutMs: number;
31
+ }
32
+
33
+ export const DEFAULT_CONFIG: Config = {
34
+ enabled: true,
35
+ showStatus: true,
36
+ mode: "enforce",
37
+ autoInit: true,
38
+ autoSync: true,
39
+ watch: true,
40
+ readHintAfter: 3,
41
+ timeoutMs: 30_000,
42
+ };
43
+
44
+ export const clampInt = (v: unknown, min: number, max: number, fallback: number): number =>
45
+ typeof v === "number" && Number.isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : fallback;
46
+
47
+ export function sanitizeConfig(raw: unknown): Config {
48
+ const r = (typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {}) as Record<string, unknown>;
49
+ const d = DEFAULT_CONFIG;
50
+ return {
51
+ enabled: typeof r.enabled === "boolean" ? r.enabled : d.enabled,
52
+ showStatus: typeof r.showStatus === "boolean" ? r.showStatus : d.showStatus,
53
+ mode: MODES.includes(r.mode as Mode) ? (r.mode as Mode) : d.mode,
54
+ autoInit: typeof r.autoInit === "boolean" ? r.autoInit : d.autoInit,
55
+ autoSync: typeof r.autoSync === "boolean" ? r.autoSync : d.autoSync,
56
+ watch: typeof r.watch === "boolean" ? r.watch : d.watch,
57
+ readHintAfter: clampInt(r.readHintAfter, 0, 100, d.readHintAfter),
58
+ timeoutMs: clampInt(r.timeoutMs, 1000, 600_000, d.timeoutMs),
59
+ };
60
+ }
61
+
62
+ /** Where pi keeps its config: PI_CODING_AGENT_DIR, then $XDG_CONFIG_HOME/pi/agent, then ~/.pi/agent. */
63
+ export function resolveAgentDir(env: Record<string, string | undefined>, home: string): string {
64
+ if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
65
+ if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "pi", "agent");
66
+ return join(home, ".pi", "agent");
67
+ }
68
+
69
+ export const configPath = (env: Record<string, string | undefined> = process.env, home = homedir()): string =>
70
+ join(resolveAgentDir(env, home), "codegraph.json");
71
+
72
+ /** The config, plus a `problem` when the file exists but cannot be used. */
73
+ export async function readConfig(path = configPath()): Promise<{ config: Config; problem?: string }> {
74
+ let raw: string;
75
+ try {
76
+ raw = await readFile(path, "utf8");
77
+ } catch (e) {
78
+ const code = (e as { code?: string } | null)?.code;
79
+ return code === "ENOENT" ? { config: sanitizeConfig({}) } : { config: sanitizeConfig({}), problem: `cannot read ${path} (${code ?? "error"})` };
80
+ }
81
+ try {
82
+ return { config: sanitizeConfig(JSON.parse(raw)) };
83
+ } catch {
84
+ return { config: sanitizeConfig({}), problem: `${path} is not valid JSON` };
85
+ }
86
+ }
87
+
88
+ export async function loadConfig(path = configPath()): Promise<Config> {
89
+ return (await readConfig(path)).config;
90
+ }
91
+
92
+ /** Atomic: written to a temp file and renamed, so a crash or a second pi session never leaves half a file. */
93
+ export async function saveConfig(config: Config, path = configPath()): Promise<void> {
94
+ await mkdir(join(path, ".."), { recursive: true });
95
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
96
+ try {
97
+ await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, "utf8");
98
+ await rename(tmp, path);
99
+ } catch (e) {
100
+ await Promise.allSettled([rm(tmp, { force: true })]); // the original error matters more
101
+ throw e;
102
+ }
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
+ }