pi-jev-find 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 +27 -0
- package/README.md +78 -0
- package/package.json +42 -0
- package/src/cascade/cascade.ts +357 -0
- package/src/cascade/keywords.ts +181 -0
- package/src/cascade/lexical.ts +143 -0
- package/src/cascade/passages.ts +171 -0
- package/src/cascade/questions.ts +143 -0
- package/src/cascade/text.ts +116 -0
- package/src/cascade/tree.ts +302 -0
- package/src/config.ts +59 -0
- package/src/index.ts +214 -0
- package/src/judge/jev-judge.ts +169 -0
- package/src/judge/types.ts +40 -0
- package/src/prompts/find-name-question.ts +2 -0
- package/src/prompts/find-passage-question.ts +2 -0
- package/src/prompts/find-sketch-question.ts +2 -0
- package/src/render.ts +97 -0
- package/src/rg.ts +99 -0
- package/src/types.ts +66 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev judge: native System One probability API (TypeSafe's `POST /v1/systemone`,
|
|
3
|
+
* the same wire jegrep uses). The cascade's `JudgeRequest` — `{state, questions}` —
|
|
4
|
+
* is forwarded verbatim with the model id; answers come back as absolute noul
|
|
5
|
+
* probabilities, no chat-JSON emulation.
|
|
6
|
+
*
|
|
7
|
+
* Ported from oh-my-pi `packages/ai/src/judgment/typesafe.ts` (MIT), reduced to
|
|
8
|
+
* the noul-only surface this cascade needs and driven entirely by environment
|
|
9
|
+
* variables so the extension carries no provider plumbing:
|
|
10
|
+
*
|
|
11
|
+
* - `JEV_API_KEY` (or `TYPESAFE_API_KEY`, jegrep-compatible)
|
|
12
|
+
* - `JEV_BASE_URL` (or `TYPESAFE_BASE_URL`; default `https://api.typesafe.ai`)
|
|
13
|
+
* - `JEV_MODEL` (or `TYPESAFE_DEFAULT_MODEL`; default `jev-latest`)
|
|
14
|
+
*/
|
|
15
|
+
import type { Judge, JudgeRequest, JudgeAnswer, JudgmentResult } from "./types";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_BASE_URL = "https://api.typesafe.ai";
|
|
18
|
+
const DEFAULT_MODEL = "jev-latest";
|
|
19
|
+
const JUDGMENT_ROUTE = "/v1/systemone";
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
21
|
+
const MAX_ATTEMPTS = 3;
|
|
22
|
+
const BACKOFF_BASE_MS = 500;
|
|
23
|
+
const BACKOFF_MAX_MS = 5_000;
|
|
24
|
+
|
|
25
|
+
/** Resolved Jev configuration, for the /find status line. */
|
|
26
|
+
export interface JevConfig {
|
|
27
|
+
apiKey: string;
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
model: string;
|
|
30
|
+
/** Which environment variable supplied the key; never the key itself. */
|
|
31
|
+
keySource: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readEnv(env: Record<string, string | undefined>, name: string, fallbackName?: string): { value: string | undefined; source: string } {
|
|
35
|
+
const primary = env[name]?.trim();
|
|
36
|
+
if (primary !== undefined && primary.length > 0) return { value: primary, source: name };
|
|
37
|
+
if (fallbackName === undefined) return { value: undefined, source: name };
|
|
38
|
+
const fallback = env[fallbackName]?.trim();
|
|
39
|
+
return { value: fallback !== undefined && fallback.length > 0 ? fallback : undefined, source: fallbackName };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the Jev endpoint from the environment. `JEV_*` names win;
|
|
44
|
+
* `TYPESAFE_*` names are honored as jegrep-compatible fallbacks. Throws a
|
|
45
|
+
* user-actionable message when no API key is configured.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveJevConfig(env: Record<string, string | undefined> = process.env): JevConfig {
|
|
48
|
+
const key = readEnv(env, "JEV_API_KEY", "TYPESAFE_API_KEY");
|
|
49
|
+
if (key.value === undefined) {
|
|
50
|
+
throw new Error("pi-jev-find needs a Jev API key. Set JEV_API_KEY (or TYPESAFE_API_KEY) in your environment.");
|
|
51
|
+
}
|
|
52
|
+
const baseUrl = (readEnv(env, "JEV_BASE_URL", "TYPESAFE_BASE_URL").value ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
53
|
+
const model = readEnv(env, "JEV_MODEL", "TYPESAFE_DEFAULT_MODEL").value ?? DEFAULT_MODEL;
|
|
54
|
+
return { apiKey: key.value, baseUrl, model, keySource: key.source };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Non-2xx response from the Jev API. */
|
|
58
|
+
export class JevApiError extends Error {
|
|
59
|
+
constructor(
|
|
60
|
+
message: string,
|
|
61
|
+
readonly status: number,
|
|
62
|
+
) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "JevApiError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface SystemOneResponse {
|
|
69
|
+
model: string;
|
|
70
|
+
answers: Record<string, JudgeAnswer & { type?: string }>;
|
|
71
|
+
usage: { input_tokens: number; output_tokens: number; cost?: number };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function backoffMs(attempt: number, response: Response | undefined): number {
|
|
75
|
+
const retryAfter = response?.headers.get("retry-after");
|
|
76
|
+
if (retryAfter) {
|
|
77
|
+
const seconds = Number.parseFloat(retryAfter);
|
|
78
|
+
if (Number.isFinite(seconds)) return Math.min(seconds * 1000, BACKOFF_MAX_MS);
|
|
79
|
+
}
|
|
80
|
+
return Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_MAX_MS);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A {@link Judge} served by the native System One probability API. */
|
|
84
|
+
export class JevJudge implements Judge {
|
|
85
|
+
readonly model: string;
|
|
86
|
+
readonly baseUrl: string;
|
|
87
|
+
readonly #apiKey: string;
|
|
88
|
+
readonly #fetch: typeof fetch;
|
|
89
|
+
readonly #timeoutMs: number;
|
|
90
|
+
|
|
91
|
+
constructor(
|
|
92
|
+
config: JevConfig,
|
|
93
|
+
options: { fetch?: typeof fetch; timeoutMs?: number } = {},
|
|
94
|
+
) {
|
|
95
|
+
this.model = config.model;
|
|
96
|
+
this.baseUrl = config.baseUrl;
|
|
97
|
+
this.#apiKey = config.apiKey;
|
|
98
|
+
this.#fetch = options.fetch ?? fetch;
|
|
99
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async judge(request: JudgeRequest, options: { signal?: AbortSignal } = {}): Promise<JudgmentResult> {
|
|
103
|
+
const signal = options.signal;
|
|
104
|
+
const body = JSON.stringify({ state: request.state, model: this.model, questions: request.questions });
|
|
105
|
+
let response: SystemOneResponse | undefined;
|
|
106
|
+
for (let attempt = 0; ; attempt++) {
|
|
107
|
+
signal?.throwIfAborted();
|
|
108
|
+
let http: Response;
|
|
109
|
+
try {
|
|
110
|
+
const timeout = AbortSignal.timeout(this.#timeoutMs);
|
|
111
|
+
http = await this.#fetch(`${this.baseUrl}${JUDGMENT_ROUTE}`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: {
|
|
114
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
115
|
+
Accept: "application/json",
|
|
116
|
+
"Content-Type": "application/json",
|
|
117
|
+
},
|
|
118
|
+
body,
|
|
119
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// Transport failures (network, timeout) are transient: bounded retry.
|
|
123
|
+
if (signal?.aborted || attempt + 1 >= MAX_ATTEMPTS) throw error;
|
|
124
|
+
await sleep(backoffMs(attempt, undefined), signal);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (http.ok) {
|
|
128
|
+
response = (await http.json()) as SystemOneResponse;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
const text = await http.text();
|
|
132
|
+
const error = new JevApiError(`jev API error (${http.status}): ${text}`, http.status);
|
|
133
|
+
const transient = http.status === 408 || http.status === 429 || http.status >= 500;
|
|
134
|
+
if (!transient || attempt + 1 >= MAX_ATTEMPTS) throw error;
|
|
135
|
+
await sleep(backoffMs(attempt, http), signal);
|
|
136
|
+
}
|
|
137
|
+
if (response === undefined) throw new JevApiError("jev API returned no response", 0);
|
|
138
|
+
// Every question must come back typed; a partial envelope is a hard error,
|
|
139
|
+
// not silently unjudged entries.
|
|
140
|
+
for (const id of Object.keys(request.questions)) {
|
|
141
|
+
const answer = response.answers[id];
|
|
142
|
+
if (answer === undefined || answer.type !== request.questions[id]?.type) {
|
|
143
|
+
throw new JevApiError(`jev response is missing a "${request.questions[id]?.type}" answer for question "${id}"`, 0);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
answers: response.answers,
|
|
148
|
+
usage: {
|
|
149
|
+
input: response.usage.input_tokens,
|
|
150
|
+
output: response.usage.output_tokens,
|
|
151
|
+
cost: response.usage.cost ?? 0,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const timer = setTimeout(() => resolve(), ms);
|
|
160
|
+
signal?.addEventListener(
|
|
161
|
+
"abort",
|
|
162
|
+
() => {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
reject(signal.reason ?? new Error("aborted"));
|
|
165
|
+
},
|
|
166
|
+
{ once: true },
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Judge wire contract: the three request shapes the cascade sends, and the
|
|
3
|
+
* answer shape every judge backend must produce. Mirrors the omp
|
|
4
|
+
* `@oh-my-pi/pi-ai` noul surface (MIT, oh-my-pi); pi has no native noul API,
|
|
5
|
+
* so {@link ../judge/pi-model-judge} emulates it with a chat completion.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | { readonly [key: string]: JsonValue };
|
|
9
|
+
|
|
10
|
+
/** One probability question. `instructions` is self-contained per entry. */
|
|
11
|
+
export interface NoulQuestion {
|
|
12
|
+
type: "noul";
|
|
13
|
+
instructions: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A judgment request: JSON state plus noul questions keyed by entry. */
|
|
17
|
+
export interface JudgeRequest {
|
|
18
|
+
state: { readonly [key: string]: JsonValue };
|
|
19
|
+
questions: Record<string, NoulQuestion>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A yes-probability in [0,1], or absent when the judge produced nothing usable. */
|
|
23
|
+
export interface JudgeAnswer {
|
|
24
|
+
noul?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface JudgeUsage {
|
|
28
|
+
input: number;
|
|
29
|
+
output: number;
|
|
30
|
+
cost: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface JudgmentResult {
|
|
34
|
+
answers: Record<string, JudgeAnswer>;
|
|
35
|
+
usage: JudgeUsage;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Judge {
|
|
39
|
+
judge(request: JudgeRequest, options: { signal?: AbortSignal }): Promise<JudgmentResult>;
|
|
40
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI rendering for the find tool: a one-line call preview and a collapsed /
|
|
3
|
+
* expanded result view with judge-calibrated score gauges and range heat.
|
|
4
|
+
* Components are plain structural objects ({ render, invalidate }) so no
|
|
5
|
+
* runtime pi-tui import is required.
|
|
6
|
+
*/
|
|
7
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
8
|
+
import { rankedHeat } from "./cascade/passages.ts";
|
|
9
|
+
import type { FindDetails, FindToolParams, FindHit } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
/** The theme surface this renderer uses; structurally satisfied by pi's Theme. */
|
|
12
|
+
export interface ThemeLike {
|
|
13
|
+
fg(name: string, text: string): string;
|
|
14
|
+
bold(text: string): string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Line ranges shown per hit in the expanded view. */
|
|
18
|
+
const RANGES_SHOWN = 3;
|
|
19
|
+
/** Heat gauge segments. */
|
|
20
|
+
const GAUGE_SEGMENTS = 10;
|
|
21
|
+
|
|
22
|
+
function component(render: (width: number) => string[]): Component {
|
|
23
|
+
return {
|
|
24
|
+
render,
|
|
25
|
+
invalidate() {},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Tabs to spaces; terminal-safe single line. */
|
|
30
|
+
function sanitize(text: string): string {
|
|
31
|
+
return text.replace(/\t/g, " ");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `0.94` → filled gauge `█████████░`. */
|
|
35
|
+
function gauge(p: number): string {
|
|
36
|
+
const filled = Math.round(Math.min(1, Math.max(0, p)) * GAUGE_SEGMENTS);
|
|
37
|
+
return "█".repeat(filled) + "░".repeat(GAUGE_SEGMENTS - filled);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Heat color: strong hits green, mid amber, weak dim. */
|
|
41
|
+
function heat(theme: ThemeLike, p: number, text: string): string {
|
|
42
|
+
if (p >= 0.6) return theme.fg("success", text);
|
|
43
|
+
if (p >= 0.4) return theme.fg("warning", text);
|
|
44
|
+
return theme.fg("dim", text);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The pending call preview: `find "query" [in path]`. */
|
|
48
|
+
export function renderFindCall(args: Partial<FindToolParams>, theme: ThemeLike): Component {
|
|
49
|
+
let text = theme.fg("toolTitle", theme.bold("find ")) + theme.fg("accent", sanitize(`"${args.query ?? ""}"`));
|
|
50
|
+
if (args.path !== undefined) text += theme.fg("dim", ` in ${sanitize(args.path)}`);
|
|
51
|
+
const keywords = args.grep_keywords ?? [];
|
|
52
|
+
if (keywords.length > 0) text += theme.fg("dim", ` [${keywords.map(sanitize).join(", ")}]`);
|
|
53
|
+
return component(() => [text]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function renderFindResult(
|
|
57
|
+
details: FindDetails | undefined,
|
|
58
|
+
isError: boolean,
|
|
59
|
+
expanded: boolean,
|
|
60
|
+
theme: ThemeLike,
|
|
61
|
+
): Component {
|
|
62
|
+
if (details === undefined) {
|
|
63
|
+
return component(() => [theme.fg(isError ? "error" : "dim", isError ? "find failed" : "find")]);
|
|
64
|
+
}
|
|
65
|
+
const { hits, stats, threshold, elapsedMs } = details;
|
|
66
|
+
const lines: string[] = [];
|
|
67
|
+
const head =
|
|
68
|
+
hits.length === 0
|
|
69
|
+
? theme.fg("warning", `no hits (τ ${threshold.toFixed(2)})`)
|
|
70
|
+
: theme.fg("success", `${hits.length} hit${hits.length === 1 ? "" : "s"}`) +
|
|
71
|
+
theme.fg("dim", ` (τ ${threshold.toFixed(2)}), strongest first`);
|
|
72
|
+
lines.push(head);
|
|
73
|
+
if (expanded) {
|
|
74
|
+
for (const hit of hits) lines.push(...hitLines(hit, theme));
|
|
75
|
+
}
|
|
76
|
+
lines.push(
|
|
77
|
+
theme.fg(
|
|
78
|
+
"dim",
|
|
79
|
+
`listed ${stats.filesListed} · judged ${stats.judged} · read ${stats.filesRead} files · ${stats.requests} requests · ${stats.inputTokens + stats.outputTokens} tokens · $${stats.cost.toFixed(4)} · ${(elapsedMs / 1000).toFixed(1)}s wall / ${(stats.apiMs / 1000).toFixed(1)}s api${stats.errors > 0 ? ` · ${stats.errors} errors` : ""}`,
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
return component(() => lines);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hitLines(hit: FindHit, theme: ThemeLike): string[] {
|
|
86
|
+
const out: string[] = [];
|
|
87
|
+
const score = hit.contentScore.toFixed(2);
|
|
88
|
+
const coverage = hit.truncated ? `${hit.linesSeen} lines judged, partial` : `${hit.linesSeen} lines judged`;
|
|
89
|
+
out.push(`${theme.fg("accent", sanitize(hit.rel))} ${heat(theme, hit.contentScore, score)} ${theme.fg("dim", coverage)}`);
|
|
90
|
+
for (const range of rankedHeat(hit.ranges, RANGES_SHOWN)) {
|
|
91
|
+
const span = range.start === range.end ? String(range.start) : `${range.start}-${range.end}`;
|
|
92
|
+
out.push(
|
|
93
|
+
` ${theme.fg("dim", sanitize(`${hit.rel}:${span}`))} ${heat(theme, range.p, `${gauge(range.p)} ${range.p.toFixed(2)}`)} ${sanitize(range.snippet)}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
package/src/rg.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin ripgrep runner shared by the lexical scan and the file listing — the
|
|
3
|
+
* pi-jev-find replacement for omp's `@oh-my-pi/pi-natives` grep/glob (MIT,
|
|
4
|
+
* oh-my-pi). `rg` is the same engine both projects ultimately rely on.
|
|
5
|
+
*
|
|
6
|
+
* Hardening: `RIPGREP_CONFIG_PATH` is cleared so a user config cannot inject
|
|
7
|
+
* flags that break the `--json`/`--files` contracts; runs are killed on abort
|
|
8
|
+
* signal or timeout; stderr is capped.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
export type RgFailure = "missing" | "failed" | "timeout" | "aborted";
|
|
15
|
+
|
|
16
|
+
export class RgError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
readonly kind: RgFailure,
|
|
19
|
+
message: string,
|
|
20
|
+
) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "RgError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RgRun {
|
|
27
|
+
/** 0 = matches, 1 = no matches (both success). */
|
|
28
|
+
exitCode: number;
|
|
29
|
+
/** Raw stdout bytes — callers decode (`--json` lines or `--files --null` paths). */
|
|
30
|
+
stdout: Buffer;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RgOptions {
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run `rg` in `root` and collect stdout. Rejects with {@link RgError}:
|
|
40
|
+
* `missing` when rg is not on PATH, `failed` on exit code > 1 (rg: 2), and
|
|
41
|
+
* `timeout`/`aborted` when the run was killed.
|
|
42
|
+
*/
|
|
43
|
+
export async function runRg(root: string, args: readonly string[], options: RgOptions = {}): Promise<RgRun> {
|
|
44
|
+
if (options.signal?.aborted) throw new RgError("aborted", "aborted");
|
|
45
|
+
const child = spawn("rg", args, {
|
|
46
|
+
cwd: root,
|
|
47
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
48
|
+
env: { ...process.env, RIPGREP_CONFIG_PATH: "" },
|
|
49
|
+
});
|
|
50
|
+
const chunks: Buffer[] = [];
|
|
51
|
+
let stderr = "";
|
|
52
|
+
let killed: "timeout" | "aborted" | null = null;
|
|
53
|
+
const closed = Promise.withResolvers<number | null>();
|
|
54
|
+
|
|
55
|
+
child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
56
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
57
|
+
if (stderr.length < 4096) stderr += chunk.toString("utf8");
|
|
58
|
+
});
|
|
59
|
+
child.once("error", error => {
|
|
60
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
61
|
+
if (code === "ENOENT") {
|
|
62
|
+
closed.reject(new RgError("missing", "ripgrep (rg) is required but was not found on PATH"));
|
|
63
|
+
} else {
|
|
64
|
+
closed.reject(new RgError("failed", `rg failed to start: ${error.message}`));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
child.once("close", code => closed.resolve(code));
|
|
68
|
+
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
killed = "timeout";
|
|
71
|
+
child.kill("SIGKILL");
|
|
72
|
+
}, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
73
|
+
const onAbort = () => {
|
|
74
|
+
killed = "aborted";
|
|
75
|
+
child.kill("SIGKILL");
|
|
76
|
+
};
|
|
77
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const code = await closed.promise;
|
|
81
|
+
if (killed === "aborted" || options.signal?.aborted) throw new RgError("aborted", "aborted");
|
|
82
|
+
if (killed === "timeout") throw new RgError("timeout", `rg timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`);
|
|
83
|
+
if (code === null) throw new RgError("failed", `rg terminated unexpectedly: ${stderr.trim()}`);
|
|
84
|
+
const stdout = Buffer.concat(chunks);
|
|
85
|
+
// Exit 2 with a completed search cycle (a `summary` event was emitted) means
|
|
86
|
+
// rg finished but found nothing searchable ("No files were searched") or hit
|
|
87
|
+
// per-file traversal errors; both are empty results, not tool failures.
|
|
88
|
+
if (code > 1 && !stdout.includes('"summary"')) {
|
|
89
|
+
throw new RgError("failed", `rg exited ${code}: ${stderr.trim()}`);
|
|
90
|
+
}
|
|
91
|
+
return { exitCode: code, stdout };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
child.kill("SIGKILL");
|
|
94
|
+
throw error;
|
|
95
|
+
} finally {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared result shapes. These mirror the omp `@oh-my-pi/pi-tui/tools/find`
|
|
3
|
+
* types (MIT, oh-my-pi) so the cascade port stays shape-compatible.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A judged line range: 1-based inclusive lines, yes-probability, one-line preview. */
|
|
7
|
+
export interface FindRange {
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
p: number;
|
|
11
|
+
snippet: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One file hit returned by the cascade. */
|
|
15
|
+
export interface FindHit {
|
|
16
|
+
/** Root-relative display path with `/` separators. */
|
|
17
|
+
rel: string;
|
|
18
|
+
/** Name-judge probability when the filename was judged; undefined for lexical champions. */
|
|
19
|
+
nameScore?: number;
|
|
20
|
+
/** Best passage score across judged windows. */
|
|
21
|
+
contentScore: number;
|
|
22
|
+
/** Judged ranges, strongest first. */
|
|
23
|
+
ranges: FindRange[];
|
|
24
|
+
/** Lines actually inspected. */
|
|
25
|
+
linesSeen: number;
|
|
26
|
+
/** Whether the read was cut at the byte cap. */
|
|
27
|
+
truncated: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Parameters of the find tool, matching the typebox schema in index.ts. */
|
|
31
|
+
export interface FindToolParams {
|
|
32
|
+
query: string;
|
|
33
|
+
grep_keywords: string[];
|
|
34
|
+
path?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Everything the renderer and the tool result need besides the raw digest. */
|
|
38
|
+
export interface FindDetails {
|
|
39
|
+
query: string;
|
|
40
|
+
keywords: string[];
|
|
41
|
+
threshold: number;
|
|
42
|
+
hits: FindHit[];
|
|
43
|
+
stats: FindStats;
|
|
44
|
+
elapsedMs: number;
|
|
45
|
+
cwd: string;
|
|
46
|
+
/** Display form of the search scope when narrower than cwd, with trailing slash. */
|
|
47
|
+
scopePath?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Cascade accounting surfaced to the renderer and stats footer. */
|
|
51
|
+
export interface FindStats {
|
|
52
|
+
filesListed: number;
|
|
53
|
+
requests: number;
|
|
54
|
+
errors: number;
|
|
55
|
+
judged: number;
|
|
56
|
+
filesRead: number;
|
|
57
|
+
fileBytes: number;
|
|
58
|
+
inputTokens: number;
|
|
59
|
+
outputTokens: number;
|
|
60
|
+
cost: number;
|
|
61
|
+
apiMs: number;
|
|
62
|
+
windowsJudged: number;
|
|
63
|
+
windowsPruned: number;
|
|
64
|
+
mapCards: number;
|
|
65
|
+
failures: string[];
|
|
66
|
+
}
|