pi-gitnexus 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/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ - **TypeScript compatibility** — `tool_result` handler now uses inferred event types from the API overload instead of a manual annotation, fixing a compile error with `@mariozechner/pi-coding-agent` 0.54.x.
6
+ - **Dependency updates** — `@mariozechner/pi-coding-agent` to 0.54.2, `@types/node` to 25.x.
7
+
8
+ ## 0.2.0
9
+
10
+ ### Features
11
+
12
+ - **`read` tool augmentation** — file reads now trigger augmentation. When the agent reads a code file (`.sol`, `.ts`, `.go`, etc.), the filename is used as the lookup pattern to get callers/callees for that file's symbols.
13
+ - **Bash `cat`/`head`/`tail` support** — extractPattern now handles `cat file.sol`, `head file.sol`, etc. alongside grep/rg/find.
14
+ - **Multi-pattern augmentation** — each tool result now augments up to 3 patterns in parallel: the primary pattern from the tool input, plus filenames extracted from grep output lines (`path/file.sol:line:`). Results are merged into a single `[GitNexus]` block.
15
+ - **Session dedup cache** — each symbol/filename is augmented at most once per session, preventing redundant lookups when the agent repeatedly searches for the same thing.
16
+
17
+ ### Fixes
18
+
19
+ - **Auto-augment now works** — `gitnexus augment` writes its output to stderr, not stdout. `runAugment` was capturing stdout only, so every augmentation returned empty. Fixed by reading from stderr (`stdio: ['ignore', 'ignore', 'pipe']`).
20
+ - **Regex patterns cleaned before augment** — grep/rg patterns like `\bwithdraw\s*\(` are stripped of regex metacharacters before passing to `gitnexus augment`, which expects a plain symbol name.
21
+ - **`gitnexus_query` limit raised** — max `limit` parameter increased from 20 to 100. The agent was hitting validation errors when requesting more results.
22
+ - **Status counters** — `/gitnexus status` now shows searches intercepted and enrichment count for observability.
23
+
24
+ ## 0.1.0
25
+
26
+ Initial release.
27
+
28
+ ### Features
29
+
30
+ - **Auto-augment hook** — intercepts grep, find, and bash tool results and appends knowledge graph context (callers, callees, execution flows) via `gitnexus augment`. Mirrors the Claude Code plugin's PreToolUse integration.
31
+ - **Five registered tools** — `gitnexus_list_repos`, `gitnexus_query`, `gitnexus_context`, `gitnexus_impact`, `gitnexus_detect_changes` available to the agent with zero setup.
32
+ - **stdio MCP client** — tools communicate with `gitnexus mcp` over a stdin/stdout pipe (no network socket, no port). Process is spawned lazily and kept alive for the session.
33
+ - **System prompt hint** — when an index is present, appends a one-liner to the agent's system prompt so it understands graph context and knows to use the tools.
34
+ - **Session lifecycle** — on session start/switch: resolves full shell PATH (nvm/fnm/volta), probes binary, checks index, notifies status. MCP process restarted on session switch.
35
+ - **`/gitnexus` command** with subcommands: `status`, `analyze`, `on`, `off`, `query`, `context`, `impact`, `<pattern>`, `help`.
36
+ - **`/gitnexus analyze`** — runs `gitnexus analyze` from within pi with start/completion notifications. Auto-augment is paused for the duration to avoid stale index results.
37
+ - **Toggle** — `/gitnexus on` / `/gitnexus off` enables/disables auto-augment without affecting tools. Resets to enabled on session switch.
38
+ - **Shell PATH resolution** — spawns `$SHELL -lc 'echo $PATH'` on session start so nvm/fnm/volta-managed binaries are found when pi is launched as a GUI app.
39
+ - **Path traversal guard** — `gitnexus_context` file parameter validated to stay within cwd before passing to the MCP server.
40
+ - **Graceful failure** — every code path (augment timeout, MCP spawn error, binary missing) returns empty rather than throwing. Extension never breaks the agent's normal flow.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tintinweb
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 ADDED
@@ -0,0 +1,103 @@
1
+ # pi-gitnexus
2
+
3
+ [GitNexus](https://github.com/abhigyanpatwari/GitNexus) knowledge graph integration for [pi](https://github.com/mariozechner/pi). Enriches every search, file read, and symbol lookup with call chains, callers/callees, and execution flows — automatically.
4
+
5
+ <img height="298" alt="pi-gitnexus screenshot" src="https://github.com/tintinweb/pi-gitnexus/raw/master/media/screenshot.png" />
6
+
7
+
8
+ https://github.com/user-attachments/assets/49e61667-f508-4d22-abad-05241e414664
9
+
10
+ > The graph view in the demo is from [gitnexus-web](https://github.com/abhigyanpatwari/GitNexus) and is not part of this extension.
11
+
12
+ ## What it does
13
+
14
+ When the agent reads a file or runs a search (grep, find, bash), the extension appends graph context from the knowledge graph inline with the results. The agent sees both together and can follow call chains without additional queries.
15
+
16
+ ```
17
+ Agent reads auth/session.ts
18
+ → file content returned normally
19
+ → [GitNexus] appended: callers of the module, what it imports, related tests
20
+
21
+ Agent runs grep("validateUser")
22
+ → grep results returned normally
23
+ → [GitNexus] appended: Called by: login, signup / Calls: checkPermissions, getUser
24
+ → filenames in the grep output are also looked up in parallel
25
+ ```
26
+
27
+ Five tools are also registered directly in pi — the agent can use them explicitly for deeper analysis without any setup.
28
+
29
+ ## Requirements
30
+
31
+ - [gitnexus](https://github.com/abhigyanpatwari/GitNexus) installed globally: `npm i -g gitnexus`
32
+ - A GitNexus index in your project: run `/gitnexus analyze`
33
+
34
+ ## Getting started
35
+
36
+ 1. Install gitnexus: `npm i -g gitnexus`
37
+ 2. Open your project in pi
38
+ 3. Run `/gitnexus analyze` to build the knowledge graph
39
+ 4. Done — file reads and searches are now enriched automatically
40
+
41
+ ## What triggers augmentation
42
+
43
+ | Tool | Pattern used |
44
+ |---|---|
45
+ | `grep` | Search pattern (regex metacharacters stripped) |
46
+ | `bash` with grep/rg | First non-flag argument after `grep`/`rg` |
47
+ | `bash` with cat/head/tail | Filename of the target file |
48
+ | `bash` with find | Value of `-name`/`-iname` |
49
+ | `find` | Glob pattern basename |
50
+ | `read` | Filename of the file being read (code files only) |
51
+ | Any grep/bash result | Filenames extracted from result lines (`path/file.sol:line:`) |
52
+
53
+ Each tool result augments up to 3 patterns in parallel. Patterns already augmented this session are skipped.
54
+
55
+ ## Commands
56
+
57
+ | Command | Description |
58
+ |---|---|
59
+ | `/gitnexus` | Show index status and session enrichment count |
60
+ | `/gitnexus analyze` | Build or rebuild the knowledge graph |
61
+ | `/gitnexus on` / `/gitnexus off` | Enable/disable auto-augment (tools unaffected) |
62
+ | `/gitnexus <pattern>` | Manual graph lookup for a symbol or pattern |
63
+ | `/gitnexus query <text>` | Search execution flows |
64
+ | `/gitnexus context <name>` | 360° view of a symbol: callers, callees, processes |
65
+ | `/gitnexus impact <name>` | Upstream blast radius of a change |
66
+ | `/gitnexus help` | Show command reference |
67
+
68
+ ## Agent tools
69
+
70
+ The following tools are registered in pi and always available to the agent:
71
+
72
+ | Tool | Description |
73
+ |---|---|
74
+ | `gitnexus_list_repos` | List all indexed repositories |
75
+ | `gitnexus_query` | Search the knowledge graph for execution flows |
76
+ | `gitnexus_context` | 360° view of a symbol: callers, callees, processes |
77
+ | `gitnexus_impact` | Blast radius analysis for a symbol |
78
+ | `gitnexus_detect_changes` | Map a git diff to affected execution flows |
79
+
80
+ ## How it works
81
+
82
+ **Auto-augment hook** — fires after every grep/find/bash/read tool result. Extracts up to 3 patterns (primary from input, secondary filenames from result content) and calls `gitnexus augment` for each in parallel. Results are merged into a single `[GitNexus]` block appended to the tool result, so the agent sees it inline.
83
+
84
+ **Session dedup cache** — each symbol or filename is augmented at most once per session. Prevents redundant lookups when the agent repeatedly searches for the same thing.
85
+
86
+ **MCP client** — tools (query, context, impact, detect_changes, list_repos) communicate with `gitnexus mcp` over a stdio pipe. The process is spawned lazily on the first tool call and kept alive for the session. No network socket, no port.
87
+
88
+ **Session lifecycle** — on session start/switch, the extension resolves the full shell PATH (picking up nvm/fnm/volta), probes the binary, checks for an index, and notifies accordingly. The MCP process is restarted with the new working directory.
89
+
90
+ **Auto-augment toggle** — `/gitnexus off` disables the hook without affecting tools. Useful when the graph output is noisy for a particular task. Resets to enabled on session switch.
91
+
92
+ **Analyze guard** — auto-augment is paused during `/gitnexus analyze` to avoid injecting stale or partially-built index results.
93
+
94
+ ## License note
95
+
96
+ This extension (pi-gitnexus) is MIT licensed. [GitNexus](https://github.com/abhigyanpatwari/GitNexus) itself is published under the [PolyForm Noncommercial License](https://polyformproject.org/licenses/noncommercial/1.0.0/) — commercial use requires a separate agreement with its author. Install and use gitnexus in accordance with its license terms.
97
+
98
+ ## Notes
99
+
100
+ - The index is a static snapshot. Re-run `/gitnexus analyze` after significant code changes. The agent will suggest this when the index appears stale.
101
+ - `gitnexus_detect_changes` is a lightweight alternative: pass `git diff HEAD` output to see affected flows without a full reindex.
102
+ - `gitnexus_cypher` and `gitnexus_rename` are intentionally not exposed (raw graph access and automated multi-file rename).
103
+ - The enrichment is appended to the tool result the agent receives — files on disk and raw tool outputs are never modified.
Binary file
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "pi-gitnexus",
3
+ "version": "0.2.1",
4
+ "description": "GitNexus knowledge graph integration for pi — enriches searches with call chains, execution flows, and blast radius",
5
+ "author": "tintinweb",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi",
10
+ "pi-extension",
11
+ "gitnexus",
12
+ "knowledge-graph",
13
+ "code-intelligence",
14
+ "call-graph",
15
+ "agent"
16
+ ],
17
+ "dependencies": {
18
+ "@mariozechner/pi-coding-agent": "latest",
19
+ "@sinclair/typebox": "latest"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^25.3.0",
23
+ "typescript": "^5.0.0"
24
+ },
25
+ "pi": {
26
+ "extensions": [
27
+ "./src/index.ts"
28
+ ],
29
+ "image": "https://github.com/tintinweb/pi-gitnexus/raw/master/media/screenshot.png"
30
+ }
31
+ }
@@ -0,0 +1,204 @@
1
+ import { spawn } from 'child_process';
2
+ import { resolve, sep, basename, extname } from 'path';
3
+ import { existsSync } from 'fs';
4
+
5
+ /** Max output chars returned to the LLM. Prevents context flooding. JS strings are UTF-16 chars, not bytes. */
6
+ export const MAX_OUTPUT_CHARS = 8 * 1024;
7
+
8
+ /**
9
+ * Environment passed to all child processes.
10
+ * Resolved from a login shell on session_start to pick up nvm/fnm/volta PATH entries
11
+ * that are missing when pi launches as a GUI app.
12
+ */
13
+ export let spawnEnv: NodeJS.ProcessEnv = process.env;
14
+ export function updateSpawnEnv(env: NodeJS.ProcessEnv): void { spawnEnv = env; }
15
+
16
+ /** Augment subprocess timeout in ms. Applied via setTimeout + proc.kill('SIGTERM') — spawn has no built-in timeout. */
17
+ const AUGMENT_TIMEOUT = 8_000;
18
+
19
+ /** Per-cwd cache: true = index found, false = not found. Invalidated on session_switch. */
20
+ const indexCache = new Map<string, boolean>();
21
+
22
+ /** Walk up to 5 ancestors looking for a .gitnexus/ directory. Result is cached per cwd. */
23
+ export function findGitNexusIndex(cwd: string): boolean {
24
+ if (indexCache.has(cwd)) return indexCache.get(cwd)!;
25
+ let dir = cwd;
26
+ for (let i = 0; i < 5; i++) {
27
+ if (existsSync(resolve(dir, '.gitnexus'))) {
28
+ indexCache.set(cwd, true);
29
+ return true;
30
+ }
31
+ const parent = resolve(dir, '..');
32
+ if (parent === dir) break;
33
+ dir = parent;
34
+ }
35
+ indexCache.set(cwd, false);
36
+ return false;
37
+ }
38
+
39
+ /** Clear the index cache. Call on session_switch when cwd may have changed. */
40
+ export function clearIndexCache(): void {
41
+ indexCache.clear();
42
+ }
43
+
44
+ /** File extensions worth augmenting when the agent reads a file. */
45
+ const CODE_EXTENSIONS = new Set([
46
+ '.sol', '.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java',
47
+ '.kt', '.swift', '.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php',
48
+ '.vy', '.fe', '.huff',
49
+ ]);
50
+
51
+ /**
52
+ * Extract the primary search pattern from a tool's input object.
53
+ *
54
+ * grep → input.pattern
55
+ * find → basename of the glob pattern (e.g. "**\/foo.ts" → "foo")
56
+ * bash → grep/rg pattern, find -name value, or cat/head/tail filename
57
+ * read → basename of the file path (code files only)
58
+ *
59
+ * Returns null if pattern is missing or shorter than 3 chars.
60
+ */
61
+ export function extractPattern(toolName: string, input: Record<string, unknown>): string | null {
62
+ let pattern: string | null = null;
63
+
64
+ if (toolName === 'grep') {
65
+ const raw = typeof input.pattern === 'string' ? input.pattern : null;
66
+ // Strip regex metacharacters — gitnexus augment expects a plain symbol/identifier name.
67
+ pattern = raw ? raw.replace(/[\\^$.*+?()[\]{}|]/g, '') : null;
68
+ } else if (toolName === 'find') {
69
+ // pi's find tool field name is unconfirmed — try common variants
70
+ const raw =
71
+ typeof input.pattern === 'string' ? input.pattern :
72
+ typeof input.glob === 'string' ? input.glob :
73
+ typeof input.path === 'string' ? input.path :
74
+ null;
75
+ if (raw) {
76
+ const seg = basename(raw).replace(/\.\w+$/, '').replace(/[*?[\]{}]/g, '');
77
+ pattern = seg || null;
78
+ }
79
+ } else if (toolName === 'bash') {
80
+ const cmd = typeof input.command === 'string' ? input.command : '';
81
+ const tokens = cmd.split(/\s+/);
82
+ let foundCmd = false;
83
+ let foundFileCmd = false;
84
+ for (let i = 0; i < tokens.length; i++) {
85
+ const tok = tokens[i];
86
+
87
+ // grep/rg: first non-flag arg after the command is the search pattern
88
+ if (tok === 'grep' || tok === 'rg') { foundCmd = true; foundFileCmd = false; continue; }
89
+ if (foundCmd) {
90
+ if (tok.startsWith('-')) continue;
91
+ // Strip quotes and regex metacharacters — gitnexus augment expects a plain symbol name.
92
+ pattern = tok.replace(/['"]/g, '').replace(/[\\^$.*+?()[\]{}|]/g, '');
93
+ break;
94
+ }
95
+
96
+ // cat / head / tail / less / wc: next non-flag arg is a file path → use basename
97
+ if (tok === 'cat' || tok === 'head' || tok === 'tail' || tok === 'less' || tok === 'wc') {
98
+ foundFileCmd = true; foundCmd = false; continue;
99
+ }
100
+ if (foundFileCmd) {
101
+ if (tok.startsWith('-')) continue;
102
+ const unquoted = tok.replace(/['"]/g, '');
103
+ const ext = extname(unquoted);
104
+ if (CODE_EXTENSIONS.has(ext)) {
105
+ pattern = basename(unquoted).replace(/\.\w+$/, '');
106
+ }
107
+ break;
108
+ }
109
+
110
+ // find -name / -iname: strip glob chars and extension from value
111
+ if (tok === 'find') { foundCmd = false; foundFileCmd = false; continue; }
112
+ if ((tok === '-name' || tok === '-iname') && tokens[i + 1]) {
113
+ const unquoted = tokens[i + 1].replace(/['"]/g, '');
114
+ const seg = basename(unquoted).replace(/\.\w+$/, '').replace(/[*?[\]{}]/g, '');
115
+ if (seg.length >= 3) { pattern = seg; }
116
+ break;
117
+ }
118
+ }
119
+ } else if (toolName === 'read') {
120
+ const raw = typeof input.path === 'string' ? input.path : null;
121
+ if (raw && CODE_EXTENSIONS.has(extname(raw))) {
122
+ pattern = basename(raw).replace(/\.\w+$/, '');
123
+ }
124
+ }
125
+
126
+ if (!pattern || pattern.length < 3) return null;
127
+ return pattern;
128
+ }
129
+
130
+ /**
131
+ * Extract up to `limit` unique file basenames (without extension) from
132
+ * grep-style output lines of the form "path/to/file.ext:lineno:content".
133
+ * Used to augment secondary context from search results.
134
+ */
135
+ export function extractFilePatternsFromContent(
136
+ content: { type: string; text?: string }[],
137
+ limit = 2,
138
+ ): string[] {
139
+ const text = content.map(c => c.text ?? '').join('\n');
140
+ const seen = new Set<string>();
141
+ const results: string[] = [];
142
+ for (const line of text.split('\n')) {
143
+ // Match "some/path/File.ext:digits:" at the start of a line
144
+ const m = line.match(/^([^\n:]+\.\w+):\d+:/);
145
+ if (!m) continue;
146
+ const base = basename(m[1]).replace(/\.\w+$/, '');
147
+ if (base.length >= 3 && !seen.has(base)) {
148
+ seen.add(base);
149
+ results.push(base);
150
+ }
151
+ if (results.length >= limit) break;
152
+ }
153
+ return results;
154
+ }
155
+
156
+ /**
157
+ * Validate that a file path stays within cwd (path traversal guard).
158
+ * Returns the resolved absolute path, or null if it escapes cwd.
159
+ */
160
+ export function safeResolvePath(file: string, cwd: string): string | null {
161
+ const resolved = resolve(cwd, file);
162
+ return resolved.startsWith(cwd + sep) || resolved === cwd ? resolved : null;
163
+ }
164
+
165
+ /**
166
+ * Spawn `gitnexus augment <pattern>` and return its output.
167
+ * gitnexus augment writes results to stderr (not stdout).
168
+ * Used by the tool_result hook — not by registered tools (those use mcp-client).
169
+ * Returns output trimmed and truncated to MAX_OUTPUT_CHARS, or "" on any error.
170
+ */
171
+ export async function runAugment(pattern: string, cwd: string): Promise<string> {
172
+ return new Promise((resolve_) => {
173
+ // gitnexus augment writes results to stderr (not stdout)
174
+ let output = '';
175
+ let done = false;
176
+
177
+ const proc = spawn('gitnexus', ['augment', pattern], {
178
+ cwd,
179
+ stdio: ['ignore', 'ignore', 'pipe'],
180
+ env: spawnEnv,
181
+ });
182
+
183
+ const timer = setTimeout(() => {
184
+ if (!done) {
185
+ done = true;
186
+ proc.kill('SIGTERM');
187
+ resolve_('');
188
+ }
189
+ }, AUGMENT_TIMEOUT);
190
+
191
+ proc.stderr.on('data', (chunk: { toString(): string }) => { output += chunk.toString(); });
192
+
193
+ proc.on('close', (code: number | null) => {
194
+ if (done) return;
195
+ done = true;
196
+ clearTimeout(timer);
197
+ resolve_(code === 0 ? output.trim().slice(0, MAX_OUTPUT_CHARS) : '');
198
+ });
199
+
200
+ proc.on('error', () => {
201
+ if (!done) { done = true; clearTimeout(timer); resolve_(''); }
202
+ });
203
+ });
204
+ }
package/src/index.ts ADDED
@@ -0,0 +1,260 @@
1
+ import { spawn } from 'child_process';
2
+ import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
3
+ import { findGitNexusIndex, clearIndexCache, extractPattern, extractFilePatternsFromContent, runAugment, spawnEnv, updateSpawnEnv } from './gitnexus';
4
+ import { mcpClient } from './mcp-client';
5
+ import { registerTools } from './tools';
6
+
7
+ const SEARCH_TOOLS = new Set(['grep', 'find', 'bash', 'read']);
8
+
9
+ /** Resolve PATH from a login shell so nvm/fnm/volta binaries are visible. */
10
+ async function resolveShellPath(): Promise<void> {
11
+ const shell = process.env.SHELL ?? '/bin/sh';
12
+ const path = await new Promise<string>((resolve_) => {
13
+ let out = '';
14
+ const proc = spawn(shell, ['-lc', 'echo $PATH'], { stdio: ['ignore', 'pipe', 'ignore'] });
15
+ proc.stdout.on('data', (d: { toString(): string }) => { out += d.toString(); });
16
+ proc.on('close', () => resolve_(out.trim()));
17
+ proc.on('error', () => resolve_(process.env.PATH ?? ''));
18
+ });
19
+ updateSpawnEnv({ ...process.env, PATH: path });
20
+ }
21
+
22
+ function probeGitNexusBinary(): Promise<boolean> {
23
+ return new Promise((resolve_) => {
24
+ const proc = spawn('gitnexus', ['--version'], { stdio: 'ignore', env: spawnEnv });
25
+ proc.on('close', (code: number | null) => resolve_(code === 0));
26
+ proc.on('error', () => resolve_(false));
27
+ });
28
+ }
29
+
30
+ /** Cached from session_start/session_switch — avoids re-probing on every /gitnexus status. */
31
+ let binaryAvailable = false;
32
+
33
+ /** Working directory of the current session — ctx.cwd in tool_result events may differ. */
34
+ let sessionCwd = '';
35
+
36
+ /** Controls whether the tool_result hook auto-appends graph context. Tools are unaffected. */
37
+ let augmentEnabled = true;
38
+
39
+ /** Number of successful augmentations this session. Shown in /gitnexus status. */
40
+ let augmentHits = 0;
41
+
42
+ /** Number of times the tool_result hook intercepted a search tool result this session. */
43
+ let hookFires = 0;
44
+
45
+ /**
46
+ * Patterns already augmented this session.
47
+ * Prevents the same symbol/file from being looked up repeatedly.
48
+ */
49
+ const augmentedCache = new Set<string>();
50
+
51
+ export default function(pi: ExtensionAPI) {
52
+ registerTools(pi);
53
+
54
+ // Append a one-liner so the agent understands graph context in search results.
55
+ pi.on('before_agent_start', async (event: { systemPrompt?: string }, ctx: ExtensionContext) => {
56
+ if (!findGitNexusIndex(ctx.cwd)) return;
57
+ if (event.systemPrompt == null) return;
58
+ return {
59
+ systemPrompt:
60
+ event.systemPrompt +
61
+ '\n\n[GitNexus active] Graph context will appear after search results. ' +
62
+ 'Use gitnexus_query, gitnexus_context, gitnexus_impact, gitnexus_detect_changes, ' +
63
+ 'gitnexus_list_repos for deeper analysis of call chains and execution flows. ' +
64
+ 'If the index is stale after code changes, run /gitnexus analyze to rebuild it.',
65
+ };
66
+ });
67
+
68
+ // Core hook: mirrors the Claude Code PreToolUse integration.
69
+ // Intercepts grep/find/bash/read results, appends knowledge graph context.
70
+ pi.on('tool_result', async (event, ctx) => {
71
+ if (!augmentEnabled) return;
72
+ if (!SEARCH_TOOLS.has(event.toolName)) return;
73
+ hookFires++;
74
+ const cwd = sessionCwd || ctx.cwd;
75
+ if (!findGitNexusIndex(cwd)) return;
76
+
77
+ // Collect patterns: primary from input, secondary filenames from result content.
78
+ const primary = extractPattern(event.toolName, event.input);
79
+ const secondary = (event.toolName === 'grep' || event.toolName === 'bash')
80
+ ? extractFilePatternsFromContent(event.content)
81
+ : [];
82
+ const candidates = [...new Set([primary, ...secondary].filter((p): p is string => !!p))];
83
+
84
+ // Filter patterns already augmented this session.
85
+ const fresh = candidates.filter(p => !augmentedCache.has(p));
86
+ if (fresh.length === 0) return;
87
+
88
+ // Run up to 3 augments in parallel, merge results.
89
+ const toRun = fresh.slice(0, 3);
90
+ toRun.forEach(p => augmentedCache.add(p));
91
+ const results = await Promise.all(toRun.map(p => runAugment(p, cwd)));
92
+ const combined = results.filter(Boolean).join('\n\n');
93
+ if (!combined) return;
94
+
95
+ augmentHits++;
96
+ return {
97
+ content: [
98
+ ...event.content,
99
+ { type: 'text' as const, text: `\n\n[GitNexus]\n${combined}` },
100
+ ],
101
+ };
102
+ });
103
+
104
+ async function onSession(ctx: ExtensionContext) {
105
+ mcpClient.stop();
106
+ clearIndexCache();
107
+ augmentEnabled = true;
108
+ augmentHits = 0;
109
+ hookFires = 0;
110
+ augmentedCache.clear();
111
+ sessionCwd = ctx.cwd;
112
+ await resolveShellPath();
113
+
114
+ binaryAvailable = await probeGitNexusBinary();
115
+ if (!findGitNexusIndex(ctx.cwd)) return;
116
+
117
+ if (binaryAvailable) {
118
+ ctx.ui.notify(
119
+ 'GitNexus: knowledge graph active — searches will be enriched automatically.',
120
+ 'info',
121
+ );
122
+ } else {
123
+ ctx.ui.notify(
124
+ 'GitNexus index found but gitnexus is not on PATH. Install: npm i -g gitnexus',
125
+ 'warning',
126
+ );
127
+ }
128
+ }
129
+
130
+ pi.on('session_start', (_event: unknown, ctx: ExtensionContext) => { void onSession(ctx); });
131
+ pi.on('session_switch', (_event: unknown, ctx: ExtensionContext) => { void onSession(ctx); });
132
+
133
+ pi.registerCommand('gitnexus', {
134
+ description: 'GitNexus knowledge graph. Type /gitnexus help for usage.',
135
+ handler: async (args: string, ctx: ExtensionContext) => {
136
+ const parts = args.trim().split(/\s+/);
137
+ const sub = parts[0] ?? '';
138
+ const rest = parts.slice(1).join(' ');
139
+
140
+ // /gitnexus or /gitnexus status
141
+ if (!sub || sub === 'status') {
142
+ if (!binaryAvailable) {
143
+ ctx.ui.notify('gitnexus is not installed. Install: npm i -g gitnexus', 'warning');
144
+ return;
145
+ }
146
+ if (!findGitNexusIndex(ctx.cwd)) {
147
+ ctx.ui.notify('No GitNexus index found. Run: /gitnexus analyze', 'info');
148
+ return;
149
+ }
150
+ const out = await new Promise<string>((resolve_) => {
151
+ let stdout = '';
152
+ const proc = spawn('gitnexus', ['status'], {
153
+ cwd: ctx.cwd,
154
+ stdio: ['ignore', 'pipe', 'ignore'],
155
+ env: spawnEnv,
156
+ });
157
+ proc.stdout.on('data', (chunk: { toString(): string }) => { stdout += chunk.toString(); });
158
+ proc.on('close', () => resolve_(stdout.trim()));
159
+ proc.on('error', () => resolve_(''));
160
+ });
161
+ const augmentLine = augmentEnabled
162
+ ? `Auto-augment: on (${hookFires} intercepted, ${augmentHits} enriched this session)`
163
+ : 'Auto-augment: off';
164
+ ctx.ui.notify((out ? out + '\n' : '') + augmentLine, 'info');
165
+ return;
166
+ }
167
+
168
+ // /gitnexus help
169
+ if (sub === 'help') {
170
+ ctx.ui.notify(
171
+ '/gitnexus — GitNexus knowledge graph\n' +
172
+ '\n' +
173
+ 'Commands:\n' +
174
+ ' /gitnexus — show status\n' +
175
+ ' /gitnexus analyze — index the codebase\n' +
176
+ ' /gitnexus on|off — enable/disable auto-augment on searches\n' +
177
+ ' /gitnexus <pattern> — manual graph lookup\n' +
178
+ ' /gitnexus query <q> — search execution flows\n' +
179
+ ' /gitnexus context <n> — callers/callees of a symbol\n' +
180
+ ' /gitnexus impact <n> — blast radius of a change\n' +
181
+ '\n' +
182
+ 'Tools (always available to the agent):\n' +
183
+ ' gitnexus_list_repos, gitnexus_query, gitnexus_context,\n' +
184
+ ' gitnexus_impact, gitnexus_detect_changes',
185
+ 'info',
186
+ );
187
+ return;
188
+ }
189
+
190
+ // /gitnexus on | off
191
+ if (sub === 'on' || sub === 'off') {
192
+ augmentEnabled = sub === 'on';
193
+ ctx.ui.notify(`GitNexus auto-augment ${augmentEnabled ? 'enabled' : 'disabled'}.`, 'info');
194
+ return;
195
+ }
196
+
197
+ // /gitnexus analyze
198
+ if (sub === 'analyze') {
199
+ if (!binaryAvailable) {
200
+ ctx.ui.notify('gitnexus is not installed. Install: npm i -g gitnexus', 'warning');
201
+ return;
202
+ }
203
+ augmentEnabled = false;
204
+ ctx.ui.notify('GitNexus: analyzing codebase, this may take a while…', 'info');
205
+ const exitCode = await new Promise<number | null>((resolve_) => {
206
+ const proc = spawn('gitnexus', ['analyze'], {
207
+ cwd: ctx.cwd,
208
+ stdio: 'ignore',
209
+ env: spawnEnv,
210
+ });
211
+ proc.on('close', resolve_);
212
+ proc.on('error', () => resolve_(null));
213
+ });
214
+ if (exitCode === 0) {
215
+ clearIndexCache();
216
+ augmentEnabled = true;
217
+ ctx.ui.notify('GitNexus: analysis complete. Knowledge graph ready.', 'info');
218
+ } else {
219
+ augmentEnabled = true;
220
+ ctx.ui.notify('GitNexus: analysis failed. Check the terminal for details.', 'error');
221
+ }
222
+ return;
223
+ }
224
+
225
+ // /gitnexus query <text>
226
+ if (sub === 'query') {
227
+ if (!rest) { ctx.ui.notify('Usage: /gitnexus query <text>', 'info'); return; }
228
+ const out = await mcpClient.callTool('query', { query: rest }, ctx.cwd);
229
+ if (out) pi.sendUserMessage(out, { deliverAs: 'followUp' });
230
+ else ctx.ui.notify('No results.', 'info');
231
+ return;
232
+ }
233
+
234
+ // /gitnexus context <name>
235
+ if (sub === 'context') {
236
+ if (!rest) { ctx.ui.notify('Usage: /gitnexus context <name>', 'info'); return; }
237
+ const out = await mcpClient.callTool('context', { name: rest }, ctx.cwd);
238
+ if (out) pi.sendUserMessage(out, { deliverAs: 'followUp' });
239
+ else ctx.ui.notify('No results.', 'info');
240
+ return;
241
+ }
242
+
243
+ // /gitnexus impact <name>
244
+ if (sub === 'impact') {
245
+ if (!rest) { ctx.ui.notify('Usage: /gitnexus impact <name>', 'info'); return; }
246
+ const out = await mcpClient.callTool('impact', { target: rest, direction: 'upstream' }, ctx.cwd);
247
+ if (out) pi.sendUserMessage(out, { deliverAs: 'followUp' });
248
+ else ctx.ui.notify('No results.', 'info');
249
+ return;
250
+ }
251
+
252
+ // /gitnexus <pattern> — manual augment lookup
253
+ const pattern = sub + (rest ? ' ' + rest : '');
254
+ if (pattern.length < 3) { ctx.ui.notify('Pattern too short (min 3 chars).', 'info'); return; }
255
+ const out = await runAugment(pattern, ctx.cwd);
256
+ if (out) pi.sendUserMessage('[GitNexus]\n' + out, { deliverAs: 'followUp' });
257
+ else ctx.ui.notify('No graph context found for: ' + pattern, 'info');
258
+ },
259
+ });
260
+ }
@@ -0,0 +1,182 @@
1
+ import { spawn, ChildProcess } from 'child_process';
2
+ import { MAX_OUTPUT_CHARS, spawnEnv } from './gitnexus';
3
+
4
+ interface JsonRpcResponse {
5
+ jsonrpc: '2.0';
6
+ id?: number;
7
+ result?: unknown;
8
+ error?: { code: number; message: string };
9
+ }
10
+
11
+ interface McpContent {
12
+ type: string;
13
+ text?: string;
14
+ isError?: boolean;
15
+ }
16
+
17
+ interface McpToolResult {
18
+ content: McpContent[];
19
+ isError?: boolean;
20
+ }
21
+
22
+ /**
23
+ * Thin stdio JSON-RPC 2.0 client for `gitnexus mcp`.
24
+ *
25
+ * Communication is exclusively over the spawned process's stdin/stdout pipe —
26
+ * no network socket, no port. Only our process can write to the pipe.
27
+ *
28
+ * The MCP process is started lazily on the first callTool() invocation and
29
+ * kept alive for the session lifetime. stop() terminates it; the next callTool()
30
+ * re-spawns with the new cwd.
31
+ */
32
+ class GitNexusMcpClient {
33
+ private proc: ChildProcess | null = null;
34
+ private buffer = '';
35
+ private pending = new Map<number, { resolve: (raw: string) => void; reject: (e: Error) => void }>();
36
+ private nextId = 2; // id 1 is reserved for the initialize handshake
37
+ private startPromise: Promise<void> | null = null;
38
+
39
+ /**
40
+ * Lazily spawn `gitnexus mcp` and complete the MCP initialize handshake.
41
+ * Idempotent — concurrent calls await the same promise; only one process spawns.
42
+ */
43
+ private ensureStarted(cwd: string): Promise<void> {
44
+ if (this.proc) return Promise.resolve();
45
+ if (this.startPromise) return this.startPromise;
46
+
47
+ this.startPromise = new Promise<void>((resolve_, reject) => {
48
+ const proc = spawn('gitnexus', ['mcp'], {
49
+ cwd,
50
+ stdio: ['pipe', 'pipe', 'ignore'],
51
+ env: spawnEnv,
52
+ });
53
+
54
+ proc.on('error', (err) => {
55
+ this.startPromise = null;
56
+ reject(err);
57
+ });
58
+
59
+ proc.stdout!.setEncoding('utf8');
60
+ proc.stdout!.on('data', (chunk: string) => {
61
+ this.buffer += chunk;
62
+ const lines = this.buffer.split('\n');
63
+ this.buffer = lines.pop() ?? '';
64
+ for (const line of lines) {
65
+ if (!line.trim()) continue;
66
+ try {
67
+ const msg = JSON.parse(line) as JsonRpcResponse;
68
+ if (msg.id !== undefined) {
69
+ const p = this.pending.get(msg.id);
70
+ if (p) { this.pending.delete(msg.id); p.resolve(line); }
71
+ }
72
+ } catch { /* ignore malformed lines */ }
73
+ }
74
+ });
75
+
76
+ proc.on('close', () => {
77
+ this.proc = null;
78
+ this.startPromise = null;
79
+ for (const p of this.pending.values()) {
80
+ p.reject(new Error('gitnexus mcp process exited'));
81
+ }
82
+ this.pending.clear();
83
+ });
84
+
85
+ // MCP initialize handshake
86
+ const initMsg = JSON.stringify({
87
+ jsonrpc: '2.0',
88
+ id: 1,
89
+ method: 'initialize',
90
+ params: {
91
+ protocolVersion: '2024-11-05',
92
+ capabilities: {},
93
+ clientInfo: { name: 'pi-gitnexus', version: '0.1.0' },
94
+ },
95
+ });
96
+
97
+ this.pending.set(1, {
98
+ resolve: () => {
99
+ proc.stdin!.write(
100
+ JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'
101
+ );
102
+ this.proc = proc;
103
+ resolve_();
104
+ },
105
+ reject: (err) => {
106
+ this.startPromise = null;
107
+ reject(err);
108
+ },
109
+ });
110
+
111
+ proc.stdin!.write(initMsg + '\n');
112
+ });
113
+
114
+ return this.startPromise;
115
+ }
116
+
117
+ /**
118
+ * Call a gitnexus MCP tool and return its formatted text response.
119
+ * Starts the MCP process lazily if not already running.
120
+ * Returns "" on any error (graceful failure, same as the augment hook).
121
+ */
122
+ async callTool(name: string, args: Record<string, unknown>, cwd: string): Promise<string> {
123
+ try {
124
+ await this.ensureStarted(cwd);
125
+ } catch {
126
+ return '';
127
+ }
128
+
129
+ if (!this.proc) return '';
130
+
131
+ const id = this.nextId++;
132
+ return new Promise<string>((resolve_) => {
133
+ this.pending.set(id, {
134
+ resolve: (raw: string) => {
135
+ try {
136
+ const msg = JSON.parse(raw) as JsonRpcResponse;
137
+ if (msg.error) { resolve_(''); return; }
138
+ const result = msg.result as McpToolResult | undefined;
139
+ if (!result?.content || result.isError) { resolve_(''); return; }
140
+ const text = result.content
141
+ .filter((c) => c.type === 'text' && !c.isError && c.text)
142
+ .map((c) => c.text!)
143
+ .join('\n');
144
+ resolve_('[GitNexus]\n' + text.slice(0, MAX_OUTPUT_CHARS));
145
+ } catch {
146
+ resolve_('');
147
+ }
148
+ },
149
+ reject: () => resolve_(''),
150
+ });
151
+
152
+ const msg = JSON.stringify({
153
+ jsonrpc: '2.0',
154
+ id,
155
+ method: 'tools/call',
156
+ params: { name, arguments: args },
157
+ });
158
+
159
+ try {
160
+ this.proc!.stdin!.write(msg + '\n');
161
+ } catch {
162
+ this.pending.delete(id);
163
+ resolve_('');
164
+ }
165
+ });
166
+ }
167
+
168
+ /** Terminate the MCP process. Called on session_switch so the next session gets a fresh process. */
169
+ stop(): void {
170
+ if (this.proc) {
171
+ this.proc.kill('SIGTERM');
172
+ this.proc = null;
173
+ }
174
+ this.startPromise = null;
175
+ for (const p of this.pending.values()) {
176
+ p.reject(new Error('MCP client stopped'));
177
+ }
178
+ this.pending.clear();
179
+ }
180
+ }
181
+
182
+ export const mcpClient = new GitNexusMcpClient();
package/src/tools.ts ADDED
@@ -0,0 +1,113 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
3
+ import { findGitNexusIndex, safeResolvePath } from './gitnexus';
4
+ import { mcpClient } from './mcp-client';
5
+
6
+ function text(msg: string) {
7
+ return { content: [{ type: 'text' as const, text: msg }], details: undefined };
8
+ }
9
+
10
+ const NO_INDEX = 'No GitNexus index found. Run: /gitnexus analyze';
11
+
12
+ /**
13
+ * Register all GitNexus tools with pi.
14
+ * Called once from index.ts — this is the only way tools.ts accesses pi.
15
+ *
16
+ * TypeBox `default` values (e.g. `default: 5`) are JSON Schema annotations for
17
+ * agent documentation only. TypeBox does not inject them into params at runtime.
18
+ * Omitted optional params become undefined and are stripped by JSON.stringify,
19
+ * so the MCP server receives no value and applies its own defaults.
20
+ *
21
+ * Not exposed:
22
+ * gitnexus_cypher — raw graph queries; too open-ended, bypasses all validation
23
+ * gitnexus_rename — automated multi-file rename; high blast radius
24
+ */
25
+ export function registerTools(pi: ExtensionAPI): void {
26
+ pi.registerTool({
27
+ name: 'gitnexus_list_repos',
28
+ label: 'GitNexus List Repos',
29
+ description: 'List all repositories indexed by GitNexus. Use first when multiple repos may be indexed.',
30
+ parameters: Type.Object({}),
31
+ execute: async (_id, _params, _signal, _onUpdate, ctx) => {
32
+ const out = await mcpClient.callTool('list_repos', {}, ctx.cwd);
33
+ return text(out || 'No indexed repositories found.');
34
+ },
35
+ });
36
+
37
+ pi.registerTool({
38
+ name: 'gitnexus_query',
39
+ label: 'GitNexus Query',
40
+ description: 'Search the knowledge graph for execution flows related to a concept or error.',
41
+ parameters: Type.Object({
42
+ query: Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' }),
43
+ task_context: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
44
+ goal: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
45
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 5 })),
46
+ include_content: Type.Optional(Type.Boolean()),
47
+ }),
48
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
49
+ if (!findGitNexusIndex(ctx.cwd)) return text(NO_INDEX);
50
+ const out = await mcpClient.callTool('query', params as Record<string, unknown>, ctx.cwd);
51
+ return text(out || 'No results.');
52
+ },
53
+ });
54
+
55
+ pi.registerTool({
56
+ name: 'gitnexus_context',
57
+ label: 'GitNexus Context',
58
+ description: '360-degree view of a code symbol: callers, callees, processes it participates in.',
59
+ parameters: Type.Object({
60
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' })),
61
+ uid: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
62
+ file: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
63
+ include_content: Type.Optional(Type.Boolean()),
64
+ }),
65
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
66
+ if (!findGitNexusIndex(ctx.cwd)) return text(NO_INDEX);
67
+ if (!params.name && !params.uid) return text('Provide either name or uid.');
68
+ // Validate file path server-side is handled by gitnexus, but guard here too.
69
+ let args: Record<string, unknown> = params as Record<string, unknown>;
70
+ if (params.file) {
71
+ const safe = safeResolvePath(params.file, ctx.cwd);
72
+ if (!safe) return text('Invalid file path.');
73
+ args = { ...params, file: safe };
74
+ }
75
+ const out = await mcpClient.callTool('context', args, ctx.cwd);
76
+ return text(out || 'No results.');
77
+ },
78
+ });
79
+
80
+ pi.registerTool({
81
+ name: 'gitnexus_impact',
82
+ label: 'GitNexus Impact',
83
+ description: 'Blast radius analysis: what breaks at each depth if you change a symbol.',
84
+ parameters: Type.Object({
85
+ target: Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' }),
86
+ direction: Type.Optional(Type.Union([
87
+ Type.Literal('upstream'),
88
+ Type.Literal('downstream'),
89
+ ])),
90
+ depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10, default: 3 })),
91
+ include_tests: Type.Optional(Type.Boolean()),
92
+ }),
93
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
94
+ if (!findGitNexusIndex(ctx.cwd)) return text(NO_INDEX);
95
+ const out = await mcpClient.callTool('impact', params as Record<string, unknown>, ctx.cwd);
96
+ return text(out || 'No results.');
97
+ },
98
+ });
99
+
100
+ pi.registerTool({
101
+ name: 'gitnexus_detect_changes',
102
+ label: 'GitNexus Detect Changes',
103
+ description: "Map a git diff to affected execution flows. Pass the output of `git diff HEAD` to find what breaks.",
104
+ parameters: Type.Object({
105
+ diff: Type.String({ minLength: 1, maxLength: 50_000 }),
106
+ }),
107
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
108
+ if (!findGitNexusIndex(ctx.cwd)) return text(NO_INDEX);
109
+ const out = await mcpClient.callTool('detect_changes', params as Record<string, unknown>, ctx.cwd);
110
+ return text(out || 'No affected flows detected.');
111
+ },
112
+ });
113
+ }