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,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lexical prior: per-file keyword occurrence counts from one ripgrep pass,
|
|
3
|
+
* turned into IDF weights and a file score that ranks candidates before any
|
|
4
|
+
* judgment is spent.
|
|
5
|
+
*
|
|
6
|
+
* Ported from oh-my-pi `packages/coding-agent/src/tools/jfind/lexical.ts`
|
|
7
|
+
* (MIT); the native grep backend is replaced by `rg --json`. Deviation: rg
|
|
8
|
+
* skips files over 16 MB (`--max-filesize`), so `filesScanned` counts files rg
|
|
9
|
+
* actually searched rather than every file offered; IDF is clamped to
|
|
10
|
+
* [0.5, 6], which bounds the effect on ranking.
|
|
11
|
+
*/
|
|
12
|
+
import { RgError, runRg } from "../rg.ts";
|
|
13
|
+
import { countOccurrences } from "./text.ts";
|
|
14
|
+
|
|
15
|
+
export interface GrepIndex {
|
|
16
|
+
/** Lowercased, non-empty keywords; `perFileKw` vectors align with this. */
|
|
17
|
+
keywords: string[];
|
|
18
|
+
/** rel file path → per-keyword occurrence counts over matching lines. */
|
|
19
|
+
perFileKw: Map<string, number[]>;
|
|
20
|
+
/** Files the scan opened (rg `begin` events), including ones with no match. */
|
|
21
|
+
filesScanned: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Regex-escape a literal keyword for the rg alternation. */
|
|
25
|
+
function escapeRegex(keyword: string): string {
|
|
26
|
+
return keyword.replace(/[\\.+*?()|[\]{}^$#&\-~]/g, "\\$&");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GrepIndexOptions {
|
|
30
|
+
includeHidden: boolean;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** rg `--json` events this module consumes; everything else is ignored. */
|
|
36
|
+
interface RgPathEvent {
|
|
37
|
+
type: string;
|
|
38
|
+
data?: { path?: { text?: string }; lines?: { text?: string } };
|
|
39
|
+
}
|
|
40
|
+
interface RgSummaryEvent {
|
|
41
|
+
type: "summary";
|
|
42
|
+
data?: { stats?: { searches?: number } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Count keyword occurrences (case-insensitive, any keyword) in every file under
|
|
47
|
+
* `root`. Only lines containing a keyword are inspected, so counts are per
|
|
48
|
+
* matching line rather than per file byte.
|
|
49
|
+
*/
|
|
50
|
+
export async function grepIndex(
|
|
51
|
+
root: string,
|
|
52
|
+
rawKeywords: readonly string[],
|
|
53
|
+
options: GrepIndexOptions,
|
|
54
|
+
): Promise<GrepIndex> {
|
|
55
|
+
const keywords = rawKeywords.map(keyword => keyword.toLowerCase()).filter(keyword => keyword.length > 0);
|
|
56
|
+
const index: GrepIndex = { keywords, perFileKw: new Map(), filesScanned: 0 };
|
|
57
|
+
if (keywords.length === 0) return index;
|
|
58
|
+
const run = await runRg(
|
|
59
|
+
root,
|
|
60
|
+
[
|
|
61
|
+
"--json",
|
|
62
|
+
"--ignore-case",
|
|
63
|
+
"--no-messages",
|
|
64
|
+
"--max-filesize",
|
|
65
|
+
"16M",
|
|
66
|
+
...(options.includeHidden ? ["--hidden"] : []),
|
|
67
|
+
"--",
|
|
68
|
+
keywords.map(escapeRegex).join("|"),
|
|
69
|
+
],
|
|
70
|
+
{ signal: options.signal, timeoutMs: options.timeoutMs },
|
|
71
|
+
);
|
|
72
|
+
for (const raw of run.stdout.toString("utf8").split("\n")) {
|
|
73
|
+
if (raw.length === 0) continue;
|
|
74
|
+
let event: RgPathEvent | RgSummaryEvent;
|
|
75
|
+
try {
|
|
76
|
+
event = JSON.parse(raw) as RgPathEvent | RgSummaryEvent;
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (event.type === "summary") {
|
|
81
|
+
// The summary event is always emitted and reports the number of files
|
|
82
|
+
// rg actually opened (searches), with or without matches — the corpus
|
|
83
|
+
// size the IDF denominator needs. `begin` events would not do: rg only
|
|
84
|
+
// emits them for files that matched.
|
|
85
|
+
const searches = (event as RgSummaryEvent).data?.stats?.searches;
|
|
86
|
+
if (typeof searches === "number") index.filesScanned = Math.max(index.filesScanned, searches);
|
|
87
|
+
} else if (event.type === "match") {
|
|
88
|
+
const match = event as RgPathEvent;
|
|
89
|
+
const rel = match.data?.path?.text;
|
|
90
|
+
const line = match.data?.lines?.text;
|
|
91
|
+
if (rel === undefined || line === undefined) continue; // non-UTF-8 path payload
|
|
92
|
+
let counts = index.perFileKw.get(rel);
|
|
93
|
+
if (!counts) {
|
|
94
|
+
counts = Array.from({ length: keywords.length }, () => 0);
|
|
95
|
+
index.perFileKw.set(rel, counts);
|
|
96
|
+
}
|
|
97
|
+
const lower = line.toLowerCase();
|
|
98
|
+
for (let k = 0; k < keywords.length; k++) {
|
|
99
|
+
counts[k]! += countOccurrences(lower, keywords[k]!);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return index;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Inverse document frequency per keyword, clamped to `[0.5, 6]`: rarity is
|
|
108
|
+
* capped so a word occurring once in a test fixture cannot beat an
|
|
109
|
+
* implementation that contains several query concepts repeatedly.
|
|
110
|
+
*/
|
|
111
|
+
export function idf(index: GrepIndex): number[] {
|
|
112
|
+
return index.keywords.map((_, k) => {
|
|
113
|
+
let df = 0;
|
|
114
|
+
for (const counts of index.perFileKw.values()) {
|
|
115
|
+
if ((counts[k] ?? 0) > 0) df++;
|
|
116
|
+
}
|
|
117
|
+
const weight = Math.log((index.filesScanned + 1) / (df + 1));
|
|
118
|
+
return Math.min(6, Math.max(0.5, weight));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Lexical rank of a file: rare query terms count more, log-scaled frequency
|
|
124
|
+
* keeps common words in a giant file from overwhelming a compact
|
|
125
|
+
* implementation with several terms, and a keyword in the path is worth two
|
|
126
|
+
* extra log-units.
|
|
127
|
+
*/
|
|
128
|
+
export function fileScore(
|
|
129
|
+
counts: readonly number[],
|
|
130
|
+
weights: readonly number[],
|
|
131
|
+
rel: string,
|
|
132
|
+
keywords: readonly string[],
|
|
133
|
+
): number {
|
|
134
|
+
const lower = rel.toLowerCase();
|
|
135
|
+
let score = 0;
|
|
136
|
+
for (let k = 0; k < keywords.length; k++) {
|
|
137
|
+
const inPath = lower.includes(keywords[k]!) ? 1 : 0;
|
|
138
|
+
score += weights[k]! * (2 * inPath + Math.log1p(counts[k] ?? 0));
|
|
139
|
+
}
|
|
140
|
+
return score;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { RgError };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-bounded source passages. A file is cut into contiguous whole-line
|
|
3
|
+
* windows tagged with their original line numbers; the lexically strongest
|
|
4
|
+
* windows are selected, sketched for routing, and finally judged verbatim.
|
|
5
|
+
* Every reported range maps back to real line coordinates.
|
|
6
|
+
*
|
|
7
|
+
* Ported from oh-my-pi `packages/coding-agent/src/tools/jfind/passages.ts` (MIT);
|
|
8
|
+
* only the `FindRange` import is local.
|
|
9
|
+
*/
|
|
10
|
+
import type { FindRange } from "../types.ts";
|
|
11
|
+
import { clipBytes, countOccurrences, lines } from "./text.ts";
|
|
12
|
+
|
|
13
|
+
/** A contiguous run of tagged source lines (`L<n>| text`), 1-based inclusive. */
|
|
14
|
+
export interface Passage {
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
text: string;
|
|
18
|
+
/** Lexical score used for window selection and tie-breaking. */
|
|
19
|
+
score: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A judged line range with its yes-probability and a one-line preview; the shape the renderer consumes. */
|
|
23
|
+
export type HeatRange = FindRange;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Contiguous whole-line windows bounded by bytes, including the final line. A
|
|
27
|
+
* single oversized line is clipped at a code-point boundary and keeps its real
|
|
28
|
+
* line id.
|
|
29
|
+
*/
|
|
30
|
+
export function windows(
|
|
31
|
+
text: string,
|
|
32
|
+
bytes: number,
|
|
33
|
+
keywords: readonly string[],
|
|
34
|
+
weights: readonly number[],
|
|
35
|
+
): Passage[] {
|
|
36
|
+
const source = lines(text);
|
|
37
|
+
const passages: Passage[] = [];
|
|
38
|
+
let start = 0;
|
|
39
|
+
while (start < source.length) {
|
|
40
|
+
let end = start;
|
|
41
|
+
let content = "";
|
|
42
|
+
let used = 0;
|
|
43
|
+
while (end < source.length) {
|
|
44
|
+
const line = source[end]!;
|
|
45
|
+
const prefix = `L${end + 1}| `;
|
|
46
|
+
const overhead = prefix.length + 1;
|
|
47
|
+
if (end > start && used + Buffer.byteLength(line) + overhead > bytes) break;
|
|
48
|
+
const piece = `${prefix}${clipBytes(line, Math.max(0, bytes - (used + overhead)))}\n`;
|
|
49
|
+
content += piece;
|
|
50
|
+
used += Buffer.byteLength(piece);
|
|
51
|
+
end++;
|
|
52
|
+
if (used >= bytes) break;
|
|
53
|
+
}
|
|
54
|
+
const lower = content.toLowerCase();
|
|
55
|
+
let score = 0;
|
|
56
|
+
for (let k = 0; k < keywords.length; k++) {
|
|
57
|
+
score += weights[k]! * Math.log1p(countOccurrences(lower, keywords[k]!));
|
|
58
|
+
}
|
|
59
|
+
passages.push({ start: start + 1, end, text: content, score });
|
|
60
|
+
start = end;
|
|
61
|
+
}
|
|
62
|
+
return passages;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Keep the best lexical windows and, when no words match, distribute the
|
|
67
|
+
* budget evenly through the file instead of always falling back to its
|
|
68
|
+
* opening bytes. Returned in file order.
|
|
69
|
+
*/
|
|
70
|
+
export function selectWindows(passages: Passage[], limit: number): Passage[] {
|
|
71
|
+
let selected = passages;
|
|
72
|
+
if (selected.length > limit) {
|
|
73
|
+
if (selected.every(passage => passage.score === 0)) {
|
|
74
|
+
const len = selected.length;
|
|
75
|
+
const step = Math.max(limit - 1, 1);
|
|
76
|
+
const keep = new Set<number>();
|
|
77
|
+
for (let k = 0; k < limit; k++) keep.add(Math.floor((k * (len - 1)) / step));
|
|
78
|
+
selected = selected.filter((_, index) => keep.has(index));
|
|
79
|
+
} else {
|
|
80
|
+
selected = [...selected].sort((a, b) => b.score - a.score || a.start - b.start).slice(0, limit);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return [...selected].sort((a, b) => a.start - b.start);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Passage text with the generated `L<n>| ` tags stripped; the caller owns the line coordinates. */
|
|
87
|
+
export function plainContent(passage: Passage): string {
|
|
88
|
+
let out = "";
|
|
89
|
+
const tagged = lines(passage.text);
|
|
90
|
+
for (let i = 0; i < tagged.length; i++) {
|
|
91
|
+
const line = tagged[i]!;
|
|
92
|
+
const prefix = `L${passage.start + i}| `;
|
|
93
|
+
out += `${line.startsWith(prefix) ? line.slice(prefix.length) : line}\n`;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Bytes reserved per selected sketch line for its `<line>: ` tag and separator. */
|
|
99
|
+
const SKETCH_LINE_OVERHEAD = 12;
|
|
100
|
+
/** Minimum bytes worth spending on one more sketch line. */
|
|
101
|
+
const SKETCH_MIN_LINE = 24;
|
|
102
|
+
/** Longest single sketch line. */
|
|
103
|
+
const SKETCH_MAX_LINE = 180;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A budgeted map of verbatim source lines, not an invented summary. Lines are
|
|
107
|
+
* ranked by keyword weight (plus a nudge for call-like lines) so deep
|
|
108
|
+
* implementation text can outrank headers, then emitted in file order.
|
|
109
|
+
*/
|
|
110
|
+
export function sketch(
|
|
111
|
+
passage: Passage,
|
|
112
|
+
keywords: readonly string[],
|
|
113
|
+
weights: readonly number[],
|
|
114
|
+
budget: number,
|
|
115
|
+
): string {
|
|
116
|
+
const plain = lines(plainContent(passage));
|
|
117
|
+
const ranked: { index: number; score: number }[] = [];
|
|
118
|
+
for (let index = 0; index < plain.length; index++) {
|
|
119
|
+
const line = plain[index]!;
|
|
120
|
+
if (line.trim().length === 0) continue;
|
|
121
|
+
const lower = line.toLowerCase();
|
|
122
|
+
let score = line.includes("(") ? 0.1 : 0;
|
|
123
|
+
for (let k = 0; k < keywords.length; k++) {
|
|
124
|
+
if (lower.includes(keywords[k]!)) score += weights[k]!;
|
|
125
|
+
}
|
|
126
|
+
ranked.push({ index, score });
|
|
127
|
+
}
|
|
128
|
+
ranked.sort((a, b) => b.score - a.score || a.index - b.index);
|
|
129
|
+
const selected: { index: number; text: string }[] = [];
|
|
130
|
+
let used = 0;
|
|
131
|
+
for (const { index } of ranked) {
|
|
132
|
+
const available = Math.max(0, budget - (used + SKETCH_LINE_OVERHEAD));
|
|
133
|
+
if (available < SKETCH_MIN_LINE) break;
|
|
134
|
+
const line = plain[index]!.trim();
|
|
135
|
+
const text = `${passage.start + index}: ${clipBytes(line, Math.min(available, SKETCH_MAX_LINE))}`;
|
|
136
|
+
used += Buffer.byteLength(text) + 1;
|
|
137
|
+
selected.push({ index, text });
|
|
138
|
+
}
|
|
139
|
+
selected.sort((a, b) => a.index - b.index);
|
|
140
|
+
return selected.map(entry => entry.text).join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Union the judged-positive spans; never bridge an unjudged gap. A merged span
|
|
145
|
+
* keeps the max probability so repeated or overlapping asks are not rewarded.
|
|
146
|
+
* Strongest first, then earliest.
|
|
147
|
+
*/
|
|
148
|
+
export function mergeHeat(heat: readonly HeatRange[], threshold: number): HeatRange[] {
|
|
149
|
+
const kept = heat
|
|
150
|
+
.filter(range => range.p >= threshold && range.p > 0 && range.start <= range.end)
|
|
151
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
152
|
+
const merged: HeatRange[] = [];
|
|
153
|
+
for (const range of kept) {
|
|
154
|
+
const last = merged[merged.length - 1];
|
|
155
|
+
if (last && range.start <= last.end + 1) {
|
|
156
|
+
last.end = Math.max(last.end, range.end);
|
|
157
|
+
if (range.p > last.p) last.p = range.p;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
merged.push({ ...range });
|
|
161
|
+
}
|
|
162
|
+
return merged.sort((a, b) => b.p - a.p || a.start - b.start);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The most relevant ranges, strongest first, then earliest. */
|
|
166
|
+
export function rankedHeat(heat: readonly HeatRange[], limit: number): HeatRange[] {
|
|
167
|
+
return heat
|
|
168
|
+
.filter(range => range.p > 0)
|
|
169
|
+
.sort((a, b) => b.p - a.p || a.start - b.start)
|
|
170
|
+
.slice(0, limit);
|
|
171
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three request shapes the cascade sends to the judge, as `state` plus
|
|
3
|
+
* one noul question per entry. Nouls give absolute probabilities, so entries
|
|
4
|
+
* are thresholded independently and batches are comparable with each other.
|
|
5
|
+
*
|
|
6
|
+
* State objects are built with alphabetically ordered keys so the wire bytes
|
|
7
|
+
* match the reference implementation (which serializes through sorted maps);
|
|
8
|
+
* judgment quality was benchmarked against that exact layout.
|
|
9
|
+
*
|
|
10
|
+
* Ported from oh-my-pi `packages/coding-agent/src/tools/jfind/questions.ts`
|
|
11
|
+
* (MIT): templates are local modules (pi's extension loader is Node-style and
|
|
12
|
+
* does not support asset imports) and `prompt.render` is a local mustache-
|
|
13
|
+
* style substitute.
|
|
14
|
+
*/
|
|
15
|
+
import type { JsonValue, NoulQuestion, JudgeRequest } from "../judge/types.ts";
|
|
16
|
+
import { nameQuestionTemplate } from "../prompts/find-name-question.ts";
|
|
17
|
+
import { passageQuestionTemplate } from "../prompts/find-passage-question.ts";
|
|
18
|
+
import { sketchQuestionTemplate } from "../prompts/find-sketch-question.ts";
|
|
19
|
+
import { type Passage, plainContent } from "./passages.ts";
|
|
20
|
+
import { type FileEntry, renderTree } from "./tree.ts";
|
|
21
|
+
|
|
22
|
+
export type Request = JudgeRequest;
|
|
23
|
+
export { type NoulQuestion };
|
|
24
|
+
|
|
25
|
+
/** `{{key}}` placeholders replaced with the value (missing keys become empty). */
|
|
26
|
+
function render(template: string, vars: Record<string, string>): string {
|
|
27
|
+
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key: string) => vars[key] ?? "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TASK =
|
|
31
|
+
"Semantic grep over a source tree: locate files whose content matches the search description. Entries are judged by name, size, position in the tree, and (for folders) a sample of what they contain.";
|
|
32
|
+
|
|
33
|
+
const TREE_FORMAT =
|
|
34
|
+
"`tree` is a directory listing. Lines starting with # are headers: `# dir/` is a folder; more #s means deeper nesting under the header above; a header may fold several levels (`# a/b/c/`). Every judgeable entry carries a tag like e017 right after the #s: files as `e017 name (size)`, folders as `e017 name/ — N entries: sample of names`. Untagged header lines are only structure.";
|
|
35
|
+
|
|
36
|
+
const FILE_CRITERIA = {
|
|
37
|
+
// Generated implementation is still implementation. File provenance must
|
|
38
|
+
// not itself be negative evidence for source-code searches.
|
|
39
|
+
no: "The file is unrelated by name and location; generated executable implementation can still be relevant.",
|
|
40
|
+
yes: "A file at this path plausibly contains code, text, or data matching the search.",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const FOLDER_CRITERIA = {
|
|
44
|
+
no: "Nothing about the folder's name, location, or sampled contents suggests it holds a match.",
|
|
45
|
+
yes: "The folder plausibly contains, at any depth, at least one file matching the search.",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const SKETCH_CRITERIA = {
|
|
49
|
+
no: "Unrelated code; mere mentions, declarations, call sites, tests or configuration without implementation.",
|
|
50
|
+
yes: "Likely substantive implementation, definition or explanation of any part of the requested behavior. A matching helper for one step counts. Excerpts omit most source: favor recall.",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const PASSAGE_CRITERIA = {
|
|
54
|
+
no: "This passage only mentions, calls, imports, tests, or configures the subject, or contains unrelated code sharing keywords.",
|
|
55
|
+
yes: "This passage contains an implementation, definition, or substantive explanation of an important part of the search. A helper implementing one requested step counts even when other steps are elsewhere.",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Question key of the `i`th entry in a filename batch. */
|
|
59
|
+
export function entryKey(i: number): string {
|
|
60
|
+
return `e${String(i).padStart(3, "0")}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Question key of the `k`th passage in a sketch or verification batch. */
|
|
64
|
+
export function passageKey(k: number): string {
|
|
65
|
+
return `p${String(k).padStart(2, "0")}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Copy of `record` with keys in lexicographic order. */
|
|
69
|
+
function sorted<T extends JsonValue>(record: Record<string, T>): Record<string, T> {
|
|
70
|
+
const out: Record<string, T> = {};
|
|
71
|
+
for (const key of Object.keys(record).sort()) out[key] = record[key]!;
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One noul per file over a shared tree-rendered listing of the batch. */
|
|
76
|
+
export function nameBatch(project: string, query: string, entries: readonly FileEntry[]): Request {
|
|
77
|
+
const questions: Record<string, NoulQuestion> = {};
|
|
78
|
+
entries.forEach((entry, i) => {
|
|
79
|
+
const key = entryKey(i);
|
|
80
|
+
const name = entry.rel.slice(entry.rel.lastIndexOf("/") + 1);
|
|
81
|
+
questions[key] = {
|
|
82
|
+
type: "noul",
|
|
83
|
+
instructions: render(nameQuestionTemplate, { key, name, query }).trim(),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
state: {
|
|
88
|
+
criteria: { file: FILE_CRITERIA, folder: FOLDER_CRITERIA },
|
|
89
|
+
format: TREE_FORMAT,
|
|
90
|
+
project,
|
|
91
|
+
search: query,
|
|
92
|
+
task: TASK,
|
|
93
|
+
tree: renderTree(entries, entryKey),
|
|
94
|
+
},
|
|
95
|
+
questions,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One sketch card: the file it came from and its budgeted verbatim lines. */
|
|
100
|
+
export interface SketchCard {
|
|
101
|
+
fileKey: string;
|
|
102
|
+
rel: string;
|
|
103
|
+
sketch: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Mixed-file packing of sketch cards: one state ingestion pays for many
|
|
108
|
+
* independent small cards instead of rereading a whole file to discover its
|
|
109
|
+
* useful spans.
|
|
110
|
+
*/
|
|
111
|
+
export function sketchBatch(query: string, cards: readonly SketchCard[]): Request {
|
|
112
|
+
const files: Record<string, string> = {};
|
|
113
|
+
const passages: Record<string, [string, string]> = {};
|
|
114
|
+
const questions: Record<string, NoulQuestion> = {};
|
|
115
|
+
cards.forEach((card, k) => {
|
|
116
|
+
const key = passageKey(k);
|
|
117
|
+
files[card.fileKey] = card.rel;
|
|
118
|
+
passages[key] = [card.fileKey, card.sketch];
|
|
119
|
+
questions[key] = { type: "noul", instructions: render(sketchQuestionTemplate, { key }).trim() };
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
state: { criteria: SKETCH_CRITERIA, files: sorted(files), passages, search: query },
|
|
123
|
+
questions,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Verification of complete passages from one file, judged independently. */
|
|
128
|
+
export function passageBatch(query: string, rel: string, passages: readonly Passage[]): Request {
|
|
129
|
+
const entries: Record<string, string> = {};
|
|
130
|
+
const questions: Record<string, NoulQuestion> = {};
|
|
131
|
+
passages.forEach((passage, k) => {
|
|
132
|
+
const key = passageKey(k);
|
|
133
|
+
entries[key] = plainContent(passage);
|
|
134
|
+
questions[key] = {
|
|
135
|
+
type: "noul",
|
|
136
|
+
instructions: render(passageQuestionTemplate, { key, query }).trim(),
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
state: { criteria: PASSAGE_CRITERIA, file: rel, passages: entries, search: query },
|
|
141
|
+
questions,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-budgeted text primitives shared by the lexical windows, sketches, and
|
|
3
|
+
* file reads. Budgets are UTF-8 bytes (what the judge is billed on), so
|
|
4
|
+
* clipping is always done at a code-point boundary.
|
|
5
|
+
*
|
|
6
|
+
* Ported from oh-my-pi `packages/coding-agent/src/tools/jfind/text.ts` (MIT);
|
|
7
|
+
* `readText` swapped from `Bun.file` to `node:fs/promises`.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs/promises";
|
|
10
|
+
|
|
11
|
+
/** Split like Rust `str::lines`: `\n`-separated, trailing `\r` stripped, no phantom last line after a final newline. */
|
|
12
|
+
export function lines(text: string): string[] {
|
|
13
|
+
if (text.length === 0) return [];
|
|
14
|
+
const out = text.split("\n");
|
|
15
|
+
if (out[out.length - 1] === "") out.pop();
|
|
16
|
+
for (let i = 0; i < out.length; i++) {
|
|
17
|
+
const line = out[i]!;
|
|
18
|
+
if (line.endsWith("\r")) out[i] = line.slice(0, -1);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Longest prefix of `text` that fits in `bytes` UTF-8 bytes without splitting a code point. */
|
|
24
|
+
export function clipBytes(text: string, bytes: number): string {
|
|
25
|
+
if (Buffer.byteLength(text) <= bytes) return text;
|
|
26
|
+
let used = 0;
|
|
27
|
+
let end = 0;
|
|
28
|
+
for (const char of text) {
|
|
29
|
+
const width = Buffer.byteLength(char);
|
|
30
|
+
if (used + width > bytes) break;
|
|
31
|
+
used += width;
|
|
32
|
+
end += char.length;
|
|
33
|
+
}
|
|
34
|
+
return text.slice(0, end);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** First `count` code points of `text`. */
|
|
38
|
+
export function takeChars(text: string, count: number): string {
|
|
39
|
+
let end = 0;
|
|
40
|
+
let taken = 0;
|
|
41
|
+
for (const char of text) {
|
|
42
|
+
if (taken === count) break;
|
|
43
|
+
end += char.length;
|
|
44
|
+
taken++;
|
|
45
|
+
}
|
|
46
|
+
return text.slice(0, end);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Non-overlapping occurrences of `needle` in `haystack`; 0 for an empty needle. */
|
|
50
|
+
export function countOccurrences(haystack: string, needle: string): number {
|
|
51
|
+
if (needle.length === 0) return 0;
|
|
52
|
+
let count = 0;
|
|
53
|
+
let from = 0;
|
|
54
|
+
for (;;) {
|
|
55
|
+
const at = haystack.indexOf(needle, from);
|
|
56
|
+
if (at === -1) return count;
|
|
57
|
+
count++;
|
|
58
|
+
from = at + needle.length;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ReadText {
|
|
63
|
+
text: string;
|
|
64
|
+
/** Bytes actually used (after trimming to a line boundary). */
|
|
65
|
+
bytes: number;
|
|
66
|
+
truncated: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Why a file was not read: not text, nothing in it, or the filesystem said no. */
|
|
70
|
+
export type ReadTextFailure = "binary" | "empty" | "io";
|
|
71
|
+
|
|
72
|
+
export class ReadTextError extends Error {
|
|
73
|
+
constructor(
|
|
74
|
+
readonly kind: ReadTextFailure,
|
|
75
|
+
message: string,
|
|
76
|
+
) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.name = "ReadTextError";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const BINARY_PROBE_BYTES = 8192;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read up to `maxBytes` of a text file. Rejects binaries (NUL in the first 8 KB)
|
|
86
|
+
* and blank files; trims a truncated read back to the last full line.
|
|
87
|
+
* @throws {ReadTextError} `binary`, `empty`, or `io`.
|
|
88
|
+
*/
|
|
89
|
+
export async function readText(path: string, maxBytes: number): Promise<ReadText> {
|
|
90
|
+
let buf: Buffer;
|
|
91
|
+
try {
|
|
92
|
+
const handle = await fs.open(path, "r");
|
|
93
|
+
try {
|
|
94
|
+
// Read only maxBytes + 1 so a single bounded syscall detects truncation
|
|
95
|
+
// without loading a potentially huge file into memory.
|
|
96
|
+
const chunk = Buffer.alloc(maxBytes + 1);
|
|
97
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, 0);
|
|
98
|
+
buf = chunk.subarray(0, bytesRead);
|
|
99
|
+
} finally {
|
|
100
|
+
await handle.close();
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new ReadTextError("io", error instanceof Error ? error.message : String(error));
|
|
104
|
+
}
|
|
105
|
+
const probe = buf.subarray(0, Math.min(buf.length, BINARY_PROBE_BYTES));
|
|
106
|
+
if (probe.includes(0)) throw new ReadTextError("binary", "binary");
|
|
107
|
+
const truncated = buf.length > maxBytes;
|
|
108
|
+
if (truncated) {
|
|
109
|
+
buf = buf.subarray(0, maxBytes);
|
|
110
|
+
const newline = buf.lastIndexOf(0x0a);
|
|
111
|
+
if (newline !== -1) buf = buf.subarray(0, newline + 1);
|
|
112
|
+
}
|
|
113
|
+
const text = new TextDecoder().decode(buf);
|
|
114
|
+
if (lines(text).every(line => line.trim().length === 0)) throw new ReadTextError("empty", "empty");
|
|
115
|
+
return { text, bytes: buf.length, truncated };
|
|
116
|
+
}
|