@probelabs/probe 0.6.0-rc253 → 0.6.0-rc255

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 (53) hide show
  1. package/README.md +166 -3
  2. package/bin/binaries/probe-v0.6.0-rc255-aarch64-apple-darwin.tar.gz +0 -0
  3. package/bin/binaries/probe-v0.6.0-rc255-aarch64-unknown-linux-musl.tar.gz +0 -0
  4. package/bin/binaries/probe-v0.6.0-rc255-x86_64-apple-darwin.tar.gz +0 -0
  5. package/bin/binaries/probe-v0.6.0-rc255-x86_64-pc-windows-msvc.zip +0 -0
  6. package/bin/binaries/probe-v0.6.0-rc255-x86_64-unknown-linux-musl.tar.gz +0 -0
  7. package/build/agent/ProbeAgent.d.ts +1 -1
  8. package/build/agent/ProbeAgent.js +51 -16
  9. package/build/agent/acp/tools.js +2 -1
  10. package/build/agent/acp/tools.test.js +2 -1
  11. package/build/agent/dsl/environment.js +19 -0
  12. package/build/agent/index.js +1512 -413
  13. package/build/agent/schemaUtils.js +91 -2
  14. package/build/agent/tools.js +0 -28
  15. package/build/delegate.js +3 -0
  16. package/build/index.js +2 -0
  17. package/build/tools/common.js +6 -5
  18. package/build/tools/edit.js +457 -65
  19. package/build/tools/executePlan.js +3 -1
  20. package/build/tools/fileTracker.js +318 -0
  21. package/build/tools/fuzzyMatch.js +271 -0
  22. package/build/tools/hashline.js +131 -0
  23. package/build/tools/lineEditHeuristics.js +138 -0
  24. package/build/tools/symbolEdit.js +119 -0
  25. package/build/tools/vercel.js +40 -9
  26. package/cjs/agent/ProbeAgent.cjs +1615 -517
  27. package/cjs/index.cjs +1643 -543
  28. package/index.d.ts +189 -1
  29. package/package.json +1 -1
  30. package/src/agent/ProbeAgent.d.ts +1 -1
  31. package/src/agent/ProbeAgent.js +51 -16
  32. package/src/agent/acp/tools.js +2 -1
  33. package/src/agent/acp/tools.test.js +2 -1
  34. package/src/agent/dsl/environment.js +19 -0
  35. package/src/agent/index.js +14 -3
  36. package/src/agent/schemaUtils.js +91 -2
  37. package/src/agent/tools.js +0 -28
  38. package/src/delegate.js +3 -0
  39. package/src/index.js +2 -0
  40. package/src/tools/common.js +6 -5
  41. package/src/tools/edit.js +457 -65
  42. package/src/tools/executePlan.js +3 -1
  43. package/src/tools/fileTracker.js +318 -0
  44. package/src/tools/fuzzyMatch.js +271 -0
  45. package/src/tools/hashline.js +131 -0
  46. package/src/tools/lineEditHeuristics.js +138 -0
  47. package/src/tools/symbolEdit.js +119 -0
  48. package/src/tools/vercel.js +40 -9
  49. package/bin/binaries/probe-v0.6.0-rc253-aarch64-apple-darwin.tar.gz +0 -0
  50. package/bin/binaries/probe-v0.6.0-rc253-aarch64-unknown-linux-musl.tar.gz +0 -0
  51. package/bin/binaries/probe-v0.6.0-rc253-x86_64-apple-darwin.tar.gz +0 -0
  52. package/bin/binaries/probe-v0.6.0-rc253-x86_64-pc-windows-msvc.zip +0 -0
  53. package/bin/binaries/probe-v0.6.0-rc253-x86_64-unknown-linux-musl.tar.gz +0 -0
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Hash-based line integrity utilities for line-targeted editing.
3
+ * Uses DJB2 hash of whitespace-stripped content, mod 256, as 2-char hex.
4
+ * Pure functions, zero external dependencies.
5
+ * @module tools/hashline
6
+ */
7
+
8
+ /**
9
+ * Compute a 2-char hex hash for a line of code.
10
+ * DJB2 hash of whitespace-stripped content mod 256.
11
+ * @param {string} line - The line content
12
+ * @returns {string} 2-char hex hash (e.g. "ab")
13
+ */
14
+ export function computeLineHash(line) {
15
+ const stripped = (line || '').replace(/\s+/g, '');
16
+ let h = 5381;
17
+ for (let i = 0; i < stripped.length; i++) {
18
+ h = ((h << 5) + h + stripped.charCodeAt(i)) & 0xFFFFFFFF;
19
+ }
20
+ return ((h >>> 0) % 256).toString(16).padStart(2, '0');
21
+ }
22
+
23
+ /**
24
+ * Parse a line reference string into line number and optional hash.
25
+ * Handles XML coercion: number 42 → {line:42, hash:null}
26
+ * String formats: "42" → {line:42, hash:null}, "42:ab" → {line:42, hash:"ab"}
27
+ * @param {string|number} ref - Line reference
28
+ * @returns {{line: number, hash: string|null}|null} Parsed ref or null if invalid
29
+ */
30
+ export function parseLineRef(ref) {
31
+ if (ref === undefined || ref === null) return null;
32
+
33
+ const str = String(ref).trim();
34
+ if (!str) return null;
35
+
36
+ // Format: "42:ab" (line with hash)
37
+ const hashMatch = str.match(/^(\d+):([0-9a-fA-F]{2})$/);
38
+ if (hashMatch) {
39
+ const line = parseInt(hashMatch[1], 10);
40
+ if (line < 1 || !isFinite(line)) return null;
41
+ return { line, hash: hashMatch[2].toLowerCase() };
42
+ }
43
+
44
+ // Format: "42" (plain line number)
45
+ const lineMatch = str.match(/^(\d+)$/);
46
+ if (lineMatch) {
47
+ const line = parseInt(lineMatch[1], 10);
48
+ if (line < 1 || !isFinite(line)) return null;
49
+ return { line, hash: null };
50
+ }
51
+
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Validate a hash against the actual file content at a line number.
57
+ * @param {number} lineNum - 1-indexed line number
58
+ * @param {string} hash - Expected 2-char hex hash
59
+ * @param {string[]} fileLines - Array of file lines
60
+ * @returns {{valid: boolean, actualHash: string, actualContent: string}}
61
+ */
62
+ export function validateLineHash(lineNum, hash, fileLines) {
63
+ const idx = lineNum - 1;
64
+ if (idx < 0 || idx >= fileLines.length) {
65
+ return { valid: false, actualHash: '', actualContent: '' };
66
+ }
67
+ const actualContent = fileLines[idx];
68
+ const actualHash = computeLineHash(actualContent);
69
+ return {
70
+ valid: actualHash === hash.toLowerCase(),
71
+ actualHash,
72
+ actualContent
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Annotate probe output with line hashes.
78
+ * Transforms " 42 |" to " 42:ab |" in each line.
79
+ * Handles the probe output format: optional whitespace + line number + space(s) + pipe.
80
+ * @param {string} output - Raw probe output
81
+ * @returns {string} Annotated output with hashes
82
+ */
83
+ export function annotateOutputWithHashes(output) {
84
+ if (!output || typeof output !== 'string') return output;
85
+
86
+ return output.split('\n').map(line => {
87
+ // Strip trailing \r from CRLF line endings before matching
88
+ const cleanLine = line.endsWith('\r') ? line.slice(0, -1) : line;
89
+ // Match probe output format: leading whitespace + digits + whitespace + pipe
90
+ const match = cleanLine.match(/^(\s*)(\d+)(\s*\|)(.*)$/);
91
+ if (!match) return line;
92
+
93
+ const [, prefix, lineNum, pipeSection, content] = match;
94
+ const hash = computeLineHash(content);
95
+ const cr = line.endsWith('\r') ? '\r' : '';
96
+ return `${prefix}${lineNum}:${hash}${pipeSection}${content}${cr}`;
97
+ }).join('\n');
98
+ }
99
+
100
+ /**
101
+ * Strip accidental line-number or line:hash prefixes from LLM new_string content.
102
+ * LLMs sometimes echo the "42:ab | " or "42 | " prefix format in their replacement text.
103
+ * @param {string} text - The new_string content
104
+ * @returns {{cleaned: string, stripped: boolean}} Cleaned text and whether stripping occurred
105
+ */
106
+ export function stripHashlinePrefixes(text) {
107
+ if (!text || typeof text !== 'string') return { cleaned: text || '', stripped: false };
108
+
109
+ const lines = text.split('\n');
110
+ if (lines.length === 0) return { cleaned: '', stripped: false };
111
+
112
+ // Check if majority of non-empty lines have the prefix pattern
113
+ const nonEmptyLines = lines.filter(l => l.trim().length > 0);
114
+ if (nonEmptyLines.length === 0) return { cleaned: text, stripped: false };
115
+
116
+ // Pattern: optional whitespace + digits + optional ":xx" + space(s) + pipe + space
117
+ const prefixPattern = /^\s*\d+(?::[0-9a-fA-F]{2})?\s*\|\s?/;
118
+ const matchCount = nonEmptyLines.filter(l => prefixPattern.test(l)).length;
119
+
120
+ // Only strip if majority (>50%) of non-empty lines have prefixes
121
+ if (matchCount / nonEmptyLines.length <= 0.5) {
122
+ return { cleaned: text, stripped: false };
123
+ }
124
+
125
+ const cleaned = lines.map(line => {
126
+ if (line.trim().length === 0) return line;
127
+ return line.replace(prefixPattern, '');
128
+ }).join('\n');
129
+
130
+ return { cleaned, stripped: true };
131
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Heuristic corrections for common LLM mistakes in line-targeted edits.
3
+ * Handles echo stripping, indent restoration, and prefix stripping.
4
+ * @module tools/lineEditHeuristics
5
+ */
6
+
7
+ import { detectBaseIndent, reindent } from './symbolEdit.js';
8
+ import { stripHashlinePrefixes } from './hashline.js';
9
+
10
+ /**
11
+ * Strip boundary lines that LLMs accidentally echo from the file context.
12
+ *
13
+ * Rules:
14
+ * - Replace: if first line of new_string matches the line before start_line → strip it.
15
+ * if last line matches the line after end_line → strip it.
16
+ * - Insert-after: if first line matches the anchor line → strip it.
17
+ * - Insert-before: if last line matches the anchor line → strip it.
18
+ * - Blank lines are never considered matches (two blanks matching is coincidence).
19
+ *
20
+ * @param {string} newStr - The new_string content
21
+ * @param {string[]} fileLines - Array of file lines (0-indexed)
22
+ * @param {number} startLine - 1-indexed start line
23
+ * @param {number} endLine - 1-indexed end line (same as startLine for single-line or insert)
24
+ * @param {string|undefined} position - "before", "after", or undefined (replace mode)
25
+ * @returns {{result: string, modifications: string[]}}
26
+ */
27
+ export function stripEchoedBoundaries(newStr, fileLines, startLine, endLine, position) {
28
+ const modifications = [];
29
+ let lines = newStr.split('\n');
30
+
31
+ if (lines.length === 0) return { result: newStr, modifications };
32
+
33
+ if (position === 'after') {
34
+ // Insert-after: anchor line is at startLine (1-indexed)
35
+ const anchorIdx = startLine - 1;
36
+ if (anchorIdx >= 0 && anchorIdx < fileLines.length) {
37
+ const anchorTrimmed = fileLines[anchorIdx].trim();
38
+ if (anchorTrimmed.length > 0 && lines.length > 0 && lines[0].trim() === anchorTrimmed) {
39
+ lines = lines.slice(1);
40
+ modifications.push('stripped echoed anchor line (insert-after)');
41
+ }
42
+ }
43
+ } else if (position === 'before') {
44
+ // Insert-before: anchor line is at startLine (1-indexed)
45
+ const anchorIdx = startLine - 1;
46
+ if (anchorIdx >= 0 && anchorIdx < fileLines.length) {
47
+ const anchorTrimmed = fileLines[anchorIdx].trim();
48
+ if (anchorTrimmed.length > 0 && lines.length > 0 && lines[lines.length - 1].trim() === anchorTrimmed) {
49
+ lines = lines.slice(0, -1);
50
+ modifications.push('stripped echoed anchor line (insert-before)');
51
+ }
52
+ }
53
+ } else {
54
+ // Replace mode: check line before start and line after end
55
+ const beforeIdx = startLine - 2; // line before start (0-indexed)
56
+ if (beforeIdx >= 0 && beforeIdx < fileLines.length) {
57
+ const beforeTrimmed = fileLines[beforeIdx].trim();
58
+ if (beforeTrimmed.length > 0 && lines.length > 0 && lines[0].trim() === beforeTrimmed) {
59
+ lines = lines.slice(1);
60
+ modifications.push('stripped echoed line before range');
61
+ }
62
+ }
63
+
64
+ const afterIdx = endLine; // line after end (0-indexed, since endLine is 1-indexed)
65
+ if (afterIdx >= 0 && afterIdx < fileLines.length) {
66
+ const afterTrimmed = fileLines[afterIdx].trim();
67
+ if (afterTrimmed.length > 0 && lines.length > 0 && lines[lines.length - 1].trim() === afterTrimmed) {
68
+ lines = lines.slice(0, -1);
69
+ modifications.push('stripped echoed line after range');
70
+ }
71
+ }
72
+ }
73
+
74
+ return { result: lines.join('\n'), modifications };
75
+ }
76
+
77
+ /**
78
+ * Restore indentation if the replacement has a different base indent than the original lines.
79
+ * @param {string} newStr - The new_string content
80
+ * @param {string[]} originalLines - The original lines being replaced (from the file)
81
+ * @returns {{result: string, modifications: string[]}}
82
+ */
83
+ export function restoreIndentation(newStr, originalLines) {
84
+ const modifications = [];
85
+
86
+ if (!newStr || !originalLines || originalLines.length === 0) {
87
+ return { result: newStr || '', modifications };
88
+ }
89
+
90
+ const originalCode = originalLines.join('\n');
91
+ const targetIndent = detectBaseIndent(originalCode);
92
+ const newIndent = detectBaseIndent(newStr);
93
+
94
+ if (targetIndent !== newIndent) {
95
+ const reindented = reindent(newStr, targetIndent);
96
+ if (reindented !== newStr) {
97
+ modifications.push(`reindented from "${newIndent}" to "${targetIndent}"`);
98
+ return { result: reindented, modifications };
99
+ }
100
+ }
101
+
102
+ return { result: newStr, modifications };
103
+ }
104
+
105
+ /**
106
+ * Pipeline: stripHashlinePrefixes → stripEchoedBoundaries → restoreIndentation.
107
+ * @param {string} newStr - The new_string content
108
+ * @param {string[]} fileLines - Array of all file lines (0-indexed)
109
+ * @param {number} startLine - 1-indexed start line
110
+ * @param {number} endLine - 1-indexed end line
111
+ * @param {string|undefined} position - "before", "after", or undefined
112
+ * @returns {{cleaned: string, modifications: string[]}}
113
+ */
114
+ export function cleanNewString(newStr, fileLines, startLine, endLine, position) {
115
+ const modifications = [];
116
+
117
+ if (!newStr && newStr !== '') return { cleaned: '', modifications };
118
+
119
+ // Step 1: Strip hashline prefixes
120
+ const { cleaned: afterPrefixes, stripped } = stripHashlinePrefixes(newStr);
121
+ if (stripped) modifications.push('stripped line-number prefixes');
122
+
123
+ // Step 2: Strip echoed boundaries
124
+ const { result: afterEchoes, modifications: echoMods } = stripEchoedBoundaries(
125
+ afterPrefixes, fileLines, startLine, endLine, position
126
+ );
127
+ modifications.push(...echoMods);
128
+
129
+ // Step 3: Restore indentation (only for replace mode, not insert)
130
+ if (!position) {
131
+ const originalLines = fileLines.slice(startLine - 1, endLine);
132
+ const { result: afterIndent, modifications: indentMods } = restoreIndentation(afterEchoes, originalLines);
133
+ modifications.push(...indentMods);
134
+ return { cleaned: afterIndent, modifications };
135
+ }
136
+
137
+ return { cleaned: afterEchoes, modifications };
138
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * AST-aware symbol editing helpers
3
+ * Uses probe's tree-sitter AST parsing to find and manipulate code symbols.
4
+ * @module tools/symbolEdit
5
+ */
6
+
7
+ import { extract } from '../extract.js';
8
+
9
+ /**
10
+ * Look up a symbol in a file using probe's AST-based extract
11
+ * @param {string} filePath - Absolute path to the file
12
+ * @param {string} symbolName - Name of the symbol to find
13
+ * @param {string} cwd - Working directory for extract
14
+ * @returns {Promise<Object|null>} Symbol info with startLine, endLine, code, nodeType, file; or null
15
+ */
16
+ export async function findSymbol(filePath, symbolName, cwd) {
17
+ try {
18
+ const result = await extract({
19
+ files: [`${filePath}#${symbolName}`],
20
+ format: 'json',
21
+ json: true,
22
+ cwd
23
+ });
24
+
25
+ if (!result || !result.results || result.results.length === 0) {
26
+ return null;
27
+ }
28
+
29
+ const match = result.results[0];
30
+ return {
31
+ startLine: match.lines[0], // 1-indexed
32
+ endLine: match.lines[1], // 1-indexed
33
+ code: match.code,
34
+ nodeType: match.node_type,
35
+ file: match.file
36
+ };
37
+ } catch (error) {
38
+ if (process.env.DEBUG === '1') {
39
+ console.error(`[SymbolEdit] findSymbol error for "${symbolName}" in ${filePath}: ${error.message}`);
40
+ }
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Look up ALL matching symbols in a file using probe's AST-based extract.
47
+ * When a bare name like "process" matches multiple definitions (e.g. a top-level
48
+ * function AND class methods), this returns all of them with qualified names.
49
+ * @param {string} filePath - Absolute path to the file
50
+ * @param {string} symbolName - Name of the symbol to find
51
+ * @param {string} cwd - Working directory for extract
52
+ * @returns {Promise<Array<Object>>} Array of symbol info objects (may be empty)
53
+ */
54
+ export async function findAllSymbols(filePath, symbolName, cwd) {
55
+ try {
56
+ const result = await extract({
57
+ files: [`${filePath}#${symbolName}`],
58
+ format: 'json',
59
+ json: true,
60
+ cwd
61
+ });
62
+
63
+ if (!result || !result.results || result.results.length === 0) {
64
+ return [];
65
+ }
66
+
67
+ return result.results.map(match => ({
68
+ startLine: match.lines[0],
69
+ endLine: match.lines[1],
70
+ code: match.code,
71
+ nodeType: match.node_type,
72
+ file: match.file,
73
+ qualifiedName: match.symbol_signature || symbolName,
74
+ }));
75
+ } catch (error) {
76
+ if (process.env.DEBUG === '1') {
77
+ console.error(`[SymbolEdit] findAllSymbols error for "${symbolName}" in ${filePath}: ${error.message}`);
78
+ }
79
+ return [];
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Detect the base indentation of a code block (leading whitespace of first non-empty line)
85
+ * @param {string} code - The code block
86
+ * @returns {string} The leading whitespace string
87
+ */
88
+ export function detectBaseIndent(code) {
89
+ const lines = code.split('\n');
90
+ for (const line of lines) {
91
+ if (line.trim().length > 0) {
92
+ const match = line.match(/^(\s*)/);
93
+ return match ? match[1] : '';
94
+ }
95
+ }
96
+ return '';
97
+ }
98
+
99
+ /**
100
+ * Reindent new content to match a target indentation level.
101
+ * Strips the existing base indent from the new content and replaces it with the target indent.
102
+ * @param {string} newContent - The new code content to reindent
103
+ * @param {string} targetIndent - The target indentation string
104
+ * @returns {string} Reindented content
105
+ */
106
+ export function reindent(newContent, targetIndent) {
107
+ const lines = newContent.split('\n');
108
+ const sourceIndent = detectBaseIndent(newContent);
109
+
110
+ return lines.map(line => {
111
+ if (line.trim().length === 0) {
112
+ return '';
113
+ }
114
+ if (line.startsWith(sourceIndent)) {
115
+ return targetIndent + line.slice(sourceIndent.length);
116
+ }
117
+ return line;
118
+ }).join('\n');
119
+ }
@@ -11,6 +11,7 @@ import { delegate } from '../delegate.js';
11
11
  import { analyzeAll } from './analyzeAll.js';
12
12
  import { searchSchema, querySchema, extractSchema, delegateSchema, analyzeAllSchema, searchDescription, queryDescription, extractDescription, delegateDescription, analyzeAllDescription, parseTargets, parseAndResolvePaths, resolveTargetPath } from './common.js';
13
13
  import { formatErrorForAI } from '../utils/error-types.js';
14
+ import { annotateOutputWithHashes } from './hashline.js';
14
15
 
15
16
  const CODE_SEARCH_SCHEMA = {
16
17
  type: 'object',
@@ -161,9 +162,17 @@ export const searchTool = (options = {}) => {
161
162
  maxTokens = 20000,
162
163
  debug = false,
163
164
  outline = false,
164
- searchDelegate = false
165
+ searchDelegate = false,
166
+ hashLines = false
165
167
  } = options;
166
168
 
169
+ const maybeAnnotate = (result) => {
170
+ if (hashLines && typeof result === 'string') {
171
+ return annotateOutputWithHashes(result);
172
+ }
173
+ return result;
174
+ };
175
+
167
176
  return tool({
168
177
  name: 'search',
169
178
  description: searchDelegate
@@ -215,7 +224,12 @@ export const searchTool = (options = {}) => {
215
224
 
216
225
  if (!searchDelegate) {
217
226
  try {
218
- return await runRawSearch();
227
+ const result = maybeAnnotate(await runRawSearch());
228
+ // Track files found in search results for staleness detection
229
+ if (options.fileTracker && typeof result === 'string') {
230
+ options.fileTracker.trackFilesFromOutput(result, options.cwd || '.').catch(() => {});
231
+ }
232
+ return result;
219
233
  } catch (error) {
220
234
  console.error('Error executing search command:', error);
221
235
  return formatErrorForAI(error);
@@ -265,7 +279,11 @@ export const searchTool = (options = {}) => {
265
279
  if (debug) {
266
280
  console.error('Delegated search returned no targets; falling back to raw search');
267
281
  }
268
- return await runRawSearch();
282
+ const fallbackResult = maybeAnnotate(await runRawSearch());
283
+ if (options.fileTracker && typeof fallbackResult === 'string') {
284
+ options.fileTracker.trackFilesFromOutput(fallbackResult, options.cwd || '.').catch(() => {});
285
+ }
286
+ return fallbackResult;
269
287
  }
270
288
 
271
289
  // Resolve relative paths against the actual search directory, not the general cwd.
@@ -288,14 +306,18 @@ export const searchTool = (options = {}) => {
288
306
  // Strip workspace root prefix from extract output so paths are relative
289
307
  if (resolutionBase && typeof extractResult === 'string') {
290
308
  const wsPrefix = resolutionBase.endsWith('/') ? resolutionBase : resolutionBase + '/';
291
- return extractResult.split(wsPrefix).join('');
309
+ return maybeAnnotate(extractResult.split(wsPrefix).join(''));
292
310
  }
293
311
 
294
- return extractResult;
312
+ return maybeAnnotate(extractResult);
295
313
  } catch (error) {
296
314
  console.error('Delegated search failed, falling back to raw search:', error);
297
315
  try {
298
- return await runRawSearch();
316
+ const fallbackResult2 = maybeAnnotate(await runRawSearch());
317
+ if (options.fileTracker && typeof fallbackResult2 === 'string') {
318
+ options.fileTracker.trackFilesFromOutput(fallbackResult2, options.cwd || '.').catch(() => {});
319
+ }
320
+ return fallbackResult2;
299
321
  } catch (fallbackError) {
300
322
  console.error('Error executing search command:', fallbackError);
301
323
  // Both delegation and fallback failed - provide detailed error
@@ -366,7 +388,7 @@ export const queryTool = (options = {}) => {
366
388
  * @returns {Object} Configured extract tool
367
389
  */
368
390
  export const extractTool = (options = {}) => {
369
- const { debug = false, outline = false } = options;
391
+ const { debug = false, outline = false, hashLines = false } = options;
370
392
 
371
393
  return tool({
372
394
  name: 'extract',
@@ -388,6 +410,7 @@ export const extractTool = (options = {}) => {
388
410
  // Create a temporary file for input content if provided
389
411
  let tempFilePath = null;
390
412
  let extractOptions = { cwd: effectiveCwd };
413
+ let extractFiles = null; // Track resolved file targets for content hashing
391
414
 
392
415
  if (input_content) {
393
416
  // Import required modules
@@ -424,7 +447,7 @@ export const extractTool = (options = {}) => {
424
447
  const parsedTargets = parseTargets(targets);
425
448
 
426
449
  // Resolve relative paths in targets against cwd
427
- const files = parsedTargets.map(target => resolveTargetPath(target, effectiveCwd));
450
+ extractFiles = parsedTargets.map(target => resolveTargetPath(target, effectiveCwd));
428
451
 
429
452
  // Apply format mapping for outline-xml to xml
430
453
  let effectiveFormat = format;
@@ -434,7 +457,7 @@ export const extractTool = (options = {}) => {
434
457
 
435
458
  // Set up extract options with files
436
459
  extractOptions = {
437
- files,
460
+ files: extractFiles,
438
461
  cwd: effectiveCwd,
439
462
  allowTests: allow_tests ?? true,
440
463
  contextLines: context_lines,
@@ -447,6 +470,11 @@ export const extractTool = (options = {}) => {
447
470
  // Execute the extract command
448
471
  const results = await extract(extractOptions);
449
472
 
473
+ // Track files and symbol content for staleness detection (post-extract)
474
+ if (options.fileTracker && extractFiles && extractFiles.length > 0) {
475
+ options.fileTracker.trackFilesFromExtract(extractFiles, effectiveCwd).catch(() => {});
476
+ }
477
+
450
478
  // Clean up temporary file if created
451
479
  if (tempFilePath) {
452
480
  const { unlinkSync } = await import('fs');
@@ -460,6 +488,9 @@ export const extractTool = (options = {}) => {
460
488
  }
461
489
  }
462
490
 
491
+ if (hashLines && typeof results === 'string') {
492
+ return annotateOutputWithHashes(results);
493
+ }
463
494
  return results;
464
495
  } catch (error) {
465
496
  console.error('Error executing extract command:', error);