pi-gortex 0.1.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/src/config.ts ADDED
@@ -0,0 +1,158 @@
1
+ // Configuration resolution: environment, then a project sidecar, then a global
2
+ // one, then defaults; first hit wins. The README documents the keys.
3
+ //
4
+ // A missing or malformed file is never fatal and falls through to the next
5
+ // layer, because losing the graph tools over a stray comma would be a worse
6
+ // failure than running with defaults.
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ /** The sidecar file's on-disk shape. `gortex init` is its writer. */
13
+ export interface SidecarConfig {
14
+ /** Absolute path to the gortex binary. Default: resolved from PATH. */
15
+ bin?: string;
16
+ /** Hook posture: deny | enrich | consult-unlock | nudge. */
17
+ hook_mode?: string;
18
+ /** false mirrors `gortex init --no-hooks`: orientation only, no enforcement. */
19
+ enforce?: boolean;
20
+ /** Eager MCP tool preset: core | full | edit | nav | readonly. */
21
+ tools_preset?: string;
22
+ }
23
+
24
+ /** Everything the extension needs to run, fully resolved. */
25
+ export interface GortexConfig {
26
+ /** Binary used for `daemon start`, `mcp` and `hook`. */
27
+ bin: string;
28
+ /** Full argv for the hook bridge, e.g. [bin, "hook", "--agent=pi", "--mode=enrich"]. */
29
+ hookArgv: string[];
30
+ /** Whether to wire read-discipline enforcement onto tool_call. */
31
+ enforce: boolean;
32
+ /** Eager tool preset; the daemon's default applies when this is core/full. */
33
+ toolsPreset: string;
34
+ }
35
+
36
+ /** Seams for tests; every one defaults to the real environment. */
37
+ export interface ResolveConfigOptions {
38
+ env?: Record<string, string | undefined>;
39
+ cwd?: string;
40
+ /** Pi's agent directory. Default: $PI_CODING_AGENT_DIR, else ~/.pi/agent. */
41
+ agentDir?: string;
42
+ readFile?: (path: string) => string;
43
+ }
44
+
45
+ export const DEFAULT_BIN = "gortex";
46
+ export const DEFAULT_TOOLS_PRESET = "core";
47
+
48
+ /**
49
+ * Pi's agent directory, resolved the way Pi itself resolves it
50
+ * (config.ts::getAgentDir in @earendil-works/pi-coding-agent).
51
+ *
52
+ * Re-derived here because the extension keeps zero runtime dependencies, so it
53
+ * also loads as a bare directory extension with no node_modules beside it.
54
+ * Pi's types are imported as types only, and erased.
55
+ */
56
+ export function defaultAgentDir(env: Record<string, string | undefined> = process.env): string {
57
+ const override = env.PI_CODING_AGENT_DIR;
58
+ if (override && override.trim() !== "") {
59
+ const path = override.trim();
60
+ if (path === "~") return homedir();
61
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
62
+ return path;
63
+ }
64
+ return join(homedir(), ".pi", "agent");
65
+ }
66
+
67
+ /** Sidecar paths, nearest first. A project file wins over the global one. */
68
+ export function sidecarPaths(cwd: string, agentDir: string): string[] {
69
+ return [join(cwd, ".pi", "gortex.json"), join(agentDir, "extensions", "gortex.json")];
70
+ }
71
+
72
+ function readSidecar(path: string, readFile: (p: string) => string): SidecarConfig {
73
+ let raw: string;
74
+ try {
75
+ raw = readFile(path);
76
+ } catch {
77
+ return {}; // absent is the common case
78
+ }
79
+ try {
80
+ const parsed: unknown = JSON.parse(raw);
81
+ return parsed !== null && typeof parsed === "object" ? (parsed as SidecarConfig) : {};
82
+ } catch {
83
+ // Malformed JSON falls through to the next layer. Nothing here can log
84
+ // without corrupting Pi's TUI.
85
+ return {};
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Hook postures the Go side accepts. Anything unrecognised (including empty)
91
+ * collapses to "deny", which is also the hook's own default.
92
+ */
93
+ export function normalizeMode(mode: string | undefined): string {
94
+ switch ((mode ?? "").trim().toLowerCase()) {
95
+ case "enrich":
96
+ return "enrich";
97
+ case "consult-unlock":
98
+ return "consult-unlock";
99
+ case "nudge":
100
+ case "adaptive-nudge":
101
+ return "nudge";
102
+ default:
103
+ return "deny";
104
+ }
105
+ }
106
+
107
+ /** Accepts the spellings a shell user reaches for; anything else is undefined. */
108
+ export function parseBoolean(value: string | undefined): boolean | undefined {
109
+ switch ((value ?? "").trim().toLowerCase()) {
110
+ case "1":
111
+ case "true":
112
+ case "yes":
113
+ case "on":
114
+ return true;
115
+ case "0":
116
+ case "false":
117
+ case "no":
118
+ case "off":
119
+ return false;
120
+ default:
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ function firstString(...values: (string | undefined)[]): string | undefined {
126
+ for (const value of values) {
127
+ if (typeof value === "string" && value.trim() !== "") return value.trim();
128
+ }
129
+ return undefined;
130
+ }
131
+
132
+ export function resolveConfig(opts: ResolveConfigOptions = {}): GortexConfig {
133
+ const env = opts.env ?? process.env;
134
+ const cwd = opts.cwd ?? process.cwd();
135
+ const agentDir = opts.agentDir ?? defaultAgentDir(env);
136
+ const readFile = opts.readFile ?? ((path: string) => readFileSync(path, "utf8"));
137
+
138
+ const [project, global] = sidecarPaths(cwd, agentDir).map((path) => readSidecar(path, readFile));
139
+
140
+ // `gortex` unqualified is resolved by spawn against PATH.
141
+ const bin = firstString(env.GORTEX_BIN, project?.bin, global?.bin) ?? DEFAULT_BIN;
142
+
143
+ const mode = normalizeMode(firstString(env.GORTEX_HOOK_MODE, project?.hook_mode, global?.hook_mode));
144
+ const hookArgv = [bin, "hook", "--agent=pi"];
145
+ // "deny" is the hook's own default, so it rides as an absent flag.
146
+ if (mode !== "deny") hookArgv.push(`--mode=${mode}`);
147
+
148
+ const enforce =
149
+ parseBoolean(env.GORTEX_ENFORCE) ??
150
+ (typeof project?.enforce === "boolean" ? project.enforce : undefined) ??
151
+ (typeof global?.enforce === "boolean" ? global.enforce : undefined) ??
152
+ true;
153
+
154
+ const toolsPreset =
155
+ firstString(env.GORTEX_TOOLS, project?.tools_preset, global?.tools_preset) ?? DEFAULT_TOOLS_PRESET;
156
+
157
+ return { bin, hookArgv, enforce, toolsPreset };
158
+ }
package/src/hook.ts ADDED
@@ -0,0 +1,97 @@
1
+ // The hook bridge: `gortex hook --agent=pi`. Pi has no hook configuration, so
2
+ // each event is shelled out with an envelope on stdin and the decision read
3
+ // back is applied.
4
+
5
+ import type { ExecFileSyncOpts, ProcessDeps } from "./runtime.ts";
6
+
7
+ /** The decision shape Go writes back (hooks.BridgeDecision). */
8
+ export interface PiDecision {
9
+ block?: boolean;
10
+ reason?: string;
11
+ additional_context?: string;
12
+ orientation?: string;
13
+ }
14
+
15
+ /** The envelope the extension writes to the hook's stdin (hooks.BridgeEvent). */
16
+ export interface PiEnvelope {
17
+ event: string;
18
+ tool_name?: string;
19
+ tool_input?: Record<string, unknown>;
20
+ cwd?: string;
21
+ session_id?: string;
22
+ is_gortex_tool?: boolean;
23
+ }
24
+
25
+ const HOOK_EXEC_OPTS: ExecFileSyncOpts = {
26
+ encoding: "utf8",
27
+ maxBuffer: 16 * 1024 * 1024,
28
+ timeout: 5_000,
29
+ };
30
+
31
+ /**
32
+ * callHook sends a normalized event envelope to `gortex hook --agent=pi` and
33
+ * parses the PiDecision it writes back. Fail-open: any error returns an empty
34
+ * decision so the extension never blocks Pi's flow on a hook hiccup (daemon
35
+ * down, parse error, timeout).
36
+ */
37
+ export function callHook(argv: string[], deps: ProcessDeps, envelope: PiEnvelope): PiDecision {
38
+ const [bin, ...args] = argv;
39
+ if (!bin) return {};
40
+ try {
41
+ const out = deps
42
+ .execFileSync(bin, args, { ...HOOK_EXEC_OPTS, input: JSON.stringify(envelope) })
43
+ .trim();
44
+ if (!out) return {};
45
+ return JSON.parse(out) as PiDecision;
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Tool-call normalization (Pi vocabulary -> canonical Claude-Code vocabulary)
53
+ // ---------------------------------------------------------------------------
54
+ //
55
+ // The Go classifier switches on Claude-Code tool names and canonical input
56
+ // keys ("file_path"/"pattern"/"command"), so the translation lives here.
57
+ //
58
+ // NOTE: Pi is pre-1.0. The built-in tool names mapped here are pinned to Pi's
59
+ // documented API (packages/coding-agent/docs/extensions.md); if an upgrade
60
+ // renames one, adjust this small map; the Go wire contract stays unchanged.
61
+ export const toolNameMap: Record<string, string> = {
62
+ read: "Read",
63
+ grep: "Grep",
64
+ find: "Glob",
65
+ ls: "Glob",
66
+ bash: "Bash",
67
+ edit: "Edit",
68
+ write: "Write",
69
+ };
70
+
71
+ function firstString(obj: Record<string, unknown>, keys: string[]): string | undefined {
72
+ for (const k of keys) {
73
+ const v = obj[k];
74
+ if (typeof v === "string" && v !== "") return v;
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ export function normalizeToolCall(
80
+ piName: string,
81
+ piInput: Record<string, unknown>,
82
+ ): { tool_name: string; tool_input: Record<string, unknown> } {
83
+ const canonical = toolNameMap[piName.toLowerCase()] ?? piName;
84
+ const out: Record<string, unknown> = { ...piInput };
85
+
86
+ const path = firstString(piInput, ["file_path", "path", "file", "filepath", "absolute_path"]);
87
+ if (path !== undefined) out.file_path = path;
88
+
89
+ let pattern = firstString(piInput, ["pattern", "glob", "query", "regex", "name"]);
90
+ if (pattern === undefined && canonical === "Glob") pattern = path;
91
+ if (pattern !== undefined) out.pattern = pattern;
92
+
93
+ const command = firstString(piInput, ["command", "cmd", "script"]);
94
+ if (command !== undefined) out.command = command;
95
+
96
+ return { tool_name: canonical, tool_input: out };
97
+ }
package/src/index.ts ADDED
@@ -0,0 +1,313 @@
1
+ // The extension factory: the readiness barrier and the Pi lifecycle wiring for
2
+ // both channels, graph tools over the MCP bridge and read discipline over the
3
+ // hook bridge. See docs/architecture.md for the design.
4
+
5
+ import type {
6
+ ContextEvent,
7
+ ExtensionAPI,
8
+ ExtensionContext,
9
+ ToolCallEvent,
10
+ ToolCallEventResult,
11
+ } from "@earendil-works/pi-coding-agent";
12
+
13
+ import { resolveConfig } from "./config.ts";
14
+ import type { GortexConfig } from "./config.ts";
15
+ import { callHook, normalizeToolCall } from "./hook.ts";
16
+ import {
17
+ MCPStdioClient,
18
+ MIN_GORTEX_VERSION,
19
+ PROTOCOL_VERSION,
20
+ ensureDaemon,
21
+ isBelowVersion,
22
+ } from "./mcp-client.ts";
23
+ import { nodeProcessDeps } from "./runtime.ts";
24
+ import type { ProcessDeps } from "./runtime.ts";
25
+ import {
26
+ clearSyncTimer,
27
+ registerGortexTools,
28
+ scheduleSyncTools,
29
+ } from "./tools.ts";
30
+ import {
31
+ getBridgeError,
32
+ getClient,
33
+ getVersionWarning,
34
+ gortexToolNames,
35
+ setBridgeError,
36
+ setClient,
37
+ setVersionWarning,
38
+ } from "./state.ts";
39
+
40
+ export type { GortexConfig, SidecarConfig } from "./config.ts";
41
+
42
+ /**
43
+ * How long before_agent_start waits for session_start to finish. Pi arms the
44
+ * editor's submit handler before session_start runs, at startup and across
45
+ * /reload and /new, so a fast prompt can reach the agent loop while
46
+ * registration is still pending. Capped so a wedged daemon costs the first turn
47
+ * seconds; start() alone can take ~2 min.
48
+ */
49
+ export const READY_WAIT_MS = 15_000;
50
+
51
+ /**
52
+ * Compares the daemon against what this extension is built for and, on a skew,
53
+ * tells both audiences: the user through Pi's warning channel (which never
54
+ * enters model context), the model through the orientation, so it knows a tool
55
+ * may misbehave. Advisory only: the session keeps every tool it registered,
56
+ * matching the fail-open posture in docs/architecture.md.
57
+ */
58
+ function reportVersionSkew(
59
+ ctx: ExtensionContext | undefined,
60
+ serverVersion: string,
61
+ negotiatedProtocol: string,
62
+ ): void {
63
+ const stale = serverVersion !== "" && isBelowVersion(serverVersion, MIN_GORTEX_VERSION);
64
+ const protocolSkew = negotiatedProtocol !== "" && negotiatedProtocol !== PROTOCOL_VERSION;
65
+ if (!stale && !protocolSkew) return;
66
+
67
+ const installed = serverVersion === "" ? "an unreported version" : `gortex ${serverVersion}`;
68
+ const user = stale
69
+ ? `${installed} is older than ${MIN_GORTEX_VERSION}, which this extension needs. ` +
70
+ `Graph tools and read discipline may be missing or wrong. Run \`gortex upgrade\`.`
71
+ : `${installed} answered the handshake with MCP protocol ${negotiatedProtocol}, ` +
72
+ `not the ${PROTOCOL_VERSION} this extension speaks. Tool calls may fail. ` +
73
+ `Run \`gortex upgrade\`.`;
74
+
75
+ setVersionWarning(
76
+ stale
77
+ ? `[Gortex] the daemon is ${installed}, older than the ${MIN_GORTEX_VERSION} this ` +
78
+ `extension targets. A graph tool may be missing or behave unexpectedly. If one ` +
79
+ `does, tell the user to run \`gortex upgrade\`.`
80
+ : `[Gortex] the daemon negotiated MCP protocol ${negotiatedProtocol} instead of ` +
81
+ `${PROTOCOL_VERSION}. A graph tool call may fail. If one does, tell the user to ` +
82
+ `run \`gortex upgrade\`.`,
83
+ );
84
+
85
+ // A Pi build without a UI (print/JSON mode) or without notify must not take
86
+ // the session down over an advisory message.
87
+ try {
88
+ if (ctx?.hasUI) ctx.ui.notify(user, "warning");
89
+ } catch {
90
+ // the model still gets the orientation line above
91
+ }
92
+ }
93
+
94
+ /** Test seams. Pi itself calls the factory with the `pi` object alone. */
95
+ export interface GortexExtensionOptions {
96
+ /** Pre-resolved configuration; resolveConfig() runs when this is absent. */
97
+ config?: GortexConfig;
98
+ /** Process seam for spawn/execFileSync. */
99
+ deps?: ProcessDeps;
100
+ /** Readiness cap override. */
101
+ readyWaitMs?: number;
102
+ }
103
+
104
+ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensionOptions = {}): void {
105
+ const config = options.config ?? resolveConfig();
106
+ // Pi 0.85's ExtensionAPI declares no `cwd`, so the hook envelope reads it
107
+ // defensively and falls back to the process cwd. A Pi version that grows the
108
+ // field is picked up when it exists.
109
+ const piCwd = (): string => (pi as { cwd?: string })?.cwd ?? process.cwd();
110
+ const deps = options.deps ?? nodeProcessDeps;
111
+ const readyWaitMs = options.readyWaitMs ?? READY_WAIT_MS;
112
+
113
+ // Latched for the whole session once the first turn has run the
114
+ // session_start hook, whether or not it produced anything to inject.
115
+ let startupHookRan = false;
116
+ // Orientation awaiting injection into the next LLM call, computed once per
117
+ // session. The `context` hook appends it as a tail user message, because a
118
+ // systemPrompt change sits at messages[0] and invalidates prefix caching.
119
+ let pendingOrientation = "";
120
+
121
+ // Settles when the current session_start handler is done: bridge up and
122
+ // tools registered, or the handshake failed. Never rejects.
123
+ //
124
+ // Armed at factory time, because every session builds a fresh instance before
125
+ // its session_start fires, so an instance can be asked for a turn before its
126
+ // own handler has run. Every path that constructs an instance goes on to emit
127
+ // session_start, so the wait ends.
128
+ let sessionReady!: Promise<void>;
129
+ let settleSessionReady: () => void = () => {};
130
+ let sessionReadyPending = false;
131
+
132
+ function armSessionReady(): void {
133
+ if (sessionReadyPending) return; // keep the promise parked waiters hold
134
+ sessionReadyPending = true;
135
+ sessionReady = new Promise<void>((resolve) => {
136
+ settleSessionReady = () => {
137
+ sessionReadyPending = false;
138
+ resolve();
139
+ };
140
+ });
141
+ }
142
+ armSessionReady();
143
+
144
+ // Resolves true when session_start finished, false when the cap expired
145
+ // first. The caller has to tell those apart, since a cap expiry leaves
146
+ // bridgeError empty.
147
+ function waitForSession(ms: number): Promise<boolean> {
148
+ const ready = sessionReady;
149
+ return new Promise<boolean>((resolve) => {
150
+ let settled = false;
151
+ const done = (ok: boolean) => {
152
+ if (settled) return;
153
+ settled = true;
154
+ clearTimeout(timer);
155
+ resolve(ok);
156
+ };
157
+ const timer = setTimeout(() => done(false), ms);
158
+ (timer as { unref?: () => void })?.unref?.();
159
+ ready.then(() => done(true), () => done(true));
160
+ });
161
+ }
162
+
163
+ // Pi resets the session's tool registry on every session_start, so
164
+ // (re)register here. Clear the name guard first, since it persists across
165
+ // sessions and would otherwise suppress re-registration. The previous
166
+ // session's bridge child (if any) is stopped before a fresh handshake.
167
+ pi.on("session_start", async (_event, ctx) => {
168
+ startupHookRan = false;
169
+ pendingOrientation = "";
170
+ setBridgeError("");
171
+ setVersionWarning("");
172
+ armSessionReady(); // no-op when a turn is already parked on this session
173
+ const settle = settleSessionReady; // this invocation's resolver
174
+ try {
175
+ await startSession(ctx);
176
+ } finally {
177
+ settle();
178
+ }
179
+ });
180
+
181
+ // startSession holds the body of session_start so the readiness promise
182
+ // above settles on every exit path, early returns included.
183
+ async function startSession(ctx?: ExtensionContext): Promise<void> {
184
+ ensureDaemon(config.bin, deps);
185
+ gortexToolNames.clear();
186
+ clearSyncTimer();
187
+ const previous = getClient();
188
+ if (previous) {
189
+ previous.stop();
190
+ setClient(null);
191
+ }
192
+ const c = new MCPStdioClient({ bin: config.bin, toolsPreset: config.toolsPreset, deps });
193
+ // Claim the slot before the first await: an overlapping
194
+ // session_start (rapid /new or /reload) then sees and stops THIS
195
+ // bridge instead of leaking its child mid-handshake.
196
+ setClient(c);
197
+ try {
198
+ await c.start();
199
+ if (getClient() !== c) return; // superseded while handshaking
200
+ reportVersionSkew(ctx, c.serverVersion, c.negotiatedProtocol);
201
+ c.onToolsListChanged = () => {
202
+ scheduleSyncTools(pi);
203
+ };
204
+ await registerGortexTools(pi);
205
+ } catch (err) {
206
+ // daemon unreachable / binary missing / handshake or tools/list
207
+ // failure: no graph tools this session; /reload (or the next
208
+ // session_start) retries.
209
+ c.stop();
210
+ if (getClient() === c) {
211
+ setClient(null);
212
+ setBridgeError(err instanceof Error ? err.message : String(err));
213
+ }
214
+ }
215
+ }
216
+
217
+ // Fires before the agent loop's first LLM call. It can't mutate messages
218
+ // itself, so it just parks the orientation for the `context` hook.
219
+ pi.on("before_agent_start", async () => {
220
+ if (startupHookRan) return;
221
+ startupHookRan = true; // before the first await, so a second turn can't race in
222
+ // Pi awaits each listener, so this holds the turn until the tools are
223
+ // registered and bridgeError reflects the handshake (or the cap expires).
224
+ const ready = await waitForSession(readyWaitMs);
225
+ const decision = callHook(config.hookArgv, deps, {
226
+ event: "session_start",
227
+ cwd: piCwd(),
228
+ });
229
+ const parts: string[] = [];
230
+ const bridgeError = getBridgeError();
231
+ if (bridgeError) {
232
+ parts.push(
233
+ `[Gortex] graph tools are unavailable this session (${bridgeError}). ` +
234
+ `Tell the user to run /reload to retry the connection.`,
235
+ );
236
+ setBridgeError("");
237
+ } else if (!ready) {
238
+ // Cap expired mid-handshake: bridgeError is still empty, and this is the
239
+ // only turn that reports it: startupHookRan latches above, so a
240
+ // handshake that fails after the cap never reaches the branch above.
241
+ parts.push(
242
+ `[Gortex] graph tools were still registering when this turn began, so a tool ` +
243
+ `named below may not be callable yet. If one is missing, say so and tell the ` +
244
+ `user to retry, or to run /reload if it stays missing. Don't fall back to ` +
245
+ `native tools.`,
246
+ );
247
+ }
248
+ const versionWarning = getVersionWarning();
249
+ if (versionWarning) {
250
+ parts.push(versionWarning);
251
+ setVersionWarning("");
252
+ }
253
+ if (decision.orientation) parts.push(decision.orientation);
254
+ if (parts.length > 0) pendingOrientation = parts.join("\n\n");
255
+ return;
256
+ });
257
+
258
+ // Fires before each LLM call with a mutable message array. Appends the
259
+ // parked orientation once, clearing it only after the push lands so the
260
+ // orientation survives a context shape this hook can't append to.
261
+ // Pi exports ContextEvent but not its result type, so it is spelled out here.
262
+ pi.on("context", (event: ContextEvent): { messages?: ContextEvent["messages"] } | void => {
263
+ if (!pendingOrientation) return;
264
+ try {
265
+ const messages = (event as { messages?: unknown })?.messages;
266
+ if (Array.isArray(messages)) {
267
+ // Pi's AgentMessage also carries a `timestamp`; only the two fields
268
+ // Pi reads for a tail user turn are pushed.
269
+ messages.push({ role: "user", content: pendingOrientation } as ContextEvent["messages"][number]);
270
+ pendingOrientation = "";
271
+ return { messages: messages as ContextEvent["messages"] };
272
+ }
273
+ } catch {
274
+ // best effort; never break context assembly.
275
+ }
276
+ return;
277
+ });
278
+
279
+ if (!config.enforce) return;
280
+
281
+ // Enforcement: every non-Gortex tool call is checked against the Go hook.
282
+ pi.on("tool_call", async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | void> => {
283
+ const piName: string = (event as { toolName?: string })?.toolName ?? "";
284
+ const piInput: Record<string, unknown> = (event?.input as Record<string, unknown>) ?? {};
285
+ const isGortexTool = gortexToolNames.has(piName);
286
+
287
+ const norm = normalizeToolCall(piName, piInput);
288
+ const decision = callHook(config.hookArgv, deps, {
289
+ event: "tool_call",
290
+ tool_name: norm.tool_name,
291
+ tool_input: norm.tool_input,
292
+ cwd: ctx?.cwd ?? piCwd(),
293
+ session_id: (ctx as { sessionManager?: { sessionId?: string } })?.sessionManager?.sessionId ?? "",
294
+ is_gortex_tool: isGortexTool,
295
+ });
296
+
297
+ if (decision.block) {
298
+ return { block: true, reason: decision.reason ?? "[Gortex] blocked: prefer graph tools." };
299
+ }
300
+ if (decision.additional_context) {
301
+ // Soft guidance: surface it without blocking the call.
302
+ try {
303
+ pi.sendMessage(
304
+ { customType: "gortex", content: decision.additional_context, display: true },
305
+ { deliverAs: "steer" },
306
+ );
307
+ } catch {
308
+ // sendMessage shape can vary across Pi versions; never fatal.
309
+ }
310
+ }
311
+ return;
312
+ });
313
+ }