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.
- shellmate/__init__.py +0 -0
- shellmate/adapters/__init__.py +67 -0
- shellmate/adapters/bash_adapter.sh +7 -0
- shellmate/adapters/ble_adapter.sh +350 -0
- shellmate/adapters/powershell_adapter.ps1 +417 -0
- shellmate/adapters/powershell_predictor.ps1 +97 -0
- shellmate/adapters/zsh_adapter.zsh +8 -0
- shellmate/agents/__init__.py +23 -0
- shellmate/agents/base.py +248 -0
- shellmate/agents/chain_patterns.yaml +98 -0
- shellmate/agents/command_chain.py +322 -0
- shellmate/agents/context_gatherer.py +175 -0
- shellmate/agents/environment.py +363 -0
- shellmate/agents/librarian.py +502 -0
- shellmate/agents/query.py +415 -0
- shellmate/agents/repomix_backend.py +171 -0
- shellmate/agents/watcher.py +266 -0
- shellmate/cache/__init__.py +0 -0
- shellmate/cache/semantic_cache.py +41 -0
- shellmate/cli/__init__.py +0 -0
- shellmate/cli/commands/__init__.py +27 -0
- shellmate/cli/commands/analyze.py +5 -0
- shellmate/cli/commands/config.py +319 -0
- shellmate/cli/commands/configure.py +118 -0
- shellmate/cli/commands/disable.py +11 -0
- shellmate/cli/commands/enable.py +49 -0
- shellmate/cli/commands/init.py +412 -0
- shellmate/cli/commands/install.py +242 -0
- shellmate/cli/commands/migrate.py +147 -0
- shellmate/cli/commands/status.py +31 -0
- shellmate/cli/commands/uninstall.py +78 -0
- shellmate/cli/main.py +114 -0
- shellmate/cli/parser.py +184 -0
- shellmate/cli/version.py +1 -0
- shellmate/completion/__init__.py +4 -0
- shellmate/completion/bash_adapter.py +145 -0
- shellmate/config/__init__.py +33 -0
- shellmate/config/credentials.py +120 -0
- shellmate/config/defaults.py +88 -0
- shellmate/config/manager.py +94 -0
- shellmate/config/paths.py +77 -0
- shellmate/config/secrets.py +66 -0
- shellmate/daemon/__init__.py +0 -0
- shellmate/daemon/lifecycle.py +308 -0
- shellmate/daemon/observability.py +82 -0
- shellmate/daemon/pipeline.py +119 -0
- shellmate/daemon/server.py +605 -0
- shellmate/daemon/session.py +170 -0
- shellmate/daemon/startup_ui.py +43 -0
- shellmate/environment/__init__.py +0 -0
- shellmate/environment/detector.py +130 -0
- shellmate/ipc/__init__.py +0 -0
- shellmate/ipc/client.py +210 -0
- shellmate/ipc/ps_client.py +190 -0
- shellmate/orchestration/__init__.py +3 -0
- shellmate/orchestration/analysis_graph.py +1274 -0
- shellmate/orchestration/state.py +35 -0
- shellmate/parser/__init__.py +0 -0
- shellmate/parser/command_parser.py +111 -0
- shellmate/plugins/__init__.py +0 -0
- shellmate/plugins/base.py +80 -0
- shellmate/plugins/loader.py +41 -0
- shellmate/providers/__init__.py +0 -0
- shellmate/providers/base.py +97 -0
- shellmate/providers/groq_command_guide.py +108 -0
- shellmate/providers/groq_provider.py +127 -0
- shellmate/providers/groq_supervisor.py +78 -0
- shellmate/providers/openrouter_provider.py +149 -0
- shellmate/providers/router.py +272 -0
- shellmate/providers/validate_keys.py +98 -0
- shellmate/providers/xai_provider.py +142 -0
- shellmate/rules/__init__.py +0 -0
- shellmate/rules/catastrophic.yaml +96 -0
- shellmate/rules/catastrophic_matcher.py +167 -0
- shellmate/rules/definitions/__init__.py +0 -0
- shellmate/rules/definitions/file_deletion.yaml +54 -0
- shellmate/rules/definitions/filesystem.yaml +43 -0
- shellmate/rules/definitions/network_execution.yaml +46 -0
- shellmate/rules/definitions/permissions.yaml +54 -0
- shellmate/rules/definitions/process_control.yaml +37 -0
- shellmate/rules/definitions/system_destruction.yaml +67 -0
- shellmate/rules/engine.py +200 -0
- shellmate/sanitizer/__init__.py +0 -0
- shellmate/sanitizer/typo_detector.py +114 -0
- shellmate/ui/__init__.py +10 -0
- shellmate/ui/analysis.py +164 -0
- shellmate/ui/annotations.py +232 -0
- shellmate/ui/banner.py +49 -0
- shellmate/ui/progress.py +126 -0
- shellmate/ui/runtime/__init__.py +15 -0
- shellmate/ui/runtime/console_factory.py +65 -0
- shellmate/ui/runtime/live_scene.py +116 -0
- shellmate/ui/runtime/motion_policy.py +69 -0
- shellmate/ui/runtime/tokens.py +47 -0
- shellmate/ui/scenes/__init__.py +24 -0
- shellmate/ui/scenes/enable.py +316 -0
- shellmate/ui/scenes/help.py +53 -0
- shellmate/ui/scenes/stages.py +128 -0
- shellmate/ui/scenes/status.py +181 -0
- shellmate/ui/status.py +3 -0
- shellmate_cli-3.0.0.dist-info/METADATA +352 -0
- shellmate_cli-3.0.0.dist-info/RECORD +106 -0
- shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
- shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
- shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|