patchling 0.2.0 → 0.3.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/README.md +53 -8
- package/package.json +6 -4
- package/src/applydiff.d.ts +69 -0
- package/src/applydiff.js +782 -0
- package/src/filter.d.ts +366 -0
- package/src/filter.js +1513 -0
- package/src/gptdiff.d.ts +28 -1
- package/src/gptdiff.js +78 -10
- package/src/index.d.ts +3 -0
- package/src/index.js +25 -0
- package/src/llm.d.ts +4 -1
- package/src/llm.js +4 -0
package/src/filter.js
ADDED
|
@@ -0,0 +1,1513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in relevance filter for large file maps.
|
|
3
|
+
*
|
|
4
|
+
* For each candidate file, a cheap model sees the contents plus the task and
|
|
5
|
+
* answers "is this file relevant to the task? yes/no". Only `yes` files are
|
|
6
|
+
* returned for `generateDiff`. `smartapply` still wants the full map so files
|
|
7
|
+
* the diff never mentions stay put.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors `filter_relevant` in 255BITS/patchling-py (`cursor/relevance-filter-0192`).
|
|
10
|
+
* Field names are camelCase. Ambiguous answers and per-file errors include the
|
|
11
|
+
* file. An error is attached only when the call throws.
|
|
12
|
+
*
|
|
13
|
+
* The disk cache loads `node:fs` on demand so this module stays browser-safe.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { swallowReasoning } from './text.js';
|
|
17
|
+
import { callLlm as defaultCallLlm, resolveApiKey, resolveBaseUrl } from './llm.js';
|
|
18
|
+
import { getEnv } from './env.js';
|
|
19
|
+
|
|
20
|
+
/** Cheap default for yes/no classification. Override with GPTDIFF_FILTER_MODEL. */
|
|
21
|
+
export const DEFAULT_FILTER_MODEL = 'xiaomi/mimo-v2.6-flash';
|
|
22
|
+
|
|
23
|
+
/** In-flight classifications per call when `concurrency` is omitted. */
|
|
24
|
+
export const DEFAULT_FILTER_CONCURRENCY = 8;
|
|
25
|
+
|
|
26
|
+
/** UTF-8 size cap. `0` or `null` disables it. */
|
|
27
|
+
export const DEFAULT_FILTER_MAX_BYTES = 200_000;
|
|
28
|
+
|
|
29
|
+
/** Input-token cap (`ceil(code points / 4)`). `0` or `null` disables it. */
|
|
30
|
+
export const DEFAULT_FILTER_MAX_TOKENS = 12_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Completion cap for the yes/no call. High enough that a reasoning model's
|
|
34
|
+
* scratchpad does not consume the whole answer. `reasoning_effort` is also
|
|
35
|
+
* sent as `low`.
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_FILTER_MAX_OUTPUT_TOKENS = 1024;
|
|
38
|
+
|
|
39
|
+
/** Sent on the filter call so reasoning models keep the yes/no inside the budget. */
|
|
40
|
+
export const DEFAULT_FILTER_REASONING_EFFORT = 'low';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Bumped when the prompt or the yes/no contract changes so an old judgment is
|
|
44
|
+
* not reused. The cache key stringifies this value. `PROMPT_VERSION` is the
|
|
45
|
+
* shared name; `FILTER_PROMPT_VERSION` is the same number.
|
|
46
|
+
*/
|
|
47
|
+
export const PROMPT_VERSION = 3;
|
|
48
|
+
export const FILTER_PROMPT_VERSION = PROMPT_VERSION;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lockfiles and generated paths skipped unless `ignore` is `false` or a custom list.
|
|
52
|
+
* Patterns are case-insensitive globs (`**` crosses directories, `*` does not).
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_IGNORE_PATTERNS = [
|
|
55
|
+
'**/package-lock.json',
|
|
56
|
+
'**/yarn.lock',
|
|
57
|
+
'**/pnpm-lock.yaml',
|
|
58
|
+
'**/bun.lock',
|
|
59
|
+
'**/bun.lockb',
|
|
60
|
+
'**/npm-shrinkwrap.json',
|
|
61
|
+
'**/Cargo.lock',
|
|
62
|
+
'**/Gemfile.lock',
|
|
63
|
+
'**/poetry.lock',
|
|
64
|
+
'**/Pipfile.lock',
|
|
65
|
+
'**/composer.lock',
|
|
66
|
+
'**/go.sum',
|
|
67
|
+
'**/uv.lock',
|
|
68
|
+
'**/pdm.lock',
|
|
69
|
+
'**/flake.lock',
|
|
70
|
+
'**/pubspec.lock',
|
|
71
|
+
'**/packages.lock.json',
|
|
72
|
+
'**/Package.resolved',
|
|
73
|
+
'**/*.min.js',
|
|
74
|
+
'**/*.min.mjs',
|
|
75
|
+
'**/*.min.css',
|
|
76
|
+
'**/*.map',
|
|
77
|
+
'**/dist/**',
|
|
78
|
+
'**/build/**',
|
|
79
|
+
'**/.next/**',
|
|
80
|
+
'**/coverage/**',
|
|
81
|
+
'**/__pycache__/**',
|
|
82
|
+
'**/*.pyc',
|
|
83
|
+
'**/*.pyo',
|
|
84
|
+
'**/*.generated.*',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const POLICIES = ['include', 'exclude', 'truncate'];
|
|
88
|
+
|
|
89
|
+
const LEADING_WRAPPER = /^(?:answer|decision)\s*:\s*|^[\s"'`*_“”‘’]+/i;
|
|
90
|
+
const TRAILING_WRAPPER = /[\s"'`*_“”‘’]+$/;
|
|
91
|
+
const YES_NO_TOKEN = /^(yes|no)["'`*_“”‘’]*(?=$|\s|[.,!:;)])/i;
|
|
92
|
+
const FENCE_LINE = /^```[a-zA-Z0-9_-]*\s*$/;
|
|
93
|
+
|
|
94
|
+
const SYSTEM_PROMPT = (
|
|
95
|
+
'You are a relevance filter for a code-editing task. '
|
|
96
|
+
+ 'Decide whether the given file must be read or edited to accomplish the task, '
|
|
97
|
+
+ 'or is necessary context for that edit. '
|
|
98
|
+
+ 'Reply with a single line that starts with exactly "yes" or exactly "no", '
|
|
99
|
+
+ 'followed by a short reason. '
|
|
100
|
+
+ 'Answer "yes" when the file is likely to need changes or is required context. '
|
|
101
|
+
+ 'Answer "no" when it is unrelated. Do not write a diff.'
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @typedef {object} RelevanceDecision
|
|
106
|
+
* @property {string} path
|
|
107
|
+
* @property {'yes' | 'no'} decision `yes` files go on to generateDiff.
|
|
108
|
+
* Ambiguous answers and errors are `yes`.
|
|
109
|
+
* @property {string} reason short explanation; the cleaned model text when a call was made
|
|
110
|
+
* @property {string | null} raw model text before parsing, or null when no call was made
|
|
111
|
+
* @property {number} tokens total tokens for the call; 0 when no call was made
|
|
112
|
+
* @property {number} promptTokens
|
|
113
|
+
* @property {number} completionTokens
|
|
114
|
+
* @property {boolean} cached true when this judgment was reused (a cache hit, or a later path in a shared request)
|
|
115
|
+
* @property {'binary' | 'oversize' | 'truncated' | 'ignored' | null} skippedReason
|
|
116
|
+
* set when the file was not sent in full. `ignored` is a lockfile or generated path.
|
|
117
|
+
* @property {string | null} error set when the call threw or the answer was empty;
|
|
118
|
+
* the file is still included
|
|
119
|
+
* @property {boolean} failedOpen true when the file was included without a
|
|
120
|
+
* definitive yes/no (empty, malformed, thrown, or a cache failure that
|
|
121
|
+
* blocked a decision)
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @typedef {object} FilterRelevantResult
|
|
126
|
+
* @property {Record<string, string | Uint8Array>} files `yes` files in input order.
|
|
127
|
+
* Valid UTF-8 bytes are decoded to text, keeping a leading BOM. A binary
|
|
128
|
+
* file kept by policy is the original value, bytes untouched.
|
|
129
|
+
* @property {RelevanceDecision[]} decisions one per input file, in input order
|
|
130
|
+
* @property {string} model
|
|
131
|
+
* @property {RelevanceDecision[]} errors decisions whose call threw
|
|
132
|
+
* @property {number} billedTokens sum of `tokens` on decisions that were not cache hits
|
|
133
|
+
* @property {number} billedPromptTokens
|
|
134
|
+
* @property {number} billedCompletionTokens
|
|
135
|
+
* @property {number} failedOpen files included because the call failed, the answer
|
|
136
|
+
* was empty, or the answer was ambiguous
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Record stored in a cache. Snake_case so a JSON file matches the Python cache.
|
|
141
|
+
* @typedef {object} RelevanceCacheRecord
|
|
142
|
+
* @property {'yes' | 'no'} decision
|
|
143
|
+
* @property {string} [reason]
|
|
144
|
+
* @property {string | null} [raw]
|
|
145
|
+
* @property {number} [tokens]
|
|
146
|
+
* @property {number} [prompt_tokens]
|
|
147
|
+
* @property {number} [completion_tokens]
|
|
148
|
+
* @property {'binary' | 'oversize' | 'truncated' | null} [skipped_reason]
|
|
149
|
+
* @property {string | null} [error]
|
|
150
|
+
* @property {boolean} [failed_open]
|
|
151
|
+
* @property {string} [cache_key]
|
|
152
|
+
*/
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* In-memory relevance cache with an optional on-disk JSON file.
|
|
156
|
+
*
|
|
157
|
+
* The key is the SHA-256 of the exact classification inputs, length-prefixed
|
|
158
|
+
* in this order: prompt version, model, output-token limit, reasoning effort
|
|
159
|
+
* actually sent, task, path, mode (`full`, `truncated`, or `binary`), the
|
|
160
|
+
* preview note (or `""`), and the content actually sent. Two paths never
|
|
161
|
+
* share a key, even when their contents match. A full file and a preview
|
|
162
|
+
* truncated to the same bytes do not share a key either.
|
|
163
|
+
*/
|
|
164
|
+
export class RelevanceCache {
|
|
165
|
+
/**
|
|
166
|
+
* @param {string} [path] JSON file. Omit for memory only.
|
|
167
|
+
* Loaded on the first read. Corrupt files are ignored.
|
|
168
|
+
*/
|
|
169
|
+
constructor(path) {
|
|
170
|
+
this.path = path ? String(path) : null;
|
|
171
|
+
/** @type {Record<string, RelevanceCacheRecord>} */
|
|
172
|
+
this._entries = Object.create(null);
|
|
173
|
+
/** @type {Promise<void> | null} */
|
|
174
|
+
this._loadPromise = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* SHA-256 hex of the length-prefixed classification inputs.
|
|
179
|
+
* Each field is its UTF-8 bytes, written as `byteLength`, a colon, the
|
|
180
|
+
* bytes, and a newline. Field order: prompt version, model, output-token
|
|
181
|
+
* limit, reasoning effort actually sent (`""` when omitted or rejected),
|
|
182
|
+
* task, path, mode (`full`, `truncated`, or `binary`), note (or `""`),
|
|
183
|
+
* content.
|
|
184
|
+
* @param {object} fields
|
|
185
|
+
* @param {string} fields.model
|
|
186
|
+
* @param {string} [fields.intent] task text
|
|
187
|
+
* @param {string} [fields.task] task text when `intent` is omitted
|
|
188
|
+
* @param {string} fields.path
|
|
189
|
+
* @param {string} fields.content body sent to the model, after truncation
|
|
190
|
+
* @param {'full' | 'truncated' | 'binary'} [fields.mode] default `full`
|
|
191
|
+
* @param {string} [fields.note] exact preview note, or empty
|
|
192
|
+
* @param {number} [fields.maxOutputTokens]
|
|
193
|
+
* @param {string | null} [fields.reasoningEffort] effort actually sent
|
|
194
|
+
* @param {number | string} [fields.promptVersion]
|
|
195
|
+
* @returns {Promise<string>}
|
|
196
|
+
*/
|
|
197
|
+
static async makeKey(fields) {
|
|
198
|
+
const promptVersion = String(
|
|
199
|
+
fields.promptVersion === undefined ? FILTER_PROMPT_VERSION : fields.promptVersion,
|
|
200
|
+
);
|
|
201
|
+
const model = String(fields.model);
|
|
202
|
+
const tokenLimit = String(
|
|
203
|
+
fields.maxOutputTokens === undefined ? DEFAULT_FILTER_MAX_OUTPUT_TOKENS : fields.maxOutputTokens,
|
|
204
|
+
);
|
|
205
|
+
const effort = fields.reasoningEffort == null || fields.reasoningEffort === undefined
|
|
206
|
+
? ''
|
|
207
|
+
: String(fields.reasoningEffort);
|
|
208
|
+
const task = String(fields.task ?? fields.intent ?? '');
|
|
209
|
+
const path = String(fields.path);
|
|
210
|
+
const mode = fields.mode == null || fields.mode === undefined ? 'full' : String(fields.mode);
|
|
211
|
+
const note = fields.note == null || fields.note === undefined ? '' : String(fields.note);
|
|
212
|
+
const content = String(fields.content);
|
|
213
|
+
return sha256(lengthPrefixed([
|
|
214
|
+
promptVersion, model, tokenLimit, effort, task, path, mode, note, content,
|
|
215
|
+
]));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @param {string} key
|
|
220
|
+
* @returns {Promise<RelevanceCacheRecord | undefined>}
|
|
221
|
+
*/
|
|
222
|
+
async get(key) {
|
|
223
|
+
await this.load();
|
|
224
|
+
const record = this._entries[key];
|
|
225
|
+
return record ? { ...record } : undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {string} key
|
|
230
|
+
* @param {RelevanceCacheRecord} record
|
|
231
|
+
* @returns {Promise<void>}
|
|
232
|
+
*/
|
|
233
|
+
async put(key, record) {
|
|
234
|
+
await this.load();
|
|
235
|
+
this._entries[key] = { ...record };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Alias of {@link put} so a `{ get, set }` cache and a `RelevanceCache` share a shape.
|
|
240
|
+
* @param {string} key
|
|
241
|
+
* @param {RelevanceCacheRecord} record
|
|
242
|
+
* @returns {Promise<void>}
|
|
243
|
+
*/
|
|
244
|
+
async set(key, record) {
|
|
245
|
+
await this.put(key, record);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Write `{ version: 1, entries }` atomically. No-op without a path. */
|
|
249
|
+
async flush() {
|
|
250
|
+
if (!this.path) return;
|
|
251
|
+
await this.load();
|
|
252
|
+
const fs = await import('node:fs/promises');
|
|
253
|
+
const pathMod = await import('node:path');
|
|
254
|
+
const dir = pathMod.dirname(pathMod.resolve(this.path));
|
|
255
|
+
await fs.mkdir(dir, { recursive: true });
|
|
256
|
+
const tmp = pathMod.join(
|
|
257
|
+
dir,
|
|
258
|
+
`.relevance-cache-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`,
|
|
259
|
+
);
|
|
260
|
+
const payload = JSON.stringify({ version: 1, entries: this._entries });
|
|
261
|
+
try {
|
|
262
|
+
await fs.writeFile(tmp, payload, 'utf8');
|
|
263
|
+
await fs.rename(tmp, this.path);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
await fs.unlink(tmp).catch(() => {});
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** @returns {Promise<void>} */
|
|
271
|
+
async load() {
|
|
272
|
+
if (!this.path) return;
|
|
273
|
+
if (!this._loadPromise) this._loadPromise = this._load();
|
|
274
|
+
await this._loadPromise;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** @returns {Promise<void>} */
|
|
278
|
+
async _load() {
|
|
279
|
+
let fs;
|
|
280
|
+
try {
|
|
281
|
+
fs = await import('node:fs/promises');
|
|
282
|
+
} catch {
|
|
283
|
+
throw new Error('A disk-backed relevance cache requires Node (node:fs).');
|
|
284
|
+
}
|
|
285
|
+
let text;
|
|
286
|
+
try {
|
|
287
|
+
text = await fs.readFile(this.path, 'utf8');
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (err && /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
let payload;
|
|
293
|
+
try {
|
|
294
|
+
payload = JSON.parse(text);
|
|
295
|
+
} catch {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const entries = payload && typeof payload === 'object' ? payload.entries : null;
|
|
299
|
+
if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
|
|
300
|
+
this._entries = entries;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Fresh in-memory cache. Pass one instance as `cache` to share it across calls.
|
|
307
|
+
* @returns {RelevanceCache}
|
|
308
|
+
*/
|
|
309
|
+
export function createMemoryCache() {
|
|
310
|
+
return new RelevanceCache();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Browsers and Node 19+ expose Web Crypto as `globalThis.crypto`. Node 18
|
|
315
|
+
* keeps the same API on `node:crypto` as `webcrypto`. That module is imported
|
|
316
|
+
* only when the global is missing, so this file still loads in the browser.
|
|
317
|
+
* @returns {Promise<{ subtle: { digest: Function } }>}
|
|
318
|
+
*/
|
|
319
|
+
async function getWebCrypto() {
|
|
320
|
+
const exposed = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined;
|
|
321
|
+
if (exposed && exposed.subtle) return exposed;
|
|
322
|
+
try {
|
|
323
|
+
const mod = await import('node:crypto');
|
|
324
|
+
if (mod.webcrypto && mod.webcrypto.subtle) return /** @type {{ subtle: { digest: Function } }} */ (mod.webcrypto);
|
|
325
|
+
} catch {
|
|
326
|
+
// A browser without `globalThis.crypto`, or a host that cannot load node:crypto.
|
|
327
|
+
}
|
|
328
|
+
throw new Error('Web Crypto (crypto.subtle) is required to hash relevance cache keys.');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Length-prefixed UTF-8 encoding shared with the Python filter.
|
|
333
|
+
* Each part is `decimalByteLength + ":" + utf8 + "\\n"`.
|
|
334
|
+
* @param {string[]} parts
|
|
335
|
+
* @returns {Uint8Array}
|
|
336
|
+
*/
|
|
337
|
+
function lengthPrefixed(parts) {
|
|
338
|
+
const encoder = new TextEncoder();
|
|
339
|
+
const encoded = parts.map((part) => encoder.encode(part));
|
|
340
|
+
let size = 0;
|
|
341
|
+
for (const bytes of encoded) size += String(bytes.length).length + 1 + bytes.length + 1;
|
|
342
|
+
const out = new Uint8Array(size);
|
|
343
|
+
let offset = 0;
|
|
344
|
+
for (const bytes of encoded) {
|
|
345
|
+
const header = encoder.encode(`${bytes.length}:`);
|
|
346
|
+
out.set(header, offset);
|
|
347
|
+
offset += header.length;
|
|
348
|
+
out.set(bytes, offset);
|
|
349
|
+
offset += bytes.length;
|
|
350
|
+
out[offset] = 0x0a;
|
|
351
|
+
offset += 1;
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* @param {string | Uint8Array} data
|
|
358
|
+
* @returns {Promise<string>} hex SHA-256
|
|
359
|
+
*/
|
|
360
|
+
async function sha256(data) {
|
|
361
|
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
362
|
+
const webcrypto = await getWebCrypto();
|
|
363
|
+
const digest = await webcrypto.subtle.digest('SHA-256', bytes);
|
|
364
|
+
let hex = '';
|
|
365
|
+
for (const byte of new Uint8Array(digest)) hex += byte.toString(16).padStart(2, '0');
|
|
366
|
+
return hex;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Drop `<think>` / `<thinking>` blocks, including an unclosed tail.
|
|
371
|
+
* @param {string} text
|
|
372
|
+
* @returns {string}
|
|
373
|
+
*/
|
|
374
|
+
function stripFilterThinkTags(text) {
|
|
375
|
+
if (!text) return '';
|
|
376
|
+
let cleaned = text.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '');
|
|
377
|
+
cleaned = cleaned.replace(/<thinking\b[^>]*>[\s\S]*?<\/thinking>/gi, '');
|
|
378
|
+
cleaned = cleaned.replace(/<think\b[^>]*>[\s\S]*$/gi, '');
|
|
379
|
+
cleaned = cleaned.replace(/<thinking\b[^>]*>[\s\S]*$/gi, '');
|
|
380
|
+
cleaned = cleaned.replace(/<\/think>/gi, '');
|
|
381
|
+
cleaned = cleaned.replace(/<\/thinking>/gi, '');
|
|
382
|
+
return cleaned.trim();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Strip a leading `Answer:` / `Decision:` prefix and surrounding quotes,
|
|
387
|
+
* markdown bold, and backticks. Repeats so `Answer: **yes**` unwraps fully.
|
|
388
|
+
* @param {string} text
|
|
389
|
+
* @returns {string}
|
|
390
|
+
*/
|
|
391
|
+
function unwrapAnswerWrappers(text) {
|
|
392
|
+
let value = text.trim();
|
|
393
|
+
for (let i = 0; i < 8; i++) {
|
|
394
|
+
const next = value.replace(LEADING_WRAPPER, '').replace(TRAILING_WRAPPER, '').trim();
|
|
395
|
+
if (next === value) break;
|
|
396
|
+
value = next;
|
|
397
|
+
}
|
|
398
|
+
return value;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Parse a yes/no relevance answer.
|
|
403
|
+
*
|
|
404
|
+
* Think tags, one fenced block, and chain-of-thought reasoning are removed
|
|
405
|
+
* first. Whitespace, quotes, markdown bold or backticks, and one `Answer:`
|
|
406
|
+
* or `Decision:` prefix are wrappers. Wrappers may sit on the decision word
|
|
407
|
+
* itself when a reason follows (`**no** unrelated`, `"no" unrelated`). The
|
|
408
|
+
* first token must then be exactly `yes` or `no`, and the next character
|
|
409
|
+
* must be the end of the string, whitespace, or one of `. , ! : ; )`.
|
|
410
|
+
* Anything else is malformed (`null`); callers include the file and do not
|
|
411
|
+
* cache it.
|
|
412
|
+
*
|
|
413
|
+
* @param {unknown} raw
|
|
414
|
+
* @returns {{ decision: 'yes' | 'no' | null, cleaned: string }}
|
|
415
|
+
*/
|
|
416
|
+
export function parseRelevanceAnswer(raw) {
|
|
417
|
+
let text = stripFilterThinkTags(raw == null ? '' : String(raw));
|
|
418
|
+
const [swallowed] = swallowReasoning(text);
|
|
419
|
+
text = swallowed.trim();
|
|
420
|
+
const fenceLines = text.split(/\r\n|\r|\n/);
|
|
421
|
+
if (
|
|
422
|
+
fenceLines.length >= 2
|
|
423
|
+
&& FENCE_LINE.test(fenceLines[0].trim())
|
|
424
|
+
&& fenceLines[fenceLines.length - 1].trim() === '```'
|
|
425
|
+
) {
|
|
426
|
+
text = fenceLines.slice(1, -1).join('\n').trim();
|
|
427
|
+
}
|
|
428
|
+
const cleaned = text.trim();
|
|
429
|
+
const unwrapped = unwrapAnswerWrappers(cleaned);
|
|
430
|
+
if (!unwrapped) return { decision: null, cleaned };
|
|
431
|
+
const match = YES_NO_TOKEN.exec(unwrapped);
|
|
432
|
+
if (!match) return { decision: null, cleaned };
|
|
433
|
+
return { decision: /** @type {'yes' | 'no'} */ (match[1].toLowerCase()), cleaned };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* @param {string} name
|
|
438
|
+
* @param {unknown} value
|
|
439
|
+
* @returns {'include' | 'exclude' | 'truncate'}
|
|
440
|
+
*/
|
|
441
|
+
function checkPolicy(name, value) {
|
|
442
|
+
if (!POLICIES.includes(/** @type {string} */ (value))) {
|
|
443
|
+
throw new Error(`${name} must be one of ${POLICIES.join(', ')}; got ${JSON.stringify(value)}`);
|
|
444
|
+
}
|
|
445
|
+
return /** @type {'include' | 'exclude' | 'truncate'} */ (value);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* @param {string | undefined} model
|
|
450
|
+
* @returns {string}
|
|
451
|
+
*/
|
|
452
|
+
function resolveFilterModel(model) {
|
|
453
|
+
return model || getEnv('GPTDIFF_FILTER_MODEL') || DEFAULT_FILTER_MODEL;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* `null` and `false` disable the cache. Omit or pass `true` for a fresh
|
|
458
|
+
* in-memory cache. A string is a JSON file. A `RelevanceCache` is used as-is.
|
|
459
|
+
* An object with `get` and `put`/`set` is a pluggable cache.
|
|
460
|
+
* @param {unknown} cache
|
|
461
|
+
* @returns {RelevanceCache | { get: Function, put?: Function, set?: Function, load?: Function, flush?: Function } | null}
|
|
462
|
+
*/
|
|
463
|
+
function resolveCache(cache) {
|
|
464
|
+
if (cache === false || cache === null) return null;
|
|
465
|
+
if (cache === undefined || cache === true) return new RelevanceCache();
|
|
466
|
+
if (cache instanceof RelevanceCache) return cache;
|
|
467
|
+
if (typeof cache === 'string') return new RelevanceCache(cache);
|
|
468
|
+
if (
|
|
469
|
+
cache
|
|
470
|
+
&& typeof cache === 'object'
|
|
471
|
+
&& typeof /** @type {{ get?: unknown }} */ (cache).get === 'function'
|
|
472
|
+
&& (
|
|
473
|
+
typeof /** @type {{ put?: unknown }} */ (cache).put === 'function'
|
|
474
|
+
|| typeof /** @type {{ set?: unknown }} */ (cache).set === 'function'
|
|
475
|
+
)
|
|
476
|
+
) {
|
|
477
|
+
return /** @type {{ get: Function, put?: Function, set?: Function }} */ (cache);
|
|
478
|
+
}
|
|
479
|
+
throw new TypeError('cache must be a RelevanceCache, a filesystem path, null, or false');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Code-point stand-in for a tokenizer. Empty text is 0. Otherwise
|
|
484
|
+
* `ceil(codePoints / 4)`. A surrogate pair counts as one code point.
|
|
485
|
+
* @param {string} text
|
|
486
|
+
* @returns {number}
|
|
487
|
+
*/
|
|
488
|
+
export function countTokens(text) {
|
|
489
|
+
if (text == null || text === '') return 0;
|
|
490
|
+
const codePoints = Array.from(String(text)).length;
|
|
491
|
+
if (!codePoints) return 0;
|
|
492
|
+
return Math.ceil(codePoints / 4);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* First `count` Unicode code points. Never splits a surrogate pair.
|
|
497
|
+
* @param {string} text
|
|
498
|
+
* @param {number} count
|
|
499
|
+
* @returns {string}
|
|
500
|
+
*/
|
|
501
|
+
function takeCodePoints(text, count) {
|
|
502
|
+
if (count <= 0) return '';
|
|
503
|
+
return Array.from(text).slice(0, count).join('');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* @param {string} text
|
|
508
|
+
* @param {number | null} maxBytes
|
|
509
|
+
* @param {number | null} maxTokens
|
|
510
|
+
* @returns {string}
|
|
511
|
+
*/
|
|
512
|
+
function truncateText(text, maxBytes, maxTokens) {
|
|
513
|
+
let out = text;
|
|
514
|
+
if (maxTokens && countTokens(out) > maxTokens) {
|
|
515
|
+
out = takeCodePoints(out, maxTokens * 4);
|
|
516
|
+
}
|
|
517
|
+
if (maxBytes) out = truncateUtf8(out, maxBytes);
|
|
518
|
+
return out;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* UTF-8 prefix cut on a code-point boundary. Never emits U+FFFD: a character
|
|
523
|
+
* that does not fit entirely inside `maxBytes` is dropped.
|
|
524
|
+
* @param {string} text
|
|
525
|
+
* @param {number} maxBytes
|
|
526
|
+
* @returns {string}
|
|
527
|
+
*/
|
|
528
|
+
function truncateUtf8(text, maxBytes) {
|
|
529
|
+
const bytes = new TextEncoder().encode(text);
|
|
530
|
+
if (bytes.length <= maxBytes || maxBytes <= 0) return maxBytes <= 0 ? '' : text;
|
|
531
|
+
let end = maxBytes;
|
|
532
|
+
while (end > 0 && (bytes[end - 1] & 0xc0) === 0x80) end -= 1;
|
|
533
|
+
if (end > 0) {
|
|
534
|
+
const leadIndex = end - 1;
|
|
535
|
+
const lead = bytes[leadIndex];
|
|
536
|
+
const need = lead < 0x80 ? 1 : lead < 0xe0 ? 2 : lead < 0xf0 ? 3 : 4;
|
|
537
|
+
const have = maxBytes - leadIndex;
|
|
538
|
+
if (have < need) end = leadIndex;
|
|
539
|
+
else end = leadIndex + need;
|
|
540
|
+
}
|
|
541
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, end));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* @param {string} text
|
|
546
|
+
* @returns {number}
|
|
547
|
+
*/
|
|
548
|
+
function utf8ByteLength(text) {
|
|
549
|
+
return new TextEncoder().encode(text).length;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** A binary probe may end inside one UTF-8 character. The rest of the file is still checked. */
|
|
553
|
+
const UTF8_PROBE_BYTES = 8192;
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* True when `content` looks like a binary file (NUL, invalid UTF-8, or a
|
|
557
|
+
* dense run of non-text controls). The whole payload is validated. An
|
|
558
|
+
* incomplete trailing sequence is tolerated only when it ends on the
|
|
559
|
+
* 8192-byte probe boundary. Non-text values other than byte arrays are not
|
|
560
|
+
* binary; {@link filterRelevant} rejects them when it coerces the file.
|
|
561
|
+
* @param {unknown} content
|
|
562
|
+
* @returns {boolean}
|
|
563
|
+
*/
|
|
564
|
+
export function isBinaryContent(content) {
|
|
565
|
+
if (content instanceof Uint8Array) return binaryBytes(content);
|
|
566
|
+
if (typeof content !== 'string') return false;
|
|
567
|
+
if (content.includes('\0')) return true;
|
|
568
|
+
return textLooksBinary(content);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* @param {number} lead
|
|
573
|
+
* @returns {number} expected sequence length, or 0 when `lead` cannot start one
|
|
574
|
+
*/
|
|
575
|
+
function utf8SequenceLength(lead) {
|
|
576
|
+
if (lead <= 0x7f) return 1;
|
|
577
|
+
if (lead >= 0xc2 && lead <= 0xdf) return 2;
|
|
578
|
+
if (lead >= 0xe0 && lead <= 0xef) return 3;
|
|
579
|
+
if (lead >= 0xf0 && lead <= 0xf4) return 4;
|
|
580
|
+
return 0;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Decode `sample` as UTF-8. An incomplete trailing sequence of at most 3
|
|
585
|
+
* bytes is dropped. Genuinely invalid bytes return null.
|
|
586
|
+
* @param {Uint8Array} sample
|
|
587
|
+
* @returns {string | null}
|
|
588
|
+
*/
|
|
589
|
+
function decodeUtf8AllowingShortTail(sample) {
|
|
590
|
+
const fatal = () => new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
591
|
+
try {
|
|
592
|
+
return fatal().decode(sample);
|
|
593
|
+
} catch {
|
|
594
|
+
// The sample is not well-formed. A cut at 8192 may end inside one character.
|
|
595
|
+
}
|
|
596
|
+
let index = sample.length - 1;
|
|
597
|
+
let continuations = 0;
|
|
598
|
+
while (continuations < 3 && index >= 0 && (sample[index] & 0xc0) === 0x80) {
|
|
599
|
+
continuations += 1;
|
|
600
|
+
index -= 1;
|
|
601
|
+
}
|
|
602
|
+
if (index < 0) return null;
|
|
603
|
+
const need = utf8SequenceLength(sample[index]);
|
|
604
|
+
const have = sample.length - index;
|
|
605
|
+
if (!need || have >= need || need - have > 3 || continuations !== have - 1) return null;
|
|
606
|
+
try {
|
|
607
|
+
return fatal().decode(sample.subarray(0, index));
|
|
608
|
+
} catch {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* @param {Uint8Array} bytes
|
|
615
|
+
* @returns {boolean}
|
|
616
|
+
*/
|
|
617
|
+
/**
|
|
618
|
+
* Decode `bytes` as UTF-8, preserving a leading BOM (U+FEFF).
|
|
619
|
+
* Returns null when the buffer is not valid UTF-8.
|
|
620
|
+
* @param {Uint8Array} bytes
|
|
621
|
+
* @returns {string | null}
|
|
622
|
+
*/
|
|
623
|
+
function decodeStrictUtf8(bytes) {
|
|
624
|
+
try {
|
|
625
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
626
|
+
} catch {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Text for a valid UTF-8 payload. A buffer of exactly {@link UTF8_PROBE_BYTES}
|
|
633
|
+
* may end on an incomplete sequence; that prefix is text. Any other invalid
|
|
634
|
+
* byte, including one past the probe, is binary (`null`).
|
|
635
|
+
* @param {Uint8Array} bytes
|
|
636
|
+
* @returns {string | null}
|
|
637
|
+
*/
|
|
638
|
+
function decodeTextBytes(bytes) {
|
|
639
|
+
const text = decodeStrictUtf8(bytes);
|
|
640
|
+
if (text != null) return text;
|
|
641
|
+
if (bytes.length !== UTF8_PROBE_BYTES) return null;
|
|
642
|
+
return decodeUtf8AllowingShortTail(bytes);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* @param {Uint8Array} bytes
|
|
647
|
+
* @returns {boolean}
|
|
648
|
+
*/
|
|
649
|
+
function binaryBytes(bytes) {
|
|
650
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
651
|
+
if (bytes[i] === 0) return true;
|
|
652
|
+
}
|
|
653
|
+
const text = decodeTextBytes(bytes);
|
|
654
|
+
if (text == null) return true;
|
|
655
|
+
if (bytes.length > 0 && text === '') return true;
|
|
656
|
+
return textLooksBinary(text);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* @param {string} sample
|
|
661
|
+
* @returns {boolean}
|
|
662
|
+
*/
|
|
663
|
+
function textLooksBinary(sample) {
|
|
664
|
+
if (!sample) return false;
|
|
665
|
+
if (sample.includes('\0')) return true;
|
|
666
|
+
let controls = 0;
|
|
667
|
+
for (let i = 0; i < sample.length; i++) {
|
|
668
|
+
const c = sample.charCodeAt(i);
|
|
669
|
+
if (c < 32 && c !== 9 && c !== 10 && c !== 13 && c !== 12) controls += 1;
|
|
670
|
+
}
|
|
671
|
+
return controls / sample.length > 0.3;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* @param {unknown} content
|
|
676
|
+
* @returns {string}
|
|
677
|
+
*/
|
|
678
|
+
function coerceText(content) {
|
|
679
|
+
if (typeof content === 'string') return content;
|
|
680
|
+
if (content instanceof Uint8Array) {
|
|
681
|
+
const text = decodeTextBytes(content);
|
|
682
|
+
if (text != null) return text;
|
|
683
|
+
throw new TypeError('file content must be valid UTF-8 bytes');
|
|
684
|
+
}
|
|
685
|
+
const name = content === null ? 'null' : typeof content;
|
|
686
|
+
throw new TypeError(`file content must be str or bytes, got ${name}`);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* @param {unknown} value
|
|
691
|
+
* @param {number} fallback
|
|
692
|
+
* @returns {number | null} null disables the limit (`0` or `null`)
|
|
693
|
+
*/
|
|
694
|
+
function resolveLimit(value, fallback) {
|
|
695
|
+
const chosen = value === undefined ? fallback : value;
|
|
696
|
+
if (chosen === null || chosen === 0) return null;
|
|
697
|
+
const n = Number(chosen);
|
|
698
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
699
|
+
return n;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* @param {object} fields
|
|
704
|
+
* @returns {RelevanceDecision}
|
|
705
|
+
*/
|
|
706
|
+
function decisionObject(fields) {
|
|
707
|
+
return {
|
|
708
|
+
path: fields.path,
|
|
709
|
+
decision: fields.decision,
|
|
710
|
+
reason: fields.reason ?? '',
|
|
711
|
+
raw: fields.raw ?? null,
|
|
712
|
+
tokens: fields.tokens ?? 0,
|
|
713
|
+
promptTokens: fields.promptTokens ?? 0,
|
|
714
|
+
completionTokens: fields.completionTokens ?? 0,
|
|
715
|
+
cached: !!fields.cached,
|
|
716
|
+
skippedReason: fields.skippedReason ?? null,
|
|
717
|
+
error: fields.error ?? null,
|
|
718
|
+
failedOpen: !!fields.failedOpen,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* @param {string} path
|
|
724
|
+
* @param {RelevanceCacheRecord} record
|
|
725
|
+
* @param {boolean} cached
|
|
726
|
+
* @returns {RelevanceDecision}
|
|
727
|
+
*/
|
|
728
|
+
function decisionFromRecord(path, record, cached) {
|
|
729
|
+
const rec = /** @type {Record<string, any>} */ (record);
|
|
730
|
+
return decisionObject({
|
|
731
|
+
path,
|
|
732
|
+
decision: /** @type {'yes' | 'no'} */ (rec.decision),
|
|
733
|
+
reason: rec.reason || '',
|
|
734
|
+
raw: rec.raw ?? null,
|
|
735
|
+
tokens: Number(rec.tokens || 0),
|
|
736
|
+
promptTokens: Number(rec.prompt_tokens ?? rec.promptTokens ?? 0),
|
|
737
|
+
completionTokens: Number(rec.completion_tokens ?? rec.completionTokens ?? 0),
|
|
738
|
+
cached,
|
|
739
|
+
skippedReason: rec.skipped_reason ?? rec.skippedReason ?? null,
|
|
740
|
+
error: rec.error ?? null,
|
|
741
|
+
failedOpen: !!(rec.failed_open ?? rec.failedOpen),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* @param {string} path
|
|
747
|
+
* @param {'yes' | 'no'} decision
|
|
748
|
+
* @param {'binary' | 'oversize' | 'ignored'} skippedReason
|
|
749
|
+
* @param {string} reason
|
|
750
|
+
* @returns {RelevanceDecision}
|
|
751
|
+
*/
|
|
752
|
+
function skipDecision(path, decision, skippedReason, reason) {
|
|
753
|
+
return decisionObject({
|
|
754
|
+
path,
|
|
755
|
+
decision,
|
|
756
|
+
reason,
|
|
757
|
+
raw: null,
|
|
758
|
+
tokens: 0,
|
|
759
|
+
cached: false,
|
|
760
|
+
skippedReason,
|
|
761
|
+
error: null,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* @param {string} task
|
|
767
|
+
* @param {string} path
|
|
768
|
+
* @param {string} body
|
|
769
|
+
* @param {string | null} note
|
|
770
|
+
* @returns {string}
|
|
771
|
+
*/
|
|
772
|
+
function buildUserPrompt(task, path, body, note) {
|
|
773
|
+
const parts = [`Task:\n${task}`, `File path:\n${path}`];
|
|
774
|
+
if (note) parts.push(note);
|
|
775
|
+
parts.push(`File contents:\n${body}`);
|
|
776
|
+
parts.push('Is this file relevant to the task? yes/no');
|
|
777
|
+
return parts.join('\n\n');
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* A response that is not a chat-completion envelope. The caller fails that
|
|
782
|
+
* file open and does not retry it.
|
|
783
|
+
* @param {string} detail
|
|
784
|
+
* @returns {Error}
|
|
785
|
+
*/
|
|
786
|
+
function envelopeError(detail) {
|
|
787
|
+
const err = new Error(detail);
|
|
788
|
+
/** @type {{ envelope?: boolean }} */ (err).envelope = true;
|
|
789
|
+
return err;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* @param {unknown} response
|
|
794
|
+
* @returns {{ raw: string, promptTokens: number, completionTokens: number, tokens: number }}
|
|
795
|
+
*/
|
|
796
|
+
function usageAndText(response) {
|
|
797
|
+
if (!response || typeof response !== 'object' || Array.isArray(response)) {
|
|
798
|
+
throw envelopeError('relevance response was not JSON');
|
|
799
|
+
}
|
|
800
|
+
const body = /** @type {{ choices?: unknown, usage?: Record<string, number> }} */ (response);
|
|
801
|
+
if (!Array.isArray(body.choices) || body.choices.length === 0) {
|
|
802
|
+
throw envelopeError('relevance response envelope was incomplete');
|
|
803
|
+
}
|
|
804
|
+
const message = /** @type {{ message?: { content?: unknown } }} */ (body.choices[0]).message;
|
|
805
|
+
if (!message || typeof message.content !== 'string') {
|
|
806
|
+
throw envelopeError('relevance response envelope was incomplete');
|
|
807
|
+
}
|
|
808
|
+
const content = message.content;
|
|
809
|
+
const usage = body.usage || {};
|
|
810
|
+
const promptTokens = Number(usage.prompt_tokens ?? usage.input_tokens ?? 0);
|
|
811
|
+
const completionTokens = Number(usage.completion_tokens ?? usage.output_tokens ?? 0);
|
|
812
|
+
const tokens = Number(usage.total_tokens ?? (promptTokens + completionTokens));
|
|
813
|
+
return {
|
|
814
|
+
raw: content,
|
|
815
|
+
promptTokens,
|
|
816
|
+
completionTokens,
|
|
817
|
+
tokens,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* @param {unknown} err
|
|
823
|
+
* @returns {string}
|
|
824
|
+
*/
|
|
825
|
+
function errorMessage(err) {
|
|
826
|
+
if (err instanceof Error) return `${err.name}: ${err.message}`;
|
|
827
|
+
return String(err);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Timeouts and HTTP 5xx (including 503). Other failures fail open immediately.
|
|
832
|
+
* @param {unknown} err
|
|
833
|
+
* @returns {boolean}
|
|
834
|
+
*/
|
|
835
|
+
function isTransientFilterError(err) {
|
|
836
|
+
const status = err && typeof err === 'object'
|
|
837
|
+
? (/** @type {{ status?: number, statusCode?: number }} */ (err).status
|
|
838
|
+
?? /** @type {{ statusCode?: number }} */ (err).statusCode)
|
|
839
|
+
: undefined;
|
|
840
|
+
if (typeof status === 'number' && status >= 500 && status <= 599) return true;
|
|
841
|
+
const code = err && typeof err === 'object'
|
|
842
|
+
? (/** @type {{ code?: string, cause?: { code?: string } }} */ (err).code
|
|
843
|
+
|| /** @type {{ cause?: { code?: string } }} */ (err).cause?.code)
|
|
844
|
+
: undefined;
|
|
845
|
+
if (typeof code === 'string' && /^(ETIMEDOUT|ECONNABORTED|UND_ERR_CONNECT_TIMEOUT|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT|UND_ERR_SOCKET)$/.test(code)) {
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
const name = err && typeof err === 'object' ? /** @type {{ name?: string }} */ (err).name : '';
|
|
849
|
+
if (name === 'TimeoutError' || name === 'AbortError') return true;
|
|
850
|
+
const message = errorMessage(err);
|
|
851
|
+
if (/timeout|timed out/i.test(message)) return true;
|
|
852
|
+
if (/\b5\d\d\b/.test(message)) return true;
|
|
853
|
+
if (/billing reservation/i.test(message)) return true;
|
|
854
|
+
return false;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* A 4xx that names `reasoning_effort`. The filter retries that call once
|
|
859
|
+
* without the parameter.
|
|
860
|
+
* @param {unknown} err
|
|
861
|
+
* @returns {boolean}
|
|
862
|
+
*/
|
|
863
|
+
function isReasoningEffortRejected(err) {
|
|
864
|
+
const message = errorMessage(err);
|
|
865
|
+
if (!/reasoning[_ ]effort/i.test(message)) return false;
|
|
866
|
+
const status = err && typeof err === 'object'
|
|
867
|
+
? (/** @type {{ status?: number, statusCode?: number }} */ (err).status
|
|
868
|
+
?? /** @type {{ statusCode?: number }} */ (err).statusCode)
|
|
869
|
+
: undefined;
|
|
870
|
+
if (typeof status === 'number') return status >= 400 && status < 500;
|
|
871
|
+
return /\b4\d\d\b/.test(message);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* @param {unknown} err
|
|
876
|
+
*/
|
|
877
|
+
function logCacheError(err) {
|
|
878
|
+
const message = errorMessage(err);
|
|
879
|
+
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
880
|
+
console.warn(`relevance cache error: ${message}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* @param {unknown} value
|
|
886
|
+
* @returns {number}
|
|
887
|
+
*/
|
|
888
|
+
function resolveRetries(value) {
|
|
889
|
+
if (value === undefined) return 2;
|
|
890
|
+
const n = Math.trunc(Number(value));
|
|
891
|
+
if (!Number.isFinite(n) || n < 0) throw new Error('retries must be >= 0');
|
|
892
|
+
return n;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* @param {string} pattern
|
|
897
|
+
* @returns {RegExp}
|
|
898
|
+
*/
|
|
899
|
+
function globToRegExp(pattern) {
|
|
900
|
+
let re = '';
|
|
901
|
+
let i = 0;
|
|
902
|
+
const src = pattern.replace(/\\/g, '/').toLowerCase();
|
|
903
|
+
while (i < src.length) {
|
|
904
|
+
if (src.startsWith('**/', i)) {
|
|
905
|
+
re += '(?:.*/)?';
|
|
906
|
+
i += 3;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
if (src.startsWith('**', i)) {
|
|
910
|
+
re += '.*';
|
|
911
|
+
i += 2;
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
const ch = src[i];
|
|
915
|
+
if (ch === '*') re += '[^/]*';
|
|
916
|
+
else {
|
|
917
|
+
if (/[\\^$+?.()|{}[\]]/.test(ch)) re += '\\';
|
|
918
|
+
re += ch;
|
|
919
|
+
}
|
|
920
|
+
i += 1;
|
|
921
|
+
}
|
|
922
|
+
return new RegExp(`^${re}$`);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* @param {string} filePath
|
|
927
|
+
* @param {string[]} patterns
|
|
928
|
+
* @returns {boolean}
|
|
929
|
+
*/
|
|
930
|
+
export function isIgnoredPath(filePath, patterns = DEFAULT_IGNORE_PATTERNS) {
|
|
931
|
+
const normalized = String(filePath).replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
|
|
932
|
+
return patterns.some((pattern) => globToRegExp(pattern).test(normalized));
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* @param {unknown} ignore
|
|
937
|
+
* @returns {string[]}
|
|
938
|
+
*/
|
|
939
|
+
function resolveIgnore(ignore) {
|
|
940
|
+
if (ignore === false || ignore === null) return [];
|
|
941
|
+
if (ignore === undefined || ignore === true) return DEFAULT_IGNORE_PATTERNS;
|
|
942
|
+
if (Array.isArray(ignore)) return ignore.map(String);
|
|
943
|
+
throw new TypeError('ignore must be true, false, or an array of patterns');
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Included without a definitive yes/no: a thrown call, an empty answer, or an
|
|
948
|
+
* ambiguous answer.
|
|
949
|
+
* @param {RelevanceDecision} decision
|
|
950
|
+
* @returns {boolean}
|
|
951
|
+
*/
|
|
952
|
+
function isFailedOpen(decision) {
|
|
953
|
+
if (!decision) return false;
|
|
954
|
+
if (decision.failedOpen) return true;
|
|
955
|
+
if (decision.error) return true;
|
|
956
|
+
return typeof decision.reason === 'string' && (
|
|
957
|
+
decision.reason.startsWith('ambiguous answer; included by default')
|
|
958
|
+
|| decision.reason.startsWith('malformed answer; included by default')
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* A stored judgment is reusable only when it is a definitive yes or no.
|
|
964
|
+
* Fallbacks and errors are not, even if an older cache file still holds them.
|
|
965
|
+
* @param {RelevanceCacheRecord | undefined} hit
|
|
966
|
+
* @returns {boolean}
|
|
967
|
+
*/
|
|
968
|
+
function reusableHit(hit) {
|
|
969
|
+
if (!hit || (hit.decision !== 'yes' && hit.decision !== 'no') || hit.error) return false;
|
|
970
|
+
if (hit.failed_open || /** @type {{ failedOpen?: boolean }} */ (hit).failedOpen) return false;
|
|
971
|
+
const reason = hit.reason || '';
|
|
972
|
+
if (
|
|
973
|
+
reason.startsWith('ambiguous answer')
|
|
974
|
+
|| reason.startsWith('malformed answer')
|
|
975
|
+
|| reason.startsWith('error; included by default')
|
|
976
|
+
) return false;
|
|
977
|
+
return true;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* @template T
|
|
982
|
+
* @param {T[]} items
|
|
983
|
+
* @param {number} limit
|
|
984
|
+
* @param {(item: T) => Promise<void>} fn
|
|
985
|
+
* @returns {Promise<void>}
|
|
986
|
+
*/
|
|
987
|
+
async function mapLimit(items, limit, fn) {
|
|
988
|
+
if (!items.length) return;
|
|
989
|
+
let cursor = 0;
|
|
990
|
+
const workers = Math.min(limit, items.length);
|
|
991
|
+
const run = async () => {
|
|
992
|
+
while (cursor < items.length) {
|
|
993
|
+
const index = cursor;
|
|
994
|
+
cursor += 1;
|
|
995
|
+
await fn(items[index]);
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
await Promise.all(Array.from({ length: workers }, () => run()));
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* @param {{ get: Function, put?: Function, set?: Function }} store
|
|
1003
|
+
* @param {string} key
|
|
1004
|
+
* @returns {Promise<RelevanceCacheRecord | undefined>}
|
|
1005
|
+
*/
|
|
1006
|
+
async function cacheGet(store, key) {
|
|
1007
|
+
const hit = await store.get(key);
|
|
1008
|
+
return hit || undefined;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* @param {{ put?: Function, set?: Function }} store
|
|
1013
|
+
* @param {string} key
|
|
1014
|
+
* @param {RelevanceCacheRecord} record
|
|
1015
|
+
* @returns {Promise<void>}
|
|
1016
|
+
*/
|
|
1017
|
+
async function cachePut(store, key, record) {
|
|
1018
|
+
if (typeof store.put === 'function') await store.put(key, record);
|
|
1019
|
+
else await store.set(key, record);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Ask a cheap model which files are relevant to `task`.
|
|
1024
|
+
*
|
|
1025
|
+
* `files` is a `{ path: content }` map (string or byte array). The returned
|
|
1026
|
+
* `files` map contains only the `yes` files, in input order, with their
|
|
1027
|
+
* original contents — a truncated preview is what the model saw, not what is
|
|
1028
|
+
* forwarded. `decisions` has one entry per input file.
|
|
1029
|
+
*
|
|
1030
|
+
* `cache` is a {@link RelevanceCache}, a filesystem path (memory plus that
|
|
1031
|
+
* JSON file), `true` or omitted (a fresh in-memory cache for this call), or
|
|
1032
|
+
* `null` / `false` (disabled). The cache key hashes the prompt version, model,
|
|
1033
|
+
* output-token limit, the reasoning effort actually sent, task, path, mode,
|
|
1034
|
+
* preview note, and the body that was sent. A cache read failure is a miss.
|
|
1035
|
+
* A cache write failure is logged and the run continues. Each path is
|
|
1036
|
+
* classified on its own, including paths whose contents match.
|
|
1037
|
+
*
|
|
1038
|
+
* Binary files and files over `maxBytes` / `maxTokens` are not sent in full.
|
|
1039
|
+
* `onBinary` and `onOversize` are `include` (keep, no model call), `exclude`
|
|
1040
|
+
* (drop, no model call), or `truncate` (ask about a truncated or redacted
|
|
1041
|
+
* preview). Pass `0` or `null` for `maxBytes` or `maxTokens` to disable that
|
|
1042
|
+
* limit. Credentials resolve the same way as the rest of the library.
|
|
1043
|
+
*
|
|
1044
|
+
* @param {Record<string, string | Uint8Array>} files
|
|
1045
|
+
* @param {string} task
|
|
1046
|
+
* @param {object} [opts]
|
|
1047
|
+
* @param {string} [opts.model] `GPTDIFF_FILTER_MODEL`, else {@link DEFAULT_FILTER_MODEL}
|
|
1048
|
+
* @param {number} [opts.concurrency] max in-flight calls (default {@link DEFAULT_FILTER_CONCURRENCY})
|
|
1049
|
+
* @param {RelevanceCache | string | boolean | null | { get: Function, put?: Function, set?: Function }} [opts.cache]
|
|
1050
|
+
* @param {number | null} [opts.maxBytes] UTF-8 cap; `0` or `null` disables; default {@link DEFAULT_FILTER_MAX_BYTES}
|
|
1051
|
+
* @param {number | null} [opts.maxTokens] input-token cap; `0` or `null` disables; default {@link DEFAULT_FILTER_MAX_TOKENS}
|
|
1052
|
+
* @param {'include' | 'exclude' | 'truncate'} [opts.onOversize] default `truncate`
|
|
1053
|
+
* @param {'include' | 'exclude' | 'truncate'} [opts.onBinary] default `exclude`
|
|
1054
|
+
* @param {number} [opts.maxOutputTokens] completion cap (default {@link DEFAULT_FILTER_MAX_OUTPUT_TOKENS})
|
|
1055
|
+
* @param {string | null} [opts.reasoningEffort] default {@link DEFAULT_FILTER_REASONING_EFFORT}; `null` omits it
|
|
1056
|
+
* @param {number} [opts.retries] extra attempts after a timeout, HTTP 5xx, or a billing-reservation error (default 2). A 4xx that names `reasoning_effort` is retried once without that parameter and does not consume one of these attempts.
|
|
1057
|
+
* @param {number} [opts.retryBaseMs] backoff base in ms (default 200, doubles, capped at 30s)
|
|
1058
|
+
* @param {boolean | string[] | null} [opts.ignore] lockfile and generated-file patterns.
|
|
1059
|
+
* `true` or omitted uses {@link DEFAULT_IGNORE_PATTERNS}. `false` or `null` classifies every path.
|
|
1060
|
+
* An array replaces the default list.
|
|
1061
|
+
* @param {(decision: RelevanceDecision) => void} [opts.onDecision] called as each file finishes (completion order)
|
|
1062
|
+
* @param {string} [opts.apiKey] `GPTDIFF_LLM_API_KEY` when omitted
|
|
1063
|
+
* @param {string} [opts.baseUrl] `GPTDIFF_LLM_BASE_URL` when omitted
|
|
1064
|
+
* @param {Function} [opts.callLlm] injectable client (tests)
|
|
1065
|
+
* @returns {Promise<FilterRelevantResult>}
|
|
1066
|
+
*/
|
|
1067
|
+
export async function filterRelevant(files, task, opts = {}) {
|
|
1068
|
+
if (files == null || typeof files !== 'object' || Array.isArray(files)) {
|
|
1069
|
+
throw new TypeError('files must be an object of path -> content');
|
|
1070
|
+
}
|
|
1071
|
+
if (typeof task !== 'string' || task.trim() === '') {
|
|
1072
|
+
throw new Error('task is required');
|
|
1073
|
+
}
|
|
1074
|
+
const concurrency = Math.trunc(Number(opts.concurrency === undefined ? DEFAULT_FILTER_CONCURRENCY : opts.concurrency));
|
|
1075
|
+
if (!Number.isFinite(concurrency) || concurrency < 1) {
|
|
1076
|
+
throw new Error('concurrency must be >= 1');
|
|
1077
|
+
}
|
|
1078
|
+
const onOversize = checkPolicy('onOversize', opts.onOversize === undefined ? 'truncate' : opts.onOversize);
|
|
1079
|
+
const onBinary = checkPolicy('onBinary', opts.onBinary === undefined ? 'exclude' : opts.onBinary);
|
|
1080
|
+
const maxBytes = resolveLimit(opts.maxBytes, DEFAULT_FILTER_MAX_BYTES);
|
|
1081
|
+
const maxTokens = resolveLimit(opts.maxTokens, DEFAULT_FILTER_MAX_TOKENS);
|
|
1082
|
+
const maxOutputTokens = opts.maxOutputTokens === undefined
|
|
1083
|
+
? DEFAULT_FILTER_MAX_OUTPUT_TOKENS
|
|
1084
|
+
: opts.maxOutputTokens;
|
|
1085
|
+
const reasoningEffort = opts.reasoningEffort === undefined
|
|
1086
|
+
? DEFAULT_FILTER_REASONING_EFFORT
|
|
1087
|
+
: opts.reasoningEffort;
|
|
1088
|
+
const retries = resolveRetries(opts.retries);
|
|
1089
|
+
const retryBaseMs = opts.retryBaseMs === undefined ? 200 : Math.max(0, Number(opts.retryBaseMs) || 0);
|
|
1090
|
+
const ignorePatterns = resolveIgnore(opts.ignore);
|
|
1091
|
+
|
|
1092
|
+
const model = resolveFilterModel(opts.model);
|
|
1093
|
+
const store = resolveCache(opts.cache);
|
|
1094
|
+
const callLlm = opts.callLlm || defaultCallLlm;
|
|
1095
|
+
const apiKey = opts.apiKey == null ? resolveApiKey(undefined) : opts.apiKey;
|
|
1096
|
+
const baseUrl = resolveBaseUrl(opts.baseUrl);
|
|
1097
|
+
const onDecision = opts.onDecision;
|
|
1098
|
+
|
|
1099
|
+
/** @type {Map<string, RelevanceDecision>} */
|
|
1100
|
+
const byPath = new Map();
|
|
1101
|
+
/**
|
|
1102
|
+
* @typedef {object} PendingFile
|
|
1103
|
+
* @property {string} path
|
|
1104
|
+
* @property {string} body content sent to the model
|
|
1105
|
+
* @property {string} note exact preview note, or ""
|
|
1106
|
+
* @property {'full' | 'truncated' | 'binary'} mode
|
|
1107
|
+
* @property {'truncated' | null} skippedReason
|
|
1108
|
+
*/
|
|
1109
|
+
/** @type {PendingFile[]} */
|
|
1110
|
+
const pending = [];
|
|
1111
|
+
const entries = Object.entries(files);
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* @param {RelevanceDecision} decision
|
|
1115
|
+
* @returns {RelevanceDecision}
|
|
1116
|
+
*/
|
|
1117
|
+
function emit(decision) {
|
|
1118
|
+
if (typeof onDecision === 'function') onDecision(decision);
|
|
1119
|
+
return decision;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
for (const [path, content] of entries) {
|
|
1123
|
+
if (isIgnoredPath(path, ignorePatterns)) {
|
|
1124
|
+
const made = skipDecision(
|
|
1125
|
+
path,
|
|
1126
|
+
'no',
|
|
1127
|
+
'ignored',
|
|
1128
|
+
'lockfile or generated file; excluded without a model call',
|
|
1129
|
+
);
|
|
1130
|
+
byPath.set(path, made);
|
|
1131
|
+
emit(made);
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
let binary = isBinaryContent(content);
|
|
1136
|
+
/** @type {string} */
|
|
1137
|
+
let text = '';
|
|
1138
|
+
if (!binary) {
|
|
1139
|
+
text = coerceText(content);
|
|
1140
|
+
if (content instanceof Uint8Array && content.length > 0 && text === '') binary = true;
|
|
1141
|
+
}
|
|
1142
|
+
if (binary) {
|
|
1143
|
+
if (onBinary === 'truncate') {
|
|
1144
|
+
const nbytes = content instanceof Uint8Array
|
|
1145
|
+
? content.length
|
|
1146
|
+
: utf8ByteLength(typeof content === 'string' ? content : text);
|
|
1147
|
+
const note = 'Note: this file is binary. Its bytes are not shown; judge relevance from the path and this note.';
|
|
1148
|
+
const body = `[binary file, ${nbytes} bytes, contents omitted]`;
|
|
1149
|
+
pending.push({ path, body, note, mode: 'binary', skippedReason: 'truncated' });
|
|
1150
|
+
} else {
|
|
1151
|
+
const decision = onBinary === 'include' ? 'yes' : 'no';
|
|
1152
|
+
const verb = decision === 'yes' ? 'included' : 'excluded';
|
|
1153
|
+
const made = skipDecision(path, decision, 'binary', `binary file; ${verb} without a model call`);
|
|
1154
|
+
byPath.set(path, made);
|
|
1155
|
+
emit(made);
|
|
1156
|
+
}
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const nBytes = utf8ByteLength(text);
|
|
1161
|
+
const nTokens = countTokens(text);
|
|
1162
|
+
const overBytes = !!maxBytes && nBytes > maxBytes;
|
|
1163
|
+
const overTokens = !!maxTokens && nTokens > maxTokens;
|
|
1164
|
+
if (overBytes || overTokens) {
|
|
1165
|
+
if (onOversize === 'truncate') {
|
|
1166
|
+
const body = truncateText(text, maxBytes, maxTokens);
|
|
1167
|
+
const note = (
|
|
1168
|
+
'Note: this file was truncated to fit the relevance check '
|
|
1169
|
+
+ `(${nBytes} bytes, ${nTokens} tokens). Judge from the portion shown.`
|
|
1170
|
+
);
|
|
1171
|
+
pending.push({ path, body, note, mode: 'truncated', skippedReason: 'truncated' });
|
|
1172
|
+
} else {
|
|
1173
|
+
const decision = onOversize === 'include' ? 'yes' : 'no';
|
|
1174
|
+
const verb = decision === 'yes' ? 'included' : 'excluded';
|
|
1175
|
+
const made = skipDecision(
|
|
1176
|
+
path,
|
|
1177
|
+
decision,
|
|
1178
|
+
'oversize',
|
|
1179
|
+
`over size threshold (${nBytes} bytes, ${nTokens} tokens); ${verb} without a model call`,
|
|
1180
|
+
);
|
|
1181
|
+
byPath.set(path, made);
|
|
1182
|
+
emit(made);
|
|
1183
|
+
}
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
pending.push({ path, body: text, note: '', mode: 'full', skippedReason: null });
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (store && typeof store.load === 'function') {
|
|
1191
|
+
try {
|
|
1192
|
+
await store.load();
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
logCacheError(err);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* @param {string} key
|
|
1200
|
+
* @returns {Promise<RelevanceCacheRecord | undefined>}
|
|
1201
|
+
*/
|
|
1202
|
+
async function readCache(key) {
|
|
1203
|
+
if (!store) return undefined;
|
|
1204
|
+
try {
|
|
1205
|
+
return await cacheGet(store, key);
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
logCacheError(err);
|
|
1208
|
+
return undefined;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const configuredEffort = reasoningEffort || null;
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Key for a lookup before the call, using the configured reasoning effort.
|
|
1216
|
+
* @param {PendingFile} item
|
|
1217
|
+
* @param {string | null} effort
|
|
1218
|
+
* @returns {Promise<string>}
|
|
1219
|
+
*/
|
|
1220
|
+
function keyFor(item, effort) {
|
|
1221
|
+
return RelevanceCache.makeKey({
|
|
1222
|
+
model,
|
|
1223
|
+
intent: task,
|
|
1224
|
+
path: item.path,
|
|
1225
|
+
content: item.body,
|
|
1226
|
+
mode: item.mode,
|
|
1227
|
+
note: item.note || '',
|
|
1228
|
+
maxOutputTokens,
|
|
1229
|
+
reasoningEffort: effort,
|
|
1230
|
+
promptVersion: FILTER_PROMPT_VERSION,
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* The configured effort is tried first. A judgment stored after the
|
|
1236
|
+
* provider rejected that parameter lives under the empty effort.
|
|
1237
|
+
* @param {PendingFile} item
|
|
1238
|
+
* @returns {Promise<{ hit: RelevanceCacheRecord, key: string } | null>}
|
|
1239
|
+
*/
|
|
1240
|
+
async function loadReusable(item) {
|
|
1241
|
+
const primaryKey = await keyFor(item, configuredEffort);
|
|
1242
|
+
const primary = await readCache(primaryKey);
|
|
1243
|
+
if (primary && reusableHit(primary)) return { hit: primary, key: primaryKey };
|
|
1244
|
+
if (configuredEffort) {
|
|
1245
|
+
const fallbackKey = await keyFor(item, null);
|
|
1246
|
+
const fallback = await readCache(fallbackKey);
|
|
1247
|
+
if (fallback && reusableHit(fallback)) return { hit: fallback, key: fallbackKey };
|
|
1248
|
+
}
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
let needsCall = false;
|
|
1253
|
+
for (const item of pending) {
|
|
1254
|
+
if (!(await loadReusable(item))) {
|
|
1255
|
+
needsCall = true;
|
|
1256
|
+
break;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
if (needsCall && !apiKey) {
|
|
1260
|
+
throw new Error(
|
|
1261
|
+
'GPTDIFF_LLM_API_KEY is required for the relevance filter. '
|
|
1262
|
+
+ 'Set it in the environment or pass apiKey.',
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* @param {PendingFile} item
|
|
1268
|
+
* @returns {Promise<void>}
|
|
1269
|
+
*/
|
|
1270
|
+
async function runOne(item) {
|
|
1271
|
+
const cached = await loadReusable(item);
|
|
1272
|
+
if (cached) {
|
|
1273
|
+
const decision = decisionFromRecord(item.path, { ...cached.hit, cache_key: cached.key }, true);
|
|
1274
|
+
byPath.set(item.path, decision);
|
|
1275
|
+
emit(decision);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
let raw;
|
|
1280
|
+
let promptTokens;
|
|
1281
|
+
let completionTokens;
|
|
1282
|
+
let totalTokens;
|
|
1283
|
+
/** @type {unknown} */
|
|
1284
|
+
let lastError = null;
|
|
1285
|
+
/** @type {string | null} */
|
|
1286
|
+
let effortUsed = configuredEffort;
|
|
1287
|
+
const userPrompt = buildUserPrompt(task, item.path, item.body, item.note);
|
|
1288
|
+
let userContent = userPrompt;
|
|
1289
|
+
if (model && model.includes('gemini')) userContent = `${SYSTEM_PROMPT}\n${userPrompt}`;
|
|
1290
|
+
const attempts = retries + 1;
|
|
1291
|
+
let strippedEffort = false;
|
|
1292
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
1293
|
+
try {
|
|
1294
|
+
const response = await callLlm({
|
|
1295
|
+
apiKey,
|
|
1296
|
+
baseUrl,
|
|
1297
|
+
model,
|
|
1298
|
+
messages: [
|
|
1299
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
1300
|
+
{ role: 'user', content: userContent },
|
|
1301
|
+
],
|
|
1302
|
+
maxTokens: maxOutputTokens,
|
|
1303
|
+
temperature: 0,
|
|
1304
|
+
budgetTokens: null,
|
|
1305
|
+
reasoningEffort: effortUsed,
|
|
1306
|
+
maxRetries: 0,
|
|
1307
|
+
});
|
|
1308
|
+
({ raw, promptTokens, completionTokens, tokens: totalTokens } = usageAndText(response));
|
|
1309
|
+
lastError = null;
|
|
1310
|
+
break;
|
|
1311
|
+
} catch (err) {
|
|
1312
|
+
lastError = err;
|
|
1313
|
+
if (err && typeof err === 'object' && /** @type {{ envelope?: boolean }} */ (err).envelope) {
|
|
1314
|
+
break;
|
|
1315
|
+
}
|
|
1316
|
+
if (!strippedEffort && effortUsed && isReasoningEffortRejected(err)) {
|
|
1317
|
+
effortUsed = null;
|
|
1318
|
+
strippedEffort = true;
|
|
1319
|
+
attempt -= 1;
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
const again = attempt + 1 < attempts && isTransientFilterError(err);
|
|
1323
|
+
if (!again) break;
|
|
1324
|
+
const wait = Math.min(retryBaseMs * 2 ** attempt, 30_000);
|
|
1325
|
+
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
if (lastError) {
|
|
1329
|
+
const message = errorMessage(lastError);
|
|
1330
|
+
const decision = decisionObject({
|
|
1331
|
+
path: item.path,
|
|
1332
|
+
decision: 'yes',
|
|
1333
|
+
reason: `error; included by default: ${message}`,
|
|
1334
|
+
raw: null,
|
|
1335
|
+
tokens: 0,
|
|
1336
|
+
promptTokens: 0,
|
|
1337
|
+
completionTokens: 0,
|
|
1338
|
+
cached: false,
|
|
1339
|
+
skippedReason: item.skippedReason,
|
|
1340
|
+
error: message,
|
|
1341
|
+
failedOpen: true,
|
|
1342
|
+
});
|
|
1343
|
+
byPath.set(item.path, decision);
|
|
1344
|
+
emit(decision);
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const parsed = parseRelevanceAnswer(raw);
|
|
1349
|
+
/** @type {'yes' | 'no'} */
|
|
1350
|
+
let decisionName;
|
|
1351
|
+
/** @type {string} */
|
|
1352
|
+
let reason;
|
|
1353
|
+
/** @type {string | null} */
|
|
1354
|
+
let error = null;
|
|
1355
|
+
let cacheable = false;
|
|
1356
|
+
let failedOpen = false;
|
|
1357
|
+
if (parsed.decision == null && !parsed.cleaned) {
|
|
1358
|
+
decisionName = 'yes';
|
|
1359
|
+
error = 'empty answer';
|
|
1360
|
+
reason = `error; included by default: ${error}`;
|
|
1361
|
+
failedOpen = true;
|
|
1362
|
+
} else if (parsed.decision == null) {
|
|
1363
|
+
decisionName = 'yes';
|
|
1364
|
+
reason = `malformed answer; included by default: ${parsed.cleaned}`;
|
|
1365
|
+
failedOpen = true;
|
|
1366
|
+
} else {
|
|
1367
|
+
decisionName = parsed.decision;
|
|
1368
|
+
reason = parsed.cleaned;
|
|
1369
|
+
cacheable = true;
|
|
1370
|
+
}
|
|
1371
|
+
const storeKey = await keyFor(item, effortUsed);
|
|
1372
|
+
if (store && cacheable) {
|
|
1373
|
+
/** @type {RelevanceCacheRecord} */
|
|
1374
|
+
const record = {
|
|
1375
|
+
decision: decisionName,
|
|
1376
|
+
reason,
|
|
1377
|
+
raw,
|
|
1378
|
+
tokens: totalTokens,
|
|
1379
|
+
prompt_tokens: promptTokens,
|
|
1380
|
+
completion_tokens: completionTokens,
|
|
1381
|
+
skipped_reason: item.skippedReason,
|
|
1382
|
+
error: null,
|
|
1383
|
+
cache_key: storeKey,
|
|
1384
|
+
failed_open: false,
|
|
1385
|
+
};
|
|
1386
|
+
try {
|
|
1387
|
+
await cachePut(store, storeKey, record);
|
|
1388
|
+
} catch (err) {
|
|
1389
|
+
logCacheError(err);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
const decision = decisionObject({
|
|
1393
|
+
path: item.path,
|
|
1394
|
+
decision: decisionName,
|
|
1395
|
+
reason,
|
|
1396
|
+
raw,
|
|
1397
|
+
tokens: totalTokens,
|
|
1398
|
+
promptTokens,
|
|
1399
|
+
completionTokens,
|
|
1400
|
+
cached: false,
|
|
1401
|
+
skippedReason: item.skippedReason,
|
|
1402
|
+
error,
|
|
1403
|
+
failedOpen,
|
|
1404
|
+
});
|
|
1405
|
+
byPath.set(item.path, decision);
|
|
1406
|
+
emit(decision);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
try {
|
|
1410
|
+
await mapLimit(pending, concurrency, runOne);
|
|
1411
|
+
} finally {
|
|
1412
|
+
if (store && typeof store.flush === 'function') {
|
|
1413
|
+
try {
|
|
1414
|
+
await store.flush();
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
logCacheError(err);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** @type {RelevanceDecision[]} */
|
|
1422
|
+
const decisions = entries.map(([path]) => byPath.get(path));
|
|
1423
|
+
/** @type {Record<string, string | Uint8Array>} */
|
|
1424
|
+
const included = {};
|
|
1425
|
+
for (const [path, content] of entries) {
|
|
1426
|
+
if (byPath.get(path).decision !== 'yes') continue;
|
|
1427
|
+
if (content instanceof Uint8Array && isBinaryContent(content)) {
|
|
1428
|
+
included[path] = content;
|
|
1429
|
+
} else {
|
|
1430
|
+
included[path] = coerceText(content);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
const errors = decisions.filter((decision) => decision.error);
|
|
1434
|
+
const failedOpen = decisions.filter(isFailedOpen).length;
|
|
1435
|
+
let billedTokens = 0;
|
|
1436
|
+
let billedPromptTokens = 0;
|
|
1437
|
+
let billedCompletionTokens = 0;
|
|
1438
|
+
for (const decision of decisions) {
|
|
1439
|
+
if (decision.cached) continue;
|
|
1440
|
+
billedTokens += decision.tokens;
|
|
1441
|
+
billedPromptTokens += decision.promptTokens;
|
|
1442
|
+
billedCompletionTokens += decision.completionTokens;
|
|
1443
|
+
}
|
|
1444
|
+
return {
|
|
1445
|
+
files: included,
|
|
1446
|
+
decisions,
|
|
1447
|
+
model,
|
|
1448
|
+
errors,
|
|
1449
|
+
billedTokens,
|
|
1450
|
+
billedPromptTokens,
|
|
1451
|
+
billedCompletionTokens,
|
|
1452
|
+
failedOpen,
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* One-screen summary of a filter pass. Per-file lines when `verbose`.
|
|
1458
|
+
* @param {FilterRelevantResult} result
|
|
1459
|
+
* @param {boolean} [verbose]
|
|
1460
|
+
* @returns {string}
|
|
1461
|
+
*/
|
|
1462
|
+
export function formatFilterSummary(result, verbose = false) {
|
|
1463
|
+
const total = result.decisions.length;
|
|
1464
|
+
const kept = Object.keys(result.files).length;
|
|
1465
|
+
const errorCount = result.errors.length;
|
|
1466
|
+
const failedOpen = Number.isFinite(result.failedOpen)
|
|
1467
|
+
? result.failedOpen
|
|
1468
|
+
: result.decisions.filter(isFailedOpen).length;
|
|
1469
|
+
let line = `Relevance filter (${result.model}): kept ${kept}/${total} files, ${result.billedTokens} tokens`;
|
|
1470
|
+
if (errorCount) line += `, ${errorCount} error${errorCount === 1 ? '' : 's'}`;
|
|
1471
|
+
if (failedOpen) line += `, ${failedOpen} failed open`;
|
|
1472
|
+
const lines = [line];
|
|
1473
|
+
for (const decision of result.decisions) {
|
|
1474
|
+
if (decision.error) {
|
|
1475
|
+
lines.push(` error ${decision.path}: ${decision.error} (included by default)`);
|
|
1476
|
+
} else if (verbose) {
|
|
1477
|
+
const flags = [];
|
|
1478
|
+
if (decision.cached) flags.push('cached');
|
|
1479
|
+
if (decision.skippedReason) flags.push(decision.skippedReason);
|
|
1480
|
+
const suffix = flags.length ? ` (${flags.join(', ')})` : '';
|
|
1481
|
+
lines.push(` ${decision.decision.padEnd(3, ' ')} ${decision.path}${suffix}`);
|
|
1482
|
+
} else if (decision.skippedReason && decision.decision === 'no') {
|
|
1483
|
+
lines.push(` skip ${decision.path} (${decision.skippedReason})`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
return lines.join('\n');
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* Write the filter result as JSON so callers can inspect every decision.
|
|
1491
|
+
* Decision fields stay camelCase. `files` is the list of kept paths.
|
|
1492
|
+
* @param {FilterRelevantResult} result
|
|
1493
|
+
* @param {string} path
|
|
1494
|
+
* @returns {Promise<void>}
|
|
1495
|
+
*/
|
|
1496
|
+
export async function dumpDecisions(result, path) {
|
|
1497
|
+
const fs = await import('node:fs/promises');
|
|
1498
|
+
const pathMod = await import('node:path');
|
|
1499
|
+
const destination = String(path);
|
|
1500
|
+
const directory = pathMod.dirname(pathMod.resolve(destination));
|
|
1501
|
+
if (directory) await fs.mkdir(directory, { recursive: true });
|
|
1502
|
+
const payload = {
|
|
1503
|
+
model: result.model,
|
|
1504
|
+
files: Object.keys(result.files),
|
|
1505
|
+
decisions: result.decisions,
|
|
1506
|
+
billedTokens: result.billedTokens,
|
|
1507
|
+
billedPromptTokens: result.billedPromptTokens,
|
|
1508
|
+
billedCompletionTokens: result.billedCompletionTokens,
|
|
1509
|
+
failedOpen: result.failedOpen,
|
|
1510
|
+
errors: result.errors,
|
|
1511
|
+
};
|
|
1512
|
+
await fs.writeFile(destination, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
1513
|
+
}
|