@selesai/code 0.13.22 → 0.13.23
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/CHANGELOG.md +9 -0
- package/dist/extensions/capability-gateway/index.ts +15 -4
- package/dist/extensions/capability-gateway/integration.test.ts +15 -0
- package/dist/extensions/package.json +2 -0
- package/dist/extensions/pi-graft/cli.test.ts +408 -0
- package/dist/extensions/pi-graft/cli.ts +547 -0
- package/dist/extensions/pi-graft/commands.test.ts +478 -0
- package/dist/extensions/pi-graft/commands.ts +392 -0
- package/dist/extensions/pi-graft/graft.integration.test.ts +153 -0
- package/dist/extensions/pi-graft/index.test.ts +167 -0
- package/dist/extensions/pi-graft/index.ts +521 -0
- package/dist/extensions/pi-graft/lifecycle.test.ts +609 -0
- package/dist/extensions/pi-graft/prompt.test.ts +213 -0
- package/dist/extensions/pi-graft/prompt.ts +216 -0
- package/dist/extensions/pi-graft/state.test.ts +258 -0
- package/dist/extensions/pi-graft/state.ts +335 -0
- package/dist/extensions/pi-graft/test-support.ts +199 -0
- package/dist/extensions/pi-graft/tools.test.ts +330 -0
- package/dist/extensions/pi-graft/tools.ts +333 -0
- package/dist/extensions/pi-graft/tsconfig.json +16 -0
- package/dist/extensions/pi-subagents/src/agents/builtin-agent-augmentations.ts +71 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +9 -3
- package/dist/extensions/pi-subagents/test/unit/builtin-agent-augmentations.test.ts +44 -0
- package/package.json +2 -2
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { GraftOp, GraftRun } from "./cli.ts";
|
|
3
|
+
import type { GraftSettings, GraftState } from "./state.ts";
|
|
4
|
+
import { makeCtx, makePi } from "./test-support.ts";
|
|
5
|
+
import { registerGraftTools, renderAskResult, type GraftToolDetails, type GraftToolRuntime } from "./tools.ts";
|
|
6
|
+
|
|
7
|
+
const TOOL_NAMES = [
|
|
8
|
+
"graft_find_code",
|
|
9
|
+
"graft_file_api",
|
|
10
|
+
"graft_trace_calls",
|
|
11
|
+
"graft_find_all",
|
|
12
|
+
"graft_repo_map",
|
|
13
|
+
"graft_check_freshness",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const ASK_JSON = JSON.stringify({
|
|
17
|
+
query: "where is auth verified",
|
|
18
|
+
mode: "lexical",
|
|
19
|
+
coverage: 0.7,
|
|
20
|
+
hits: [
|
|
21
|
+
{
|
|
22
|
+
kind: "symbol",
|
|
23
|
+
title: "verify · function",
|
|
24
|
+
pointer: "src/auth.ts:L10-L20",
|
|
25
|
+
snippet: "checks the token",
|
|
26
|
+
code: "export function verify(token: string) {\n return true;\n}",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
saved: { files: 1, baselineChars: 800 },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
interface Harness {
|
|
33
|
+
/** The runtime the tools were registered against. */
|
|
34
|
+
runtime: GraftToolRuntime;
|
|
35
|
+
/** The operation each tool call asked Graft to run. */
|
|
36
|
+
run: ReturnType<typeof vi.fn>;
|
|
37
|
+
/** Runs a registered tool by name against this harness's runtime. */
|
|
38
|
+
exec: (
|
|
39
|
+
name: string,
|
|
40
|
+
params: Record<string, unknown>,
|
|
41
|
+
signal?: AbortSignal,
|
|
42
|
+
) => Promise<{ text: string; details: GraftToolDetails }>;
|
|
43
|
+
/** Results the runtime was told to treat as answered queries. */
|
|
44
|
+
noted: GraftRun[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeHarness(
|
|
48
|
+
options: { result?: Partial<GraftRun>; settings?: GraftSettings; state?: GraftState } = {},
|
|
49
|
+
): Harness {
|
|
50
|
+
const base: GraftRun = {
|
|
51
|
+
op: { kind: "repo-map" },
|
|
52
|
+
argv: ["graft", "map"],
|
|
53
|
+
cwd: "/repo",
|
|
54
|
+
code: 0,
|
|
55
|
+
stdout: "",
|
|
56
|
+
stderr: "",
|
|
57
|
+
killed: false,
|
|
58
|
+
cancelled: false,
|
|
59
|
+
timedOut: false,
|
|
60
|
+
...options.result,
|
|
61
|
+
};
|
|
62
|
+
const run = vi.fn(async (_ctx: unknown, op: GraftOp) => ({ ...base, op }));
|
|
63
|
+
const noted: GraftRun[] = [];
|
|
64
|
+
const runtime: GraftToolRuntime = {
|
|
65
|
+
run: run as never,
|
|
66
|
+
settings: () => options.settings ?? {},
|
|
67
|
+
state: () => options.state ?? { name: "fresh-structural", version: "0.18.0" },
|
|
68
|
+
noteQuery: ((_ctx: unknown, result: GraftRun) => {
|
|
69
|
+
noted.push(result);
|
|
70
|
+
}) as never,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const exec = async (
|
|
74
|
+
name: string,
|
|
75
|
+
params: Record<string, unknown>,
|
|
76
|
+
signal?: AbortSignal,
|
|
77
|
+
): Promise<{ text: string; details: GraftToolDetails }> => {
|
|
78
|
+
const { pi, tools } = makePi();
|
|
79
|
+
registerGraftTools(pi as never, runtime);
|
|
80
|
+
const definition = tools.get(name);
|
|
81
|
+
if (!definition) throw new Error(`${name} was not registered`);
|
|
82
|
+
const result = (await (definition.execute as (...args: unknown[]) => Promise<unknown>)(
|
|
83
|
+
"call-1",
|
|
84
|
+
params,
|
|
85
|
+
signal,
|
|
86
|
+
undefined,
|
|
87
|
+
makeCtx(),
|
|
88
|
+
)) as { content: { text: string }[]; details: GraftToolDetails };
|
|
89
|
+
return { text: result.content[0]!.text, details: result.details };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return { runtime, run, exec, noted };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("graft tool registration", () => {
|
|
96
|
+
it("registers exactly the six read-only semantic tools", () => {
|
|
97
|
+
const { pi, tools } = makePi();
|
|
98
|
+
registerGraftTools(pi as never, makeHarness().runtime);
|
|
99
|
+
expect([...tools.keys()].sort()).toEqual([...TOOL_NAMES].sort());
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("gives every tool a label, description and parameter schema", () => {
|
|
103
|
+
const { pi, tools } = makePi();
|
|
104
|
+
registerGraftTools(pi as never, makeHarness().runtime);
|
|
105
|
+
for (const name of TOOL_NAMES) {
|
|
106
|
+
const tool = tools.get(name)!;
|
|
107
|
+
expect(tool.description).toBeTruthy();
|
|
108
|
+
expect(tool.parameters).toBeTruthy();
|
|
109
|
+
expect(tool.label).toContain("Graft");
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("tells the model that Graft output is derived and must be verified against source", () => {
|
|
114
|
+
const { pi, tools } = makePi();
|
|
115
|
+
registerGraftTools(pi as never, makeHarness().runtime);
|
|
116
|
+
const guidelines = tools.get("graft_find_code")!.promptGuidelines as string[];
|
|
117
|
+
expect(guidelines.join("\n")).toContain("verify");
|
|
118
|
+
expect(tools.get("graft_find_code")!.description).toContain("derived");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("graft_find_code", () => {
|
|
123
|
+
it("asks Graft the model's question with source inlining and structured output", async () => {
|
|
124
|
+
const harness = makeHarness({ result: { stdout: ASK_JSON } });
|
|
125
|
+
const { text, details } = await harness.exec("graft_find_code", { question: "where is auth verified" });
|
|
126
|
+
|
|
127
|
+
expect(harness.run).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(harness.run.mock.calls[0]![1]).toEqual({
|
|
129
|
+
kind: "find-code",
|
|
130
|
+
question: "where is auth verified",
|
|
131
|
+
limit: undefined,
|
|
132
|
+
in: undefined,
|
|
133
|
+
});
|
|
134
|
+
expect(text).toContain("src/auth.ts:L10-L20");
|
|
135
|
+
expect(text).toContain("export function verify");
|
|
136
|
+
expect(details.references).toEqual([{ path: "src/auth.ts", span: "L10-L20" }]);
|
|
137
|
+
expect(details.coverage).toBe(0.7);
|
|
138
|
+
expect(details.saved).toEqual({ files: 1, baselineChars: 800 });
|
|
139
|
+
expect(details.operation).toBe("find-code");
|
|
140
|
+
expect(details.graphState).toBe("fresh-structural");
|
|
141
|
+
expect(harness.noted).toHaveLength(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("applies the project's scope setting when the model does not name one", async () => {
|
|
145
|
+
const harness = makeHarness({ result: { stdout: ASK_JSON }, settings: { in: "packages/api" } });
|
|
146
|
+
await harness.exec("graft_find_code", { question: "auth" });
|
|
147
|
+
expect(harness.run.mock.calls[0]![1]).toMatchObject({ in: "packages/api" });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("falls back to the CLI's own text when its JSON is not the shape we know", async () => {
|
|
151
|
+
const harness = makeHarness({
|
|
152
|
+
result: { stdout: "verify · function — src/auth.ts:L10-L20\n checks the token" },
|
|
153
|
+
});
|
|
154
|
+
const { text, details } = await harness.exec("graft_find_code", { question: "auth" });
|
|
155
|
+
expect(text).toContain("src/auth.ts:L10-L20");
|
|
156
|
+
expect(details.references).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("keeps command diagnostics out of the model-facing text", async () => {
|
|
160
|
+
const harness = makeHarness({
|
|
161
|
+
result: { stdout: ASK_JSON, stderr: "[graft] rebuilt from src/ (2 files changed)" },
|
|
162
|
+
});
|
|
163
|
+
const { text, details } = await harness.exec("graft_find_code", { question: "auth" });
|
|
164
|
+
expect(text).not.toContain("rebuilt from");
|
|
165
|
+
expect(details.stderr).toContain("rebuilt from");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("bounds the result and reports the truncation and the recovery path", async () => {
|
|
169
|
+
const harness = makeHarness({
|
|
170
|
+
result: {
|
|
171
|
+
stdout: JSON.stringify({ hits: [{ pointer: "src/a.ts", title: "big", code: "x".repeat(200_000) }] }),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
const { text, details } = await harness.exec("graft_find_code", { question: "auth" });
|
|
175
|
+
expect(details.truncated).toBe(true);
|
|
176
|
+
expect(text).toContain("[graft] output truncated");
|
|
177
|
+
expect(Buffer.byteLength(text, "utf-8")).toBeLessThan(48 * 1024);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("fails the tool call with the command's own diagnostic when Graft errors", async () => {
|
|
181
|
+
const harness = makeHarness({ result: { code: 1, stderr: "✗ no graft/ index here" } });
|
|
182
|
+
await expect(harness.exec("graft_find_code", { question: "auth" })).rejects.toThrow(/no graft\/ index here/);
|
|
183
|
+
expect(harness.noted).toHaveLength(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("fails the tool call when the model cancels the turn", async () => {
|
|
187
|
+
const harness = makeHarness({ result: { cancelled: true } });
|
|
188
|
+
await expect(harness.exec("graft_find_code", { question: "auth" })).rejects.toThrow(/cancelled/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("reports a timeout as a timeout rather than as a generic failure", async () => {
|
|
192
|
+
const harness = makeHarness({ result: { code: 1, killed: true, timedOut: true } });
|
|
193
|
+
await expect(harness.exec("graft_find_code", { question: "auth" })).rejects.toThrow(/timed out/);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("the remaining read-only tools", () => {
|
|
198
|
+
it("maps graft_file_api to a signatures-only view of one file", async () => {
|
|
199
|
+
const harness = makeHarness({ result: { stdout: "src/app.ts\n fn run(): void" } });
|
|
200
|
+
const { text, details } = await harness.exec("graft_file_api", { path: "src/app.ts" });
|
|
201
|
+
expect(harness.run.mock.calls[0]![1]).toEqual({ kind: "file-api", path: "src/app.ts" });
|
|
202
|
+
expect(text).toContain("fn run(): void");
|
|
203
|
+
expect(details.references).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("says so plainly when a file has no indexed signatures", async () => {
|
|
207
|
+
const harness = makeHarness({ result: { stdout: " " } });
|
|
208
|
+
const { text } = await harness.exec("graft_file_api", { path: "src/missing.ts" });
|
|
209
|
+
expect(text).toContain("No indexed signatures for src/missing.ts");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("defaults graft_trace_calls to the caller direction and forwards direction and depth", async () => {
|
|
213
|
+
const harness = makeHarness({ result: { stdout: "login · function · src/login.ts:L1-L4 · 2 in-edges" } });
|
|
214
|
+
await harness.exec("graft_trace_calls", { symbol: "verify" });
|
|
215
|
+
expect(harness.run.mock.calls[0]![1]).toEqual({
|
|
216
|
+
kind: "trace-calls",
|
|
217
|
+
symbol: "verify",
|
|
218
|
+
direction: "in",
|
|
219
|
+
depth: undefined,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await harness.exec("graft_trace_calls", { symbol: "verify", direction: "out", depth: 4 });
|
|
223
|
+
expect(harness.run.mock.calls[1]![1]).toEqual({
|
|
224
|
+
kind: "trace-calls",
|
|
225
|
+
symbol: "verify",
|
|
226
|
+
direction: "out",
|
|
227
|
+
depth: 4,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("names the direction it searched when a trace found nothing", async () => {
|
|
232
|
+
const outgoing = makeHarness({ result: { stdout: " " } });
|
|
233
|
+
const { text } = await outgoing.exec("graft_trace_calls", { symbol: "orphan", direction: "out" });
|
|
234
|
+
expect(text).toContain("No indexed callees for orphan");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("passes graft_find_all flags through and reports an empty search as an answer", async () => {
|
|
238
|
+
const harness = makeHarness({ result: { stdout: "" } });
|
|
239
|
+
const { text } = await harness.exec("graft_find_all", {
|
|
240
|
+
pattern: "NEEDLE",
|
|
241
|
+
ignore_case: true,
|
|
242
|
+
fixed: true,
|
|
243
|
+
in: "src",
|
|
244
|
+
});
|
|
245
|
+
expect(harness.run.mock.calls[0]![1]).toEqual({
|
|
246
|
+
kind: "find-all",
|
|
247
|
+
pattern: "NEEDLE",
|
|
248
|
+
ignoreCase: true,
|
|
249
|
+
fixed: true,
|
|
250
|
+
in: "src",
|
|
251
|
+
});
|
|
252
|
+
expect(text).toContain("No matches for NEEDLE");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("maps graft_repo_map to the token-budgeted orientation view", async () => {
|
|
256
|
+
const harness = makeHarness({ result: { stdout: "repo map — 113 files · 687 symbols" } });
|
|
257
|
+
const { text } = await harness.exec("graft_repo_map", { max_dirs: 8 });
|
|
258
|
+
expect(harness.run.mock.calls[0]![1]).toEqual({ kind: "repo-map", maxDirs: 8 });
|
|
259
|
+
expect(text).toContain("113 files");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("says so when the repository map came back empty", async () => {
|
|
263
|
+
const harness = makeHarness({ result: { stdout: "" } });
|
|
264
|
+
const { text } = await harness.exec("graft_repo_map", {});
|
|
265
|
+
expect(text).toContain("empty repository map");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("graft_check_freshness", () => {
|
|
270
|
+
it("treats a non-zero exit as the drift answer rather than a tool failure", async () => {
|
|
271
|
+
const harness = makeHarness({ result: { code: 1, stdout: '{"stale":["src/a.ts"]}' } });
|
|
272
|
+
const { text, details } = await harness.exec("graft_check_freshness", {});
|
|
273
|
+
expect(text).toContain("has drifted");
|
|
274
|
+
expect(text).toContain("/graft build");
|
|
275
|
+
expect(details.exitCode).toBe(1);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("reports an in-sync graph on exit zero", async () => {
|
|
279
|
+
const harness = makeHarness({ result: { code: 0, stdout: '{"stale":[]}' } });
|
|
280
|
+
const { text } = await harness.exec("graft_check_freshness", {});
|
|
281
|
+
expect(text).toContain("in sync with the working tree");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("does not mark the graph fresh, because check never refreshes", async () => {
|
|
285
|
+
const harness = makeHarness({ result: { code: 0 } });
|
|
286
|
+
await harness.exec("graft_check_freshness", {});
|
|
287
|
+
expect(harness.noted).toHaveLength(0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("still fails on cancellation", async () => {
|
|
291
|
+
const harness = makeHarness({ result: { cancelled: true } });
|
|
292
|
+
await expect(harness.exec("graft_check_freshness", {})).rejects.toThrow(/cancelled/);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("renderAskResult", () => {
|
|
297
|
+
it("returns an explicit empty answer rather than blank text", () => {
|
|
298
|
+
const run: GraftRun = {
|
|
299
|
+
op: { kind: "find-code", question: "x" },
|
|
300
|
+
argv: ["graft", "ask", "x"],
|
|
301
|
+
cwd: "/repo",
|
|
302
|
+
code: 0,
|
|
303
|
+
stdout: "",
|
|
304
|
+
stderr: "",
|
|
305
|
+
killed: false,
|
|
306
|
+
cancelled: false,
|
|
307
|
+
timedOut: false,
|
|
308
|
+
};
|
|
309
|
+
expect(renderAskResult(run, { maxBytes: 4096, maxLines: 50 }).text).toBe("Graft returned no matching context.");
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("read-only guarantee", () => {
|
|
314
|
+
it("never asks Graft to build or configure anything", async () => {
|
|
315
|
+
const harness = makeHarness();
|
|
316
|
+
const params: Record<string, Record<string, unknown>> = {
|
|
317
|
+
graft_find_code: { question: "anything" },
|
|
318
|
+
graft_file_api: { path: "src/a.ts" },
|
|
319
|
+
graft_trace_calls: { symbol: "x" },
|
|
320
|
+
graft_find_all: { pattern: "x" },
|
|
321
|
+
graft_repo_map: {},
|
|
322
|
+
graft_check_freshness: {},
|
|
323
|
+
};
|
|
324
|
+
for (const name of TOOL_NAMES) await harness.exec(name, params[name]!);
|
|
325
|
+
|
|
326
|
+
const kinds = harness.run.mock.calls.map((call) => (call[1] as GraftOp).kind);
|
|
327
|
+
expect(kinds).toEqual(["find-code", "file-api", "trace-calls", "find-all", "repo-map", "check-freshness"]);
|
|
328
|
+
expect(kinds).not.toContain("build");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The six read-only Graft tools.
|
|
3
|
+
*
|
|
4
|
+
* Names and jobs deliberately mirror Graft's own MCP tool surface, so a model
|
|
5
|
+
* that already knows Graft needs no new vocabulary:
|
|
6
|
+
*
|
|
7
|
+
* graft_find_code → graft ask --source --json
|
|
8
|
+
* graft_file_api → graft skeleton <file>
|
|
9
|
+
* graft_trace_calls → graft callers <symbol> [--direction out] [-d N]
|
|
10
|
+
* graft_find_all → graft grep <regex> [--in P] [-i] [--fixed]
|
|
11
|
+
* graft_repo_map → graft map [--max-dirs N]
|
|
12
|
+
* graft_check_freshness → graft check --json
|
|
13
|
+
*
|
|
14
|
+
* Every one of them is read-only under Selesai's capability model. Nothing here
|
|
15
|
+
* can build, mutate, install, or configure anything — the build operations live
|
|
16
|
+
* behind the consent-gated commands in `commands.ts`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
20
|
+
import type { ExtensionAPI, ExtensionContext } from "@selesai/code";
|
|
21
|
+
import { Type } from "typebox";
|
|
22
|
+
import {
|
|
23
|
+
askReferences,
|
|
24
|
+
boundText,
|
|
25
|
+
graftFailureMessage,
|
|
26
|
+
MAX_FIND_LIMIT,
|
|
27
|
+
MAX_TRACE_DEPTH,
|
|
28
|
+
parseAskJson,
|
|
29
|
+
renderAsk,
|
|
30
|
+
RESULT_MAX_BYTES,
|
|
31
|
+
RESULT_MAX_LINES,
|
|
32
|
+
type GraftOp,
|
|
33
|
+
type GraftReference,
|
|
34
|
+
type GraftRun,
|
|
35
|
+
} from "./cli.ts";
|
|
36
|
+
import type { GraftSettings, GraftState, GraftStateName } from "./state.ts";
|
|
37
|
+
|
|
38
|
+
export const GRAFT_TOOL_NAMES = [
|
|
39
|
+
"graft_check_freshness",
|
|
40
|
+
"graft_file_api",
|
|
41
|
+
"graft_find_all",
|
|
42
|
+
"graft_find_code",
|
|
43
|
+
"graft_repo_map",
|
|
44
|
+
"graft_trace_calls",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export interface GraftToolRuntime {
|
|
48
|
+
/** Run a read-only Graft operation against the resolved repository root. */
|
|
49
|
+
run(ctx: ExtensionContext, op: GraftOp, signal?: AbortSignal): Promise<GraftRun>;
|
|
50
|
+
settings(): GraftSettings;
|
|
51
|
+
state(): GraftState;
|
|
52
|
+
/** A query answered; the graph is fresh as of now. */
|
|
53
|
+
noteQuery(ctx: ExtensionContext, run: GraftRun): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Structured details attached to every Graft tool result. */
|
|
57
|
+
export interface GraftToolDetails {
|
|
58
|
+
operation: string;
|
|
59
|
+
/** Full argv, for support reports and TUI expansion. */
|
|
60
|
+
argv: string[];
|
|
61
|
+
cwd: string;
|
|
62
|
+
exitCode: number;
|
|
63
|
+
graphState: GraftStateName;
|
|
64
|
+
bytes: number;
|
|
65
|
+
truncated: boolean;
|
|
66
|
+
truncatedBy: "lines" | "bytes" | null;
|
|
67
|
+
/** `file` or `file:Lx-Ly` pointers the result references. */
|
|
68
|
+
references: GraftReference[];
|
|
69
|
+
/** Command diagnostics. Kept out of model-facing text on purpose. */
|
|
70
|
+
stderr?: string;
|
|
71
|
+
mode?: string;
|
|
72
|
+
/** Share of the query terms the top hit matched (`ask` only). */
|
|
73
|
+
coverage?: number;
|
|
74
|
+
saved?: { files: number; baselineChars: number };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const GUIDELINES = [
|
|
78
|
+
"Use graft_find_code to locate the systems, files, and symbols relevant to a repository question before reading widely.",
|
|
79
|
+
"Use graft_trace_calls before changing a shared symbol, to see its callers (or, with direction out, what it depends on) and the blast radius.",
|
|
80
|
+
"Use graft_file_api to see a file's signatures without reading its implementation bodies.",
|
|
81
|
+
"Use graft_find_all for exhaustive regex discovery across indexed files, grouped by enclosing symbol.",
|
|
82
|
+
"Use graft_repo_map to orient in an unfamiliar repository, and graft_check_freshness when you need to know whether Graft context describes the code as it is now.",
|
|
83
|
+
"Graft results are derived and may be incomplete: verify anything you are about to change with read or grep, and treat Graft pointers as leads rather than proof.",
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
function throwIfFailed(run: GraftRun, allowNonZero = false): void {
|
|
87
|
+
if (run.cancelled) throw new Error("Graft command cancelled.");
|
|
88
|
+
if (run.code === 0 || allowNonZero) return;
|
|
89
|
+
throw new Error(graftFailureMessage(run));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function textResult(text: string, details: GraftToolDetails) {
|
|
93
|
+
return { content: [{ type: "text" as const, text }], details };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function baseDetails(run: GraftRun, state: GraftStateName): Omit<GraftToolDetails, "bytes" | "truncated" | "truncatedBy" | "references"> {
|
|
97
|
+
return {
|
|
98
|
+
operation: run.op.kind,
|
|
99
|
+
argv: run.argv,
|
|
100
|
+
cwd: run.cwd,
|
|
101
|
+
exitCode: run.code,
|
|
102
|
+
graphState: state,
|
|
103
|
+
...(run.stderr.trim() ? { stderr: run.stderr.trim().slice(0, 4000) } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `graft ask` renders from its JSON when it parses, and from stdout otherwise. */
|
|
108
|
+
export function renderAskResult(
|
|
109
|
+
run: GraftRun,
|
|
110
|
+
limits: { maxBytes: number; maxLines: number },
|
|
111
|
+
): { text: string; references: GraftReference[]; bounded: ReturnType<typeof boundText>; payload: ReturnType<typeof parseAskJson> } {
|
|
112
|
+
const payload = parseAskJson(run.stdout);
|
|
113
|
+
if (payload) {
|
|
114
|
+
const bounded = boundText(renderAsk(payload), limits);
|
|
115
|
+
return { text: bounded.text, references: askReferences(payload), bounded, payload };
|
|
116
|
+
}
|
|
117
|
+
const bounded = boundText(run.stdout, limits);
|
|
118
|
+
return { text: bounded.text || "Graft returned no matching context.", references: [], bounded, payload: undefined };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function registerGraftTools(pi: ExtensionAPI, runtime: GraftToolRuntime): void {
|
|
122
|
+
const limits = () => {
|
|
123
|
+
const maxBytes = runtime.settings().maxResultBytes ?? RESULT_MAX_BYTES;
|
|
124
|
+
return { maxBytes, maxLines: RESULT_MAX_LINES };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const scoped = (explicit: string | undefined): string | undefined => explicit ?? runtime.settings().in;
|
|
128
|
+
|
|
129
|
+
pi.registerTool({
|
|
130
|
+
name: "graft_find_code",
|
|
131
|
+
label: "Graft: find code",
|
|
132
|
+
description:
|
|
133
|
+
"Find the code relevant to a natural-language question about this repository. Returns ranked graph nodes with exact file:line pointers and the source inlined at each hit, so it is usually the whole answer and no follow-up read is needed. Use it to orient before editing or to locate the systems a change touches. The result is a derived summary — verify with read before changing anything.",
|
|
134
|
+
promptSnippet: "Find repository code relevant to a question (ranked nodes, file:line, source inlined)",
|
|
135
|
+
promptGuidelines: GUIDELINES,
|
|
136
|
+
parameters: Type.Object({
|
|
137
|
+
question: Type.String({ description: "What you want to understand, in plain words." }),
|
|
138
|
+
limit: Type.Optional(
|
|
139
|
+
Type.Integer({ minimum: 1, maximum: MAX_FIND_LIMIT, description: "Max hits (default 8)." }),
|
|
140
|
+
),
|
|
141
|
+
in: Type.Optional(
|
|
142
|
+
Type.String({ description: "Narrow to one sub-project: a repository-relative path prefix." }),
|
|
143
|
+
),
|
|
144
|
+
}),
|
|
145
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
146
|
+
const run = await runtime.run(
|
|
147
|
+
ctx,
|
|
148
|
+
{
|
|
149
|
+
kind: "find-code",
|
|
150
|
+
question: params.question,
|
|
151
|
+
limit: params.limit,
|
|
152
|
+
in: scoped(params.in),
|
|
153
|
+
},
|
|
154
|
+
signal,
|
|
155
|
+
);
|
|
156
|
+
throwIfFailed(run);
|
|
157
|
+
runtime.noteQuery(ctx, run);
|
|
158
|
+
|
|
159
|
+
const { text, references, bounded, payload } = renderAskResult(run, limits());
|
|
160
|
+
return textResult(text, {
|
|
161
|
+
...baseDetails(run, runtime.state().name),
|
|
162
|
+
bytes: Buffer.byteLength(text, "utf-8"),
|
|
163
|
+
truncated: bounded.truncated,
|
|
164
|
+
truncatedBy: bounded.truncatedBy,
|
|
165
|
+
references,
|
|
166
|
+
...(payload?.mode ? { mode: payload.mode } : {}),
|
|
167
|
+
...(payload?.coverage !== undefined ? { coverage: payload.coverage } : {}),
|
|
168
|
+
...(payload?.saved ? { saved: payload.saved } : {}),
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
pi.registerTool({
|
|
174
|
+
name: "graft_file_api",
|
|
175
|
+
label: "Graft: file API",
|
|
176
|
+
description:
|
|
177
|
+
"Show every signature in one file without the implementation bodies — the file's API surface for roughly a tenth of the tokens of reading it. Use it to learn what a file offers before deciding which bodies to read. Accepts a repository-relative path or a unique basename.",
|
|
178
|
+
promptSnippet: "Show one file's signatures without implementation bodies",
|
|
179
|
+
parameters: Type.Object({
|
|
180
|
+
path: Type.String({ description: "Repository-relative path, or a unique file basename." }),
|
|
181
|
+
}),
|
|
182
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
183
|
+
const run = await runtime.run(ctx, { kind: "file-api", path: params.path }, signal);
|
|
184
|
+
throwIfFailed(run);
|
|
185
|
+
runtime.noteQuery(ctx, run);
|
|
186
|
+
const bounded = boundText(run.stdout, limits());
|
|
187
|
+
const text = bounded.text || `No indexed signatures for ${params.path}.`;
|
|
188
|
+
return textResult(text, {
|
|
189
|
+
...baseDetails(run, runtime.state().name),
|
|
190
|
+
bytes: Buffer.byteLength(text, "utf-8"),
|
|
191
|
+
truncated: bounded.truncated,
|
|
192
|
+
truncatedBy: bounded.truncatedBy,
|
|
193
|
+
references: [],
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
pi.registerTool({
|
|
199
|
+
name: "graft_trace_calls",
|
|
200
|
+
label: "Graft: trace calls",
|
|
201
|
+
description:
|
|
202
|
+
"Trace a symbol's call graph. Default direction is 'in': who calls, references, implements, or extends it — the blast radius of changing it. Direction 'out' answers the reverse: what the symbol itself calls, references, or imports. Raise depth to walk transitively. Use it before modifying shared behavior.",
|
|
203
|
+
promptSnippet: "Trace who calls a symbol, or what it calls (blast radius)",
|
|
204
|
+
parameters: Type.Object({
|
|
205
|
+
symbol: Type.String({ description: "Symbol name; a qualified name like Cache.get is supported." }),
|
|
206
|
+
direction: Type.Optional(
|
|
207
|
+
StringEnum(["in", "out"] as const, {
|
|
208
|
+
description: "in = callers/dependents (default); out = what this symbol depends on.",
|
|
209
|
+
}),
|
|
210
|
+
),
|
|
211
|
+
depth: Type.Optional(
|
|
212
|
+
Type.Integer({ minimum: 1, maximum: MAX_TRACE_DEPTH, description: "Transitive depth (default 1)." }),
|
|
213
|
+
),
|
|
214
|
+
}),
|
|
215
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
216
|
+
const run = await runtime.run(
|
|
217
|
+
ctx,
|
|
218
|
+
{
|
|
219
|
+
kind: "trace-calls",
|
|
220
|
+
symbol: params.symbol,
|
|
221
|
+
direction: params.direction ?? "in",
|
|
222
|
+
depth: params.depth,
|
|
223
|
+
},
|
|
224
|
+
signal,
|
|
225
|
+
);
|
|
226
|
+
throwIfFailed(run);
|
|
227
|
+
runtime.noteQuery(ctx, run);
|
|
228
|
+
const bounded = boundText(run.stdout, limits());
|
|
229
|
+
const direction = params.direction ?? "in";
|
|
230
|
+
const text = bounded.text || `No indexed ${direction === "in" ? "callers" : "callees"} for ${params.symbol}.`;
|
|
231
|
+
return textResult(text, {
|
|
232
|
+
...baseDetails(run, runtime.state().name),
|
|
233
|
+
bytes: Buffer.byteLength(text, "utf-8"),
|
|
234
|
+
truncated: bounded.truncated,
|
|
235
|
+
truncatedBy: bounded.truncatedBy,
|
|
236
|
+
references: [],
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
pi.registerTool({
|
|
242
|
+
name: "graft_find_all",
|
|
243
|
+
label: "Graft: find all",
|
|
244
|
+
description:
|
|
245
|
+
"Exhaustive regex search over every indexed file, with hits grouped by the symbol that encloses them and ranked by how coupled that symbol is. Prefer it over a plain text search when a broad pattern needs semantic context, and over graft_find_code when you need every occurrence rather than a ranked few.",
|
|
246
|
+
promptSnippet: "Exhaustive regex search over indexed files, grouped by enclosing symbol",
|
|
247
|
+
parameters: Type.Object({
|
|
248
|
+
pattern: Type.String({ description: "Regular expression to search for." }),
|
|
249
|
+
ignore_case: Type.Optional(Type.Boolean({ description: "Case-insensitive matching." })),
|
|
250
|
+
fixed: Type.Optional(Type.Boolean({ description: "Treat the pattern as a literal string, not a regex." })),
|
|
251
|
+
in: Type.Optional(Type.String({ description: "Narrow to files at or under this path prefix." })),
|
|
252
|
+
}),
|
|
253
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
254
|
+
const run = await runtime.run(
|
|
255
|
+
ctx,
|
|
256
|
+
{
|
|
257
|
+
kind: "find-all",
|
|
258
|
+
pattern: params.pattern,
|
|
259
|
+
ignoreCase: params.ignore_case,
|
|
260
|
+
fixed: params.fixed,
|
|
261
|
+
in: scoped(params.in),
|
|
262
|
+
},
|
|
263
|
+
signal,
|
|
264
|
+
);
|
|
265
|
+
throwIfFailed(run);
|
|
266
|
+
runtime.noteQuery(ctx, run);
|
|
267
|
+
const bounded = boundText(run.stdout, limits());
|
|
268
|
+
const text = bounded.text || `No matches for ${params.pattern} in the indexed files.`;
|
|
269
|
+
return textResult(text, {
|
|
270
|
+
...baseDetails(run, runtime.state().name),
|
|
271
|
+
bytes: Buffer.byteLength(text, "utf-8"),
|
|
272
|
+
truncated: bounded.truncated,
|
|
273
|
+
truncatedBy: bounded.truncatedBy,
|
|
274
|
+
references: [],
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
pi.registerTool({
|
|
280
|
+
name: "graft_repo_map",
|
|
281
|
+
label: "Graft: repo map",
|
|
282
|
+
description:
|
|
283
|
+
"Token-budgeted orientation for an unfamiliar repository: directory clusters with file and symbol counts, each directory's local hubs, and the repository's global hotspots, ranked by in-degree. Use it as a first look before narrower queries.",
|
|
284
|
+
promptSnippet: "Directory clusters, hubs, and hotspots for an unfamiliar repository",
|
|
285
|
+
parameters: Type.Object({
|
|
286
|
+
max_dirs: Type.Optional(Type.Integer({ minimum: 1, maximum: 60, description: "Directories to show." })),
|
|
287
|
+
}),
|
|
288
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
289
|
+
const run = await runtime.run(ctx, { kind: "repo-map", maxDirs: params.max_dirs }, signal);
|
|
290
|
+
throwIfFailed(run);
|
|
291
|
+
runtime.noteQuery(ctx, run);
|
|
292
|
+
const bounded = boundText(run.stdout, limits());
|
|
293
|
+
const text = bounded.text || "Graft returned an empty repository map.";
|
|
294
|
+
return textResult(text, {
|
|
295
|
+
...baseDetails(run, runtime.state().name),
|
|
296
|
+
bytes: Buffer.byteLength(text, "utf-8"),
|
|
297
|
+
truncated: bounded.truncated,
|
|
298
|
+
truncatedBy: bounded.truncatedBy,
|
|
299
|
+
references: [],
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
pi.registerTool({
|
|
305
|
+
name: "graft_check_freshness",
|
|
306
|
+
label: "Graft: check freshness",
|
|
307
|
+
description:
|
|
308
|
+
"Report whether the local Graft graph has drifted from the code. Use it when an earlier Graft answer might predate an edit, or when you need to tell the user that repository context is stale. It never rebuilds anything — it is the drift report.",
|
|
309
|
+
promptSnippet: "Report whether the local Graft graph has drifted from the code",
|
|
310
|
+
parameters: Type.Object({}),
|
|
311
|
+
async execute(_id, _params, signal, _onUpdate, ctx) {
|
|
312
|
+
// `graft check` exits 1 when the graph has drifted. That is the answer,
|
|
313
|
+
// not a failure, so a non-zero exit is allowed here and interpreted below.
|
|
314
|
+
const run = await runtime.run(ctx, { kind: "check-freshness" }, signal);
|
|
315
|
+
throwIfFailed(run, true);
|
|
316
|
+
|
|
317
|
+
const inSync = run.code === 0;
|
|
318
|
+
const bounded = boundText(run.stdout, limits());
|
|
319
|
+
const headline = inSync
|
|
320
|
+
? "Graft graph is in sync with the working tree."
|
|
321
|
+
: "Graft graph has drifted from the working tree (or has not been built yet). Run /graft build to rebuild it; Graft queries still refresh the graph they read.";
|
|
322
|
+
const text = `${headline}\n\n${bounded.text}`.trim();
|
|
323
|
+
|
|
324
|
+
return textResult(text, {
|
|
325
|
+
...baseDetails(run, runtime.state().name),
|
|
326
|
+
bytes: Buffer.byteLength(text, "utf-8"),
|
|
327
|
+
truncated: bounded.truncated,
|
|
328
|
+
truncatedBy: bounded.truncatedBy,
|
|
329
|
+
references: [],
|
|
330
|
+
});
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": true,
|
|
5
|
+
"declaration": false,
|
|
6
|
+
"declarationMap": false,
|
|
7
|
+
"sourceMap": false,
|
|
8
|
+
"inlineSources": false,
|
|
9
|
+
"baseUrl": "../../..",
|
|
10
|
+
"paths": {
|
|
11
|
+
"@selesai/code": ["./dist/index.d.ts"],
|
|
12
|
+
"@earendil-works/pi-coding-agent": ["./dist/index.d.ts"]
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"include": ["./**/*.ts"]
|
|
16
|
+
}
|