shellmate-cli 3.0.0__py3-none-any.whl

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 (106) hide show
  1. shellmate/__init__.py +0 -0
  2. shellmate/adapters/__init__.py +67 -0
  3. shellmate/adapters/bash_adapter.sh +7 -0
  4. shellmate/adapters/ble_adapter.sh +350 -0
  5. shellmate/adapters/powershell_adapter.ps1 +417 -0
  6. shellmate/adapters/powershell_predictor.ps1 +97 -0
  7. shellmate/adapters/zsh_adapter.zsh +8 -0
  8. shellmate/agents/__init__.py +23 -0
  9. shellmate/agents/base.py +248 -0
  10. shellmate/agents/chain_patterns.yaml +98 -0
  11. shellmate/agents/command_chain.py +322 -0
  12. shellmate/agents/context_gatherer.py +175 -0
  13. shellmate/agents/environment.py +363 -0
  14. shellmate/agents/librarian.py +502 -0
  15. shellmate/agents/query.py +415 -0
  16. shellmate/agents/repomix_backend.py +171 -0
  17. shellmate/agents/watcher.py +266 -0
  18. shellmate/cache/__init__.py +0 -0
  19. shellmate/cache/semantic_cache.py +41 -0
  20. shellmate/cli/__init__.py +0 -0
  21. shellmate/cli/commands/__init__.py +27 -0
  22. shellmate/cli/commands/analyze.py +5 -0
  23. shellmate/cli/commands/config.py +319 -0
  24. shellmate/cli/commands/configure.py +118 -0
  25. shellmate/cli/commands/disable.py +11 -0
  26. shellmate/cli/commands/enable.py +49 -0
  27. shellmate/cli/commands/init.py +412 -0
  28. shellmate/cli/commands/install.py +242 -0
  29. shellmate/cli/commands/migrate.py +147 -0
  30. shellmate/cli/commands/status.py +31 -0
  31. shellmate/cli/commands/uninstall.py +78 -0
  32. shellmate/cli/main.py +114 -0
  33. shellmate/cli/parser.py +184 -0
  34. shellmate/cli/version.py +1 -0
  35. shellmate/completion/__init__.py +4 -0
  36. shellmate/completion/bash_adapter.py +145 -0
  37. shellmate/config/__init__.py +33 -0
  38. shellmate/config/credentials.py +120 -0
  39. shellmate/config/defaults.py +88 -0
  40. shellmate/config/manager.py +94 -0
  41. shellmate/config/paths.py +77 -0
  42. shellmate/config/secrets.py +66 -0
  43. shellmate/daemon/__init__.py +0 -0
  44. shellmate/daemon/lifecycle.py +308 -0
  45. shellmate/daemon/observability.py +82 -0
  46. shellmate/daemon/pipeline.py +119 -0
  47. shellmate/daemon/server.py +605 -0
  48. shellmate/daemon/session.py +170 -0
  49. shellmate/daemon/startup_ui.py +43 -0
  50. shellmate/environment/__init__.py +0 -0
  51. shellmate/environment/detector.py +130 -0
  52. shellmate/ipc/__init__.py +0 -0
  53. shellmate/ipc/client.py +210 -0
  54. shellmate/ipc/ps_client.py +190 -0
  55. shellmate/orchestration/__init__.py +3 -0
  56. shellmate/orchestration/analysis_graph.py +1274 -0
  57. shellmate/orchestration/state.py +35 -0
  58. shellmate/parser/__init__.py +0 -0
  59. shellmate/parser/command_parser.py +111 -0
  60. shellmate/plugins/__init__.py +0 -0
  61. shellmate/plugins/base.py +80 -0
  62. shellmate/plugins/loader.py +41 -0
  63. shellmate/providers/__init__.py +0 -0
  64. shellmate/providers/base.py +97 -0
  65. shellmate/providers/groq_command_guide.py +108 -0
  66. shellmate/providers/groq_provider.py +127 -0
  67. shellmate/providers/groq_supervisor.py +78 -0
  68. shellmate/providers/openrouter_provider.py +149 -0
  69. shellmate/providers/router.py +272 -0
  70. shellmate/providers/validate_keys.py +98 -0
  71. shellmate/providers/xai_provider.py +142 -0
  72. shellmate/rules/__init__.py +0 -0
  73. shellmate/rules/catastrophic.yaml +96 -0
  74. shellmate/rules/catastrophic_matcher.py +167 -0
  75. shellmate/rules/definitions/__init__.py +0 -0
  76. shellmate/rules/definitions/file_deletion.yaml +54 -0
  77. shellmate/rules/definitions/filesystem.yaml +43 -0
  78. shellmate/rules/definitions/network_execution.yaml +46 -0
  79. shellmate/rules/definitions/permissions.yaml +54 -0
  80. shellmate/rules/definitions/process_control.yaml +37 -0
  81. shellmate/rules/definitions/system_destruction.yaml +67 -0
  82. shellmate/rules/engine.py +200 -0
  83. shellmate/sanitizer/__init__.py +0 -0
  84. shellmate/sanitizer/typo_detector.py +114 -0
  85. shellmate/ui/__init__.py +10 -0
  86. shellmate/ui/analysis.py +164 -0
  87. shellmate/ui/annotations.py +232 -0
  88. shellmate/ui/banner.py +49 -0
  89. shellmate/ui/progress.py +126 -0
  90. shellmate/ui/runtime/__init__.py +15 -0
  91. shellmate/ui/runtime/console_factory.py +65 -0
  92. shellmate/ui/runtime/live_scene.py +116 -0
  93. shellmate/ui/runtime/motion_policy.py +69 -0
  94. shellmate/ui/runtime/tokens.py +47 -0
  95. shellmate/ui/scenes/__init__.py +24 -0
  96. shellmate/ui/scenes/enable.py +316 -0
  97. shellmate/ui/scenes/help.py +53 -0
  98. shellmate/ui/scenes/stages.py +128 -0
  99. shellmate/ui/scenes/status.py +181 -0
  100. shellmate/ui/status.py +3 -0
  101. shellmate_cli-3.0.0.dist-info/METADATA +352 -0
  102. shellmate_cli-3.0.0.dist-info/RECORD +106 -0
  103. shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
  104. shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
  105. shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
  106. shellmate_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,415 @@
