pi-gortex 0.1.0 → 0.2.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 CHANGED
@@ -88,7 +88,8 @@ npm run typecheck
88
88
  npm run check # both
89
89
  ```
90
90
 
91
- Zero runtime dependencies. Pi's types are imported as types only and erased, so
91
+ Zero runtime dependencies. Pi's extension loader resolves Pi's own packages to
92
+ the running Pi, so they are imported freely and kept in `devDependencies`, and
92
93
  the package also loads as a bare directory extension with no `node_modules`
93
94
  beside it.
94
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gortex",
3
- "version": "0.1.0",
3
+ "version": "0.2.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,9 @@
35
35
  "prepublishOnly": "npm run check"
36
36
  },
37
37
  "devDependencies": {
38
- "@earendil-works/pi-coding-agent": "~0.85.1",
39
- "@types/node": "^26.6.2",
38
+ "@earendil-works/pi-coding-agent": "~0.87.0",
39
+ "@earendil-works/pi-tui": "^0.87.0",
40
+ "@types/node": "^26.4.1",
40
41
  "typescript": "^7.0.2"
41
42
  },
42
43
  "peerDependencies": {
package/src/render.ts ADDED
@@ -0,0 +1,149 @@
1
+ // TUI rendering for bridged tools: a call line read off Gortex's argument
2
+ // shape (operation, target, options), Pi's own read renderer for a file read,
3
+ // and a result collapsed until the user expands it.
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";
8
+ import type { Component } from "@earendil-works/pi-tui";
9
+
10
+ import { textFromResult } from "./mcp-client.ts";
11
+
12
+ type ToolRenderContext = Parameters<NonNullable<ToolDefinition["renderCall"]>>[2];
13
+
14
+ export type RenderCall = (args: Record<string, unknown>, theme: Theme, context: ToolRenderContext) => Component;
15
+ export type RenderResult = (result: unknown, options: { expanded?: boolean }) => Component;
16
+
17
+ const OPERATION_ARGS = ["operation", "kind"];
18
+ const SUBJECT_ARGS = ["target", "query", "question", "task", "path"];
19
+ const HIDDEN_ARGS = new Set(["output", "view", "context", "to"]);
20
+ const NESTED_ARGS = new Set(["options", "arguments"]);
21
+ const HIDDEN_OPTIONS = new Set(["new_user_task"]);
22
+ const SCOPE_OPTIONS = ["repo", "project", "path", "scope", "kind", "lang"];
23
+ const MAX_VALUE_CHARS = 60;
24
+
25
+ /** CallLine renders on one row cut to the terminal width, unlike Text which wraps. */
26
+ class CallLine implements Component {
27
+ text = "";
28
+ setText(text: string): void {
29
+ this.text = text;
30
+ }
31
+ invalidate(): void {}
32
+ render(width: number): string[] {
33
+ return [truncateToWidth(this.text, width)];
34
+ }
35
+ }
36
+
37
+ function isSet(value: unknown): boolean {
38
+ if (value === undefined || value === null || value === "") return false;
39
+ if (typeof value === "object" && !Array.isArray(value)) return Object.keys(value).length > 0;
40
+ return true;
41
+ }
42
+
43
+ function asRecord(value: unknown): Record<string, unknown> {
44
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
45
+ }
46
+
47
+ /** selector unwraps a target-shaped object ({file}, {symbol}, ...) to the value it selects. */
48
+ function selector(value: unknown): unknown {
49
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
50
+ const entry = Object.values(value).find(isSet);
51
+ return entry ?? value;
52
+ }
53
+
54
+ /** lineNumber keeps a positive finite number; Gortex reads 0 as unset. */
55
+ function lineNumber(value: unknown): number | undefined {
56
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
57
+ }
58
+
59
+ function formatValue(value: unknown, full: boolean): string {
60
+ let s: string;
61
+ if (typeof value === "string") s = value;
62
+ else if (Array.isArray(value) && value.every((v) => typeof v !== "object")) s = value.join(",");
63
+ else if (typeof value === "object") s = JSON.stringify(value);
64
+ else s = String(value);
65
+ s = s.replace(/\s+/g, " ").trim();
66
+ return !full && s.length > MAX_VALUE_CHARS ? s.slice(0, MAX_VALUE_CHARS - 1) + "…" : s;
67
+ }
68
+
69
+ /**
70
+ * callRenderer renders `<tool> <operation> <subject> to <other> key=value...`.
71
+ * Scope options come first so they survive the cut to the terminal width;
72
+ * expanded, the line wraps with every value in full.
73
+ */
74
+ export function callRenderer(name: string): RenderCall {
75
+ return (rawArgs, theme, context) => {
76
+ const args = asRecord(rawArgs);
77
+ const full = context.expanded;
78
+ const parts = [theme.fg("toolTitle", theme.bold(name))];
79
+ const operationKey = OPERATION_ARGS.find((k) => isSet(args[k]));
80
+ if (operationKey) parts.push(theme.fg("toolTitle", formatValue(args[operationKey], full)));
81
+ const subjectKey = SUBJECT_ARGS.find((k) => isSet(args[k]));
82
+ if (subjectKey) parts.push(theme.fg("accent", formatValue(selector(args[subjectKey]), full)));
83
+ if (isSet(args.to)) parts.push(theme.fg("dim", "to"), theme.fg("accent", formatValue(selector(args.to), full)));
84
+
85
+ const pairs: [string, unknown][] = [];
86
+ for (const [k, v] of Object.entries(args)) {
87
+ if (HIDDEN_ARGS.has(k) || k === operationKey || k === subjectKey) continue;
88
+ if (NESTED_ARGS.has(k)) {
89
+ const nested = Object.entries(asRecord(v)).filter(([nk]) => !HIDDEN_OPTIONS.has(nk));
90
+ const rank = ([nk]: [string, unknown]) => (SCOPE_OPTIONS.includes(nk) ? 0 : 1);
91
+ pairs.push(...nested.sort((a, b) => rank(a) - rank(b)));
92
+ } else {
93
+ pairs.push([k, v]);
94
+ }
95
+ }
96
+ for (const [k, v] of pairs) {
97
+ if (isSet(v)) parts.push(theme.fg("dim", `${k}=`) + theme.fg("muted", formatValue(v, full)));
98
+ }
99
+
100
+ const line = full ? new Text("", 0, 0) : new CallLine();
101
+ line.setText(parts.join(" "));
102
+ return line;
103
+ };
104
+ }
105
+
106
+ /** GortexText marks a line rendered by Pi's own renderer as Gortex's. */
107
+ class GortexText extends Text {
108
+ theme: Theme | undefined;
109
+ override setText(text: string): void {
110
+ super.setText(this.theme ? this.theme.fg("dim", "gortex ") + text : text);
111
+ }
112
+ }
113
+
114
+ let piRead: ReturnType<typeof createReadToolDefinition> | undefined;
115
+
116
+ /**
117
+ * readCallRenderer hands a file read to Pi's read renderer, so it shows the
118
+ * same path, line range and skill / docs / resource labels as Pi's own read.
119
+ * Any other read operation falls through to the generic call line.
120
+ */
121
+ export function readCallRenderer(name: string): RenderCall {
122
+ const generic = callRenderer(name);
123
+ return (rawArgs, theme, context) => {
124
+ const args = asRecord(rawArgs);
125
+ const file = asRecord(args.target).file;
126
+ if (typeof file !== "string" || (args.operation !== undefined && args.operation !== "file")) {
127
+ return generic(rawArgs, theme, context);
128
+ }
129
+ piRead ??= createReadToolDefinition(context.cwd);
130
+ const text = context.lastComponent instanceof GortexText ? context.lastComponent : new GortexText("", 0, 0);
131
+ text.theme = theme;
132
+ const options = asRecord(args.options);
133
+ const readArgs = {
134
+ path: file,
135
+ offset: lineNumber(options.offset),
136
+ limit: lineNumber(options.limit),
137
+ };
138
+ return piRead.renderCall!(readArgs, theme, { ...context, args: readArgs, lastComponent: text });
139
+ };
140
+ }
141
+
142
+ export function resultText(result: unknown): string {
143
+ const structured = (result as { structuredContent?: unknown })?.structuredContent;
144
+ return textFromResult(result) || JSON.stringify(structured ?? result ?? {});
145
+ }
146
+
147
+ /** renderResult collapses the result to nothing until the user expands it with ctrl+o. */
148
+ export const renderResult: RenderResult = (result, options) =>
149
+ new Text(options?.expanded ? resultText(result) : "", 0, 0);
package/src/tools.ts CHANGED
@@ -1,12 +1,11 @@
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 { createRequire } from "node:module";
5
-
6
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
5
 
8
- import { textFromResult } from "./mcp-client.ts";
9
6
  import type { ToolDescriptor } from "./mcp-client.ts";
7
+ import { callRenderer, readCallRenderer, renderResult, resultText } from "./render.ts";
8
+ import type { RenderCall, RenderResult } from "./render.ts";
10
9
  import { getClient, gortexToolNames } from "./state.ts";
11
10
  import { toolNameMap } from "./hook.ts";
12
11
 
@@ -46,7 +45,8 @@ interface BridgeToolDefinition {
46
45
  description: string;
47
46
  parameters: unknown;
48
47
  execute(toolCallId: string, params: Record<string, unknown>): Promise<unknown>;
49
- renderResult?: (result: unknown, options: { expanded?: boolean }) => unknown;
48
+ renderCall: RenderCall;
49
+ renderResult: RenderResult;
50
50
  }
51
51
 
52
52
  /**
@@ -67,33 +67,11 @@ export function safeRegister(pi: ExtensionAPI, def: BridgeToolDefinition): strin
67
67
  return def.name;
68
68
  }
69
69
 
70
- // Lazily resolved TUI Text component, used only to collapse a gortex tool's
71
- // result to nothing until the user expands it (ctrl+o). Resolved through
72
- // createRequire rather than a static import so a Pi packaging that can't
73
- // provide @earendil-works/pi-tui degrades to Pi's default renderer instead of
74
- // throwing at load and taking tool registration down with it.
75
- type TextComponent = new (text: string, x: number, y: number) => unknown;
76
- let TuiText: TextComponent | undefined;
77
- try {
78
- const require = createRequire(import.meta.url);
79
- TuiText = (require("@earendil-works/pi-tui") as { Text: TextComponent }).Text;
80
- } catch {
81
- TuiText = undefined;
82
- }
83
-
84
- function resultText(result: unknown): string {
85
- const structured = (result as { structuredContent?: unknown })?.structuredContent;
86
- return textFromResult(result) || JSON.stringify(structured ?? result ?? {});
87
- }
88
-
89
70
  /**
90
71
  * Registers one Gortex tool under its bare daemon name (or its alias).
91
72
  * Idempotent: a name already registered is skipped. Adds the name to
92
73
  * gortexToolNames so the read-discipline postures treat a call to it as a graph
93
74
  * query.
94
- *
95
- * renderResult collapses the result to nothing until the user expands it with
96
- * ctrl+o; Pi's fallback renderCall already shows the tool name.
97
75
  */
98
76
  export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
99
77
  const name = desc.name;
@@ -127,14 +105,9 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
127
105
  if ((result as { isError?: boolean })?.isError) throw new Error(text || `gortex ${name} failed`);
128
106
  return { content: [{ type: "text", text }], details: {} };
129
107
  },
108
+ renderCall: (name === "read" ? readCallRenderer : callRenderer)(piAliasName(name)),
109
+ renderResult,
130
110
  };
131
- if (TuiText) {
132
- const Text = TuiText;
133
- def.renderResult = (result: unknown, options: { expanded?: boolean }) => {
134
- if (!options?.expanded) return new Text("", 0, 0);
135
- return new Text(resultText(result), 0, 0);
136
- };
137
- }
138
111
  gortexToolNames.add(safeRegister(pi, def));
139
112
  }
140
113