@probelabs/probe 0.6.0-rc294 → 0.6.0-rc296

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +7 -0
  2. package/bin/binaries/{probe-v0.6.0-rc294-aarch64-apple-darwin.tar.gz → probe-v0.6.0-rc296-aarch64-apple-darwin.tar.gz} +0 -0
  3. package/bin/binaries/{probe-v0.6.0-rc294-aarch64-unknown-linux-musl.tar.gz → probe-v0.6.0-rc296-aarch64-unknown-linux-musl.tar.gz} +0 -0
  4. package/bin/binaries/{probe-v0.6.0-rc294-x86_64-apple-darwin.tar.gz → probe-v0.6.0-rc296-x86_64-apple-darwin.tar.gz} +0 -0
  5. package/bin/binaries/{probe-v0.6.0-rc294-x86_64-pc-windows-msvc.zip → probe-v0.6.0-rc296-x86_64-pc-windows-msvc.zip} +0 -0
  6. package/bin/binaries/{probe-v0.6.0-rc294-x86_64-unknown-linux-musl.tar.gz → probe-v0.6.0-rc296-x86_64-unknown-linux-musl.tar.gz} +0 -0
  7. package/build/agent/ProbeAgent.d.ts +10 -0
  8. package/build/agent/ProbeAgent.js +868 -29
  9. package/build/agent/mcp/client.js +81 -4
  10. package/build/agent/mcp/xmlBridge.js +11 -0
  11. package/build/agent/otelLogBridge.js +184 -0
  12. package/build/agent/simpleTelemetry.js +8 -0
  13. package/build/delegate.js +75 -6
  14. package/build/index.js +6 -2
  15. package/build/tools/common.js +84 -11
  16. package/build/tools/vercel.js +78 -18
  17. package/cjs/agent/ProbeAgent.cjs +1004 -48
  18. package/cjs/agent/simpleTelemetry.cjs +112 -0
  19. package/cjs/index.cjs +1116 -48
  20. package/index.d.ts +26 -0
  21. package/package.json +1 -1
  22. package/src/agent/ProbeAgent.d.ts +10 -0
  23. package/src/agent/ProbeAgent.js +868 -29
  24. package/src/agent/mcp/client.js +81 -4
  25. package/src/agent/mcp/xmlBridge.js +11 -0
  26. package/src/agent/otelLogBridge.js +184 -0
  27. package/src/agent/simpleTelemetry.js +8 -0
  28. package/src/delegate.js +75 -6
  29. package/src/index.js +6 -2
  30. package/src/tools/common.js +84 -11
  31. package/src/tools/vercel.js +78 -18
