pi-ketch 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/LICENSE +21 -0
- package/README.md +149 -0
- package/package.json +74 -0
- package/skills/ketch/SKILL.md +59 -0
- package/skills/ketch/references/research.md +30 -0
- package/skills/ketch/references/setup.md +37 -0
- package/skills/ketch/references/surfaces.md +27 -0
- package/src/diagnostics.ts +34 -0
- package/src/format.ts +123 -0
- package/src/index.ts +16 -0
- package/src/ketch.ts +291 -0
- package/src/output.ts +97 -0
- package/src/render.ts +87 -0
- package/src/schemas.ts +64 -0
- package/src/tools/code.ts +46 -0
- package/src/tools/common.ts +26 -0
- package/src/tools/crawl.ts +65 -0
- package/src/tools/docs.ts +52 -0
- package/src/tools/scrape.ts +60 -0
- package/src/tools/search.ts +49 -0
- package/tsconfig.json +14 -0
package/src/ketch.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type KetchStatus =
|
|
4
|
+
| "ok"
|
|
5
|
+
| "not_found"
|
|
6
|
+
| "validation"
|
|
7
|
+
| "upstream"
|
|
8
|
+
| "precondition"
|
|
9
|
+
| "cancelled"
|
|
10
|
+
| "partial"
|
|
11
|
+
| "error";
|
|
12
|
+
|
|
13
|
+
export interface RunKetchOptions {
|
|
14
|
+
cwd: string;
|
|
15
|
+
args: string[];
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface KetchProcessResult {
|
|
22
|
+
command: string;
|
|
23
|
+
bin: string;
|
|
24
|
+
args: string[];
|
|
25
|
+
cwd: string;
|
|
26
|
+
stdout: string;
|
|
27
|
+
stderr: string;
|
|
28
|
+
code: number;
|
|
29
|
+
status: KetchStatus;
|
|
30
|
+
durationMs: number;
|
|
31
|
+
termination?: "timeout" | "abort";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CrawlRunOptions extends RunKetchOptions {
|
|
35
|
+
maxPages: number;
|
|
36
|
+
maxChars: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CrawlPage {
|
|
40
|
+
url: string;
|
|
41
|
+
title?: string;
|
|
42
|
+
words?: number;
|
|
43
|
+
status?: string;
|
|
44
|
+
source?: string;
|
|
45
|
+
body: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CrawlRunResult extends KetchProcessResult {
|
|
49
|
+
pages: CrawlPage[];
|
|
50
|
+
parseErrors: string[];
|
|
51
|
+
stopped?: "max_pages" | "timeout";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class KetchError extends Error {
|
|
55
|
+
constructor(
|
|
56
|
+
message: string,
|
|
57
|
+
readonly result: KetchProcessResult,
|
|
58
|
+
readonly cause?: unknown,
|
|
59
|
+
) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = "KetchError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveKetchBinary(env: NodeJS.ProcessEnv = process.env): string {
|
|
66
|
+
return env.KETCH_BIN?.trim() || "ketch";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function buildKetchEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
70
|
+
return { ...base, NO_COLOR: "1", TERM: "dumb" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function shellQuote(value: string): string {
|
|
74
|
+
return /^[A-Za-z0-9_./:@=,+-]+$/.test(value) ? value : JSON.stringify(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatCommand(bin: string, args: string[]): string {
|
|
78
|
+
return [bin, ...args].map(shellQuote).join(" ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function statusForExitCode(code: number): KetchStatus {
|
|
82
|
+
switch (code) {
|
|
83
|
+
case 0:
|
|
84
|
+
return "ok";
|
|
85
|
+
case 2:
|
|
86
|
+
return "validation";
|
|
87
|
+
case 3:
|
|
88
|
+
return "not_found";
|
|
89
|
+
case 4:
|
|
90
|
+
return "upstream";
|
|
91
|
+
case 5:
|
|
92
|
+
return "precondition";
|
|
93
|
+
case 6:
|
|
94
|
+
return "cancelled";
|
|
95
|
+
default:
|
|
96
|
+
return "error";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function missingBinaryMessage(): string {
|
|
101
|
+
return [
|
|
102
|
+
"Ketch is unavailable because the `ketch` command was not found.",
|
|
103
|
+
"Install it with `brew install 1broseidon/tap/ketch`, put it on PATH, or set KETCH_BIN.",
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function failureMessage(result: KetchProcessResult): string {
|
|
108
|
+
const hint = result.stderr.trim() || result.stdout.trim();
|
|
109
|
+
const lead: Record<KetchStatus, string> = {
|
|
110
|
+
ok: "Ketch completed",
|
|
111
|
+
not_found: "Ketch found no matching resource",
|
|
112
|
+
validation: "Ketch rejected the request",
|
|
113
|
+
upstream: "Ketch's upstream backend failed",
|
|
114
|
+
precondition: "Ketch requires operator setup",
|
|
115
|
+
cancelled: "Ketch was cancelled",
|
|
116
|
+
partial: "Ketch stopped with partial results",
|
|
117
|
+
error: "Ketch failed",
|
|
118
|
+
};
|
|
119
|
+
return `${lead[result.status]} (exit ${result.code}).${hint ? `\n${hint}` : ""}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function runKetch(options: RunKetchOptions): Promise<KetchProcessResult> {
|
|
123
|
+
const bin = resolveKetchBinary(options.env);
|
|
124
|
+
const result = await runChild({ ...options, bin });
|
|
125
|
+
if (result.code === 0 || result.code === 3) return result;
|
|
126
|
+
throw new KetchError(failureMessage(result), result);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface ChildOptions extends RunKetchOptions {
|
|
130
|
+
bin: string;
|
|
131
|
+
onStdoutChunk?: (chunk: string, child: ReturnType<typeof spawn>) => void;
|
|
132
|
+
captureStdout?: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function runChild(options: ChildOptions): Promise<KetchProcessResult> {
|
|
136
|
+
const command = formatCommand(options.bin, options.args);
|
|
137
|
+
const started = Date.now();
|
|
138
|
+
if (options.signal?.aborted) {
|
|
139
|
+
const result: KetchProcessResult = {
|
|
140
|
+
command,
|
|
141
|
+
bin: options.bin,
|
|
142
|
+
args: [...options.args],
|
|
143
|
+
cwd: options.cwd,
|
|
144
|
+
stdout: "",
|
|
145
|
+
stderr: "",
|
|
146
|
+
code: 6,
|
|
147
|
+
status: "cancelled",
|
|
148
|
+
durationMs: 0,
|
|
149
|
+
termination: "abort",
|
|
150
|
+
};
|
|
151
|
+
throw new KetchError("Ketch was cancelled before it started.", result);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return await new Promise<KetchProcessResult>((resolve, reject) => {
|
|
155
|
+
const child = spawn(options.bin, options.args, {
|
|
156
|
+
cwd: options.cwd,
|
|
157
|
+
env: buildKetchEnv(options.env),
|
|
158
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
159
|
+
});
|
|
160
|
+
let stdout = "";
|
|
161
|
+
let stderr = "";
|
|
162
|
+
let settled = false;
|
|
163
|
+
let timedOut = false;
|
|
164
|
+
|
|
165
|
+
const snapshot = (code: number): KetchProcessResult => ({
|
|
166
|
+
command,
|
|
167
|
+
bin: options.bin,
|
|
168
|
+
args: [...options.args],
|
|
169
|
+
cwd: options.cwd,
|
|
170
|
+
stdout,
|
|
171
|
+
stderr,
|
|
172
|
+
code,
|
|
173
|
+
status: statusForExitCode(code),
|
|
174
|
+
durationMs: Date.now() - started,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const cleanup = () => {
|
|
178
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
179
|
+
if (timer) clearTimeout(timer);
|
|
180
|
+
};
|
|
181
|
+
const finishReject = (error: unknown, code: number) => {
|
|
182
|
+
if (settled) return;
|
|
183
|
+
settled = true;
|
|
184
|
+
cleanup();
|
|
185
|
+
const cause = error as NodeJS.ErrnoException;
|
|
186
|
+
const result = snapshot(code);
|
|
187
|
+
const message = cause.code === "ENOENT" ? missingBinaryMessage() : cause.message || failureMessage(result);
|
|
188
|
+
reject(new KetchError(message, result, error));
|
|
189
|
+
};
|
|
190
|
+
const terminate = () => {
|
|
191
|
+
child.kill("SIGTERM");
|
|
192
|
+
const killTimer = setTimeout(() => child.kill("SIGKILL"), 500);
|
|
193
|
+
killTimer.unref();
|
|
194
|
+
};
|
|
195
|
+
const onAbort = () => {
|
|
196
|
+
if (settled) return;
|
|
197
|
+
terminate();
|
|
198
|
+
};
|
|
199
|
+
const timer = options.timeoutMs
|
|
200
|
+
? setTimeout(() => {
|
|
201
|
+
if (settled) return;
|
|
202
|
+
timedOut = true;
|
|
203
|
+
terminate();
|
|
204
|
+
}, options.timeoutMs)
|
|
205
|
+
: undefined;
|
|
206
|
+
|
|
207
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
208
|
+
child.stdout.setEncoding("utf8");
|
|
209
|
+
child.stderr.setEncoding("utf8");
|
|
210
|
+
child.stdout.on("data", (chunk: string) => {
|
|
211
|
+
if (options.captureStdout !== false) stdout += chunk;
|
|
212
|
+
options.onStdoutChunk?.(chunk, child);
|
|
213
|
+
});
|
|
214
|
+
child.stderr.on("data", (chunk: string) => {
|
|
215
|
+
stderr += chunk;
|
|
216
|
+
});
|
|
217
|
+
child.on("error", (error: NodeJS.ErrnoException) => finishReject(error, error.code === "ENOENT" ? 127 : 1));
|
|
218
|
+
child.on("close", (code) => {
|
|
219
|
+
if (settled) return;
|
|
220
|
+
settled = true;
|
|
221
|
+
cleanup();
|
|
222
|
+
const aborted = options.signal?.aborted;
|
|
223
|
+
const exitCode = aborted ? 6 : timedOut ? 6 : (code ?? 1);
|
|
224
|
+
const result = snapshot(exitCode);
|
|
225
|
+
if (aborted) result.termination = "abort";
|
|
226
|
+
else if (timedOut) result.termination = "timeout";
|
|
227
|
+
if (aborted) {
|
|
228
|
+
reject(new KetchError("Ketch was cancelled.", result));
|
|
229
|
+
} else {
|
|
230
|
+
resolve(result);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function truncateCharacters(value: string, maxChars: number): string {
|
|
237
|
+
if (maxChars <= 0 || value.length <= maxChars) return value;
|
|
238
|
+
return `${value.slice(0, maxChars)}\n\n[truncated]`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function runKetchCrawl(options: CrawlRunOptions): Promise<CrawlRunResult> {
|
|
242
|
+
const bin = resolveKetchBinary(options.env);
|
|
243
|
+
const pages: CrawlPage[] = [];
|
|
244
|
+
const parseErrors: string[] = [];
|
|
245
|
+
let buffered = "";
|
|
246
|
+
let stopped: CrawlRunResult["stopped"];
|
|
247
|
+
|
|
248
|
+
const parseLine = (line: string, child?: ReturnType<typeof spawn>) => {
|
|
249
|
+
const trimmed = line.trim();
|
|
250
|
+
if (!trimmed || pages.length >= options.maxPages) return;
|
|
251
|
+
try {
|
|
252
|
+
const parsed = JSON.parse(trimmed) as Partial<CrawlPage> & { body?: unknown; url?: unknown };
|
|
253
|
+
if (typeof parsed.url !== "string" || typeof parsed.body !== "string") {
|
|
254
|
+
parseErrors.push(`Invalid crawl record: ${trimmed.slice(0, 240)}`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
pages.push({
|
|
258
|
+
url: parsed.url,
|
|
259
|
+
title: typeof parsed.title === "string" ? parsed.title : undefined,
|
|
260
|
+
words: typeof parsed.words === "number" ? parsed.words : undefined,
|
|
261
|
+
status: typeof parsed.status === "string" ? parsed.status : undefined,
|
|
262
|
+
source: typeof parsed.source === "string" ? parsed.source : undefined,
|
|
263
|
+
body: truncateCharacters(parsed.body, options.maxChars),
|
|
264
|
+
});
|
|
265
|
+
if (pages.length >= options.maxPages && child) {
|
|
266
|
+
stopped = "max_pages";
|
|
267
|
+
child.kill("SIGTERM");
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
parseErrors.push(`Could not parse crawl JSON: ${trimmed.slice(0, 240)}`);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const result = await runChild({
|
|
275
|
+
...options,
|
|
276
|
+
bin,
|
|
277
|
+
captureStdout: false,
|
|
278
|
+
onStdoutChunk(chunk, child) {
|
|
279
|
+
buffered += chunk;
|
|
280
|
+
const lines = buffered.split("\n");
|
|
281
|
+
buffered = lines.pop() ?? "";
|
|
282
|
+
for (const line of lines) parseLine(line, child);
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
if (buffered.trim()) parseLine(buffered);
|
|
286
|
+
if (!stopped && result.termination === "timeout") stopped = "timeout";
|
|
287
|
+
const status: KetchStatus = stopped ? "partial" : result.status;
|
|
288
|
+
const crawlResult: CrawlRunResult = { ...result, status, pages, parseErrors, stopped };
|
|
289
|
+
if (result.code !== 0 && !stopped) throw new KetchError(failureMessage(crawlResult), crawlResult);
|
|
290
|
+
return crawlResult;
|
|
291
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_MAX_BYTES,
|
|
6
|
+
DEFAULT_MAX_LINES,
|
|
7
|
+
formatSize,
|
|
8
|
+
truncateHead,
|
|
9
|
+
withFileMutationQueue,
|
|
10
|
+
type TruncationResult,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { KetchProcessResult, KetchStatus } from "./ketch.js";
|
|
13
|
+
|
|
14
|
+
export interface KetchOutputDetails {
|
|
15
|
+
command: string;
|
|
16
|
+
args: string[];
|
|
17
|
+
cwd: string;
|
|
18
|
+
exitCode: number;
|
|
19
|
+
status: KetchStatus;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
resultCount?: number;
|
|
22
|
+
errorCount?: number;
|
|
23
|
+
stopped?: string;
|
|
24
|
+
parsed?: unknown;
|
|
25
|
+
stderr?: string;
|
|
26
|
+
truncated: boolean;
|
|
27
|
+
truncation?: TruncationResult;
|
|
28
|
+
fullOutputPath?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface KetchToolResult {
|
|
32
|
+
content: Array<{ type: "text"; text: string }>;
|
|
33
|
+
details: KetchOutputDetails;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FormatKetchOutputOptions {
|
|
37
|
+
process: KetchProcessResult;
|
|
38
|
+
text: string;
|
|
39
|
+
parsed?: unknown;
|
|
40
|
+
fullText?: string;
|
|
41
|
+
resultCount?: number;
|
|
42
|
+
errorCount?: number;
|
|
43
|
+
stopped?: string;
|
|
44
|
+
maxBytes?: number;
|
|
45
|
+
maxLines?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function formatKetchOutput(options: FormatKetchOutputOptions): Promise<KetchToolResult> {
|
|
49
|
+
const details: KetchOutputDetails = {
|
|
50
|
+
command: options.process.command,
|
|
51
|
+
args: [...options.process.args],
|
|
52
|
+
cwd: options.process.cwd,
|
|
53
|
+
exitCode: options.process.code,
|
|
54
|
+
status: options.process.status,
|
|
55
|
+
durationMs: options.process.durationMs,
|
|
56
|
+
resultCount: options.resultCount,
|
|
57
|
+
errorCount: options.errorCount,
|
|
58
|
+
stopped: options.stopped,
|
|
59
|
+
parsed: options.parsed,
|
|
60
|
+
stderr: options.process.stderr.trim() || undefined,
|
|
61
|
+
truncated: false,
|
|
62
|
+
};
|
|
63
|
+
const fullText = options.fullText ?? options.text;
|
|
64
|
+
const truncation = truncateHead(options.text, {
|
|
65
|
+
maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
66
|
+
maxLines: options.maxLines ?? DEFAULT_MAX_LINES,
|
|
67
|
+
});
|
|
68
|
+
let visible = truncation.content;
|
|
69
|
+
|
|
70
|
+
if (truncation.truncated) {
|
|
71
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-ketch-"));
|
|
72
|
+
const fullOutputPath = join(dir, "output.txt");
|
|
73
|
+
await withFileMutationQueue(fullOutputPath, async () => writeFile(fullOutputPath, fullText, "utf8"));
|
|
74
|
+
details.truncated = true;
|
|
75
|
+
details.truncation = truncation;
|
|
76
|
+
details.fullOutputPath = fullOutputPath;
|
|
77
|
+
const omittedLines = truncation.totalLines - truncation.outputLines;
|
|
78
|
+
const omittedBytes = truncation.totalBytes - truncation.outputBytes;
|
|
79
|
+
visible += [
|
|
80
|
+
"",
|
|
81
|
+
`[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines ` +
|
|
82
|
+
`(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). ` +
|
|
83
|
+
`${omittedLines} lines (${formatSize(omittedBytes)}) omitted. Full output: ${fullOutputPath}]`,
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!visible.trim()) visible = options.process.status === "not_found" ? "No results found." : "No output.";
|
|
88
|
+
return { content: [{ type: "text", text: visible }], details };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseJson<T>(result: KetchProcessResult): T {
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(result.stdout) as T;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new Error(`Ketch returned invalid JSON for ${result.command}: ${(error as Error).message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { KetchToolResult } from "./output.js";
|
|
4
|
+
|
|
5
|
+
interface Theme {
|
|
6
|
+
fg(slot: string, text: string): string;
|
|
7
|
+
bold(text: string): string;
|
|
8
|
+
}
|
|
9
|
+
interface Context {
|
|
10
|
+
lastComponent?: unknown;
|
|
11
|
+
expanded: boolean;
|
|
12
|
+
isError?: boolean;
|
|
13
|
+
}
|
|
14
|
+
interface ResultOptions {
|
|
15
|
+
expanded: boolean;
|
|
16
|
+
isPartial: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function component(previous: unknown): Text {
|
|
20
|
+
return previous instanceof Text ? previous : new Text("", 0, 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function compactValue(value: unknown): string | undefined {
|
|
24
|
+
if (value === undefined || value === null || value === false) return undefined;
|
|
25
|
+
const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value);
|
|
26
|
+
return text.length > 72 ? `${text.slice(0, 71)}…` : text;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function compactArgs(args: unknown): string {
|
|
30
|
+
if (!args || typeof args !== "object") return "";
|
|
31
|
+
return Object.entries(args)
|
|
32
|
+
.map(([key, value]) => {
|
|
33
|
+
const rendered = compactValue(value);
|
|
34
|
+
return rendered === undefined ? undefined : `${key}=${rendered}`;
|
|
35
|
+
})
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.slice(0, 4)
|
|
38
|
+
.join(" ");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hint(): string {
|
|
42
|
+
try {
|
|
43
|
+
return keyHint("app.tools.expand", "to expand");
|
|
44
|
+
} catch {
|
|
45
|
+
return "expand for details";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ketchRenderers(name: string) {
|
|
50
|
+
return {
|
|
51
|
+
renderCall(args: unknown, theme: Theme, context: Context): Text {
|
|
52
|
+
const text = component(context.lastComponent);
|
|
53
|
+
const title = theme.fg("toolTitle", theme.bold(name));
|
|
54
|
+
if (context.expanded) {
|
|
55
|
+
text.setText(`${title}\n${JSON.stringify(args, null, 2)}`);
|
|
56
|
+
} else {
|
|
57
|
+
const summary = compactArgs(args);
|
|
58
|
+
text.setText(summary ? `${title} ${theme.fg("muted", summary)}` : title);
|
|
59
|
+
}
|
|
60
|
+
return text;
|
|
61
|
+
},
|
|
62
|
+
renderResult(result: unknown, options: ResultOptions, theme: Theme, context: Context): Text {
|
|
63
|
+
const text = component(context.lastComponent);
|
|
64
|
+
if (options.isPartial) {
|
|
65
|
+
text.setText(theme.fg("muted", "Running Ketch…"));
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
const value = result as KetchToolResult;
|
|
69
|
+
const output = value.content.map((item) => item.text).join("\n");
|
|
70
|
+
if (options.expanded) {
|
|
71
|
+
text.setText(output || theme.fg("muted", "No output"));
|
|
72
|
+
return text;
|
|
73
|
+
}
|
|
74
|
+
const status = context.isError ? "error" : value.details.status;
|
|
75
|
+
const icon = status === "error" ? "✗" : status === "not_found" ? "!" : "✓";
|
|
76
|
+
const color = status === "error" ? "error" : status === "not_found" || status === "partial" ? "warning" : "success";
|
|
77
|
+
const count = value.details.resultCount;
|
|
78
|
+
const failures = value.details.errorCount;
|
|
79
|
+
const parts = [status.replace(/_/g, " ")];
|
|
80
|
+
if (count !== undefined) parts.push(`${count} result${count === 1 ? "" : "s"}`);
|
|
81
|
+
if (failures) parts.push(`${failures} failed`);
|
|
82
|
+
if (value.details.truncated) parts.push("output truncated");
|
|
83
|
+
text.setText(`${theme.fg(color, `${icon} ${parts.join(" · ")}`)} · ${hint()}`);
|
|
84
|
+
return text;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
|
|
4
|
+
const SearchBackend = StringEnum(["brave", "ddg", "searxng", "exa", "firecrawl", "keenable"] as const);
|
|
5
|
+
const CodeBackend = StringEnum(["grepapp", "sourcegraph", "github"] as const);
|
|
6
|
+
const DocsBackend = StringEnum(["context7"] as const);
|
|
7
|
+
|
|
8
|
+
export const SearchParams = Type.Object({
|
|
9
|
+
query: Type.String({ description: "Web search query." }),
|
|
10
|
+
backend: Type.Optional(SearchBackend),
|
|
11
|
+
multi: Type.Optional(Type.Array(SearchBackend, { description: "Federated-search backends. Use all six entries to query every configured backend.", maxItems: 6 })),
|
|
12
|
+
limit: Type.Optional(Type.Integer({ description: "Maximum results. Defaults to Ketch config; capped at 20.", minimum: 1, maximum: 20 })),
|
|
13
|
+
scrape: Type.Optional(Type.Boolean({ description: "Fetch result pages and include extracted content." })),
|
|
14
|
+
trim: Type.Optional(Type.Boolean({ description: "Strip Markdown formatting from scraped content." })),
|
|
15
|
+
maxChars: Type.Optional(Type.Integer({ description: "Maximum scraped characters per result. Defaults to 6000 when scrape is enabled.", minimum: 1, maximum: 20000 })),
|
|
16
|
+
searxngUrl: Type.Optional(Type.String({ description: "Override the configured SearXNG endpoint." })),
|
|
17
|
+
});
|
|
18
|
+
export type SearchArgs = Static<typeof SearchParams>;
|
|
19
|
+
|
|
20
|
+
export const CodeParams = Type.Object({
|
|
21
|
+
query: Type.String({ description: "Literal or regular-expression code query for public OSS repositories." }),
|
|
22
|
+
backend: Type.Optional(CodeBackend),
|
|
23
|
+
lang: Type.Optional(Type.String({ description: "Language filter, such as go, typescript, or python." })),
|
|
24
|
+
limit: Type.Optional(Type.Integer({ description: "Maximum results. Capped at 20.", minimum: 1, maximum: 20 })),
|
|
25
|
+
regex: Type.Optional(Type.Boolean({ description: "Interpret query as a regular expression. Supported by grepapp and sourcegraph, not GitHub." })),
|
|
26
|
+
});
|
|
27
|
+
export type CodeArgs = Static<typeof CodeParams>;
|
|
28
|
+
|
|
29
|
+
export const DocsParams = Type.Object({
|
|
30
|
+
query: Type.String({ description: "Library name to resolve or documentation query." }),
|
|
31
|
+
backend: Type.Optional(DocsBackend),
|
|
32
|
+
library: Type.Optional(Type.String({ description: "Context7 library ID. Skips name resolution." })),
|
|
33
|
+
resolve: Type.Optional(Type.Boolean({ description: "Resolve a library name instead of searching documentation." })),
|
|
34
|
+
limit: Type.Optional(Type.Integer({ description: "Maximum matches for search or resolve. Capped at 20.", minimum: 1, maximum: 20 })),
|
|
35
|
+
tokens: Type.Optional(Type.Integer({ description: "Context7 token budget. Defaults to 4000; capped at 12000.", minimum: 100, maximum: 12000 })),
|
|
36
|
+
});
|
|
37
|
+
export type DocsArgs = Static<typeof DocsParams>;
|
|
38
|
+
|
|
39
|
+
export const ScrapeParams = Type.Object({
|
|
40
|
+
url: Type.Optional(Type.String({ description: "One HTTP(S) URL to scrape. Provide exactly one of url or urls." })),
|
|
41
|
+
urls: Type.Optional(Type.Array(Type.String(), { description: "HTTP(S) URLs to scrape in one batch. Provide exactly one of url or urls.", minItems: 1, maxItems: 20 })),
|
|
42
|
+
selector: Type.Optional(Type.String({ description: "CSS selector to extract. Incompatible with raw." })),
|
|
43
|
+
trim: Type.Optional(Type.Boolean({ description: "Strip Markdown formatting. Incompatible with raw." })),
|
|
44
|
+
maxChars: Type.Optional(Type.Integer({ description: "Maximum characters per page. Defaults to 6000.", minimum: 1, maximum: 20000 })),
|
|
45
|
+
noCache: Type.Optional(Type.Boolean({ description: "Bypass Ketch's page cache." })),
|
|
46
|
+
raw: Type.Optional(Type.Boolean({ description: "Return raw HTML instead of extracted Markdown." })),
|
|
47
|
+
forceBrowser: Type.Optional(Type.Boolean({ description: "Force configured headless-browser rendering." })),
|
|
48
|
+
noLlmsTxt: Type.Optional(Type.Boolean({ description: "Disable automatic /llms.txt detection for bare domains." })),
|
|
49
|
+
concurrency: Type.Optional(Type.Integer({ description: "Batch concurrency. Defaults to 5; capped at 16.", minimum: 1, maximum: 16 })),
|
|
50
|
+
});
|
|
51
|
+
export type ScrapeArgs = Static<typeof ScrapeParams>;
|
|
52
|
+
|
|
53
|
+
export const CrawlParams = Type.Object({
|
|
54
|
+
url: Type.String({ description: "HTTP(S) seed URL. Ketch crawls the same host breadth-first." }),
|
|
55
|
+
depth: Type.Optional(Type.Integer({ description: "Maximum crawl depth. Defaults to 3; capped at 5.", minimum: 1, maximum: 5 })),
|
|
56
|
+
sitemap: Type.Optional(Type.Boolean({ description: "Treat the seed as a sitemap." })),
|
|
57
|
+
maxPages: Type.Optional(Type.Integer({ description: "Stop after this many successful pages. Defaults to 30; capped at 100.", minimum: 1, maximum: 100 })),
|
|
58
|
+
allow: Type.Optional(Type.Array(Type.String(), { description: "Path substrings; a URL must match at least one." })),
|
|
59
|
+
deny: Type.Optional(Type.Array(Type.String(), { description: "Regular expressions for URLs to skip." })),
|
|
60
|
+
maxChars: Type.Optional(Type.Integer({ description: "Maximum Markdown characters per page. Defaults to 6000.", minimum: 1, maximum: 20000 })),
|
|
61
|
+
noCache: Type.Optional(Type.Boolean({ description: "Bypass Ketch's page cache." })),
|
|
62
|
+
concurrency: Type.Optional(Type.Integer({ description: "Worker count. Defaults to 8; capped at 16.", minimum: 1, maximum: 16 })),
|
|
63
|
+
});
|
|
64
|
+
export type CrawlArgs = Static<typeof CrawlParams>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { formatCodeResults, type CodeResult } from "../format.js";
|
|
3
|
+
import { runKetch } from "../ketch.js";
|
|
4
|
+
import { formatKetchOutput, parseJson } from "../output.js";
|
|
5
|
+
import { ketchRenderers } from "../render.js";
|
|
6
|
+
import { CodeParams, type CodeArgs } from "../schemas.js";
|
|
7
|
+
import { addFlag } from "./common.js";
|
|
8
|
+
|
|
9
|
+
export function buildCodeArgs(params: CodeArgs): string[] {
|
|
10
|
+
if (params.regex && params.backend === "github") throw new Error("ketch_code: regex is unsupported by the GitHub backend; use grepapp or sourcegraph.");
|
|
11
|
+
const args = ["code"];
|
|
12
|
+
addFlag(args, "--backend", params.backend);
|
|
13
|
+
addFlag(args, "--lang", params.lang);
|
|
14
|
+
addFlag(args, "--limit", params.limit);
|
|
15
|
+
addFlag(args, "--regex", params.regex);
|
|
16
|
+
args.push("--json", "--", params.query);
|
|
17
|
+
return args;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerCodeTool(pi: ExtensionAPI): void {
|
|
21
|
+
pi.registerTool(
|
|
22
|
+
defineTool({
|
|
23
|
+
name: "ketch_code",
|
|
24
|
+
label: "Ketch Public Code Search",
|
|
25
|
+
description: "Search public open-source repositories through Ketch's grep.app, Sourcegraph, or GitHub backends. This is not local repository search.",
|
|
26
|
+
parameters: CodeParams,
|
|
27
|
+
promptSnippet: "ketch_code: Find real-world API usage in public OSS repositories; use local code tools for the current project.",
|
|
28
|
+
promptGuidelines: [
|
|
29
|
+
"Use ketch_code for public OSS examples and real-world API usage, not for searching the current local repository.",
|
|
30
|
+
"Cite the repository URLs returned by ketch_code when drawing conclusions from examples.",
|
|
31
|
+
],
|
|
32
|
+
...ketchRenderers("ketch_code"),
|
|
33
|
+
async execute(_id, params: CodeArgs, signal, _update, ctx) {
|
|
34
|
+
const process = await runKetch({ cwd: ctx.cwd, args: buildCodeArgs(params), signal, timeoutMs: 60_000 });
|
|
35
|
+
const results = process.stdout.trim() ? parseJson<CodeResult[]>(process) : [];
|
|
36
|
+
if (!results.length) process.status = "not_found";
|
|
37
|
+
return await formatKetchOutput({
|
|
38
|
+
process,
|
|
39
|
+
text: formatCodeResults(results),
|
|
40
|
+
parsed: results.map(({ repo, path, line, language, stars, url, source }) => ({ repo, path, line, language, stars, url, source })),
|
|
41
|
+
resultCount: results.length,
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function addFlag(args: string[], flag: string, value: string | number | boolean | undefined): void {
|
|
2
|
+
if (value === undefined || value === false) return;
|
|
3
|
+
args.push(flag);
|
|
4
|
+
if (value !== true) args.push(String(value));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function addListFlag(args: string[], flag: string, values: readonly string[] | undefined): void {
|
|
8
|
+
if (!values?.length) return;
|
|
9
|
+
args.push(flag, values.join(","));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function validateHttpUrl(value: string, label = "url"): void {
|
|
13
|
+
let url: URL;
|
|
14
|
+
try {
|
|
15
|
+
url = new URL(value);
|
|
16
|
+
} catch {
|
|
17
|
+
throw new Error(`${label} must be a valid absolute URL.`);
|
|
18
|
+
}
|
|
19
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
20
|
+
throw new Error(`${label} must use http or https.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function warningCount(stderr: string): number {
|
|
25
|
+
return stderr.split(/\r?\n/).filter((line) => /^warn:/i.test(line.trim())).length;
|
|
26
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { formatScrapePages } from "../format.js";
|
|
3
|
+
import { runKetchCrawl } from "../ketch.js";
|
|
4
|
+
import { formatKetchOutput } from "../output.js";
|
|
5
|
+
import { ketchRenderers } from "../render.js";
|
|
6
|
+
import { CrawlParams, type CrawlArgs } from "../schemas.js";
|
|
7
|
+
import { addFlag, addListFlag, validateHttpUrl, warningCount } from "./common.js";
|
|
8
|
+
|
|
9
|
+
export function buildCrawlArgs(params: CrawlArgs): string[] {
|
|
10
|
+
validateHttpUrl(params.url);
|
|
11
|
+
const args = ["crawl", params.url];
|
|
12
|
+
addFlag(args, "--depth", params.depth ?? 3);
|
|
13
|
+
addFlag(args, "--concurrency", params.concurrency ?? 8);
|
|
14
|
+
addListFlag(args, "--allow", params.allow);
|
|
15
|
+
addListFlag(args, "--deny", params.deny);
|
|
16
|
+
addFlag(args, "--sitemap", params.sitemap);
|
|
17
|
+
addFlag(args, "--no-cache", params.noCache);
|
|
18
|
+
args.push("--json");
|
|
19
|
+
return args;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function registerCrawlTool(pi: ExtensionAPI): void {
|
|
23
|
+
pi.registerTool(
|
|
24
|
+
defineTool({
|
|
25
|
+
name: "ketch_crawl",
|
|
26
|
+
label: "Ketch Site Crawl",
|
|
27
|
+
description: "Breadth-first crawl one host through Ketch with Pi-enforced page, depth, per-page character, concurrency, and time bounds. Returns partial pages when a bound is reached.",
|
|
28
|
+
parameters: CrawlParams,
|
|
29
|
+
promptSnippet: "ketch_crawl: Read a bounded set of pages from one site; prefer scrape when one known page is enough.",
|
|
30
|
+
promptGuidelines: [
|
|
31
|
+
"Use ketch_crawl only when multiple pages from one host are required; prefer ketch_scrape for one known page.",
|
|
32
|
+
"Keep ketch_crawl bounded with maxPages and maxChars, and cite the page URLs used in conclusions.",
|
|
33
|
+
],
|
|
34
|
+
...ketchRenderers("ketch_crawl"),
|
|
35
|
+
async execute(_id, params: CrawlArgs, signal, _update, ctx) {
|
|
36
|
+
const maxPages = params.maxPages ?? 30;
|
|
37
|
+
const maxChars = params.maxChars ?? 6000;
|
|
38
|
+
const process = await runKetchCrawl({
|
|
39
|
+
cwd: ctx.cwd,
|
|
40
|
+
args: buildCrawlArgs(params),
|
|
41
|
+
signal,
|
|
42
|
+
timeoutMs: 180_000,
|
|
43
|
+
maxPages,
|
|
44
|
+
maxChars,
|
|
45
|
+
});
|
|
46
|
+
const errors = warningCount(process.stderr) + process.parseErrors.length;
|
|
47
|
+
let text = formatScrapePages(process.pages.map((page) => ({ url: page.url, title: page.title, markdown: page.body, source: page.source })));
|
|
48
|
+
const notices = [
|
|
49
|
+
process.stopped ? `Crawl stopped at the ${process.stopped.replace("_", " ")} bound; results are partial.` : undefined,
|
|
50
|
+
...process.parseErrors,
|
|
51
|
+
process.stderr.trim() || undefined,
|
|
52
|
+
].filter(Boolean);
|
|
53
|
+
if (notices.length) text += `\n\n## Crawl notices\n\n${notices.join("\n")}`;
|
|
54
|
+
return await formatKetchOutput({
|
|
55
|
+
process,
|
|
56
|
+
text,
|
|
57
|
+
parsed: process.pages.map(({ url, title, words, status, source }) => ({ url, title, words, status, source })),
|
|
58
|
+
resultCount: process.pages.length,
|
|
59
|
+
errorCount: errors,
|
|
60
|
+
stopped: process.stopped,
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|