@hcu-lab.me/mcp-device 1.0.0

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 (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/bootstrap.d.ts +1 -0
  4. package/dist/bootstrap.js +22 -0
  5. package/dist/command-manager.d.ts +8 -0
  6. package/dist/command-manager.js +241 -0
  7. package/dist/config-field-definitions.d.ts +41 -0
  8. package/dist/config-field-definitions.js +37 -0
  9. package/dist/config-manager.d.ts +102 -0
  10. package/dist/config-manager.js +291 -0
  11. package/dist/config.d.ts +6 -0
  12. package/dist/config.js +10 -0
  13. package/dist/custom-stdio.d.ts +51 -0
  14. package/dist/custom-stdio.js +358 -0
  15. package/dist/data/official-app-ca.pem +12 -0
  16. package/dist/data/onboarding-prompts.json +70 -0
  17. package/dist/device/device-state.d.ts +13 -0
  18. package/dist/device/device-state.js +100 -0
  19. package/dist/device/device-status.d.ts +81 -0
  20. package/dist/device/device-status.js +156 -0
  21. package/dist/device/device.d.ts +16 -0
  22. package/dist/device/device.js +190 -0
  23. package/dist/device/execution-engine.d.ts +144 -0
  24. package/dist/device/execution-engine.js +195 -0
  25. package/dist/device/gateway-channel.d.ts +48 -0
  26. package/dist/device/gateway-channel.js +384 -0
  27. package/dist/device/gateway-config.d.ts +40 -0
  28. package/dist/device/gateway-config.js +207 -0
  29. package/dist/device/gateway-identity.d.ts +35 -0
  30. package/dist/device/gateway-identity.js +178 -0
  31. package/dist/device/gateway-pairing.d.ts +25 -0
  32. package/dist/device/gateway-pairing.js +105 -0
  33. package/dist/device/gateway-secure-transport.d.ts +25 -0
  34. package/dist/device/gateway-secure-transport.js +117 -0
  35. package/dist/device/gateway-tool-adapter.d.ts +17 -0
  36. package/dist/device/gateway-tool-adapter.js +284 -0
  37. package/dist/device/gateway-url-policy.d.ts +3 -0
  38. package/dist/device/gateway-url-policy.js +43 -0
  39. package/dist/device/linux-service.d.ts +70 -0
  40. package/dist/device/linux-service.js +268 -0
  41. package/dist/device/official-trust.d.ts +7 -0
  42. package/dist/device/official-trust.js +49 -0
  43. package/dist/device/project-inspection.d.ts +1 -0
  44. package/dist/device/project-inspection.js +180 -0
  45. package/dist/device/runtime-owner.d.ts +41 -0
  46. package/dist/device/runtime-owner.js +177 -0
  47. package/dist/device/windows-dpapi.d.ts +11 -0
  48. package/dist/device/windows-dpapi.js +67 -0
  49. package/dist/device/windows-service.d.ts +56 -0
  50. package/dist/device/windows-service.js +238 -0
  51. package/dist/error-handlers.d.ts +7 -0
  52. package/dist/error-handlers.js +15 -0
  53. package/dist/handlers/edit-search-handlers.d.ts +6 -0
  54. package/dist/handlers/edit-search-handlers.js +6 -0
  55. package/dist/handlers/filesystem-handlers.d.ts +38 -0
  56. package/dist/handlers/filesystem-handlers.js +440 -0
  57. package/dist/handlers/history-handlers.d.ts +12 -0
  58. package/dist/handlers/history-handlers.js +67 -0
  59. package/dist/handlers/index.d.ts +6 -0
  60. package/dist/handlers/index.js +7 -0
  61. package/dist/handlers/process-handlers.d.ts +9 -0
  62. package/dist/handlers/process-handlers.js +15 -0
  63. package/dist/handlers/search-handlers.d.ts +17 -0
  64. package/dist/handlers/search-handlers.js +224 -0
  65. package/dist/handlers/terminal-handlers.d.ts +21 -0
  66. package/dist/handlers/terminal-handlers.js +35 -0
  67. package/dist/index.d.ts +2 -0
  68. package/dist/index.js +138 -0
  69. package/dist/mcp-device.d.ts +2 -0
  70. package/dist/mcp-device.js +7 -0
  71. package/dist/npm-scripts/remote.d.ts +3 -0
  72. package/dist/npm-scripts/remote.js +314 -0
  73. package/dist/search-manager.d.ts +147 -0
  74. package/dist/search-manager.js +828 -0
  75. package/dist/server.d.ts +46 -0
  76. package/dist/server.js +1563 -0
  77. package/dist/terminal-manager.d.ts +104 -0
  78. package/dist/terminal-manager.js +635 -0
  79. package/dist/tools/config.d.ts +96 -0
  80. package/dist/tools/config.js +260 -0
  81. package/dist/tools/edit.d.ts +38 -0
  82. package/dist/tools/edit.js +435 -0
  83. package/dist/tools/feedback.d.ts +8 -0
  84. package/dist/tools/feedback.js +132 -0
  85. package/dist/tools/filesystem.d.ts +79 -0
  86. package/dist/tools/filesystem.js +947 -0
  87. package/dist/tools/fuzzySearch.d.ts +12 -0
  88. package/dist/tools/fuzzySearch.js +74 -0
  89. package/dist/tools/fuzzySearchCore.d.ts +52 -0
  90. package/dist/tools/fuzzySearchCore.js +125 -0
  91. package/dist/tools/improved-process-tools.d.ts +24 -0
  92. package/dist/tools/improved-process-tools.js +627 -0
  93. package/dist/tools/mime-types.d.ts +3 -0
  94. package/dist/tools/mime-types.js +28 -0
  95. package/dist/tools/pdf/extract-images.d.ts +34 -0
  96. package/dist/tools/pdf/extract-images.js +132 -0
  97. package/dist/tools/pdf/index.d.ts +6 -0
  98. package/dist/tools/pdf/index.js +3 -0
  99. package/dist/tools/pdf/lib/pdf2md.d.ts +36 -0
  100. package/dist/tools/pdf/lib/pdf2md.js +76 -0
  101. package/dist/tools/pdf/manipulations.d.ts +13 -0
  102. package/dist/tools/pdf/manipulations.js +99 -0
  103. package/dist/tools/pdf/markdown.d.ts +26 -0
  104. package/dist/tools/pdf/markdown.js +275 -0
  105. package/dist/tools/pdf/utils.d.ts +12 -0
  106. package/dist/tools/pdf/utils.js +34 -0
  107. package/dist/tools/process.d.ts +3 -0
  108. package/dist/tools/process.js +56 -0
  109. package/dist/tools/prompts.d.ts +28 -0
  110. package/dist/tools/prompts.js +259 -0
  111. package/dist/tools/schemas.d.ts +454 -0
  112. package/dist/tools/schemas.js +242 -0
  113. package/dist/tools/usage.d.ts +5 -0
  114. package/dist/tools/usage.js +24 -0
  115. package/dist/types.d.ts +83 -0
  116. package/dist/types.js +1 -0
  117. package/dist/ui/config-editor/config-editor-runtime.js +150 -0
  118. package/dist/ui/config-editor/index.html +13 -0
  119. package/dist/ui/config-editor/src/app.d.ts +43 -0
  120. package/dist/ui/config-editor/src/app.js +831 -0
  121. package/dist/ui/config-editor/src/array-modal.d.ts +19 -0
  122. package/dist/ui/config-editor/src/array-modal.js +185 -0
  123. package/dist/ui/config-editor/src/main.d.ts +1 -0
  124. package/dist/ui/config-editor/src/main.js +2 -0
  125. package/dist/ui/config-editor/styles.css +587 -0
  126. package/dist/ui/contracts.d.ts +14 -0
  127. package/dist/ui/contracts.js +21 -0
  128. package/dist/ui/file-preview/index.html +16 -0
  129. package/dist/ui/file-preview/preview-runtime.js +469 -0
  130. package/dist/ui/file-preview/shared/preview-file-types.d.ts +5 -0
  131. package/dist/ui/file-preview/shared/preview-file-types.js +59 -0
  132. package/dist/ui/file-preview/src/app.d.ts +4 -0
  133. package/dist/ui/file-preview/src/app.js +680 -0
  134. package/dist/ui/file-preview/src/components/code-viewer.d.ts +6 -0
  135. package/dist/ui/file-preview/src/components/code-viewer.js +73 -0
  136. package/dist/ui/file-preview/src/components/highlighting.d.ts +2 -0
  137. package/dist/ui/file-preview/src/components/highlighting.js +54 -0
  138. package/dist/ui/file-preview/src/components/html-renderer.d.ts +5 -0
  139. package/dist/ui/file-preview/src/components/html-renderer.js +47 -0
  140. package/dist/ui/file-preview/src/components/markdown-renderer.d.ts +1 -0
  141. package/dist/ui/file-preview/src/components/markdown-renderer.js +59 -0
  142. package/dist/ui/file-preview/src/directory-controller.d.ts +8 -0
  143. package/dist/ui/file-preview/src/directory-controller.js +240 -0
  144. package/dist/ui/file-preview/src/document-layout.d.ts +20 -0
  145. package/dist/ui/file-preview/src/document-layout.js +109 -0
  146. package/dist/ui/file-preview/src/document-outline.d.ts +17 -0
  147. package/dist/ui/file-preview/src/document-outline.js +97 -0
  148. package/dist/ui/file-preview/src/document-workspace.d.ts +19 -0
  149. package/dist/ui/file-preview/src/document-workspace.js +33 -0
  150. package/dist/ui/file-preview/src/file-type-handlers.d.ts +10 -0
  151. package/dist/ui/file-preview/src/file-type-handlers.js +109 -0
  152. package/dist/ui/file-preview/src/host/external-actions.d.ts +8 -0
  153. package/dist/ui/file-preview/src/host/external-actions.js +55 -0
  154. package/dist/ui/file-preview/src/host/selection-context.d.ts +9 -0
  155. package/dist/ui/file-preview/src/host/selection-context.js +106 -0
  156. package/dist/ui/file-preview/src/image-preview.d.ts +3 -0
  157. package/dist/ui/file-preview/src/image-preview.js +21 -0
  158. package/dist/ui/file-preview/src/main.d.ts +1 -0
  159. package/dist/ui/file-preview/src/main.js +5 -0
  160. package/dist/ui/file-preview/src/markdown/conflict-dialog.d.ts +40 -0
  161. package/dist/ui/file-preview/src/markdown/conflict-dialog.js +163 -0
  162. package/dist/ui/file-preview/src/markdown/controller.d.ts +44 -0
  163. package/dist/ui/file-preview/src/markdown/controller.js +1043 -0
  164. package/dist/ui/file-preview/src/markdown/editor.d.ts +131 -0
  165. package/dist/ui/file-preview/src/markdown/editor.js +1479 -0
  166. package/dist/ui/file-preview/src/markdown/linking.d.ts +16 -0
  167. package/dist/ui/file-preview/src/markdown/linking.js +228 -0
  168. package/dist/ui/file-preview/src/markdown/outline.d.ts +2 -0
  169. package/dist/ui/file-preview/src/markdown/outline.js +16 -0
  170. package/dist/ui/file-preview/src/markdown/parser.d.ts +30 -0
  171. package/dist/ui/file-preview/src/markdown/parser.js +38 -0
  172. package/dist/ui/file-preview/src/markdown/preview.d.ts +1 -0
  173. package/dist/ui/file-preview/src/markdown/preview.js +20 -0
  174. package/dist/ui/file-preview/src/markdown/slugify.d.ts +3 -0
  175. package/dist/ui/file-preview/src/markdown/slugify.js +31 -0
  176. package/dist/ui/file-preview/src/markdown/utils.d.ts +1 -0
  177. package/dist/ui/file-preview/src/markdown/utils.js +15 -0
  178. package/dist/ui/file-preview/src/model.d.ts +35 -0
  179. package/dist/ui/file-preview/src/model.js +1 -0
  180. package/dist/ui/file-preview/src/panel-actions.d.ts +17 -0
  181. package/dist/ui/file-preview/src/panel-actions.js +182 -0
  182. package/dist/ui/file-preview/src/path-utils.d.ts +6 -0
  183. package/dist/ui/file-preview/src/path-utils.js +64 -0
  184. package/dist/ui/file-preview/src/payload-utils.d.ts +11 -0
  185. package/dist/ui/file-preview/src/payload-utils.js +119 -0
  186. package/dist/ui/file-preview/src/types.d.ts +1 -0
  187. package/dist/ui/file-preview/src/types.js +1 -0
  188. package/dist/ui/file-preview/styles.css +1631 -0
  189. package/dist/ui/resources.d.ts +28 -0
  190. package/dist/ui/resources.js +86 -0
  191. package/dist/ui/shared/compact-row.d.ts +11 -0
  192. package/dist/ui/shared/compact-row.js +18 -0
  193. package/dist/ui/shared/escape-html.d.ts +4 -0
  194. package/dist/ui/shared/escape-html.js +11 -0
  195. package/dist/ui/shared/host-context.d.ts +15 -0
  196. package/dist/ui/shared/host-context.js +51 -0
  197. package/dist/ui/shared/tool-bridge.d.ts +30 -0
  198. package/dist/ui/shared/tool-bridge.js +137 -0
  199. package/dist/ui/shared/tool-shell.d.ts +25 -0
  200. package/dist/ui/shared/tool-shell.js +107 -0
  201. package/dist/ui/shared/ui-event-tracker.d.ts +9 -0
  202. package/dist/ui/shared/ui-event-tracker.js +27 -0
  203. package/dist/ui/shared/widget-state.d.ts +33 -0
  204. package/dist/ui/shared/widget-state.js +158 -0
  205. package/dist/utils/ab-test.d.ts +12 -0
  206. package/dist/utils/ab-test.js +104 -0
  207. package/dist/utils/capture.d.ts +35 -0
  208. package/dist/utils/capture.js +481 -0
  209. package/dist/utils/dockerPrompt.d.ts +17 -0
  210. package/dist/utils/dockerPrompt.js +53 -0
  211. package/dist/utils/feature-flags.d.ts +59 -0
  212. package/dist/utils/feature-flags.js +215 -0
  213. package/dist/utils/files/base.d.ts +173 -0
  214. package/dist/utils/files/base.js +5 -0
  215. package/dist/utils/files/binary.d.ts +21 -0
  216. package/dist/utils/files/binary.js +65 -0
  217. package/dist/utils/files/docx.d.ts +47 -0
  218. package/dist/utils/files/docx.js +679 -0
  219. package/dist/utils/files/excel.d.ts +24 -0
  220. package/dist/utils/files/excel.js +437 -0
  221. package/dist/utils/files/factory.d.ts +41 -0
  222. package/dist/utils/files/factory.js +113 -0
  223. package/dist/utils/files/image.d.ts +21 -0
  224. package/dist/utils/files/image.js +78 -0
  225. package/dist/utils/files/index.d.ts +10 -0
  226. package/dist/utils/files/index.js +13 -0
  227. package/dist/utils/files/pdf.d.ts +32 -0
  228. package/dist/utils/files/pdf.js +142 -0
  229. package/dist/utils/files/text.d.ts +63 -0
  230. package/dist/utils/files/text.js +370 -0
  231. package/dist/utils/fuzzySearchLogger.d.ts +30 -0
  232. package/dist/utils/fuzzySearchLogger.js +126 -0
  233. package/dist/utils/lineEndingHandler.d.ts +21 -0
  234. package/dist/utils/lineEndingHandler.js +77 -0
  235. package/dist/utils/logger.d.ts +32 -0
  236. package/dist/utils/logger.js +72 -0
  237. package/dist/utils/mcp-ui-ab-test.d.ts +13 -0
  238. package/dist/utils/mcp-ui-ab-test.js +62 -0
  239. package/dist/utils/open-browser.d.ts +9 -0
  240. package/dist/utils/open-browser.js +42 -0
  241. package/dist/utils/process-detection.d.ts +23 -0
  242. package/dist/utils/process-detection.js +150 -0
  243. package/dist/utils/ripgrep-resolver.d.ts +9 -0
  244. package/dist/utils/ripgrep-resolver.js +74 -0
  245. package/dist/utils/system-info.d.ts +71 -0
  246. package/dist/utils/system-info.js +712 -0
  247. package/dist/utils/toolHistory.d.ts +86 -0
  248. package/dist/utils/toolHistory.js +257 -0
  249. package/dist/utils/trackTools.d.ts +6 -0
  250. package/dist/utils/trackTools.js +54 -0
  251. package/dist/utils/unsupportedParams.d.ts +24 -0
  252. package/dist/utils/unsupportedParams.js +66 -0
  253. package/dist/utils/usageTracker.d.ts +133 -0
  254. package/dist/utils/usageTracker.js +477 -0
  255. package/dist/utils/welcome-onboarding.d.ts +11 -0
  256. package/dist/utils/welcome-onboarding.js +99 -0
  257. package/dist/utils/withTimeout.d.ts +28 -0
  258. package/dist/utils/withTimeout.js +85 -0
  259. package/dist/version.d.ts +1 -0
  260. package/dist/version.js +1 -0
  261. package/package.json +131 -0
@@ -0,0 +1,828 @@
1
+ import { spawn } from 'child_process';
2
+ import path from 'path';
3
+ import fs from 'fs/promises';
4
+ import { validatePath } from './tools/filesystem.js';
5
+ import { capture } from './utils/capture.js';
6
+ import { getRipgrepPath } from './utils/ripgrep-resolver.js';
7
+ import { isExcelFile } from './utils/files/index.js';
8
+ import PizZip from 'pizzip';
9
+ /**
10
+ * Search Session Manager - handles ripgrep processes like terminal sessions
11
+ * Supports both file search and content search with progressive results
12
+ */ export class SearchManager {
13
+ constructor() {
14
+ this.sessions = new Map();
15
+ this.sessionCounter = 0;
16
+ }
17
+ /**
18
+ * Start a new search session (like start_process)
19
+ * Returns immediately with initial state and results
20
+ */
21
+ async startSearch(options) {
22
+ const sessionId = `search_${++this.sessionCounter}_${Date.now()}`;
23
+ // Validate path first
24
+ const validPath = await validatePath(options.rootPath);
25
+ // Build ripgrep arguments
26
+ const args = this.buildRipgrepArgs({ ...options, rootPath: validPath });
27
+ // Get ripgrep path with fallback resolution
28
+ let rgPath;
29
+ try {
30
+ rgPath = await getRipgrepPath();
31
+ }
32
+ catch (err) {
33
+ throw new Error(`Failed to locate ripgrep binary: ${err instanceof Error ? err.message : String(err)}`);
34
+ }
35
+ // Start ripgrep process
36
+ const rgProcess = spawn(rgPath, args, { windowsHide: true }); // Prevent visible console windows on Windows
37
+ if (!rgProcess.pid) {
38
+ throw new Error('Failed to start ripgrep process');
39
+ }
40
+ // Create session
41
+ const session = {
42
+ id: sessionId,
43
+ process: rgProcess,
44
+ results: [],
45
+ isComplete: false,
46
+ isError: false,
47
+ startTime: Date.now(),
48
+ lastReadTime: Date.now(),
49
+ options,
50
+ buffer: '',
51
+ totalMatches: 0,
52
+ totalContextLines: 0
53
+ };
54
+ this.sessions.set(sessionId, session);
55
+ // Set up process event handlers
56
+ this.setupProcessHandlers(session);
57
+ // Start cleanup interval now that we have a session
58
+ startCleanupIfNeeded();
59
+ // Set up timeout if specified and auto-terminate
60
+ // For exact filename searches, use a shorter default timeout
61
+ const timeoutMs = options.timeout ?? (this.isExactFilename(options.pattern) ? 1500 : undefined);
62
+ let killTimer = null;
63
+ if (timeoutMs) {
64
+ killTimer = setTimeout(() => {
65
+ if (!session.isComplete && !session.process.killed) {
66
+ session.process.kill('SIGTERM');
67
+ }
68
+ }, timeoutMs);
69
+ }
70
+ // Clear timer on process completion
71
+ session.process.once('close', () => {
72
+ if (killTimer) {
73
+ clearTimeout(killTimer);
74
+ killTimer = null;
75
+ }
76
+ });
77
+ session.process.once('error', () => {
78
+ if (killTimer) {
79
+ clearTimeout(killTimer);
80
+ killTimer = null;
81
+ }
82
+ });
83
+ capture('search_session_started', {
84
+ sessionId,
85
+ searchType: options.searchType,
86
+ hasTimeout: !!timeoutMs,
87
+ timeoutMs,
88
+ requestedPath: options.rootPath,
89
+ validatedPath: validPath
90
+ });
91
+ // For content searches, only search Excel files when contextually relevant:
92
+ // - filePattern explicitly targets Excel files (*.xlsx, *.xls, etc.)
93
+ // - or rootPath is an Excel file itself
94
+ const shouldSearchExcel = options.searchType === 'content' &&
95
+ this.shouldIncludeExcelSearch(options.filePattern, validPath);
96
+ if (shouldSearchExcel) {
97
+ this.searchExcelFiles(validPath, options.pattern, options.ignoreCase !== false, options.maxResults, options.filePattern, // Pass filePattern to filter Excel files too
98
+ options.literalSearch // Respect literalSearch flag for Office files
99
+ ).then(excelResults => {
100
+ // Add Excel results to session (merged after initial response)
101
+ for (const result of excelResults) {
102
+ session.results.push(result);
103
+ session.totalMatches++;
104
+ }
105
+ }).catch((err) => {
106
+ // Log Excel search errors but don't fail the whole search
107
+ capture('excel_search_error', { error: err instanceof Error ? err.message : String(err) });
108
+ });
109
+ }
110
+ // For content searches, also search DOCX files
111
+ const shouldSearchDocx = options.searchType === 'content' &&
112
+ this.shouldIncludeDocxSearch(options.filePattern, validPath);
113
+ if (shouldSearchDocx) {
114
+ this.searchDocxFiles(validPath, options.pattern, options.ignoreCase !== false, options.maxResults, options.filePattern, options.literalSearch // Respect literalSearch flag for Office files
115
+ ).then(docxResults => {
116
+ for (const result of docxResults) {
117
+ session.results.push(result);
118
+ session.totalMatches++;
119
+ }
120
+ }).catch((err) => {
121
+ capture('docx_search_error', { error: err instanceof Error ? err.message : String(err) });
122
+ });
123
+ }
124
+ // Wait for first chunk of data or early completion instead of fixed delay
125
+ // Excel search runs in background and results are merged via readSearchResults
126
+ const firstChunk = new Promise(resolve => {
127
+ const onData = () => {
128
+ session.process.stdout?.off('data', onData);
129
+ resolve();
130
+ };
131
+ session.process.stdout?.once('data', onData);
132
+ setTimeout(resolve, 40); // cap at 40ms instead of 50-100ms
133
+ });
134
+ // Only wait for ripgrep first chunk - Excel results merge asynchronously
135
+ await firstChunk;
136
+ return {
137
+ sessionId,
138
+ isComplete: session.isComplete,
139
+ isError: session.isError,
140
+ results: [...session.results],
141
+ totalResults: session.totalMatches,
142
+ runtime: Date.now() - session.startTime
143
+ };
144
+ }
145
+ /**
146
+ * Read search results with offset-based pagination (like read_file)
147
+ * Supports both range reading and tail behavior
148
+ */
149
+ readSearchResults(sessionId, offset = 0, length = 100) {
150
+ const session = this.sessions.get(sessionId);
151
+ if (!session) {
152
+ throw new Error(`Search session ${sessionId} not found`);
153
+ }
154
+ // Get all results (excluding internal markers)
155
+ const allResults = session.results.filter(r => r.file !== '__LAST_READ_MARKER__');
156
+ // Handle negative offsets (tail behavior) - like file reading
157
+ if (offset < 0) {
158
+ const tailCount = Math.abs(offset);
159
+ const tailResults = allResults.slice(-tailCount);
160
+ return {
161
+ results: tailResults,
162
+ returnedCount: tailResults.length,
163
+ totalResults: session.totalMatches + session.totalContextLines,
164
+ totalMatches: session.totalMatches, // Actual matches only
165
+ isComplete: session.isComplete,
166
+ isError: session.isError && !!session.error?.trim(), // Only error if we have actual errors
167
+ error: session.error?.trim() || undefined,
168
+ hasMoreResults: false, // Tail always returns what's available
169
+ runtime: Date.now() - session.startTime,
170
+ wasIncomplete: session.wasIncomplete
171
+ };
172
+ }
173
+ // Handle positive offsets (range behavior) - like file reading
174
+ const slicedResults = allResults.slice(offset, offset + length);
175
+ const hasMoreResults = offset + length < allResults.length || !session.isComplete;
176
+ session.lastReadTime = Date.now();
177
+ return {
178
+ results: slicedResults,
179
+ returnedCount: slicedResults.length,
180
+ totalResults: session.totalMatches + session.totalContextLines,
181
+ totalMatches: session.totalMatches, // Actual matches only
182
+ isComplete: session.isComplete,
183
+ isError: session.isError && !!session.error?.trim(), // Only error if we have actual errors
184
+ error: session.error?.trim() || undefined,
185
+ hasMoreResults,
186
+ runtime: Date.now() - session.startTime,
187
+ wasIncomplete: session.wasIncomplete
188
+ };
189
+ }
190
+ /**
191
+ * Terminate a search session (like force_terminate)
192
+ */
193
+ terminateSearch(sessionId) {
194
+ const session = this.sessions.get(sessionId);
195
+ if (!session) {
196
+ return false;
197
+ }
198
+ if (!session.process.killed) {
199
+ session.process.kill('SIGTERM');
200
+ }
201
+ // Don't delete session immediately - let user read final results
202
+ // It will be cleaned up by cleanup process
203
+ return true;
204
+ }
205
+ /**
206
+ * Get list of active search sessions (like list_sessions)
207
+ */
208
+ listSearchSessions() {
209
+ return Array.from(this.sessions.values()).map(session => ({
210
+ id: session.id,
211
+ searchType: session.options.searchType,
212
+ pattern: session.options.pattern,
213
+ isComplete: session.isComplete,
214
+ isError: session.isError,
215
+ runtime: Date.now() - session.startTime,
216
+ totalResults: session.totalMatches + session.totalContextLines
217
+ }));
218
+ }
219
+ /**
220
+ * Search Excel files for content matches
221
+ * Called during content search to include Excel files alongside text files
222
+ * Searches ALL sheets in each Excel file (row-wise for cross-column matching)
223
+ *
224
+ * TODO: Refactor - Extract Excel search logic to separate module (src/utils/search/excel-search.ts)
225
+ * and inject into SearchManager, similar to how file handlers are structured in src/utils/files/
226
+ * This would allow adding other file type searches (PDF, etc.) without bloating search-manager.ts
227
+ */
228
+ async searchExcelFiles(rootPath, pattern, ignoreCase, maxResults, filePattern, _literalSearch) {
229
+ const results = [];
230
+ // Office file search always uses literal matching to prevent ReDoS.
231
+ // Regex patterns are treated as literal strings — this is intentional.
232
+ const searchTerm = ignoreCase ? pattern.toLowerCase() : pattern;
233
+ // Find Excel files recursively
234
+ let excelFiles = await this.findExcelFiles(rootPath);
235
+ // Filter by filePattern if provided
236
+ if (filePattern) {
237
+ const patterns = filePattern.split('|').map(p => p.trim()).filter(Boolean);
238
+ excelFiles = excelFiles.filter(filePath => {
239
+ const fileName = path.basename(filePath);
240
+ return patterns.some(pat => {
241
+ // Support glob-like patterns
242
+ if (pat.includes('*')) {
243
+ // Escape all regex metacharacters first (preserving * for glob expansion),
244
+ // then convert the remaining * wildcards to .* for glob matching.
245
+ // Without this, patterns like report(2024).xlsx or [draft].xlsx would be
246
+ // misinterpreted as regex groups/character-classes.
247
+ const regexPat = pat
248
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape metacharacters except *
249
+ .replace(/\*/g, '.*'); // glob * → regex .*
250
+ return new RegExp(`^${regexPat}$`, 'i').test(fileName);
251
+ }
252
+ // Exact match (case-insensitive)
253
+ return fileName.toLowerCase() === pat.toLowerCase();
254
+ });
255
+ });
256
+ }
257
+ // Dynamically import ExcelJS to search all sheets
258
+ const ExcelJS = await import('exceljs');
259
+ for (const filePath of excelFiles) {
260
+ if (maxResults && results.length >= maxResults)
261
+ break;
262
+ try {
263
+ const workbook = new ExcelJS.default.Workbook();
264
+ await workbook.xlsx.readFile(filePath);
265
+ // Search ALL sheets in the workbook (row-wise for speed and cross-column matching)
266
+ for (const worksheet of workbook.worksheets) {
267
+ if (maxResults && results.length >= maxResults)
268
+ break;
269
+ const sheetName = worksheet.name;
270
+ // Iterate through rows (faster than cell-by-cell)
271
+ worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
272
+ if (maxResults && results.length >= maxResults)
273
+ return;
274
+ // Build a concatenated string of all cell values in the row
275
+ const rowValues = [];
276
+ row.eachCell({ includeEmpty: false }, (cell) => {
277
+ if (cell.value === null || cell.value === undefined)
278
+ return;
279
+ let cellStr;
280
+ if (typeof cell.value === 'object') {
281
+ if ('result' in cell.value) {
282
+ cellStr = String(cell.value.result ?? '');
283
+ }
284
+ else if ('richText' in cell.value) {
285
+ cellStr = cell.value.richText.map((rt) => rt.text).join('');
286
+ }
287
+ else if ('text' in cell.value) {
288
+ cellStr = String(cell.value.text);
289
+ }
290
+ else {
291
+ cellStr = String(cell.value);
292
+ }
293
+ }
294
+ else {
295
+ cellStr = String(cell.value);
296
+ }
297
+ if (cellStr.trim()) {
298
+ rowValues.push(cellStr);
299
+ }
300
+ });
301
+ // Join all cell values with space for cross-column matching
302
+ const rowText = rowValues.join(' ');
303
+ const textToSearch = ignoreCase ? rowText.toLowerCase() : rowText;
304
+ const matchIndex = textToSearch.indexOf(searchTerm);
305
+ if (matchIndex !== -1) {
306
+ const matchContext = this.getMatchContext(rowText, matchIndex, searchTerm.length);
307
+ results.push({
308
+ file: `${filePath}:${sheetName}!Row${rowNumber}`,
309
+ line: rowNumber,
310
+ match: matchContext,
311
+ type: 'content'
312
+ });
313
+ }
314
+ });
315
+ }
316
+ }
317
+ catch (error) {
318
+ // Skip files that can't be read (permission issues, corrupted, etc.)
319
+ continue;
320
+ }
321
+ }
322
+ return results;
323
+ }
324
+ /**
325
+ * Find all Excel files in a directory recursively
326
+ */
327
+ async findExcelFiles(rootPath) {
328
+ const excelFiles = [];
329
+ async function walk(dir) {
330
+ try {
331
+ const entries = await fs.readdir(dir, { withFileTypes: true });
332
+ for (const entry of entries) {
333
+ const fullPath = path.join(dir, entry.name);
334
+ if (entry.isDirectory()) {
335
+ // Skip node_modules, .git, etc.
336
+ if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
337
+ await walk(fullPath);
338
+ }
339
+ }
340
+ else if (entry.isFile() && isExcelFile(entry.name)) {
341
+ excelFiles.push(fullPath);
342
+ }
343
+ }
344
+ }
345
+ catch {
346
+ // Skip directories we can't read
347
+ }
348
+ }
349
+ // Check if rootPath is a file or directory
350
+ try {
351
+ const stats = await fs.stat(rootPath);
352
+ if (stats.isFile() && isExcelFile(rootPath)) {
353
+ return [rootPath];
354
+ }
355
+ else if (stats.isDirectory()) {
356
+ await walk(rootPath);
357
+ }
358
+ }
359
+ catch {
360
+ // Path doesn't exist or can't be accessed
361
+ }
362
+ return excelFiles;
363
+ }
364
+ /**
365
+ * Determine if DOCX search should be included based on context
366
+ */
367
+ shouldIncludeDocxSearch(filePattern, rootPath) {
368
+ const docxExtensions = ['.docx'];
369
+ if (rootPath) {
370
+ const lowerPath = rootPath.toLowerCase();
371
+ if (docxExtensions.some(ext => lowerPath.endsWith(ext))) {
372
+ return true;
373
+ }
374
+ }
375
+ if (filePattern) {
376
+ const lowerPattern = filePattern.toLowerCase();
377
+ if (docxExtensions.some(ext => lowerPattern.includes(`*${ext}`) || lowerPattern.endsWith(ext))) {
378
+ return true;
379
+ }
380
+ }
381
+ return false;
382
+ }
383
+ /**
384
+ * Search DOCX files for content matches
385
+ * Extracts <w:t> text from document.xml and searches it
386
+ */
387
+ async searchDocxFiles(rootPath, pattern, ignoreCase, maxResults, filePattern, _literalSearch) {
388
+ const results = [];
389
+ // Office file search always uses literal matching to prevent ReDoS.
390
+ // Regex patterns are treated as literal strings — this is intentional.
391
+ const searchTerm = ignoreCase ? pattern.toLowerCase() : pattern;
392
+ let docxFiles = await this.findDocxFiles(rootPath);
393
+ if (filePattern) {
394
+ const patterns = filePattern.split('|').map(p => p.trim()).filter(Boolean);
395
+ docxFiles = docxFiles.filter(filePath => {
396
+ const fileName = path.basename(filePath);
397
+ return patterns.some(pat => {
398
+ if (pat.includes('*')) {
399
+ const regexPat = pat
400
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape metacharacters except *
401
+ .replace(/\*/g, '.*'); // glob * → regex .*
402
+ return new RegExp(`^${regexPat}$`, 'i').test(fileName);
403
+ }
404
+ return fileName.toLowerCase() === pat.toLowerCase();
405
+ });
406
+ });
407
+ }
408
+ for (const filePath of docxFiles) {
409
+ if (maxResults && results.length >= maxResults)
410
+ break;
411
+ try {
412
+ const buf = await fs.readFile(filePath);
413
+ const zip = new PizZip(buf);
414
+ // Search all XML parts that can contain text
415
+ const xmlParts = ['word/document.xml', 'word/header1.xml', 'word/header2.xml',
416
+ 'word/header3.xml', 'word/footer1.xml', 'word/footer2.xml', 'word/footer3.xml'];
417
+ for (const xmlPath of xmlParts) {
418
+ if (maxResults && results.length >= maxResults)
419
+ break;
420
+ const file = zip.file(xmlPath);
421
+ if (!file)
422
+ continue;
423
+ const xml = file.asText();
424
+ // Extract all <w:t> text with position tracking
425
+ const wtRe = /<w:t(?:\s[^>]*)?>([^<]*)<\/w:t>/g;
426
+ let m;
427
+ let lineNum = 0;
428
+ while ((m = wtRe.exec(xml)) !== null) {
429
+ if (maxResults && results.length >= maxResults)
430
+ break;
431
+ const text = m[1];
432
+ if (!text || !text.trim())
433
+ continue;
434
+ lineNum++;
435
+ const textToSearch = ignoreCase ? text.toLowerCase() : text;
436
+ const matchIndex = textToSearch.indexOf(searchTerm);
437
+ if (matchIndex !== -1) {
438
+ const matchContext = this.getMatchContext(text, matchIndex, searchTerm.length);
439
+ const partName = xmlPath === 'word/document.xml' ? '' : `:${xmlPath.replace('word/', '')}`;
440
+ results.push({
441
+ file: `${filePath}${partName}`,
442
+ line: lineNum,
443
+ match: matchContext,
444
+ type: 'content'
445
+ });
446
+ }
447
+ }
448
+ }
449
+ }
450
+ catch {
451
+ continue;
452
+ }
453
+ }
454
+ return results;
455
+ }
456
+ /**
457
+ * Find all DOCX files in a directory recursively
458
+ */
459
+ async findDocxFiles(rootPath) {
460
+ const docxFiles = [];
461
+ const isDocx = (name) => name.toLowerCase().endsWith('.docx');
462
+ async function walk(dir) {
463
+ try {
464
+ const entries = await fs.readdir(dir, { withFileTypes: true });
465
+ for (const entry of entries) {
466
+ const fullPath = path.join(dir, entry.name);
467
+ if (entry.isDirectory()) {
468
+ if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
469
+ await walk(fullPath);
470
+ }
471
+ }
472
+ else if (entry.isFile() && isDocx(entry.name)) {
473
+ docxFiles.push(fullPath);
474
+ }
475
+ }
476
+ }
477
+ catch { /* skip */ }
478
+ }
479
+ try {
480
+ const stats = await fs.stat(rootPath);
481
+ if (stats.isFile() && isDocx(rootPath)) {
482
+ return [rootPath];
483
+ }
484
+ else if (stats.isDirectory()) {
485
+ await walk(rootPath);
486
+ }
487
+ }
488
+ catch { /* skip */ }
489
+ return docxFiles;
490
+ }
491
+ /**
492
+ * Extract context around a match for display (show surrounding text)
493
+ */
494
+ getMatchContext(text, matchStart, matchLength) {
495
+ const contextChars = 50; // chars before and after match
496
+ const start = Math.max(0, matchStart - contextChars);
497
+ const end = Math.min(text.length, matchStart + matchLength + contextChars);
498
+ let context = text.substring(start, end);
499
+ // Add ellipsis if truncated
500
+ if (start > 0)
501
+ context = '...' + context;
502
+ if (end < text.length)
503
+ context = context + '...';
504
+ return context;
505
+ }
506
+ /**
507
+ * Clean up completed sessions older than specified time
508
+ * Called automatically by cleanup interval
509
+ */
510
+ cleanupSessions(maxAge = 5 * 60 * 1000) {
511
+ const cutoffTime = Date.now() - maxAge;
512
+ for (const [sessionId, session] of this.sessions) {
513
+ if (session.isComplete && session.lastReadTime < cutoffTime) {
514
+ this.sessions.delete(sessionId);
515
+ }
516
+ }
517
+ }
518
+ /**
519
+ * Get total number of active sessions (excluding completed ones)
520
+ */
521
+ getActiveSessionCount() {
522
+ return Array.from(this.sessions.values()).filter(session => !session.isComplete).length;
523
+ }
524
+ /**
525
+ * Detect if pattern looks like an exact filename
526
+ * (has file extension and no glob wildcards)
527
+ */
528
+ isExactFilename(pattern) {
529
+ return /\.[a-zA-Z0-9]+$/.test(pattern) &&
530
+ !this.isGlobPattern(pattern);
531
+ }
532
+ /**
533
+ * Detect if pattern contains glob wildcards
534
+ */
535
+ isGlobPattern(pattern) {
536
+ return pattern.includes('*') ||
537
+ pattern.includes('?') ||
538
+ pattern.includes('[') ||
539
+ pattern.includes('{') ||
540
+ pattern.includes(']') ||
541
+ pattern.includes('}');
542
+ }
543
+ /**
544
+ * Determine if Excel search should be included based on context
545
+ * Only searches Excel files when:
546
+ * - filePattern explicitly targets Excel files (*.xlsx, *.xls, *.xlsm, *.xlsb)
547
+ * - or the rootPath itself is an Excel file
548
+ */
549
+ shouldIncludeExcelSearch(filePattern, rootPath) {
550
+ const excelExtensions = ['.xlsx', '.xls', '.xlsm', '.xlsb'];
551
+ // Check if rootPath is an Excel file
552
+ if (rootPath) {
553
+ const lowerPath = rootPath.toLowerCase();
554
+ if (excelExtensions.some(ext => lowerPath.endsWith(ext))) {
555
+ return true;
556
+ }
557
+ }
558
+ // Check if filePattern targets Excel files
559
+ if (filePattern) {
560
+ const lowerPattern = filePattern.toLowerCase();
561
+ // Check for patterns like *.xlsx, *.xls, or explicit Excel extensions
562
+ if (excelExtensions.some(ext => lowerPattern.includes(`*${ext}`) ||
563
+ lowerPattern.endsWith(ext))) {
564
+ return true;
565
+ }
566
+ }
567
+ return false;
568
+ }
569
+ buildRipgrepArgs(options) {
570
+ const args = [];
571
+ if (options.searchType === 'content') {
572
+ // Content search mode
573
+ args.push('--json', '--line-number');
574
+ // Add literal search support for content searches
575
+ if (options.literalSearch) {
576
+ args.push('-F'); // Fixed string matching (literal)
577
+ }
578
+ if (options.contextLines && options.contextLines > 0) {
579
+ args.push('-C', options.contextLines.toString());
580
+ }
581
+ }
582
+ else {
583
+ // File search mode
584
+ args.push('--files');
585
+ }
586
+ // Case-insensitive: content searches use -i flag, file searches use --iglob
587
+ if (options.searchType === 'content' && options.ignoreCase !== false) {
588
+ args.push('-i');
589
+ }
590
+ if (options.includeHidden) {
591
+ args.push('--hidden');
592
+ }
593
+ if (options.maxResults && options.maxResults > 0) {
594
+ args.push('-m', options.maxResults.toString());
595
+ }
596
+ // File pattern filtering (for file type restrictions like *.js, *.d.ts)
597
+ if (options.filePattern) {
598
+ const patterns = options.filePattern
599
+ .split('|')
600
+ .map(p => p.trim())
601
+ .filter(Boolean);
602
+ for (const p of patterns) {
603
+ if (options.searchType === 'content') {
604
+ args.push('-g', p);
605
+ }
606
+ else {
607
+ // For file search: use --iglob for case-insensitive or --glob for case-sensitive
608
+ if (options.ignoreCase !== false) {
609
+ args.push('--iglob', p);
610
+ }
611
+ else {
612
+ args.push('--glob', p);
613
+ }
614
+ }
615
+ }
616
+ }
617
+ // Handle the main search pattern
618
+ if (options.searchType === 'files') {
619
+ // For file search: determine how to treat the pattern
620
+ const globFlag = options.ignoreCase !== false ? '--iglob' : '--glob';
621
+ if (this.isExactFilename(options.pattern)) {
622
+ // Exact filename: use appropriate glob flag with the exact pattern
623
+ args.push(globFlag, options.pattern);
624
+ }
625
+ else if (this.isGlobPattern(options.pattern)) {
626
+ // Already a glob pattern: use appropriate glob flag as-is
627
+ args.push(globFlag, options.pattern);
628
+ }
629
+ else {
630
+ // Substring/fuzzy search: wrap with wildcards
631
+ args.push(globFlag, `*${options.pattern}*`);
632
+ }
633
+ // Add the root path for file mode
634
+ args.push(options.rootPath);
635
+ }
636
+ else {
637
+ // Content search: terminate options before the pattern to prevent
638
+ // patterns starting with '-' being interpreted as flags
639
+ args.push('--', options.pattern, options.rootPath);
640
+ }
641
+ return args;
642
+ }
643
+ setupProcessHandlers(session) {
644
+ const { process } = session;
645
+ process.stdout?.on('data', (data) => {
646
+ session.buffer += data.toString();
647
+ this.processBufferedOutput(session);
648
+ });
649
+ process.stderr?.on('data', (data) => {
650
+ const errorText = data.toString();
651
+ // Store error text for potential user display, but don't capture individual errors
652
+ // We'll capture incomplete search status in the completion event instead
653
+ session.error = (session.error || '') + errorText;
654
+ // Filter meaningful errors
655
+ const filteredErrors = errorText
656
+ .split('\n')
657
+ .filter(line => {
658
+ const trimmed = line.trim();
659
+ // Skip empty lines and lines with just symbols/numbers/colons
660
+ if (!trimmed || trimmed.match(/^[\)\(\s\d:]*$/))
661
+ return false;
662
+ // Skip all ripgrep system errors that start with "rg:"
663
+ if (trimmed.startsWith('rg:'))
664
+ return false;
665
+ return true;
666
+ });
667
+ // Only add to session.error if there are actual meaningful errors after filtering
668
+ if (filteredErrors.length > 0) {
669
+ const meaningfulErrors = filteredErrors.join('\n').trim();
670
+ if (meaningfulErrors) {
671
+ session.error = (session.error || '') + meaningfulErrors + '\n';
672
+ capture('search_session_error', {
673
+ sessionId: session.id,
674
+ error: meaningfulErrors.substring(0, 200)
675
+ });
676
+ }
677
+ }
678
+ });
679
+ process.on('close', (code) => {
680
+ // Process any remaining buffer content
681
+ if (session.buffer.trim()) {
682
+ this.processBufferedOutput(session, true);
683
+ }
684
+ session.isComplete = true;
685
+ // Track if search was incomplete due to access issues
686
+ // Ripgrep exit code 2 means "some files couldn't be searched"
687
+ if (code === 2) {
688
+ session.wasIncomplete = true;
689
+ }
690
+ // Only treat as error if:
691
+ // 1. Unexpected exit code (not 0, 1, or 2) AND
692
+ // 2. We have meaningful errors after filtering AND
693
+ // 3. We found no results at all
694
+ if (code !== 0 && code !== 1 && code !== 2) {
695
+ // Codes 0=success, 1=no matches, 2=some files couldn't be searched
696
+ if (session.error?.trim() && session.totalMatches === 0) {
697
+ session.isError = true;
698
+ session.error = session.error || `ripgrep exited with code ${code}`;
699
+ }
700
+ }
701
+ // If we have results, don't mark as error even if there were permission issues
702
+ if (session.totalMatches > 0) {
703
+ session.isError = false;
704
+ }
705
+ capture('search_session_completed', {
706
+ sessionId: session.id,
707
+ exitCode: code,
708
+ totalResults: session.totalMatches + session.totalContextLines,
709
+ totalMatches: session.totalMatches,
710
+ runtime: Date.now() - session.startTime,
711
+ wasIncomplete: session.wasIncomplete || false // NEW: Track incomplete searches
712
+ });
713
+ // Rely on cleanupSessions(maxAge) only; no per-session timer
714
+ });
715
+ process.on('error', (error) => {
716
+ session.isComplete = true;
717
+ session.isError = true;
718
+ session.error = `Process error: ${error.message}`;
719
+ // Rely on cleanupSessions(maxAge) only; no per-session timer
720
+ });
721
+ }
722
+ processBufferedOutput(session, isFinal = false) {
723
+ const lines = session.buffer.split('\n');
724
+ // Keep the last incomplete line in the buffer unless this is final processing
725
+ if (!isFinal) {
726
+ session.buffer = lines.pop() || '';
727
+ }
728
+ else {
729
+ session.buffer = '';
730
+ }
731
+ for (const line of lines) {
732
+ if (!line.trim())
733
+ continue;
734
+ const result = this.parseLine(line, session.options.searchType);
735
+ if (result) {
736
+ session.results.push(result);
737
+ // Separate counting of matches vs context lines
738
+ if (result.type === 'content' && line.includes('"type":"context"')) {
739
+ session.totalContextLines++;
740
+ }
741
+ else {
742
+ session.totalMatches++;
743
+ }
744
+ // Early termination for exact filename matches (if enabled)
745
+ if (session.options.earlyTermination !== false && // Default to true
746
+ session.options.searchType === 'files' &&
747
+ this.isExactFilename(session.options.pattern)) {
748
+ const pat = path.normalize(session.options.pattern);
749
+ const filePath = path.normalize(result.file);
750
+ const ignoreCase = session.options.ignoreCase !== false;
751
+ const ends = ignoreCase
752
+ ? filePath.toLowerCase().endsWith(pat.toLowerCase())
753
+ : filePath.endsWith(pat);
754
+ if (ends) {
755
+ // Found exact match, terminate search early
756
+ setTimeout(() => {
757
+ if (!session.process.killed) {
758
+ session.process.kill('SIGTERM');
759
+ }
760
+ }, 100); // Small delay to allow any remaining results
761
+ break;
762
+ }
763
+ }
764
+ }
765
+ }
766
+ }
767
+ parseLine(line, searchType) {
768
+ if (searchType === 'content') {
769
+ // Parse JSON output from content search
770
+ try {
771
+ const parsed = JSON.parse(line);
772
+ if (parsed.type === 'match') {
773
+ // Handle multiple submatches per line - return first submatch
774
+ const submatch = parsed.data?.submatches?.[0];
775
+ return {
776
+ file: parsed.data.path.text,
777
+ line: parsed.data.line_number,
778
+ match: submatch?.match?.text || parsed.data.lines.text,
779
+ type: 'content'
780
+ };
781
+ }
782
+ if (parsed.type === 'context') {
783
+ return {
784
+ file: parsed.data.path.text,
785
+ line: parsed.data.line_number,
786
+ match: parsed.data.lines.text.trim(),
787
+ type: 'content'
788
+ };
789
+ }
790
+ // Handle summary to reconcile totals
791
+ if (parsed.type === 'summary') {
792
+ // Optional: could reconcile totalMatches with parsed.data.stats?.matchedLines
793
+ return null;
794
+ }
795
+ return null;
796
+ }
797
+ catch (error) {
798
+ // Skip invalid JSON lines
799
+ return null;
800
+ }
801
+ }
802
+ else {
803
+ // File search - each line is a file path
804
+ return {
805
+ file: line.trim(),
806
+ type: 'file'
807
+ };
808
+ }
809
+ }
810
+ }
811
+ // Global search manager instance
812
+ export const searchManager = new SearchManager();
813
+ // Cleanup management - run on fixed schedule
814
+ let cleanupInterval = null;
815
+ /**
816
+ * Start cleanup interval - now runs on fixed schedule
817
+ */
818
+ function startCleanupIfNeeded() {
819
+ if (!cleanupInterval) {
820
+ cleanupInterval = setInterval(() => {
821
+ searchManager.cleanupSessions();
822
+ }, 5 * 60 * 1000);
823
+ // Also check immediately after a short delay (let search process finish)
824
+ setTimeout(() => {
825
+ searchManager.cleanupSessions();
826
+ }, 1000);
827
+ }
828
+ }