@@ -58,6 +58,11 @@ export function isMethodAllowed(methodName, allowedMethods, blockedMethods) {
58
58
  export function createTransport(serverConfig) {
59
59
  const { transport, command, args, url, env } = serverConfig;
60
60
 
61
+ // Allow pre-created transport instances (e.g., InMemoryTransport for testing)
62
+ if (serverConfig.transportInstance) {
63
+ return serverConfig.transportInstance;
64
+ }
65
+
61
66
  switch (transport) {
62
67
  case 'stdio':
63
68
  return new StdioClientTransport({
@@ -520,6 +525,57 @@ export class MCPClientManager {
520
525
  }
521
526
  }
522
527
 
528
+ /**
529
+ * Call graceful_stop on all MCP servers that expose it.
530
+ * This signals agent-type MCP servers to wrap up their work.
531
+ * @returns {Promise<Array<{server: string, success: boolean, error?: string}>>}
532
+ */
533
+ async callGracefulStopAll() {
534
+ const results = [];
535
+ for (const [serverName, clientInfo] of this.clients) {
536
+ // Look for a graceful_stop tool on this server (qualified name: serverName_graceful_stop)
537
+ const qualifiedName = `${serverName}_graceful_stop`;
538
+ if (this.tools.has(qualifiedName)) {
539
+ if (this.debug) {
540
+ console.log(`[DEBUG] MCP callGracefulStopAll: calling graceful_stop on server "${serverName}"`);
541
+ }
542
+ try {
543
+ // Short timeout — this is a signal, not a long operation
544
+ const timeoutMs = 5000;
545
+ const timeoutPromise = new Promise((_, reject) =>
546
+ setTimeout(() => reject(new Error('graceful_stop timeout')), timeoutMs)
547
+ );
548
+ await Promise.race([
549
+ clientInfo.client.callTool({ name: 'graceful_stop', arguments: {} }, undefined, { timeout: timeoutMs }),
550
+ timeoutPromise
551
+ ]);
552
+ results.push({ server: serverName, success: true });
553
+ if (this.debug) {
554
+ console.log(`[DEBUG] MCP callGracefulStopAll: server "${serverName}" acknowledged graceful_stop`);
555
+ }
556
+ } catch (e) {
557
+ results.push({ server: serverName, success: false, error: e.message });
558
+ if (this.debug) {
559
+ console.log(`[DEBUG] MCP callGracefulStopAll: server "${serverName}" graceful_stop failed: ${e.message}`);
560
+ }
561
+ }
562
+ }
563
+ }
564
+ if (this.debug) {
565
+ const withStop = results.length;
566
+ const total = this.clients.size;
567
+ console.log(`[DEBUG] MCP callGracefulStopAll: ${withStop}/${total} servers had graceful_stop tool`);
568
+ }
569
+ // Record telemetry event for the graceful_stop sweep
570
+ this.recordMcpEvent('graceful_stop.sweep_completed', {
571
+ servers_total: this.clients.size,
572
+ servers_with_graceful_stop: results.length,
573
+ servers_acknowledged: results.filter(r => r.success).length,
574
+ servers_failed: results.filter(r => !r.success).length,
575
+ });
576
+ return results;
577
+ }
578
+
523
579
  /**
524
580
  * Get all available tools with their schemas
525
581
  * @returns {Object} Map of tool name to tool definition
@@ -550,11 +606,32 @@ export class MCPClientManager {
550
606
  inputSchema: tool.inputSchema,
551
607
  execute: async (args) => {
552
608
  const result = await this.callTool(name, args);
553
- // Extract text content from MCP response
554
- if (result.content && result.content[0]) {
555
- return result.content[0].text;
609
+ if (!result.content || !result.content[0]) {
610
+ return JSON.stringify(result);
611
+ }
612
+ // Check if response contains image content blocks
613
+ const hasImage = result.content.some(block => block.type === 'image');
614
+ if (hasImage) {
615
+ // Return the full content array so toModelOutput can convert it
616
+ return { _mcpContent: result.content };
617
+ }
618
+ // Text-only: return just the text
619
+ return result.content[0].text;
620
+ },
621
+ // Convert MCP content blocks (including images) to Vercel AI SDK format
622
+ toModelOutput: ({ output }) => {
623
+ if (output && typeof output === 'object' && output._mcpContent) {
624
+ const parts = [];
625
+ for (const block of output._mcpContent) {
626
+ if (block.type === 'text') {
627
+ parts.push({ type: 'text', text: block.text });
628
+ } else if (block.type === 'image') {
629
+ parts.push({ type: 'image-data', data: block.data, mediaType: block.mimeType });
630
+ }
631
+ }
632
+ return { type: 'content', value: parts };
556
633
  }
557
- return JSON.stringify(result);
634
+ return { type: 'text', value: typeof output === 'string' ? output : JSON.stringify(output) };
558
635
  }
559
636
  };
560
637
  }
@@ -145,6 +145,17 @@ export class MCPXmlBridge {
145
145
  return toolName in this.mcpTools;
146
146
  }
147
147
 
148
+ /**
149
+ * Call graceful_stop on all MCP servers that expose it.
150
+ * @returns {Promise<Array>}
151
+ */
152
+ async callGracefulStopAll() {
153
+ if (this.mcpManager) {
154
+ return this.mcpManager.callGracefulStopAll();
155
+ }
156
+ return [];
157
+ }
158
+
148
159
  /**
149
160
  * Clean up MCP connections
150
161
  */
@@ -0,0 +1,184 @@
1
+ /**
2
+ * OTEL Log Bridge — patches console.log/info/warn/error to:
3
+ * 1. Append trace context [trace_id=... span_id=...] to output (like visor2)
4
+ * 2. Emit each log as an OTEL Log Record via @opentelemetry/api-logs
5
+ *
6
+ * Lazy-loads @opentelemetry/api and @opentelemetry/api-logs.
7
+ * If packages are not installed, patching is a no-op.
8
+ *
9
+ * Usage:
10
+ * import { patchConsole, unpatchConsole } from './otelLogBridge.js';
11
+ * patchConsole(); // Call once at startup
12
+ */
13
+
14
+ import { createRequire } from 'module';
15
+
16
+ // createRequire for loading optional @opentelemetry packages in ESM context
17
+ const _require = createRequire(import.meta.url);
18
+
19
+ // OTel severity mapping (OpenTelemetry SeverityNumber values)
20
+ const OTEL_SEVERITY = {
21
+ log: 9, // INFO
22
+ info: 9, // INFO
23
+ warn: 13, // WARN
24
+ error: 17, // ERROR
25
+ debug: 5, // DEBUG
26
+ };
27
+
28
+ // Track patch state
29
+ let patched = false;
30
+ const originals = {};
31
+
32
+ // Lazy-loaded OTel references
33
+ let otelApi = null;
34
+ let otelApiAttempted = false;
35
+ let otelLogger = null;
36
+ let otelLoggerAttempted = false;
37
+
38
+ /**
39
+ * Try to load @opentelemetry/api lazily.
40
+ * Returns { trace, context } or null if not available.
41
+ */
42
+ function getOtelApi() {
43
+ if (otelApiAttempted) return otelApi;
44
+ otelApiAttempted = true;
45
+ try {
46
+ // Dynamic require wrapped in IIFE to prevent bundler from resolving
47
+ otelApi = (function(name) { return _require(name); })('@opentelemetry/api');
48
+ } catch {
49
+ // @opentelemetry/api not installed
50
+ }
51
+ return otelApi;
52
+ }
53
+
54
+ /**
55
+ * Try to get an OTEL Logger from @opentelemetry/api-logs lazily.
56
+ * Returns a logger instance or null if not available.
57
+ */
58
+ function getOtelLogger() {
59
+ if (otelLoggerAttempted) return otelLogger;
60
+ otelLoggerAttempted = true;
61
+ try {
62
+ const { logs } = (function(name) { return _require(name); })('@opentelemetry/api-logs');
63
+ otelLogger = logs.getLogger('probe-agent');
64
+ } catch {
65
+ // @opentelemetry/api-logs not installed
66
+ }
67
+ return otelLogger;
68
+ }
69
+
70
+ /**
71
+ * Extract trace context suffix from the active OTel span.
72
+ * Returns '' if no active span or OTel is not available.
73
+ */
74
+ function getTraceSuffix() {
75
+ try {
76
+ const api = getOtelApi();
77
+ if (!api) return '';
78
+ const span = api.trace.getSpan(api.context.active());
79
+ const ctx = span?.spanContext?.();
80
+ if (!ctx?.traceId) return '';
81
+ return ` [trace_id=${ctx.traceId} span_id=${ctx.spanId}]`;
82
+ } catch {
83
+ return '';
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Emit a log record to the OTEL Logs pipeline.
89
+ * Non-blocking, best-effort — errors are silently ignored.
90
+ */
91
+ function emitOtelLog(msg, level) {
92
+ try {
93
+ const logger = getOtelLogger();
94
+ if (!logger) return;
95
+
96
+ const api = getOtelApi();
97
+ let traceId, spanId;
98
+ if (api) {
99
+ const span = api.trace.getSpan(api.context.active());
100
+ const ctx = span?.spanContext?.();
101
+ if (ctx?.traceId) {
102
+ traceId = ctx.traceId;
103
+ spanId = ctx.spanId;
104
+ }
105
+ }
106
+
107
+ logger.emit({
108
+ severityNumber: OTEL_SEVERITY[level] || 9,
109
+ severityText: level.toUpperCase(),
110
+ body: msg,
111
+ attributes: {
112
+ 'probe.logger': true,
113
+ ...(traceId ? { trace_id: traceId, span_id: spanId } : {}),
114
+ },
115
+ });
116
+ } catch {
117
+ // OTel logs not available; ignore
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Patch console.log/info/warn/error to:
123
+ * - Append trace context to output
124
+ * - Emit OTEL log records
125
+ *
126
+ * Safe to call multiple times — only patches once.
127
+ */
128
+ export function patchConsole() {
129
+ if (patched) return;
130
+
131
+ const methods = ['log', 'info', 'warn', 'error'];
132
+ const c = globalThis.console;
133
+
134
+ for (const m of methods) {
135
+ const orig = c[m].bind(c);
136
+ originals[m] = orig;
137
+
138
+ c[m] = (...args) => {
139
+ // Build the message string for OTEL log emission
140
+ const msgParts = args.map(a =>
141
+ typeof a === 'string' ? a : (a instanceof Error ? a.message : JSON.stringify(a))
142
+ );
143
+ const msg = msgParts.join(' ');
144
+
145
+ // Emit to OTEL Logs pipeline (non-blocking, best-effort)
146
+ emitOtelLog(msg, m === 'log' ? 'log' : m);
147
+
148
+ // Append trace context suffix to console output
149
+ const suffix = getTraceSuffix();
150
+ if (suffix) {
151
+ if (typeof args[0] === 'string') {
152
+ args[0] = args[0] + suffix;
153
+ } else {
154
+ args.push(suffix);
155
+ }
156
+ }
157
+
158
+ return orig(...args);
159
+ };
160
+ }
161
+
162
+ patched = true;
163
+ }
164
+
165
+ /**
166
+ * Restore original console methods.
167
+ * Useful for testing or cleanup.
168
+ */
169
+ export function unpatchConsole() {
170
+ if (!patched) return;
171
+
172
+ const c = globalThis.console;
173
+ for (const [m, orig] of Object.entries(originals)) {
174
+ c[m] = orig;
175
+ }
176
+ patched = false;
177
+ }
178
+
179
+ /**
180
+ * Check if console is currently patched.
181
+ */
182
+ export function isConsolePatched() {
183
+ return patched;
184
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, createWriteStream } from 'fs';
2
2
  import { dirname } from 'path';
3
+ import { patchConsole } from './otelLogBridge.js';
3
4
 
4
5
  /**
5
6
  * Simple telemetry implementation for probe-agent
@@ -512,5 +513,12 @@ export function initializeSimpleTelemetryFromOptions(options) {
512
513
  filePath: options.traceFile || './traces.jsonl'
513
514
  });
514
515
 
516
+ // Patch console methods to append trace context and emit OTEL log records.
517
+ // This bridges ALL console.log/info/warn/error calls to the OTEL Logs pipeline
518
+ // automatically — no code changes needed at existing call sites.
519
+ // Safe to call multiple times (idempotent). No-op if @opentelemetry/api-logs
520
+ // is not installed.
521
+ patchConsole();
522
+
515
523
  return telemetry;
516
524
  }
package/build/delegate.js CHANGED
@@ -397,7 +397,14 @@ export async function delegate({
397
397
  mcpConfigPath = null,
398
398
  delegationManager = null, // Optional per-instance manager, falls back to default singleton
399
399
  concurrencyLimiter = null, // Optional global AI concurrency limiter
400
- parentAbortSignal = null // Optional AbortSignal from parent to cancel this delegation
400
+ parentAbortSignal = null, // Optional AbortSignal from parent to cancel this delegation
401
+ // Timeout settings inherited from parent agent
402
+ timeoutBehavior = undefined,
403
+ requestTimeout = undefined,
404
+ gracefulTimeoutBonusSteps = undefined,
405
+ // Subagent lifecycle callbacks for graceful stop coordination
406
+ onSubagentCreated = null,
407
+ onSubagentCompleted = null,
401
408
  }) {
402
409
  if (!task || typeof task !== 'string') {
403
410
  throw new Error('Task parameter is required and must be a string');
@@ -489,12 +496,38 @@ export async function delegate({
489
496
  enableMcp, // Inherit from parent (subagent creates own MCPXmlBridge)
490
497
  mcpConfig, // Inherit from parent
491
498
  mcpConfigPath, // Inherit from parent
492
- concurrencyLimiter // Inherit global AI concurrency limiter
499
+ concurrencyLimiter, // Inherit global AI concurrency limiter
500
+ // Inherit timeout behavior from parent — subagent gets its own graceful wind-down
501
+ // so it can produce partial results instead of being hard-killed by the external timer.
502
+ // The external delegate timeout (capped to parent's remaining budget) is the hard limit;
503
+ // maxOperationTimeout on the subagent is set slightly shorter so its own wind-down
504
+ // fires before the external kill.
505
+ maxOperationTimeout: Math.max(10000, (timeout * 1000) - 15000), // 15s before external kill
506
+ timeoutBehavior: timeoutBehavior || 'graceful',
507
+ requestTimeout,
508
+ gracefulTimeoutBonusSteps: gracefulTimeoutBonusSteps ?? 2, // fewer steps for subagents
493
509
  });
494
510
 
511
+ // Register subagent with parent for graceful stop coordination
512
+ if (onSubagentCreated) {
513
+ onSubagentCreated(sessionId, subagent);
514
+ }
515
+
495
516
  if (debug) {
496
517
  console.error(`[DELEGATE] Created subagent with session ${sessionId}`);
497
518
  console.error(`[DELEGATE] Subagent config: promptType=${promptType}, enableDelegate=false, maxIterations=${remainingIterations}`);
519
+ console.error(`[DELEGATE] Timeout inheritance: externalTimeout=${timeout}s, maxOperationTimeout=${Math.max(10000, (timeout * 1000) - 15000)}ms, behavior=${timeoutBehavior || 'graceful'}, bonusSteps=${gracefulTimeoutBonusSteps ?? 2}`);
520
+ }
521
+ if (tracer) {
522
+ tracer.addEvent('delegation.subagent_created', {
523
+ 'delegation.session_id': sessionId,
524
+ 'delegation.parent_session_id': parentSessionId,
525
+ 'delegation.external_timeout_s': timeout,
526
+ 'delegation.internal_timeout_ms': Math.max(10000, (timeout * 1000) - 15000),
527
+ 'delegation.timeout_behavior': timeoutBehavior || 'graceful',
528
+ 'delegation.bonus_steps': gracefulTimeoutBonusSteps ?? 2,
529
+ 'delegation.max_iterations': remainingIterations,
530
+ });
498
531
  }
499
532
 
500
533
  // Set up timeout and parent abort handling.
@@ -507,8 +540,11 @@ export async function delegate({
507
540
  }, timeout * 1000);
508
541
  });
509
542
 
510
- // Listen for parent abort signal
543
+ // Listen for parent abort signal — use two-phase shutdown:
544
+ // Phase 1: Trigger graceful wind-down so subagent can summarize its work
545
+ // Phase 2: Hard cancel after deadline if subagent hasn't finished
511
546
  let parentAbortHandler;
547
+ let parentAbortHardCancelId = null;
512
548
  const parentAbortPromise = new Promise((_, reject) => {
513
549
  if (parentAbortSignal) {
514
550
  if (parentAbortSignal.aborted) {
@@ -517,8 +553,33 @@ export async function delegate({
517
553
  return;
518
554
  }
519
555
  parentAbortHandler = () => {
520
- subagent.cancel();
521
- reject(new Error('Delegation cancelled: parent operation was aborted'));
556
+ // Phase 1: graceful wind-down — let subagent finish its current step
557
+ subagent.triggerGracefulWindDown();
558
+ if (debug) {
559
+ console.error(`[DELEGATE] Parent abort signal received — triggered graceful wind-down on subagent ${sessionId}`);
560
+ }
561
+ if (tracer) {
562
+ tracer.addEvent('delegation.parent_abort_phase1', {
563
+ 'delegation.session_id': sessionId,
564
+ 'delegation.parent_session_id': parentSessionId,
565
+ 'delegation.action': 'graceful_wind_down',
566
+ });
567
+ }
568
+ // Phase 2: hard cancel after 30s if subagent hasn't finished
569
+ parentAbortHardCancelId = setTimeout(() => {
570
+ if (debug) {
571
+ console.error(`[DELEGATE] Graceful wind-down deadline expired — hard cancelling subagent ${sessionId}`);
572
+ }
573
+ if (tracer) {
574
+ tracer.addEvent('delegation.parent_abort_phase2', {
575
+ 'delegation.session_id': sessionId,
576
+ 'delegation.parent_session_id': parentSessionId,
577
+ 'delegation.action': 'hard_cancel',
578
+ });
579
+ }
580
+ subagent.cancel();
581
+ reject(new Error('Delegation cancelled: parent operation was aborted (graceful wind-down deadline expired)'));
582
+ }, 30000);
522
583
  };
523
584
  parentAbortSignal.addEventListener('abort', parentAbortHandler, { once: true });
524
585
  }
@@ -533,10 +594,18 @@ export async function delegate({
533
594
  try {
534
595
  response = await Promise.race(racers);
535
596
  } finally {
536
- // Clean up parent abort listener to prevent memory leaks
597
+ // Clean up parent abort listener and hard cancel timer to prevent memory leaks
537
598
  if (parentAbortHandler && parentAbortSignal) {
538
599
  parentAbortSignal.removeEventListener('abort', parentAbortHandler);
539
600
  }
601
+ if (parentAbortHardCancelId) {
602
+ clearTimeout(parentAbortHardCancelId);
603
+ parentAbortHardCancelId = null;
604
+ }
605
+ // Unregister subagent from parent
606
+ if (onSubagentCompleted) {
607
+ onSubagentCompleted(sessionId);
608
+ }
540
609
  }
541
610
 
542
611
  // Clear timeout immediately after race completes to prevent memory leak
package/build/index.js CHANGED
@@ -45,7 +45,7 @@ import { createExecutePlanTool, createCleanupExecutePlanTool } from './tools/exe
45
45
  import { bashTool } from './tools/bash.js';
46
46
  import { editTool, createTool, multiEditTool } from './tools/edit.js';
47
47
  import { FileTracker } from './tools/fileTracker.js';
48
- import { ProbeAgent } from './agent/ProbeAgent.js';
48
+ import { ProbeAgent, ENGINE_ACTIVITY_TIMEOUT_DEFAULT, ENGINE_ACTIVITY_TIMEOUT_MIN, ENGINE_ACTIVITY_TIMEOUT_MAX } from './agent/ProbeAgent.js';
49
49
  import { SimpleTelemetry, SimpleAppTracer, initializeSimpleTelemetryFromOptions } from './agent/simpleTelemetry.js';
50
50
  import { listFilesToolInstance, searchFilesToolInstance } from './agent/probeTool.js';
51
51
  import { StorageAdapter, InMemoryStorageAdapter } from './agent/storage/index.js';
@@ -68,8 +68,12 @@ export {
68
68
  listFilesByLevel,
69
69
  tools,
70
70
  DEFAULT_SYSTEM_MESSAGE,
71
- // Export AI Agent (NEW!)
71
+ // Export AI Agent
72
72
  ProbeAgent,
73
+ // Export timeout constants
74
+ ENGINE_ACTIVITY_TIMEOUT_DEFAULT,
75
+ ENGINE_ACTIVITY_TIMEOUT_MIN,
76
+ ENGINE_ACTIVITY_TIMEOUT_MAX,
73
77
  // Export storage adapters
74
78
  StorageAdapter,
75
79
  InMemoryStorageAdapter,
@@ -10,7 +10,7 @@ import { resolve, isAbsolute } from 'path';
10
10
  export const searchSchema = z.object({
11
11
  query: z.string().describe('Search query — natural language questions or Elasticsearch-style keywords both work. For keywords: use quotes for exact phrases, AND/OR for boolean logic, - for negation. Probe handles stemming and camelCase/snake_case splitting automatically, so do NOT try case or style variations of the same keyword.'),
12
12
  path: z.string().optional().default('.').describe('Path to search in. For dependencies use "go:github.com/owner/repo", "js:package_name", or "rust:cargo_name" etc.'),
13
- exact: z.boolean().optional().default(false).describe('Default (false) enables stemming and keyword splitting for exploratory search - "getUserData" matches "get", "user", "data", etc. Set true for precise symbol lookup where "getUserData" matches only "getUserData". Use true when you know the exact symbol name.'),
13
+ exact: z.boolean().optional().default(false).describe('Default (false) enables stemming and keyword splitting for exploratory search - "getUserData" matches "get", "user", "data", etc. Set true for precise symbol lookup OR when searching for strings with punctuation/quotes/empty values (e.g. \'description: ""\' BM25 strips punctuation so exact=true is required for literal matching). Use true when you know the exact symbol name or need literal string matching.'),
14
14
  maxTokens: z.number().nullable().optional().describe('Maximum tokens to return. Default is 20000. Set to null for unlimited results.'),
15
15
  session: z.string().optional().describe('Session ID for result caching and pagination. Pass the session ID from a previous search to get additional results (next page). Results already shown in a session are automatically excluded. Omit for a fresh search.'),
16
16
  nextPage: z.boolean().optional().default(false).describe('Set to true when requesting the next page of results. Requires passing the same session ID from the previous search output.')
@@ -188,9 +188,74 @@ export function areBothStuckResponses(response1, response2) {
188
188
  }
189
189
 
190
190
 
191
+ /**
192
+ * Parse a shell-like string into tokens, respecting quoted substrings.
193
+ * Supports double quotes, single quotes, and escaped characters within quotes.
194
+ * Splits on commas and/or whitespace outside of quotes.
195
+ *
196
+ * @param {string} input - The string to tokenize
197
+ * @returns {string[]} Array of tokens with quotes stripped
198
+ *
199
+ * @example
200
+ * splitQuotedString('"path with spaces/file.md" other.rs')
201
+ * // Returns: ["path with spaces/file.md", "other.rs"]
202
+ */
203
+ export function splitQuotedString(input) {
204
+ const tokens = [];
205
+ let current = '';
206
+ let inQuote = null; // null, '"', or "'"
207
+ let i = 0;
208
+
209
+ while (i < input.length) {
210
+ const ch = input[i];
211
+
212
+ if (inQuote) {
213
+ if (ch === '\\' && i + 1 < input.length) {
214
+ // Escaped character inside quotes — keep the literal character
215
+ current += input[i + 1];
216
+ i += 2;
217
+ continue;
218
+ }
219
+ if (ch === inQuote) {
220
+ // Closing quote
221
+ inQuote = null;
222
+ i++;
223
+ continue;
224
+ }
225
+ current += ch;
226
+ i++;
227
+ } else {
228
+ if (ch === '"' || ch === "'") {
229
+ inQuote = ch;
230
+ i++;
231
+ continue;
232
+ }
233
+ if (/[\s,]/.test(ch)) {
234
+ // Delimiter outside quotes
235
+ if (current.length > 0) {
236
+ tokens.push(current);
237
+ current = '';
238
+ }
239
+ i++;
240
+ continue;
241
+ }
242
+ current += ch;
243
+ i++;
244
+ }
245
+ }
246
+
247
+ if (current.length > 0) {
248
+ tokens.push(current);
249
+ }
250
+
251
+ return tokens;
252
+ }
253
+
191
254
  /**
192
255
  * Parse targets string into array of file specifications
193
- * Handles both space-separated and comma-separated targets for extract tool
256
+ * Handles both space-separated and comma-separated targets for extract tool.
257
+ * Quoted strings (single or double) are preserved as single targets,
258
+ * allowing file paths with spaces.
194
259
  *
195
260
  * @param {string} targets - Space or comma-separated file targets (e.g., "file1.rs:10-20, file2.rs#symbol")
196
261
  * @returns {string[]} Array of individual file specifications
@@ -204,16 +269,15 @@ export function areBothStuckResponses(response1, response2) {
204
269
  * // Returns: ["file1.rs:10-20", "file2.rs:30-40"]
205
270
  *
206
271
  * @example
207
- * parseTargets("session.rs#AuthService.login auth.rs:2-100 config.rs#DatabaseConfig")
208
- * // Returns: ["session.rs#AuthService.login", "auth.rs:2-100", "config.rs#DatabaseConfig"]
272
+ * parseTargets('"Customers/First American/Meeting Notes.md" other.rs')
273
+ * // Returns: ["Customers/First American/Meeting Notes.md", "other.rs"]
209
274
  */
210
275
  export function parseTargets(targets) {
211
276
  if (!targets || typeof targets !== 'string') {
212
277
  return [];
213
278
  }
214
279
 
215
- // Split on any whitespace or comma (with optional surrounding whitespace) and filter out empty strings
216
- return targets.split(/[\s,]+/).filter(f => f.length > 0);
280
+ return splitQuotedString(targets);
217
281
  }
218
282
 
219
283
  /**
@@ -227,7 +291,19 @@ export function parseTargets(targets) {
227
291
  export function parseAndResolvePaths(pathStr, cwd) {
228
292
  if (!pathStr) return [];
229
293
 
230
- // Split on comma and trim whitespace
294
+ // If the input contains quotes, use the quote-aware tokenizer which
295
+ // preserves quoted strings with spaces as single tokens.
296
+ if (/["']/.test(pathStr)) {
297
+ const paths = splitQuotedString(pathStr);
298
+ return paths.map(p => {
299
+ if (isAbsolute(p)) return p;
300
+ return cwd ? resolve(cwd, p) : p;
301
+ });
302
+ }
303
+
304
+ // No quotes: use comma-split + space-split heuristic (original behavior).
305
+ // Split on comma first, then auto-fix space-separated paths if each part
306
+ // looks like a file path.
231
307
  let paths = pathStr.split(',').map(p => p.trim()).filter(p => p.length > 0);
232
308
 
233
309
  // Auto-fix: model sometimes passes space-separated file paths as one string
@@ -242,10 +318,7 @@ export function parseAndResolvePaths(pathStr, cwd) {
242
318
 
243
319
  // Resolve relative paths against cwd
244
320
  return paths.map(p => {
245
- if (isAbsolute(p)) {
246
- return p;
247
- }
248
- // Resolve relative path against cwd
321
+ if (isAbsolute(p)) return p;
249
322
  return cwd ? resolve(cwd, p) : p;
250
323
  });
251
324
  }