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
package/LICENSE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-jfind contributors
|
|
4
|
+
|
|
5
|
+
Portions derived from:
|
|
6
|
+
- oh-my-pi (https://github.com/can1357/oh-my-pi), MIT License (c) 2025 Can Bölük —
|
|
7
|
+
the jfind cascade (packages/coding-agent/src/tools/jfind).
|
|
8
|
+
- jegrep (https://github.com/can1357/jegrep), MIT License (c) 2026 Can Bölük —
|
|
9
|
+
the cascade budget design.
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# pi-jev-find
|
|
2
|
+
|
|
3
|
+
A semantic **find** tool for the [pi coding agent](https://github.com/earendil-works/pi-coding-agent) — a standalone port of the [oh-my-pi](https://github.com/can1357/oh-my-pi) `jfind` cascade (itself derived from [jegrep](https://github.com/can1357/jegrep)).
|
|
4
|
+
|
|
5
|
+
`find` answers *"where does this codebase implement X?"* in one call: a four-stage cascade (lexical scan → filename ranking → passage judging → full-file verification) that uses ripgrep for the cheap stages and the **Jev** probability API ([TypeSafe System One](https://typesafe.ai)) for judgment, returning calibrated line ranges instead of keyword soup.
|
|
6
|
+
|
|
7
|
+
The judge is the native System One API — the same wire jegrep uses — so probabilities are absolute and calibrated, not emulated through a chat model.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pi install npm:pi-jev-find
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
or in a single project, copy/symlink `src/` under `.pi/extensions/` and run `/reload`.
|
|
16
|
+
|
|
17
|
+
Requires `rg` (ripgrep) on your `PATH`.
|
|
18
|
+
|
|
19
|
+
## What the model sees
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
find(query: "where do we validate webhook signatures?", max: 8)
|
|
23
|
+
→
|
|
24
|
+
src/billing/webhooks.ts
|
|
25
|
+
41-89 0.94 hmac verification + timestamp tolerance
|
|
26
|
+
212-248 0.71 retry queue re-checks signature on replay
|
|
27
|
+
src/lib/crypto/sign.ts
|
|
28
|
+
...
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Each hit carries judge-calibrated probabilities; ranges are exact line spans, ready for a follow-up `read`.
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
All knobs are environment variables; all are optional except the API key.
|
|
36
|
+
|
|
37
|
+
### Judge endpoint
|
|
38
|
+
|
|
39
|
+
| Variable | Fallback (jegrep-compatible) | Default | Meaning |
|
|
40
|
+
| --- | --- | --- | --- |
|
|
41
|
+
| `JEV_API_KEY` | `TYPESAFE_API_KEY` | — (required) | Bearer key for the System One API. |
|
|
42
|
+
| `JEV_BASE_URL` | `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | API root. |
|
|
43
|
+
| `JEV_MODEL` | `TYPESAFE_DEFAULT_MODEL` | `jev-latest` | Judgment model. |
|
|
44
|
+
|
|
45
|
+
### Cascade budgets
|
|
46
|
+
|
|
47
|
+
| Variable | Default | Meaning |
|
|
48
|
+
| --- | --- | --- |
|
|
49
|
+
| `JF_ENABLED` | `1` | `0` disables the tool registration entirely. |
|
|
50
|
+
| `JF_CANDIDATES` | `128` | Max candidate files kept after the lexical scan. |
|
|
51
|
+
| `JF_FILES` | `20` | Max files whose names are judged. |
|
|
52
|
+
| `JF_WINDOWS` | `24` | Max passage windows judged per file. |
|
|
53
|
+
| `JF_WINDOW_BYTES` | `8192` | Passage window size in UTF-8 bytes. |
|
|
54
|
+
| `JF_SKETCH_BYTES` | `384` | Bytes per file sketch shown to the filename judge. |
|
|
55
|
+
| `JF_FULL_LIMIT` | `40` | Max files read fully in the verification stage. |
|
|
56
|
+
| `JF_CONCURRENCY` | `16` | Parallel judge requests. |
|
|
57
|
+
|
|
58
|
+
Run `/find` inside pi to see the resolved judge endpoint and budgets.
|
|
59
|
+
|
|
60
|
+
## How it works
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
query ──► keywords (stopword split, camelCase split)
|
|
64
|
+
──► rg -i --json lexical scores: idf-weighted hit counts (CANDIDATES=128)
|
|
65
|
+
──► rg --files + filters deny dirs, secrets, binaries (FILES=20)
|
|
66
|
+
├─ stage 1: filename judge "is <sketch of path> likely to contain <intent>? 0..1"
|
|
67
|
+
├─ stage 2: passage judge per 8 KB window, line ranges + p
|
|
68
|
+
└─ stage 3: full-file verify top hits, merge ranges
|
|
69
|
+
──► ranked hits with line ranges + probabilities
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- Budgets mirror jegrep's published defaults; every request is one batched System One call, so a typical find costs a few cents at most.
|
|
73
|
+
- Secret files (`.env`, keys, pem, tfstate…) and VCS dirs never reach the judge.
|
|
74
|
+
- Lexical champions (top-2 files by keyword score) skip judging and are read directly.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT — see [LICENSE](LICENSE). Derived from oh-my-pi's `jfind` (MIT, Can Bölük) and jegrep (MIT, Can Bölük).
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-jev-find",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Semantic find tool for the pi coding agent — a cascade-search port of jegrep / oh-my-pi jfind",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"src",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi",
|
|
15
|
+
"pi-extension",
|
|
16
|
+
"semantic-search",
|
|
17
|
+
"ripgrep",
|
|
18
|
+
"jev",
|
|
19
|
+
"typesafe",
|
|
20
|
+
"code-search"
|
|
21
|
+
],
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/TengYangTuoHai/pi-jev-find.git"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"typebox": "^1.3.27"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "^0.87.0",
|
|
35
|
+
"@earendil-works/pi-tui": "^0.87.0",
|
|
36
|
+
"@types/bun": "^1.3.0",
|
|
37
|
+
"typescript": "^5.9.0"
|
|
38
|
+
},
|
|
39
|
+
"pi": {
|
|
40
|
+
"extensions": ["src/index.ts"]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cascade search: semantic passage maps before full-source reading.
|
|
3
|
+
*
|
|
4
|
+
* 1. Lexical scan ranks every eligible file by query keywords.
|
|
5
|
+
* 2. Filename ranking judges the top candidates by name and place in the tree.
|
|
6
|
+
* 3. Passage scoring reads the strongest files, cuts them into byte-bounded
|
|
7
|
+
* windows, and judges a budgeted verbatim sketch of each window.
|
|
8
|
+
* 4. Verification judges the complete text of the sketches that survived.
|
|
9
|
+
*
|
|
10
|
+
* Sketch judgments are only routing signals: a reported heat range always
|
|
11
|
+
* comes from a complete passage. Each judged phase runs its requests through
|
|
12
|
+
* a bounded-parallel dispatcher and drains fully before the next begins, so
|
|
13
|
+
* the critical path is three dependent waves.
|
|
14
|
+
*
|
|
15
|
+
* Ported from oh-my-pi `packages/coding-agent/src/tools/jfind/cascade.ts`
|
|
16
|
+
* (MIT): local judge/types instead of `@oh-my-pi/pi-ai`, local FindHit /
|
|
17
|
+
* FindStats instead of `@oh-my-pi/pi-tui`, budgets injectable via options,
|
|
18
|
+
* and a local `throwIfAborted`.
|
|
19
|
+
*/
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import type { Budgets } from "../config.ts";
|
|
22
|
+
import { DEFAULT_BUDGETS } from "../config.ts";
|
|
23
|
+
import type { Judge, JudgmentResult } from "../judge/types.ts";
|
|
24
|
+
import type { FindHit, FindStats } from "../types.ts";
|
|
25
|
+
import { fileScore, grepIndex, idf } from "./lexical.ts";
|
|
26
|
+
import { keywords as deriveKeywords } from "./keywords.ts";
|
|
27
|
+
import { type HeatRange, mergeHeat, type Passage, plainContent, selectWindows, sketch, windows } from "./passages.ts";
|
|
28
|
+
import { nameBatch, passageBatch, passageKey, entryKey, type Request, type SketchCard, sketchBatch } from "./questions.ts";
|
|
29
|
+
import { lines, readText, ReadTextError, takeChars } from "./text.ts";
|
|
30
|
+
import { type FileEntry, listFiles } from "./tree.ts";
|
|
31
|
+
|
|
32
|
+
/** Sketch probability below which a passage is not verified. */
|
|
33
|
+
const CUTOFF = 0.45;
|
|
34
|
+
/** Verified-passage probability at or above which a file is a hit. */
|
|
35
|
+
const THRESHOLD = 0.2;
|
|
36
|
+
/** Bytes of a file read for windowing. */
|
|
37
|
+
const READ_LIMIT = 4 * 1024 * 1024;
|
|
38
|
+
/** Sketch state budget per request; sized so cards stay well inside the judge's context. */
|
|
39
|
+
const SKETCH_STATE_BYTES = 18_000;
|
|
40
|
+
/** Hard cap on sketch cards per request. */
|
|
41
|
+
const SKETCH_CARDS_MAX = 48;
|
|
42
|
+
/** Passage state budget per verification request, tags included. */
|
|
43
|
+
const VERIFY_STATE_BYTES = 24 * 1024;
|
|
44
|
+
/** Wall-clock budget for the lexical scan. */
|
|
45
|
+
const SCAN_TIMEOUT_MS = 30_000;
|
|
46
|
+
/** Distinct failure messages retained for the report. */
|
|
47
|
+
const FAILURES_KEPT = 5;
|
|
48
|
+
|
|
49
|
+
export interface CascadeOptions {
|
|
50
|
+
root: string;
|
|
51
|
+
query: string;
|
|
52
|
+
/** Caller-supplied lexical keywords, added to those derived from the query. */
|
|
53
|
+
extraKeywords: readonly string[];
|
|
54
|
+
judge: Judge;
|
|
55
|
+
includeHidden: boolean;
|
|
56
|
+
/** Cascade budgets; defaults mirror the reference implementation. */
|
|
57
|
+
budgets?: Budgets;
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
onProgress?: (message: string) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CascadeResult {
|
|
63
|
+
/** Files whose verified passages cleared the threshold, strongest first. */
|
|
64
|
+
hits: FindHit[];
|
|
65
|
+
threshold: number;
|
|
66
|
+
keywords: string[];
|
|
67
|
+
stats: FindStats;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface FilePlan {
|
|
71
|
+
node: number;
|
|
72
|
+
total: number;
|
|
73
|
+
truncated: boolean;
|
|
74
|
+
passages: Passage[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Answers = JudgmentResult;
|
|
78
|
+
type Outcome = { ok: true; result: Answers } | { ok: false; error: unknown };
|
|
79
|
+
|
|
80
|
+
/** Throw the signal's abort reason, mapped to a plain DOM abort error when absent. */
|
|
81
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
82
|
+
if (signal?.aborted) throw new DOMException("aborted", "AbortError");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A finite probability, or `undefined` when the judge produced nothing usable for the key. */
|
|
86
|
+
function noul(outcome: Outcome, key: string): number | undefined {
|
|
87
|
+
if (!outcome.ok) return undefined;
|
|
88
|
+
const p = outcome.result.answers[key]?.noul;
|
|
89
|
+
return p !== undefined && Number.isFinite(p) && p >= 0 && p <= 1 ? p : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function chunks<T>(items: readonly T[], size: number): T[][] {
|
|
93
|
+
const out: T[][] = [];
|
|
94
|
+
for (let start = 0; start < items.length; start += size) out.push(items.slice(start, start + size));
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function compareRel(a: FileEntry, b: FileEntry): number {
|
|
99
|
+
return a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
class Cascade {
|
|
103
|
+
readonly #options: CascadeOptions;
|
|
104
|
+
readonly #budgets: Budgets;
|
|
105
|
+
readonly stats: FindStats = {
|
|
106
|
+
filesListed: 0,
|
|
107
|
+
requests: 0,
|
|
108
|
+
errors: 0,
|
|
109
|
+
judged: 0,
|
|
110
|
+
filesRead: 0,
|
|
111
|
+
fileBytes: 0,
|
|
112
|
+
inputTokens: 0,
|
|
113
|
+
outputTokens: 0,
|
|
114
|
+
cost: 0,
|
|
115
|
+
apiMs: 0,
|
|
116
|
+
windowsJudged: 0,
|
|
117
|
+
windowsPruned: 0,
|
|
118
|
+
mapCards: 0,
|
|
119
|
+
failures: [],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
constructor(options: CascadeOptions) {
|
|
123
|
+
this.#options = options;
|
|
124
|
+
this.#budgets = options.budgets ?? DEFAULT_BUDGETS;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#fail(phase: string, error: unknown): void {
|
|
128
|
+
const message = `${phase}: ${error instanceof Error ? error.message : String(error)}`;
|
|
129
|
+
const { failures } = this.stats;
|
|
130
|
+
if (failures.length < FAILURES_KEPT && !failures.includes(message)) failures.push(message);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async #ask(request: Request): Promise<Outcome> {
|
|
134
|
+
const { judge, signal } = this.#options;
|
|
135
|
+
const started = performance.now();
|
|
136
|
+
try {
|
|
137
|
+
const result = await judge.judge(request, { signal });
|
|
138
|
+
this.stats.requests++;
|
|
139
|
+
this.stats.apiMs += performance.now() - started;
|
|
140
|
+
this.stats.inputTokens += result.usage.input;
|
|
141
|
+
this.stats.outputTokens += result.usage.output;
|
|
142
|
+
this.stats.cost += result.usage.cost;
|
|
143
|
+
return { ok: true, result };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throwIfAborted(signal);
|
|
146
|
+
this.stats.requests++;
|
|
147
|
+
this.stats.errors++;
|
|
148
|
+
this.stats.apiMs += performance.now() - started;
|
|
149
|
+
return { ok: false, error };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Run every job with at most `budgets.concurrency` requests in flight, handing
|
|
155
|
+
* each outcome to `settle` in completion order. Resolves once the queue drains.
|
|
156
|
+
*/
|
|
157
|
+
async #dispatch<J extends { request: Request }>(jobs: readonly J[], settle: (job: J, outcome: Outcome) => void) {
|
|
158
|
+
let next = 0;
|
|
159
|
+
const worker = async () => {
|
|
160
|
+
while (next < jobs.length) {
|
|
161
|
+
throwIfAborted(this.#options.signal);
|
|
162
|
+
const job = jobs[next++]!;
|
|
163
|
+
settle(job, await this.#ask(job.request));
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
await Promise.all(Array.from({ length: Math.min(this.#budgets.concurrency, jobs.length) }, worker));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async run(): Promise<CascadeResult> {
|
|
170
|
+
const { root, query, includeHidden, signal, onProgress } = this.#options;
|
|
171
|
+
const budgets = this.#budgets;
|
|
172
|
+
const keywords = deriveKeywords(query, this.#options.extraKeywords);
|
|
173
|
+
|
|
174
|
+
onProgress?.("lexical scan");
|
|
175
|
+
const [entries, index] = await Promise.all([
|
|
176
|
+
listFiles(root, { includeHidden, signal }),
|
|
177
|
+
grepIndex(root, keywords, { includeHidden, signal, timeoutMs: SCAN_TIMEOUT_MS }),
|
|
178
|
+
]);
|
|
179
|
+
this.stats.filesListed = entries.length;
|
|
180
|
+
const weights = idf(index);
|
|
181
|
+
const noCounts = Array.from({ length: keywords.length }, () => 0);
|
|
182
|
+
const ranked = entries
|
|
183
|
+
.map((entry, node) => ({
|
|
184
|
+
node,
|
|
185
|
+
lex: fileScore(index.perFileKw.get(entry.rel) ?? noCounts, weights, entry.rel, keywords),
|
|
186
|
+
}))
|
|
187
|
+
.sort((a, b) => b.lex - a.lex || compareRel(entries[a.node]!, entries[b.node]!))
|
|
188
|
+
.slice(0, budgets.candidates);
|
|
189
|
+
const nameScore = Array.from<number | undefined>({ length: entries.length });
|
|
190
|
+
|
|
191
|
+
// Wave 1: filename ranking over the lexical shortlist.
|
|
192
|
+
const project = path.basename(root);
|
|
193
|
+
const nameJobs = chunks(
|
|
194
|
+
ranked.map(candidate => candidate.node),
|
|
195
|
+
budgets.nameBatch,
|
|
196
|
+
).map(batch => ({
|
|
197
|
+
batch,
|
|
198
|
+
request: nameBatch(
|
|
199
|
+
project,
|
|
200
|
+
query,
|
|
201
|
+
batch.map(node => entries[node]!),
|
|
202
|
+
),
|
|
203
|
+
}));
|
|
204
|
+
let named = 0;
|
|
205
|
+
onProgress?.(`filename ranking 0/${ranked.length}`);
|
|
206
|
+
await this.#dispatch(nameJobs, (job, outcome) => {
|
|
207
|
+
named += job.batch.length;
|
|
208
|
+
if (!outcome.ok) this.#fail("filenames", outcome.error);
|
|
209
|
+
job.batch.forEach((node, k) => {
|
|
210
|
+
const p = noul(outcome, entryKey(k));
|
|
211
|
+
nameScore[node] = p;
|
|
212
|
+
if (p === undefined) {
|
|
213
|
+
if (outcome.ok) this.stats.errors++;
|
|
214
|
+
} else {
|
|
215
|
+
this.stats.judged++;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
onProgress?.(`filename ranking ${named}/${ranked.length}`);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// The two strongest lexical candidates are read regardless of the name
|
|
222
|
+
// judgment; the rest of the budget follows name score, then lexical rank.
|
|
223
|
+
const selected = ranked.slice(0, Math.min(budgets.files, 2)).map(candidate => candidate.node);
|
|
224
|
+
ranked.sort((a, b) => (nameScore[b.node] ?? 0) - (nameScore[a.node] ?? 0) || b.lex - a.lex);
|
|
225
|
+
for (const candidate of ranked) {
|
|
226
|
+
if (selected.length >= budgets.files) break;
|
|
227
|
+
if (!selected.includes(candidate.node)) selected.push(candidate.node);
|
|
228
|
+
}
|
|
229
|
+
onProgress?.(`reading ${selected.length} files`);
|
|
230
|
+
const plans = await Promise.all(
|
|
231
|
+
selected.map(async (node): Promise<FilePlan | undefined> => {
|
|
232
|
+
const entry = entries[node]!;
|
|
233
|
+
try {
|
|
234
|
+
const read = await readText(entry.path, READ_LIMIT);
|
|
235
|
+
const passages = selectWindows(windows(read.text, budgets.windowBytes, keywords, weights), budgets.windows);
|
|
236
|
+
if (passages.length === 0) return undefined;
|
|
237
|
+
return { node, total: lines(read.text).length, truncated: read.truncated, passages };
|
|
238
|
+
} catch (error) {
|
|
239
|
+
// Binary and blank files are expected misses, not failures worth reporting.
|
|
240
|
+
if (!(error instanceof ReadTextError) || error.kind === "io") this.#fail(`read ${entry.rel}`, error);
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
const files = plans.filter((plan): plan is FilePlan => plan !== undefined);
|
|
246
|
+
|
|
247
|
+
// Wave 2: sketch routing over mixed-file cards.
|
|
248
|
+
const cards: { f: number; p: number }[] = [];
|
|
249
|
+
files.forEach((plan, f) => plan.passages.forEach((_, p) => cards.push({ f, p })));
|
|
250
|
+
let sketchSentBytes = 0;
|
|
251
|
+
const sketchJobs = chunks(
|
|
252
|
+
cards,
|
|
253
|
+
Math.min(SKETCH_CARDS_MAX, Math.max(1, Math.floor(SKETCH_STATE_BYTES / budgets.sketchBytes))),
|
|
254
|
+
).map(batch => {
|
|
255
|
+
const sketches: SketchCard[] = batch.map(({ f, p }) => {
|
|
256
|
+
const text = sketch(files[f]!.passages[p]!, keywords, weights, budgets.sketchBytes);
|
|
257
|
+
sketchSentBytes += Buffer.byteLength(text);
|
|
258
|
+
return { fileKey: `f${f}`, rel: entries[files[f]!.node]!.rel, sketch: text };
|
|
259
|
+
});
|
|
260
|
+
return { batch, request: sketchBatch(query, sketches) };
|
|
261
|
+
});
|
|
262
|
+
this.stats.mapCards += cards.length;
|
|
263
|
+
onProgress?.(`scoring ${cards.length} passage sketches across ${files.length} files`);
|
|
264
|
+
const candidates: { f: number; p: number; score: number }[] = [];
|
|
265
|
+
await this.#dispatch(sketchJobs, (job, outcome) => {
|
|
266
|
+
if (!outcome.ok) this.#fail("sketches", outcome.error);
|
|
267
|
+
job.batch.forEach(({ f, p }, k) => {
|
|
268
|
+
const score = noul(outcome, passageKey(k));
|
|
269
|
+
if (score === undefined && outcome.ok) this.stats.errors++;
|
|
270
|
+
// Failure is unknown, never grounds for a negative judgment.
|
|
271
|
+
candidates.push({ f, p, score: score ?? 1 });
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
candidates.sort(
|
|
275
|
+
(a, b) =>
|
|
276
|
+
b.score - a.score ||
|
|
277
|
+
files[b.f]!.passages[b.p]!.score - files[a.f]!.passages[a.p]!.score ||
|
|
278
|
+
compareRel(entries[files[a.f]!.node]!, entries[files[b.f]!.node]!) ||
|
|
279
|
+
files[a.f]!.passages[a.p]!.start - files[b.f]!.passages[b.p]!.start,
|
|
280
|
+
);
|
|
281
|
+
const survivors = candidates.filter(candidate => candidate.score >= CUTOFF).slice(0, budgets.fullLimit);
|
|
282
|
+
this.stats.windowsPruned += cards.length - survivors.length;
|
|
283
|
+
const chosen = new Map<number, number[]>();
|
|
284
|
+
for (const { f, p } of survivors) {
|
|
285
|
+
const list = chosen.get(f);
|
|
286
|
+
if (list) list.push(p);
|
|
287
|
+
else chosen.set(f, [p]);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Wave 3: verification of complete passages, grouped per file.
|
|
291
|
+
const verifyJobs: { f: number; passages: Passage[]; request: Request }[] = [];
|
|
292
|
+
for (const f of [...chosen.keys()].sort((a, b) => a - b)) {
|
|
293
|
+
const rel = entries[files[f]!.node]!.rel;
|
|
294
|
+
const ps = chosen.get(f)!.sort((a, b) => a - b);
|
|
295
|
+
for (const group of chunks(ps, Math.max(1, Math.floor(VERIFY_STATE_BYTES / budgets.windowBytes)))) {
|
|
296
|
+
const passages = group.map(p => files[f]!.passages[p]!);
|
|
297
|
+
verifyJobs.push({ f, passages, request: passageBatch(query, rel, passages) });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
onProgress?.(`verifying ${survivors.length} passages in ${chosen.size} files`);
|
|
301
|
+
const results = new Map<number, { score: number; heat: HeatRange[]; lines: number; bytes: number }>();
|
|
302
|
+
await this.#dispatch(verifyJobs, (job, outcome) => {
|
|
303
|
+
if (!outcome.ok) {
|
|
304
|
+
this.#fail("verification", outcome.error);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
let entry = results.get(job.f);
|
|
308
|
+
if (!entry) {
|
|
309
|
+
entry = { score: 0, heat: [], lines: 0, bytes: 0 };
|
|
310
|
+
results.set(job.f, entry);
|
|
311
|
+
}
|
|
312
|
+
job.passages.forEach((passage, k) => {
|
|
313
|
+
const score = noul(outcome, passageKey(k));
|
|
314
|
+
if (score === undefined) {
|
|
315
|
+
this.stats.errors++;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const text = plainContent(passage);
|
|
319
|
+
entry.score = Math.max(entry.score, score);
|
|
320
|
+
entry.heat.push({
|
|
321
|
+
start: passage.start,
|
|
322
|
+
end: passage.end,
|
|
323
|
+
p: score,
|
|
324
|
+
snippet: takeChars(lines(text).find(line => line.trim().length > 0) ?? "", 100),
|
|
325
|
+
});
|
|
326
|
+
entry.lines += passage.end - passage.start + 1;
|
|
327
|
+
entry.bytes += Buffer.byteLength(text);
|
|
328
|
+
this.stats.windowsJudged++;
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const hits: FindHit[] = [];
|
|
333
|
+
for (const [f, entry] of results) {
|
|
334
|
+
const plan = files[f]!;
|
|
335
|
+
this.stats.filesRead++;
|
|
336
|
+
this.stats.fileBytes += entry.bytes;
|
|
337
|
+
if (entry.score < THRESHOLD) continue;
|
|
338
|
+
hits.push({
|
|
339
|
+
rel: entries[plan.node]!.rel,
|
|
340
|
+
nameScore: nameScore[plan.node],
|
|
341
|
+
contentScore: entry.score,
|
|
342
|
+
ranges: mergeHeat(entry.heat, THRESHOLD),
|
|
343
|
+
linesSeen: entry.lines,
|
|
344
|
+
truncated: plan.truncated || entry.lines < plan.total,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
this.stats.fileBytes += sketchSentBytes;
|
|
348
|
+
this.stats.filesRead += files.length - results.size;
|
|
349
|
+
hits.sort((a, b) => b.contentScore - a.contentScore);
|
|
350
|
+
return { hits, threshold: THRESHOLD, keywords, stats: this.stats };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Run one cascade search over `options.root`. Judge failures degrade coverage and are reported in `stats.failures`, never thrown. */
|
|
355
|
+
export function runCascade(options: CascadeOptions): Promise<CascadeResult> {
|
|
356
|
+
return new Cascade(options).run();
|
|
357
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query-derived keywords for the lexical prior: quoted phrases whole, then
|
|
3
|
+
* alphanumeric tokens minus stopwords, cheaply stemmed and deduplicated.
|
|
4
|
+
*
|
|
5
|
+
* Ported verbatim from oh-my-pi `packages/coding-agent/src/tools/jfind/keywords.ts` (MIT).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const STOPWORDS: Record<string, true> = Object.fromEntries(
|
|
9
|
+
[
|
|
10
|
+
"the",
|
|
11
|
+
"a",
|
|
12
|
+
"an",
|
|
13
|
+
"or",
|
|
14
|
+
"and",
|
|
15
|
+
"to",
|
|
16
|
+
"is",
|
|
17
|
+
"are",
|
|
18
|
+
"be",
|
|
19
|
+
"when",
|
|
20
|
+
"where",
|
|
21
|
+
"how",
|
|
22
|
+
"that",
|
|
23
|
+
"this",
|
|
24
|
+
"of",
|
|
25
|
+
"in",
|
|
26
|
+
"on",
|
|
27
|
+
"at",
|
|
28
|
+
"for",
|
|
29
|
+
"with",
|
|
30
|
+
"by",
|
|
31
|
+
"its",
|
|
32
|
+
"it",
|
|
33
|
+
"as",
|
|
34
|
+
"from",
|
|
35
|
+
"into",
|
|
36
|
+
"like",
|
|
37
|
+
"gets",
|
|
38
|
+
"get",
|
|
39
|
+
"up",
|
|
40
|
+
"which",
|
|
41
|
+
"what",
|
|
42
|
+
"does",
|
|
43
|
+
"do",
|
|
44
|
+
"code",
|
|
45
|
+
"file",
|
|
46
|
+
"files",
|
|
47
|
+
"over",
|
|
48
|
+
"all",
|
|
49
|
+
"user",
|
|
50
|
+
"using",
|
|
51
|
+
"then",
|
|
52
|
+
"than",
|
|
53
|
+
"there",
|
|
54
|
+
"their",
|
|
55
|
+
"they",
|
|
56
|
+
"them",
|
|
57
|
+
"you",
|
|
58
|
+
"your",
|
|
59
|
+
"we",
|
|
60
|
+
"our",
|
|
61
|
+
"has",
|
|
62
|
+
"have",
|
|
63
|
+
"had",
|
|
64
|
+
"was",
|
|
65
|
+
"were",
|
|
66
|
+
"been",
|
|
67
|
+
"being",
|
|
68
|
+
"will",
|
|
69
|
+
"would",
|
|
70
|
+
"should",
|
|
71
|
+
"can",
|
|
72
|
+
"could",
|
|
73
|
+
"not",
|
|
74
|
+
"but",
|
|
75
|
+
"if",
|
|
76
|
+
"so",
|
|
77
|
+
"such",
|
|
78
|
+
"via",
|
|
79
|
+
"per",
|
|
80
|
+
"any",
|
|
81
|
+
"some",
|
|
82
|
+
"each",
|
|
83
|
+
"every",
|
|
84
|
+
"also",
|
|
85
|
+
"just",
|
|
86
|
+
"only",
|
|
87
|
+
"more",
|
|
88
|
+
"most",
|
|
89
|
+
"other",
|
|
90
|
+
"out",
|
|
91
|
+
"off",
|
|
92
|
+
"about",
|
|
93
|
+
"after",
|
|
94
|
+
"before",
|
|
95
|
+
"between",
|
|
96
|
+
"through",
|
|
97
|
+
"during",
|
|
98
|
+
"without",
|
|
99
|
+
"within",
|
|
100
|
+
"one",
|
|
101
|
+
"two",
|
|
102
|
+
"new",
|
|
103
|
+
"used",
|
|
104
|
+
"use",
|
|
105
|
+
"make",
|
|
106
|
+
"makes",
|
|
107
|
+
"made",
|
|
108
|
+
"run",
|
|
109
|
+
"runs",
|
|
110
|
+
"way",
|
|
111
|
+
"thing",
|
|
112
|
+
"things",
|
|
113
|
+
"something",
|
|
114
|
+
"actually",
|
|
115
|
+
"really",
|
|
116
|
+
"still",
|
|
117
|
+
"yet",
|
|
118
|
+
].map(word => [word, true]),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
/** Cheap stem so a substring match covers inflections: spawned→spawn, compacted→compact. */
|
|
122
|
+
function stem(token: string): string {
|
|
123
|
+
for (const suffix of ["ing", "ed", "es", "s"]) {
|
|
124
|
+
if (token.endsWith(suffix)) {
|
|
125
|
+
const base = token.slice(0, -suffix.length);
|
|
126
|
+
if (base.length >= 4) return base;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return token;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Unicode letter, digit, or underscore — the token alphabet of the query. */
|
|
133
|
+
const TOKEN_RE = /[^\p{L}\p{N}_]+/u;
|
|
134
|
+
const DIGITS_RE = /^[0-9]+$/;
|
|
135
|
+
|
|
136
|
+
/** Keywords for the grep prior: quoted phrases whole, then tokens minus stopwords. */
|
|
137
|
+
export function keywordsFromQuery(query: string): string[] {
|
|
138
|
+
const out: string[] = [];
|
|
139
|
+
let rest = "";
|
|
140
|
+
const chars = Array.from(query);
|
|
141
|
+
for (let i = 0; i < chars.length; i++) {
|
|
142
|
+
const c = chars[i]!;
|
|
143
|
+
if (c !== '"' && c !== "'") {
|
|
144
|
+
rest += c;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
let phrase = "";
|
|
148
|
+
let closed = false;
|
|
149
|
+
while (++i < chars.length) {
|
|
150
|
+
if (chars[i] === c) {
|
|
151
|
+
closed = true;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
phrase += chars[i];
|
|
155
|
+
}
|
|
156
|
+
phrase = phrase.trim().toLowerCase();
|
|
157
|
+
if (closed && Buffer.byteLength(phrase) >= 3) {
|
|
158
|
+
out.push(phrase);
|
|
159
|
+
rest += " ";
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
rest += `${phrase} `;
|
|
163
|
+
}
|
|
164
|
+
for (const token of rest.split(TOKEN_RE)) {
|
|
165
|
+
const lower = token.toLowerCase();
|
|
166
|
+
if (Buffer.byteLength(lower) < 3 || Object.hasOwn(STOPWORDS, lower) || DIGITS_RE.test(lower)) continue;
|
|
167
|
+
const stemmed = stem(lower);
|
|
168
|
+
if (!out.includes(stemmed)) out.push(stemmed);
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** {@link keywordsFromQuery} plus the caller's extra keywords, lowercased and deduplicated. */
|
|
174
|
+
export function keywords(query: string, extra: readonly string[]): string[] {
|
|
175
|
+
const out = keywordsFromQuery(query);
|
|
176
|
+
for (const raw of extra) {
|
|
177
|
+
const keyword = raw.trim().toLowerCase();
|
|
178
|
+
if (keyword.length > 0 && !out.includes(keyword)) out.push(keyword);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|