@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/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
+ }