pi-supernova 0.8.2 → 0.9.1

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 (67) hide show
  1. package/README.md +188 -51
  2. package/docs/CHANGELOG.md +86 -1
  3. package/docs/TOKEN_COSTS.md +38 -0
  4. package/index.js +10 -175
  5. package/package.json +2 -1
  6. package/src/adapters/bash.js +14 -30
  7. package/src/adapters/errors.js +1 -9
  8. package/src/adapters/read-focus.js +98 -0
  9. package/src/adapters/read-image.js +51 -0
  10. package/src/adapters/read-json.js +42 -0
  11. package/src/adapters/read-text.js +71 -0
  12. package/src/adapters/read.js +66 -635
  13. package/src/bridge/catalog.js +3 -2
  14. package/src/bridge/host-bridge.js +35 -167
  15. package/src/bridge/tool-registry.js +104 -0
  16. package/src/bridge/trace.js +41 -0
  17. package/src/context/evidence-graph.js +249 -0
  18. package/src/context/evidence-rank.js +153 -0
  19. package/src/context/evidence.js +10 -424
  20. package/src/context/fuzzy.js +116 -43
  21. package/src/context/query.js +80 -0
  22. package/src/context/repo-index.js +23 -166
  23. package/src/context/search-files.js +19 -0
  24. package/src/context/search.js +2 -24
  25. package/src/context/snap-search.js +203 -0
  26. package/src/context/snap.js +5 -266
  27. package/src/context/source-entry.js +112 -0
  28. package/src/contract/bash.js +6 -1
  29. package/src/contract/program.js +36 -0
  30. package/src/contract/read.js +8 -53
  31. package/src/fs/check.js +1 -1
  32. package/src/fs/commit.js +161 -0
  33. package/src/fs/diff.js +11 -15
  34. package/src/fs/directory.js +79 -0
  35. package/src/fs/file-io.js +100 -0
  36. package/src/fs/glob.js +54 -0
  37. package/src/fs/json-size.js +54 -0
  38. package/src/fs/lines.js +117 -0
  39. package/src/fs/read-window.js +74 -0
  40. package/src/fs/session-resource.js +50 -0
  41. package/src/fs/text-ops.js +7 -227
  42. package/src/fs/vfs.js +5 -239
  43. package/src/fs/workspace.js +2 -1
  44. package/src/output/bottleneck.js +13 -67
  45. package/src/output/final.js +114 -0
  46. package/src/output/format.js +94 -5
  47. package/src/output/outcome.js +91 -0
  48. package/src/runtime/batch-input.js +68 -0
  49. package/src/runtime/guest-api.js +281 -0
  50. package/src/runtime/guest-worker.js +62 -333
  51. package/src/runtime/parallel.js +41 -39
  52. package/src/runtime/program-batch.js +21 -75
  53. package/src/runtime/program-file.js +3 -11
  54. package/src/runtime/program.js +141 -0
  55. package/src/runtime/reference.js +6 -5
  56. package/src/runtime/runtime.js +77 -253
  57. package/src/runtime/worker-pool.js +91 -0
  58. package/src/shared/decode.js +22 -8
  59. package/src/shared/image-worker.js +30 -0
  60. package/src/shared/image.js +78 -0
  61. package/src/shared/png.js +57 -0
  62. package/src/shared/result.js +77 -0
  63. package/src/shared/syntax-context.js +61 -3
  64. package/src/ui/host-render.js +104 -0
  65. package/src/ui/progress.js +51 -0
  66. package/src/ui/render.js +21 -421
  67. package/src/ui/trace.js +277 -0
