Added nono profiles & orca integration - Added some extensions to start working on
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Pure CLI argument construction for the fd and rg tools.
|
||||
*
|
||||
* Everything here is synchronous and side-effect free so the exact argv
|
||||
* passed to pi.exec can be asserted in tests. Patterns are always placed
|
||||
* after a `--` separator so user-controlled input can never be parsed as a
|
||||
* flag, and paths are normalized (leading `@`, `~` expansion) before use.
|
||||
*/
|
||||
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const FD_DEFAULT_LIMIT = 1000;
|
||||
export const FD_MAX_LIMIT = 10_000;
|
||||
export const FD_MAX_DEPTH_LIMIT = 64;
|
||||
export const RG_DEFAULT_COUNT_LIMIT = 100;
|
||||
export const RG_MAX_COUNT_LIMIT = 1000;
|
||||
export const RG_MAX_CONTEXT = 20;
|
||||
|
||||
/** Some models prefix path arguments with @; built-in tools strip it, so do we. */
|
||||
export function normalizeSearchPath(raw: string) {
|
||||
let path = raw.trim();
|
||||
if (path.startsWith("@")) path = path.slice(1);
|
||||
if (path === "~") return homedir();
|
||||
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
||||
return path;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(maximum, Math.max(minimum, Math.floor(value)));
|
||||
}
|
||||
|
||||
function optionalPath(raw: string | undefined) {
|
||||
if (raw === undefined) return undefined;
|
||||
const normalized = normalizeSearchPath(raw);
|
||||
return normalized === "" ? undefined : normalized;
|
||||
}
|
||||
|
||||
export type FdEntryType = "file" | "directory" | "symlink";
|
||||
|
||||
export interface FdToolParams {
|
||||
pattern?: string;
|
||||
path?: string;
|
||||
type?: FdEntryType;
|
||||
extension?: string;
|
||||
glob?: boolean;
|
||||
hidden?: boolean;
|
||||
max_depth?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const FD_TYPE_FLAGS: Record<FdEntryType, string> = {
|
||||
file: "f",
|
||||
directory: "d",
|
||||
symlink: "l",
|
||||
};
|
||||
|
||||
export function buildFdArgs(params: FdToolParams) {
|
||||
const args = ["--color=never"];
|
||||
if (params.hidden) args.push("--hidden");
|
||||
if (params.glob) args.push("--glob");
|
||||
if (params.type) args.push("--type", FD_TYPE_FLAGS[params.type]);
|
||||
if (params.extension) {
|
||||
args.push("--extension", params.extension.replace(/^\.+/, ""));
|
||||
}
|
||||
if (params.max_depth !== undefined) {
|
||||
args.push(
|
||||
"--max-depth",
|
||||
String(clamp(params.max_depth, 1, FD_MAX_DEPTH_LIMIT)),
|
||||
);
|
||||
}
|
||||
args.push(
|
||||
"--max-results",
|
||||
String(clamp(params.limit ?? FD_DEFAULT_LIMIT, 1, FD_MAX_LIMIT)),
|
||||
);
|
||||
// An empty pattern matches everything, which keeps `path` usable without a pattern.
|
||||
args.push("--", params.pattern ?? "");
|
||||
const path = optionalPath(params.path);
|
||||
if (path) args.push(path);
|
||||
return args;
|
||||
}
|
||||
|
||||
export interface RgToolParams {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
glob?: string;
|
||||
file_type?: string;
|
||||
case_sensitive?: boolean;
|
||||
fixed_strings?: boolean;
|
||||
hidden?: boolean;
|
||||
context?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function buildRgArgs(params: RgToolParams) {
|
||||
const args = [
|
||||
"--line-number",
|
||||
"--color=never",
|
||||
"--no-heading",
|
||||
"--with-filename",
|
||||
];
|
||||
if (params.case_sensitive === true) args.push("--case-sensitive");
|
||||
else if (params.case_sensitive === false) args.push("--ignore-case");
|
||||
else args.push("--smart-case");
|
||||
if (params.fixed_strings) args.push("--fixed-strings");
|
||||
if (params.hidden) args.push("--hidden");
|
||||
if (params.context !== undefined) {
|
||||
args.push("--context", String(clamp(params.context, 0, RG_MAX_CONTEXT)));
|
||||
}
|
||||
if (params.glob) args.push("--glob", params.glob);
|
||||
if (params.file_type) args.push("--type", params.file_type);
|
||||
args.push(
|
||||
"--max-count",
|
||||
String(
|
||||
clamp(params.limit ?? RG_DEFAULT_COUNT_LIMIT, 1, RG_MAX_COUNT_LIMIT),
|
||||
),
|
||||
);
|
||||
args.push("--", params.pattern);
|
||||
const path = optionalPath(params.path);
|
||||
if (path) args.push(path);
|
||||
return args;
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Startup resolution of the fd and rg executables.
|
||||
*
|
||||
* Resolution order (per tool, first usable wins):
|
||||
* 1. A normally installed system binary (`fd`/`fdfind`, `rg`) — used silently.
|
||||
* 2. An existing fallback in this repository's `bin/` directory — used silently.
|
||||
* 3. A fresh download of an official release into `bin/` — the only case that
|
||||
* should surface a UI notification.
|
||||
*
|
||||
* The decision logic is an Effect over a small injectable environment
|
||||
* (`BinaryEnv`) so tests can drive it without touching the filesystem or the
|
||||
* network. `liveBinaryEnv` is the real implementation.
|
||||
*/
|
||||
|
||||
import { NodeHttpClient, NodeServices } from "@effect/platform-node";
|
||||
import { execFile } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Crypto, Data, Effect, Encoding, FileSystem, Stream } from "effect";
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http";
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const FD_VERSION = "10.4.2";
|
||||
export const FD_INTEL_DARWIN_VERSION = "10.3.0";
|
||||
export const RG_VERSION = "15.2.0";
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
||||
const MAX_ARCHIVE_BYTES = 25 * 1024 * 1024;
|
||||
const MAX_DOWNLOAD_REDIRECTS = 10;
|
||||
|
||||
const FD_SHA256: Readonly<Record<string, string>> = {
|
||||
"aarch64-apple-darwin":
|
||||
"623dc0afc81b92e4d4606b380d7bc91916ba7b97814263e554d50923a39e480a",
|
||||
"x86_64-apple-darwin":
|
||||
"50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3",
|
||||
"aarch64-unknown-linux-musl":
|
||||
"f32d3657473fba74e2600babc8db0b93420d51169223b7e8143b2ed55d8fd9e8",
|
||||
"x86_64-unknown-linux-musl":
|
||||
"e3257d48e29a6be965187dbd24ce9af564e0fe67b3e73c9bdcd180f4ec11bdde",
|
||||
};
|
||||
|
||||
const RG_SHA256: Readonly<Record<string, string>> = {
|
||||
"aarch64-apple-darwin":
|
||||
"3750b2e93f37e0c692657da574d7019a101c0084da05a790c83fd335bad973e4",
|
||||
"x86_64-apple-darwin":
|
||||
"af7825fcc69a2afc7a7aea55fc9af90e26421d8f20fe59df32e233c0b8a231c1",
|
||||
"aarch64-unknown-linux-musl":
|
||||
"800b1e7206afe799dfb5a6901f23147cfaabe0e52210538100f61e86e1740915",
|
||||
"x86_64-unknown-linux-musl":
|
||||
"33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c",
|
||||
};
|
||||
|
||||
export type ToolName = "fd" | "rg";
|
||||
export type BinarySource = "system" | "bundled" | "installed";
|
||||
|
||||
export interface ToolSpec {
|
||||
readonly tool: ToolName;
|
||||
/** Commands probed on PATH, in order. Debian/Ubuntu install fd as `fdfind`. */
|
||||
readonly systemCommands: readonly string[];
|
||||
/** Executable name used inside release archives and the repo bin directory. */
|
||||
readonly binaryName: string;
|
||||
}
|
||||
|
||||
export const TOOL_SPECS: Record<ToolName, ToolSpec> = {
|
||||
fd: { tool: "fd", systemCommands: ["fd", "fdfind"], binaryName: "fd" },
|
||||
rg: { tool: "rg", systemCommands: ["rg"], binaryName: "rg" },
|
||||
};
|
||||
|
||||
export interface PlatformTarget {
|
||||
readonly os: string;
|
||||
readonly arch: string;
|
||||
}
|
||||
|
||||
export interface ReleaseAsset {
|
||||
readonly url: string;
|
||||
readonly fileName: string;
|
||||
/** Top-level directory inside the tarball. */
|
||||
readonly archiveDir: string;
|
||||
readonly binaryName: string;
|
||||
readonly version: string;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
function targetTriple(target: PlatformTarget) {
|
||||
const cpu =
|
||||
target.arch === "arm64"
|
||||
? "aarch64"
|
||||
: target.arch === "x64"
|
||||
? "x86_64"
|
||||
: undefined;
|
||||
if (!cpu) return undefined;
|
||||
if (target.os === "darwin") return `${cpu}-apple-darwin`;
|
||||
// musl builds are statically linked, so they run on any Linux distribution.
|
||||
if (target.os === "linux") return `${cpu}-unknown-linux-musl`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Official GitHub release asset for a tool on a platform, if supported. */
|
||||
export function releaseAsset(
|
||||
tool: ToolName,
|
||||
target: PlatformTarget,
|
||||
): ReleaseAsset | undefined {
|
||||
const triple = targetTriple(target);
|
||||
if (!triple) return undefined;
|
||||
|
||||
if (tool === "fd") {
|
||||
const sha256 = FD_SHA256[triple];
|
||||
if (!sha256) return undefined;
|
||||
// fd 10.4.2 dropped the Intel macOS archive, so retain 10.3.0 there.
|
||||
const version =
|
||||
triple === "x86_64-apple-darwin" ? FD_INTEL_DARWIN_VERSION : FD_VERSION;
|
||||
const archiveDir = `fd-v${version}-${triple}`;
|
||||
const fileName = `${archiveDir}.tar.gz`;
|
||||
return {
|
||||
url: `https://github.com/sharkdp/fd/releases/download/v${version}/${fileName}`,
|
||||
fileName,
|
||||
archiveDir,
|
||||
binaryName: "fd",
|
||||
version,
|
||||
sha256,
|
||||
};
|
||||
}
|
||||
|
||||
const sha256 = RG_SHA256[triple];
|
||||
if (!sha256) return undefined;
|
||||
const archiveDir = `ripgrep-${RG_VERSION}-${triple}`;
|
||||
const fileName = `${archiveDir}.tar.gz`;
|
||||
return {
|
||||
url: `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${fileName}`,
|
||||
fileName,
|
||||
archiveDir,
|
||||
binaryName: "rg",
|
||||
version: RG_VERSION,
|
||||
sha256,
|
||||
};
|
||||
}
|
||||
|
||||
export function currentTarget(): PlatformTarget {
|
||||
return { os: process.platform, arch: process.arch };
|
||||
}
|
||||
|
||||
/** Repository root (`~/.pi/agent`) resolved from this module's location. */
|
||||
export function repositoryBinDir() {
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
return join(moduleDir, "..", "..", "..", "bin");
|
||||
}
|
||||
|
||||
export class UnsupportedPlatformError extends Data.TaggedError(
|
||||
"UnsupportedPlatformError",
|
||||
)<{
|
||||
readonly message: string;
|
||||
}> {}
|
||||
|
||||
export class InstallError extends Data.TaggedError("InstallError")<{
|
||||
readonly message: string;
|
||||
readonly cause?: unknown;
|
||||
}> {}
|
||||
|
||||
export interface BinaryEnv {
|
||||
/** True when the executable runs and supports the flags this tool requires. */
|
||||
readonly probe: (command: string, tool: ToolName) => Effect.Effect<boolean>;
|
||||
/** Download and place a release binary at the destination path. */
|
||||
readonly install: (
|
||||
asset: ReleaseAsset,
|
||||
destination: string,
|
||||
) => Effect.Effect<void, InstallError>;
|
||||
}
|
||||
|
||||
export interface ResolvedBinary {
|
||||
readonly tool: ToolName;
|
||||
/** Command or absolute path passed to pi.exec. */
|
||||
readonly command: string;
|
||||
readonly source: BinarySource;
|
||||
readonly version?: string;
|
||||
}
|
||||
|
||||
/** Resolve one tool: system binary, existing bin fallback, or fresh install. */
|
||||
export function resolveBinary(
|
||||
spec: ToolSpec,
|
||||
binDir: string,
|
||||
target: PlatformTarget,
|
||||
env: BinaryEnv,
|
||||
): Effect.Effect<ResolvedBinary, UnsupportedPlatformError | InstallError> {
|
||||
return Effect.gen(function* () {
|
||||
for (const command of spec.systemCommands) {
|
||||
if (yield* env.probe(command, spec.tool)) {
|
||||
return { tool: spec.tool, command, source: "system" as const };
|
||||
}
|
||||
}
|
||||
|
||||
const bundled = join(binDir, spec.binaryName);
|
||||
if (yield* env.probe(bundled, spec.tool)) {
|
||||
return { tool: spec.tool, command: bundled, source: "bundled" as const };
|
||||
}
|
||||
|
||||
const asset = releaseAsset(spec.tool, target);
|
||||
if (!asset) {
|
||||
return yield* new UnsupportedPlatformError({
|
||||
message: `No ${spec.tool} binary is available for ${target.os}/${target.arch}. Install ${spec.tool} manually and restart pi.`,
|
||||
});
|
||||
}
|
||||
|
||||
yield* env.install(asset, bundled);
|
||||
|
||||
if (!(yield* env.probe(bundled, spec.tool))) {
|
||||
return yield* new InstallError({
|
||||
message: `${spec.tool} ${asset.version} was installed to ${bundled} but failed to run.`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tool: spec.tool,
|
||||
command: bundled,
|
||||
source: "installed" as const,
|
||||
version: asset.version,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** Read a response incrementally while enforcing the startup memory bound. */
|
||||
export function readBoundedResponse<E, R>(
|
||||
response: {
|
||||
readonly headers: Readonly<Record<string, string | undefined>>;
|
||||
readonly stream: Stream.Stream<Uint8Array, E, R>;
|
||||
},
|
||||
maxBytes = MAX_ARCHIVE_BYTES,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const declaredLength = Number(response.headers["content-length"]);
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
return yield* Effect.fail(
|
||||
new Error(`download exceeds the ${maxBytes}-byte size limit`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = yield* Stream.runFoldEffect(
|
||||
response.stream,
|
||||
() => ({ chunks: [] as Uint8Array[], totalBytes: 0 }),
|
||||
(accumulator, chunk) => {
|
||||
const totalBytes = accumulator.totalBytes + chunk.byteLength;
|
||||
if (totalBytes > maxBytes) {
|
||||
return Effect.fail(
|
||||
new Error(`download exceeds the ${maxBytes}-byte size limit`),
|
||||
);
|
||||
}
|
||||
return Effect.sync(() => {
|
||||
accumulator.chunks.push(chunk);
|
||||
accumulator.totalBytes = totalBytes;
|
||||
return accumulator;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return Buffer.concat(result.chunks, result.totalBytes);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadAsset(client: HttpClient.HttpClient, initialUrl: URL) {
|
||||
const scopedClient = client.pipe(HttpClient.withScope);
|
||||
|
||||
return Effect.gen(function* () {
|
||||
let url = initialUrl;
|
||||
|
||||
for (let redirects = 0; redirects <= MAX_DOWNLOAD_REDIRECTS; redirects++) {
|
||||
if (url.protocol !== "https:") {
|
||||
return yield* Effect.fail(
|
||||
new Error(`refusing non-HTTPS download URL: ${url.href}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const response = yield* scopedClient.get(url);
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
const location = response.headers.location;
|
||||
if (!location) {
|
||||
return yield* Effect.fail(
|
||||
new Error(`redirect from ${url.href} had no location header`),
|
||||
);
|
||||
}
|
||||
if (redirects === MAX_DOWNLOAD_REDIRECTS) {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`download exceeded ${MAX_DOWNLOAD_REDIRECTS} redirects`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!URL.canParse(location, url)) {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`download returned an invalid redirect URL: ${location}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return { _tag: "Redirect" as const, url: new URL(location, url) };
|
||||
}
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return yield* Effect.fail(
|
||||
new Error(`download failed with HTTP ${response.status}`),
|
||||
);
|
||||
}
|
||||
return {
|
||||
_tag: "Complete" as const,
|
||||
bytes: yield* readBoundedResponse(response),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (result._tag === "Complete") return result.bytes;
|
||||
url = result.url;
|
||||
}
|
||||
|
||||
return yield* Effect.fail(new Error("download redirect handling failed"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Real environment: probes via `--version`, installs via HTTPS + tar. */
|
||||
export const liveBinaryEnv: BinaryEnv = {
|
||||
probe: (command, tool) =>
|
||||
Effect.promise(async () => {
|
||||
try {
|
||||
const args =
|
||||
tool === "fd" ? ["--max-results", "1", "--", ""] : ["--version"];
|
||||
await execFileAsync(command, args, {
|
||||
cwd: tmpdir(),
|
||||
timeout: 5_000,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
|
||||
install: (asset, destination) => {
|
||||
const install = Effect.gen(function* () {
|
||||
if (!URL.canParse(asset.url)) {
|
||||
return yield* Effect.fail(
|
||||
new Error(`invalid download URL: ${asset.url}`),
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(asset.url);
|
||||
const client = yield* HttpClient.HttpClient;
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const crypto = yield* Crypto.Crypto;
|
||||
const bytes = yield* downloadAsset(client, url).pipe(
|
||||
Effect.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const digestBytes = yield* crypto.digest("SHA-256", bytes);
|
||||
const digest = Encoding.encodeHex(digestBytes);
|
||||
if (digest !== asset.sha256) {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`SHA-256 mismatch for ${asset.fileName}: expected ${asset.sha256}, received ${digest}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const workDir = yield* fs.makeTempDirectoryScoped({
|
||||
prefix: "pi-file-search-",
|
||||
});
|
||||
const archivePath = join(workDir, asset.fileName);
|
||||
yield* fs.writeFile(archivePath, bytes);
|
||||
|
||||
const tarExitCode = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const tar = yield* ChildProcess.make(
|
||||
"tar",
|
||||
["-xzf", archivePath, "-C", workDir],
|
||||
{ stdin: "ignore", stdout: "ignore", stderr: "ignore" },
|
||||
);
|
||||
return yield* tar.exitCode;
|
||||
}),
|
||||
).pipe(Effect.timeout(60_000));
|
||||
if (tarExitCode !== ChildProcessSpawner.ExitCode(0)) {
|
||||
return yield* Effect.fail(
|
||||
new Error(`tar failed with exit code ${tarExitCode}`),
|
||||
);
|
||||
}
|
||||
|
||||
const extracted = join(workDir, asset.archiveDir, asset.binaryName);
|
||||
yield* fs.makeDirectory(dirname(destination), { recursive: true });
|
||||
const uuid = yield* crypto.randomUUIDv4;
|
||||
const stagedDestination = `${destination}.${process.pid}.${uuid}.tmp`;
|
||||
yield* Effect.addFinalizer(() =>
|
||||
fs.remove(stagedDestination, { force: true }).pipe(Effect.orDie),
|
||||
);
|
||||
yield* fs.copyFile(extracted, stagedDestination);
|
||||
yield* fs.chmod(stagedDestination, 0o755);
|
||||
yield* fs.rename(stagedDestination, destination);
|
||||
});
|
||||
|
||||
return install.pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.provide(NodeHttpClient.layerFetch),
|
||||
Effect.provideService(FetchHttpClient.RequestInit, {
|
||||
redirect: "manual",
|
||||
}),
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new InstallError({
|
||||
message: `Failed to install ${asset.binaryName} ${asset.version} from ${asset.url}: ${errorMessage(cause)}`,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Shared output shaping for the fd and rg tools: standard pi truncation
|
||||
* (2000 lines / 50KB) with the full output persisted to a temp file when
|
||||
* anything is cut off.
|
||||
*/
|
||||
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
formatSize,
|
||||
truncateHead,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export interface FormattedOutput {
|
||||
readonly text: string;
|
||||
readonly lineCount: number;
|
||||
readonly truncated: boolean;
|
||||
readonly fullOutputPath?: string;
|
||||
}
|
||||
|
||||
export interface CapturedOutput {
|
||||
readonly preview: string;
|
||||
readonly lineCount: number;
|
||||
readonly totalBytes: number;
|
||||
readonly truncated: boolean;
|
||||
readonly fullOutputPath?: string;
|
||||
}
|
||||
|
||||
export interface FormatOutputOptions {
|
||||
/** Temp-file prefix, e.g. "pi-fd-". */
|
||||
readonly tempPrefix: string;
|
||||
/** Injectable for tests. */
|
||||
readonly persistFullOutput?: (output: string) => Promise<string>;
|
||||
}
|
||||
|
||||
async function persistToTempFile(prefix: string, output: string) {
|
||||
const directory = await mkdtemp(join(tmpdir(), prefix));
|
||||
const path = join(directory, "output.txt");
|
||||
await writeFile(path, output, "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
function truncationNotice(options: {
|
||||
content: string;
|
||||
outputLines: number;
|
||||
totalLines: number;
|
||||
outputBytes: number;
|
||||
totalBytes: number;
|
||||
fullOutputPath: string;
|
||||
}) {
|
||||
return (
|
||||
`${options.content}\n\n[Output truncated: ${options.outputLines} of ${options.totalLines} lines ` +
|
||||
`(${formatSize(options.outputBytes)} of ${formatSize(options.totalBytes)}). ` +
|
||||
`Full output saved to: ${options.fullOutputPath}]`
|
||||
);
|
||||
}
|
||||
|
||||
/** Format output already captured by a bounded-memory streaming process. */
|
||||
export function formatCapturedOutput(captured: CapturedOutput) {
|
||||
const trimmed = captured.preview.replace(/\n+$/, "");
|
||||
if (!captured.truncated || !captured.fullOutputPath) {
|
||||
return {
|
||||
text: trimmed,
|
||||
lineCount: captured.lineCount,
|
||||
truncated: false,
|
||||
} satisfies FormattedOutput;
|
||||
}
|
||||
|
||||
const truncation = truncateHead(trimmed, {
|
||||
maxLines: DEFAULT_MAX_LINES,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
});
|
||||
const content = truncation.content;
|
||||
const outputLines = content === "" ? 0 : content.split("\n").length;
|
||||
const outputBytes = Buffer.byteLength(content);
|
||||
return {
|
||||
text: truncationNotice({
|
||||
content,
|
||||
outputLines,
|
||||
totalLines: captured.lineCount,
|
||||
outputBytes,
|
||||
totalBytes: captured.totalBytes,
|
||||
fullOutputPath: captured.fullOutputPath,
|
||||
}),
|
||||
lineCount: captured.lineCount,
|
||||
truncated: true,
|
||||
fullOutputPath: captured.fullOutputPath,
|
||||
} satisfies FormattedOutput;
|
||||
}
|
||||
|
||||
/** Truncate to pi's standard limits, persisting the full output when cut. */
|
||||
export async function formatOutput(
|
||||
output: string,
|
||||
options: FormatOutputOptions,
|
||||
): Promise<FormattedOutput> {
|
||||
const trimmed = output.replace(/\n+$/, "");
|
||||
const lineCount = trimmed === "" ? 0 : trimmed.split("\n").length;
|
||||
|
||||
const truncation = truncateHead(trimmed, {
|
||||
maxLines: DEFAULT_MAX_LINES,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
});
|
||||
|
||||
if (!truncation.truncated) {
|
||||
return { text: trimmed, lineCount, truncated: false };
|
||||
}
|
||||
|
||||
const persist =
|
||||
options.persistFullOutput ??
|
||||
((full: string) => persistToTempFile(options.tempPrefix, full));
|
||||
const fullOutputPath = await persist(trimmed);
|
||||
|
||||
const text = truncationNotice({
|
||||
content: truncation.content,
|
||||
outputLines: truncation.outputLines,
|
||||
totalLines: truncation.totalLines,
|
||||
outputBytes: truncation.outputBytes,
|
||||
totalBytes: truncation.totalBytes,
|
||||
fullOutputPath,
|
||||
});
|
||||
|
||||
return { text, lineCount, truncated: true, fullOutputPath };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
truncateHead,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { Effect, FileSystem, Stream } from "effect";
|
||||
import { ChildProcess } from "effect/unstable/process";
|
||||
import type { CapturedOutput } from "./output.ts";
|
||||
|
||||
const STDERR_MAX_BYTES = 64 * 1024;
|
||||
|
||||
interface PreviewState {
|
||||
readonly decoder: TextDecoder;
|
||||
preview: string;
|
||||
totalBytes: number;
|
||||
lineBreaks: number;
|
||||
trailingLineBreaks: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
function makePreviewState(): PreviewState {
|
||||
return {
|
||||
decoder: new TextDecoder(),
|
||||
preview: "",
|
||||
totalBytes: 0,
|
||||
lineBreaks: 0,
|
||||
trailingLineBreaks: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function observeStdout(state: PreviewState, chunk: Uint8Array) {
|
||||
state.totalBytes += chunk.byteLength;
|
||||
for (const byte of chunk) {
|
||||
if (byte === 0x0a) {
|
||||
state.lineBreaks++;
|
||||
state.trailingLineBreaks++;
|
||||
} else {
|
||||
state.trailingLineBreaks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.truncated) return;
|
||||
state.preview += state.decoder.decode(chunk, { stream: true });
|
||||
const truncation = truncateHead(state.preview, {
|
||||
maxLines: DEFAULT_MAX_LINES,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
});
|
||||
if (truncation.truncated) {
|
||||
state.preview = truncation.content;
|
||||
state.truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
function finishStdout(state: PreviewState, fullOutputPath: string) {
|
||||
if (!state.truncated) state.preview += state.decoder.decode();
|
||||
const totalBytes = state.totalBytes - state.trailingLineBreaks;
|
||||
const lineCount =
|
||||
totalBytes === 0 ? 0 : state.lineBreaks - state.trailingLineBreaks + 1;
|
||||
return {
|
||||
preview: state.preview,
|
||||
lineCount,
|
||||
totalBytes,
|
||||
truncated: state.truncated,
|
||||
fullOutputPath: state.truncated ? fullOutputPath : undefined,
|
||||
} satisfies CapturedOutput;
|
||||
}
|
||||
|
||||
function collectStderr<E, R>(stream: Stream.Stream<Uint8Array, E, R>) {
|
||||
return Stream.runFold(
|
||||
stream,
|
||||
() => Buffer.alloc(0),
|
||||
(captured, chunk) => {
|
||||
if (captured.byteLength >= STDERR_MAX_BYTES) return captured;
|
||||
const remaining = STDERR_MAX_BYTES - captured.byteLength;
|
||||
return Buffer.concat([captured, chunk.subarray(0, remaining)]);
|
||||
},
|
||||
).pipe(Effect.map((bytes) => bytes.toString("utf8")));
|
||||
}
|
||||
|
||||
export function executeSearchProcess(options: {
|
||||
readonly command: string;
|
||||
readonly args: readonly string[];
|
||||
readonly cwd: string;
|
||||
readonly tempPrefix: string;
|
||||
}) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const directory = yield* fs.makeTempDirectory({
|
||||
prefix: options.tempPrefix,
|
||||
});
|
||||
const fullOutputPath = join(directory, "output.txt");
|
||||
let retainDirectory = false;
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const preview = makePreviewState();
|
||||
const process = yield* ChildProcess.make(options.command, options.args, {
|
||||
cwd: options.cwd,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const result = yield* Effect.all(
|
||||
{
|
||||
exitCode: process.exitCode,
|
||||
stdout: process.stdout.pipe(
|
||||
Stream.tap((chunk) =>
|
||||
Effect.sync(() => observeStdout(preview, chunk)),
|
||||
),
|
||||
Stream.run(fs.sink(fullOutputPath)),
|
||||
),
|
||||
stderr: collectStderr(process.stderr),
|
||||
},
|
||||
{ concurrency: "unbounded" },
|
||||
);
|
||||
const output = finishStdout(preview, fullOutputPath);
|
||||
retainDirectory = output.truncated;
|
||||
return {
|
||||
code: Number(result.exitCode),
|
||||
stderr: result.stderr,
|
||||
output,
|
||||
};
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.suspend(() =>
|
||||
retainDirectory
|
||||
? Effect.void
|
||||
: fs
|
||||
.remove(directory, { recursive: true, force: true })
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).pipe(Effect.scoped);
|
||||
}
|
||||
|
||||
export function discardCapturedOutput(output: CapturedOutput) {
|
||||
if (!output.fullOutputPath) return Effect.void;
|
||||
const directory = dirname(output.fullOutputPath);
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
yield* fs.remove(directory, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Model-facing text for the fd and rg tools. */
|
||||
|
||||
export const FD_TOOL_DESCRIPTION =
|
||||
"Find files and directories by name with fd. Respects .gitignore by default. Results are limited to 1000 entries unless a higher limit is given; output is limited to 2000 lines or 50KB, and complete truncated output is saved to a temporary file.";
|
||||
|
||||
export const FD_PROMPT_SNIPPET =
|
||||
"Find files and directories by name with fd (fast, gitignore-aware).";
|
||||
|
||||
export const FD_PROMPT_GUIDELINES = [
|
||||
"Use fd as the primary tool for discovering files and directories by name, extension, or glob instead of bash with find or ls -R.",
|
||||
"Use rg instead of fd when searching file contents rather than file names.",
|
||||
"Keep using bash for complex multi-step workflows that pipe or post-process file listings.",
|
||||
];
|
||||
|
||||
export const FD_PARAMETER_DESCRIPTIONS = {
|
||||
pattern:
|
||||
"Regex matched against file names (or a glob when glob is true). Omit to list everything under path.",
|
||||
path: "Directory to search. Defaults to the current working directory.",
|
||||
type: "Only return entries of this type: file, directory, or symlink.",
|
||||
extension: "Only return files with this extension, e.g. 'ts' or 'md'.",
|
||||
glob: "Treat pattern as a glob (e.g. '*.test.ts') instead of a regex.",
|
||||
hidden: "Include hidden files and directories. Defaults to false.",
|
||||
max_depth: "Maximum directory depth to descend (1-64).",
|
||||
limit: "Maximum number of results (1-10000). Defaults to 1000.",
|
||||
};
|
||||
|
||||
export const RG_TOOL_DESCRIPTION =
|
||||
"Search file contents with ripgrep. Uses smart-case matching, respects .gitignore by default, and returns at most 100 matches per file unless a different limit is given. Output is limited to 2000 lines or 50KB; complete truncated output is saved to a temporary file.";
|
||||
|
||||
export const RG_PROMPT_SNIPPET =
|
||||
"Search file contents with ripgrep (fast regex content search).";
|
||||
|
||||
export const RG_PROMPT_GUIDELINES = [
|
||||
"Use rg as the primary tool for searching file contents instead of bash with grep.",
|
||||
"Use fd instead of rg when looking for files by name rather than content.",
|
||||
"Set fixed_strings on rg when searching for literal code snippets containing regex metacharacters.",
|
||||
"Keep using bash for complex multi-step workflows that combine searching with other commands.",
|
||||
];
|
||||
|
||||
export const RG_PARAMETER_DESCRIPTIONS = {
|
||||
pattern: "Regex to search for (literal text when fixed_strings is true).",
|
||||
path: "File or directory to search. Defaults to the current working directory.",
|
||||
glob: "Only search files matching this glob, e.g. '*.ts' or 'src/**'.",
|
||||
file_type:
|
||||
"Only search files of this ripgrep type, e.g. 'ts', 'js', 'py', 'rust'.",
|
||||
case_sensitive:
|
||||
"true forces case-sensitive matching, false forces case-insensitive. Defaults to smart-case.",
|
||||
fixed_strings: "Treat pattern as a literal string instead of a regex.",
|
||||
hidden: "Search hidden files and directories. Defaults to false.",
|
||||
context: "Lines of context to show around each match (0-20).",
|
||||
limit: "Maximum matches per file (1-1000). Defaults to 100.",
|
||||
};
|
||||
Reference in New Issue
Block a user