pi-gortex 0.3.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 +1 -1
- package/docs/architecture.md +27 -5
- package/package.json +3 -3
- package/src/config.ts +4 -3
- package/src/diff.ts +404 -0
- package/src/index.ts +79 -37
- package/src/render.ts +65 -8
- package/src/state.ts +7 -0
- package/src/tools.ts +48 -8
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ 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. |
|
package/docs/architecture.md
CHANGED
|
@@ -88,6 +88,21 @@ built-ins Pi may grow later as well as today's collisions. Each aliased tool
|
|
|
88
88
|
front-loads the rename into its own description, because Gortex's guidance and
|
|
89
89
|
denial messages name the bare tool and the model has to map it.
|
|
90
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
|
+
|
|
91
106
|
## Channel 2: read discipline over the hook bridge
|
|
92
107
|
|
|
93
108
|
Every tool call Pi is about to run is forwarded to Gortex's hook with a
|
|
@@ -109,11 +124,14 @@ behaviour identical across hosts.
|
|
|
109
124
|
|
|
110
125
|
## Orientation injection
|
|
111
126
|
|
|
112
|
-
The session briefing
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
|
117
135
|
|
|
118
136
|
## The readiness barrier
|
|
119
137
|
|
|
@@ -174,3 +192,7 @@ Every value resolves at runtime, layered from the environment down through a
|
|
|
174
192
|
project file and a global one to defaults. See the table in the
|
|
175
193
|
[README](../README.md#configure). Runtime resolution is what lets one artifact
|
|
176
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,8 +35,8 @@
|
|
|
35
35
|
"prepublishOnly": "npm run check"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@earendil-works/pi-coding-agent": "
|
|
39
|
-
"@earendil-works/pi-tui": "
|
|
38
|
+
"@earendil-works/pi-coding-agent": "1.0.2",
|
|
39
|
+
"@earendil-works/pi-tui": "1.0.2",
|
|
40
40
|
"@types/node": "^24.19.1",
|
|
41
41
|
"jiti": "^2.7.0",
|
|
42
42
|
"typescript": "^7.0.2"
|
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;
|
|
@@ -141,8 +141,9 @@ export function resolveConfig(opts: ResolveConfigOptions = {}): GortexConfig {
|
|
|
141
141
|
|
|
142
142
|
const [project, global] = sidecarPaths(cwd, agentDir).map((path) => readSidecar(path, readFile));
|
|
143
143
|
|
|
144
|
-
//
|
|
145
|
-
|
|
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;
|
|
146
147
|
|
|
147
148
|
const mode = normalizeMode(firstString(env.GORTEX_HOOK_MODE, project?.hook_mode, global?.hook_mode));
|
|
148
149
|
const hookArgv = [bin, "hook", "--agent=pi"];
|
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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// hook bridge. See docs/architecture.md for the design.
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
|
-
|
|
6
|
+
BeforeAgentStartEventResult,
|
|
7
7
|
ExtensionAPI,
|
|
8
8
|
ExtensionContext,
|
|
9
9
|
ToolCallEvent,
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
|
|
13
13
|
import { resolveConfig } from "./config.ts";
|
|
14
14
|
import type { GortexConfig } from "./config.ts";
|
|
15
|
+
import { watchNativeEdits } from "./diff.ts";
|
|
15
16
|
import { callHook, normalizeToolCall } from "./hook.ts";
|
|
16
17
|
import {
|
|
17
18
|
MCPStdioClient,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
supportsNativeMcp,
|
|
33
34
|
waitForNativeTools,
|
|
34
35
|
} from "./native.ts";
|
|
36
|
+
import { nativeRenderers } from "./render.ts";
|
|
35
37
|
import { nodeProcessDeps } from "./runtime.ts";
|
|
36
38
|
import type { ProcessDeps } from "./runtime.ts";
|
|
37
39
|
import {
|
|
@@ -47,6 +49,7 @@ import {
|
|
|
47
49
|
setBridgeError,
|
|
48
50
|
setClient,
|
|
49
51
|
setVersionWarning,
|
|
52
|
+
withdrawnToolNames,
|
|
50
53
|
} from "./state.ts";
|
|
51
54
|
|
|
52
55
|
export type { GortexConfig, SidecarConfig } from "./config.ts";
|
|
@@ -109,6 +112,37 @@ function notifyUser(ctx: ExtensionContext | undefined, message: string): void {
|
|
|
109
112
|
}
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/** Marks the messages this extension stores in the session. */
|
|
116
|
+
const GORTEX_MESSAGE_TYPE = "gortex";
|
|
117
|
+
|
|
118
|
+
interface GortexMessageDetails {
|
|
119
|
+
/** Whether the content includes the session briefing, not only warnings. */
|
|
120
|
+
briefing: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Kept out of the transcript; Pi still sends it to the model. */
|
|
124
|
+
function gortexMessage(content: string, briefing: boolean): BeforeAgentStartEventResult {
|
|
125
|
+
const details: GortexMessageDetails = { briefing };
|
|
126
|
+
return { message: { customType: GORTEX_MESSAGE_TYPE, content, display: false, details } };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether a briefing already sits in what the model sees: after a resume or
|
|
131
|
+
* /reload, or when compaction kept it. Answers false when Pi can't tell.
|
|
132
|
+
*/
|
|
133
|
+
function briefingInContext(ctx: ExtensionContext | undefined): boolean {
|
|
134
|
+
try {
|
|
135
|
+
return (ctx?.sessionManager.buildContextEntries() ?? []).some(
|
|
136
|
+
(entry) =>
|
|
137
|
+
entry.type === "custom_message" &&
|
|
138
|
+
entry.customType === GORTEX_MESSAGE_TYPE &&
|
|
139
|
+
(entry.details as GortexMessageDetails | undefined)?.briefing === true,
|
|
140
|
+
);
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
112
146
|
/** Test seams. Pi itself calls the factory with the `pi` object alone. */
|
|
113
147
|
export interface GortexExtensionOptions {
|
|
114
148
|
/** Pre-resolved configuration; resolveConfig() runs when this is absent. */
|
|
@@ -145,10 +179,10 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
145
179
|
// Latched for the whole session once the first turn has run the
|
|
146
180
|
// session_start hook, whether or not it produced anything to inject.
|
|
147
181
|
let startupHookRan = false;
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
let
|
|
182
|
+
// This session's briefing, kept so a compaction that summarises it away can
|
|
183
|
+
// send it again on the next turn.
|
|
184
|
+
let briefing = "";
|
|
185
|
+
let briefingCompacted = false;
|
|
152
186
|
|
|
153
187
|
// Settles when the current session_start handler is done: bridge up and
|
|
154
188
|
// tools registered, or the handshake failed. Never rejects.
|
|
@@ -198,7 +232,8 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
198
232
|
// session's bridge child (if any) is stopped before a fresh handshake.
|
|
199
233
|
pi.on("session_start", async (_event, ctx) => {
|
|
200
234
|
startupHookRan = false;
|
|
201
|
-
|
|
235
|
+
briefing = "";
|
|
236
|
+
briefingCompacted = false;
|
|
202
237
|
setBridgeError("");
|
|
203
238
|
setVersionWarning("");
|
|
204
239
|
armSessionReady(); // no-op when a turn is already parked on this session
|
|
@@ -232,6 +267,7 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
232
267
|
if (nativeRefusal) notifyUser(ctx, `Pi refused the gortex MCP server (${nativeRefusal}), so the extension runs its own client.`);
|
|
233
268
|
ensureDaemon(config.bin, deps);
|
|
234
269
|
gortexToolNames.clear();
|
|
270
|
+
withdrawnToolNames.clear();
|
|
235
271
|
stopBridge();
|
|
236
272
|
const c = new MCPStdioClient({ bin: config.bin, toolsPreset: config.toolsPreset, deps });
|
|
237
273
|
// Claim the slot before the first await: an overlapping
|
|
@@ -258,10 +294,20 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
258
294
|
}
|
|
259
295
|
}
|
|
260
296
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
297
|
+
pi.on("session_compact", () => {
|
|
298
|
+
briefingCompacted = true;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Fires before the agent loop's first LLM call. The returned message is
|
|
302
|
+
// stored in the session after the prompt, so every later LLM call carries it
|
|
303
|
+
// and the system prompt, which prefix caching keys on, stays untouched.
|
|
304
|
+
pi.on("before_agent_start", async (_event, ctx): Promise<BeforeAgentStartEventResult | void> => {
|
|
305
|
+
if (startupHookRan) {
|
|
306
|
+
if (!briefingCompacted) return;
|
|
307
|
+
briefingCompacted = false;
|
|
308
|
+
if (!briefing || briefingInContext(ctx)) return;
|
|
309
|
+
return gortexMessage(briefing, true);
|
|
310
|
+
}
|
|
265
311
|
startupHookRan = true; // before the first await, so a second turn can't race in
|
|
266
312
|
// Pi awaits each listener, so this holds the turn until the tools are
|
|
267
313
|
// registered and bridgeError reflects the handshake (or the cap expires).
|
|
@@ -300,30 +346,15 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
300
346
|
parts.push(versionWarning);
|
|
301
347
|
setVersionWarning("");
|
|
302
348
|
}
|
|
303
|
-
|
|
304
|
-
if (
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
// parked orientation once, clearing it only after the push lands so the
|
|
310
|
-
// orientation survives a context shape this hook can't append to.
|
|
311
|
-
// Pi exports ContextEvent but not its result type, so it is spelled out here.
|
|
312
|
-
pi.on("context", (event: ContextEvent): { messages?: ContextEvent["messages"] } | void => {
|
|
313
|
-
if (!pendingOrientation) return;
|
|
314
|
-
try {
|
|
315
|
-
const messages = (event as { messages?: unknown })?.messages;
|
|
316
|
-
if (Array.isArray(messages)) {
|
|
317
|
-
// Pi's AgentMessage also carries a `timestamp`; only the two fields
|
|
318
|
-
// Pi reads for a tail user turn are pushed.
|
|
319
|
-
messages.push({ role: "user", content: pendingOrientation } as ContextEvent["messages"][number]);
|
|
320
|
-
pendingOrientation = "";
|
|
321
|
-
return { messages: messages as ContextEvent["messages"] };
|
|
322
|
-
}
|
|
323
|
-
} catch {
|
|
324
|
-
// best effort; never break context assembly.
|
|
349
|
+
let briefed = false;
|
|
350
|
+
if (decision.orientation) {
|
|
351
|
+
briefing = decision.orientation;
|
|
352
|
+
// A resumed or reloaded session already holds it.
|
|
353
|
+
briefed = !briefingInContext(ctx);
|
|
354
|
+
if (briefed) parts.push(decision.orientation);
|
|
325
355
|
}
|
|
326
|
-
return;
|
|
356
|
+
if (parts.length === 0) return;
|
|
357
|
+
return gortexMessage(parts.join("\n\n"), briefed);
|
|
327
358
|
});
|
|
328
359
|
|
|
329
360
|
// Never let the bridge child outlive the session.
|
|
@@ -331,10 +362,8 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
331
362
|
stopBridge();
|
|
332
363
|
});
|
|
333
364
|
|
|
334
|
-
if (!config.enforce) return;
|
|
335
|
-
|
|
336
365
|
// Enforcement: every non-Gortex tool call is checked against the Go hook.
|
|
337
|
-
|
|
366
|
+
const enforceReadDiscipline = async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | void> => {
|
|
338
367
|
const piName: string = (event as { toolName?: string })?.toolName ?? "";
|
|
339
368
|
const piInput: Record<string, unknown> = (event?.input as Record<string, unknown>) ?? {};
|
|
340
369
|
const isGortexTool = native ? isNativeGortexTool(piName) : gortexToolNames.has(piName);
|
|
@@ -367,5 +396,18 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
367
396
|
}
|
|
368
397
|
}
|
|
369
398
|
return;
|
|
370
|
-
}
|
|
399
|
+
};
|
|
400
|
+
if (config.enforce) pi.on("tool_call", enforceReadDiscipline);
|
|
401
|
+
|
|
402
|
+
// After enforcement, so a blocked call is never snapshotted. Shell calls are
|
|
403
|
+
// tracked on both channels; edits are snapshotted on Pi's built-in MCP only.
|
|
404
|
+
watchNativeEdits(pi, () => native);
|
|
405
|
+
if (native) {
|
|
406
|
+
// Pi < 1.0.1 has no tool renderers; the diffs are still stored.
|
|
407
|
+
try {
|
|
408
|
+
(pi as Partial<Pick<ExtensionAPI, "registerToolRenderer">>).registerToolRenderer?.(nativeRenderers);
|
|
409
|
+
} catch {
|
|
410
|
+
// drawing diffs is optional
|
|
411
|
+
}
|
|
412
|
+
}
|
|
371
413
|
}
|
package/src/render.ts
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
// TUI rendering for bridged tools: a call line read off Gortex's argument
|
|
2
2
|
// shape (operation, target, options), Pi's own read renderer for a file read,
|
|
3
|
-
// and a result collapsed until the user expands it.
|
|
3
|
+
// an edit's diff, and a result collapsed until the user expands it.
|
|
4
4
|
|
|
5
|
-
import { createReadToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { createReadToolDefinition, renderDiff } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { Theme, ToolDefinition, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
8
|
import type { Component } from "@earendil-works/pi-tui";
|
|
9
9
|
|
|
10
|
+
import type { EditDetails } from "./diff.ts";
|
|
10
11
|
import { textFromResult } from "./mcp-client.ts";
|
|
12
|
+
import { isNativeGortexTool } from "./native.ts";
|
|
11
13
|
|
|
12
14
|
type ToolRenderContext = Parameters<NonNullable<ToolDefinition["renderCall"]>>[2];
|
|
13
15
|
|
|
14
16
|
export type RenderCall = (args: Record<string, unknown>, theme: Theme, context: ToolRenderContext) => Component;
|
|
15
|
-
export type RenderResult = (
|
|
17
|
+
export type RenderResult = (
|
|
18
|
+
result: unknown,
|
|
19
|
+
options: { expanded?: boolean },
|
|
20
|
+
theme?: Theme,
|
|
21
|
+
context?: ToolRenderContext,
|
|
22
|
+
) => Component;
|
|
16
23
|
|
|
17
24
|
const OPERATION_ARGS = ["operation", "kind"];
|
|
18
25
|
const SUBJECT_ARGS = ["target", "query", "question", "task", "path"];
|
|
@@ -144,6 +151,56 @@ export function resultText(result: unknown): string {
|
|
|
144
151
|
return textFromResult(result) || JSON.stringify(structured ?? result ?? {});
|
|
145
152
|
}
|
|
146
153
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
/** diffView draws each file an edit changed with Pi's diff renderer, or nothing when the result has none. */
|
|
155
|
+
function diffView(result: unknown, theme: Theme | undefined, context: ToolRenderContext | undefined): Container | undefined {
|
|
156
|
+
const diffs = (result as { details?: EditDetails })?.details?.diffs;
|
|
157
|
+
if (!diffs?.length || !theme || context?.isError) return undefined;
|
|
158
|
+
const view = new Container();
|
|
159
|
+
for (const { path, diff, note } of diffs) {
|
|
160
|
+
view.addChild(new Spacer(1));
|
|
161
|
+
if (note) {
|
|
162
|
+
view.addChild(new Text(`${theme.fg("accent", path)} ${theme.fg("muted", note)}`, 1, 0));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (diffs.length > 1) view.addChild(new Text(theme.fg("accent", path), 1, 0));
|
|
166
|
+
view.addChild(new Text(renderDiff(diff, { filePath: path }), 1, 0));
|
|
167
|
+
}
|
|
168
|
+
return view;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* renderResult shows the diff of every file an edit changed, the way Pi's own
|
|
173
|
+
* edit does. Gortex's text reply stays collapsed until the user expands it
|
|
174
|
+
* with ctrl+o.
|
|
175
|
+
*/
|
|
176
|
+
export const renderResult: RenderResult = (result, options, theme, context) => {
|
|
177
|
+
const text = new Text(options?.expanded ? resultText(result) : "", 0, 0);
|
|
178
|
+
const view = diffView(result, theme, context);
|
|
179
|
+
if (!view) return text;
|
|
180
|
+
if (options?.expanded) view.addChild(text);
|
|
181
|
+
return view;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* nativeRenderers draws the diffs watchNativeEdits stores above Pi's own MCP
|
|
186
|
+
* result, which shows once expanded or on an error. Every other tool keeps its
|
|
187
|
+
* renderers.
|
|
188
|
+
*/
|
|
189
|
+
export function nativeRenderers(toolName: string, next: () => ToolRenderers | undefined): ToolRenderers | undefined {
|
|
190
|
+
const base = next();
|
|
191
|
+
// Without a base, Pi's MCP extension fills in its own renderers.
|
|
192
|
+
if (!base || !isNativeGortexTool(toolName)) return base;
|
|
193
|
+
const baseResult = base.renderResult;
|
|
194
|
+
return {
|
|
195
|
+
...base,
|
|
196
|
+
renderResult(result, options, theme, context) {
|
|
197
|
+
// Pi's renderer reuses lastComponent as its own and clears it.
|
|
198
|
+
const own = () => baseResult?.(result, options, theme, { ...context, lastComponent: undefined });
|
|
199
|
+
const rest = options.expanded || context.isError ? own() : undefined;
|
|
200
|
+
const view = diffView(result, theme, context);
|
|
201
|
+
if (!view) return rest ?? new Text("", 0, 0);
|
|
202
|
+
if (rest) view.addChild(rest);
|
|
203
|
+
return view;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -33,6 +33,13 @@ let versionWarning = "";
|
|
|
33
33
|
*/
|
|
34
34
|
export const gortexToolNames = new Set<string>();
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Registered names the daemon has since dropped from its list. Pi cannot
|
|
38
|
+
* unregister a tool, so these are switched off in the active set instead, and
|
|
39
|
+
* back on if the daemon lists them again.
|
|
40
|
+
*/
|
|
41
|
+
export const withdrawnToolNames = new Set<string>();
|
|
42
|
+
|
|
36
43
|
export function getClient(): MCPStdioClient | null {
|
|
37
44
|
return client;
|
|
38
45
|
}
|
package/src/tools.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// Tool registration: each tool the bridge lists becomes a native Pi tool whose
|
|
2
2
|
// execute() forwards to tools/call.
|
|
3
3
|
|
|
4
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { candidatePaths, editDetails, isFileEdit, withFileDiffs } from "./diff.ts";
|
|
7
|
+
import type { FileDiff } from "./diff.ts";
|
|
5
8
|
|
|
6
9
|
import type { ToolDescriptor } from "./mcp-client.ts";
|
|
7
10
|
import { callRenderer, readCallRenderer, renderResult, resultText } from "./render.ts";
|
|
8
11
|
import type { RenderCall, RenderResult } from "./render.ts";
|
|
9
|
-
import { getClient, gortexToolNames } from "./state.ts";
|
|
12
|
+
import { getClient, gortexToolNames, withdrawnToolNames } from "./state.ts";
|
|
10
13
|
import { toolNameMap } from "./hook.ts";
|
|
11
14
|
|
|
12
15
|
// Pi's reserved built-in names (mirrors toolNameMap's keys). A bridge tool that
|
|
@@ -44,7 +47,13 @@ interface BridgeToolDefinition {
|
|
|
44
47
|
label: string;
|
|
45
48
|
description: string;
|
|
46
49
|
parameters: unknown;
|
|
47
|
-
execute(
|
|
50
|
+
execute(
|
|
51
|
+
toolCallId: string,
|
|
52
|
+
params: Record<string, unknown>,
|
|
53
|
+
signal: AbortSignal | undefined,
|
|
54
|
+
onUpdate: unknown,
|
|
55
|
+
ctx: ExtensionContext | undefined,
|
|
56
|
+
): Promise<unknown>;
|
|
48
57
|
renderCall: RenderCall;
|
|
49
58
|
renderResult: RenderResult;
|
|
50
59
|
}
|
|
@@ -86,12 +95,20 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
|
|
|
86
95
|
label: name,
|
|
87
96
|
description: piAliasedDescription(name, piAliasName(name), (desc.description || name).trim()),
|
|
88
97
|
parameters,
|
|
89
|
-
async execute(_id
|
|
98
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
90
99
|
const client = getClient();
|
|
91
100
|
if (!client) throw new Error(`gortex ${name}: MCP bridge is not connected`);
|
|
101
|
+
const args = params ?? {};
|
|
102
|
+
const callTool = () => client.callTool(name, args);
|
|
92
103
|
let result: unknown;
|
|
104
|
+
let diffs: FileDiff[] = [];
|
|
93
105
|
try {
|
|
94
|
-
|
|
106
|
+
if (ctx && isFileEdit(name, args)) {
|
|
107
|
+
const paths = candidatePaths(args, ctx.cwd);
|
|
108
|
+
({ result, diffs } = await withFileDiffs(paths, ctx.cwd, callTool, (r) => !(r as { isError?: boolean })?.isError));
|
|
109
|
+
} else {
|
|
110
|
+
result = await callTool();
|
|
111
|
+
}
|
|
95
112
|
} catch (err) {
|
|
96
113
|
throw new Error(`gortex ${name} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
97
114
|
}
|
|
@@ -103,7 +120,7 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
|
|
|
103
120
|
}
|
|
104
121
|
const text = resultText(result);
|
|
105
122
|
if ((result as { isError?: boolean })?.isError) throw new Error(text || `gortex ${name} failed`);
|
|
106
|
-
return { content: [{ type: "text", text }], details:
|
|
123
|
+
return { content: [{ type: "text", text }], details: editDetails(diffs) };
|
|
107
124
|
},
|
|
108
125
|
renderCall: (name === "read" ? readCallRenderer : callRenderer)(piAliasName(name)),
|
|
109
126
|
renderResult,
|
|
@@ -115,13 +132,36 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
|
|
|
115
132
|
export async function registerGortexTools(pi: ExtensionAPI): Promise<void> {
|
|
116
133
|
const client = getClient();
|
|
117
134
|
if (!client) return;
|
|
135
|
+
const listed = new Set<string>();
|
|
118
136
|
for (const desc of await client.listTools()) {
|
|
119
|
-
if (!desc) continue;
|
|
137
|
+
if (!desc?.name) continue;
|
|
120
138
|
registerOneTool(pi, desc);
|
|
139
|
+
listed.add(piAliasName(desc.name));
|
|
140
|
+
}
|
|
141
|
+
matchActiveTools(pi, listed);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Keeps the tools offered to the model in step with the daemon's list. */
|
|
145
|
+
function matchActiveTools(pi: ExtensionAPI, listed: Set<string>): void {
|
|
146
|
+
const dropped = [...gortexToolNames].filter((name) => !listed.has(name));
|
|
147
|
+
const relisted = [...withdrawnToolNames].filter((name) => listed.has(name));
|
|
148
|
+
if (dropped.length === 0 && relisted.length === 0) return;
|
|
149
|
+
for (const name of dropped) {
|
|
150
|
+
gortexToolNames.delete(name);
|
|
151
|
+
withdrawnToolNames.add(name);
|
|
152
|
+
}
|
|
153
|
+
for (const name of relisted) withdrawnToolNames.delete(name);
|
|
154
|
+
try {
|
|
155
|
+
const active = new Set(pi.getActiveTools());
|
|
156
|
+
for (const name of dropped) active.delete(name);
|
|
157
|
+
for (const name of relisted) active.add(name);
|
|
158
|
+
pi.setActiveTools([...active]);
|
|
159
|
+
} catch {
|
|
160
|
+
// an invalidated instance keeps its last tool set.
|
|
121
161
|
}
|
|
122
162
|
}
|
|
123
163
|
|
|
124
|
-
/** Re-fetches the tool list after a list_changed and
|
|
164
|
+
/** Re-fetches the tool list after a list_changed and applies the difference. */
|
|
125
165
|
export async function syncTools(pi: ExtensionAPI): Promise<void> {
|
|
126
166
|
try {
|
|
127
167
|
await registerGortexTools(pi);
|