@sayknow-cli/coding-agent 0.6.5 → 0.6.7

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/config/keybindings.d.ts +5 -0
  3. package/dist/types/config/settings-schema.d.ts +79 -0
  4. package/dist/types/i18n/messages/en.d.ts +6 -29
  5. package/dist/types/modes/components/welcome.d.ts +34 -40
  6. package/dist/types/modes/interactive-mode.d.ts +7 -4
  7. package/dist/types/modes/types.d.ts +4 -0
  8. package/dist/types/sdk/broker/broker.d.ts +22 -0
  9. package/dist/types/sdk/broker/process-guard.d.ts +71 -0
  10. package/dist/types/sdk/broker/transport.d.ts +2 -0
  11. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  12. package/dist/types/session/agent-session.d.ts +9 -0
  13. package/dist/types/session/auth-storage-discovery.d.ts +15 -0
  14. package/dist/types/session/auto-fallback.d.ts +27 -0
  15. package/dist/types/session/fallback-chain-controller.d.ts +5 -0
  16. package/dist/types/session/response-language.d.ts +25 -0
  17. package/dist/types/setup/model-onboarding-guidance.d.ts +8 -1
  18. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  19. package/dist/types/tools/debug.d.ts +2 -2
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/locate-core.d.ts +97 -0
  22. package/dist/types/tools/locate.d.ts +38 -0
  23. package/package.json +7 -7
  24. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  25. package/src/cli/setup-cli.ts +7 -4
  26. package/src/commands/sdk.ts +3 -0
  27. package/src/commands/setup.ts +4 -1
  28. package/src/config/keybindings.ts +7 -0
  29. package/src/config/settings-schema.ts +82 -0
  30. package/src/decisions/typesafe-backend.ts +38 -4
  31. package/src/i18n/messages/de.settings.ts +26 -0
  32. package/src/i18n/messages/de.ts +4 -27
  33. package/src/i18n/messages/en.ts +6 -29
  34. package/src/i18n/messages/es.settings.ts +26 -0
  35. package/src/i18n/messages/es.ts +4 -27
  36. package/src/i18n/messages/fr.settings.ts +26 -0
  37. package/src/i18n/messages/fr.ts +4 -27
  38. package/src/i18n/messages/ja.settings.ts +26 -0
  39. package/src/i18n/messages/ja.ts +4 -27
  40. package/src/i18n/messages/ko.settings.ts +25 -0
  41. package/src/i18n/messages/ko.ts +5 -28
  42. package/src/i18n/messages/zh.settings.ts +23 -0
  43. package/src/i18n/messages/zh.ts +4 -27
  44. package/src/internal-urls/docs-index.generated.ts +8 -7
  45. package/src/modes/action-registry.ts +1 -0
  46. package/src/modes/components/welcome.ts +385 -387
  47. package/src/modes/controllers/input-controller.ts +15 -0
  48. package/src/modes/controllers/selector-controller.ts +13 -0
  49. package/src/modes/interactive-mode.ts +160 -98
  50. package/src/modes/types.ts +4 -0
  51. package/src/prompts/system/system-prompt.md +7 -2
  52. package/src/prompts/tools/locate.md +12 -0
  53. package/src/sdk/broker/broker.ts +75 -1
  54. package/src/sdk/broker/process-guard.ts +160 -0
  55. package/src/sdk/broker/transport.ts +15 -1
  56. package/src/sdk/bus/chat-daemon-runtime.ts +13 -1
  57. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  58. package/src/sdk/session.ts +4 -1
  59. package/src/session/agent-session.ts +118 -2
  60. package/src/session/auth-storage-discovery.ts +22 -7
  61. package/src/session/auto-fallback.ts +59 -0
  62. package/src/session/fallback-chain-controller.ts +5 -0
  63. package/src/session/response-language.ts +71 -0
  64. package/src/setup/model-onboarding-guidance.ts +29 -14
  65. package/src/setup/provider-onboarding.ts +5 -0
  66. package/src/slash-commands/builtin-registry.ts +112 -2
  67. package/src/tools/index.ts +3 -0
  68. package/src/tools/locate-core.ts +720 -0
  69. package/src/tools/locate.ts +197 -0