1
+ """
2
+ QueryAgent: Processes user queries about command analysis.
3
+
4
+ Handles queries like [?why], [?what-if], [?alternatives], [?history], [?impact]
5
+ and provides context-aware responses based on AnalysisState.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from shellmate.agents.base import Agent, AgentRequest, AgentResponse
15
+ from shellmate.orchestration.state import AnalysisState
16
+
17
+
18
+ class QueryAgent(Agent):
19
+ """Processes user queries about command analysis."""
20
+
21
+ def __init__(self):
22
+ super().__init__(name="query")
23
+ self.query_handlers = {
24
+ "why": self._answer_why,
25
+ "what-if": self._answer_what_if,
26
+ "what if": self._answer_what_if,
27
+ "alternatives": self._answer_alternatives,
28
+ "history": self._answer_history,
29
+ "impact": self._answer_impact,
30
+ "did i": self._answer_did_i,
31
+ }
32
+
33
+ async def initialize(self) -> None:
34
+ """Initialize the agent."""
35
+ pass
36
+
37
+ async def shutdown(self) -> None:
38
+ """Shutdown the agent."""
39
+ pass
40
+
41
+ async def process(self, request: AgentRequest) -> AgentResponse:
42
+ """Process agent requests by dispatching to appropriate methods."""
43
+ start_time = time.time()
44
+
45
+ try:
46
+ if request.command == "process_queries":
47
+ queries = request.data.get("queries", [])
48
+ state = request.data.get("state", {})
49
+ responses = await self.process_queries(queries, state)
50
+ latency = (time.time() - start_time) * 1000
51
+ return AgentResponse(
52
+ request.request_id,
53
+ self.name,
54
+ status="ok",
55
+ data={"responses": responses},
56
+ latency_ms=latency,
57
+ )
58
+ else:
59
+ return AgentResponse(
60
+ request.request_id,
61
+ self.name,
62
+ status="error",
63
+ error=f"Unknown command: {request.command}",
64
+ )
65
+ except Exception as e:
66
+ latency = (time.time() - start_time) * 1000
67
+ return AgentResponse(
68
+ request.request_id,
69
+ self.name,
70
+ status="error",
71
+ error=str(e),
72
+ latency_ms=latency,
73
+ )
74
+
75
+ async def process_queries(
76
+ self, queries: List[str], state: AnalysisState
77
+ ) -> Dict[str, str]:
78
+ """
79
+ Process all user queries and return responses with caching.
80
+
81
+ Args:
82
+ queries: List of query strings
83
+ state: Current AnalysisState with all context
84
+
85
+ Returns:
86
+ Dictionary mapping queries to responses
87
+ """
88
+ responses = {}
89
+ cache = self._load_cache()
90
+ command = state.get("command", "")
91
+
92
+ for query in queries:
93
+ # Check cache first
94
+ cache_key = f"{command}::{query}"
95
+ if cache_key in cache:
96
+ responses[query] = cache[cache_key]
97
+ continue
98
+
99
+ # Process query if not cached
100
+ query_lower = query.lower().strip()
101
+
102
+ # Match query to handler
103
+ handler = None
104
+ for key, func in self.query_handlers.items():
105
+ if query_lower.startswith(key) or key in query_lower:
106
+ handler = func
107
+ break
108
+
109
+ if handler:
110
+ try:
111
+ response = await handler(query, state)
112
+ responses[query] = response
113
+ # Cache the response
114
+ cache[cache_key] = response
115
+ except Exception as e:
116
+ responses[query] = f"Unable to answer: {str(e)}"
117
+ else:
118
+ response = (
119
+ "I don't understand this question. "
120
+ "Try: [?why], [?what-if], [?alternatives], [?history], [?impact]"
121
+ )
122
+ responses[query] = response
123
+ cache[cache_key] = response
124
+
125
+ # Save updated cache
126
+ self._save_cache(cache)
127
+ return responses
128
+
129
+ async def _answer_why(self, query: str, state: AnalysisState) -> str:
130
+ """Explain why command is risky."""
131
+ # Get risk level (correction C-1: key is "risk", not "risk_level")
132
+ risk_analysis = state.get("risk_analysis") or {}
133
+ risk_level = risk_analysis.get("risk", "UNKNOWN")
134
+ env_context = state.get("environment_context", {})
135
+ chain_patterns = state.get("chain_patterns", {})
136
+ risk_factors = state.get("risk_factors", {})
137
+
138
+ reasons = [f"This command is {risk_level} risk because:"]
139
+
140
+ # Environment factors
141
+ env_type = env_context.get("type", "unknown")
142
+ if env_type == "production":
143
+ reasons.append("• Running in production environment")
144
+ elif env_type == "staging":
145
+ reasons.append("• Running in staging environment")
146
+
147
+ time_ctx = env_context.get("time", {})
148
+ if time_ctx.get("is_business_hours"):
149
+ hour = time_ctx.get("hour", 0)
150
+ reasons.append(f"• Business hours ({hour}:00)")
151
+
152
+ blast = env_context.get("blast_radius", {})
153
+ if blast.get("is_multi_user"):
154
+ user_count = blast.get("active_users", 0)
155
+ reasons.append(f"• {user_count} other users active")
156
+
157
+ services = blast.get("critical_services", [])
158
+ if services:
159
+ reasons.append(f"• Critical services running: {', '.join(services)}")
160
+
161
+ resources = env_context.get("resources", {})
162
+ if resources.get("disk_low"):
163
+ reasons.append(
164
+ f"• Low disk space ({resources.get('disk_free_percent', 0)}% free)"
165
+ )
166
+ if resources.get("memory_high"):
167
+ reasons.append(
168
+ f"• High memory usage ({resources.get('memory_used_percent', 0)}%)"
169
+ )
170
+
171
+ # Chain factors
172
+ for pattern in chain_patterns.get("broken", []):
173
+ reasons.append(f"• Workflow broken: {pattern['description']}")
174
+
175
+ for pattern in chain_patterns.get("dangerous_escalations", []):
176
+ reasons.append(f"• Dangerous escalation: {pattern['description']}")
177
+
178
+ # Risk breakdown
179
+ if risk_factors:
180
+ reasons.append("")
181
+ reasons.append("Risk Breakdown:")
182
+ for factor, weight in risk_factors.items():
183
+ if weight > 0:
184
+ percentage = int(weight * 100)
185
+ reasons.append(f" {factor.title()}: {percentage}%")
186
+
187
+ # Add primary reason from analysis
188
+ if risk_analysis.get("reason"):
189
+ reasons.append("")
190
+ reasons.append(f"Primary reason: {risk_analysis['reason']}")
191
+
192
+ return "\n".join(reasons)
193
+
194
+ async def _answer_what_if(self, query: str, state: AnalysisState) -> str:
195
+ """Answer '[?what-if]' using consequence_preview (hypothetical outcome)."""
196
+ command = state.get("command") or "this command"
197
+ consequence = state.get("consequence_preview") or {}
198
+ risk_analysis = state.get("risk_analysis") or {}
199
+ env_context = state.get("environment_context") or {}
200
+ remediation = state.get("remediation") or {}
201
+
202
+ what_happens = consequence.get("what_happens") or risk_analysis.get("reason")
203
+ scope = consequence.get("scope")
204
+ reversible = consequence.get("reversible")
205
+ affected = consequence.get("affected_count")
206
+ severity = consequence.get("severity") or risk_analysis.get("risk")
207
+ suggested = consequence.get("suggested_fix")
208
+
209
+ if not suggested:
210
+ alternatives = remediation.get("alternatives") or []
211
+ if alternatives:
212
+ suggested = alternatives[0]
213
+
214
+ lines = [f"If you run `{command}`:"]
215
+
216
+ if what_happens:
217
+ lines.append(f"• What happens: {what_happens}")
218
+ else:
219
+ lines.append("• What happens: Outcome is unclear from available analysis")
220
+
221
+ if scope:
222
+ lines.append(f"• Scope: {scope}")
223
+
224
+ env_type = env_context.get("type")
225
+ if env_type:
226
+ lines.append(f"• Environment: {env_type}")
227
+
228
+ if reversible is not None:
229
+ lines.append(f"• Reversible: {'yes' if reversible else 'no'}")
230
+
231
+ if affected is not None:
232
+ lines.append(f"• Affected: {affected}")
233
+
234
+ blast = env_context.get("blast_radius") or {}
235
+ if blast.get("is_multi_user"):
236
+ users = blast.get("active_users", 0)
237
+ lines.append(f"• Active users impacted: {users}")
238
+
239
+ if severity:
240
+ lines.append(f"• Severity: {severity}")
241
+
242
+ if suggested:
243
+ lines.append(f"• Safer form: {suggested}")
244
+
245
+ return "\n".join(lines)
246
+
247
+ async def _answer_alternatives(self, query: str, state: AnalysisState) -> str:
248
+ """Suggest safer alternatives."""
249
+ remediation = state.get("remediation", {})
250
+ alternatives = remediation.get("alternatives", [])
251
+
252
+ if not alternatives:
253
+ return "No safer alternatives identified."
254
+
255
+ lines = ["Safer alternatives:"]
256
+ for i, alt in enumerate(alternatives[:5], 1):
257
+ lines.append(f"{i}. {alt}")
258
+
259
+ return "\n".join(lines)
260
+
261
+ async def _answer_history(self, query: str, state: AnalysisState) -> str:
262
+ """Query command history."""
263
+ command = state.get("command", "")
264
+ command_chain = state.get("command_chain", [])
265
+
266
+ # Check if command was run before
267
+ matching = [c for c in command_chain if c.get("command") == command]
268
+
269
+ if not matching:
270
+ return "You haven't run this exact command in this session."
271
+
272
+ last_run = matching[-1]
273
+ timestamp = last_run.get("timestamp", "unknown")
274
+ exit_code = last_run.get("exit_code", "unknown")
275
+ risk = last_run.get("risk", "unknown")
276
+
277
+ return (
278
+ f"Last run: {timestamp}\n"
279
+ f"Exit code: {exit_code}\n"
280
+ f"Risk level: {risk}"
281
+ )
282
+
283
+ async def _answer_impact(self, query: str, state: AnalysisState) -> str:
284
+ """Estimate blast radius and impact."""
285
+ env_context = state.get("environment_context", {})
286
+ blast = env_context.get("blast_radius", {})
287
+ resources = env_context.get("resources", {})
288
+
289
+ impact_lines = ["This command will affect:"]
290
+
291
+ env_type = env_context.get("type", "unknown")
292
+ impact_lines.append(f"• Environment: {env_type}")
293
+
294
+ if blast.get("is_multi_user"):
295
+ user_count = blast.get("active_users", 0)
296
+ impact_lines.append(f"• {user_count} active users on this system")
297
+
298
+ services = blast.get("critical_services", [])
299
+ if services:
300
+ impact_lines.append(f"• Critical services: {', '.join(services)}")
301
+
302
+ if resources.get("disk_low"):
303
+ impact_lines.append(
304
+ f"• WARNING: Low disk space ({resources.get('disk_free_percent', 0)}% free)"
305
+ )
306
+
307
+ if resources.get("memory_high"):
308
+ impact_lines.append(
309
+ f"• WARNING: High memory usage ({resources.get('memory_used_percent', 0)}%)"
310
+ )
311
+
312
+ if resources.get("cpu_high"):
313
+ impact_lines.append(
314
+ f"• WARNING: High CPU load ({resources.get('cpu_load_percent', 0)}%)"
315
+ )
316
+
317
+ if len(impact_lines) == 1:
318
+ impact_lines.append("• No significant impact factors detected")
319
+
320
+ return "\n".join(impact_lines)
321
+
322
+ async def _answer_did_i(self, query: str, state: AnalysisState) -> str:
323
+ """Answer 'did I...' questions about command history."""
324
+ command_chain = state.get("command_chain", [])
325
+
326
+ if not command_chain:
327
+ return "No command history available for this session."
328
+
329
+ # Try to extract what they're asking about
330
+ query_lower = query.lower()
331
+ search_terms = []
332
+
333
+ # Extract keywords after "did i"
334
+ if "did i" in query_lower:
335
+ after_did_i = query_lower.split("did i", 1)[1].strip()
336
+ search_terms = after_did_i.split()
337
+
338
+ if not search_terms:
339
+ return "I couldn't understand what you're asking about."
340
+
341
+ # Search history for matching commands
342
+ matching = []
343
+ for entry in command_chain:
344
+ cmd = entry.get("command", "").lower()
345
+ if any(term in cmd for term in search_terms):
346
+ matching.append(entry)
347
+
348
+ if not matching:
349
+ return f"No, you haven't run commands matching: {' '.join(search_terms)}"
350
+
351
+ # Return recent matches
352
+ lines = [f"Yes, you ran {len(matching)} matching command(s):"]
353
+ for entry in matching[-5:]: # Last 5 matches
354
+ timestamp = entry.get("timestamp", "unknown")
355
+ command = entry.get("command", "unknown")
356
+ lines.append(f"• {timestamp}: {command}")
357
+
358
+ return "\n".join(lines)
359
+
360
+
361
+ def _load_cache(self) -> dict:
362
+ """Load query cache from session storage.
363
+
364
+ Returns:
365
+ dict: Cached query responses (command::query -> response)
366
+ """
367
+ try:
368
+ from shellmate.daemon.session import get_query_cache_file
369
+ cache_file = get_query_cache_file()
370
+
371
+ if not cache_file.exists():
372
+ return {}
373
+
374
+ with open(cache_file, "r", encoding="utf-8") as f:
375
+ return json.load(f)
376
+ except Exception:
377
+ # Gracefully handle any cache loading errors
378
+ return {}
379
+
380
+ def _save_cache(self, cache: dict) -> None:
381
+ """Save query cache to session storage.
382
+
383
+ Args:
384
+ cache: Dictionary of cached responses to save
385
+ """
386
+ try:
387
+ from shellmate.daemon.session import get_query_cache_file
388
+ cache_file = get_query_cache_file()
389
+
390
+ # Ensure parent directory exists
391
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
392
+
393
+ with open(cache_file, "w", encoding="utf-8") as f:
394
+ json.dump(cache, f, indent=2)
395
+
396
+ # Set restrictive permissions on non-Windows
397
+ import sys
398
+ if sys.platform != "win32":
399
+ import os
400
+ os.chmod(cache_file, 0o600)
401
+ except Exception:
402
+ # Silently ignore cache save errors to not break query processing
403
+ pass
404
+
405
+
406
+ # Singleton instance
407
+ _query_agent: Optional[QueryAgent] = None
408
+
409
+
410
+ def get_query_agent() -> QueryAgent:
411
+ """Get the singleton QueryAgent instance."""
412
+ global _query_agent
413
+ if _query_agent is None:
414
+ _query_agent = QueryAgent()
415
+ return _query_agent
@@ -0,0 +1,171 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import re
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from shellmate.config.secrets import redact_secrets
10
+ from shellmate.daemon.session import get_session_dir_path
11
+
12
+ logger = logging.getLogger("shellmate.repomix")
13
+
14
+ MAX_SNIPPET_LENGTH = 200
15
+ MAX_MATCHES = 10
16
+
17
+
18
+ class RepomixBackend:
19
+ def __init__(self):
20
+ self._pack_path: Optional[Path] = None
21
+ self._pack_content: Optional[str] = None
22
+ self._ready = False
23
+
24
+ async def initialize(self) -> None:
25
+ try:
26
+ session_dir = get_session_dir_path()
27
+ session_dir.mkdir(parents=True, exist_ok=True)
28
+ self._pack_path = session_dir / "repomix_pack.txt"
29
+ await self._generate_pack()
30
+ self._ready = True
31
+ logger.info(f"Repomix pack generated: {self._pack_path}")
32
+ except Exception as e:
33
+ logger.warning(f"Failed to initialize repomix backend: {e}")
34
+ self._ready = False
35
+
36
+ async def _generate_pack(self) -> None:
37
+ project_root = Path.cwd()
38
+ lines = []
39
+ eligible_extensions = {
40
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".rs", ".go", ".java", ".rb",
41
+ ".php", ".c", ".cpp", ".h", ".hpp", ".cs", ".swift", ".kt", ".scala",
42
+ ".sh", ".bash", ".zsh", ".ps1", ".bat", ".cmd",
43
+ ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf",
44
+ ".md", ".rst", ".txt", ".html", ".css", ".scss", ".less",
45
+ ".xml", ".svg", ".dockerfile",
46
+ ".env", ".env.example",
47
+ ".gitignore", ".gitattributes",
48
+ ".editorconfig", ".prettierrc", ".eslintrc",
49
+ }
50
+ exclude_dirs = {
51
+ ".git", "__pycache__", "node_modules", "venv", ".venv", "env",
52
+ ".env", "virtualenv", ".tox", ".nox",
53
+ ".mypy_cache", ".pytest_cache", ".ruff_cache",
54
+ "target", "build", "dist", ".next", ".nuxt",
55
+ ".cache", ".npm", ".yarn", ".pnpm-store",
56
+ "vendor", ".bundle", "deps", "third_party",
57
+ ".idea", ".vscode", ".vs",
58
+ }
59
+ exclude_files = {
60
+ "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
61
+ ".DS_Store", "Thumbs.db",
62
+ }
63
+
64
+ for root, dirs, files in os.walk(project_root, topdown=True):
65
+ rel_root = Path(root).relative_to(project_root)
66
+ dirs[:] = [d for d in dirs if d not in exclude_dirs]
67
+ for f in files:
68
+ if f in exclude_files:
69
+ continue
70
+ file_path = Path(root) / f
71
+ ext = file_path.suffix.lower()
72
+ if ext not in eligible_extensions and f.lower() not in ("makefile", "dockerfile", "procfile"):
73
+ continue
74
+ try:
75
+ rel_path = file_path.relative_to(project_root)
76
+ content = file_path.read_text(encoding="utf-8", errors="replace")
77
+ content = redact_secrets(content)
78
+ lines.append(f"## {rel_path}")
79
+ lines.append("```" + (ext.lstrip(".") if ext else "text"))
80
+ lines.append(content)
81
+ lines.append("```")
82
+ lines.append("")
83
+ except (OSError, UnicodeDecodeError):
84
+ continue
85
+
86
+ content = "\n".join(lines)
87
+ if self._pack_path:
88
+ self._pack_path.write_text(content, encoding="utf-8")
89
+ self._pack_content = content
90
+
91
+ async def search(self, query: str) -> Dict[str, Any]:
92
+ if not self._ready or not self._pack_content:
93
+ return {"matches": []}
94
+
95
+ start = time.monotonic()
96
+ tokens = self._tokenize(query)
97
+ if not tokens:
98
+ return {"matches": []}
99
+
100
+ current_file = None
101
+ file_scores: Dict[str, float] = {}
102
+ file_snippets: Dict[str, str] = {}
103
+ file_sizes: Dict[str, int] = {}
104
+
105
+ lines = self._pack_content.split("\n")
106
+ for line in lines:
107
+ if line.startswith("## "):
108
+ current_file = line[3:].strip()
109
+ continue
110
+ if current_file:
111
+ for token in tokens:
112
+ if token in line.lower():
113
+ file_scores[current_file] = file_scores.get(current_file, 0) + 1
114
+ if current_file not in file_snippets and line.strip():
115
+ snippet = line.strip()
116
+ if len(snippet) > MAX_SNIPPET_LENGTH:
117
+ snippet = snippet[:MAX_SNIPPET_LENGTH] + "..."
118
+ file_snippets[current_file] = snippet
119
+
120
+ ranked = sorted(file_scores.items(), key=lambda x: (-x[1], x[0]))
121
+ matches = []
122
+ for path, score in ranked[:MAX_MATCHES]:
123
+ snippet = file_snippets.get(path, "")
124
+ matches.append({
125
+ "path": path,
126
+ "score": round(score, 1),
127
+ "snippet": snippet,
128
+ "size": file_sizes.get(path, 0),
129
+ })
130
+
131
+ elapsed = (time.monotonic() - start) * 1000
132
+ logger.debug(f"Repomix search for '{query}': {len(matches)} matches in {elapsed:.0f}ms")
133
+ return {"matches": matches, "latency_ms": round(elapsed, 1)}
134
+
135
+ def _tokenize(self, text: str) -> List[str]:
136
+ parts = re.split(r"[/\\\.\-_ ]", text.lower())
137
+ result = []
138
+ for part in parts:
139
+ if len(part) >= 2:
140
+ result.append(part)
141
+ sub_parts = re.findall(r"[a-z]+|[A-Z][a-z]*|\d+", part)
142
+ for sp in sub_parts:
143
+ sp_lower = sp.lower()
144
+ if len(sp_lower) >= 2 and sp_lower != part:
145
+ result.append(sp_lower)
146
+ return list(set(result))
147
+
148
+ async def cleanup(self) -> None:
149
+ self._ready = False
150
+ self._pack_content = None
151
+ if self._pack_path and self._pack_path.exists():
152
+ try:
153
+ self._pack_path.unlink()
154
+ logger.info("Repomix pack deleted")
155
+ except OSError as e:
156
+ logger.warning(f"Failed to delete repomix pack: {e}")
157
+ self._pack_path = None
158
+
159
+ @property
160
+ def is_ready(self) -> bool:
161
+ return self._ready
162
+
163
+
164
+ _repomix_backend: Optional[RepomixBackend] = None
165
+
166
+
167
+ def get_repomix_backend() -> RepomixBackend:
168
+ global _repomix_backend
169
+ if _repomix_backend is None:
170
+ _repomix_backend = RepomixBackend()
171
+ return _repomix_backend