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,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The searchable file set and its model-facing listing. Files are listed once
|
|
3
|
+
* up front (the cascade judges every candidate by name before reading any), so
|
|
4
|
+
* the tree is a flat list of eligible files rather than a lazily expanded
|
|
5
|
+
* directory graph. Eligibility deny-lists build noise, lockfiles, binaries, and
|
|
6
|
+
* obvious credential material.
|
|
7
|
+
*
|
|
8
|
+
* Ported from oh-my-pi `packages/coding-agent/src/tools/jfind/tree.ts` (MIT):
|
|
9
|
+
* the native glob backend is replaced by `rg --files`, and the prefix-folded
|
|
10
|
+
* tree renderer is local (omp uses `@oh-my-pi/pi-utils` walkPathTree).
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs/promises";
|
|
13
|
+
import { runRg } from "../rg.ts";
|
|
14
|
+
|
|
15
|
+
/** One eligible file under the search root. */
|
|
16
|
+
export interface FileEntry {
|
|
17
|
+
/** Absolute path. */
|
|
18
|
+
path: string;
|
|
19
|
+
/** Root-relative display path with `/` separators. */
|
|
20
|
+
rel: string;
|
|
21
|
+
size: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DENY_DIRS: Record<string, true> = {
|
|
25
|
+
".git": true,
|
|
26
|
+
node_modules: true,
|
|
27
|
+
target: true,
|
|
28
|
+
dist: true,
|
|
29
|
+
build: true,
|
|
30
|
+
out: true,
|
|
31
|
+
".next": true,
|
|
32
|
+
".nuxt": true,
|
|
33
|
+
".turbo": true,
|
|
34
|
+
".cache": true,
|
|
35
|
+
__pycache__: true,
|
|
36
|
+
".venv": true,
|
|
37
|
+
venv: true,
|
|
38
|
+
".tox": true,
|
|
39
|
+
coverage: true,
|
|
40
|
+
".idea": true,
|
|
41
|
+
".vscode": true,
|
|
42
|
+
".gradle": true,
|
|
43
|
+
".mypy_cache": true,
|
|
44
|
+
".pytest_cache": true,
|
|
45
|
+
".ruff_cache": true,
|
|
46
|
+
".parcel-cache": true,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const DENY_FILES: Record<string, true> = {
|
|
50
|
+
"Cargo.lock": true,
|
|
51
|
+
"package-lock.json": true,
|
|
52
|
+
"yarn.lock": true,
|
|
53
|
+
"pnpm-lock.yaml": true,
|
|
54
|
+
"bun.lock": true,
|
|
55
|
+
"bun.lockb": true,
|
|
56
|
+
"poetry.lock": true,
|
|
57
|
+
"Pipfile.lock": true,
|
|
58
|
+
"composer.lock": true,
|
|
59
|
+
"Gemfile.lock": true,
|
|
60
|
+
"go.sum": true,
|
|
61
|
+
"flake.lock": true,
|
|
62
|
+
".DS_Store": true,
|
|
63
|
+
"Thumbs.db": true,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Credential files by exact name. Never listed or read, even when hidden files are included. */
|
|
67
|
+
const SECRET_FILES: Record<string, true> = {
|
|
68
|
+
".env": true,
|
|
69
|
+
".envrc": true,
|
|
70
|
+
".netrc": true,
|
|
71
|
+
".npmrc": true,
|
|
72
|
+
".pypirc": true,
|
|
73
|
+
".pgpass": true,
|
|
74
|
+
".boto": true,
|
|
75
|
+
".s3cfg": true,
|
|
76
|
+
".dockercfg": true,
|
|
77
|
+
".git-credentials": true,
|
|
78
|
+
".htpasswd": true,
|
|
79
|
+
htpasswd: true,
|
|
80
|
+
credentials: true,
|
|
81
|
+
"credentials.json": true,
|
|
82
|
+
"client_secret.json": true,
|
|
83
|
+
"service-account.json": true,
|
|
84
|
+
id_rsa: true,
|
|
85
|
+
id_dsa: true,
|
|
86
|
+
id_ecdsa: true,
|
|
87
|
+
id_ed25519: true,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Credential files by extension: keys, certificate stores, encrypted vaults, and infrastructure state that embeds secrets. */
|
|
91
|
+
const SECRET_EXT = [
|
|
92
|
+
"pem",
|
|
93
|
+
"key",
|
|
94
|
+
"p12",
|
|
95
|
+
"pfx",
|
|
96
|
+
"jks",
|
|
97
|
+
"keystore",
|
|
98
|
+
"bks",
|
|
99
|
+
"ppk",
|
|
100
|
+
"kdbx",
|
|
101
|
+
"gpg",
|
|
102
|
+
"pgp",
|
|
103
|
+
"asc",
|
|
104
|
+
"der",
|
|
105
|
+
"crt",
|
|
106
|
+
"cer",
|
|
107
|
+
"tfvars",
|
|
108
|
+
"tfvars.json",
|
|
109
|
+
"tfstate",
|
|
110
|
+
"tfstate.backup",
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const BINARY_EXT = [
|
|
114
|
+
"png",
|
|
115
|
+
"jpg",
|
|
116
|
+
"jpeg",
|
|
117
|
+
"gif",
|
|
118
|
+
"webp",
|
|
119
|
+
"avif",
|
|
120
|
+
"ico",
|
|
121
|
+
"bmp",
|
|
122
|
+
"tiff",
|
|
123
|
+
"psd",
|
|
124
|
+
"svg",
|
|
125
|
+
"woff",
|
|
126
|
+
"woff2",
|
|
127
|
+
"ttf",
|
|
128
|
+
"otf",
|
|
129
|
+
"eot",
|
|
130
|
+
"zip",
|
|
131
|
+
"gz",
|
|
132
|
+
"tgz",
|
|
133
|
+
"tar",
|
|
134
|
+
"bz2",
|
|
135
|
+
"xz",
|
|
136
|
+
"zst",
|
|
137
|
+
"7z",
|
|
138
|
+
"rar",
|
|
139
|
+
"pdf",
|
|
140
|
+
"mp3",
|
|
141
|
+
"mp4",
|
|
142
|
+
"mov",
|
|
143
|
+
"avi",
|
|
144
|
+
"mkv",
|
|
145
|
+
"wav",
|
|
146
|
+
"ogg",
|
|
147
|
+
"flac",
|
|
148
|
+
"wasm",
|
|
149
|
+
"so",
|
|
150
|
+
"dylib",
|
|
151
|
+
"dll",
|
|
152
|
+
"exe",
|
|
153
|
+
"o",
|
|
154
|
+
"a",
|
|
155
|
+
"class",
|
|
156
|
+
"jar",
|
|
157
|
+
"pyc",
|
|
158
|
+
"pyo",
|
|
159
|
+
"bin",
|
|
160
|
+
"dat",
|
|
161
|
+
"db",
|
|
162
|
+
"sqlite",
|
|
163
|
+
"sqlite3",
|
|
164
|
+
"lock",
|
|
165
|
+
"map",
|
|
166
|
+
"min.js",
|
|
167
|
+
"min.css",
|
|
168
|
+
"snap",
|
|
169
|
+
"pb",
|
|
170
|
+
"onnx",
|
|
171
|
+
"safetensors",
|
|
172
|
+
"parquet",
|
|
173
|
+
"arrow",
|
|
174
|
+
"ipynb",
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
const ENV_TEMPLATES: Record<string, true> = {
|
|
178
|
+
".env.example": true,
|
|
179
|
+
".env.sample": true,
|
|
180
|
+
".env.template": true,
|
|
181
|
+
".env.dist": true,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** `lower` ends with `.<ext>` for some `ext` in `exts`. */
|
|
185
|
+
function hasExt(lower: string, exts: readonly string[]): boolean {
|
|
186
|
+
return exts.some(ext => lower.length > ext.length && lower.endsWith(`.${ext}`));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Credential material: exact names, `.env.*` variants (except committed templates), and key/vault extensions. */
|
|
190
|
+
function secret(name: string): boolean {
|
|
191
|
+
if (Object.hasOwn(SECRET_FILES, name)) return true;
|
|
192
|
+
if (name.startsWith(".env.")) return !Object.hasOwn(ENV_TEMPLATES, name);
|
|
193
|
+
return hasExt(name.toLowerCase(), SECRET_EXT);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Whether a root-relative regular file is searchable. */
|
|
197
|
+
export function eligibleFile(rel: string, size: number, includeHidden: boolean): boolean {
|
|
198
|
+
if (size <= 0) return false;
|
|
199
|
+
const segments = rel.split("/");
|
|
200
|
+
const name = segments[segments.length - 1]!;
|
|
201
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
202
|
+
const dir = segments[i]!;
|
|
203
|
+
if (Object.hasOwn(DENY_DIRS, dir) || (!includeHidden && dir.startsWith("."))) return false;
|
|
204
|
+
}
|
|
205
|
+
if (!includeHidden && name.startsWith(".")) return false;
|
|
206
|
+
return !Object.hasOwn(DENY_FILES, name) && !secret(name) && !hasExt(name.toLowerCase(), BINARY_EXT);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface ListFilesOptions {
|
|
210
|
+
includeHidden: boolean;
|
|
211
|
+
signal?: AbortSignal;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Stats are I/O only; batch them instead of serializing thousands of round trips. */
|
|
215
|
+
const STAT_BATCH = 128;
|
|
216
|
+
|
|
217
|
+
async function statSizes(root: string, rels: readonly string[]): Promise<Map<string, number>> {
|
|
218
|
+
const sizes = new Map<string, number>();
|
|
219
|
+
for (let offset = 0; offset < rels.length; offset += STAT_BATCH) {
|
|
220
|
+
const batch = rels.slice(offset, offset + STAT_BATCH);
|
|
221
|
+
const stats = await Promise.allSettled(
|
|
222
|
+
batch.map(rel => fs.stat(`${root}/${rel}`)),
|
|
223
|
+
);
|
|
224
|
+
for (let i = 0; i < stats.length; i++) {
|
|
225
|
+
const result = stats[i]!;
|
|
226
|
+
if (result.status === "fulfilled" && result.value.isFile()) {
|
|
227
|
+
sizes.set(batch[i]!, result.value.size);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return sizes;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Every eligible, non-gitignored regular file under `root`, in path order.
|
|
236
|
+
* Symlinks are never followed (rg does not list them without `-L`).
|
|
237
|
+
*/
|
|
238
|
+
export async function listFiles(root: string, options: ListFilesOptions): Promise<FileEntry[]> {
|
|
239
|
+
const run = await runRg(
|
|
240
|
+
root,
|
|
241
|
+
["--files", "--null", "--no-messages", ...(options.includeHidden ? ["--hidden"] : [])],
|
|
242
|
+
{ signal: options.signal },
|
|
243
|
+
);
|
|
244
|
+
const rels = run.stdout
|
|
245
|
+
.subarray(0, run.stdout.length > 0 && run.stdout[run.stdout.length - 1] === 0 ? run.stdout.length - 1 : run.stdout.length)
|
|
246
|
+
.toString("utf8")
|
|
247
|
+
.split("\0")
|
|
248
|
+
.filter(rel => rel.length > 0);
|
|
249
|
+
const sizes = await statSizes(root, rels);
|
|
250
|
+
const entries: FileEntry[] = [];
|
|
251
|
+
for (const rel of rels) {
|
|
252
|
+
const size = sizes.get(rel);
|
|
253
|
+
if (size === undefined || !eligibleFile(rel, size, options.includeHidden)) continue;
|
|
254
|
+
entries.push({ path: `${root}/${rel}`, rel, size });
|
|
255
|
+
}
|
|
256
|
+
entries.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
257
|
+
return entries;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"];
|
|
261
|
+
|
|
262
|
+
/** `812 B`, `3.4 KB`, … */
|
|
263
|
+
export function humanSize(bytes: number): string {
|
|
264
|
+
let value = bytes;
|
|
265
|
+
let unit = 0;
|
|
266
|
+
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
|
|
267
|
+
value /= 1024;
|
|
268
|
+
unit++;
|
|
269
|
+
}
|
|
270
|
+
return unit === 0 ? `${bytes} B` : `${value.toFixed(1)} ${SIZE_UNITS[unit]}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Model-facing listing of `entries` as a prefix-folded directory tree: one `#`
|
|
275
|
+
* per depth, `# dir/` headers, and every file line tagged with its question key
|
|
276
|
+
* (`# e017 name (size)`), with a blank line before every directory header and
|
|
277
|
+
* every root-level file after the first line.
|
|
278
|
+
*/
|
|
279
|
+
export function renderTree(entries: readonly FileEntry[], tagOf: (index: number) => string): string {
|
|
280
|
+
let out = "";
|
|
281
|
+
let emitted = false;
|
|
282
|
+
/** Directory segments currently active in the emitted header stack. */
|
|
283
|
+
const stack: string[] = [];
|
|
284
|
+
for (let index = 0; index < entries.length; index++) {
|
|
285
|
+
const segments = entries[index]!.rel.split("/");
|
|
286
|
+
const dirCount = segments.length - 1;
|
|
287
|
+
let shared = 0;
|
|
288
|
+
while (shared < dirCount && shared < stack.length && stack[shared] === segments[shared]) shared++;
|
|
289
|
+
for (let depth = shared; depth < dirCount; depth++) {
|
|
290
|
+
if (emitted) out += "\n";
|
|
291
|
+
emitted = true;
|
|
292
|
+
out += `${"#".repeat(depth + 1)} ${segments[depth]}/\n`;
|
|
293
|
+
}
|
|
294
|
+
stack.length = dirCount;
|
|
295
|
+
for (let depth = shared; depth < dirCount; depth++) stack[depth] = segments[depth]!;
|
|
296
|
+
if (emitted && dirCount === 0) out += "\n";
|
|
297
|
+
emitted = true;
|
|
298
|
+
const hashes = "#".repeat(dirCount + 1);
|
|
299
|
+
out += `${hashes} ${tagOf(index)} ${segments[dirCount]!} (${humanSize(entries[index]!.size)})\n`;
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-driven configuration. Every knob is optional; defaults mirror
|
|
3
|
+
* the jegrep / oh-my-pi jfind cascade budgets.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface Budgets {
|
|
7
|
+
/** Requests in flight per dispatched phase. */
|
|
8
|
+
concurrency: number;
|
|
9
|
+
/** Files per filename-ranking request. */
|
|
10
|
+
nameBatch: number;
|
|
11
|
+
/** Lexically ranked files that receive a filename judgment. */
|
|
12
|
+
candidates: number;
|
|
13
|
+
/** Files whose content is read and sketched. */
|
|
14
|
+
files: number;
|
|
15
|
+
/** Windows kept per read file. */
|
|
16
|
+
windows: number;
|
|
17
|
+
/** Bytes per passage window, tags included. */
|
|
18
|
+
windowBytes: number;
|
|
19
|
+
/** Bytes per sketch card. */
|
|
20
|
+
sketchBytes: number;
|
|
21
|
+
/** Complete passages verified across all files. */
|
|
22
|
+
fullLimit: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_BUDGETS: Budgets = {
|
|
26
|
+
concurrency: 16,
|
|
27
|
+
nameBatch: 64,
|
|
28
|
+
candidates: 128,
|
|
29
|
+
files: 20,
|
|
30
|
+
windows: 24,
|
|
31
|
+
windowBytes: 8192,
|
|
32
|
+
sketchBytes: 384,
|
|
33
|
+
fullLimit: 40,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface JfConfig {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
budgets: Budgets;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readInt(env: Record<string, string | undefined>, name: string, fallback: number, min: number, max: number): number {
|
|
42
|
+
const raw = env[name];
|
|
43
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
44
|
+
const value = Number.parseInt(raw, 10);
|
|
45
|
+
if (!Number.isFinite(value)) return fallback;
|
|
46
|
+
return Math.min(max, Math.max(min, value));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loadConfig(env: Record<string, string | undefined> = process.env): JfConfig {
|
|
50
|
+
const budgets = { ...DEFAULT_BUDGETS };
|
|
51
|
+
const b = budgets as { [K in keyof Budgets]: number };
|
|
52
|
+
for (const key of Object.keys(DEFAULT_BUDGETS) as (keyof Budgets)[]) {
|
|
53
|
+
b[key] = readInt(env, `JF_${key.toUpperCase()}`, DEFAULT_BUDGETS[key], 1, 10_000);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
enabled: env.JF_ENABLED !== "0",
|
|
57
|
+
budgets,
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-jev-find extension entry: registers the `find` tool (semantic grep with a
|
|
3
|
+
* judge-calibrated cascade) and a `/find` status command.
|
|
4
|
+
*
|
|
5
|
+
* The tool contract and model-facing digest are ported from oh-my-pi
|
|
6
|
+
* `packages/coding-agent/src/tools/jfind/index.ts` (MIT); the judge is the
|
|
7
|
+
* native Jev probability API (System One), configured purely through
|
|
8
|
+
* environment variables: JEV_API_KEY / JEV_BASE_URL / JEV_MODEL.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs/promises";
|
|
11
|
+
import type { Stats } from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { runCascade } from "./cascade/cascade.ts";
|
|
16
|
+
import { rankedHeat } from "./cascade/passages.ts";
|
|
17
|
+
import { loadConfig } from "./config.ts";
|
|
18
|
+
import { JevJudge, resolveJevConfig } from "./judge/jev-judge.ts";
|
|
19
|
+
import { renderFindCall, renderFindResult } from "./render.ts";
|
|
20
|
+
import type { FindDetails, FindToolParams } from "./types.ts";
|
|
21
|
+
|
|
22
|
+
const parameters = Type.Object({
|
|
23
|
+
query: Type.String({
|
|
24
|
+
description: "what to find, in plain language (concept or behavior, not a regex)",
|
|
25
|
+
}),
|
|
26
|
+
grep_keywords: Type.Array(Type.String(), {
|
|
27
|
+
description:
|
|
28
|
+
"identifiers or terms likely to appear verbatim in matching source; steer lexical pre-ranking. [] when unsure",
|
|
29
|
+
}),
|
|
30
|
+
path: Type.Optional(Type.String({ description: "directory to search. Omitted -> the workspace root" })),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const DESCRIPTION = `Semantic grep: describe what you are looking for in plain language; returns the files and line ranges that implement it, each with a calibrated 0-1 relevance score. No index; searches the live workspace tree on every call.
|
|
34
|
+
|
|
35
|
+
- \`query\`: a concept or behavior ("where do we verify JWT tokens?", "retry budget for failed requests"), not a regex. Quoted phrases in \`query\` are matched whole.
|
|
36
|
+
- \`grep_keywords\`: identifiers, symbols, or terms likely to appear verbatim in matching source; they steer the lexical pre-ranking. Pass \`[]\` when nothing specific comes to mind.
|
|
37
|
+
- \`path\`: one directory to search; omit for the workspace root. Narrow it when you already know the subsystem.
|
|
38
|
+
- Results are strongest first as \`path:start-end score snippet\`; open ranges with \`read\`.
|
|
39
|
+
- Scores are absolute yes/no probabilities: comparable across calls; below ~0.4 is weak evidence, so widen the query or fall back to \`grep\` before concluding absence.
|
|
40
|
+
- \`grep\` is for exact strings, regexes, and known symbols; \`glob\` is for file names. Reach for them after \`find\` has narrowed the files, or when the target is literally a string.
|
|
41
|
+
- Every call spends judge requests over the search scope; batch related questions into one descriptive \`query\` instead of many narrow calls.`;
|
|
42
|
+
|
|
43
|
+
const PROMPT_SNIPPET =
|
|
44
|
+
"find: semantic search — describe a behavior in plain language, get files and calibrated line ranges that implement it";
|
|
45
|
+
|
|
46
|
+
const PROMPT_GUIDELINES = [
|
|
47
|
+
"When you do not already know where a behavior lives, call `find` once with a descriptive query instead of chaining guessed `grep` patterns and `glob` sweeps followed by speculative reads.",
|
|
48
|
+
"`grep` and `glob` come after `find` has narrowed the files, or when the target is an exact string or file name.",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Line ranges shown per hit in the model-facing text, strongest first. */
|
|
52
|
+
const RANGES_SHOWN = 3;
|
|
53
|
+
|
|
54
|
+
function toDisplay(rel: string, root: string, cwd: string): string {
|
|
55
|
+
const relative = path.relative(cwd, path.join(root, rel));
|
|
56
|
+
if (relative.startsWith("..")) return rel;
|
|
57
|
+
return relative.split(path.sep).join("/");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function resolveRoot(rawPath: string | undefined, cwd: string): Promise<string> {
|
|
61
|
+
const input = (rawPath ?? "").trim();
|
|
62
|
+
if (input.length === 0) return path.resolve(cwd);
|
|
63
|
+
const root = path.resolve(cwd, input);
|
|
64
|
+
let stat: Stats;
|
|
65
|
+
try {
|
|
66
|
+
stat = await fs.stat(root);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`Path not found: ${input}`);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
if (!stat.isDirectory()) throw new Error(`Path is not a directory: ${input}`);
|
|
72
|
+
return root;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatBytes(bytes: number): string {
|
|
76
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
77
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
78
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatDuration(ms: number): string {
|
|
82
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatNumber(value: number): string {
|
|
86
|
+
return value.toLocaleString("en-US");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatDigest(
|
|
90
|
+
query: string,
|
|
91
|
+
scopePath: string | undefined,
|
|
92
|
+
details: FindDetails,
|
|
93
|
+
stats: FindDetails["stats"],
|
|
94
|
+
): string {
|
|
95
|
+
const where = scopePath === undefined ? "" : ` in ${scopePath}`;
|
|
96
|
+
const out: string[] = [];
|
|
97
|
+
if (details.hits.length === 0) {
|
|
98
|
+
out.push(`no hits for "${query}"${where} (τ ${details.threshold.toFixed(2)})`);
|
|
99
|
+
} else {
|
|
100
|
+
out.push(
|
|
101
|
+
`${details.hits.length} hit(s) for "${query}"${where} (τ ${details.threshold.toFixed(2)}), strongest first`,
|
|
102
|
+
"",
|
|
103
|
+
);
|
|
104
|
+
for (const hit of details.hits) {
|
|
105
|
+
const coverage = hit.truncated
|
|
106
|
+
? `${hit.linesSeen} lines judged, partial`
|
|
107
|
+
: `${hit.linesSeen} lines judged`;
|
|
108
|
+
out.push(`${hit.rel} ${hit.contentScore.toFixed(2)} ${coverage}`);
|
|
109
|
+
for (const range of rankedHeat(hit.ranges, RANGES_SHOWN)) {
|
|
110
|
+
const span = range.start === range.end ? String(range.start) : `${range.start}-${range.end}`;
|
|
111
|
+
out.push(` ${hit.rel}:${span} ${range.p.toFixed(2)} ${range.snippet}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
out.push(
|
|
116
|
+
"",
|
|
117
|
+
`listed ${stats.filesListed} · judged ${stats.judged} · read ${stats.filesRead} files (${formatBytes(stats.fileBytes)}) · ${stats.requests} requests · ${formatNumber(stats.inputTokens)} tokens · $${stats.cost.toFixed(4)} · ${formatDuration(details.elapsedMs)} wall / ${formatDuration(stats.apiMs)} api`,
|
|
118
|
+
);
|
|
119
|
+
if (stats.failures.length > 0) {
|
|
120
|
+
out.push(`${stats.errors} of ${stats.requests} requests failed:`, ...stats.failures.map(failure => ` ${failure}`));
|
|
121
|
+
}
|
|
122
|
+
return out.join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export default function jfindExtension(pi: ExtensionAPI): void {
|
|
126
|
+
const config = loadConfig();
|
|
127
|
+
if (!config.enabled) return;
|
|
128
|
+
|
|
129
|
+
pi.registerTool({
|
|
130
|
+
name: "find",
|
|
131
|
+
label: "Find",
|
|
132
|
+
description: DESCRIPTION,
|
|
133
|
+
promptSnippet: PROMPT_SNIPPET,
|
|
134
|
+
promptGuidelines: PROMPT_GUIDELINES,
|
|
135
|
+
parameters,
|
|
136
|
+
async execute(_toolCallId, params: FindToolParams, signal, onUpdate, ctx) {
|
|
137
|
+
const query = params.query.trim();
|
|
138
|
+
if (query.length === 0) throw new Error("`query` must be a non-empty description");
|
|
139
|
+
const root = await resolveRoot(params.path, ctx.cwd);
|
|
140
|
+
const scopePath =
|
|
141
|
+
root === path.resolve(ctx.cwd)
|
|
142
|
+
? undefined
|
|
143
|
+
: `${path.relative(ctx.cwd, root).split(path.sep).join("/")}/`;
|
|
144
|
+
const jev = resolveJevConfig();
|
|
145
|
+
const judge = new JevJudge(jev);
|
|
146
|
+
const started = performance.now();
|
|
147
|
+
const result = await runCascade({
|
|
148
|
+
root,
|
|
149
|
+
query,
|
|
150
|
+
extraKeywords: params.grep_keywords,
|
|
151
|
+
judge,
|
|
152
|
+
includeHidden: false,
|
|
153
|
+
budgets: config.budgets,
|
|
154
|
+
signal,
|
|
155
|
+
onProgress: message =>
|
|
156
|
+
onUpdate?.({ content: [{ type: "text", text: message }] } as AgentToolResult<FindDetails>),
|
|
157
|
+
});
|
|
158
|
+
const elapsedMs = performance.now() - started;
|
|
159
|
+
const { stats, threshold, keywords } = result;
|
|
160
|
+
const hits = result.hits.map(hit => ({ ...hit, rel: toDisplay(hit.rel, root, ctx.cwd) }));
|
|
161
|
+
const details: FindDetails = { query, keywords, threshold, hits, stats, elapsedMs, cwd: ctx.cwd, scopePath };
|
|
162
|
+
const digest = formatDigest(query, scopePath, details, stats);
|
|
163
|
+
if (stats.requests > 0 && stats.errors === stats.requests) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`find failed — all ${stats.requests} judge requests failed:\n${stats.failures.join("\n") || "unknown errors"}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: "text", text: digest }],
|
|
170
|
+
details,
|
|
171
|
+
usage: {
|
|
172
|
+
input: stats.inputTokens,
|
|
173
|
+
output: stats.outputTokens,
|
|
174
|
+
cacheRead: 0,
|
|
175
|
+
cacheWrite: 0,
|
|
176
|
+
totalTokens: stats.inputTokens + stats.outputTokens,
|
|
177
|
+
cost: {
|
|
178
|
+
input: 0,
|
|
179
|
+
output: 0,
|
|
180
|
+
cacheRead: 0,
|
|
181
|
+
cacheWrite: 0,
|
|
182
|
+
total: stats.cost,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
renderCall(args, theme) {
|
|
188
|
+
return renderFindCall(args, theme);
|
|
189
|
+
},
|
|
190
|
+
renderResult(result, options, theme) {
|
|
191
|
+
const details = result.details as FindDetails | undefined;
|
|
192
|
+
const isError = (result as { isError?: boolean }).isError === true;
|
|
193
|
+
return renderFindResult(details, isError, options.expanded, theme);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
pi.registerCommand("find", {
|
|
198
|
+
description: "pi-jev-find: show the resolved Jev judge and cascade budgets",
|
|
199
|
+
handler: async (_args, ctx) => {
|
|
200
|
+
const current = loadConfig();
|
|
201
|
+
try {
|
|
202
|
+
const jev = resolveJevConfig();
|
|
203
|
+
ctx.ui.notify(`pi-jev-find judge: jev ${jev.model} @ ${jev.baseUrl} (key from ${jev.keySource})`, "info");
|
|
204
|
+
} catch (error) {
|
|
205
|
+
ctx.ui.notify(`pi-jev-find: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
ctx.ui.notify(
|
|
209
|
+
`budgets: candidates=${current.budgets.candidates} files=${current.budgets.files} windows=${current.budgets.windows} windowBytes=${current.budgets.windowBytes} sketchBytes=${current.budgets.sketchBytes} fullLimit=${current.budgets.fullLimit} concurrency=${current.budgets.concurrency}`,
|
|
210
|
+
"info",
|
|
211
|
+
);
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|