@@ -0,0 +1,203 @@
1
+ import * as path from 'node:path';
2
+ import {isString} from '../shared/decode.js';
3
+ import {truncateChars} from '../output/format.js';
4
+ import {extractStructuralSurface} from './surface.js';
5
+ import {scorePathTopology,stem,SOURCE_EXT,MAX_NEEDLE_CHARS} from './query.js';
6
+
7
+ const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
8
+
9
+ function inScope(filePath, dir, includeHidden) {
10
+ const relative = path.relative(dir, filePath);
11
+
12
+ if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return false;
13
+ const parts = relative.split(path.sep);
14
+
15
+ return !parts.includes(".git") && (includeHidden || !parts.some(part => part.startsWith(".") && part.length > 1));
16
+ }
17
+
18
+ function makeCandidate(filePath, dir, query, tokens, flags, needles = tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS))) {
19
+ const relative = path.relative(dir, filePath);
20
+ const lower = relative.toLowerCase();
21
+ const base = path.basename(lower);
22
+
23
+ const extension = path.extname(base);
24
+ const stemBase = extension ? base.slice(0, -extension.length) : base;
25
+ const queryLower = query.toLowerCase();
26
+ const exactPath = lower === queryLower || base === queryLower || stemBase === queryLower;
27
+
28
+ return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
29
+ pathCoverage: tokens.filter((token, index) => lower.includes(needles[index] ?? token)).length,
30
+ matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
31
+ line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1, exactLines: new Set() };
32
+ }
33
+
34
+ function bestDeclaration(items, query, tokens, needles) {
35
+ let declaration;
36
+ let definitionCoverage = 0;
37
+ let exact = false;
38
+ const queryLower = query.toLowerCase();
39
+
40
+ for (const item of items) {
41
+ const name = item.name.toLowerCase();
42
+ const itemExact = name === queryLower;
43
+ const coverage = tokens.filter((token, index) => name.includes(needles[index] ?? token)).length;
44
+
45
+ if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
46
+ if (exact) break;
47
+ }
48
+
49
+ return { declaration, definitionCoverage, exact };
50
+ }
51
+
52
+ function applyMatch(candidate, lineNumber, text, query, tokens, needles, lower) {
53
+ const matches = tokens.filter((token, index) => lower.includes(needles[index] ?? token));
54
+
55
+ for (const token of matches) candidate.matched.add(token);
56
+ const ext = path.extname(candidate.path).toLowerCase();
57
+ const items = SOURCE_EXT.has(ext) ? extractStructuralSurface(text, ext).items : [];
58
+ const { declaration, definitionCoverage, exact } = bestDeclaration(items, query, tokens, needles);
59
+ const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
60
+
61
+ if (exact) candidate.exactLines.add(lineNumber);
62
+
63
+ if (score > candidate.anchorScore) {
64
+ candidate.anchorScore = score;
65
+ candidate.line = lineNumber;
66
+ candidate.signature = truncateChars(declaration?.signature ?? "", 240, "signature").text;
67
+ candidate.exactDefinition = exact;
68
+ candidate.definitionCoverage = definitionCoverage;
69
+ candidate.lineCoverage = matches.length;
70
+ candidate.context.clear();
71
+
72
+ for (const [number, line] of candidate.recent) if (number >= lineNumber - 2) candidate.context.set(number, line);
73
+ }
74
+ }
75
+
76
+ function inspectLine(candidate, lineNumber, raw, query, tokens, needles, isMatch) {
77
+ const text = raw.replace(/\r?\n$/, "");
78
+ const lower = text.toLowerCase();
79
+
80
+ if (isMatch) applyMatch(candidate, lineNumber, text, query, tokens, needles, lower);
81
+ const excerpt = truncateChars(text, 240, "source line").text;
82
+
83
+ if (lineNumber >= candidate.line - 2 && lineNumber <= candidate.line + 4) candidate.context.set(lineNumber, excerpt);
84
+ candidate.recent.push([lineNumber, excerpt]);
85
+
86
+ if (candidate.recent.length > 2) candidate.recent.shift();
87
+ }
88
+
89
+ function inspectOverlay(candidate, text, needles, query, tokens, signal) {
90
+ let start = 0, line = 1, truncated = false;
91
+
92
+ // Keep only the candidate and its short context, not another copy of every
93
+ // line in a staged document. Oversized individual lines disclose uncertainty.
94
+ while (start < text.length) {
95
+ if ((line & 127) === 0) signal?.throwIfAborted();
96
+ const newline = text.indexOf("\n", start);
97
+ const end = newline < 0 ? text.length : newline + 1;
98
+
99
+ if (end - start > MAX_SEARCH_CHARS) truncated = true;
100
+ else {
101
+ const row = text.slice(start, end);
102
+ const lower = row.toLowerCase();
103
+ inspectLine(candidate, line, row, query, tokens, needles, needles.some(needle => lower.includes(needle)));
104
+ }
105
+ start = end;
106
+ line++;
107
+ }
108
+
109
+ return truncated;
110
+ }
111
+
112
+ function parseRgRecord(line, truncated, isLast) {
113
+ if (!line) return null;
114
+
115
+ try { return JSON.parse(line); } catch (error) {
116
+ if (truncated && isLast) return undefined;
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ function absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles) {
122
+ if (record.type !== "match" && record.type !== "context") return;
123
+ const data = record.data;
124
+
125
+ if (!data?.path?.text || !isString(data.lines?.text)) return;
126
+ const filePath = path.resolve(dir, data.path.text);
127
+
128
+ if (!inScope(filePath, dir, includeHidden) || overlayText(filePath) !== undefined) return;
129
+ let candidate = candidates.get(filePath);
130
+
131
+ if (!candidate) {
132
+ candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags, candidateNeedles);
133
+ candidates.set(filePath, candidate);
134
+ }
135
+
136
+ inspectLine(candidate, data.line_number, data.lines.text, query, tokens, needles, record.type === "match");
137
+ }
138
+
139
+ function overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal) {
140
+ let overlayTruncated = false;
141
+
142
+ for (const filePath of pendingPaths) {
143
+ const pending = overlayText(filePath);
144
+
145
+ if (pending === undefined) continue;
146
+ const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags, candidateNeedles);
147
+ overlayTruncated = inspectOverlay(candidate, pending, needles, query, tokens, signal) || overlayTruncated;
148
+
149
+ if (candidate.matched.size) candidates.set(filePath, candidate);
150
+ }
151
+
152
+ return overlayTruncated;
153
+ }
154
+
155
+ function rgSearchArgs(includeHidden, searchNeedles, focusFile, dir) {
156
+ const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
157
+
158
+ if (includeHidden) args.push("--hidden");
159
+ args.push("-g", "!.git/**", "-g", "!**/.git/**");
160
+ for (const needle of searchNeedles) args.push("-e", needle);
161
+ args.push("--", focusFile ?? dir);
162
+
163
+ return args;
164
+ }
165
+
166
+ async function runContentSearch({ dir, includeHidden, searchNeedles, run, overlayText, signal, diskFiles, focusFile }) {
167
+ const args = rgSearchArgs(includeHidden, searchNeedles, focusFile, dir);
168
+ const response = diskFiles || (focusFile && overlayText(focusFile) === undefined)
169
+ ? await run(args, { cwd: focusFile ? path.dirname(focusFile) : dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
170
+ : { stdout: "", stderr: "", exitCode: 1 };
171
+
172
+ if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
173
+
174
+ return response;
175
+ }
176
+
177
+ function absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal) {
178
+ const records = response.stdout.split("\n");
179
+
180
+ for (let i = 0; i < records.length; i++) {
181
+ if ((i & 127) === 0) signal?.throwIfAborted();
182
+ const record = parseRgRecord(records[i], response.outputTruncated, i === records.length - 1);
183
+
184
+ if (record === undefined) break;
185
+ if (!record) continue;
186
+ absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles);
187
+ }
188
+ }
189
+
190
+ async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile }) {
191
+ const needles = exact ? [query.toLowerCase().slice(0, MAX_NEEDLE_CHARS)] : tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
192
+ // Coverage needles are always token-derived (even in exact mode, where the
193
+ // search needles collapse to the query): computed once, not once per file.
194
+ const candidateNeedles = exact ? tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS)) : needles;
195
+ const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
196
+ const candidates = new Map();
197
+ const response = await runContentSearch({ dir, includeHidden, searchNeedles: [...new Set(needles)], run, overlayText, signal, diskFiles, focusFile });
198
+ absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal);
199
+ const overlayTruncated = overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal);
200
+
201
+ return { candidates, truncated: response.outputTruncated === true || overlayTruncated };
202
+ }
203
+ export { inScope, makeCandidate, contentCandidates, MAX_SEARCH_CHARS };
@@ -1,278 +1,17 @@
1
+ import {inScope,makeCandidate,contentCandidates,MAX_SEARCH_CHARS} from './snap-search.js';
2
+ import { tokenizeQuery, stem } from "./query.js";
3
+ export {tokenizeQuery,scorePathTopology,stem} from './query.js';
1
4
  import * as path from "node:path";