@@ -0,0 +1,720 @@
1
+ import * as path from "node:path";
2
+ import type { ChoiceQuestion, DecisionRequest, DecisionResult, NoulQuestion } from "../decisions/types";
3
+
4
+ /**
5
+ * Find where behaviour lives by asking Jev, the way `jevgrep` does, but inside SKC and
6
+ * without shipping source bodies: a folder is judged from its path and the names of the
7
+ * files in it; a file is judged from its path and its declaration lines (signatures),
8
+ * never its function bodies or comments.
9
+ *
10
+ * The walk narrows before it widens: large folders are split and only the subfolders Jev
11
+ * rates plausible are entered, so a question about the broker never uploads the TUI.
12
+ */
13
+
14
+ export interface LocateParams {
15
+ query: string;
16
+ /** Absolute search root. */
17
+ root: string;
18
+ /** Most files to return. */
19
+ limit: number;
20
+ }
21
+
22
+ export interface LocateDeps {
23
+ /** Source files under the root, relative, `/`-separated. */
24
+ listFiles(root: string, signal?: AbortSignal): Promise<string[]>;
25
+ /** Declaration lines of one file (`{ line, text }`), empty when none can be extracted. */
26
+ outline(absolutePath: string, signal?: AbortSignal): Promise<OutlineLine[]>;
27
+ /** One Jev request. Null means the request failed or no key is configured. */
28
+ decide(request: DecisionRequest): Promise<DecisionResult | null>;
29
+ /**
30
+ * Full text of a file for the local keyword score. Read and scored on this machine only;
31
+ * nothing from it is sent. Undefined when a file cannot or should not be read.
32
+ */
33
+ readText?(absolutePath: string): Promise<string | undefined>;
34
+ signal?: AbortSignal;
35
+ }
36
+
37
+ export interface OutlineLine {
38
+ line: number;
39
+ text: string;
40
+ }
41
+
42
+ export interface LocatedFile {
43
+ path: string;
44
+ score: number;
45
+ outline: OutlineLine[];
46
+ /** Below {@link FILE_BAR}: a lead worth a glance, not a confident match. */
47
+ weak?: boolean;
48
+ }
49
+
50
+ export interface LocateResult {
51
+ files: LocatedFile[];
52
+ /** Best-scoring files when none cleared the bar, so the caller still has leads. */
53
+ weakLeads: LocatedFile[];
54
+ foldersJudged: number;
55
+ filesJudged: number;
56
+ requests: number;
57
+ failedRequests: number;
58
+ /** Files considered at all after the folder walk (before any cap). */
59
+ candidates: number;
60
+ truncated: boolean;
61
+ durationMs: number;
62
+ }
63
+
64
+ /** A folder with at most this many files underneath is not split further; its files are judged directly. */
65
+ export const LEAF_FOLDER_FILES = 40;
66
+ const FOLDERS_PER_REQUEST = 12;
67
+ const FILES_PER_REQUEST = 8;
68
+ /**
69
+ * Folders are kept relative to the best sibling at the same depth: Jev's folder scores
70
+ * separate well at the top (0.8 for the right one) but leave a long 0.25-0.5 tail that an
71
+ * absolute bar lets through, which multiplies the files judged next.
72
+ */
73
+ const FOLDER_FLOOR = 0.35;
74
+ const FOLDER_RELATIVE = 0.55;
75
+ /** Always follow this many best folders per depth (if they clear {@link FOLDER_MIN}), so a flat score spread never dead-ends. */
76
+ const FOLDERS_ALWAYS_KEPT = 2;
77
+ const FOLDER_MIN = 0.2;
78
+ /** Extra folders per depth entered on the local keyword score despite a low Jev score. */
79
+ const LEXICAL_FOLDERS_KEPT = 2;
80
+ /** Files with the strongest local keyword score, always judged even if their folder was not entered. */
81
+ const LEXICAL_FILES_KEPT = 12;
82
+ /** Weight of the local keyword score (0..1) added to Jev's score for ranking. */
83
+ const LEXICAL_WEIGHT = 0.3;
84
+ /** Priority of files sitting directly in the search root. */
85
+ const ROOT_FILE_PRIORITY = 0.5;
86
+ export const FILE_BAR = 0.5;
87
+ /** Files between this and {@link FILE_BAR} fill the remaining slots, marked weak. */
88
+ export const WEAK_FILE_BAR = 0.3;
89
+ /** Hard ceilings so one question can never turn into an unbounded bill. */
90
+ export const MAX_REQUESTS = 100;
91
+ const MAX_CANDIDATE_FILES = 480;
92
+ const MAX_FOLDER_DEPTH = 8;
93
+ const FILE_NAMES_PER_FOLDER = 14;
94
+ const OUTLINE_CHARS_PER_FILE = 2_000;
95
+ const CONCURRENCY = 8;
96
+
97
+ interface Folder {
98
+ path: string;
99
+ files: string[];
100
+ children: Set<string>;
101
+ total: number;
102
+ }
103
+
104
+ function buildTree(files: string[]): Map<string, Folder> {
105
+ const folders = new Map<string, Folder>();
106
+ const folder = (dir: string): Folder => {
107
+ let entry = folders.get(dir);
108
+ if (!entry) {
109
+ entry = { path: dir, files: [], children: new Set(), total: 0 };
110
+ folders.set(dir, entry);
111
+ if (dir !== "") folder(parentOf(dir)).children.add(dir);
112
+ }
113
+ return entry;
114
+ };
115
+ folder("");
116
+ for (const file of files) {
117
+ const dir = parentOf(file);
118
+ folder(dir).files.push(file);
119
+ for (let current = dir; ; current = parentOf(current)) {
120
+ folder(current).total += 1;
121
+ if (current === "") break;
122
+ }
123
+ }
124
+ return folders;
125
+ }
126
+
127
+ const STOP_WORDS = new Set(
128
+ "the and for with from that this into when which what where does each only than then have will should would could their there about after before other while used uses using".split(
129
+ " ",
130
+ ),
131
+ );
132
+
133
+ /** Query terms for lexical hints: lowercase words of 4+ letters, cut to a 5-letter stem. */
134
+ export function queryTerms(query: string): string[] {
135
+ const words = query.toLowerCase().match(/[a-z][a-z0-9]{3,}/g) ?? [];
136
+ return [...new Set(words.filter(word => !STOP_WORDS.has(word)).map(word => word.slice(0, 5)))];
137
+ }
138
+
139
+ /** How many query terms appear in `text`, with camelCase and snake_case identifiers split into words. */
140
+ export function termHits(text: string, terms: readonly string[]): number {
141
+ if (terms.length === 0) return 0;
142
+ const words = text
143
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
144
+ .toLowerCase()
145
+ .split(/[^a-z0-9]+/);
146
+ let hits = 0;
147
+ for (const term of terms) if (words.some(word => word.startsWith(term))) hits += 1;
148
+ return hits;
149
+ }
150
+
151
+ /**
152
+ * Local keyword score per file, 0..1: IDF-weighted query terms present in the file's own
153
+ * text (identifiers split into words), normalised to the best file. Uses comments and
154
+ * strings too, since none of it leaves the machine.
155
+ */
156
+ async function lexicalScores(
157
+ root: string,
158
+ files: readonly string[],
159
+ terms: readonly string[],
160
+ readText: (absolutePath: string) => Promise<string | undefined>,
161
+ ): Promise<Map<string, number>> {
162
+ const scores = new Map<string, number>();
163
+ if (terms.length === 0) return scores;
164
+ const present = new Map<string, Set<string>>();
165
+ await inParallel([...files], 32, async file => {
166
+ const text = await readText(path.join(root, file)).catch(() => undefined);
167
+ if (!text) return;
168
+ const words = `${file} ${text}`
169
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
170
+ .toLowerCase()
171
+ .split(/[^a-z0-9]+/);
172
+ const found = new Set<string>();
173
+ for (const word of words) {
174
+ if (word.length < 4) continue;
175
+ const stem = word.slice(0, 5);
176
+ if (terms.includes(stem)) found.add(stem);
177
+ }
178
+ if (found.size > 0) present.set(file, found);
179
+ });
180
+ const total = Math.max(1, files.length);
181
+ const idf = new Map(
182
+ terms.map(term => [term, Math.log(total / (1 + [...present.values()].filter(set => set.has(term)).length))]),
183
+ );
184
+ let best = 0;
185
+ for (const [file, found] of present) {
186
+ const score = [...found].reduce((sum, term) => sum + Math.max(0, idf.get(term)!), 0);
187
+ scores.set(file, score);
188
+ best = Math.max(best, score);
189
+ }
190
+ if (best > 0) for (const [file, score] of scores) scores.set(file, score / best);
191
+ return scores;
192
+ }
193
+
194
+ function parentOf(relative: string): string {
195
+ const dir = path.posix.dirname(relative);
196
+ return dir === "." ? "" : dir;
197
+ }
198
+
199
+ function allFilesUnder(folders: Map<string, Folder>, dir: string): string[] {
200
+ const folder = folders.get(dir);
201
+ if (!folder) return [];
202
+ return [...folder.files, ...[...folder.children].flatMap(child => allFilesUnder(folders, child))];
203
+ }
204
+
205
+ function chunk<T>(items: T[], size: number): T[][] {
206
+ const out: T[][] = [];
207
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
208
+ return out;
209
+ }
210
+
211
+ async function inParallel<T, R>(items: T[], limit: number, run: (item: T) => Promise<R>): Promise<R[]> {
212
+ const results: R[] = new Array(items.length);
213
+ let next = 0;
214
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
215
+ while (next < items.length) {
216
+ const index = next++;
217
+ results[index] = await run(items[index]!);
218
+ }
219
+ });
220
+ await Promise.all(workers);
221
+ return results;
222
+ }
223
+
224
+ export async function locateCode(params: LocateParams, deps: LocateDeps): Promise<LocateResult> {
225
+ const started = Date.now();
226
+ const terms = queryTerms(params.query);
227
+ const allFiles = await deps.listFiles(params.root, deps.signal);
228
+ const folders = buildTree(allFiles);
229
+ const lexical = deps.readText
230
+ ? await lexicalScores(params.root, allFiles, terms, deps.readText)
231
+ : new Map<string, number>();
232
+ const lex = (file: string) => lexical.get(file) ?? 0;
233
+ /** Best local keyword score of any file under a folder. */
234
+ const folderLexical = new Map<string, number>();
235
+ for (const [file, score] of lexical) {
236
+ for (let dir = parentOf(file); ; dir = parentOf(dir)) {
237
+ folderLexical.set(dir, Math.max(folderLexical.get(dir) ?? 0, score));
238
+ if (dir === "") break;
239
+ }
240
+ }
241
+ let requests = 0;
242
+ let failedRequests = 0;
243
+ let foldersJudged = 0;
244
+ let truncated = false;
245
+
246
+ const request = async (
247
+ state: string,
248
+ questions: Record<string, NoulQuestion | ChoiceQuestion>,
249
+ reserved = false,
250
+ ): Promise<DecisionResult | null> => {
251
+ if (deps.signal?.aborted) return null;
252
+ // The final rerank has its own slot so a walk that used the whole budget still gets it.
253
+ if (requests >= MAX_REQUESTS + (reserved ? 1 : 0)) {
254
+ truncated = true;
255
+ return null;
256
+ }
257
+ requests += 1;
258
+ let result: DecisionResult | null;
259
+ try {
260
+ result = await deps.decide({ state, questions, signal: deps.signal });
261
+ } catch {
262
+ // A timed-out or dropped request is a failed judgement, not a failed search.
263
+ result = null;
264
+ }
265
+ if (!result) failedRequests += 1;
266
+ return result;
267
+ };
268
+ const ask = async (state: string, questions: Record<string, NoulQuestion>): Promise<Map<string, number> | null> => {
269
+ const result = await request(state, questions);
270
+ if (!result) return null;
271
+ const scores = new Map<string, number>();
272
+ for (const [key, answer] of Object.entries(result.answers)) {
273
+ if (answer.type === "noul") scores.set(key, answer.noul);
274
+ }
275
+ return scores;
276
+ };
277
+
278
+ // --- Folder walk: split big folders, enter only plausible subfolders ---------------
279
+ /** Candidate file -> priority (the score of the folder it came from). */
280
+ const candidates = new Map<string, number>();
281
+ const folderScore = new Map<string, number>([["", ROOT_FILE_PRIORITY]]);
282
+ const addCandidate = (file: string, folderPriority: number) => {
283
+ // A path that names the question's words goes ahead of its folder-mates under the cap.
284
+ const priority = folderPriority + 0.1 * Math.min(2, termHits(file, terms)) + 0.5 * lex(file);
285
+ if ((candidates.get(file) ?? -1) < priority) candidates.set(file, priority);
286
+ };
287
+ let frontier = [""];
288
+ for (let depth = 0; frontier.length > 0 && depth < MAX_FOLDER_DEPTH; depth++) {
289
+ const toJudge: Folder[] = [];
290
+ for (const dir of frontier) {
291
+ const folder = folders.get(dir)!;
292
+ const priority = folderScore.get(dir) ?? ROOT_FILE_PRIORITY;
293
+ if (folder.total <= LEAF_FOLDER_FILES) {
294
+ for (const file of allFilesUnder(folders, dir)) addCandidate(file, priority);
295
+ continue;
296
+ }
297
+ // A big folder's own files are judged by content later; its subfolders are judged here.
298
+ for (const file of folder.files) addCandidate(file, priority);
299
+ for (const child of folder.children) toJudge.push(folders.get(child)!);
300
+ }
301
+ const levelScores: Array<{ path: string; score: number }> = [];
302
+ const batches = chunk(toJudge, FOLDERS_PER_REQUEST);
303
+ const answered = await inParallel(batches, CONCURRENCY, async batch => {
304
+ const listing = batch
305
+ .map(folder => {
306
+ const names = folder.files.slice(0, FILE_NAMES_PER_FOLDER).map(file => path.posix.basename(file));
307
+ const children = [...folder.children].map(child => `${path.posix.basename(child)}/`);
308
+ const shown = [...children.slice(0, 6), ...names].join(", ");
309
+ return `- ${folder.path}/ (${folder.total} files): ${shown}`;
310
+ })
311
+ .join("\n");
312
+ const questions: Record<string, NoulQuestion> = {};
313
+ batch.forEach((folder, index) => {
314
+ questions[`f${index}`] = {
315
+ type: "noul",
316
+ instructions: `Could code that answers the question live under "${folder.path}/"? Judge only from the folder path and the names listed for it.`,
317
+ };
318
+ });
319
+ const scores = await ask(`Question: ${params.query}\n\nFolders:\n${listing}`, questions);
320
+ return { batch, scores };
321
+ });
322
+ for (const { batch, scores } of answered) {
323
+ foldersJudged += batch.length;
324
+ batch.forEach((folder, index) => {
325
+ // A failed request keeps its folders at a middling score: an unknown is not a no.
326
+ levelScores.push({ path: folder.path, score: scores ? (scores.get(`f${index}`) ?? 0) : FOLDER_FLOOR });
327
+ });
328
+ }
329
+ levelScores.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
330
+ const best = levelScores[0]?.score ?? 0;
331
+ const bar = Math.max(FOLDER_FLOOR, best * FOLDER_RELATIVE);
332
+ const keep = new Set(
333
+ levelScores
334
+ .filter((entry, rank) => entry.score >= bar || (rank < FOLDERS_ALWAYS_KEPT && entry.score >= FOLDER_MIN))
335
+ .map(entry => entry.path),
336
+ );
337
+ // Jev judges folders from names alone and sometimes undersells the obvious one
338
+ // ("notifications/" at 0.2 for a notification question). The folders whose own name
339
+ // or file names share the most query words are entered too.
340
+ const lexicalFolders = levelScores
341
+ .filter(entry => !keep.has(entry.path))
342
+ .map(entry => {
343
+ const folder = folders.get(entry.path)!;
344
+ const names = termHits(`${entry.path} ${folder.files.join(" ")}`, terms);
345
+ return { entry, hits: lexical.size > 0 ? (folderLexical.get(entry.path) ?? 0) : names };
346
+ })
347
+ .filter(candidate => candidate.hits > 0)
348
+ .sort((a, b) => b.hits - a.hits || b.entry.score - a.entry.score)
349
+ .slice(0, LEXICAL_FOLDERS_KEPT);
350
+ for (const { entry } of lexicalFolders) keep.add(entry.path);
351
+ frontier = levelScores
352
+ .filter(entry => keep.has(entry.path))
353
+ .map(entry => {
354
+ folderScore.set(entry.path, Math.max(entry.score, FOLDER_FLOOR));
355
+ return entry.path;
356
+ });
357
+ }
358
+
359
+ // The strongest local keyword matches are judged wherever they live: Jev prunes folders
360
+ // from names alone, and a pruned folder would otherwise hide an exact match.
361
+ for (const [file, score] of [...lexical].sort((a, b) => b[1] - a[1]).slice(0, LEXICAL_FILES_KEPT)) {
362
+ if (score > 0) candidates.set(file, Math.max(candidates.get(file) ?? 0, 1 + score));
363
+ }
364
+
365
+ // --- File judgement: path + declaration lines, no bodies -------------------------
366
+ // Most promising folders first, so a cap drops the least likely files, not the late alphabet.
367
+ let fileList = [...candidates].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([file]) => file);
368
+ const totalCandidates = fileList.length;
369
+ if (fileList.length > MAX_CANDIDATE_FILES) {
370
+ fileList = fileList.slice(0, MAX_CANDIDATE_FILES);
371
+ truncated = true;
372
+ }
373
+ const outlines = new Map<string, OutlineLine[]>();
374
+ await inParallel(fileList, 16, async file => {
375
+ outlines.set(file, await deps.outline(path.join(params.root, file), deps.signal).catch(() => []));
376
+ });
377
+
378
+ const scored: LocatedFile[] = [];
379
+ const fileBatches = chunk(fileList, FILES_PER_REQUEST);
380
+ const fileAnswers = await inParallel(fileBatches, CONCURRENCY, async batch => {
381
+ const body = batch
382
+ .map(file => {
383
+ const text = fitOutline(outlines.get(file) ?? [], terms);
384
+ return `### ${file}\n${text || " (no declarations extracted)\n"}`;
385
+ })
386
+ .join("\n");
387
+ const questions: Record<string, NoulQuestion> = {};
388
+ batch.forEach((file, index) => {
389
+ questions[`p${index}`] = {
390
+ type: "noul",
391
+ instructions: `Does "${file}" contain code that implements, directly handles, or tests what the question asks? Judge from its path and declarations.`,
392
+ };
393
+ });
394
+ const scores = await ask(`Question: ${params.query}\n\nFiles (declarations only):\n${body}`, questions);
395
+ return { batch, scores };
396
+ });
397
+ let filesJudged = 0;
398
+ for (const { batch, scores } of fileAnswers) {
399
+ if (!scores) continue;
400
+ filesJudged += batch.length;
401
+ batch.forEach((file, index) => {
402
+ // Jev's judgement from declarations, nudged by the local keyword score (which can
403
+ // see comments and strings). Clamped so the display stays a 0..1 score.
404
+ const jev = scores.get(`p${index}`) ?? 0;
405
+ const score = Math.min(1, jev + LEXICAL_WEIGHT * lex(file));
406
+ scored.push({ path: file, score, outline: outlines.get(file) ?? [] });
407
+ });
408
+ }
409
+ scored.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
410
+ await rerank(scored, params.query, terms, outlines, request);
411
+ // Rerank order wins; below the rerank head files are already in score order.
412
+ const files = scored
413
+ .filter(file => file.score >= WEAK_FILE_BAR)
414
+ .slice(0, params.limit)
415
+ .map(file => (file.score < FILE_BAR ? { ...file, weak: true } : file));
416
+ return {
417
+ files,
418
+ weakLeads: files.length === 0 ? scored.slice(0, 3) : [],
419
+ foldersJudged,
420
+ filesJudged,
421
+ requests,
422
+ failedRequests,
423
+ candidates: totalCandidates,
424
+ truncated,
425
+ durationMs: Date.now() - started,
426
+ };
427
+ }
428
+
429
+ /** How many top files the final comparison looks at together. */
430
+ const RERANK_FILES = 6;
431
+
432
+ /**
433
+ * Put the leading files in order with one comparative question. Each file was scored on
434
+ * its own, and near-equal scores (0.62 vs 0.60) order close siblings almost at random;
435
+ * shown side by side, the one that implements the behaviour is picked reliably. Jev
436
+ * returns a probability per file for a choice question; files are reordered by it and keep
437
+ * their own score for display. A failed request leaves the order as it was.
438
+ */
439
+ async function rerank(
440
+ scored: LocatedFile[],
441
+ query: string,
442
+ terms: readonly string[],
443
+ outlines: Map<string, OutlineLine[]>,
444
+ request: (
445
+ state: string,
446
+ questions: Record<string, ChoiceQuestion>,
447
+ reserved: boolean,
448
+ ) => Promise<DecisionResult | null>,
449
+ ): Promise<void> {
450
+ const head = scored.slice(0, RERANK_FILES).filter(file => file.score >= WEAK_FILE_BAR);
451
+ if (head.length < 2) return;
452
+ const criteria: Record<string, string> = {};
453
+ head.forEach((file, index) => {
454
+ criteria[`c${index}`] = file.path;
455
+ });
456
+ const body = head
457
+ .map((file, index) => `### c${index}: ${file.path}\n${fitOutline(outlines.get(file.path) ?? [], terms)}`)
458
+ .join("\n");
459
+ const result = await request(
460
+ `Question: ${query}\n\nCandidate files (declarations only):\n${body}`,
461
+ {
462
+ best: {
463
+ type: "choice",
464
+ instructions:
465
+ "Which file most directly implements, handles, or tests what the question asks? Judge from paths and declarations.",
466
+ criteria,
467
+ },
468
+ },
469
+ true,
470
+ );
471
+ const answer = result?.answers.best;
472
+ if (answer?.type !== "choice") return;
473
+ const probability = (index: number) =>
474
+ answer.probabilities?.[`c${index}`] ?? (answer.choice === `c${index}` ? 1 : 0);
475
+ const reordered = head
476
+ .map((file, index) => ({ file, p: probability(index), index }))
477
+ .sort((a, b) => b.p - a.p || a.index - b.index)
478
+ .map(entry => entry.file);
479
+ scored.splice(0, head.length, ...reordered);
480
+ }
481
+
482
+ /**
483
+ * An outline within {@link OUTLINE_CHARS_PER_FILE}. When it does not fit, declarations
484
+ * that share words with the query go first, so the one that matters in a large file is
485
+ * not the one cut; the kept lines stay in source order.
486
+ */
487
+ function fitOutline(outline: readonly OutlineLine[], terms: readonly string[]): string {
488
+ const render = (entries: readonly OutlineLine[]) => entries.map(entry => ` ${entry.text}\n`).join("");
489
+ const full = render(outline);
490
+ if (full.length <= OUTLINE_CHARS_PER_FILE) return full;
491
+ const ordered = outline
492
+ .map((entry, index) => ({ entry, index, hits: termHits(entry.text, terms) }))
493
+ .sort((a, b) => b.hits - a.hits || a.index - b.index);
494
+ const kept: Array<{ entry: OutlineLine; index: number }> = [];
495
+ let used = 4; // room for the ellipsis line
496
+ for (const candidate of ordered) {
497
+ const size = candidate.entry.text.length + 3;
498
+ if (used + size > OUTLINE_CHARS_PER_FILE) continue;
499
+ kept.push(candidate);
500
+ used += size;
501
+ }
502
+ kept.sort((a, b) => a.index - b.index);
503
+ return `${render(kept.map(candidate => candidate.entry))} …\n`;
504
+ }
505
+
506
+ /**
507
+ * Lines that are not declarations: blank, comments, lone brackets, imports, and union or
508
+ * intersection members (`| "spawn_failed"`), which otherwise crowd real signatures out.
509
+ */
510
+ const NOT_DECLARATION =
511
+ /^\s*$|^\s*(\/\/|\/\*|\*|#(?!\[)|"""|'''|--)|^\s*[)\]};,]+\s*$|^\s*(import\b|from\s+\S+\s+import\b|use\s)|^\s*[|&]\s|^\s*['"`]|^\s*(return|assert|yield|raise|pass|del|await)\b/;
512
+ const MAX_OUTLINE_LINES = 60;
513
+ const MAX_OUTLINE_LINE_CHARS = 140;
514
+ /** A kept line that opens a type-like container whose body lists members, not statements. */
515
+ const CONTAINER_HEAD = /\b(class|interface|impl|trait|struct|enum|object|namespace|module|protocol|extension)\b/;
516
+ /** Member-level lines that are still control flow or noise, not declarations. */
517
+ const NOT_MEMBER = /^(return|if|else|for|while|switch|case|throw|await|yield|super|this\.|@)/;
518
+
519
+ /**
520
+ * The declaration part of a line: an initialiser is cut (`const LIMIT = 42` → `const
521
+ * LIMIT`, `secret = "x"` → `secret`) unless the value is a function, whose parameters are
522
+ * the signature. Values are data, not names.
523
+ */
524
+ /** A trailing ` # …` or ` // …` comment, when no string could be holding the marker. */
525
+ function withoutTrailingComment(text: string): string {
526
+ const match = /\s+(#|\/\/)\s.*$/.exec(text);
527
+ if (!match) return text;
528
+ const before = text.slice(0, match.index);
529
+ const balanced = [`"`, `'`, "`"].every(quote => (before.split(quote).length - 1) % 2 === 0);
530
+ return balanced ? before : text;
531
+ }
532
+
533
+ function declarationOnly(text: string): string {
534
+ const assign = /^([^=(]*?[^=!<>])\s*=\s*(?!=|>)(.*)$/.exec(text);
535
+ if (!assign) return text;
536
+ const value = assign[2]!.trim();
537
+ if (/^(async\b|function\b|\(|<|[A-Za-z_$][\w$]*\s*=>)/.test(value)) return text;
538
+ return assign[1]!.trim();
539
+ }
540
+
541
+ /**
542
+ * Declaration lines from a structural summary whose bodies and comments were all elided
543
+ * (`minBodyLines: 1`, `minCommentLines: 1`), plus the member lines of elided class-like
544
+ * bodies taken from `sourceLines`: the summariser folds a whole class body, so its methods
545
+ * would otherwise vanish. Only lines at the body's own member indentation are taken;
546
+ * method bodies sit deeper and never come along. Initialisers are cut, so values do not
547
+ * leave the machine. Over {@link MAX_OUTLINE_LINES}, top-level declarations and methods
548
+ * are kept before fields, so a long run of type members never hides what follows.
549
+ */
550
+ export function outlineFromSegments(
551
+ segments: ReadonlyArray<{ kind: string; startLine: number; endLine?: number; text?: string | null }>,
552
+ sourceLines: readonly string[] = [],
553
+ ): OutlineLine[] {
554
+ const all: Array<OutlineLine & { rank: number }> = [];
555
+ const push = (line: number, raw: string, rank: number) => {
556
+ const trimmed = declarationOnly(withoutTrailingComment(raw.trim()));
557
+ all.push({
558
+ line,
559
+ rank,
560
+ text: trimmed.length > MAX_OUTLINE_LINE_CHARS ? `${trimmed.slice(0, MAX_OUTLINE_LINE_CHARS)}…` : trimmed,
561
+ });
562
+ };
563
+ // Lines inside block comments, docstrings and multi-line string literals. The per-line
564
+ // filter only sees a comment's opening line; everything after it would leak.
565
+ const masked = maskedLines(sourceLines.length > 0 ? sourceLines : linesFromSegments(segments));
566
+ let lastHead = "";
567
+ for (const segment of segments) {
568
+ if (segment.kind === "elided") {
569
+ if (CONTAINER_HEAD.test(lastHead) && segment.endLine !== undefined) {
570
+ pushMembers(segment.startLine, segment.endLine, sourceLines, masked, push);
571
+ }
572
+ continue;
573
+ }
574
+ if (!segment.text) continue;
575
+ const lines = segment.text.split("\n");
576
+ for (let i = 0; i < lines.length; i++) {
577
+ const text = lines[i]!;
578
+ if (masked.has(segment.startLine + i) || NOT_DECLARATION.test(text)) continue;
579
+ lastHead = text;
580
+ const topLevel = !/^\s/.test(text);
581
+ // 0: top-level declaration, 1: nested callable (method), 2: nested field.
582
+ push(segment.startLine + i, text, topLevel ? 0 : text.includes("(") ? 1 : 2);
583
+ }
584
+ }
585
+ const kept =
586
+ all.length <= MAX_OUTLINE_LINES
587
+ ? all
588
+ : [...all]
589
+ .sort((a, b) => a.rank - b.rank || a.line - b.line)
590
+ .slice(0, MAX_OUTLINE_LINES)
591
+ .sort((a, b) => a.line - b.line);
592
+ return kept.map(({ line, text }) => ({ line, text }));
593
+ }
594
+
595
+ function linesFromSegments(
596
+ segments: ReadonlyArray<{ kind: string; startLine: number; text?: string | null }>,
597
+ ): string[] {
598
+ const lines: string[] = [];
599
+ for (const segment of segments) {
600
+ if (segment.kind === "elided" || !segment.text) continue;
601
+ segment.text.split("\n").forEach((text, i) => {
602
+ lines[segment.startLine - 1 + i] = text;
603
+ });
604
+ }
605
+ return Array.from(lines, text => text ?? "");
606
+ }
607
+
608
+ const TRIPLE_QUOTES = ['"""', "'''"] as const;
609
+
610
+ /**
611
+ * 1-based numbers of lines that belong to a multi-line comment or string: `/* … *\/`
612
+ * blocks (including `/**` and Rust's `/*!`), Python triple-quoted docstrings and strings,
613
+ * and multi-line template literals. A declaration line that opens a string value
614
+ * (`x = """`) is not masked itself — its initialiser is cut later — but the lines after
615
+ * it are.
616
+ */
617
+ export function maskedLines(lines: readonly string[]): Set<number> {
618
+ const masked = new Set<number>();
619
+ let closer: string | undefined;
620
+ lines.forEach((text, index) => {
621
+ const line = index + 1;
622
+ const trimmed = text.trim();
623
+ if (closer) {
624
+ masked.add(line);
625
+ if (text.includes(closer)) closer = undefined;
626
+ return;
627
+ }
628
+ if (trimmed.startsWith("/*")) {
629
+ masked.add(line);
630
+ if (!trimmed.slice(2).includes("*/")) closer = "*/";
631
+ return;
632
+ }
633
+ for (const quote of TRIPLE_QUOTES) {
634
+ const count = text.split(quote).length - 1;
635
+ if (count === 0) continue;
636
+ if (/^[rbuRBUfF]{0,2}("""|''')/.test(trimmed)) masked.add(line);
637
+ if (count % 2 === 1) closer = quote;
638
+ return;
639
+ }
640
+ if ((text.split("`").length - 1) % 2 === 1) {
641
+ closer = "`";
642
+ return;
643
+ }
644
+ // Rust raw string `r#"…"#`: it ends only at `"` followed by the same number of `#`.
645
+ const raw = /\br(#+)"/.exec(text);
646
+ if (raw) {
647
+ const end = `"${raw[1]}`;
648
+ if (!text.slice(raw.index + raw[0].length).includes(end)) {
649
+ closer = end;
650
+ return;
651
+ }
652
+ }
653
+ // An odd number of double quotes opens a string that continues on the next lines
654
+ // (Rust and Go allow it; `r#"…"#` raw strings too). Char literals and escapes first.
655
+ const quotes = text.replace(/\\./g, "").replace(/'"'/g, "").split('"').length - 1;
656
+ if (quotes % 2 === 1) closer = '"';
657
+ });
658
+ return masked;
659
+ }
660
+
661
+ function pushMembers(
662
+ startLine: number,
663
+ endLine: number,
664
+ sourceLines: readonly string[],
665
+ masked: ReadonlySet<number>,
666
+ push: (line: number, raw: string, rank: number) => void,
667
+ ): void {
668
+ let memberIndent: string | undefined;
669
+ for (let line = startLine; line <= endLine && line <= sourceLines.length; line++) {
670
+ const text = sourceLines[line - 1]!;
671
+ if (masked.has(line) || NOT_DECLARATION.test(text)) continue;
672
+ const indent = /^\s*/.exec(text)![0];
673
+ memberIndent ??= indent;
674
+ if (indent !== memberIndent) continue;
675
+ const trimmed = text.trim();
676
+ if (NOT_MEMBER.test(trimmed)) continue;
677
+ push(line, text, trimmed.includes("(") ? 1 : 2);
678
+ }
679
+ }
680
+
681
+ /** A top-level declaration line in TS/JS, Python, Rust or Go. */
682
+ const TOP_DECLARATION =
683
+ /^(export\s+)?(default\s+)?(declare\s+)?(abstract\s+)?(async\s+)?(function\*?|class|interface|type|enum|const|let|var|namespace|def|struct|trait|impl|fn|pub|func)\b/;
684
+ /** A method or member signature one indent level in: `name(`, `async name(`, `#name(`, `get name(`. */
685
+ const MEMBER_DECLARATION =
686
+ /^(?:(?:public|private|protected|static|readonly|override|abstract|async|get|set|declare)\s+)*(?:#?[A-Za-z_$][\w$]*)\s*[<(]/;
687
+
688
+ /**
689
+ * Fallback outline for a file the structural parser rejects (1-2% of TypeScript files,
690
+ * often large central ones). Keeps top-level declarations and members one indent level
691
+ * in, under the same rules as {@link outlineFromSegments}: no comments, imports, bodies,
692
+ * or initialiser values.
693
+ */
694
+ export function outlineFromSource(sourceLines: readonly string[]): OutlineLine[] {
695
+ const segments: Array<{ kind: string; startLine: number; text: string }> = [];
696
+ let memberIndent: string | undefined;
697
+ let inBlockComment = false;
698
+ sourceLines.forEach((text, index) => {
699
+ const trimmed = text.trim();
700
+ if (inBlockComment) {
701
+ if (trimmed.includes("*/")) inBlockComment = false;
702
+ return;
703
+ }
704
+ if (trimmed.startsWith("/*") && !trimmed.includes("*/")) {
705
+ inBlockComment = true;
706
+ return;
707
+ }
708
+ const indent = /^\s*/.exec(text)![0];
709
+ if (indent === "") {
710
+ memberIndent = undefined;
711
+ if (TOP_DECLARATION.test(trimmed)) segments.push({ kind: "kept", startLine: index + 1, text });
712
+ return;
713
+ }
714
+ memberIndent ??= indent;
715
+ if (indent === memberIndent && MEMBER_DECLARATION.test(trimmed) && !NOT_MEMBER.test(trimmed)) {
716
+ segments.push({ kind: "kept", startLine: index + 1, text });
717
+ }
718
+ });
719
+ return outlineFromSegments(segments);
720
+ }