pi-gortex 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/docs/architecture.md +86 -14
- package/package.json +5 -4
- package/src/config.ts +15 -4
- package/src/diff.ts +404 -0
- package/src/index.ts +149 -49
- package/src/mcp-client.ts +24 -10
- package/src/native.ts +141 -0
- package/src/render.ts +65 -8
- package/src/runtime.ts +4 -0
- package/src/state.ts +10 -1
- package/src/tools.ts +48 -8
package/README.md
CHANGED
|
@@ -62,10 +62,11 @@ environment -> `<cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
|
|
|
62
62
|
|
|
63
63
|
| Sidecar key | Environment | Default | Meaning |
|
|
64
64
|
|----------------|--------------------|--------------------|---------|
|
|
65
|
-
| `bin` | `GORTEX_BIN` | `gortex` on `PATH` | Binary used for `daemon start`, `mcp` and `hook`. |
|
|
65
|
+
| `bin` | `GORTEX_BIN` | `gortex` on `PATH` | Binary used for `daemon start`, `mcp` and `hook`. Read from the global sidecar only; a project file cannot set it. |
|
|
66
66
|
| `hook_mode` | `GORTEX_HOOK_MODE` | `deny` | Posture: `deny`, `enrich`, `consult-unlock`, `nudge`. |
|
|
67
67
|
| `enforce` | `GORTEX_ENFORCE` | `true` | `false` keeps the graph tools and the briefing, and wires no enforcement. |
|
|
68
68
|
| `tools_preset` | `GORTEX_TOOLS` | `core` | Eager MCP surface. `core`/`full` use the daemon's default; `edit`/`nav`/`readonly` narrow it. |
|
|
69
|
+
| `native_mcp` | `GORTEX_NATIVE_MCP` | `true` | On Pi 0.99+, register `gortex mcp` with Pi's built-in MCP. `false` keeps the extension's own client. |
|
|
69
70
|
|
|
70
71
|
```json
|
|
71
72
|
{
|
|
@@ -75,6 +76,17 @@ environment -> `<cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
|
|
|
75
76
|
}
|
|
76
77
|
```
|
|
77
78
|
|
|
79
|
+
The extension reads its configuration when Pi loads it, so a change takes
|
|
80
|
+
effect after `/reload`.
|
|
81
|
+
|
|
82
|
+
**Pi's own `mcp.json` wins.** With `native_mcp` on, the extension registers a
|
|
83
|
+
server named `gortex`. A `gortex` entry in `~/.pi/agent/mcp.json` or
|
|
84
|
+
`.pi/mcp.json` replaces it, including its exposure, and Pi's default exposure
|
|
85
|
+
there is `codemode`, which keeps the tools out of the model's tool list. Leave
|
|
86
|
+
Gortex out of `mcp.json`, or set `"exposure": "direct"` on that entry. A
|
|
87
|
+
Gortex server configured under another name runs as a second connection, and
|
|
88
|
+
read discipline treats its tools as non-graph calls.
|
|
89
|
+
|
|
78
90
|
**If the graph tools go missing**, the first turn of the session says so and
|
|
79
91
|
names `/reload` as the retry. The bridge fails open, always: an absent binary or
|
|
80
92
|
a daemon that is down costs the session its graph tools, never the session.
|
package/docs/architecture.md
CHANGED
|
@@ -4,20 +4,55 @@ How the bridge is built, and why each piece is shaped the way it is.
|
|
|
4
4
|
|
|
5
5
|
## The problem
|
|
6
6
|
|
|
7
|
-
Pi has no MCP support, by design; its extension API covers the ground MCP would.
|
|
8
7
|
Gortex speaks MCP and owns a hook protocol that every agent it integrates with
|
|
9
|
-
shares.
|
|
10
|
-
jobs over two separate
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
shares. Pi 0.99 added built-in MCP support; earlier Pi has none. This extension
|
|
9
|
+
is the adapter between the two, and it does two separate jobs over two separate
|
|
10
|
+
channels.
|
|
11
|
+
|
|
12
|
+
## Channel 1 on Pi 0.99+: Pi's built-in MCP
|
|
13
|
+
|
|
14
|
+
On a Pi that offers `pi.registerMcpServer()`, the extension registers
|
|
15
|
+
`gortex mcp` as a stdio server named `gortex` and leaves the tool channel to Pi.
|
|
16
|
+
Pi spawns and connects the server, registers its tools as `mcp__gortex__<tool>`,
|
|
17
|
+
follows `tools/list_changed`, renders the calls and stops the child on reload
|
|
18
|
+
and quit. `gortex mcp` starts the shared daemon itself, so the extension starts
|
|
19
|
+
nothing.
|
|
20
|
+
|
|
21
|
+
- **Exposure.** The server is registered with `direct` exposure, so its tools
|
|
22
|
+
are declared to the model like built-in tools and Pi holds the first prompt
|
|
23
|
+
until the server connects. Pi's default, codemode, would hand scripts the
|
|
24
|
+
compact text results to parse.
|
|
25
|
+
- **Timeout.** The per-request timeout matches the tool-call cap of the
|
|
26
|
+
extension's own client. Pi's default is a minute, short for some analyzers.
|
|
27
|
+
- **Wire format.** Pi's client identifies itself as `pi` and declares no
|
|
28
|
+
capabilities the extension can extend. The daemon picks the compact format
|
|
29
|
+
from that client name.
|
|
30
|
+
- **Readiness.** Pi reports neither a connection nor a failure to extensions. The
|
|
31
|
+
first turn waits briefly beyond Pi's own wait for an `mcp__gortex__` tool to
|
|
32
|
+
appear, and a binary that does not resolve is caught before registering,
|
|
33
|
+
because it would otherwise read as a server still connecting.
|
|
34
|
+
- **Version skew.** Pi keeps the handshake to itself, so the daemon version is
|
|
35
|
+
read from the readiness line of the session briefing and checked against the
|
|
36
|
+
same floor.
|
|
37
|
+
- **Precedence.** A `gortex` entry in Pi's own `mcp.json` replaces the
|
|
38
|
+
extension's registration, exposure included.
|
|
39
|
+
|
|
40
|
+
The extension keeps its own client below when Pi lacks `registerMcpServer`, when
|
|
41
|
+
the binary does not resolve (its spawn failure is what reaches the model), when
|
|
42
|
+
Pi refuses the registration (the user is warned with Pi's reason), and when
|
|
43
|
+
`native_mcp` is off. It also switches to it on session start when no loaded
|
|
44
|
+
extension connects MCP servers, as with `--no-mcp`: it withdraws the
|
|
45
|
+
registration then. Pi's MCP extension and the extensions that replace it
|
|
46
|
+
register the `/mcp` command, which is what the check looks for.
|
|
47
|
+
|
|
48
|
+
## Channel 1 on older Pi: the extension's own MCP client
|
|
13
49
|
|
|
14
50
|
One Gortex MCP child process per session, spoken to over newline-delimited
|
|
15
51
|
JSON-RPC on stdio. Every tool the daemon offers becomes a native Pi tool whose
|
|
16
52
|
implementation forwards the call down that channel.
|
|
17
53
|
|
|
18
|
-
- **Handshake.** The client identifies itself as Pi
|
|
19
|
-
|
|
20
|
-
daemon needing a per-client allowlist entry. One retry after a short backoff
|
|
54
|
+
- **Handshake.** The client identifies itself as Pi, which is the name the
|
|
55
|
+
daemon picks the compact wire format from. One retry after a short backoff
|
|
21
56
|
absorbs a daemon that is still warming up.
|
|
22
57
|
- **Version skew.** The handshake reply carries `serverInfo.version` and the
|
|
23
58
|
protocol the daemon settled on. A daemon below the supported floor, or one
|
|
@@ -53,6 +88,21 @@ built-ins Pi may grow later as well as today's collisions. Each aliased tool
|
|
|
53
88
|
front-loads the rename into its own description, because Gortex's guidance and
|
|
54
89
|
denial messages name the bare tool and the model has to map it.
|
|
55
90
|
|
|
91
|
+
### Edit diffs
|
|
92
|
+
|
|
93
|
+
The daemon reports an applied edit without its content, so the bridge reads the
|
|
94
|
+
files a write names before and after the call and stores their diffs in the
|
|
95
|
+
result's `details`, shaped like Pi's own edit details (see `src/diff.ts`). The
|
|
96
|
+
model still reads only Gortex's reply.
|
|
97
|
+
|
|
98
|
+
On Pi's built-in MCP the reads ride the `tool_call` and `tool_result` events.
|
|
99
|
+
Pi prepares a whole parallel batch before running it, so no lock can span a
|
|
100
|
+
call; a file two calls in flight write gets no diff instead.
|
|
101
|
+
|
|
102
|
+
A wrong diff is worse than none, so where one could mislead or stall Pi (a
|
|
103
|
+
shell command running alongside, or a change a line diff cannot show cheaply) a
|
|
104
|
+
one-line note stands in for it.
|
|
105
|
+
|
|
56
106
|
## Channel 2: read discipline over the hook bridge
|
|
57
107
|
|
|
58
108
|
Every tool call Pi is about to run is forwarded to Gortex's hook with a
|
|
@@ -64,16 +114,24 @@ own way, while Gortex's classifier switches on canonical tool names and input
|
|
|
64
114
|
keys. That translation lives on this side of the bridge, so the Go side keeps
|
|
65
115
|
one vocabulary for every agent it serves.
|
|
66
116
|
|
|
117
|
+
A call to a Gortex tool is flagged as a graph call and sent under the daemon's
|
|
118
|
+
own tool name, which is what the postures key off. It skips the translation, so
|
|
119
|
+
Gortex's `read` never passes for Pi's. On Pi's built-in MCP the same hook also
|
|
120
|
+
sees each tool a codemode script calls.
|
|
121
|
+
|
|
67
122
|
Postures are resolved by Gortex. Keeping that decision there is what keeps the
|
|
68
123
|
behaviour identical across hosts.
|
|
69
124
|
|
|
70
125
|
## Orientation injection
|
|
71
126
|
|
|
72
|
-
The session briefing
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
127
|
+
The session briefing is stored once in the session history as a hidden message,
|
|
128
|
+
so every LLM call carries it. It stays out of the system prompt, because a
|
|
129
|
+
change there invalidates prefix prompt caching.
|
|
130
|
+
|
|
131
|
+
## Withdrawn tools
|
|
132
|
+
|
|
133
|
+
Pi cannot unregister a tool, so one the daemon withdraws leaves Pi's active tool
|
|
134
|
+
set instead.
|
|
77
135
|
|
|
78
136
|
## The readiness barrier
|
|
79
137
|
|
|
@@ -98,9 +156,13 @@ matter:
|
|
|
98
156
|
If the cap expires first, that turn is told the tools may not be callable yet
|
|
99
157
|
and pointed at a reload. Exactly one turn reports it.
|
|
100
158
|
|
|
159
|
+
On Pi's built-in MCP the session hook registers nothing, and the turn waits for
|
|
160
|
+
the server's tools instead (see Channel 1 on Pi 0.99+).
|
|
161
|
+
|
|
101
162
|
## Session state
|
|
102
163
|
|
|
103
|
-
|
|
164
|
+
This applies to the extension's own client; on Pi's built-in MCP, Pi owns the
|
|
165
|
+
child. The live bridge, the last bridge error and the registered tool names live at
|
|
104
166
|
module scope, and that is load bearing. A reload re-imports the module, so the
|
|
105
167
|
state resets with it. A new session, a resume and a fork re-invoke the extension
|
|
106
168
|
factory against the cached module, and the new invocation stops the previous
|
|
@@ -110,6 +172,12 @@ invocation that overlaps it stops that bridge instead of leaving the child
|
|
|
110
172
|
behind. State held per instance would leak a Gortex child on every new
|
|
111
173
|
session.
|
|
112
174
|
|
|
175
|
+
Process exit has no next invocation, so the extension also stops the bridge
|
|
176
|
+
from Pi's `session_shutdown` event, which fires on quit and on every
|
|
177
|
+
replacement path. The child is spawned unref'd, pipes included: they must
|
|
178
|
+
never be what holds the host's event loop open, or a print or JSON run that
|
|
179
|
+
finished its work hangs at exit instead of draining.
|
|
180
|
+
|
|
113
181
|
## Failure posture
|
|
114
182
|
|
|
115
183
|
Fail open, everywhere. A missing binary, a daemon that is down, a handshake that
|
|
@@ -124,3 +192,7 @@ Every value resolves at runtime, layered from the environment down through a
|
|
|
124
192
|
project file and a global one to defaults. See the table in the
|
|
125
193
|
[README](../README.md#configure). Runtime resolution is what lets one artifact
|
|
126
194
|
serve both the npm package and a directory install.
|
|
195
|
+
|
|
196
|
+
The project file sits in whatever repository Pi was opened in, so it cannot
|
|
197
|
+
set `bin`. Otherwise a cloned repository could choose the executable the
|
|
198
|
+
extension spawns. The binary comes from `GORTEX_BIN`, the global file or `PATH`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-gortex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Gortex for Pi: high-performance code-intelligence engine for AI agents, based on graph. Exposes only the information the agent needs, cutting token usage up to 50x. 100% local.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -35,9 +35,10 @@
|
|
|
35
35
|
"prepublishOnly": "npm run check"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@earendil-works/pi-coding-agent": "
|
|
39
|
-
"@earendil-works/pi-tui": "
|
|
40
|
-
"@types/node": "^
|
|
38
|
+
"@earendil-works/pi-coding-agent": "1.0.2",
|
|
39
|
+
"@earendil-works/pi-tui": "1.0.2",
|
|
40
|
+
"@types/node": "^24.19.1",
|
|
41
|
+
"jiti": "^2.7.0",
|
|
41
42
|
"typescript": "^7.0.2"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
package/src/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { join } from "node:path";
|
|
|
11
11
|
|
|
12
12
|
/** The sidecar file's on-disk shape. `gortex init` is its writer. */
|
|
13
13
|
export interface SidecarConfig {
|
|
14
|
-
/** Absolute path to the gortex binary. Default: resolved from PATH. */
|
|
14
|
+
/** Absolute path to the gortex binary. Global sidecar only. Default: resolved from PATH. */
|
|
15
15
|
bin?: string;
|
|
16
16
|
/** Hook posture: deny | enrich | consult-unlock | nudge. */
|
|
17
17
|
hook_mode?: string;
|
|
@@ -19,6 +19,8 @@ export interface SidecarConfig {
|
|
|
19
19
|
enforce?: boolean;
|
|
20
20
|
/** Eager MCP tool preset: core | full | edit | nav | readonly. */
|
|
21
21
|
tools_preset?: string;
|
|
22
|
+
/** false keeps the extension's own MCP client on a Pi that has built-in MCP. */
|
|
23
|
+
native_mcp?: boolean;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/** Everything the extension needs to run, fully resolved. */
|
|
@@ -31,6 +33,8 @@ export interface GortexConfig {
|
|
|
31
33
|
enforce: boolean;
|
|
32
34
|
/** Eager tool preset; the daemon's default applies when this is core/full. */
|
|
33
35
|
toolsPreset: string;
|
|
36
|
+
/** Whether to hand the tool channel to Pi's built-in MCP when Pi has one. */
|
|
37
|
+
nativeMcp: boolean;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/** Seams for tests; every one defaults to the real environment. */
|
|
@@ -137,8 +141,9 @@ export function resolveConfig(opts: ResolveConfigOptions = {}): GortexConfig {
|
|
|
137
141
|
|
|
138
142
|
const [project, global] = sidecarPaths(cwd, agentDir).map((path) => readSidecar(path, readFile));
|
|
139
143
|
|
|
140
|
-
//
|
|
141
|
-
|
|
144
|
+
// A checked-out repository must never pick the executable, so the project
|
|
145
|
+
// sidecar has no say in `bin`. `gortex` unqualified is resolved against PATH.
|
|
146
|
+
const bin = firstString(env.GORTEX_BIN, global?.bin) ?? DEFAULT_BIN;
|
|
142
147
|
|
|
143
148
|
const mode = normalizeMode(firstString(env.GORTEX_HOOK_MODE, project?.hook_mode, global?.hook_mode));
|
|
144
149
|
const hookArgv = [bin, "hook", "--agent=pi"];
|
|
@@ -154,5 +159,11 @@ export function resolveConfig(opts: ResolveConfigOptions = {}): GortexConfig {
|
|
|
154
159
|
const toolsPreset =
|
|
155
160
|
firstString(env.GORTEX_TOOLS, project?.tools_preset, global?.tools_preset) ?? DEFAULT_TOOLS_PRESET;
|
|
156
161
|
|
|
157
|
-
|
|
162
|
+
const nativeMcp =
|
|
163
|
+
parseBoolean(env.GORTEX_NATIVE_MCP) ??
|
|
164
|
+
(typeof project?.native_mcp === "boolean" ? project.native_mcp : undefined) ??
|
|
165
|
+
(typeof global?.native_mcp === "boolean" ? global.native_mcp : undefined) ??
|
|
166
|
+
true;
|
|
167
|
+
|
|
168
|
+
return { bin, hookArgv, enforce, toolsPreset, nativeMcp };
|
|
158
169
|
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// Diffs for Gortex's file-writing tools. The daemon reports an applied edit
|
|
2
|
+
// without its content, so the bridge snapshots the files a call names, runs
|
|
3
|
+
// the call, and diffs what changed in Pi's own edit format.
|
|
4
|
+
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { readFile, stat } from "node:fs/promises";
|
|
7
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { generateDiffString, generateUnifiedPatch, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
import { bareToolName, isNativeGortexTool } from "./native.ts";
|
|
13
|
+
|
|
14
|
+
export interface FileDiff {
|
|
15
|
+
/** Relative to the session's cwd. */
|
|
16
|
+
path: string;
|
|
17
|
+
/** Empty when `note` explains why the change is not drawn as a diff. */
|
|
18
|
+
diff: string;
|
|
19
|
+
patch: string;
|
|
20
|
+
firstChangedLine?: number;
|
|
21
|
+
note?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A result's details: every changed file under `diffs`, plus Pi's EditToolDetails
|
|
26
|
+
* fields at the top level. `patch` covers every file; `diff` and
|
|
27
|
+
* `firstChangedLine` are set when exactly one file changed.
|
|
28
|
+
*/
|
|
29
|
+
export interface EditDetails {
|
|
30
|
+
diffs?: FileDiff[];
|
|
31
|
+
diff?: string;
|
|
32
|
+
patch?: string;
|
|
33
|
+
firstChangedLine?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
37
|
+
// Pi's line diff costs about (lines old + new) x (changed lines) and runs on
|
|
38
|
+
// the event loop; this budget keeps one diff to roughly a tenth of a second.
|
|
39
|
+
const MAX_DIFF_WORK = 2_000_000;
|
|
40
|
+
|
|
41
|
+
const LEGACY_EDIT_TOOLS = new Set(["edit_file", "write_file", "edit_symbol", "batch_edit"]);
|
|
42
|
+
const EDIT_OPERATIONS = new Set(["file", "symbol", "write", "batch"]);
|
|
43
|
+
// Argument containers the `edit` facade flattens before dispatch.
|
|
44
|
+
const CONTAINERS = ["arguments", "options", "source", "context", "guard", "output"];
|
|
45
|
+
const PATH_KEYS = ["path", "file", "file_path", "source", "destination"];
|
|
46
|
+
const SYMBOL_KEYS = ["id", "symbol", "symbol_id"];
|
|
47
|
+
// Pi's own tools that write the file named by their `path`.
|
|
48
|
+
const PI_WRITE_TOOLS = new Set(["edit", "write"]);
|
|
49
|
+
|
|
50
|
+
type Snapshot =
|
|
51
|
+
| { kind: "missing" }
|
|
52
|
+
| { kind: "text"; raw: Buffer; text: string }
|
|
53
|
+
/** Not diffable. `reason` is empty when the change should pass silently. */
|
|
54
|
+
| { kind: "skipped"; reason: string; size: number; mtimeMs: number };
|
|
55
|
+
|
|
56
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
57
|
+
|
|
58
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
59
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** isFileEdit reports whether a call writes files the bridge can name up front. */
|
|
63
|
+
export function isFileEdit(tool: string, params: Record<string, unknown>): boolean {
|
|
64
|
+
if (LEGACY_EDIT_TOOLS.has(tool)) return !isDryRun(params);
|
|
65
|
+
return tool === "edit" && EDIT_OPERATIONS.has(String(params.operation)) && !isDryRun(params);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isDryRun(params: Record<string, unknown>): boolean {
|
|
69
|
+
return flatten(params).dry_run === true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function flatten(params: Record<string, unknown>): Record<string, unknown> {
|
|
73
|
+
const out: Record<string, unknown> = {};
|
|
74
|
+
for (const key of CONTAINERS) Object.assign(out, asRecord(params[key]));
|
|
75
|
+
for (const [key, value] of Object.entries(params)) {
|
|
76
|
+
if (key === "target" || (CONTAINERS.includes(key) && typeof value === "object")) continue;
|
|
77
|
+
out[key] = value;
|
|
78
|
+
}
|
|
79
|
+
const target = asRecord(params.target);
|
|
80
|
+
if (typeof target.file === "string") out.path = target.file;
|
|
81
|
+
if (typeof target.symbol === "string") out.id = target.symbol;
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function batchItems(value: unknown): Record<string, unknown>[] {
|
|
86
|
+
let items = value;
|
|
87
|
+
if (typeof items === "string") {
|
|
88
|
+
try {
|
|
89
|
+
items = JSON.parse(items);
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Array.isArray(items) ? items.map(asRecord) : [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function namedPaths(entry: Record<string, unknown>): string[] {
|
|
98
|
+
const out: string[] = [];
|
|
99
|
+
for (const key of PATH_KEYS) {
|
|
100
|
+
if (typeof entry[key] === "string") out.push(entry[key]);
|
|
101
|
+
}
|
|
102
|
+
for (const key of SYMBOL_KEYS) {
|
|
103
|
+
const id = entry[key];
|
|
104
|
+
if (typeof id === "string" && id.includes("::")) out.push(id.slice(0, id.indexOf("::")));
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const repoRoots = new Map<string, string>();
|
|
110
|
+
|
|
111
|
+
/** The nearest directory at or above cwd holding `.git`, else cwd itself. */
|
|
112
|
+
function repoRoot(cwd: string): string {
|
|
113
|
+
let root = repoRoots.get(cwd);
|
|
114
|
+
if (root !== undefined) return root;
|
|
115
|
+
root = cwd;
|
|
116
|
+
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
117
|
+
if (existsSync(join(dir, ".git"))) {
|
|
118
|
+
root = dir;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
if (dirname(dir) === dir) break;
|
|
122
|
+
}
|
|
123
|
+
repoRoots.set(cwd, root);
|
|
124
|
+
return root;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* candidatePaths lists the disk paths a call may write. Gortex paths are
|
|
129
|
+
* relative to the repo root and may carry the repo name as a first segment, so
|
|
130
|
+
* both forms are tried against cwd and the repo root; a candidate that does
|
|
131
|
+
* not exist simply never changes.
|
|
132
|
+
*/
|
|
133
|
+
export function candidatePaths(params: Record<string, unknown>, cwd: string): string[] {
|
|
134
|
+
const args = flatten(params);
|
|
135
|
+
const raw = [...namedPaths(args), ...batchItems(args.changes ?? args.edits).flatMap(namedPaths)];
|
|
136
|
+
const bases = [...new Set([cwd, repoRoot(cwd)])];
|
|
137
|
+
const out = new Set<string>();
|
|
138
|
+
for (const name of raw) {
|
|
139
|
+
if (!name) continue;
|
|
140
|
+
if (isAbsolute(name)) {
|
|
141
|
+
out.add(resolve(name));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const path = name.replace(/\\/g, "/");
|
|
145
|
+
const slash = path.indexOf("/");
|
|
146
|
+
for (const base of bases) {
|
|
147
|
+
out.add(resolve(base, path));
|
|
148
|
+
if (slash > 0) out.add(resolve(base, path.slice(slash + 1)));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return [...out].sort();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function snapshot(path: string): Promise<Snapshot> {
|
|
155
|
+
try {
|
|
156
|
+
const info = await stat(path);
|
|
157
|
+
const skipped = (reason: string): Snapshot => ({ kind: "skipped", reason, size: info.size, mtimeMs: info.mtimeMs });
|
|
158
|
+
if (!info.isFile()) return skipped("");
|
|
159
|
+
if (info.size > MAX_FILE_BYTES) return skipped("over 1 MiB");
|
|
160
|
+
const raw = await readFile(path);
|
|
161
|
+
if (raw.includes(0)) return skipped("binary file");
|
|
162
|
+
let text: string;
|
|
163
|
+
try {
|
|
164
|
+
text = utf8.decode(raw);
|
|
165
|
+
} catch {
|
|
166
|
+
return skipped("not UTF-8 text");
|
|
167
|
+
}
|
|
168
|
+
return { kind: "text", raw, text: text.replace(/^/, "").replace(/\r\n/g, "\n") };
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if ((err as { code?: string }).code === "ENOENT") return { kind: "missing" };
|
|
171
|
+
return { kind: "skipped", reason: "", size: -1, mtimeMs: -1 };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function snapshotAll(paths: string[]): Promise<Snapshot[]> {
|
|
176
|
+
return Promise.all(paths.map(snapshot));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function lineEnding(raw: Buffer): string {
|
|
180
|
+
return raw.includes("\r\n") ? "CRLF" : "LF";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function hasBom(raw: Buffer): boolean {
|
|
184
|
+
return raw[0] === 0xef && raw[1] === 0xbb && raw[2] === 0xbf;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** What changed between two texts that are equal once normalized, or "" for nothing. */
|
|
188
|
+
function invisibleChange(old: Buffer, now: Buffer): string {
|
|
189
|
+
if (old.equals(now)) return "";
|
|
190
|
+
const notes: string[] = [];
|
|
191
|
+
if (lineEnding(old) !== lineEnding(now)) notes.push(`line endings ${lineEnding(old)} -> ${lineEnding(now)}`);
|
|
192
|
+
if (hasBom(old) !== hasBom(now)) notes.push(hasBom(now) ? "byte order mark added" : "byte order mark removed");
|
|
193
|
+
return notes.length > 0 ? notes.join(", ") : "line endings changed";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A lower bound on the work Pi's line diff does: total lines times lines not shared. */
|
|
197
|
+
function diffWork(old: string, now: string): number {
|
|
198
|
+
const a = old.split("\n");
|
|
199
|
+
const b = now.split("\n");
|
|
200
|
+
const counts = new Map<string, number>();
|
|
201
|
+
for (const line of a) counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
202
|
+
let shared = 0;
|
|
203
|
+
for (const line of b) {
|
|
204
|
+
const n = counts.get(line);
|
|
205
|
+
if (n) {
|
|
206
|
+
counts.set(line, n - 1);
|
|
207
|
+
shared++;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return (a.length + b.length) * (a.length + b.length - 2 * shared);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function textOf(s: Snapshot): string | undefined {
|
|
214
|
+
if (s.kind === "missing") return "";
|
|
215
|
+
return s.kind === "text" ? s.text : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function diffOne(path: string, old: Snapshot, now: Snapshot): FileDiff | undefined {
|
|
219
|
+
const note = (text: string): FileDiff => ({ path, diff: "", patch: "", note: text });
|
|
220
|
+
if (old.kind === "skipped" || now.kind === "skipped") {
|
|
221
|
+
const reason = (now.kind === "skipped" ? now.reason : "") || (old.kind === "skipped" ? old.reason : "");
|
|
222
|
+
const unchanged =
|
|
223
|
+
old.kind === "skipped" && now.kind === "skipped" && old.size === now.size && old.mtimeMs === now.mtimeMs;
|
|
224
|
+
return reason && !unchanged ? note(`not diffed: ${reason}`) : undefined;
|
|
225
|
+
}
|
|
226
|
+
const before = textOf(old)!;
|
|
227
|
+
const after = textOf(now)!;
|
|
228
|
+
if (before === after) {
|
|
229
|
+
if (old.kind !== "text" || now.kind !== "text") return undefined;
|
|
230
|
+
const change = invisibleChange(old.raw, now.raw);
|
|
231
|
+
return change ? note(change) : undefined;
|
|
232
|
+
}
|
|
233
|
+
if (diffWork(before, after) > MAX_DIFF_WORK) return note("not diffed: change too large to diff quickly");
|
|
234
|
+
const { diff, firstChangedLine } = generateDiffString(before, after);
|
|
235
|
+
if (!diff) return undefined;
|
|
236
|
+
return { path, diff, patch: generateUnifiedPatch(path, before, after), firstChangedLine };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function diffSnapshots(paths: string[], before: Snapshot[], after: Snapshot[], cwd: string): FileDiff[] {
|
|
240
|
+
const diffs: FileDiff[] = [];
|
|
241
|
+
paths.forEach((abs, i) => {
|
|
242
|
+
const diff = diffOne(relative(cwd, abs) || abs, before[i]!, after[i]!);
|
|
243
|
+
if (diff) diffs.push(diff);
|
|
244
|
+
});
|
|
245
|
+
return diffs;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** editDetails shapes diffs as a result's details; no diffs give no fields. */
|
|
249
|
+
export function editDetails(diffs: FileDiff[]): EditDetails {
|
|
250
|
+
if (diffs.length === 0) return {};
|
|
251
|
+
const details: EditDetails = { diffs };
|
|
252
|
+
const patch = diffs.map((d) => d.patch).join("");
|
|
253
|
+
if (patch) details.patch = patch;
|
|
254
|
+
if (diffs.length === 1 && diffs[0]!.diff) {
|
|
255
|
+
details.diff = diffs[0]!.diff;
|
|
256
|
+
if (diffs[0]!.firstChangedLine !== undefined) details.firstChangedLine = diffs[0]!.firstChangedLine;
|
|
257
|
+
}
|
|
258
|
+
return details;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A shell command can write any file, so a diff taken while one runs may
|
|
262
|
+
// include its writes. Tracked on both channels through Pi's events.
|
|
263
|
+
const SHELL_TOOLS = new Set(["bash", "powershell"]);
|
|
264
|
+
const shellCallsRunning = new Set<string>();
|
|
265
|
+
let shellCallsSeen = 0;
|
|
266
|
+
|
|
267
|
+
/** Starts watching for shell calls; the result reports whether one overlapped. */
|
|
268
|
+
function shellWindow(): () => boolean {
|
|
269
|
+
const busy = shellCallsRunning.size > 0;
|
|
270
|
+
const seen = shellCallsSeen;
|
|
271
|
+
return () => busy || shellCallsRunning.size > 0 || shellCallsSeen !== seen;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const SHELL_NOTE = "not diffed: a shell command ran at the same time";
|
|
275
|
+
|
|
276
|
+
function shellNotes(diffs: FileDiff[]): FileDiff[] {
|
|
277
|
+
return diffs.map(({ path }) => ({ path, diff: "", patch: "", note: SHELL_NOTE }));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function withLocks<T>(paths: string[], fn: () => Promise<T>): Promise<T> {
|
|
281
|
+
return paths.reduceRight<() => Promise<T>>((next, path) => () => withFileMutationQueue(path, next), fn)();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* withFileDiffs runs call while holding Pi's per-file mutation lock on every
|
|
286
|
+
* candidate path, and returns its result with a diff for each file it changed.
|
|
287
|
+
* A failed call is not diffed.
|
|
288
|
+
*/
|
|
289
|
+
export async function withFileDiffs<T>(
|
|
290
|
+
paths: string[],
|
|
291
|
+
cwd: string,
|
|
292
|
+
call: () => Promise<T>,
|
|
293
|
+
succeeded: (result: T) => boolean,
|
|
294
|
+
): Promise<{ result: T; diffs: FileDiff[] }> {
|
|
295
|
+
return withLocks(paths, async () => {
|
|
296
|
+
const overlapped = shellWindow();
|
|
297
|
+
const before = await snapshotAll(paths);
|
|
298
|
+
const result = await call();
|
|
299
|
+
if (!succeeded(result)) return { result, diffs: [] };
|
|
300
|
+
const diffs = diffSnapshots(paths, before, await snapshotAll(paths), cwd);
|
|
301
|
+
return { result, diffs: overlapped() ? shellNotes(diffs) : diffs };
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
interface PendingEdit {
|
|
306
|
+
cwd: string;
|
|
307
|
+
paths: string[];
|
|
308
|
+
before?: Snapshot[];
|
|
309
|
+
/** Per path, the other calls in flight that also write it, so a diff could mix both. */
|
|
310
|
+
contested: Map<string, Set<string>>;
|
|
311
|
+
overlapped?: () => boolean;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function contest(entry: PendingEdit, path: string, by: string): void {
|
|
315
|
+
const ids = entry.contested.get(path) ?? new Set<string>();
|
|
316
|
+
ids.add(by);
|
|
317
|
+
entry.contested.set(path, ids);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* watchNativeEdits diffs the gortex tools Pi's built-in MCP runs. Pi executes
|
|
322
|
+
* those, so the files are read on `tool_call` and again on `tool_result`, which
|
|
323
|
+
* stores the diffs in the result's details. Pi prepares a whole parallel batch
|
|
324
|
+
* before running any of it, so no lock can span the call; a path two calls in
|
|
325
|
+
* flight write gets no diff instead. Register it after every handler that may
|
|
326
|
+
* block, since a blocked call never reaches `tool_result`.
|
|
327
|
+
*/
|
|
328
|
+
export function watchNativeEdits(pi: ExtensionAPI, active: () => boolean): void {
|
|
329
|
+
const pending = new Map<string, PendingEdit>();
|
|
330
|
+
|
|
331
|
+
function claim(id: string, entry: PendingEdit): void {
|
|
332
|
+
for (const [otherId, other] of pending) {
|
|
333
|
+
for (const path of entry.paths) {
|
|
334
|
+
if (!other.paths.includes(path)) continue;
|
|
335
|
+
contest(other, path, id);
|
|
336
|
+
contest(entry, path, otherId);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
pending.set(id, entry);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** A call that ended without a result never ran, so it contests nothing. */
|
|
343
|
+
function release(id: string): void {
|
|
344
|
+
if (!pending.delete(id)) return;
|
|
345
|
+
for (const other of pending.values()) {
|
|
346
|
+
for (const ids of other.contested.values()) ids.delete(id);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
pi.on("tool_call", async (event, ctx: ExtensionContext) => {
|
|
351
|
+
if (SHELL_TOOLS.has(event.toolName)) {
|
|
352
|
+
shellCallsRunning.add(event.toolCallId);
|
|
353
|
+
shellCallsSeen++;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
if (!active() || event.parentToolCallId) return;
|
|
357
|
+
const input = (event.input ?? {}) as Record<string, unknown>;
|
|
358
|
+
const cwd = ctx.cwd;
|
|
359
|
+
if (PI_WRITE_TOOLS.has(event.toolName) && typeof input.path === "string") {
|
|
360
|
+
claim(event.toolCallId, { cwd, paths: [resolve(cwd, input.path)], contested: new Map() });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (!isNativeGortexTool(event.toolName) || !isFileEdit(bareToolName(event.toolName), input)) return;
|
|
364
|
+
const paths = candidatePaths(input, cwd);
|
|
365
|
+
if (paths.length === 0) return;
|
|
366
|
+
const overlapped = shellWindow();
|
|
367
|
+
claim(event.toolCallId, { cwd, paths, before: await snapshotAll(paths), contested: new Map(), overlapped });
|
|
368
|
+
} catch {
|
|
369
|
+
// A throwing tool_call handler blocks the call.
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
pi.on("tool_result", async (event) => {
|
|
374
|
+
const entry = pending.get(event.toolCallId);
|
|
375
|
+
if (!entry) return;
|
|
376
|
+
pending.delete(event.toolCallId);
|
|
377
|
+
if (!entry.before || event.isError) return;
|
|
378
|
+
try {
|
|
379
|
+
const paths = entry.paths.filter((p) => !entry.contested.get(p)?.size);
|
|
380
|
+
const keep = entry.paths.map((p) => paths.includes(p));
|
|
381
|
+
const before = entry.before.filter((_, i) => keep[i]);
|
|
382
|
+
let diffs = diffSnapshots(paths, before, await snapshotAll(paths), entry.cwd);
|
|
383
|
+
if (diffs.length === 0) return;
|
|
384
|
+
if (entry.overlapped?.()) diffs = shellNotes(diffs);
|
|
385
|
+
return { details: { ...asRecord(event.details), ...editDetails(diffs) } };
|
|
386
|
+
} catch {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Pi emits this after tool_result, and also for a call another handler blocked.
|
|
392
|
+
pi.on("tool_execution_end", (event) => {
|
|
393
|
+
release(event.toolCallId);
|
|
394
|
+
shellCallsRunning.delete(event.toolCallId);
|
|
395
|
+
});
|
|
396
|
+
pi.on("turn_end", () => {
|
|
397
|
+
pending.clear();
|
|
398
|
+
shellCallsRunning.clear();
|
|
399
|
+
});
|
|
400
|
+
pi.on("session_shutdown", () => {
|
|
401
|
+
pending.clear();
|
|
402
|
+
shellCallsRunning.clear();
|
|
403
|
+
});
|
|
404
|
+
}
|