2
- import { isString } from "../shared/decode.js";
3
- import { truncateChars } from "../output/format.js";
5
+
4
6
  import * as fs from "node:fs/promises";
5
- import { extractStructuralSurface } from "./surface.js";
7
+
6
8
  import { WorkspaceIndex } from "./repo-index.js";
7
9
  import { pickSpan, spanCandidate, spanWindow } from "./spans.js";
8
10
  import { rankPaths } from "./fuzzy.js";
9
11
  import { isTestPath, runCommand, relativeSlash } from "../fs/workspace.js";
10
12
 
11
- const STOP_WORDS = new Set([
12
- "the", "a", "an", "and", "or", "in", "on", "at", "to", "for", "of", "with",
13
- "by", "from", "is", "it", "this", "that", "where", "how", "what", "which",
14
- "file", "code", "function", "class", "method", "find", "get", "look", "are", "does", "do",
15
- ]);
16
-
17
- const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs", ".py", ".go"]);
18
-
19
- const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
20
-
21
- const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
22
-
23
- const MAX_NEEDLE_CHARS = 128;
24
-
25
13
  const MAX_ALTERNATIVES = 3;
26
14
 
27
- /** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
28
- export function stem(token) {
29
- if (token.length < 5) return token;
30
-
31
- return token.replace(/(ations?|ings?|ed|es|e|s|ly|ers?)$/, (m) => (token.length - m.length >= 4 ? "" : m));
32
- }
33
-
34
- export function tokenizeQuery(query) {
35
- if (!isString(query) || !query.trim()) return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
36
- const words = query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-zA-Z0-9_]+/);
37
-
38
- return {
39
- tokens: [...new Set(words.filter(word => word.length > 1 && !STOP_WORDS.has(word)))],
40
- wantsTest: words.some(word => ["test", "tests", "testing", "spec", "specs"].includes(word)),
41
- wantsType: words.some(word => ["type", "types", "interface", "interfaces", "schema", "schemas"].includes(word)),
42
- wantsDoc: words.some(word => ["doc", "docs", "documentation", "readme"].includes(word)),
43
- };
44
- }
45
-
46
- function tokenPathScore(base, words, normalized, tokens) {
47
- let score = 0;
48
-
49
- for (const token of tokens) {
50
- if (base === token || base.startsWith(token + ".")) score += 60;
51
- else if (base.includes(token)) score += 30;
52
- else if (words.includes(token)) score += 15;
53
- else if (normalized.includes(token)) score += 5;
54
- }
55
-
56
- return score;
57
- }
58
-
59
- function topologyPenalty(normalized, flags) {
60
- const parts = normalized.split("/");
61
-
62
- if (parts.some(part => ["node_modules", "dist", "target"].includes(part))) return -100;
63
- const test = isTestPath(normalized);
64
-
65
- if (test && !flags.wantsTest) return -50;
66
- if (!test && flags.wantsTest) return -20;
67
- }
68
-
69
- export function scorePathTopology(filePath, tokens, flags) {
70
- const normalized = filePath.replaceAll("\\", "/").toLowerCase();
71
- const penalty = topologyPenalty(normalized, flags);
72
-
73
- if (penalty !== undefined) return penalty;
74
- const ext = path.extname(normalized);
75
- let score = SOURCE_EXT.has(ext) && !flags.wantsDoc ? 5 : 0;
76
-
77
- if (flags.wantsType && TYPED_EXT.has(ext)) score += 10;
78
-
79
- return score + tokenPathScore(path.basename(normalized), normalized.split(/[^a-zA-Z0-9]+/), normalized, tokens);
80
- }
81
-
82
- function inScope(filePath, dir, includeHidden) {
83
- const relative = path.relative(dir, filePath);
84
-
85
- if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return false;
86
- const parts = relative.split(path.sep);
87
-
88
- return !parts.includes(".git") && (includeHidden || !parts.some(part => part.startsWith(".") && part.length > 1));
89
- }
90
-
91
- function makeCandidate(filePath, dir, query, tokens, flags) {
92
- const relative = path.relative(dir, filePath);
93
- const lower = relative.toLowerCase();
94
- const base = path.basename(lower);
95
-
96
- const extension = path.extname(base);
97
- const stemBase = extension ? base.slice(0, -extension.length) : base;
98
- const exactPath = lower === query.toLowerCase() || base === query.toLowerCase()
99
- || stemBase === query.toLowerCase();
100
-
101
- const needles = tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
102
-
103
- return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
104
- pathCoverage: tokens.filter((token, index) => lower.includes(needles[index] ?? token)).length,
105
- matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
106
- line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1, exactLines: new Set() };
107
- }
108
-
109
- function bestDeclaration(items, query, tokens, needles) {
110
- let declaration;
111
- let definitionCoverage = 0;
112
- let exact = false;
113
- const queryLower = query.toLowerCase();
114
-
115
- for (const item of items) {
116
- const name = item.name.toLowerCase();
117
- const itemExact = name === queryLower;
118
- const coverage = tokens.filter((token, index) => name.includes(needles[index] ?? token)).length;
119
-
120
- if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
121
- if (exact) break;
122
- }
123
-
124
- return { declaration, definitionCoverage, exact };
125
- }
126
-
127
- function applyMatch(candidate, lineNumber, text, query, tokens, needles, lower) {
128
- const matches = tokens.filter((token, index) => lower.includes(needles[index] ?? token));
129
-
130
- for (const token of matches) candidate.matched.add(token);
131
- const ext = path.extname(candidate.path).toLowerCase();
132
- const items = SOURCE_EXT.has(ext) ? extractStructuralSurface(text, ext).items : [];
133
- const { declaration, definitionCoverage, exact } = bestDeclaration(items, query, tokens, needles);
134
- const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
135
-
136
- if (exact) candidate.exactLines.add(lineNumber);
137
-
138
- if (score > candidate.anchorScore) {
139
- candidate.anchorScore = score;
140
- candidate.line = lineNumber;
141
- candidate.signature = truncateChars(declaration?.signature ?? "", 240, "signature").text;
142
- candidate.exactDefinition = exact;
143
- candidate.definitionCoverage = definitionCoverage;
144
- candidate.lineCoverage = matches.length;
145
- candidate.context.clear();
146
-
147
- for (const [number, line] of candidate.recent) if (number >= lineNumber - 2) candidate.context.set(number, line);
148
- }
149
- }
150
-
151
- function inspectLine(candidate, lineNumber, raw, query, tokens, needles, isMatch) {
152
- const text = raw.replace(/\r?\n$/, "");
153
- const lower = text.toLowerCase();
154
-
155
- if (isMatch) applyMatch(candidate, lineNumber, text, query, tokens, needles, lower);
156
- const excerpt = truncateChars(text, 240, "source line").text;
157
-
158
- if (lineNumber >= candidate.line - 2 && lineNumber <= candidate.line + 4) candidate.context.set(lineNumber, excerpt);
159
- candidate.recent.push([lineNumber, excerpt]);
160
-
161
- if (candidate.recent.length > 2) candidate.recent.shift();
162
- }
163
-
164
- function inspectOverlay(candidate, text, needles, query, tokens, signal) {
165
- let start = 0, line = 1, truncated = false;
166
-
167
- // Keep only the candidate and its short context, not another copy of every
168
- // line in a staged document. Oversized individual lines disclose uncertainty.
169
- while (start < text.length) {
170
- if ((line & 127) === 0) signal?.throwIfAborted();
171
- const newline = text.indexOf("\n", start);
172
- const end = newline < 0 ? text.length : newline + 1;
173
-
174
- if (end - start > MAX_SEARCH_CHARS) truncated = true;
175
- else {
176
- const row = text.slice(start, end);
177
- const lower = row.toLowerCase();
178
- inspectLine(candidate, line, row, query, tokens, needles, needles.some(needle => lower.includes(needle)));
179
- }
180
- start = end;
181
- line++;
182
- }
183
-
184
- return truncated;
185
- }
186
-
187
- function parseRgRecord(line, truncated, isLast) {
188
- if (!line) return null;
189
-
190
- try { return JSON.parse(line); } catch (error) {
191
- if (truncated && isLast) return undefined;
192
- throw error;
193
- }
194
- }
195
-
196
- function absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles) {
197
- if (record.type !== "match" && record.type !== "context") return;
198
- const data = record.data;
199
-
200
- if (!data?.path?.text || !isString(data.lines?.text)) return;
201
- const filePath = path.resolve(dir, data.path.text);
202
-
203
- if (!inScope(filePath, dir, includeHidden) || overlayText(filePath) !== undefined) return;
204
- let candidate = candidates.get(filePath);
205
-
206
- if (!candidate) {
207
- candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
208
- candidates.set(filePath, candidate);
209
- }
210
-
211
- inspectLine(candidate, data.line_number, data.lines.text, query, tokens, needles, record.type === "match");
212
- }
213
-
214
- function overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, signal) {
215
- let overlayTruncated = false;
216
-
217
- for (const filePath of pendingPaths) {
218
- const pending = overlayText(filePath);
219
-
220
- if (pending === undefined) continue;
221
- const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
222
- overlayTruncated = inspectOverlay(candidate, pending, needles, query, tokens, signal) || overlayTruncated;
223
-
224
- if (candidate.matched.size) candidates.set(filePath, candidate);
225
- }
226
-
227
- return overlayTruncated;
228
- }
229
-
230
- function rgSearchArgs(includeHidden, searchNeedles, focusFile, dir) {
231
- const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
232
-
233
- if (includeHidden) args.push("--hidden");
234
- args.push("-g", "!.git/**", "-g", "!**/.git/**");
235
- for (const needle of searchNeedles) args.push("-e", needle);
236
- args.push("--", focusFile ?? dir);
237
-
238
- return args;
239
- }
240
-
241
- async function runContentSearch({ dir, includeHidden, searchNeedles, run, overlayText, signal, diskFiles, focusFile }) {
242
- const args = rgSearchArgs(includeHidden, searchNeedles, focusFile, dir);
243
- const response = diskFiles || (focusFile && overlayText(focusFile) === undefined)
244
- ? await run(args, { cwd: focusFile ? path.dirname(focusFile) : dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
245
- : { stdout: "", stderr: "", exitCode: 1 };
246
-
247
- if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
248
-
249
- return response;
250
- }
251
-
252
- function absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, signal) {
253
- const records = response.stdout.split("\n");
254
-
255
- for (let i = 0; i < records.length; i++) {
256
- if ((i & 127) === 0) signal?.throwIfAborted();
257
- const record = parseRgRecord(records[i], response.outputTruncated, i === records.length - 1);
258
-
259
- if (record === undefined) break;
260
- if (!record) continue;
261
- absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles);
262
- }
263
- }
264
-
265
- async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile }) {
266
- const needles = exact ? [query.toLowerCase().slice(0, MAX_NEEDLE_CHARS)] : tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
267
- const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
268
- const candidates = new Map();
269
- const response = await runContentSearch({ dir, includeHidden, searchNeedles: [...new Set(needles)], run, overlayText, signal, diskFiles, focusFile });
270
- absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, signal);
271
- const overlayTruncated = overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, signal);
272
-
273
- return { candidates, truncated: response.outputTruncated === true || overlayTruncated };
274
- }
275
-
276
15
  function rankScore(candidate, tokenCount) {
277
16
  return (candidate.exactDefinition ? 10000 : 0) + (candidate.exactPath ? 500 : 0)
278
17
  + candidate.definitionCoverage / tokenCount * 100 + candidate.matched.size / tokenCount * 30
@@ -0,0 +1,112 @@
1
+ import * as path from 'node:path';
2
+ import {extractStructuralSurface} from './surface.js';
3
+
4
+ const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
5
+
6
+ const EMPTY = Object.freeze([]);
7
+
8
+ const DEF_PATTERN = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(?:(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)|([A-Z][A-Z0-9_$]*)\s*(?::[^=\n]+)?=)/;
9
+
10
+ /** Declared identifier on a line (function/class/UPPER_CASE constant/…), or ""; the same rule snap and grep use. */
11
+ export function declaredName(line) {
12
+ const match = DEF_PATTERN.exec(String(line).trim());
13
+
14
+ return match?.[2] ?? match?.[3] ?? "";
15
+ }
16
+
17
+ function lineIndent(raw, i) {
18
+ return raw[i].length - raw[i].trimStart().length;
19
+ }
20
+
21
+ function pythonDeclarationEnd(raw, lower, start, lineCount) {
22
+ const base = lineIndent(raw, start - 1);
23
+ let end = start;
24
+
25
+ for (let i = start; i < lineCount; i++) {
26
+ if (lower[i] === "") { end = i + 1; continue; }
27
+ if (lineIndent(raw, i) <= base) break;
28
+ end = i + 1;
29
+ }
30
+
31
+ return Math.min(end, lineCount);
32
+ }
33
+
34
+ function braceDelta(text) {
35
+ let depth = 0;
36
+
37
+ for (const ch of text) {
38
+ if (ch === "{") depth++;
39
+ else if (ch === "}") depth--;
40
+ }
41
+
42
+ return depth;
43
+ }
44
+
45
+ function braceDeclarationEnd(raw, start, lineCount) {
46
+ let depth = braceDelta(raw[start - 1] ?? "");
47
+
48
+ if (depth <= 0) return start;
49
+
50
+ for (let i = start; i < raw.length; i++) {
51
+ depth += braceDelta(raw[i]);
52
+
53
+ if (depth <= 0) return i + 1;
54
+ }
55
+
56
+ return lineCount;
57
+ }
58
+
59
+ function declarationEnd(raw, lower, start, lineCount, ext) {
60
+ if (ext === ".py") return pythonDeclarationEnd(raw, lower, start, lineCount);
61
+
62
+ return braceDeclarationEnd(raw, start, lineCount);
63
+ }
64
+
65
+ export function fromText(filePath, text) {
66
+ return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
67
+ }
68
+
69
+ export function linesOf(entry) {
70
+ if (entry.lines) return entry.lines;
71
+ const raw = entry.text.split("\n");
72
+ const lower = [];
73
+ const defNames = [];
74
+ const idents = [];
75
+
76
+ for (let i = 0; i < raw.length; i++) {
77
+ const trimmed = raw[i].trim();
78
+ lower[i] = trimmed.toLowerCase();
79
+ const declared = DEF_PATTERN.exec(trimmed);
80
+ defNames[i] = (declared?.[2] ?? declared?.[3] ?? "").toLowerCase();
81
+ idents[i] = trimmed.match(IDENT_TOKEN) || EMPTY;
82
+ }
83
+
84
+ entry.lines = { raw, lower, defNames, idents };
85
+
86
+ return entry.lines;
87
+ }
88
+
89
+ export function spansOf(entry) {
90
+ if (entry.spans) return entry.spans;
91
+ const { items, lineCount } = surfaceOf(entry);
92
+ const { lower, raw } = linesOf(entry);
93
+ const spans = [];
94
+
95
+ for (let i = 0; i < items.length; i++) {
96
+ const start = items[i].line;
97
+ let end = declarationEnd(raw, lower, start, lineCount, entry.ext);
98
+
99
+ while (end > start && lower[end - 1] === "") end--;
100
+ spans.push({ start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true });
101
+ }
102
+
103
+ entry.spans = spans;
104
+
105
+ return spans;
106
+ }
107
+
108
+ export function surfaceOf(entry) {
109
+ if (!entry.surface) entry.surface = extractStructuralSurface(entry.text, entry.ext);
110
+
111
+ return entry.surface;
112
+ }
@@ -44,6 +44,12 @@ export function normalizeBash(command, opts) {
44
44
  if (args.command.includes("\0")) throw new Error("bash command must not contain null bytes");
45
45
  normalizeArgv(args);
46
46
 
47
+ normalizeTimeout(args);
48
+
49
+ return args;
50
+ }
51
+
52
+ function normalizeTimeout(args) {
47
53
  if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
48
54
  // Reject before the host's external-mutation barrier can flush staged files.
49
55
  if (args.timeoutMs !== undefined) {
@@ -52,5 +58,4 @@ export function normalizeBash(command, opts) {
52
58
  args.timeoutMs = Math.max(1, Math.min(2_147_483_647, Math.floor(timeout)));
53
59
  }
54
60
 
55
- return args;
56
61
  }
@@ -0,0 +1,36 @@
1
+ import {createRequire} from 'node:module';
2
+
3
+ // Sync only, never top-level await. Dynamic import of host/deps hung OMP plugin load.
4
+ const require = createRequire(import.meta.url);
5
+
6
+ let Type;
7
+
8
+ try {
9
+ Type = require("typebox").Type;
10
+ } catch {
11
+ Type = {
12
+ Object: (props, opts) => ({ type: "object", properties: props || {}, additionalProperties: false, ...opts }),
13
+ String: (opts) => ({ type: "string", ...opts }),
14
+ Unknown: (opts) => ({ ...opts }),
15
+ Array: (items, opts) => ({ type: "array", items, ...opts }),
16
+ Integer: (opts) => ({ type: "integer", ...opts }),
17
+ Optional: (s) => ({ ...s }),
18
+ Boolean: (opts) => ({ type: "boolean", ...opts }),
19
+ };
20
+ }
21
+
22
+ export function programParameters(config) {
23
+ return Type.Object({
24
+ code: Type.Optional(Type.String({ maxLength: config.maxCodeChars ?? 48000 })),
25
+ file: Type.Optional(Type.String({ minLength: 1 })),
26
+ data: Type.Optional(Type.Unknown()),
27
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })),
28
+ programs: Type.Optional(Type.Array(Type.Object({
29
+ code: Type.Optional(Type.String({ maxLength: config.maxCodeChars ?? 48000 })),
30
+ file: Type.Optional(Type.String({ minLength: 1 })),
31
+ data: Type.Optional(Type.Unknown()),
32
+ }, {additionalProperties:false}), {minItems:1,maxItems:32})),
33
+ parallel: Type.Optional(Type.Boolean()),
34
+ mergeData: Type.Optional(Type.Boolean()),
35
+ });
36
+ }