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 +2 -1
- package/package.json +4 -3
- package/src/render.ts +149 -0
- package/src/tools.ts +6 -33
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
|
|
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.
|
|
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.
|
|
39
|
-
"@
|
|
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
|
-
|
|
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
|
|