@selesai/code 0.13.21 → 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 +18 -0
- package/dist/extensions/capability-gateway/index.ts +28 -29
- package/dist/extensions/capability-gateway/integration.test.ts +43 -6
- package/dist/extensions/capability-gateway/package.json +1 -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/dist/extensions/tps.test.ts +9 -1
- package/dist/extensions/tps.ts +9 -3
- package/package.json +2 -2
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graft CLI adapter.
|
|
3
|
+
*
|
|
4
|
+
* Graft is an external executable (npm: `@nanonets/graft`). This module is the
|
|
5
|
+
* only place that knows how to find it, install it when missing, check
|
|
6
|
+
* its version, build argument vectors, and turn its output into bounded
|
|
7
|
+
* model-facing text.
|
|
8
|
+
*
|
|
9
|
+
* Safety constraints:
|
|
10
|
+
* - `pi.exec(command, args, { cwd })` only. Never a shell string, so repository
|
|
11
|
+
* paths, regexes, symbols, and question text cannot become shell injection.
|
|
12
|
+
* - Graft's upstream agent-wiring command (`init`) is never invoked. A missing
|
|
13
|
+
* compatible CLI is installed once through npm; incompatible CLIs are left alone.
|
|
14
|
+
* - Semantic tools are read-only. Graph builds are command operations gated
|
|
15
|
+
* behind explicit consent; CLI provisioning is automatic.
|
|
16
|
+
* - Extension-spawned commands default to telemetry opt-out (`DO_NOT_TRACK=1`).
|
|
17
|
+
*
|
|
18
|
+
* Verified against @nanonets/graft 0.18.0 (see {@link MIN_GRAFT_VERSION}).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { truncateHead, type ExecOptions, type ExecResult } from "@selesai/code";
|
|
22
|
+
|
|
23
|
+
export const GRAFT_BIN = "graft";
|
|
24
|
+
export const GRAFT_PACKAGE = "@nanonets/graft";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Lowest Graft CLI whose documented surface this adapter was verified against:
|
|
28
|
+
* `ask --source/--json`, `skeleton`, `callers --direction/-d`, `grep --in/-i/--fixed`,
|
|
29
|
+
* `map --max-dirs`, `check --json`, `build [--deep]`, and `--version`.
|
|
30
|
+
*/
|
|
31
|
+
export const MIN_GRAFT_VERSION = "0.18.0";
|
|
32
|
+
/** Upper bound is exclusive: a 1.x CLI is allowed to change its contract. */
|
|
33
|
+
export const MAX_GRAFT_VERSION_EXCLUSIVE = "1.0.0";
|
|
34
|
+
|
|
35
|
+
/** Local, regenerable graph cache directory Graft creates at the repo root. */
|
|
36
|
+
export const GRAPH_DIR_NAME = "graft";
|
|
37
|
+
export const GRAFT_CONFIG_PATH = ".graft/config.json";
|
|
38
|
+
|
|
39
|
+
export const PROBE_TIMEOUT_MS = 5_000;
|
|
40
|
+
export const QUERY_TIMEOUT_MS = 60_000;
|
|
41
|
+
/** Structural build: tree-sitter only, no model, no network. */
|
|
42
|
+
export const BUILD_TIMEOUT_MS = 600_000;
|
|
43
|
+
/** Deep build: provider-backed, one call per changed file. */
|
|
44
|
+
export const DEEP_BUILD_TIMEOUT_MS = 1_800_000;
|
|
45
|
+
/** Global npm installs can be slow on a cold cache. */
|
|
46
|
+
export const INSTALL_TIMEOUT_MS = 180_000;
|
|
47
|
+
|
|
48
|
+
/** Kept on the verified 0.18 minor line; never install a floating latest CLI. */
|
|
49
|
+
export const GRAFT_INSTALL_SPEC = `${GRAFT_PACKAGE}@^0.18`;
|
|
50
|
+
export const INSTALL_COMMAND = `npm install -g ${GRAFT_INSTALL_SPEC}`;
|
|
51
|
+
export const TELEMETRY_ENV = "DO_NOT_TRACK";
|
|
52
|
+
|
|
53
|
+
/** `pi.exec` shape, narrowed so tests can inject a stub. */
|
|
54
|
+
export type ExecLike = (command: string, args: string[], options?: ExecOptions) => Promise<ExecResult>;
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Executable resolution
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the executable/argument pair for a Graft invocation.
|
|
62
|
+
*
|
|
63
|
+
* `pi.exec` spawns without a shell, so Windows needs the `.cmd` shim launched
|
|
64
|
+
* through `cmd.exe` (same rule as the agent-browser and rtk extensions).
|
|
65
|
+
*/
|
|
66
|
+
export function graftCommand(args: string[]): { command: string; args: string[] } {
|
|
67
|
+
if (process.platform === "win32") {
|
|
68
|
+
return { command: "cmd", args: ["/c", GRAFT_BIN, ...args] };
|
|
69
|
+
}
|
|
70
|
+
return { command: GRAFT_BIN, args };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** npm is a .cmd shim on Windows, so it follows the same spawn rule as Graft. */
|
|
74
|
+
export function npmCommand(args: string[]): { command: string; args: string[] } {
|
|
75
|
+
if (process.platform === "win32") {
|
|
76
|
+
return { command: "cmd", args: ["/c", "npm", ...args] };
|
|
77
|
+
}
|
|
78
|
+
return { command: "npm", args };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** First semantic version in arbitrary CLI output, `v` prefix and suffixes included. */
|
|
82
|
+
export function parseVersion(text: string): string | undefined {
|
|
83
|
+
const match = /(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?/.exec(text);
|
|
84
|
+
return match ? match[0] : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Numeric-segment comparison. Pre-release/build suffixes are ignored. */
|
|
88
|
+
export function compareVersions(a: string, b: string): number {
|
|
89
|
+
const nums = (v: string) => (/(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1) ?? ["0", "0", "0"]).map(Number);
|
|
90
|
+
const [a1, a2, a3] = nums(a);
|
|
91
|
+
const [b1, b2, b3] = nums(b);
|
|
92
|
+
return a1 - b1 || a2 - b2 || a3 - b3;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isCompatibleVersion(version: string): boolean {
|
|
96
|
+
return (
|
|
97
|
+
compareVersions(version, MIN_GRAFT_VERSION) >= 0 &&
|
|
98
|
+
compareVersions(version, MAX_GRAFT_VERSION_EXCLUSIVE) < 0
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type GraftProbe =
|
|
103
|
+
| { kind: "ok"; version: string; argv: string[] }
|
|
104
|
+
| { kind: "missing"; detail: string }
|
|
105
|
+
| { kind: "incompatible"; version?: string; detail: string };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Locate a usable Graft executable.
|
|
109
|
+
*
|
|
110
|
+
* A binary that answers but reports no parseable version is treated as
|
|
111
|
+
* incompatible rather than assumed-good: we only claim compatibility for a
|
|
112
|
+
* version we actually read.
|
|
113
|
+
*/
|
|
114
|
+
export async function probeGraft(exec: ExecLike, cwd: string, signal?: AbortSignal): Promise<GraftProbe> {
|
|
115
|
+
const args = ["--version"];
|
|
116
|
+
const { command, args: argv } = graftCommand(args);
|
|
117
|
+
let result: ExecResult;
|
|
118
|
+
try {
|
|
119
|
+
result = await exec(command, argv, { cwd, timeout: PROBE_TIMEOUT_MS, signal });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return { kind: "missing", detail: error instanceof Error ? error.message : String(error) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
125
|
+
const version = parseVersion(output);
|
|
126
|
+
if (version === undefined) {
|
|
127
|
+
if (result.killed || result.code !== 0) {
|
|
128
|
+
return { kind: "missing", detail: humanizeFailure(result, output) };
|
|
129
|
+
}
|
|
130
|
+
return { kind: "incompatible", detail: `could not read a version from \`${GRAFT_BIN} --version\`` };
|
|
131
|
+
}
|
|
132
|
+
if (!isCompatibleVersion(version)) {
|
|
133
|
+
return {
|
|
134
|
+
kind: "incompatible",
|
|
135
|
+
version,
|
|
136
|
+
detail: `graft ${version} is outside the supported range >=${MIN_GRAFT_VERSION} <${MAX_GRAFT_VERSION_EXCLUSIVE}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return { kind: "ok", version, argv };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Operations
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The read-only operations this adapter exposes, plus the two consent-gated
|
|
148
|
+
* build operations. Every other Graft subcommand (including `init`, `viz`, and
|
|
149
|
+
* `uninstall`) is deliberately unreachable from tool calls.
|
|
150
|
+
*/
|
|
151
|
+
export type GraftOp =
|
|
152
|
+
| { kind: "find-code"; question: string; limit?: number; in?: string }
|
|
153
|
+
| { kind: "file-api"; path: string }
|
|
154
|
+
| { kind: "trace-calls"; symbol: string; direction: "in" | "out"; depth?: number }
|
|
155
|
+
| { kind: "find-all"; pattern: string; ignoreCase?: boolean; fixed?: boolean; in?: string }
|
|
156
|
+
| { kind: "repo-map"; maxDirs?: number }
|
|
157
|
+
| { kind: "check-freshness" }
|
|
158
|
+
| { kind: "build"; deep: boolean };
|
|
159
|
+
|
|
160
|
+
/** Default hit count for `graft ask`, matching the CLI's own default. */
|
|
161
|
+
export const DEFAULT_FIND_LIMIT = 8;
|
|
162
|
+
export const MAX_FIND_LIMIT = 25;
|
|
163
|
+
export const MAX_TRACE_DEPTH = 6;
|
|
164
|
+
|
|
165
|
+
export function graftArgs(op: GraftOp): string[] {
|
|
166
|
+
switch (op.kind) {
|
|
167
|
+
case "find-code": {
|
|
168
|
+
// `--source` inlines the source at each file:line hit, so the pack is the
|
|
169
|
+
// answer and the model does not pay a second read for the same file.
|
|
170
|
+
// `--json` keeps `coverage`, `saved`, and the per-hit pointers available
|
|
171
|
+
// to the extension for rendering and for the relevance gate.
|
|
172
|
+
const args = ["ask", op.question, "--source", "--json"];
|
|
173
|
+
if (op.limit !== undefined) args.push("-n", String(op.limit));
|
|
174
|
+
if (op.in) args.push("--in", op.in);
|
|
175
|
+
return args;
|
|
176
|
+
}
|
|
177
|
+
case "file-api":
|
|
178
|
+
return ["skeleton", op.path];
|
|
179
|
+
case "trace-calls": {
|
|
180
|
+
const args = ["callers", op.symbol];
|
|
181
|
+
if (op.direction === "out") args.push("--direction", "out");
|
|
182
|
+
if (op.depth !== undefined && op.depth !== 1) args.push("-d", String(op.depth));
|
|
183
|
+
return args;
|
|
184
|
+
}
|
|
185
|
+
case "find-all": {
|
|
186
|
+
const args = ["grep", op.pattern];
|
|
187
|
+
if (op.ignoreCase) args.push("-i");
|
|
188
|
+
if (op.fixed) args.push("--fixed");
|
|
189
|
+
if (op.in) args.push("--in", op.in);
|
|
190
|
+
return args;
|
|
191
|
+
}
|
|
192
|
+
case "repo-map": {
|
|
193
|
+
const args = ["map"];
|
|
194
|
+
if (op.maxDirs !== undefined) args.push("--max-dirs", String(op.maxDirs));
|
|
195
|
+
return args;
|
|
196
|
+
}
|
|
197
|
+
case "check-freshness":
|
|
198
|
+
return ["check", "--json"];
|
|
199
|
+
case "build":
|
|
200
|
+
return op.deep ? ["build", "--deep"] : ["build"];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface GraftInstallRun {
|
|
205
|
+
argv: string[];
|
|
206
|
+
cwd: string;
|
|
207
|
+
code: number;
|
|
208
|
+
stdout: string;
|
|
209
|
+
stderr: string;
|
|
210
|
+
killed: boolean;
|
|
211
|
+
cancelled: boolean;
|
|
212
|
+
timedOut: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Install the compatible CLI when probing found none. */
|
|
216
|
+
export async function installGraft(
|
|
217
|
+
exec: ExecLike,
|
|
218
|
+
cwd: string,
|
|
219
|
+
options: { signal?: AbortSignal } = {},
|
|
220
|
+
): Promise<GraftInstallRun> {
|
|
221
|
+
const args = ["install", "-g", GRAFT_INSTALL_SPEC];
|
|
222
|
+
const { command, args: argv } = npmCommand(args);
|
|
223
|
+
try {
|
|
224
|
+
const result = await exec(command, argv, {
|
|
225
|
+
cwd,
|
|
226
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
227
|
+
signal: options.signal,
|
|
228
|
+
});
|
|
229
|
+
const cancelled = options.signal?.aborted === true;
|
|
230
|
+
return {
|
|
231
|
+
argv: [command, ...argv],
|
|
232
|
+
cwd,
|
|
233
|
+
code: result.code,
|
|
234
|
+
stdout: result.stdout,
|
|
235
|
+
stderr: result.stderr,
|
|
236
|
+
killed: result.killed,
|
|
237
|
+
cancelled,
|
|
238
|
+
timedOut: result.killed && !cancelled,
|
|
239
|
+
};
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return {
|
|
242
|
+
argv: [command, ...argv],
|
|
243
|
+
cwd,
|
|
244
|
+
code: 1,
|
|
245
|
+
stdout: "",
|
|
246
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
247
|
+
killed: false,
|
|
248
|
+
cancelled: options.signal?.aborted === true,
|
|
249
|
+
timedOut: false,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface GraftRun {
|
|
255
|
+
op: GraftOp;
|
|
256
|
+
/** Full argv including the executable, for diagnostics. */
|
|
257
|
+
argv: string[];
|
|
258
|
+
cwd: string;
|
|
259
|
+
code: number;
|
|
260
|
+
stdout: string;
|
|
261
|
+
stderr: string;
|
|
262
|
+
killed: boolean;
|
|
263
|
+
/** The caller's abort signal fired. */
|
|
264
|
+
cancelled: boolean;
|
|
265
|
+
/** The timeout fired rather than the caller aborting. */
|
|
266
|
+
timedOut: boolean;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function timeoutFor(op: GraftOp): number {
|
|
270
|
+
switch (op.kind) {
|
|
271
|
+
case "build":
|
|
272
|
+
return op.deep ? DEEP_BUILD_TIMEOUT_MS : BUILD_TIMEOUT_MS;
|
|
273
|
+
default:
|
|
274
|
+
return QUERY_TIMEOUT_MS;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Run one Graft operation against `repoRoot`.
|
|
280
|
+
*
|
|
281
|
+
* The repository root is always passed as the process working directory and
|
|
282
|
+
* never as a positional argument, so a monorepo's scoping stays Graft's own
|
|
283
|
+
* `nearestGraftRoot` decision.
|
|
284
|
+
*/
|
|
285
|
+
export async function runGraft(
|
|
286
|
+
exec: ExecLike,
|
|
287
|
+
repoRoot: string,
|
|
288
|
+
op: GraftOp,
|
|
289
|
+
options: { signal?: AbortSignal; timeout?: number } = {},
|
|
290
|
+
): Promise<GraftRun> {
|
|
291
|
+
const args = graftArgs(op);
|
|
292
|
+
const { command, args: argv } = graftCommand(args);
|
|
293
|
+
const displayArgv = [command, ...argv];
|
|
294
|
+
let result: ExecResult;
|
|
295
|
+
try {
|
|
296
|
+
result = await exec(command, argv, {
|
|
297
|
+
cwd: repoRoot,
|
|
298
|
+
timeout: options.timeout ?? timeoutFor(op),
|
|
299
|
+
signal: options.signal,
|
|
300
|
+
});
|
|
301
|
+
} catch (error) {
|
|
302
|
+
return {
|
|
303
|
+
op,
|
|
304
|
+
argv: displayArgv,
|
|
305
|
+
cwd: repoRoot,
|
|
306
|
+
code: 1,
|
|
307
|
+
stdout: "",
|
|
308
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
309
|
+
killed: false,
|
|
310
|
+
cancelled: options.signal?.aborted === true,
|
|
311
|
+
timedOut: false,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const cancelled = options.signal?.aborted === true;
|
|
316
|
+
return {
|
|
317
|
+
op,
|
|
318
|
+
argv: displayArgv,
|
|
319
|
+
cwd: repoRoot,
|
|
320
|
+
code: result.code,
|
|
321
|
+
stdout: result.stdout,
|
|
322
|
+
stderr: result.stderr,
|
|
323
|
+
killed: result.killed,
|
|
324
|
+
cancelled,
|
|
325
|
+
timedOut: result.killed && !cancelled,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function humanizeFailure(result: ExecResult, output: string): string {
|
|
330
|
+
if (result.killed) return `\`${GRAFT_BIN}\` did not answer within ${PROBE_TIMEOUT_MS}ms`;
|
|
331
|
+
if (output.trim()) return output.trim().split("\n")[0] ?? "command failed";
|
|
332
|
+
return "command failed";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** One readable line for a failed command; the full output stays in tool details. */
|
|
336
|
+
export function graftFailureMessage(run: GraftRun): string {
|
|
337
|
+
if (run.cancelled) return "Graft command cancelled.";
|
|
338
|
+
if (run.timedOut) return `Graft command timed out after ${timeoutFor(run.op)}ms.`;
|
|
339
|
+
const detail = (run.stderr.trim() || run.stdout.trim()).split("\n")[0]?.trim();
|
|
340
|
+
return `graft ${run.argv.slice(1).join(" ")} failed (exit ${run.code})${detail ? `: ${detail}` : ""}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Output normalization
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
export interface BoundedText {
|
|
348
|
+
text: string;
|
|
349
|
+
truncated: boolean;
|
|
350
|
+
truncatedBy: "lines" | "bytes" | null;
|
|
351
|
+
totalBytes: number;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Default model-facing ceiling per Graft result. */
|
|
355
|
+
export const RESULT_MAX_LINES = 400;
|
|
356
|
+
export const RESULT_MAX_BYTES = 32 * 1024;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Bound text with Selesai's own truncation utility, and say so when it cut.
|
|
360
|
+
* Truncation must never be silent: the model needs to know it is seeing a
|
|
361
|
+
* partial result and how to see more.
|
|
362
|
+
*/
|
|
363
|
+
export function boundText(
|
|
364
|
+
raw: string,
|
|
365
|
+
limits: { maxLines?: number; maxBytes?: number } = {},
|
|
366
|
+
): BoundedText {
|
|
367
|
+
const result = truncateHead(raw.trimEnd(), {
|
|
368
|
+
maxLines: limits.maxLines ?? RESULT_MAX_LINES,
|
|
369
|
+
maxBytes: limits.maxBytes ?? RESULT_MAX_BYTES,
|
|
370
|
+
});
|
|
371
|
+
if (!result.truncated) {
|
|
372
|
+
return { text: result.content, truncated: false, truncatedBy: null, totalBytes: result.totalBytes };
|
|
373
|
+
}
|
|
374
|
+
const kept = result.truncatedBy === "lines" ? `${result.outputLines} of ${result.totalLines} lines` : `${result.outputBytes} of ${result.totalBytes} bytes`;
|
|
375
|
+
const note = `[graft] output truncated (${kept}). Narrow the request — a subdirectory, a tighter regex, or a lower limit — to see the rest.`;
|
|
376
|
+
return {
|
|
377
|
+
text: `${result.content}\n\n${note}`,
|
|
378
|
+
truncated: true,
|
|
379
|
+
truncatedBy: result.truncatedBy,
|
|
380
|
+
totalBytes: result.totalBytes,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** A `file` or `file:Lx-Ly` pointer the model can open directly. */
|
|
385
|
+
export interface GraftReference {
|
|
386
|
+
path: string;
|
|
387
|
+
span?: string;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface AskHit {
|
|
391
|
+
kind: string;
|
|
392
|
+
title: string;
|
|
393
|
+
pointer: string;
|
|
394
|
+
snippet: string;
|
|
395
|
+
relation?: string;
|
|
396
|
+
code?: string;
|
|
397
|
+
scope?: string;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* `graft ask --json` result, as emitted by `AskResult` upstream (0.18.0).
|
|
402
|
+
*
|
|
403
|
+
* Every field is optional on read: a CLI that changes shape degrades to raw
|
|
404
|
+
* text rather than to a wrong summary, and callers must never treat the parse
|
|
405
|
+
* as authoritative for anything but display.
|
|
406
|
+
*/
|
|
407
|
+
export interface AskPayload {
|
|
408
|
+
query?: string;
|
|
409
|
+
mode?: string;
|
|
410
|
+
subject?: string;
|
|
411
|
+
note?: string;
|
|
412
|
+
hits: AskHit[];
|
|
413
|
+
/** Share (0..1) of the query's terms the top hit matched. Lexical mode only. */
|
|
414
|
+
coverage?: number;
|
|
415
|
+
coverageStrong?: number;
|
|
416
|
+
saved?: { files: number; baselineChars: number };
|
|
417
|
+
rules?: Array<{ id?: string; text?: string }>;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
421
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function asString(value: unknown): string | undefined {
|
|
425
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Parse `graft ask --json`. Returns undefined for anything unrecognized. */
|
|
429
|
+
export function parseAskJson(stdout: string): AskPayload | undefined {
|
|
430
|
+
let parsed: unknown;
|
|
431
|
+
try {
|
|
432
|
+
parsed = JSON.parse(stdout);
|
|
433
|
+
} catch {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.hits)) return undefined;
|
|
437
|
+
|
|
438
|
+
const hits: AskHit[] = [];
|
|
439
|
+
for (const raw of parsed.hits) {
|
|
440
|
+
if (!isRecord(raw)) continue;
|
|
441
|
+
const pointer = asString(raw.pointer);
|
|
442
|
+
if (!pointer) continue;
|
|
443
|
+
hits.push({
|
|
444
|
+
kind: asString(raw.kind) ?? "symbol",
|
|
445
|
+
title: asString(raw.title) ?? pointer,
|
|
446
|
+
pointer,
|
|
447
|
+
snippet: asString(raw.snippet) ?? "",
|
|
448
|
+
relation: asString(raw.relation),
|
|
449
|
+
code: asString(raw.code),
|
|
450
|
+
scope: asString(raw.scope),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const saved = isRecord(parsed.saved) ? parsed.saved : undefined;
|
|
455
|
+
const rules = Array.isArray(parsed.rules) ? parsed.rules.filter(isRecord).map((rule) => ({
|
|
456
|
+
id: asString(rule.id),
|
|
457
|
+
text: asString(rule.text),
|
|
458
|
+
})) : undefined;
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
query: asString(parsed.query),
|
|
462
|
+
mode: asString(parsed.mode),
|
|
463
|
+
subject: asString(parsed.subject),
|
|
464
|
+
note: asString(parsed.note),
|
|
465
|
+
hits,
|
|
466
|
+
coverage: typeof parsed.coverage === "number" ? parsed.coverage : undefined,
|
|
467
|
+
coverageStrong: typeof parsed.coverageStrong === "number" ? parsed.coverageStrong : undefined,
|
|
468
|
+
saved: saved && typeof saved.files === "number" && typeof saved.baselineChars === "number"
|
|
469
|
+
? { files: saved.files, baselineChars: saved.baselineChars }
|
|
470
|
+
: undefined,
|
|
471
|
+
rules,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Split a `file` or `file:Lx-Ly` pointer into its parts. */
|
|
476
|
+
export function parsePointer(pointer: string): GraftReference {
|
|
477
|
+
const match = /^(.*?):(L\d+(?:-L\d+)?)$/.exec(pointer);
|
|
478
|
+
if (!match) return { path: pointer };
|
|
479
|
+
return { path: match[1] ?? pointer, span: match[2] };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Compact markdown rendering of an ask payload: pointers first, source last. */
|
|
483
|
+
export function renderAsk(payload: AskPayload): string {
|
|
484
|
+
const lines: string[] = [];
|
|
485
|
+
const header = payload.mode ? `graft ask (${payload.mode}${payload.subject ? `: ${payload.subject}` : ""})` : "graft ask";
|
|
486
|
+
lines.push(`${header} — ${payload.hits.length} hit(s)`);
|
|
487
|
+
if (payload.note) lines.push(payload.note);
|
|
488
|
+
|
|
489
|
+
payload.hits.forEach((hit, index) => {
|
|
490
|
+
const label = hit.scope ? `${hit.scope}/${hit.pointer}` : hit.pointer;
|
|
491
|
+
const relation = hit.relation ? ` (${hit.relation})` : "";
|
|
492
|
+
lines.push("", `${index + 1}. ${hit.title}${relation} — ${label}`);
|
|
493
|
+
if (hit.snippet) lines.push(` ${hit.snippet}`);
|
|
494
|
+
if (hit.code) {
|
|
495
|
+
lines.push("", ...hit.code.split("\n").map((line) => ` ${line}`), "");
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
if (payload.rules && payload.rules.length > 0) {
|
|
500
|
+
lines.push("", "governing rules:");
|
|
501
|
+
for (const rule of payload.rules) if (rule.text) lines.push(`- ${rule.text}`);
|
|
502
|
+
}
|
|
503
|
+
if (payload.saved) {
|
|
504
|
+
lines.push(
|
|
505
|
+
"",
|
|
506
|
+
`(pack inlines ${payload.saved.files} file(s); reading them instead would cost ~${Math.round(payload.saved.baselineChars / 4)} tokens)`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
return lines.join("\n").trim();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Pointers from an ask payload, used for session metadata and tool details. */
|
|
513
|
+
export function askReferences(payload: AskPayload): GraftReference[] {
|
|
514
|
+
return payload.hits.map((hit) => parsePointer(hit.pointer));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// Telemetry
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
export interface TelemetryDefaultHandle {
|
|
522
|
+
/** Undo the default, restoring the process environment to its prior state. */
|
|
523
|
+
restore(): void;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Default Graft to telemetry opt-out without touching the user's Graft
|
|
528
|
+
* configuration.
|
|
529
|
+
*
|
|
530
|
+
* `pi.exec` inherits the pi process environment and offers no per-spawn env
|
|
531
|
+
* override, so the only place to set this is the process itself. We therefore
|
|
532
|
+
* set it once per session and only when the user has not already expressed a
|
|
533
|
+
* preference, record that we were the one to set it, and remove it again on
|
|
534
|
+
* session shutdown. `DO_NOT_TRACK` is a cross-tool convention that Graft
|
|
535
|
+
* honors; the user's own `graft telemetry disable` setting is never touched.
|
|
536
|
+
*/
|
|
537
|
+
export function applyTelemetryDefault(env: NodeJS.ProcessEnv = process.env): TelemetryDefaultHandle {
|
|
538
|
+
if (env[TELEMETRY_ENV] !== undefined) {
|
|
539
|
+
return { restore: () => {} };
|
|
540
|
+
}
|
|
541
|
+
env[TELEMETRY_ENV] = "1";
|
|
542
|
+
return {
|
|
543
|
+
restore: () => {
|
|
544
|
+
delete env[TELEMETRY_ENV];
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|