pi-gortex 0.3.0 → 0.4.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/README.md CHANGED
@@ -13,13 +13,16 @@ need instead of reading whole files around it, which is where the token
13
13
  savings come from. It is a single static binary with no dependency chain,
14
14
  and it covers 257 languages through tree-sitter.
15
15
 
16
- Pi has no MCP support by design, so this extension is the bridge.
16
+ This extension connects the two. It hands `gortex mcp` to Pi's built-in MCP,
17
+ or runs its own MCP client with `native_mcp` off. Read discipline goes through
18
+ `gortex hook` on both.
17
19
 
18
20
  ## What it does
19
21
 
20
- - **Registers Gortex's graph tools as native Pi tools**, over a persistent
21
- `gortex mcp` child per session. The deferred catalogue is reached through the
22
- daemon's own `tools_search`, and promoted tools appear in Pi's registry.
22
+ - **Registers Gortex's graph tools as native Pi tools**, through Pi's
23
+ built-in MCP or over the extension's own `gortex mcp` child per session. The
24
+ deferred catalogue is reached through the daemon's own `tools_search`, and
25
+ promoted tools appear in Pi's registry.
23
26
  - **Applies read discipline.** Pi's tool calls are forwarded to
24
27
  `gortex hook --agent=pi`, which answers block / soft guidance / nothing
25
28
  according to the posture you configured. Gortex owns that logic, so the
@@ -31,7 +34,7 @@ See [docs/architecture.md](docs/architecture.md) for how it works.
31
34
 
32
35
  ## Requirements
33
36
 
34
- - **Node 24+**, **Pi 0.85+**
37
+ - **Node 24+**, **Pi 1.0+**
35
38
  - **The `gortex` binary** on `PATH`, **v0.61.2 or newer**. See
36
39
  [Gortex installation](https://github.com/zzet/gortex/blob/main/docs/installation.md).
37
40
  The extension starts the shared daemon itself and never blocks Pi's startup
@@ -57,16 +60,16 @@ the absolute path, or a single run with `pi -e /path/to/pi-gortex`.
57
60
  ## Configure
58
61
 
59
62
  Zero configuration required. Every value resolves at runtime, first hit wins:
60
- environment -> `<cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
63
+ environment -> `<session cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
61
64
  (agent dir is `$PI_CODING_AGENT_DIR`, else `~/.pi/agent`) -> defaults.
62
65
 
63
66
  | Sidecar key | Environment | Default | Meaning |
64
67
  |----------------|--------------------|--------------------|---------|
65
- | `bin` | `GORTEX_BIN` | `gortex` on `PATH` | Binary used for `daemon start`, `mcp` and `hook`. |
66
- | `hook_mode` | `GORTEX_HOOK_MODE` | `deny` | Posture: `deny`, `enrich`, `consult-unlock`, `nudge`. |
68
+ | `bin` | `GORTEX_BIN` | `gortex` on `PATH` | The gortex binary. Read from the global sidecar only; a project file cannot set it. `~` expands, and a relative path resolves against the sidecar's directory (the launch directory for `GORTEX_BIN`). |
69
+ | `hook_mode` | `GORTEX_HOOK_MODE` | `deny` | Posture: `deny`, `enrich`, `consult-unlock`, `nudge` (`adaptive-nudge` is an alias). |
67
70
  | `enforce` | `GORTEX_ENFORCE` | `true` | `false` keeps the graph tools and the briefing, and wires no enforcement. |
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. |
71
+ | `tools_preset` | `GORTEX_TOOLS` | daemon's default | Eager MCP surface: a Gortex preset (`core`, `full`, `agent`, `edit`, `nav`, `readonly`, `localization`), optionally followed by comma-separated tool names and `+tool`/`-tool` changes, e.g. `nav,+edit_file`. A value that does not open with a preset is ignored. |
72
+ | `native_mcp` | `GORTEX_NATIVE_MCP` | `true` | Register `gortex mcp` with Pi's built-in MCP. `false` keeps the extension's own client. |
70
73
 
71
74
  ```json
72
75
  {
@@ -76,8 +79,8 @@ environment -> `<cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
76
79
  }
