@sammysnake/fast-context-mcp 1.3.0-beta.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.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/package.json +47 -0
- package/src/core.mjs +1906 -0
- package/src/directory-scorer.mjs +1059 -0
- package/src/executor.mjs +597 -0
- package/src/extract-key.mjs +93 -0
- package/src/protobuf.mjs +235 -0
- package/src/server.mjs +320 -0
package/src/core.mjs
ADDED
|
@@ -0,0 +1,1906 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windsurf Fast Context — core protocol implementation (Node.js).
|
|
3
|
+
*
|
|
4
|
+
* Reverse-engineered Windsurf SWE-grep Connect-RPC/Protobuf protocol
|
|
5
|
+
* for standalone AI-driven semantic code search.
|
|
6
|
+
*
|
|
7
|
+
* Flow:
|
|
8
|
+
* query + tree → Windsurf Devstral API
|
|
9
|
+
* → Devstral returns tool_calls (rg/readfile/tree/ls/glob, up to 8 parallel)
|
|
10
|
+
* → execute locally → send results back → repeat for N rounds
|
|
11
|
+
* → ANSWER: file paths + line ranges + suggested rg patterns
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readdirSync, existsSync, statSync } from "node:fs";
|
|
15
|
+
import { resolve, join, relative, sep, isAbsolute } from "node:path";
|
|
16
|
+
import { gzipSync } from "node:zlib";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { platform, arch, release, version as osVersion, hostname, cpus, totalmem } from "node:os";
|
|
19
|
+
import treeNodeCli from "tree-node-cli";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
ProtobufEncoder,
|
|
23
|
+
extractStrings,
|
|
24
|
+
connectFrameEncode,
|
|
25
|
+
connectFrameDecode,
|
|
26
|
+
} from "./protobuf.mjs";
|
|
27
|
+
import { ToolExecutor } from "./executor.mjs";
|
|
28
|
+
import { extractKey } from "./extract-key.mjs";
|
|
29
|
+
import { scoreDirectories, tokenize as tokenizeBM25 } from "./directory-scorer.mjs";
|
|
30
|
+
|
|
31
|
+
// ─── Error Classification ──────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Classified error for fetch failures with structured error codes.
|
|
35
|
+
*/
|
|
36
|
+
class FastContextError extends Error {
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} message
|
|
39
|
+
* @param {string} code - TIMEOUT | PAYLOAD_TOO_LARGE | RATE_LIMITED | AUTH_ERROR | SERVER_ERROR | NETWORK_ERROR
|
|
40
|
+
* @param {Object} [details]
|
|
41
|
+
*/
|
|
42
|
+
constructor(message, code, details = {}) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "FastContextError";
|
|
45
|
+
this.code = code;
|
|
46
|
+
this.details = details;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Classify a raw fetch/HTTP error into a FastContextError.
|
|
52
|
+
* @param {Error} err
|
|
53
|
+
* @returns {FastContextError}
|
|
54
|
+
*/
|
|
55
|
+
function _classifyError(err) {
|
|
56
|
+
if (err instanceof FastContextError) return err;
|
|
57
|
+
|
|
58
|
+
// HTTP status-based classification
|
|
59
|
+
if (err.status) {
|
|
60
|
+
const s = err.status;
|
|
61
|
+
if (s === 413) return new FastContextError(err.message, "PAYLOAD_TOO_LARGE", { status: s });
|
|
62
|
+
if (s === 429) return new FastContextError(err.message, "RATE_LIMITED", { status: s });
|
|
63
|
+
if (s === 401 || s === 403) return new FastContextError(err.message, "AUTH_ERROR", { status: s });
|
|
64
|
+
return new FastContextError(err.message, "SERVER_ERROR", { status: s });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Timeout (AbortSignal.timeout throws AbortError or TimeoutError)
|
|
68
|
+
if (err.name === "AbortError" || err.name === "TimeoutError" || /timeout/i.test(err.message)) {
|
|
69
|
+
return new FastContextError(err.message, "TIMEOUT");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Everything else is a network-level issue
|
|
73
|
+
return new FastContextError(err.message, "NETWORK_ERROR");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Protocol Constants ────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
const API_BASE = "https://server.self-serve.windsurf.com/exa.api_server_pb.ApiServerService";
|
|
79
|
+
const AUTH_BASE = "https://server.self-serve.windsurf.com/exa.auth_pb.AuthService";
|
|
80
|
+
const WS_APP = "windsurf";
|
|
81
|
+
const WS_APP_VER = process.env.WS_APP_VER || "1.48.2";
|
|
82
|
+
const WS_LS_VER = process.env.WS_LS_VER || "1.9544.35";
|
|
83
|
+
const WS_MODEL = process.env.WS_MODEL || "MODEL_SWE_1_6_FAST";
|
|
84
|
+
const DEBUG_MODE = process.env.FAST_CONTEXT_DEBUG === "1" || process.env.FAST_CONTEXT_DEBUG === "true";
|
|
85
|
+
|
|
86
|
+
// Default excludes aligned with Windsurf fast-search guidance.
|
|
87
|
+
// Minimal defaults — only dirs that are almost never source code.
|
|
88
|
+
// Users can add more via the exclude_paths parameter.
|
|
89
|
+
const DEFAULT_EXCLUDE_PATHS = [
|
|
90
|
+
"node_modules",
|
|
91
|
+
".git",
|
|
92
|
+
"__pycache__",
|
|
93
|
+
".venv",
|
|
94
|
+
"venv",
|
|
95
|
+
"dist",
|
|
96
|
+
"*.min.*",
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
// Repo-map optimization defaults (tunable via MCP params).
|
|
100
|
+
const REPO_MAP_OPTIMIZER_DEFAULTS = {
|
|
101
|
+
mode: "bootstrap_hotspot", // classic | bootstrap_hotspot
|
|
102
|
+
bootstrapTreeDepth: 1,
|
|
103
|
+
hotspotTopK: 4,
|
|
104
|
+
hotspotTreeDepth: 2,
|
|
105
|
+
maxBytes: 120 * 1024,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function _mergeExcludePaths(excludePaths = []) {
|
|
109
|
+
const merged = [...DEFAULT_EXCLUDE_PATHS];
|
|
110
|
+
for (const p of excludePaths || []) {
|
|
111
|
+
if (typeof p === "string" && p && !merged.includes(p)) {
|
|
112
|
+
merged.push(p);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return merged;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── System Prompt Template ────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const SYSTEM_PROMPT_TEMPLATE = `You are an expert software engineer, responsible for providing context \
|
|
121
|
+
to another engineer to solve a code issue in the current codebase. \
|
|
122
|
+
The user will present you with a description of the issue, and it is \
|
|
123
|
+
your job to provide a series of file paths with associated line ranges \
|
|
124
|
+
that contain ALL the information relevant to understand and correctly \
|
|
125
|
+
address the issue.
|
|
126
|
+
|
|
127
|
+
# IMPORTANT:
|
|
128
|
+
- A relevant file does not mean only the files that must be modified to \
|
|
129
|
+
solve the task. It means any file that contains information relevant to \
|
|
130
|
+
planning and implementing the fix, such as the definitions of classes \
|
|
131
|
+
and functions that are relevant to the pieces of code that will have to \
|
|
132
|
+
be modified.
|
|
133
|
+
- You should include enough context around the relevant lines to allow \
|
|
134
|
+
the engineer to understand the task correctly. You must include ENTIRE \
|
|
135
|
+
semantic blocks (functions, classes, definitions, etc). For example:
|
|
136
|
+
If addressing the issue requires modifying a method within a class, then \
|
|
137
|
+
you should include the entire class definition, not just the lines around \
|
|
138
|
+
the method we want to modify.
|
|
139
|
+
- NEVER truncate these blocks unless they are very large (hundreds of \
|
|
140
|
+
lines or more, in which case providing only a relevant portion of the \
|
|
141
|
+
block is acceptable).
|
|
142
|
+
- Your job is to essentially alleviate the job of the other engineer by \
|
|
143
|
+
giving them a clean starting context from which to start working. More \
|
|
144
|
+
precisely, you should minimize the number of files the engineer has to \
|
|
145
|
+
read to understand and solve the task correctly (while not providing \
|
|
146
|
+
irrelevant code snippets).
|
|
147
|
+
|
|
148
|
+
# ENVIRONMENT
|
|
149
|
+
- Working directory: /codebase. Make sure to run commands in this \
|
|
150
|
+
directory, not \`.
|
|
151
|
+
- Tool access: use the restricted_exec tool ONLY
|
|
152
|
+
- Allowed sub-commands (schema-enforced):
|
|
153
|
+
- rg: Search for patterns in files using ripgrep
|
|
154
|
+
- Required: pattern (string), path (string)
|
|
155
|
+
- Optional: include (array of globs), exclude (array of globs)
|
|
156
|
+
- readfile: Read contents of a file with optional line range
|
|
157
|
+
- Required: file (string)
|
|
158
|
+
- Optional: start_line (int), end_line (int) — 1-indexed, inclusive
|
|
159
|
+
- tree: Display directory structure as a tree
|
|
160
|
+
- Required: path (string)
|
|
161
|
+
- Optional: levels (int)
|
|
162
|
+
|
|
163
|
+
# THINKING RULES
|
|
164
|
+
- Think step-by-step. Plan, reason, and reflect before each tool call.
|
|
165
|
+
- Use tool calls liberally and purposefully to ground every conclusion \
|
|
166
|
+
in real code, not assumptions.
|
|
167
|
+
- If a command fails, rethink and try something different; do not \
|
|
168
|
+
complain to the user.
|
|
169
|
+
- AVOID REDUNDANT SEARCHES: Do not search for the same pattern multiple \
|
|
170
|
+
times with slightly different paths or excludes. One well-targeted search \
|
|
171
|
+
is better than multiple overlapping ones.
|
|
172
|
+
- PRIORITIZE READING over searching: Once you find a file path, read it \
|
|
173
|
+
directly instead of searching for more variations of the same pattern.
|
|
174
|
+
|
|
175
|
+
# FAST-SEARCH DEFAULTS (optimize rg/tree on large repos)
|
|
176
|
+
- Start NARROW, then widen only if needed. Prefer searching likely code \
|
|
177
|
+
roots first (e.g., \`src/\`, \`lib/\`, \`app/\`, \`packages/\`, \`services/\`) \
|
|
178
|
+
instead of \`/codebase\`.
|
|
179
|
+
- Prefer fixed-string search for literals: escape patterns or keep regex \
|
|
180
|
+
simple. Use smart case; avoid case-insensitive unless necessary.
|
|
181
|
+
- Prefer file-type filters and globs (in include) over full-repo scans.
|
|
182
|
+
- Default EXCLUDES for speed (apply via the exclude array): \
|
|
183
|
+
node_modules, .git, dist, build, coverage, .venv, venv, target, out, \
|
|
184
|
+
.cache, __pycache__, vendor, deps, third_party, logs, data, *.min.*
|
|
185
|
+
- Skip huge files where possible; when opening files, prefer reading \
|
|
186
|
+
only relevant ranges with readfile.
|
|
187
|
+
- Limit directory traversal with tree levels to quickly orient before \
|
|
188
|
+
deeper inspection.
|
|
189
|
+
|
|
190
|
+
# SOME EXAMPLES OF WORKFLOWS
|
|
191
|
+
- MAP – Use \`tree\` with small levels; \`rg\` on likely roots to grasp \
|
|
192
|
+
structure and hotspots.
|
|
193
|
+
- ANCHOR – \`rg\` for problem keywords and anchor symbols; restrict by \
|
|
194
|
+
language globs via include.
|
|
195
|
+
- TRACE – Follow imports with targeted \`rg\` in narrowed roots; open \
|
|
196
|
+
files with \`readfile\` scoped to entire semantic blocks.
|
|
197
|
+
- VERIFY – Confirm each candidate path exists by reading or additional \
|
|
198
|
+
searches; drop false positives (tests, vendored, generated) unless they \
|
|
199
|
+
must change.
|
|
200
|
+
|
|
201
|
+
# TOOL USE GUIDELINES
|
|
202
|
+
- You must use a SINGLE restricted_exec call in your answer, that lets \
|
|
203
|
+
you execute at most {max_commands} commands in a single turn. Each command must be \
|
|
204
|
+
an object with a \`type\` field of \`rg\`, \`readfile\`, or \`tree\` and the appropriate fields for that type.
|
|
205
|
+
- Example restricted_exec usage:
|
|
206
|
+
[TOOL_CALLS]restricted_exec[ARGS]{{
|
|
207
|
+
"command1": {{
|
|
208
|
+
"type": "rg",
|
|
209
|
+
"pattern": "Controller",
|
|
210
|
+
"path": "/codebase/slime",
|
|
211
|
+
"include": ["**/*.py"],
|
|
212
|
+
"exclude": ["**/node_modules/**", "**/.git/**", "**/dist/**", \
|
|
213
|
+
"**/build/**", "**/.venv/**", "**/__pycache__/**"]
|
|
214
|
+
}},
|
|
215
|
+
"command2": {{
|
|
216
|
+
"type": "readfile",
|
|
217
|
+
"file": "/codebase/slime/train.py",
|
|
218
|
+
"start_line": 1,
|
|
219
|
+
"end_line": 200
|
|
220
|
+
}},
|
|
221
|
+
"command3": {{
|
|
222
|
+
"type": "tree",
|
|
223
|
+
"path": "/codebase/slime/",
|
|
224
|
+
"levels": 2
|
|
225
|
+
}}
|
|
226
|
+
}}
|
|
227
|
+
- You have at most {max_turns} turns to interact with the environment by calling \
|
|
228
|
+
tools, so issuing multiple commands at once is necessary and encouraged \
|
|
229
|
+
to speed up your research.
|
|
230
|
+
- Each command result may be truncated to 50 lines; prefer multiple \
|
|
231
|
+
targeted reads/searches to build complete context.
|
|
232
|
+
- DO NOT EVER USE MORE THAN {max_commands} commands in a single turn, or you will \
|
|
233
|
+
be penalized.
|
|
234
|
+
|
|
235
|
+
# ANSWER FORMAT (strict format, including tags)
|
|
236
|
+
- You will output an XML structure with a root element "ANSWER" \
|
|
237
|
+
containing "file" elements. Each "file" element will have a "path" \
|
|
238
|
+
attribute and contain "range" elements.
|
|
239
|
+
- You will output this as your final response.
|
|
240
|
+
- The line ranges must be inclusive.
|
|
241
|
+
|
|
242
|
+
Output example inside the "answer" tool argument:
|
|
243
|
+
<ANSWER>
|
|
244
|
+
<file path="/codebase/info_theory/formulas/entropy.py">
|
|
245
|
+
<range>10-60</range>
|
|
246
|
+
<range>150-210</range>
|
|
247
|
+
</file>
|
|
248
|
+
<file path="/codebase/info_theory/data_structures/bits.py">
|
|
249
|
+
<range>1-40</range>
|
|
250
|
+
<range>110-170</range>
|
|
251
|
+
</file>
|
|
252
|
+
</ANSWER>
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
Remember: Prefer narrow, fixed-string, and type-filtered searches with \
|
|
256
|
+
aggressive excludes and size/depth limits. Widen scope only as needed. \
|
|
257
|
+
Use the restricted tools available to you, and output your answer in \
|
|
258
|
+
exactly the specified format.
|
|
259
|
+
|
|
260
|
+
# NO RESULTS POLICY
|
|
261
|
+
If after thorough searching you are confident that NO relevant files exist \
|
|
262
|
+
for the given query (e.g., the function/class/concept does not exist in the \
|
|
263
|
+
codebase), you MUST return an empty ANSWER:
|
|
264
|
+
<ANSWER></ANSWER>
|
|
265
|
+
Do NOT return irrelevant files (such as entry points or config files) just \
|
|
266
|
+
to provide some output. An empty answer is always better than a misleading one.
|
|
267
|
+
|
|
268
|
+
# RESULT COUNT
|
|
269
|
+
Aim to return at most {max_results} files in your answer. Focus on the most \
|
|
270
|
+
relevant files first. If fewer files are relevant, return fewer.
|
|
271
|
+
`;
|
|
272
|
+
|
|
273
|
+
const FINAL_FORCE_ANSWER =
|
|
274
|
+
"You have no turns left. Now you MUST provide your final ANSWER, even if it's not complete.";
|
|
275
|
+
|
|
276
|
+
const BOOTSTRAP_PROMPT_TEMPLATE = `You are a bootstrap planning agent for codebase hotspot discovery.
|
|
277
|
+
Your ONLY goal is to discover high-signal search keywords and hotspot directories for a later full search phase.
|
|
278
|
+
|
|
279
|
+
# OUTPUT CONTRACT
|
|
280
|
+
- Use the restricted_exec tool ONLY.
|
|
281
|
+
- Prefer rg + tree commands. Avoid deep readfile unless absolutely necessary.
|
|
282
|
+
- Do NOT output final <ANSWER> for code fixes in this phase.
|
|
283
|
+
- Keep commands focused and broad enough to identify likely relevant modules quickly.
|
|
284
|
+
|
|
285
|
+
# TOOL BUDGET
|
|
286
|
+
- You have at most {max_turns} turns.
|
|
287
|
+
- You may use up to {max_commands} commands per turn.
|
|
288
|
+
|
|
289
|
+
# STRATEGY
|
|
290
|
+
1) Start from the provided mini repo map.
|
|
291
|
+
2) Use targeted rg patterns derived from the user problem.
|
|
292
|
+
3) Use tree on likely top-level directories to identify hotspots.
|
|
293
|
+
4) Stop once you have enough keyword and hotspot coverage for phase-2.
|
|
294
|
+
`;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Smart trim accumulated messages to reduce payload size.
|
|
298
|
+
*
|
|
299
|
+
* Why this is needed:
|
|
300
|
+
* - Proto size grows quickly across turns (messages + tool results).
|
|
301
|
+
* - Keeping only the last N messages naively may drop the tool-call ↔ tool-result
|
|
302
|
+
* linkage (tool_call_id/ref_call_id) and remove useful progress context.
|
|
303
|
+
*
|
|
304
|
+
* Strategy:
|
|
305
|
+
* - Keep system prompt (index 0).
|
|
306
|
+
* - Keep user problem statement, but compact the repo map when trimming.
|
|
307
|
+
* - Keep the latest tool-call + tool-result pair (plus any trailing prompts).
|
|
308
|
+
* - Insert a compact progress summary so the model doesn't lose the thread.
|
|
309
|
+
*
|
|
310
|
+
* @param {Array} messages
|
|
311
|
+
* @param {Object} [state]
|
|
312
|
+
* @param {string} [state.query]
|
|
313
|
+
* @param {string[]} [state.recentFiles]
|
|
314
|
+
* @param {string[]} [state.recentPatterns]
|
|
315
|
+
* @param {Array<{type:string, desc:string}>} [state.recentCommands]
|
|
316
|
+
* @param {number} [state.turn]
|
|
317
|
+
* @returns {boolean} true if messages were actually trimmed/compacted
|
|
318
|
+
*/
|
|
319
|
+
function _trimMessages(messages, state = {}) {
|
|
320
|
+
if (!Array.isArray(messages) || messages.length < 2) return false;
|
|
321
|
+
|
|
322
|
+
const systemMsg = messages[0];
|
|
323
|
+
const userMsg = messages[1];
|
|
324
|
+
|
|
325
|
+
const truncateToolResultsPreserve = (text, maxPerBlock = 4000, maxTotal = 20000) => {
|
|
326
|
+
if (typeof text !== "string" || text.length <= maxTotal) return text;
|
|
327
|
+
const re = /<(command\d+)_result>\n([\s\S]*?)\n<\/\1_result>/g;
|
|
328
|
+
let m;
|
|
329
|
+
const parts = [];
|
|
330
|
+
let matched = false;
|
|
331
|
+
while ((m = re.exec(text)) !== null) {
|
|
332
|
+
matched = true;
|
|
333
|
+
const key = m[1];
|
|
334
|
+
let body = m[2] || "";
|
|
335
|
+
if (body.length > maxPerBlock) {
|
|
336
|
+
body = body.slice(0, maxPerBlock) + "\n...[truncated]...";
|
|
337
|
+
}
|
|
338
|
+
parts.push(`<${key}_result>\n${body}\n</${key}_result>`);
|
|
339
|
+
if (parts.join("").length > maxTotal) break;
|
|
340
|
+
}
|
|
341
|
+
if (!matched) {
|
|
342
|
+
return text.slice(0, maxTotal) + "\n...[tool results truncated]...";
|
|
343
|
+
}
|
|
344
|
+
const out = parts.join("");
|
|
345
|
+
return out.length <= maxTotal ? out : out.slice(0, maxTotal) + "\n...[tool results truncated]...";
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Find the most recent tool-result message and its matching tool-call message (if present).
|
|
349
|
+
let lastToolResultIdx = -1;
|
|
350
|
+
let refId = null;
|
|
351
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
352
|
+
const m = messages[i];
|
|
353
|
+
if (m && m.role === 4 && typeof m.ref_call_id === "string" && m.ref_call_id) {
|
|
354
|
+
lastToolResultIdx = i;
|
|
355
|
+
refId = m.ref_call_id;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let lastToolCallIdx = -1;
|
|
361
|
+
if (refId) {
|
|
362
|
+
for (let i = lastToolResultIdx - 1; i >= 0; i--) {
|
|
363
|
+
const m = messages[i];
|
|
364
|
+
if (m && m.role === 2 && m.tool_call_id === refId) {
|
|
365
|
+
lastToolCallIdx = i;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Tail: keep tool-call + tool-result pair, plus anything after it (e.g., force-answer).
|
|
372
|
+
let tailStart = -1;
|
|
373
|
+
if (lastToolResultIdx !== -1) {
|
|
374
|
+
tailStart = lastToolCallIdx !== -1 ? lastToolCallIdx : Math.max(2, lastToolResultIdx - 1);
|
|
375
|
+
} else {
|
|
376
|
+
// No tool results yet: keep the last few messages only.
|
|
377
|
+
tailStart = Math.max(2, messages.length - 4);
|
|
378
|
+
}
|
|
379
|
+
const tail = messages.slice(tailStart);
|
|
380
|
+
|
|
381
|
+
// Compact the user message (repo map) when trimming, since it's usually the largest chunk.
|
|
382
|
+
let compactedUser = userMsg;
|
|
383
|
+
let didCompactUser = false;
|
|
384
|
+
if (userMsg && typeof userMsg.content === "string" && userMsg.content.includes("Repo Map")) {
|
|
385
|
+
const q =
|
|
386
|
+
(typeof state.query === "string" && state.query) ||
|
|
387
|
+
userMsg.content.match(/Problem Statement:\s*([^\n]+)/)?.[1]?.trim() ||
|
|
388
|
+
"";
|
|
389
|
+
const compact = `Problem Statement: ${q}\n\nRepo Map: (omitted to reduce payload). Use tree/rg to explore structure if needed.`;
|
|
390
|
+
if (compact.length < userMsg.content.length) {
|
|
391
|
+
compactedUser = { ...userMsg, content: compact };
|
|
392
|
+
didCompactUser = true;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Build a compact progress summary to preserve important context across trims.
|
|
397
|
+
const recentCommands = Array.isArray(state.recentCommands) ? state.recentCommands : [];
|
|
398
|
+
const recentFiles = Array.isArray(state.recentFiles) ? state.recentFiles : [];
|
|
399
|
+
const recentPatterns = Array.isArray(state.recentPatterns) ? state.recentPatterns : [];
|
|
400
|
+
const turnNote = Number.isInteger(state.turn) ? ` turn=${state.turn}` : "";
|
|
401
|
+
|
|
402
|
+
const summaryLines = [
|
|
403
|
+
`[Context trimmed to reduce payload size.${turnNote}]`,
|
|
404
|
+
recentCommands.length ? `recent_commands: ${recentCommands.slice(-6).map((c) => c.desc).join(" | ")}` : "",
|
|
405
|
+
recentFiles.length ? `recent_files: ${recentFiles.slice(-12).join(", ")}` : "",
|
|
406
|
+
recentPatterns.length ? `rg_patterns: ${recentPatterns.slice(-20).join(", ")}` : "",
|
|
407
|
+
"Continue from the most recent tool results kept below.",
|
|
408
|
+
].filter(Boolean);
|
|
409
|
+
|
|
410
|
+
const summaryMsg = { role: 1, content: summaryLines.join("\n") };
|
|
411
|
+
|
|
412
|
+
// If trimming doesn't actually reduce anything, bail.
|
|
413
|
+
// We consider it "useful" if we either compact the user message or drop history.
|
|
414
|
+
const willDropHistory = tailStart > 2;
|
|
415
|
+
if (!didCompactUser && !willDropHistory) return false;
|
|
416
|
+
|
|
417
|
+
// Reduce oversized assistant/tool messages in the tail to avoid immediate re-overflow.
|
|
418
|
+
for (const m of tail) {
|
|
419
|
+
if (m && typeof m.content === "string") {
|
|
420
|
+
if (m.role === 2 && m.content.length > 8000) {
|
|
421
|
+
m.content = m.content.slice(0, 8000) + "\n...[assistant content truncated]...";
|
|
422
|
+
}
|
|
423
|
+
if (m.role === 4 && m.content.length > 20000) {
|
|
424
|
+
m.content = truncateToolResultsPreserve(m.content, 4000, 20000);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
messages.length = 0;
|
|
430
|
+
messages.push(systemMsg);
|
|
431
|
+
// Avoid duplicating user message if it's already within the kept tail.
|
|
432
|
+
if (tailStart > 1) {
|
|
433
|
+
messages.push(compactedUser);
|
|
434
|
+
}
|
|
435
|
+
messages.push(summaryMsg, ...tail);
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* @param {number} maxTurns
|
|
441
|
+
* @param {number} maxCommands
|
|
442
|
+
* @param {number} maxResults
|
|
443
|
+
* @returns {string}
|
|
444
|
+
*/
|
|
445
|
+
function buildSystemPrompt(maxTurns = 3, maxCommands = 8, maxResults = 10) {
|
|
446
|
+
return SYSTEM_PROMPT_TEMPLATE
|
|
447
|
+
.replaceAll("{max_turns}", String(maxTurns))
|
|
448
|
+
.replaceAll("{max_commands}", String(maxCommands))
|
|
449
|
+
.replaceAll("{max_results}", String(maxResults));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function buildBootstrapPrompt(maxTurns = 2, maxCommands = 6) {
|
|
453
|
+
return BOOTSTRAP_PROMPT_TEMPLATE
|
|
454
|
+
.replaceAll("{max_turns}", String(maxTurns))
|
|
455
|
+
.replaceAll("{max_commands}", String(maxCommands));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function _extractTopDirFromCodebasePath(path = "") {
|
|
459
|
+
const p = String(path || "").replace(/\\/g, "/");
|
|
460
|
+
if (!p.startsWith("/codebase")) return null;
|
|
461
|
+
const rel = p.replace(/^\/codebase\/?/, "");
|
|
462
|
+
if (!rel) return null;
|
|
463
|
+
return rel.split("/")[0] || null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function _runBootstrapPhase({
|
|
467
|
+
query,
|
|
468
|
+
projectRoot,
|
|
469
|
+
apiKey,
|
|
470
|
+
jwt,
|
|
471
|
+
timeoutMs,
|
|
472
|
+
excludePaths,
|
|
473
|
+
bootstrapTreeDepth,
|
|
474
|
+
bootstrapMaxTurns,
|
|
475
|
+
bootstrapMaxCommands,
|
|
476
|
+
onProgress,
|
|
477
|
+
}) {
|
|
478
|
+
const log = (msg) => onProgress?.(`[bootstrap] ${msg}`);
|
|
479
|
+
const hints = { rgPatterns: [], hotDirs: [] };
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
const { tree: miniMap, depth } = getRepoMap(projectRoot, bootstrapTreeDepth, excludePaths);
|
|
483
|
+
const systemPrompt = buildBootstrapPrompt(bootstrapMaxTurns, bootstrapMaxCommands);
|
|
484
|
+
const userContent = `Problem Statement: ${query}\n\nRepo Map (tree -L ${depth} /codebase):\n\`\`\`text\n${miniMap}\n\`\`\``;
|
|
485
|
+
|
|
486
|
+
const messages = [
|
|
487
|
+
{ role: 5, content: systemPrompt },
|
|
488
|
+
{ role: 1, content: userContent },
|
|
489
|
+
];
|
|
490
|
+
|
|
491
|
+
const toolDefs = getToolDefinitions(bootstrapMaxCommands);
|
|
492
|
+
const executor = new ToolExecutor(projectRoot);
|
|
493
|
+
|
|
494
|
+
for (let turn = 0; turn < bootstrapMaxTurns; turn++) {
|
|
495
|
+
log(`Turn ${turn + 1}/${bootstrapMaxTurns}`);
|
|
496
|
+
const proto = _buildRequest(apiKey, jwt, messages, toolDefs);
|
|
497
|
+
let respData;
|
|
498
|
+
try {
|
|
499
|
+
respData = await _streamingRequest(proto, timeoutMs);
|
|
500
|
+
} catch (e) {
|
|
501
|
+
log(`request failed: ${e.code || "UNKNOWN"}`);
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const [thinking, toolInfo] = _parseResponse(respData);
|
|
506
|
+
if (!toolInfo) break;
|
|
507
|
+
|
|
508
|
+
const [toolName, toolArgs] = toolInfo;
|
|
509
|
+
if (toolName !== "restricted_exec") break;
|
|
510
|
+
|
|
511
|
+
const callId = randomUUID();
|
|
512
|
+
const argsJson = JSON.stringify(toolArgs);
|
|
513
|
+
const cmds = Object.keys(toolArgs).filter((k) => k.startsWith("command"));
|
|
514
|
+
|
|
515
|
+
for (const cmdKey of cmds) {
|
|
516
|
+
const cmd = toolArgs[cmdKey];
|
|
517
|
+
if (!cmd || typeof cmd !== "object") continue;
|
|
518
|
+
if (cmd.type === "rg" && typeof cmd.pattern === "string" && cmd.pattern) {
|
|
519
|
+
hints.rgPatterns.push(cmd.pattern);
|
|
520
|
+
}
|
|
521
|
+
if (cmd.type === "tree" && typeof cmd.path === "string") {
|
|
522
|
+
const top = _extractTopDirFromCodebasePath(cmd.path);
|
|
523
|
+
if (top) hints.hotDirs.push(top);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const results = await executor.execToolCallAsync(toolArgs);
|
|
528
|
+
messages.push({
|
|
529
|
+
role: 2,
|
|
530
|
+
content: thinking,
|
|
531
|
+
tool_call_id: callId,
|
|
532
|
+
tool_name: "restricted_exec",
|
|
533
|
+
tool_args_json: argsJson,
|
|
534
|
+
});
|
|
535
|
+
messages.push({ role: 4, content: results, ref_call_id: callId });
|
|
536
|
+
}
|
|
537
|
+
} catch {
|
|
538
|
+
// Bootstrap is best-effort. Fall back silently.
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
rgPatterns: [...new Set(hints.rgPatterns)].slice(-30),
|
|
543
|
+
hotDirs: [...new Set(hints.hotDirs)].slice(-12),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ─── Tool Schema ───────────────────────────────────────────
|
|
548
|
+
|
|
549
|
+
function _buildCommandSchema(n) {
|
|
550
|
+
return {
|
|
551
|
+
type: "object",
|
|
552
|
+
description: `Command ${n} to execute. Must be one of: rg, readfile, or tree.`,
|
|
553
|
+
oneOf: [
|
|
554
|
+
{
|
|
555
|
+
properties: {
|
|
556
|
+
type: { type: "string", const: "rg", description: "Search for patterns in files using ripgrep." },
|
|
557
|
+
pattern: { type: "string", description: "The regex pattern to search for." },
|
|
558
|
+
path: { type: "string", description: "The path to search in." },
|
|
559
|
+
include: { type: "array", items: { type: "string" }, description: "File patterns to include." },
|
|
560
|
+
exclude: { type: "array", items: { type: "string" }, description: "File patterns to exclude." },
|
|
561
|
+
},
|
|
562
|
+
required: ["type", "pattern", "path"],
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
properties: {
|
|
566
|
+
type: { type: "string", const: "readfile", description: "Read contents of a file with optional line range." },
|
|
567
|
+
file: { type: "string", description: "Path to the file to read." },
|
|
568
|
+
start_line: { type: "integer", description: "Starting line number (1-indexed)." },
|
|
569
|
+
end_line: { type: "integer", description: "Ending line number (1-indexed)." },
|
|
570
|
+
},
|
|
571
|
+
required: ["type", "file"],
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
properties: {
|
|
575
|
+
type: { type: "string", const: "tree", description: "Display directory structure as a tree." },
|
|
576
|
+
path: { type: "string", description: "Path to the directory." },
|
|
577
|
+
levels: { type: "integer", description: "Number of directory levels." },
|
|
578
|
+
},
|
|
579
|
+
required: ["type", "path"],
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
properties: {
|
|
583
|
+
type: { type: "string", const: "ls", description: "List files in a directory." },
|
|
584
|
+
path: { type: "string", description: "Path to the directory." },
|
|
585
|
+
long_format: { type: "boolean" },
|
|
586
|
+
all: { type: "boolean" },
|
|
587
|
+
},
|
|
588
|
+
required: ["type", "path"],
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
properties: {
|
|
592
|
+
type: { type: "string", const: "glob", description: "Find files matching a glob pattern." },
|
|
593
|
+
pattern: { type: "string" },
|
|
594
|
+
path: { type: "string" },
|
|
595
|
+
type_filter: { type: "string", enum: ["file", "directory", "all"] },
|
|
596
|
+
},
|
|
597
|
+
required: ["type", "pattern", "path"],
|
|
598
|
+
},
|
|
599
|
+
],
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* @param {number} maxCommands
|
|
605
|
+
* @returns {string}
|
|
606
|
+
*/
|
|
607
|
+
function getToolDefinitions(maxCommands = 8) {
|
|
608
|
+
const props = {};
|
|
609
|
+
for (let i = 1; i <= maxCommands; i++) {
|
|
610
|
+
props[`command${i}`] = _buildCommandSchema(i);
|
|
611
|
+
}
|
|
612
|
+
const tools = [
|
|
613
|
+
{
|
|
614
|
+
type: "function",
|
|
615
|
+
function: {
|
|
616
|
+
name: "restricted_exec",
|
|
617
|
+
description: "Execute restricted commands (rg, readfile, tree, ls, glob) in parallel.",
|
|
618
|
+
parameters: { type: "object", properties: props, required: ["command1"] },
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
type: "function",
|
|
623
|
+
function: {
|
|
624
|
+
name: "answer",
|
|
625
|
+
description: "Final answer with relevant files and line ranges.",
|
|
626
|
+
parameters: {
|
|
627
|
+
type: "object",
|
|
628
|
+
properties: { answer: { type: "string", description: "The final answer in XML format." } },
|
|
629
|
+
required: ["answer"],
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
];
|
|
634
|
+
return JSON.stringify(tools);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ─── Credentials ───────────────────────────────────────────
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Auto-discover Windsurf API key from local installation.
|
|
641
|
+
* @returns {Promise<string|null>}
|
|
642
|
+
*/
|
|
643
|
+
async function autoDiscoverApiKey() {
|
|
644
|
+
try {
|
|
645
|
+
const result = await extractKey();
|
|
646
|
+
if (result.api_key && result.api_key.startsWith("sk-")) {
|
|
647
|
+
return result.api_key;
|
|
648
|
+
}
|
|
649
|
+
} catch {
|
|
650
|
+
// Extraction failed
|
|
651
|
+
}
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Get API key from env var or auto-discovery.
|
|
657
|
+
* @returns {Promise<string>}
|
|
658
|
+
*/
|
|
659
|
+
async function getApiKey() {
|
|
660
|
+
const key = process.env.WINDSURF_API_KEY;
|
|
661
|
+
if (key) return key;
|
|
662
|
+
const discovered = await autoDiscoverApiKey();
|
|
663
|
+
if (discovered) return discovered;
|
|
664
|
+
throw new Error(
|
|
665
|
+
"Windsurf API Key not found. Set WINDSURF_API_KEY env var or ensure Windsurf is logged in. " +
|
|
666
|
+
"Run extract-key.mjs to see extraction methods."
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ─── JWT Cache ──────────────────────────────────────────────
|
|
671
|
+
|
|
672
|
+
/** @type {Map<string, { token: string, expiresAt: number }>} */
|
|
673
|
+
const _jwtCache = new Map();
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Decode JWT payload and extract expiration time.
|
|
677
|
+
* @param {string} jwt
|
|
678
|
+
* @returns {number} expiration timestamp in seconds
|
|
679
|
+
*/
|
|
680
|
+
function _getJwtExp(jwt) {
|
|
681
|
+
try {
|
|
682
|
+
const parts = jwt.split(".");
|
|
683
|
+
if (parts.length < 2) return 0;
|
|
684
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
|
|
685
|
+
return payload.exp || 0;
|
|
686
|
+
} catch {
|
|
687
|
+
return 0;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Get a cached or fresh JWT token.
|
|
693
|
+
* Refreshes when token expires or is within 60s of expiration.
|
|
694
|
+
* @param {string} apiKey
|
|
695
|
+
* @returns {Promise<string>}
|
|
696
|
+
*/
|
|
697
|
+
async function getCachedJwt(apiKey) {
|
|
698
|
+
const now = Math.floor(Date.now() / 1000);
|
|
699
|
+
const cached = _jwtCache.get(apiKey);
|
|
700
|
+
if (cached && cached.expiresAt > now + 60) return cached.token;
|
|
701
|
+
const token = await fetchJwt(apiKey);
|
|
702
|
+
const exp = _getJwtExp(token);
|
|
703
|
+
_jwtCache.set(apiKey, { token, expiresAt: exp || now + 3600 });
|
|
704
|
+
return token;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ─── TLS Fallback ──────────────────────────────────────────
|
|
708
|
+
// Match Python's SSL fallback: if NODE_TLS_REJECT_UNAUTHORIZED is not set
|
|
709
|
+
// and the first fetch fails with a TLS error, disable cert verification.
|
|
710
|
+
let _tlsFallbackApplied = false;
|
|
711
|
+
|
|
712
|
+
function _applyTlsFallback() {
|
|
713
|
+
if (!_tlsFallbackApplied && !process.env.NODE_TLS_REJECT_UNAUTHORIZED) {
|
|
714
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
715
|
+
_tlsFallbackApplied = true;
|
|
716
|
+
process.stderr.write(
|
|
717
|
+
"[fast-context] WARNING: TLS certificate verification disabled due to connection failure. " +
|
|
718
|
+
"Set NODE_TLS_REJECT_UNAUTHORIZED=0 explicitly to suppress this warning.\n"
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ─── Network Layer ─────────────────────────────────────────
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Standard unary HTTP POST with proto content type.
|
|
727
|
+
* @param {string} url
|
|
728
|
+
* @param {Buffer} protoBytes
|
|
729
|
+
* @param {boolean} [compress=true]
|
|
730
|
+
* @returns {Promise<Buffer>}
|
|
731
|
+
*/
|
|
732
|
+
async function _unaryRequest(url, protoBytes, compress = true) {
|
|
733
|
+
const headers = {
|
|
734
|
+
"Content-Type": "application/proto",
|
|
735
|
+
"Connect-Protocol-Version": "1",
|
|
736
|
+
"User-Agent": "connect-go/1.18.1 (go1.25.5)",
|
|
737
|
+
"Accept-Encoding": "gzip",
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
let body;
|
|
741
|
+
if (compress) {
|
|
742
|
+
body = gzipSync(protoBytes);
|
|
743
|
+
headers["Content-Encoding"] = "gzip";
|
|
744
|
+
} else {
|
|
745
|
+
body = protoBytes;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const doFetch = () => fetch(url, {
|
|
749
|
+
method: "POST",
|
|
750
|
+
headers,
|
|
751
|
+
body,
|
|
752
|
+
signal: AbortSignal.timeout(30000),
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
let resp;
|
|
756
|
+
try {
|
|
757
|
+
resp = await doFetch();
|
|
758
|
+
} catch (e) {
|
|
759
|
+
// TLS or network error — try with cert verification disabled
|
|
760
|
+
_applyTlsFallback();
|
|
761
|
+
try {
|
|
762
|
+
resp = await doFetch();
|
|
763
|
+
} catch (e2) {
|
|
764
|
+
throw _classifyError(e2);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (!resp.ok) {
|
|
769
|
+
const err = new Error(`HTTP ${resp.status}`);
|
|
770
|
+
err.status = resp.status;
|
|
771
|
+
throw _classifyError(err);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const arrayBuf = await resp.arrayBuffer();
|
|
775
|
+
return Buffer.from(arrayBuf);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Connect-RPC streaming POST to GetDevstralStream with retry.
|
|
780
|
+
* @param {Buffer} protoBytes
|
|
781
|
+
* @param {number} [timeoutMs=30000]
|
|
782
|
+
* @param {number} [maxRetries=2]
|
|
783
|
+
* @returns {Promise<Buffer>}
|
|
784
|
+
*/
|
|
785
|
+
async function _streamingRequest(protoBytes, timeoutMs = 30000, maxRetries = 2) {
|
|
786
|
+
const frame = connectFrameEncode(protoBytes);
|
|
787
|
+
const url = `${API_BASE}/GetDevstralStream`;
|
|
788
|
+
const traceId = randomUUID().replace(/-/g, "");
|
|
789
|
+
const spanId = randomUUID().replace(/-/g, "").slice(0, 16);
|
|
790
|
+
const baseTimeoutMs = Number.isFinite(timeoutMs) ? timeoutMs : 30000;
|
|
791
|
+
const abortMs = baseTimeoutMs + 5000;
|
|
792
|
+
|
|
793
|
+
const headers = {
|
|
794
|
+
"Content-Type": "application/connect+proto",
|
|
795
|
+
"Connect-Protocol-Version": "1",
|
|
796
|
+
"Connect-Accept-Encoding": "gzip",
|
|
797
|
+
"Connect-Content-Encoding": "gzip",
|
|
798
|
+
"Connect-Timeout-Ms": String(baseTimeoutMs),
|
|
799
|
+
"User-Agent": "connect-go/1.18.1 (go1.25.5)",
|
|
800
|
+
"Accept-Encoding": "identity",
|
|
801
|
+
"Baggage": `sentry-release=language-server-windsurf@${WS_LS_VER},` +
|
|
802
|
+
`sentry-environment=stable,sentry-sampled=false,` +
|
|
803
|
+
`sentry-trace_id=${traceId},` +
|
|
804
|
+
`sentry-public_key=b813f73488da69eedec534dba1029111`,
|
|
805
|
+
"Sentry-Trace": `${traceId}-${spanId}-0`,
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
const doFetch = () => fetch(url, {
|
|
809
|
+
method: "POST",
|
|
810
|
+
headers,
|
|
811
|
+
body: frame,
|
|
812
|
+
signal: AbortSignal.timeout(abortMs),
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
let lastErr;
|
|
816
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
817
|
+
try {
|
|
818
|
+
let resp;
|
|
819
|
+
try {
|
|
820
|
+
resp = await doFetch();
|
|
821
|
+
} catch (e) {
|
|
822
|
+
if (attempt === 0) {
|
|
823
|
+
_applyTlsFallback();
|
|
824
|
+
resp = await doFetch();
|
|
825
|
+
} else {
|
|
826
|
+
throw e;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (!resp.ok) {
|
|
831
|
+
const err = new Error(`HTTP ${resp.status}`);
|
|
832
|
+
err.status = resp.status;
|
|
833
|
+
// Don't retry on 4xx client errors (except 429)
|
|
834
|
+
if (resp.status >= 400 && resp.status < 500 && resp.status !== 429) {
|
|
835
|
+
throw err;
|
|
836
|
+
}
|
|
837
|
+
lastErr = err;
|
|
838
|
+
if (attempt < maxRetries) {
|
|
839
|
+
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
throw err;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const arrayBuf = await resp.arrayBuffer();
|
|
846
|
+
return Buffer.from(arrayBuf);
|
|
847
|
+
} catch (e) {
|
|
848
|
+
lastErr = e;
|
|
849
|
+
// Don't retry on 4xx client errors (except 429)
|
|
850
|
+
if (e.status && e.status >= 400 && e.status < 500 && e.status !== 429) {
|
|
851
|
+
throw _classifyError(e);
|
|
852
|
+
}
|
|
853
|
+
if (attempt < maxRetries) {
|
|
854
|
+
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
throw _classifyError(lastErr);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Authenticate with API key to get JWT token.
|
|
864
|
+
* @param {string} apiKey
|
|
865
|
+
* @returns {Promise<string>}
|
|
866
|
+
*/
|
|
867
|
+
async function fetchJwt(apiKey) {
|
|
868
|
+
const meta = new ProtobufEncoder();
|
|
869
|
+
meta.writeString(1, WS_APP);
|
|
870
|
+
meta.writeString(2, WS_APP_VER);
|
|
871
|
+
meta.writeString(3, apiKey);
|
|
872
|
+
meta.writeString(4, "zh-cn");
|
|
873
|
+
meta.writeString(7, WS_LS_VER);
|
|
874
|
+
meta.writeString(12, WS_APP);
|
|
875
|
+
meta.writeBytes(30, Buffer.from([0x00, 0x01]));
|
|
876
|
+
|
|
877
|
+
const outer = new ProtobufEncoder();
|
|
878
|
+
outer.writeMessage(1, meta);
|
|
879
|
+
|
|
880
|
+
const resp = await _unaryRequest(`${AUTH_BASE}/GetUserJwt`, outer.toBuffer(), false);
|
|
881
|
+
for (const s of extractStrings(resp)) {
|
|
882
|
+
if (s.startsWith("eyJ") && s.includes(".")) {
|
|
883
|
+
return s;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
throw new Error("Failed to extract JWT from GetUserJwt response");
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Check rate limit. Returns true if OK, false if rate-limited.
|
|
891
|
+
* @param {string} apiKey
|
|
892
|
+
* @param {string} jwt
|
|
893
|
+
* @returns {Promise<boolean>}
|
|
894
|
+
*/
|
|
895
|
+
async function checkRateLimit(apiKey, jwt) {
|
|
896
|
+
const req = new ProtobufEncoder();
|
|
897
|
+
req.writeMessage(1, _buildMetadata(apiKey, jwt));
|
|
898
|
+
req.writeString(3, WS_MODEL);
|
|
899
|
+
|
|
900
|
+
try {
|
|
901
|
+
await _unaryRequest(`${API_BASE}/CheckUserMessageRateLimit`, req.toBuffer(), true);
|
|
902
|
+
return true;
|
|
903
|
+
} catch (e) {
|
|
904
|
+
if (e.status === 429) return false;
|
|
905
|
+
return true; // Don't block on network issues
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ─── Request Building ──────────────────────────────────────
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Build protobuf metadata with app info, system info, JWT, etc.
|
|
913
|
+
* @param {string} apiKey
|
|
914
|
+
* @param {string} jwt
|
|
915
|
+
* @returns {ProtobufEncoder}
|
|
916
|
+
*/
|
|
917
|
+
function _buildMetadata(apiKey, jwt) {
|
|
918
|
+
const meta = new ProtobufEncoder();
|
|
919
|
+
meta.writeString(1, WS_APP);
|
|
920
|
+
meta.writeString(2, WS_APP_VER);
|
|
921
|
+
meta.writeString(3, apiKey);
|
|
922
|
+
meta.writeString(4, "zh-cn");
|
|
923
|
+
|
|
924
|
+
const plat = platform();
|
|
925
|
+
const sysInfo = {
|
|
926
|
+
Os: plat,
|
|
927
|
+
Arch: arch(),
|
|
928
|
+
Release: release(),
|
|
929
|
+
Version: osVersion(),
|
|
930
|
+
Machine: arch(),
|
|
931
|
+
Nodename: hostname(),
|
|
932
|
+
Sysname: plat === "darwin" ? "Darwin" : plat === "win32" ? "Windows_NT" : "Linux",
|
|
933
|
+
ProductVersion: "",
|
|
934
|
+
};
|
|
935
|
+
meta.writeString(5, JSON.stringify(sysInfo));
|
|
936
|
+
meta.writeString(7, WS_LS_VER);
|
|
937
|
+
|
|
938
|
+
const cpuList = cpus();
|
|
939
|
+
const ncpu = cpuList.length || 4;
|
|
940
|
+
const mem = totalmem();
|
|
941
|
+
const cpuInfo = {
|
|
942
|
+
NumSockets: 1,
|
|
943
|
+
NumCores: ncpu,
|
|
944
|
+
NumThreads: ncpu,
|
|
945
|
+
VendorID: "",
|
|
946
|
+
Family: "0",
|
|
947
|
+
Model: "0",
|
|
948
|
+
ModelName: cpuList[0]?.model || "Unknown",
|
|
949
|
+
Memory: mem,
|
|
950
|
+
};
|
|
951
|
+
meta.writeString(8, JSON.stringify(cpuInfo));
|
|
952
|
+
meta.writeString(12, WS_APP);
|
|
953
|
+
meta.writeString(21, jwt);
|
|
954
|
+
meta.writeBytes(30, Buffer.from([0x00, 0x01]));
|
|
955
|
+
return meta;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Build a chat message protobuf.
|
|
960
|
+
* @param {number} role - 1=user, 2=assistant, 4=tool_result, 5=system
|
|
961
|
+
* @param {string} content
|
|
962
|
+
* @param {Object} [opts]
|
|
963
|
+
* @param {string} [opts.toolCallId]
|
|
964
|
+
* @param {string} [opts.toolName]
|
|
965
|
+
* @param {string} [opts.toolArgsJson]
|
|
966
|
+
* @param {string} [opts.refCallId]
|
|
967
|
+
* @returns {ProtobufEncoder}
|
|
968
|
+
*/
|
|
969
|
+
function _buildChatMessage(role, content, opts = {}) {
|
|
970
|
+
const msg = new ProtobufEncoder();
|
|
971
|
+
msg.writeVarint(2, role);
|
|
972
|
+
msg.writeString(3, content);
|
|
973
|
+
|
|
974
|
+
if (opts.toolCallId && opts.toolName && opts.toolArgsJson) {
|
|
975
|
+
const tc = new ProtobufEncoder();
|
|
976
|
+
tc.writeString(1, opts.toolCallId);
|
|
977
|
+
tc.writeString(2, opts.toolName);
|
|
978
|
+
tc.writeString(3, opts.toolArgsJson);
|
|
979
|
+
msg.writeMessage(6, tc);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (opts.refCallId) {
|
|
983
|
+
msg.writeString(7, opts.refCallId);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
return msg;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Build a full request with metadata, messages, and tool definitions.
|
|
991
|
+
* @param {string} apiKey
|
|
992
|
+
* @param {string} jwt
|
|
993
|
+
* @param {Array} messages
|
|
994
|
+
* @param {string} toolDefs
|
|
995
|
+
* @returns {Buffer}
|
|
996
|
+
*/
|
|
997
|
+
function _buildRequest(apiKey, jwt, messages, toolDefs) {
|
|
998
|
+
const req = new ProtobufEncoder();
|
|
999
|
+
req.writeMessage(1, _buildMetadata(apiKey, jwt));
|
|
1000
|
+
|
|
1001
|
+
for (const m of messages) {
|
|
1002
|
+
const msgEnc = _buildChatMessage(m.role, m.content, {
|
|
1003
|
+
toolCallId: m.tool_call_id,
|
|
1004
|
+
toolName: m.tool_name,
|
|
1005
|
+
toolArgsJson: m.tool_args_json,
|
|
1006
|
+
refCallId: m.ref_call_id,
|
|
1007
|
+
});
|
|
1008
|
+
req.writeMessage(2, msgEnc);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
req.writeString(3, toolDefs);
|
|
1012
|
+
return req.toBuffer();
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// ─── Response Parsing ──────────────────────────────────────
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Strip invalid UTF-8 bytes from a Buffer → clean string.
|
|
1019
|
+
* Matches Python's bytes.decode("utf-8", errors="ignore").
|
|
1020
|
+
* @param {Buffer} buf
|
|
1021
|
+
* @returns {string}
|
|
1022
|
+
*/
|
|
1023
|
+
function stripInvalidUtf8(buf) {
|
|
1024
|
+
return buf.toString("utf-8").replace(/\ufffd/g, "");
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Parse tool call from [TOOL_CALLS]name[ARGS]{json} format.
|
|
1029
|
+
* @param {string} text
|
|
1030
|
+
* @returns {[string, string, Object]|null} [thinking, name, args] or null
|
|
1031
|
+
*/
|
|
1032
|
+
function _parseToolCall(text) {
|
|
1033
|
+
text = text.replace(/<\/s>/g, "");
|
|
1034
|
+
const m = text.match(/\[TOOL_CALLS\](\w+)\[ARGS\](\{.+)/s);
|
|
1035
|
+
if (!m) return null;
|
|
1036
|
+
|
|
1037
|
+
const name = m[1];
|
|
1038
|
+
const raw = m[2].trim();
|
|
1039
|
+
|
|
1040
|
+
// Find matching closing brace
|
|
1041
|
+
let depth = 0;
|
|
1042
|
+
let end = 0;
|
|
1043
|
+
for (let i = 0; i < raw.length; i++) {
|
|
1044
|
+
if (raw[i] === "{") depth++;
|
|
1045
|
+
else if (raw[i] === "}") {
|
|
1046
|
+
depth--;
|
|
1047
|
+
if (depth === 0) {
|
|
1048
|
+
end = i + 1;
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (end === 0) end = raw.length;
|
|
1054
|
+
|
|
1055
|
+
let args;
|
|
1056
|
+
const jsonCandidate = raw.slice(0, end);
|
|
1057
|
+
try {
|
|
1058
|
+
args = JSON.parse(jsonCandidate);
|
|
1059
|
+
} catch {
|
|
1060
|
+
// Attempt lenient fix: unquoted keys like exclude": → "exclude":
|
|
1061
|
+
try {
|
|
1062
|
+
const fixed = jsonCandidate.replace(/([{,]\s*)(\w+)\s*:/g, '$1"$2":');
|
|
1063
|
+
args = JSON.parse(fixed);
|
|
1064
|
+
} catch {
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const thinking = text.slice(0, m.index).trim();
|
|
1070
|
+
return [thinking, name, args];
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Parse streaming response: decode frames, extract text, parse tool calls.
|
|
1075
|
+
* @param {Buffer} data
|
|
1076
|
+
* @returns {[string, [string, Object]|null]} [text, toolInfo]
|
|
1077
|
+
*/
|
|
1078
|
+
function _parseResponse(data) {
|
|
1079
|
+
const frames = connectFrameDecode(data);
|
|
1080
|
+
let allText = "";
|
|
1081
|
+
|
|
1082
|
+
for (const frameData of frames) {
|
|
1083
|
+
// Check for error JSON
|
|
1084
|
+
try {
|
|
1085
|
+
const textCandidate = frameData.toString("utf-8");
|
|
1086
|
+
if (textCandidate.startsWith("{")) {
|
|
1087
|
+
const errObj = JSON.parse(textCandidate);
|
|
1088
|
+
if (errObj.error) {
|
|
1089
|
+
const code = errObj.error.code || "unknown";
|
|
1090
|
+
const msg = errObj.error.message || "";
|
|
1091
|
+
return [`[Error] ${code}: ${msg}`, null];
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
} catch {
|
|
1095
|
+
// Not JSON, continue
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Extract text from frame — strip invalid UTF-8 (matches Python errors="ignore")
|
|
1099
|
+
const rawText = stripInvalidUtf8(frameData);
|
|
1100
|
+
if (rawText.includes("[TOOL_CALLS]")) {
|
|
1101
|
+
allText = rawText;
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
for (const s of extractStrings(frameData)) {
|
|
1106
|
+
if (s.length > 10) {
|
|
1107
|
+
allText += s;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const parsed = _parseToolCall(allText);
|
|
1113
|
+
if (parsed) {
|
|
1114
|
+
const [thinking, name, args] = parsed;
|
|
1115
|
+
return [thinking, [name, args]];
|
|
1116
|
+
}
|
|
1117
|
+
return [allText, null];
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// ─── Core Search ───────────────────────────────────────────
|
|
1121
|
+
|
|
1122
|
+
// Max safe tree size in bytes (server payload limit ~346KB, fixed overhead ~26KB,
|
|
1123
|
+
// leave room for conversation accumulation across rounds)
|
|
1124
|
+
const MAX_TREE_BYTES = 250 * 1024;
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Convert an exclude pattern (directory/file name or simple glob) to RegExp
|
|
1128
|
+
* for tree-node-cli's exclude option.
|
|
1129
|
+
* @param {string} pattern - e.g. "node_modules", "dist", "*.min.*"
|
|
1130
|
+
* @returns {RegExp}
|
|
1131
|
+
*/
|
|
1132
|
+
function _excludePatternToRegex(pattern) {
|
|
1133
|
+
if (!/[*?]/.test(pattern)) {
|
|
1134
|
+
// Simple name — exact match
|
|
1135
|
+
return new RegExp("^" + pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "$");
|
|
1136
|
+
}
|
|
1137
|
+
// Glob → regex
|
|
1138
|
+
let regex = "^";
|
|
1139
|
+
for (const c of pattern) {
|
|
1140
|
+
if (c === "*") regex += ".*";
|
|
1141
|
+
else if (c === "?") regex += ".";
|
|
1142
|
+
else if (".+^${}()|[]\\".includes(c)) regex += "\\" + c;
|
|
1143
|
+
else regex += c;
|
|
1144
|
+
}
|
|
1145
|
+
regex += "$";
|
|
1146
|
+
return new RegExp(regex);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Count files in a directory (non-recursive, fast estimate).
|
|
1151
|
+
* @param {string} dir
|
|
1152
|
+
* @returns {number}
|
|
1153
|
+
*/
|
|
1154
|
+
function _countFilesQuick(dir) {
|
|
1155
|
+
try {
|
|
1156
|
+
return readdirSync(dir).length;
|
|
1157
|
+
} catch {
|
|
1158
|
+
return 0;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Estimate project size and suggest optimal tree depth.
|
|
1164
|
+
* - Small project (< 500 entries): depth 4
|
|
1165
|
+
* - Medium project (500-5000 entries): depth 3
|
|
1166
|
+
* - Large project (> 5000 entries): depth 2
|
|
1167
|
+
* @param {string} projectRoot
|
|
1168
|
+
* @returns {number}
|
|
1169
|
+
*/
|
|
1170
|
+
function _suggestTreeDepth(projectRoot) {
|
|
1171
|
+
const count = _countFilesQuick(projectRoot);
|
|
1172
|
+
if (count < 500) return 4;
|
|
1173
|
+
if (count <= 5000) return 3;
|
|
1174
|
+
return 2;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function _normalizeTreeRoot(treeStr, absRoot, virtualRoot = "/codebase") {
|
|
1178
|
+
const rootPattern = new RegExp(absRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
|
|
1179
|
+
let out = String(treeStr || "").replace(rootPattern, virtualRoot);
|
|
1180
|
+
const lines = out.split("\n");
|
|
1181
|
+
const dirName = absRoot.split("/").pop() || absRoot.split("\\").pop() || absRoot;
|
|
1182
|
+
if (lines[0] === dirName) {
|
|
1183
|
+
lines[0] = virtualRoot;
|
|
1184
|
+
out = lines.join("\n");
|
|
1185
|
+
}
|
|
1186
|
+
return out;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Get a directory tree of the project with adaptive depth fallback.
|
|
1191
|
+
*
|
|
1192
|
+
* Tries the requested depth first. If the tree output exceeds MAX_TREE_BYTES,
|
|
1193
|
+
* automatically falls back to lower depths until it fits.
|
|
1194
|
+
*
|
|
1195
|
+
* @param {string} projectRoot
|
|
1196
|
+
* @param {number} [targetDepth=3] - Desired tree depth (0-6), 0 means auto
|
|
1197
|
+
* @param {string[]} [excludePaths=[]] - Patterns to exclude from tree
|
|
1198
|
+
* @returns {{ tree: string, depth: number, sizeBytes: number, fellBack: boolean, autoDepth: boolean }}
|
|
1199
|
+
*/
|
|
1200
|
+
function getRepoMap(projectRoot, targetDepth = 3, excludePaths = []) {
|
|
1201
|
+
// Auto depth: if targetDepth is 0, use heuristic
|
|
1202
|
+
const autoDepth = targetDepth === 0;
|
|
1203
|
+
if (autoDepth) {
|
|
1204
|
+
targetDepth = _suggestTreeDepth(projectRoot);
|
|
1205
|
+
}
|
|
1206
|
+
const excludeRegexes = excludePaths.length ? excludePaths.map(_excludePatternToRegex) : [];
|
|
1207
|
+
|
|
1208
|
+
for (let L = targetDepth; L >= 1; L--) {
|
|
1209
|
+
try {
|
|
1210
|
+
const opts = { maxDepth: L };
|
|
1211
|
+
if (excludeRegexes.length) opts.exclude = excludeRegexes;
|
|
1212
|
+
const stdout = treeNodeCli(projectRoot, opts);
|
|
1213
|
+
// Normalize root to /codebase consistently.
|
|
1214
|
+
let treeStr = _normalizeTreeRoot(stdout, projectRoot, "/codebase");
|
|
1215
|
+
const sizeBytes = Buffer.byteLength(treeStr, "utf-8");
|
|
1216
|
+
|
|
1217
|
+
if (sizeBytes <= MAX_TREE_BYTES) {
|
|
1218
|
+
return { tree: treeStr, depth: L, sizeBytes, fellBack: L < targetDepth, autoDepth };
|
|
1219
|
+
}
|
|
1220
|
+
// Too large, try lower depth
|
|
1221
|
+
} catch {
|
|
1222
|
+
// tree failed at this level, try lower
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Ultimate fallback: simple ls (also respects excludePaths)
|
|
1227
|
+
try {
|
|
1228
|
+
let entries = readdirSync(projectRoot).sort();
|
|
1229
|
+
if (excludeRegexes.length) {
|
|
1230
|
+
entries = entries.filter((e) => !excludeRegexes.some((rx) => rx.test(e)));
|
|
1231
|
+
}
|
|
1232
|
+
const treeStr = ["/codebase", ...entries.map((e) => `├── ${e}`)].join("\n");
|
|
1233
|
+
return { tree: treeStr, depth: 0, sizeBytes: Buffer.byteLength(treeStr, "utf-8"), fellBack: true, autoDepth };
|
|
1234
|
+
} catch {
|
|
1235
|
+
const treeStr = "/codebase\n(empty or inaccessible)";
|
|
1236
|
+
return { tree: treeStr, depth: 0, sizeBytes: treeStr.length, fellBack: true, autoDepth };
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function _tokenizeQuery(query = "") {
|
|
1241
|
+
return [...new Set(
|
|
1242
|
+
String(query)
|
|
1243
|
+
.toLowerCase()
|
|
1244
|
+
.split(/[^a-z0-9_\-]+/)
|
|
1245
|
+
.map((t) => t.trim())
|
|
1246
|
+
.filter((t) => t.length >= 3)
|
|
1247
|
+
)];
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function _scoreTopLevelDir(dirName, queryTokens = []) {
|
|
1251
|
+
const name = String(dirName || "").toLowerCase();
|
|
1252
|
+
let score = 0;
|
|
1253
|
+
|
|
1254
|
+
const commonRoots = ["src", "app", "lib", "packages", "services", "server", "backend", "frontend", "api"];
|
|
1255
|
+
if (commonRoots.includes(name)) score += 2;
|
|
1256
|
+
|
|
1257
|
+
for (const token of queryTokens) {
|
|
1258
|
+
if (name.includes(token)) score += 4;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
return score;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function _listTopLevelDirs(projectRoot, excludePaths = []) {
|
|
1265
|
+
const excludeRegexes = excludePaths.length ? excludePaths.map(_excludePatternToRegex) : [];
|
|
1266
|
+
const out = [];
|
|
1267
|
+
let entries = [];
|
|
1268
|
+
try {
|
|
1269
|
+
entries = readdirSync(projectRoot).sort();
|
|
1270
|
+
} catch {
|
|
1271
|
+
return out;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
for (const e of entries) {
|
|
1275
|
+
if (excludeRegexes.some((rx) => rx.test(e))) continue;
|
|
1276
|
+
const abs = join(projectRoot, e);
|
|
1277
|
+
try {
|
|
1278
|
+
if (statSync(abs).isDirectory()) out.push(e);
|
|
1279
|
+
} catch {
|
|
1280
|
+
// ignore
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return out;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function _buildSubtreeForDir(projectRoot, dir, levels = 2) {
|
|
1287
|
+
const abs = join(projectRoot, dir);
|
|
1288
|
+
const vRoot = `/codebase/${dir}`;
|
|
1289
|
+
try {
|
|
1290
|
+
const stdout = treeNodeCli(abs, { maxDepth: levels });
|
|
1291
|
+
return _normalizeTreeRoot(stdout, abs, vRoot);
|
|
1292
|
+
} catch {
|
|
1293
|
+
return `${vRoot}\n (failed to generate subtree)`;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function buildOptimizedRepoMap({
|
|
1298
|
+
query,
|
|
1299
|
+
projectRoot,
|
|
1300
|
+
treeDepth,
|
|
1301
|
+
excludePaths,
|
|
1302
|
+
optimizer = {},
|
|
1303
|
+
bootstrapHints = null,
|
|
1304
|
+
onProgress = null,
|
|
1305
|
+
}) {
|
|
1306
|
+
const log = (msg) => onProgress?.(msg);
|
|
1307
|
+
const cfg = { ...REPO_MAP_OPTIMIZER_DEFAULTS, ...(optimizer || {}) };
|
|
1308
|
+
if (cfg.mode === "classic") {
|
|
1309
|
+
const base = getRepoMap(projectRoot, treeDepth, excludePaths);
|
|
1310
|
+
return {
|
|
1311
|
+
...base,
|
|
1312
|
+
strategy: "classic",
|
|
1313
|
+
hotDirs: [],
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const bootstrapDepth = Math.max(1, Math.min(3, Number(cfg.bootstrapTreeDepth) || 1));
|
|
1318
|
+
const hotspotTopK = Math.max(0, Math.min(8, Number(cfg.hotspotTopK) || 4));
|
|
1319
|
+
const hotspotTreeDepth = Math.max(1, Math.min(4, Number(cfg.hotspotTreeDepth) || 2));
|
|
1320
|
+
const maxBytes = Math.max(16 * 1024, Number(cfg.maxBytes) || REPO_MAP_OPTIMIZER_DEFAULTS.maxBytes);
|
|
1321
|
+
|
|
1322
|
+
const bootstrap = getRepoMap(projectRoot, bootstrapDepth, excludePaths);
|
|
1323
|
+
const topDirs = _listTopLevelDirs(projectRoot, excludePaths);
|
|
1324
|
+
|
|
1325
|
+
// Extract keywords from bootstrap hints (rgPatterns)
|
|
1326
|
+
const keywords = bootstrapHints?.rgPatterns || [];
|
|
1327
|
+
|
|
1328
|
+
// Use BM25F + Probe + RRF for directory scoring
|
|
1329
|
+
// This replaces the old token-based scoring + commonRoots approach
|
|
1330
|
+
let hotDirs = [];
|
|
1331
|
+
let pathSpines = [];
|
|
1332
|
+
try {
|
|
1333
|
+
const results = scoreDirectories(query, projectRoot, topDirs, excludePaths, {
|
|
1334
|
+
topK: hotspotTopK,
|
|
1335
|
+
useProbe: true, // Enable probe grep signal
|
|
1336
|
+
keywords, // Bootstrap keywords
|
|
1337
|
+
minReturn: 2, // Always return at least 2 directories for coverage
|
|
1338
|
+
});
|
|
1339
|
+
hotDirs = results.hotDirs;
|
|
1340
|
+
pathSpines = results.pathSpines;
|
|
1341
|
+
log(`BM25F scoring: hotDirs=[${hotDirs.join(",")}] pathSpines=${pathSpines.length} signals=${JSON.stringify(results.signals)}`);
|
|
1342
|
+
} catch (e) {
|
|
1343
|
+
// Lightweight fallback: use quick scoring without commonRoots
|
|
1344
|
+
log(`BM25F failed, using quick token scoring: ${e.message}`);
|
|
1345
|
+
const queryTerms = tokenizeBM25(query);
|
|
1346
|
+
const scored = topDirs.map((d) => {
|
|
1347
|
+
const dirTerms = tokenizeBM25(d);
|
|
1348
|
+
let score = 0;
|
|
1349
|
+
for (const qt of queryTerms) {
|
|
1350
|
+
if (dirTerms.some(dt => dt.includes(qt) || qt.includes(dt))) score += 1;
|
|
1351
|
+
}
|
|
1352
|
+
return { dir: d, score };
|
|
1353
|
+
}).sort((a, b) => b.score - a.score);
|
|
1354
|
+
|
|
1355
|
+
// Always return at least topK directories (no score > 0 filter)
|
|
1356
|
+
hotDirs = scored.slice(0, hotspotTopK).map((x) => x.dir);
|
|
1357
|
+
if (hotDirs.length === 0) hotDirs = topDirs.slice(0, hotspotTopK);
|
|
1358
|
+
log(`Quick scoring fallback: ${hotDirs.join(",")}`);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const hotspotSections = [];
|
|
1362
|
+
for (const d of hotDirs) {
|
|
1363
|
+
hotspotSections.push(_buildSubtreeForDir(projectRoot, d, hotspotTreeDepth));
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// Build path spines section for deep file visibility
|
|
1367
|
+
const pathSpineSection = pathSpines.length > 0
|
|
1368
|
+
? "# Relevant File Paths (from BM25F path spine extraction)\n" + pathSpines.map(p => `- /codebase/${p}`).join("\n")
|
|
1369
|
+
: "";
|
|
1370
|
+
|
|
1371
|
+
let tree = bootstrap.tree;
|
|
1372
|
+
const sections = [];
|
|
1373
|
+
if (hotspotSections.length) {
|
|
1374
|
+
sections.push("# Hotspot Subtrees\n" + hotspotSections.join("\n\n"));
|
|
1375
|
+
}
|
|
1376
|
+
if (pathSpineSection) {
|
|
1377
|
+
sections.push(pathSpineSection);
|
|
1378
|
+
}
|
|
1379
|
+
if (sections.length) {
|
|
1380
|
+
tree = `${bootstrap.tree}\n\n${sections.join("\n\n")}`;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Keep map under configurable budget.
|
|
1384
|
+
let sizeBytes = Buffer.byteLength(tree, "utf-8");
|
|
1385
|
+
if (sizeBytes > maxBytes && (hotspotSections.length || pathSpineSection)) {
|
|
1386
|
+
// First try removing path spines
|
|
1387
|
+
if (pathSpineSection) {
|
|
1388
|
+
const withoutSpines = sections.length > 1
|
|
1389
|
+
? `${bootstrap.tree}\n\n${sections[0]}`
|
|
1390
|
+
: bootstrap.tree;
|
|
1391
|
+
sizeBytes = Buffer.byteLength(withoutSpines, "utf-8");
|
|
1392
|
+
if (sizeBytes <= maxBytes) {
|
|
1393
|
+
tree = withoutSpines;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// If still too large, progressively remove hotspot sections
|
|
1398
|
+
if (sizeBytes > maxBytes && hotspotSections.length) {
|
|
1399
|
+
let kept = [...hotspotSections];
|
|
1400
|
+
while (kept.length > 0) {
|
|
1401
|
+
kept.pop();
|
|
1402
|
+
tree = kept.length
|
|
1403
|
+
? `${bootstrap.tree}\n\n# Hotspot Subtrees\n${kept.join("\n\n")}`
|
|
1404
|
+
: bootstrap.tree;
|
|
1405
|
+
sizeBytes = Buffer.byteLength(tree, "utf-8");
|
|
1406
|
+
if (sizeBytes <= maxBytes) break;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
return {
|
|
1412
|
+
tree,
|
|
1413
|
+
depth: bootstrap.depth,
|
|
1414
|
+
sizeBytes: Buffer.byteLength(tree, "utf-8"),
|
|
1415
|
+
fellBack: bootstrap.fellBack,
|
|
1416
|
+
autoDepth: bootstrap.autoDepth,
|
|
1417
|
+
strategy: "bootstrap_hotspot",
|
|
1418
|
+
hotDirs,
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Parse answer XML into structured file + range data.
|
|
1424
|
+
* @param {string} xmlText
|
|
1425
|
+
* @param {string} projectRoot
|
|
1426
|
+
* @returns {{ files: Array }}
|
|
1427
|
+
*/
|
|
1428
|
+
function _parseAnswer(xmlText, projectRoot) {
|
|
1429
|
+
const files = [];
|
|
1430
|
+
const resolvedRoot = resolve(projectRoot);
|
|
1431
|
+
const fileRegex = /<file\s+path=(["'])([^"']+)\1>([\s\S]*?)<\/file>/g;
|
|
1432
|
+
let fm;
|
|
1433
|
+
while ((fm = fileRegex.exec(xmlText)) !== null) {
|
|
1434
|
+
const vpath = fm[2];
|
|
1435
|
+
let rel = vpath.replace(/^\/codebase[\/\\]?/, "");
|
|
1436
|
+
rel = rel.replace(/^[\/\\]+/, "");
|
|
1437
|
+
|
|
1438
|
+
// Path safety: reject traversal attempts (../) and paths outside project root
|
|
1439
|
+
const fullPath = resolve(projectRoot, rel);
|
|
1440
|
+
const relToRoot = relative(resolvedRoot, fullPath);
|
|
1441
|
+
if (relToRoot === ".." || relToRoot.startsWith(`..${sep}`) || isAbsolute(relToRoot)) {
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const ranges = [];
|
|
1446
|
+
const rangeRegex = /<range>(\d+)-(\d+)<\/range>/g;
|
|
1447
|
+
let rm;
|
|
1448
|
+
while ((rm = rangeRegex.exec(fm[3])) !== null) {
|
|
1449
|
+
ranges.push([parseInt(rm[1], 10), parseInt(rm[2], 10)]);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
files.push({ path: rel, full_path: fullPath, ranges });
|
|
1453
|
+
}
|
|
1454
|
+
return { files };
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* Execute Fast Context search.
|
|
1459
|
+
*
|
|
1460
|
+
* @param {Object} opts
|
|
1461
|
+
* @param {string} opts.query - Natural language search query
|
|
1462
|
+
* @param {string} opts.projectRoot - Project root directory
|
|
1463
|
+
* @param {string} [opts.apiKey] - Windsurf API key (auto-discovered if not set)
|
|
1464
|
+
* @param {string} [opts.jwt] - JWT token (auto-fetched if not set)
|
|
1465
|
+
* @param {number} [opts.maxTurns=3] - Search rounds
|
|
1466
|
+
* @param {number} [opts.maxCommands=8] - Max commands per round
|
|
1467
|
+
* @param {number} [opts.maxResults=10] - Max number of files to return
|
|
1468
|
+
* @param {number} [opts.treeDepth=3] - Directory tree depth for repo map (1-6, auto fallback)
|
|
1469
|
+
* @param {number} [opts.timeoutMs=30000] - Connect-Timeout-Ms for streaming requests
|
|
1470
|
+
* @param {string[]} [opts.excludePaths=[]] - Patterns to exclude from tree
|
|
1471
|
+
* @param {function} [opts.onProgress] - Progress callback
|
|
1472
|
+
* @returns {Promise<Object>}
|
|
1473
|
+
*/
|
|
1474
|
+
export async function search({
|
|
1475
|
+
query,
|
|
1476
|
+
projectRoot,
|
|
1477
|
+
apiKey = null,
|
|
1478
|
+
jwt = null,
|
|
1479
|
+
maxTurns = 3,
|
|
1480
|
+
maxCommands = 8,
|
|
1481
|
+
maxResults = 10,
|
|
1482
|
+
treeDepth = 3,
|
|
1483
|
+
timeoutMs = 30000,
|
|
1484
|
+
excludePaths = [],
|
|
1485
|
+
repoMapMode = "bootstrap_hotspot",
|
|
1486
|
+
bootstrapTreeDepth = 1,
|
|
1487
|
+
hotspotTopK = 4,
|
|
1488
|
+
hotspotTreeDepth = 2,
|
|
1489
|
+
hotspotMaxBytes = 120 * 1024,
|
|
1490
|
+
bootstrapEnabled = true,
|
|
1491
|
+
bootstrapMaxTurns = 2,
|
|
1492
|
+
bootstrapMaxCommands = 6,
|
|
1493
|
+
onProgress = null,
|
|
1494
|
+
}) {
|
|
1495
|
+
const log = (msg) => onProgress?.(msg);
|
|
1496
|
+
projectRoot = resolve(projectRoot);
|
|
1497
|
+
const effectiveExcludePaths = _mergeExcludePaths(excludePaths);
|
|
1498
|
+
|
|
1499
|
+
// Get credentials
|
|
1500
|
+
if (!apiKey) {
|
|
1501
|
+
apiKey = await getApiKey();
|
|
1502
|
+
}
|
|
1503
|
+
if (!jwt) {
|
|
1504
|
+
log("Fetching JWT...");
|
|
1505
|
+
jwt = await getCachedJwt(apiKey);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// Check rate limit
|
|
1509
|
+
log("Checking rate limit...");
|
|
1510
|
+
if (!(await checkRateLimit(apiKey, jwt))) {
|
|
1511
|
+
return { files: [], error: "Rate limited, please try again later" };
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const executor = new ToolExecutor(projectRoot);
|
|
1515
|
+
const toolDefs = getToolDefinitions(maxCommands);
|
|
1516
|
+
const systemPrompt = buildSystemPrompt(maxTurns, maxCommands, maxResults);
|
|
1517
|
+
|
|
1518
|
+
let bootstrapHints = null;
|
|
1519
|
+
if (bootstrapEnabled) {
|
|
1520
|
+
bootstrapHints = await _runBootstrapPhase({
|
|
1521
|
+
query,
|
|
1522
|
+
projectRoot,
|
|
1523
|
+
apiKey,
|
|
1524
|
+
jwt,
|
|
1525
|
+
timeoutMs,
|
|
1526
|
+
excludePaths: effectiveExcludePaths,
|
|
1527
|
+
bootstrapTreeDepth,
|
|
1528
|
+
bootstrapMaxTurns,
|
|
1529
|
+
bootstrapMaxCommands,
|
|
1530
|
+
onProgress,
|
|
1531
|
+
});
|
|
1532
|
+
log(`Bootstrap hints: patterns=${bootstrapHints.rgPatterns.length}, hot_dirs=${bootstrapHints.hotDirs.length}`);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const { tree: repoMap, depth: actualDepth, sizeBytes: treeSizeBytes, fellBack, autoDepth, strategy: repoMapStrategy, hotDirs = [] } = buildOptimizedRepoMap({
|
|
1536
|
+
query,
|
|
1537
|
+
projectRoot,
|
|
1538
|
+
treeDepth,
|
|
1539
|
+
excludePaths: effectiveExcludePaths,
|
|
1540
|
+
optimizer: {
|
|
1541
|
+
mode: repoMapMode,
|
|
1542
|
+
bootstrapTreeDepth,
|
|
1543
|
+
hotspotTopK,
|
|
1544
|
+
hotspotTreeDepth,
|
|
1545
|
+
maxBytes: hotspotMaxBytes,
|
|
1546
|
+
},
|
|
1547
|
+
bootstrapHints,
|
|
1548
|
+
onProgress,
|
|
1549
|
+
});
|
|
1550
|
+
log(`Repo map: tree -L ${actualDepth} (${(treeSizeBytes / 1024).toFixed(1)}KB)${fellBack ? ` [fell back from L=${treeDepth}]` : ""}${autoDepth ? " [auto]" : ""} [strategy=${repoMapStrategy}]${hotDirs.length ? ` [hot=${hotDirs.join(",")}]` : ""}`);
|
|
1551
|
+
const userContent = `Problem Statement: ${query}\n\nRepo Map (tree -L ${actualDepth} /codebase):\n\`\`\`text\n${repoMap}\n\`\`\``;
|
|
1552
|
+
|
|
1553
|
+
const messages = [
|
|
1554
|
+
{ role: 5, content: systemPrompt },
|
|
1555
|
+
{ role: 1, content: userContent },
|
|
1556
|
+
];
|
|
1557
|
+
|
|
1558
|
+
// Trim state for smart context trimming
|
|
1559
|
+
const trimState = {
|
|
1560
|
+
query,
|
|
1561
|
+
turn: 0,
|
|
1562
|
+
recentFiles: [],
|
|
1563
|
+
recentPatterns: [],
|
|
1564
|
+
recentCommands: [],
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
// Total API calls = maxTurns + 1 (last round for answer)
|
|
1568
|
+
const totalApiCalls = maxTurns + 1;
|
|
1569
|
+
let compensatedTurns = 0;
|
|
1570
|
+
const MAX_COMPENSATIONS = 2;
|
|
1571
|
+
let forceAnswerInjected = false;
|
|
1572
|
+
|
|
1573
|
+
for (let turn = 0; turn < totalApiCalls + compensatedTurns; turn++) {
|
|
1574
|
+
log(`Turn ${turn + 1}/${totalApiCalls}`);
|
|
1575
|
+
trimState.turn = turn + 1;
|
|
1576
|
+
|
|
1577
|
+
let proto = _buildRequest(apiKey, jwt, messages, toolDefs);
|
|
1578
|
+
|
|
1579
|
+
// Debug logging
|
|
1580
|
+
if (DEBUG_MODE) {
|
|
1581
|
+
console.error(`\n[DEBUG] ===== Turn ${turn + 1} Request =====`);
|
|
1582
|
+
console.error(`[DEBUG] Messages count: ${messages.length}`);
|
|
1583
|
+
console.error(`[DEBUG] Last message role: ${messages[messages.length - 1]?.role}`);
|
|
1584
|
+
console.error(`[DEBUG] Proto size: ${proto.length} bytes`);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Preflight trim: proactively reduce payload if proto is already large.
|
|
1588
|
+
const MAX_PROTO_BYTES = 320 * 1024;
|
|
1589
|
+
if (proto.length > MAX_PROTO_BYTES && messages.length > 1) {
|
|
1590
|
+
log(`Proto size ${proto.length} bytes > ${MAX_PROTO_BYTES}. Trimming context before request...`);
|
|
1591
|
+
if (_trimMessages(messages, trimState)) {
|
|
1592
|
+
proto = _buildRequest(apiKey, jwt, messages, toolDefs);
|
|
1593
|
+
if (DEBUG_MODE) console.error(`[DEBUG] Proto size after trim: ${proto.length} bytes`);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
let respData;
|
|
1598
|
+
try {
|
|
1599
|
+
respData = await _streamingRequest(proto, timeoutMs);
|
|
1600
|
+
} catch (e) {
|
|
1601
|
+
const errCode = e.code || "UNKNOWN";
|
|
1602
|
+
const baseMeta = {
|
|
1603
|
+
treeDepth: actualDepth,
|
|
1604
|
+
treeSizeKB: +(treeSizeBytes / 1024).toFixed(1),
|
|
1605
|
+
fellBack,
|
|
1606
|
+
projectRoot,
|
|
1607
|
+
errorCode: errCode,
|
|
1608
|
+
repoMapStrategy,
|
|
1609
|
+
hotDirs,
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
// Auto-retry with trimmed context on payload/timeout errors
|
|
1613
|
+
if ((errCode === "PAYLOAD_TOO_LARGE" || errCode === "TIMEOUT") && messages.length > 1) {
|
|
1614
|
+
log(`${errCode} on turn ${turn + 1}: trimming context and retrying...`);
|
|
1615
|
+
_trimMessages(messages, trimState);
|
|
1616
|
+
const retryProto = _buildRequest(apiKey, jwt, messages, toolDefs);
|
|
1617
|
+
try {
|
|
1618
|
+
respData = await _streamingRequest(retryProto, timeoutMs);
|
|
1619
|
+
} catch (retryErr) {
|
|
1620
|
+
const retryCode = retryErr.code || errCode;
|
|
1621
|
+
return {
|
|
1622
|
+
files: [],
|
|
1623
|
+
error: `${retryCode}: ${retryErr.message} (retry after context trim also failed)`,
|
|
1624
|
+
_meta: { ...baseMeta, errorCode: retryCode, contextTrimmed: true },
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
} else {
|
|
1628
|
+
return {
|
|
1629
|
+
files: [],
|
|
1630
|
+
error: `${errCode}: ${e.message}`,
|
|
1631
|
+
_meta: baseMeta,
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
const [thinking, toolInfo] = _parseResponse(respData);
|
|
1637
|
+
|
|
1638
|
+
// Debug logging
|
|
1639
|
+
if (DEBUG_MODE) {
|
|
1640
|
+
console.error(`\n[DEBUG] ===== Turn ${turn + 1} Response =====`);
|
|
1641
|
+
console.error(`[DEBUG] Response size: ${respData.length} bytes`);
|
|
1642
|
+
console.error(`[DEBUG] Thinking: ${thinking.slice(0, 500)}${thinking.length > 500 ? '...' : ''}`);
|
|
1643
|
+
console.error(`[DEBUG] Tool info: ${toolInfo ? `${toolInfo[0]}` : 'null'}`);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
if (toolInfo === null) {
|
|
1647
|
+
if (thinking.startsWith("[Error]")) {
|
|
1648
|
+
return { files: [], error: thinking };
|
|
1649
|
+
}
|
|
1650
|
+
return { files: [], raw_response: thinking };
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const [toolName, toolArgs] = toolInfo;
|
|
1654
|
+
|
|
1655
|
+
if (toolName === "answer") {
|
|
1656
|
+
const answerXml = toolArgs.answer || "";
|
|
1657
|
+
log("Received final answer");
|
|
1658
|
+
const result = _parseAnswer(answerXml, projectRoot);
|
|
1659
|
+
result.rg_patterns = [...new Set(executor.collectedRgPatterns)];
|
|
1660
|
+
result._meta = {
|
|
1661
|
+
treeDepth: actualDepth,
|
|
1662
|
+
treeSizeKB: +(treeSizeBytes / 1024).toFixed(1),
|
|
1663
|
+
fellBack,
|
|
1664
|
+
repoMapStrategy,
|
|
1665
|
+
hotDirs,
|
|
1666
|
+
};
|
|
1667
|
+
return result;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
if (toolName === "restricted_exec") {
|
|
1671
|
+
const callId = randomUUID();
|
|
1672
|
+
const argsJson = JSON.stringify(toolArgs);
|
|
1673
|
+
|
|
1674
|
+
const cmds = Object.keys(toolArgs).filter((k) => k.startsWith("command"));
|
|
1675
|
+
log(`Executing ${cmds.length} local commands`);
|
|
1676
|
+
|
|
1677
|
+
// Debug logging
|
|
1678
|
+
if (DEBUG_MODE) {
|
|
1679
|
+
console.error(`\n[DEBUG] ===== Tool Calls =====`);
|
|
1680
|
+
for (const cmdKey of cmds) {
|
|
1681
|
+
const cmd = toolArgs[cmdKey];
|
|
1682
|
+
console.error(`[DEBUG] ${cmdKey}: ${JSON.stringify(cmd)}`);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// Check for valid commands (those with a type field)
|
|
1687
|
+
const validCommands = cmds.filter((k) => {
|
|
1688
|
+
const cmd = toolArgs[k];
|
|
1689
|
+
return cmd && typeof cmd === "object" && cmd.type;
|
|
1690
|
+
});
|
|
1691
|
+
if (validCommands.length === 0 && compensatedTurns < MAX_COMPENSATIONS) {
|
|
1692
|
+
compensatedTurns++;
|
|
1693
|
+
log(`Turn compensation: no valid commands, extending search by 1 turn (${compensatedTurns}/${MAX_COMPENSATIONS})`);
|
|
1694
|
+
} else if (validCommands.length === 0) {
|
|
1695
|
+
log(`Turn compensation skipped: max compensations (${MAX_COMPENSATIONS}) reached, forcing turn advance`);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
const results = await executor.execToolCallAsync(toolArgs);
|
|
1699
|
+
|
|
1700
|
+
// Update trim state with a compact summary of what we executed
|
|
1701
|
+
try {
|
|
1702
|
+
const tailUnique = (arr, n) => {
|
|
1703
|
+
const out = [];
|
|
1704
|
+
const seen = new Set();
|
|
1705
|
+
for (let i = arr.length - 1; i >= 0 && out.length < n; i--) {
|
|
1706
|
+
const v = arr[i];
|
|
1707
|
+
if (typeof v !== "string" || !v) continue;
|
|
1708
|
+
if (seen.has(v)) continue;
|
|
1709
|
+
seen.add(v);
|
|
1710
|
+
out.push(v);
|
|
1711
|
+
}
|
|
1712
|
+
return out.reverse();
|
|
1713
|
+
};
|
|
1714
|
+
|
|
1715
|
+
const newCommands = [];
|
|
1716
|
+
const newFiles = [];
|
|
1717
|
+
const newPatterns = [];
|
|
1718
|
+
|
|
1719
|
+
for (const cmdKey of cmds) {
|
|
1720
|
+
const cmd = toolArgs[cmdKey];
|
|
1721
|
+
if (!cmd || typeof cmd !== "object") continue;
|
|
1722
|
+
const t = cmd.type;
|
|
1723
|
+
if (t === "rg" && cmd.pattern) {
|
|
1724
|
+
newPatterns.push(cmd.pattern);
|
|
1725
|
+
newCommands.push({ type: "rg", desc: `rg ${cmd.pattern}` });
|
|
1726
|
+
} else if (t === "readfile" && cmd.file) {
|
|
1727
|
+
const shortFile = cmd.file.replace(/^\/codebase\//, "");
|
|
1728
|
+
newFiles.push(shortFile);
|
|
1729
|
+
newCommands.push({ type: "readfile", desc: `read ${shortFile}` });
|
|
1730
|
+
} else if (t === "tree" && cmd.path) {
|
|
1731
|
+
newCommands.push({ type: "tree", desc: `tree ${cmd.path}` });
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
trimState.recentCommands = [...trimState.recentCommands, ...newCommands].slice(-12);
|
|
1736
|
+
trimState.recentFiles = tailUnique([...trimState.recentFiles, ...newFiles], 20);
|
|
1737
|
+
trimState.recentPatterns = tailUnique([...trimState.recentPatterns, ...newPatterns], 30);
|
|
1738
|
+
} catch {
|
|
1739
|
+
// Ignore errors in trim state update
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
messages.push({
|
|
1743
|
+
role: 2,
|
|
1744
|
+
content: thinking,
|
|
1745
|
+
tool_call_id: callId,
|
|
1746
|
+
tool_name: "restricted_exec",
|
|
1747
|
+
tool_args_json: argsJson,
|
|
1748
|
+
});
|
|
1749
|
+
messages.push({ role: 4, content: results, ref_call_id: callId });
|
|
1750
|
+
|
|
1751
|
+
// Inject force-answer after last effective search round
|
|
1752
|
+
const effectiveTurn = turn - compensatedTurns;
|
|
1753
|
+
if (effectiveTurn >= maxTurns - 1 && !forceAnswerInjected) {
|
|
1754
|
+
messages.push({ role: 1, content: FINAL_FORCE_ANSWER });
|
|
1755
|
+
forceAnswerInjected = true;
|
|
1756
|
+
log("Injected force-answer prompt");
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
return {
|
|
1762
|
+
files: [],
|
|
1763
|
+
error: "Max turns reached without getting an answer",
|
|
1764
|
+
rg_patterns: [...new Set(executor.collectedRgPatterns)],
|
|
1765
|
+
_meta: {
|
|
1766
|
+
treeDepth: actualDepth,
|
|
1767
|
+
treeSizeKB: +(treeSizeBytes / 1024).toFixed(1),
|
|
1768
|
+
fellBack,
|
|
1769
|
+
projectRoot,
|
|
1770
|
+
repoMapStrategy,
|
|
1771
|
+
hotDirs,
|
|
1772
|
+
},
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* Search and return formatted result suitable for MCP tool response.
|
|
1778
|
+
*
|
|
1779
|
+
* @param {Object} opts
|
|
1780
|
+
* @param {string} opts.query
|
|
1781
|
+
* @param {string} opts.projectRoot
|
|
1782
|
+
* @param {string} [opts.apiKey]
|
|
1783
|
+
* @param {number} [opts.maxTurns=3]
|
|
1784
|
+
* @param {number} [opts.maxCommands=8]
|
|
1785
|
+
* @param {number} [opts.maxResults=10]
|
|
1786
|
+
* @param {number} [opts.treeDepth=3]
|
|
1787
|
+
* @param {number} [opts.timeoutMs=30000]
|
|
1788
|
+
* @param {string[]} [opts.excludePaths=[]]
|
|
1789
|
+
* @returns {Promise<string>}
|
|
1790
|
+
*/
|
|
1791
|
+
export async function searchWithContent({
|
|
1792
|
+
query,
|
|
1793
|
+
projectRoot,
|
|
1794
|
+
apiKey = null,
|
|
1795
|
+
maxTurns = 3,
|
|
1796
|
+
maxCommands = 8,
|
|
1797
|
+
maxResults = 10,
|
|
1798
|
+
treeDepth = 3,
|
|
1799
|
+
timeoutMs = 30000,
|
|
1800
|
+
excludePaths = [],
|
|
1801
|
+
repoMapMode = "bootstrap_hotspot",
|
|
1802
|
+
bootstrapTreeDepth = 1,
|
|
1803
|
+
hotspotTopK = 4,
|
|
1804
|
+
hotspotTreeDepth = 2,
|
|
1805
|
+
hotspotMaxBytes = 120 * 1024,
|
|
1806
|
+
bootstrapEnabled = true,
|
|
1807
|
+
bootstrapMaxTurns = 2,
|
|
1808
|
+
bootstrapMaxCommands = 6,
|
|
1809
|
+
}) {
|
|
1810
|
+
const result = await search({
|
|
1811
|
+
query,
|
|
1812
|
+
projectRoot,
|
|
1813
|
+
apiKey,
|
|
1814
|
+
maxTurns,
|
|
1815
|
+
maxCommands,
|
|
1816
|
+
maxResults,
|
|
1817
|
+
treeDepth,
|
|
1818
|
+
timeoutMs,
|
|
1819
|
+
excludePaths,
|
|
1820
|
+
repoMapMode,
|
|
1821
|
+
bootstrapTreeDepth,
|
|
1822
|
+
hotspotTopK,
|
|
1823
|
+
hotspotTreeDepth,
|
|
1824
|
+
hotspotMaxBytes,
|
|
1825
|
+
bootstrapEnabled,
|
|
1826
|
+
bootstrapMaxTurns,
|
|
1827
|
+
bootstrapMaxCommands,
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
if (result.error) {
|
|
1831
|
+
const meta = result._meta;
|
|
1832
|
+
let errMsg = `Error: ${result.error}`;
|
|
1833
|
+
if (meta) {
|
|
1834
|
+
errMsg += `\n\n[diagnostic] error_type=${meta.errorCode || "unknown"}, tree_depth_used=${meta.treeDepth}, tree_size=${meta.treeSizeKB}KB`;
|
|
1835
|
+
if (meta.fellBack) errMsg += ` (auto fell back from requested depth)`;
|
|
1836
|
+
if (meta.contextTrimmed) errMsg += `, context_trimmed=true`;
|
|
1837
|
+
if (meta.projectRoot) errMsg += `\n[diagnostic] project_path=${meta.projectRoot}`;
|
|
1838
|
+
errMsg += `\n[config] max_turns=${maxTurns}, max_results=${maxResults}, max_commands=${maxCommands}, timeout_ms=${timeoutMs}`;
|
|
1839
|
+
if (excludePaths.length) errMsg += `, exclude_paths=[${excludePaths.join(", ")}]`;
|
|
1840
|
+
// Targeted hints based on error type
|
|
1841
|
+
if (meta.errorCode === "PAYLOAD_TOO_LARGE" || meta.errorCode === "TIMEOUT") {
|
|
1842
|
+
errMsg += `\n[hint] Payload/timeout error. Try: reduce tree_depth, reduce max_turns, add exclude_paths, or narrow project_path to a subdirectory.`;
|
|
1843
|
+
} else if (meta.errorCode === "AUTH_ERROR") {
|
|
1844
|
+
errMsg += `\n[hint] Authentication error. The API key may be expired or revoked. Try re-extracting with extract_windsurf_key, or set a fresh WINDSURF_API_KEY.`;
|
|
1845
|
+
} else if (meta.errorCode === "RATE_LIMITED") {
|
|
1846
|
+
errMsg += `\n[hint] Rate limited. Wait a moment and retry.`;
|
|
1847
|
+
} else {
|
|
1848
|
+
errMsg += `\n[hint] If the error is payload-related, try a lower tree_depth value or add exclude_paths.`;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
return errMsg;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
const files = result.files || [];
|
|
1855
|
+
const rgPatterns = result.rg_patterns || [];
|
|
1856
|
+
// Deduplicate + filter short patterns
|
|
1857
|
+
const uniquePatterns = [...new Set(rgPatterns)].filter((p) => p.length >= 3);
|
|
1858
|
+
|
|
1859
|
+
if (!files.length && !uniquePatterns.length) {
|
|
1860
|
+
const raw = result.raw_response || "";
|
|
1861
|
+
if (!raw) return "No relevant files found.";
|
|
1862
|
+
const MAX_RAW = 500;
|
|
1863
|
+
const truncated = raw.length > MAX_RAW ? raw.slice(0, MAX_RAW) + "\n...[raw_response truncated]..." : raw;
|
|
1864
|
+
return `No relevant files found.\n\nRaw response:\n${truncated}`;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
const parts = [];
|
|
1868
|
+
const n = files.length;
|
|
1869
|
+
|
|
1870
|
+
if (files.length) {
|
|
1871
|
+
parts.push(`Found ${n} relevant files.`);
|
|
1872
|
+
parts.push("");
|
|
1873
|
+
for (let i = 0; i < files.length; i++) {
|
|
1874
|
+
const entry = files[i];
|
|
1875
|
+
const rangesStr = entry.ranges.map(([s, e]) => `L${s}-${e}`).join(", ");
|
|
1876
|
+
parts.push(` [${i + 1}/${n}] ${entry.full_path} (${rangesStr})`);
|
|
1877
|
+
}
|
|
1878
|
+
} else {
|
|
1879
|
+
parts.push("No files found.");
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
if (uniquePatterns.length) {
|
|
1883
|
+
parts.push("");
|
|
1884
|
+
parts.push(`grep keywords: ${uniquePatterns.join(", ")}`);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// Append diagnostic metadata so the calling AI knows what happened
|
|
1888
|
+
const meta = result._meta;
|
|
1889
|
+
if (meta) {
|
|
1890
|
+
const fbNote = meta.fellBack ? ` (fell back from requested depth)` : "";
|
|
1891
|
+
parts.push("");
|
|
1892
|
+
let configLine = `[config] tree_depth=${meta.treeDepth}${fbNote}, tree_size=${meta.treeSizeKB}KB, max_turns=${maxTurns}, max_results=${maxResults}, timeout_ms=${timeoutMs}`;
|
|
1893
|
+
if (excludePaths.length) configLine += `, exclude_paths=[${excludePaths.join(", ")}]`;
|
|
1894
|
+
parts.push(configLine);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
return parts.join("\n");
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
/**
|
|
1901
|
+
* Extract Windsurf API Key info (for MCP tool use).
|
|
1902
|
+
* @returns {Promise<Object>}
|
|
1903
|
+
*/
|
|
1904
|
+
export async function extractKeyInfo() {
|
|
1905
|
+
return extractKey();
|
|
1906
|
+
}
|