77
80
  ```
78
81
 
79
- The extension reads its configuration when Pi loads it, so a change takes
80
- effect after `/reload`.
82
+ The extension reads its configuration at every session start, from the
83
+ directory the session runs in, so a change takes effect on `/reload` or `/new`.
81
84
 
82
85
  **Pi's own `mcp.json` wins.** With `native_mcp` on, the extension registers a
83
86
  server named `gortex`. A `gortex` entry in `~/.pi/agent/mcp.json` or
@@ -68,15 +68,15 @@ implementation forwards the call down that channel.
68
68
  arrive in bursts, so the re-sync is debounced; a search call awaits its own
69
69
  re-sync directly, so every tool its reply names is callable by the time the
70
70
  model reads it.
71
- - **Framing.** Reads are incremental. The scan for a frame boundary resumes
72
- where the previous chunk ended, so one large frame split across many chunks
73
- does not re-scan a growing buffer each time. A frame past a generous size cap
74
- kills the child, because a stream cannot be resynced mid-frame, and the next
75
- call respawns it.
71
+ - **Framing.** Frames are cut at the newline byte and decoded whole, so a
72
+ character split across chunks arrives intact. A frame past a generous byte
73
+ cap kills the child, because a stream cannot be resynced mid-frame, and the
74
+ next call respawns it.
76
75
  - **Timeouts.** Every request is bounded: a short cap for ordinary requests, a
77
76
  longer one for the handshake, the longest for tool calls. The last is generous
78
77
  enough for analyzers that legitimately run for minutes, and finite so a wedged
79
- daemon cannot hang an agent turn forever.
78
+ daemon cannot hang an agent turn forever. A call dropped on timeout or on
79
+ Pi's abort tells the daemon to cancel it.
80
80
 
81
81
  ### Tool-name aliasing
82
82
 
@@ -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
@@ -100,7 +115,8 @@ keys. That translation lives on this side of the bridge, so the Go side keeps
100
115
  one vocabulary for every agent it serves.
101
116
 
102
117
  A call to a Gortex tool is flagged as a graph call and sent under the daemon's
103
- own tool name, which is what the postures key off. It skips the translation, so
118
+ own tool name, which is what the postures key off. On Pi's built-in MCP that
119
+ means the `mcp__gortex__` prefix is stripped. It skips the translation, so
104
120
  Gortex's `read` never passes for Pi's. On Pi's built-in MCP the same hook also
105
121
  sees each tool a codemode script calls.
106
122
 
@@ -109,11 +125,14 @@ behaviour identical across hosts.
109
125
 
110
126
  ## Orientation injection
111
127
 
112
- The session briefing rides a tail user message appended during context assembly.
113
- Carrying it in the system prompt would place it at the head of the conversation,
114
- where a change invalidates prefix prompt caching on every turn. The text is
115
- computed once per session and cleared only once it has been appended, so a
116
- context shape the extension cannot append to does not silently drop it.
128
+ The session briefing is stored once in the session history as a hidden message,
129
+ so every LLM call carries it. It stays out of the system prompt, because a
130
+ change there invalidates prefix prompt caching.
131
+
132
+ ## Withdrawn tools
133
+
134
+ Pi cannot unregister a tool, so one the daemon withdraws leaves Pi's active tool
135
+ set instead.
117
136
 
118
137
  ## The readiness barrier
119
138
 
@@ -174,3 +193,21 @@ Every value resolves at runtime, layered from the environment down through a
174
193
  project file and a global one to defaults. See the table in the
175
194
  [README](../README.md#configure). Runtime resolution is what lets one artifact
176
195
  serve both the npm package and a directory install.
196
+
197
+ Resolution runs on every `session_start`, against the session's own directory.
198
+ A resumed session runs where it was created, which need not be where Pi was
199
+ launched. So the native server is registered on `session_start` rather than
200
+ when Pi loads the extension, and again only when the session's preset or
201
+ channel differ, since Pi reconnects a re-registered server. Pi reads a
202
+ registration made before its own `session_start` handler and connects one made
203
+ after it. The extension's own `gortex mcp` is spawned in the session's
204
+ directory, which is what the proxy scopes the graph to.
205
+
206
+ A tool preset reaches `gortex mcp` only when it opens with a preset Gortex
207
+ knows. Gortex reads anything else as a bare allow-list, so a typo would strand
208
+ the session; it falls through to the next layer instead. The resolved value
209
+ always overrides an inherited `GORTEX_TOOLS`.
210
+
211
+ The project file sits in whatever repository the session runs in, so it cannot
212
+ set `bin`. Otherwise a cloned repository could choose the executable the
213
+ extension spawns. The binary comes from `GORTEX_BIN`, the global file or `PATH`.
package/docs/tests.md CHANGED
@@ -88,15 +88,15 @@ point in the directory when the manifest resolves to nothing.
88
88
  ## Where the double stops
89
89
 
90
90
  The double models what the suites need and no more. Behaviour past that edge,
91
- such as promotion re-sync under real notification traffic, frame splitting, the
92
- frame size cap, a real model turn or a live daemon, is unverified here. A change
91
+ such as promotion re-sync under real notification traffic, a real model turn or
92
+ a live daemon, is unverified here. A change
93
93
  in that territory is verified by hand against a running Gortex, and the suite
94
94
  says as much rather than implying coverage it does not have.
95
95
 
96
96
  ## Pinning
97
97
 
98
- The Pi devDependency is pinned tight on purpose. Pi is pre-1.0 and its runner's
99
- method surface has been renamed without a changelog entry before, so a tight pin
98
+ The Pi devDependency is pinned tight on purpose. Pi's runner's method surface
99
+ has been renamed without a changelog entry before, so a tight pin
100
100
  turns that into a red build rather than a silently skipped suite. The runtime
101
101
  peer dependency stays wide.
102
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gortex",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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": "~1.0.2",
39
- "@earendil-works/pi-tui": "^1.0.2",
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
@@ -7,17 +7,17 @@
7
7
 
8
8
  import { readFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
- import { join } from "node:path";
10
+ import { dirname, isAbsolute, join, resolve } 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
+ /** The gortex binary. Global sidecar only; relative to the sidecar's directory. Default: resolved from PATH. */
15
15
  bin?: string;
16
16
  /** Hook posture: deny | enrich | consult-unlock | nudge. */
17
17
  hook_mode?: string;
18
18
  /** false mirrors `gortex init --no-hooks`: orientation only, no enforcement. */
19
19
  enforce?: boolean;
20
- /** Eager MCP tool preset: core | full | edit | nav | readonly. */
20
+ /** Eager MCP tool preset (core | full | agent | edit | nav | readonly | localization) or a tool spec. */
21
21
  tools_preset?: string;
22
22
  /** false keeps the extension's own MCP client on a Pi that has built-in MCP. */
23
23
  native_mcp?: boolean;
@@ -31,7 +31,7 @@ export interface GortexConfig {
31
31
  hookArgv: string[];
32
32
  /** Whether to wire read-discipline enforcement onto tool_call. */
33
33
  enforce: boolean;
34
- /** Eager tool preset; the daemon's default applies when this is core/full. */
34
+ /** GORTEX_TOOLS for `gortex mcp`; empty leaves the daemon's default surface. */
35
35
  toolsPreset: string;
36
36
  /** Whether to hand the tool channel to Pi's built-in MCP when Pi has one. */
37
37
  nativeMcp: boolean;
@@ -47,7 +47,15 @@ export interface ResolveConfigOptions {
47
47
  }
48
48
 
49
49
  export const DEFAULT_BIN = "gortex";
50
- export const DEFAULT_TOOLS_PRESET = "core";
50
+ /** No preset: `gortex mcp` serves the daemon's default surface. */
51
+ export const DEFAULT_TOOLS_PRESET = "";
52
+
53
+ /** Expands a leading `~`, `~/` or `~\` to the home directory. */
54
+ export function expandHome(path: string, home: string = homedir()): string {
55
+ if (path === "~") return home;
56
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
57
+ return path;
58
+ }
51
59
 
52
60
  /**
53
61
  * Pi's agent directory, resolved the way Pi itself resolves it
@@ -59,15 +67,46 @@ export const DEFAULT_TOOLS_PRESET = "core";
59
67
  */
60
68
  export function defaultAgentDir(env: Record<string, string | undefined> = process.env): string {
61
69
  const override = env.PI_CODING_AGENT_DIR;
62
- if (override && override.trim() !== "") {
63
- const path = override.trim();
64
- if (path === "~") return homedir();
65
- if (path.startsWith("~/")) return join(homedir(), path.slice(2));
66
- return path;
67
- }
70
+ if (override && override.trim() !== "") return expandHome(override.trim());
68
71
  return join(homedir(), ".pi", "agent");
69
72
  }
70
73
 
74
+ /**
75
+ * A `bin` setting as spawn needs it: `~` expanded, a relative path anchored at
76
+ * `baseDir`, a bare name left for PATH lookup.
77
+ */
78
+ export function resolveBin(bin: string, baseDir: string): string {
79
+ const expanded = expandHome(bin);
80
+ if (!/[\\/]/.test(expanded) || isAbsolute(expanded)) return expanded;
81
+ return resolve(baseDir, expanded);
82
+ }
83
+
84
+ // Preset names and aliases Gortex accepts (internal/mcp/tool_presets.go).
85
+ const KNOWN_PRESETS = new Set([
86
+ "full", "all",
87
+ "core", "default", "classic",
88
+ "agent", "coding-agent",
89
+ "facade-v1", "agent-v2", "compact", "facade",
90
+ "readonly", "read-only", "read_only",
91
+ "edit", "editor", "edit-harness",
92
+ "nav", "navigate", "explore",
93
+ "localization", "locate", "find",
94
+ ]);
95
+
96
+ /**
97
+ * A tools_preset / GORTEX_TOOLS value Gortex should see, or undefined. Gortex
98
+ * reads a spec that does not open with a preset as a bare allow-list, so a
99
+ * typo (or `+tool` alone) would strip the session down to a tool or two. Only
100
+ * a spec opening with a known preset passes, optionally followed by
101
+ * comma-separated tool names and `+tool`/`-tool` changes.
102
+ */
103
+ export function toolsSpec(value: string | undefined): string | undefined {
104
+ const tokens = (value ?? "").split(",").map((token) => token.trim()).filter(Boolean);
105
+ const preset = tokens[0]?.toLowerCase();
106
+ if (preset === undefined || !KNOWN_PRESETS.has(preset)) return undefined;
107
+ return [preset, ...tokens.slice(1)].join(",");
108
+ }
109
+
71
110
  /** Sidecar paths, nearest first. A project file wins over the global one. */
72
111
  export function sidecarPaths(cwd: string, agentDir: string): string[] {
73
112
  return [join(cwd, ".pi", "gortex.json"), join(agentDir, "extensions", "gortex.json")];
@@ -139,29 +178,41 @@ export function resolveConfig(opts: ResolveConfigOptions = {}): GortexConfig {
139
178
  const agentDir = opts.agentDir ?? defaultAgentDir(env);
140
179
  const readFile = opts.readFile ?? ((path: string) => readFileSync(path, "utf8"));
141
180
 
142
- const [project, global] = sidecarPaths(cwd, agentDir).map((path) => readSidecar(path, readFile));
143
-
144
- // `gortex` unqualified is resolved by spawn against PATH.
145
- const bin = firstString(env.GORTEX_BIN, project?.bin, global?.bin) ?? DEFAULT_BIN;
146
-
147
- const mode = normalizeMode(firstString(env.GORTEX_HOOK_MODE, project?.hook_mode, global?.hook_mode));
181
+ const [projectPath, globalPath] = sidecarPaths(cwd, agentDir) as [string, string];
182
+ const project = readSidecar(projectPath, readFile);
183
+ const global = readSidecar(globalPath, readFile);
184
+
185
+ // A checked-out repository must never pick the executable, so the project
186
+ // sidecar has no say in `bin`. `gortex` unqualified is resolved against PATH.
187
+ const envBin = firstString(env.GORTEX_BIN);
188
+ const globalBin = firstString(global.bin);
189
+ const bin = envBin
190
+ ? resolveBin(envBin, process.cwd())
191
+ : globalBin
192
+ ? resolveBin(globalBin, dirname(globalPath))
193
+ : DEFAULT_BIN;
194
+
195
+ const mode = normalizeMode(firstString(env.GORTEX_HOOK_MODE, project.hook_mode, global.hook_mode));
148
196
  const hookArgv = [bin, "hook", "--agent=pi"];
149
197
  // "deny" is the hook's own default, so it rides as an absent flag.
150
198
  if (mode !== "deny") hookArgv.push(`--mode=${mode}`);
151
199
 
152
200
  const enforce =
153
201
  parseBoolean(env.GORTEX_ENFORCE) ??
154
- (typeof project?.enforce === "boolean" ? project.enforce : undefined) ??
155
- (typeof global?.enforce === "boolean" ? global.enforce : undefined) ??
202
+ (typeof project.enforce === "boolean" ? project.enforce : undefined) ??
203
+ (typeof global.enforce === "boolean" ? global.enforce : undefined) ??
156
204
  true;
157
205
 
158
206
  const toolsPreset =
159
- firstString(env.GORTEX_TOOLS, project?.tools_preset, global?.tools_preset) ?? DEFAULT_TOOLS_PRESET;
207
+ toolsSpec(env.GORTEX_TOOLS) ??
208
+ toolsSpec(firstString(project.tools_preset)) ??
209
+ toolsSpec(firstString(global.tools_preset)) ??
210
+ DEFAULT_TOOLS_PRESET;
160
211
 
161
212
  const nativeMcp =
162
213
  parseBoolean(env.GORTEX_NATIVE_MCP) ??
163
- (typeof project?.native_mcp === "boolean" ? project.native_mcp : undefined) ??
164
- (typeof global?.native_mcp === "boolean" ? global.native_mcp : undefined) ??
214
+ (typeof project.native_mcp === "boolean" ? project.native_mcp : undefined) ??
215
+ (typeof global.native_mcp === "boolean" ? global.native_mcp : undefined) ??
165
216
  true;
166
217
 
167
218
  return { bin, hookArgv, enforce, toolsPreset, nativeMcp };