ezshell 0.2.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.
src/core/assistant.py ADDED
@@ -0,0 +1,525 @@
1
+ """
2
+ Core assistant module for ezShell — orchestrates LLM-based shell diagnostics and chat.
3
+
4
+ This module provides three main capabilities:
5
+ 1. **Error diagnosis** (``ShellAssistant.diagnose_failure``): analyses a failed shell command,
6
+ queries the Groq LLM, and returns a structured ``DiagnosticResult`` with a suggested fix.
7
+ 2. **Natural-language queries** (``ShellAssistant.ask``): answers free-form terminal questions
8
+ and returns a structured ``AskResult``.
9
+ 3. **Multi-turn chat** (``ChatSession``): maintains a growing message history for an interactive
10
+ conversation with context-aware system-environment injection.
11
+
12
+ All LLM responses are expected to be JSON objects. ``extract_json_payload`` handles cases
13
+ where the model wraps the payload in markdown fences or adds surrounding prose.
14
+
15
+ ---
16
+
17
+ Module principal de l'assistant ezShell — orchestre les diagnostics de shell et le chat via LLM.
18
+
19
+ Ce module fournit trois capacités principales :
20
+ 1. **Diagnostic d'erreur** (``ShellAssistant.diagnose_failure``) : analyse une commande shell
21
+ échouée, interroge le LLM Groq et retourne un ``DiagnosticResult`` structuré avec un
22
+ correctif suggéré.
23
+ 2. **Requêtes en langage naturel** (``ShellAssistant.ask``) : répond à des questions libres
24
+ sur le terminal et retourne un ``AskResult`` structuré.
25
+ 3. **Chat multi-tours** (``ChatSession``) : maintient un historique de messages croissant pour
26
+ une conversation interactive avec injection du contexte système.
27
+
28
+ Toutes les réponses LLM sont censées être des objets JSON. ``extract_json_payload`` gère les
29
+ cas où le modèle enveloppe la charge utile dans des balises markdown ou ajoute du texte autour.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import re
36
+ from dataclasses import dataclass
37
+ from typing import Optional
38
+
39
+ from src.core.config import EzShellConfig
40
+ from src.core.context import SystemContext
41
+ from src.core.groq_client import GroqClient
42
+ from src.core.prompts import (
43
+ ASK_SYSTEM_PROMPT,
44
+ SYSTEM_PROMPT,
45
+ build_ask_prompt,
46
+ build_chat_system_prompt,
47
+ build_error_analysis_prompt,
48
+ )
49
+ from src.core.safety import SafetyGuard, SafetyReport
50
+
51
+ __all__ = [
52
+ "DiagnosticResult",
53
+ "AskResult",
54
+ "ChatMessageResult",
55
+ "ShellAssistant",
56
+ "ChatSession",
57
+ "extract_json_payload",
58
+ "extract_bash_commands",
59
+ ]
60
+
61
+
62
+ @dataclass
63
+ class DiagnosticResult:
64
+ """Structured result returned by ``ShellAssistant.diagnose_failure``.
65
+
66
+ Attributes:
67
+ command: The original shell command that failed.
68
+ exit_code: The non-zero exit code returned by the failed command.
69
+ summary: A single-sentence human-readable summary of the failure reason.
70
+ explanation: A short markdown-formatted explanation of the root cause
71
+ (typically 2–3 sentences).
72
+ suggested_fix: An exact, executable shell command that should resolve
73
+ the failure, or ``None`` if no automated fix is applicable.
74
+ fix_description: A brief prose description of what ``suggested_fix`` does
75
+ (e.g. ``"Installs the missing development headers"``).
76
+ safety_report: A ``SafetyReport`` produced by ``SafetyGuard.inspect`` for
77
+ ``suggested_fix``, or ``None`` when no fix was suggested.
78
+ """
79
+
80
+ command: str
81
+ exit_code: int
82
+ summary: str
83
+ explanation: str
84
+ suggested_fix: Optional[str]
85
+ fix_description: str
86
+ safety_report: Optional[SafetyReport] = None
87
+
88
+
89
+ @dataclass
90
+ class AskResult:
91
+ """Structured result returned by ``ShellAssistant.ask``.
92
+
93
+ Attributes:
94
+ question: The original natural-language question posed by the user.
95
+ summary: A short title or one-line summary of the answer.
96
+ explanation: A brief technical explanation of the answer in markdown
97
+ format (typically 2–3 sentences).
98
+ suggested_fix: An exact shell command that answers or demonstrates the
99
+ solution, or ``None`` if a command is not applicable.
100
+ fix_description: A brief prose description of what ``suggested_fix`` does.
101
+ safety_report: A ``SafetyReport`` produced by ``SafetyGuard.inspect`` for
102
+ ``suggested_fix``, or ``None`` when no command was suggested.
103
+ """
104
+
105
+ question: str
106
+ summary: str
107
+ explanation: str
108
+ suggested_fix: Optional[str]
109
+ fix_description: str
110
+ safety_report: Optional[SafetyReport] = None
111
+
112
+
113
+ @dataclass
114
+ class ChatMessageResult:
115
+ """Structured result returned by ``ChatSession.send_message``.
116
+
117
+ Attributes:
118
+ content: The full raw text of the assistant's response, which may contain
119
+ markdown-formatted prose, lists, and code blocks.
120
+ suggested_commands: A list of shell commands extracted from bash/sh/zsh
121
+ fenced code blocks within ``content``.
122
+ safety_reports: A ``SafetyReport`` for each entry in ``suggested_commands``,
123
+ in the same order, produced by ``SafetyGuard.inspect``.
124
+ """
125
+
126
+ content: str
127
+ suggested_commands: list[str]
128
+ safety_reports: list[SafetyReport]
129
+
130
+
131
+ def extract_json_payload(raw_response: str) -> dict:
132
+ """Extract and parse a JSON object from a raw LLM completion string.
133
+
134
+ The LLM may return the JSON payload wrapped in markdown code fences,
135
+ preceded by conversational text, or as a bare JSON string. This function
136
+ applies three successive parsing strategies and falls back to a minimal
137
+ structure if all strategies fail.
138
+
139
+ Parsing strategies (applied in order):
140
+ 1. **Direct parse** — strip surrounding whitespace and attempt
141
+ ``json.loads`` immediately.
142
+ 2. **Markdown fence extraction** — search for a ````` ```json … ``` `````
143
+ or ```` ``` … ``` ```` block using a regex and parse its contents.
144
+ 3. **Brace extraction** — locate the first ``{`` and last ``}`` in the
145
+ string and attempt to parse the substring they delimit.
146
+
147
+ Args:
148
+ raw_response: The raw completion string returned by the LLM, which may
149
+ or may not be a valid standalone JSON document.
150
+
151
+ Returns:
152
+ A ``dict`` parsed from the JSON payload embedded in ``raw_response``.
153
+ If all three parsing strategies fail, returns a fallback dict with the
154
+ keys ``summary``, ``explanation``, ``suggested_fix``, and
155
+ ``fix_description``, where ``explanation`` contains the raw response
156
+ text verbatim.
157
+
158
+ Notes:
159
+ The function intentionally swallows all ``json.JSONDecodeError``
160
+ exceptions — failures at each stage are silent so that the next
161
+ strategy is attempted. Only the final fallback surfaces the raw text
162
+ to the caller.
163
+ """
164
+ clean = raw_response.strip()
165
+
166
+ # Strategy 1: attempt a direct JSON parse of the stripped response
167
+ try:
168
+ return json.loads(clean)
169
+ except Exception:
170
+ pass
171
+
172
+ # Strategy 2: extract the body of a markdown fenced code block (```json … ```)
173
+ match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", clean, re.DOTALL)
174
+ if match:
175
+ try:
176
+ return json.loads(match.group(1))
177
+ except Exception:
178
+ pass
179
+
180
+ # Strategy 3: find the outermost pair of curly braces and parse the slice
181
+ start = clean.find("{")
182
+ end = clean.rfind("}")
183
+ if start != -1 and end != -1 and end > start:
184
+ try:
185
+ return json.loads(clean[start : end + 1])
186
+ except Exception:
187
+ pass
188
+
189
+ # Fallback: return a minimal structure so callers always receive a dict
190
+ return {
191
+ "summary": "Diagnostic completed",
192
+ "explanation": clean,
193
+ "suggested_fix": None,
194
+ "fix_description": "",
195
+ }
196
+
197
+
198
+ class ShellAssistant:
199
+ """Orchestrates error analysis, prompt generation, LLM querying, and safety validation.
200
+
201
+ ``ShellAssistant`` is the primary entry-point for one-shot interactions with
202
+ the Groq LLM. It handles two use-cases:
203
+
204
+ * **Failure diagnosis** (``diagnose_failure``): given a failed shell command
205
+ and its output, it constructs a structured prompt, queries the LLM, parses
206
+ the JSON response, and runs the suggested fix through ``SafetyGuard``.
207
+ * **Natural-language queries** (``ask``): given a free-form question about the
208
+ terminal or system, it follows the same pipeline using a different system
209
+ prompt tuned for question-answering.
210
+
211
+ The underlying ``GroqClient`` is instantiated lazily on first use so that
212
+ the API key is not resolved until it is actually needed.
213
+ """
214
+
215
+ def __init__(
216
+ self,
217
+ config: Optional[EzShellConfig] = None,
218
+ client: Optional[GroqClient] = None,
219
+ ):
220
+ """Initialise the ShellAssistant.
221
+
222
+ Args:
223
+ config: An ``EzShellConfig`` instance that supplies model settings and
224
+ API key resolution. When ``None``, configuration is loaded from
225
+ the default sources via ``EzShellConfig.load()``.
226
+ client: A pre-constructed ``GroqClient`` to use for LLM calls. When
227
+ ``None``, a client is created lazily on first access via the
228
+ ``client`` property. Pass an explicit client in tests to inject
229
+ a mock or stub.
230
+ """
231
+ self.config = config or EzShellConfig.load()
232
+ self._client = client
233
+
234
+ @property
235
+ def client(self) -> GroqClient:
236
+ """Return the active ``GroqClient``, creating one lazily if necessary.
237
+
238
+ The client is constructed on first access so that the API key lookup
239
+ (which may read environment variables or config files) is deferred until
240
+ an actual LLM call is about to be made.
241
+
242
+ Returns:
243
+ The shared ``GroqClient`` instance for this assistant.
244
+ """
245
+ if self._client is None:
246
+ api_key = self.config.get_effective_api_key()
247
+ self._client = GroqClient(api_key=api_key)
248
+ return self._client
249
+
250
+ def diagnose_failure(
251
+ self,
252
+ command: str,
253
+ exit_code: int,
254
+ error_output: str,
255
+ context: SystemContext,
256
+ ) -> DiagnosticResult:
257
+ """Diagnose a failed terminal command and suggest a fix.
258
+
259
+ Builds a structured prompt that includes the failed command, its exit
260
+ code, stderr/stdout output, and system-environment metadata. Sends the
261
+ prompt to the Groq LLM, parses the JSON response, and validates any
262
+ suggested fix command with ``SafetyGuard``.
263
+
264
+ Args:
265
+ command: The exact shell command string that was executed and failed.
266
+ exit_code: The non-zero integer exit code returned by the command.
267
+ error_output: Combined stderr (and optionally stdout) text produced
268
+ by the failed command. May be an empty string if the command
269
+ produced no output before exiting.
270
+ context: A ``SystemContext`` snapshot containing OS, shell, package
271
+ manager, working directory, and other environment details that
272
+ are embedded in the prompt to make the fix OS-specific.
273
+
274
+ Returns:
275
+ A ``DiagnosticResult`` populated with the LLM's diagnosis.
276
+ The ``safety_report`` field is ``None`` when no fix is suggested.
277
+
278
+ Raises:
279
+ groq.APIError: If the underlying Groq API call fails (network error,
280
+ auth failure, rate-limit, etc.).
281
+ """
282
+ prompt = build_error_analysis_prompt(
283
+ command=command,
284
+ exit_code=exit_code,
285
+ error_output=error_output,
286
+ system_context=context.to_prompt_context(),
287
+ )
288
+
289
+ raw_completion = self.client.complete(
290
+ prompt=prompt,
291
+ system_prompt=SYSTEM_PROMPT,
292
+ model=self.config.model,
293
+ temperature=self.config.temperature,
294
+ max_tokens=self.config.max_tokens,
295
+ )
296
+
297
+ data = extract_json_payload(raw_completion)
298
+
299
+ summary = data.get("summary", f"Command failed with exit code {exit_code}")
300
+ explanation = data.get("explanation", raw_completion)
301
+ suggested_fix = data.get("suggested_fix")
302
+ fix_description = data.get("fix_description", "")
303
+
304
+ # Only run safety inspection when a non-empty fix command was provided
305
+ safety_report = None
306
+ if suggested_fix and isinstance(suggested_fix, str) and suggested_fix.strip():
307
+ suggested_fix = suggested_fix.strip()
308
+ safety_report = SafetyGuard.inspect(suggested_fix)
309
+
310
+ return DiagnosticResult(
311
+ command=command,
312
+ exit_code=exit_code,
313
+ summary=summary,
314
+ explanation=explanation,
315
+ suggested_fix=suggested_fix,
316
+ fix_description=fix_description,
317
+ safety_report=safety_report,
318
+ )
319
+
320
+ def ask(self, question: str, context: SystemContext) -> AskResult:
321
+ """Answer a user's natural-language terminal query.
322
+
323
+ Constructs a prompt from the user's question and system-environment
324
+ metadata, queries the Groq LLM with the ask-oriented system prompt,
325
+ and parses the JSON response into an ``AskResult``. Any suggested
326
+ command is inspected by ``SafetyGuard`` before being returned.
327
+
328
+ Args:
329
+ question: The free-form natural-language question entered by the
330
+ user (e.g. ``"how do I recursively find files larger than 1 GB?"``).
331
+ context: A ``SystemContext`` snapshot providing OS, shell, working
332
+ directory, and other metadata used to tailor the answer.
333
+
334
+ Returns:
335
+ An ``AskResult`` containing the LLM's answer, an optional executable
336
+ command, and a ``SafetyReport`` for that command when one is present.
337
+
338
+ Raises:
339
+ groq.APIError: If the underlying Groq API call fails.
340
+ """
341
+ prompt = build_ask_prompt(
342
+ question=question,
343
+ system_context=context.to_prompt_context(),
344
+ )
345
+
346
+ raw_completion = self.client.complete(
347
+ prompt=prompt,
348
+ system_prompt=ASK_SYSTEM_PROMPT,
349
+ model=self.config.model,
350
+ temperature=self.config.temperature,
351
+ max_tokens=self.config.max_tokens,
352
+ )
353
+
354
+ data = extract_json_payload(raw_completion)
355
+
356
+ summary = data.get("summary", "Terminal Assistant")
357
+ explanation = data.get("explanation", raw_completion)
358
+ suggested_fix = data.get("suggested_fix")
359
+ fix_description = data.get("fix_description", "")
360
+
361
+ # Only run safety inspection when a non-empty command was provided
362
+ safety_report = None
363
+ if suggested_fix and isinstance(suggested_fix, str) and suggested_fix.strip():
364
+ suggested_fix = suggested_fix.strip()
365
+ safety_report = SafetyGuard.inspect(suggested_fix)
366
+
367
+ return AskResult(
368
+ question=question,
369
+ summary=summary,
370
+ explanation=explanation,
371
+ suggested_fix=suggested_fix,
372
+ fix_description=fix_description,
373
+ safety_report=safety_report,
374
+ )
375
+
376
+
377
+ def extract_bash_commands(text: str) -> list[str]:
378
+ """Extract shell commands from bash/sh/zsh/shell fenced code blocks in text.
379
+
380
+ Scans ``text`` for markdown fenced code blocks whose language tag is one of
381
+ ``bash``, ``sh``, ``shell``, or ``zsh`` (case-insensitive) and returns the
382
+ trimmed body of each block. Blocks whose content starts with ``#`` (i.e.
383
+ comment-only blocks) are silently skipped.
384
+
385
+ Args:
386
+ text: A string that may contain zero or more markdown fenced code
387
+ blocks, typically the raw assistant response from ``ChatSession``.
388
+
389
+ Returns:
390
+ A list of non-empty command strings extracted from matching code blocks,
391
+ in the order they appear in ``text``. Returns an empty list when no
392
+ matching blocks are found.
393
+ """
394
+ matches = re.findall(
395
+ r"```(?:bash|sh|shell|zsh)?\s*\n(.*?)\n```",
396
+ text,
397
+ re.DOTALL | re.IGNORECASE,
398
+ )
399
+ commands: list[str] = []
400
+ for m in matches:
401
+ cmd = m.strip()
402
+ # Skip empty captures and comment-only blocks
403
+ if cmd and not cmd.startswith("#"):
404
+ commands.append(cmd)
405
+ return commands
406
+
407
+
408
+ class ChatSession:
409
+ """Manage an ongoing multi-turn interactive conversation with the Groq LLM.
410
+
411
+ ``ChatSession`` maintains a growing ``messages`` list that mirrors the
412
+ OpenAI-compatible chat-completion message format (``{"role": ..., "content":
413
+ ...}``). The first message is always a system prompt built from
414
+ ``CHAT_SYSTEM_PROMPT`` combined with live system-environment metadata.
415
+
416
+ Each call to ``send_message`` appends the user's message, obtains an
417
+ assistant reply via ``GroqClient.chat``, appends the reply, extracts any
418
+ bash commands from the reply, and wraps everything in a
419
+ ``ChatMessageResult``.
420
+
421
+ Calling ``clear`` resets the history to just the initial system message,
422
+ effectively starting a fresh conversation without creating a new session
423
+ object.
424
+ """
425
+
426
+ def __init__(
427
+ self,
428
+ config: Optional[EzShellConfig] = None,
429
+ client: Optional[GroqClient] = None,
430
+ context: Optional[SystemContext] = None,
431
+ ):
432
+ """Initialise the ChatSession and inject the system environment prompt.
433
+
434
+ Args:
435
+ config: An ``EzShellConfig`` instance that supplies model settings
436
+ and API key resolution. When ``None``, configuration is loaded
437
+ from the default sources via ``EzShellConfig.load()``.
438
+ client: A pre-constructed ``GroqClient``. When ``None``, a client
439
+ is created lazily on first access. Pass an explicit client in
440
+ tests to inject a mock or stub.
441
+ context: A ``SystemContext`` snapshot whose metadata is embedded in
442
+ the system prompt so the LLM can give OS-specific advice. When
443
+ ``None``, context is collected from the live environment via
444
+ ``SystemContext.collect()``.
445
+ """
446
+ self.config = config or EzShellConfig.load()
447
+ self._client = client
448
+ self.context = context or SystemContext.collect()
449
+ # ``messages`` will be populated by ``_init_session``
450
+ self.messages: list[dict[str, str]] = []
451
+ self._init_session()
452
+
453
+ @property
454
+ def client(self) -> GroqClient:
455
+ """Return the active ``GroqClient``, creating one lazily if necessary.
456
+
457
+ Returns:
458
+ The shared ``GroqClient`` instance for this chat session.
459
+ """
460
+ if self._client is None:
461
+ api_key = self.config.get_effective_api_key()
462
+ self._client = GroqClient(api_key=api_key)
463
+ return self._client
464
+
465
+ def _init_session(self) -> None:
466
+ """Initialise (or reinitialise) the message history with the system prompt.
467
+
468
+ Builds the context-aware system prompt by combining ``CHAT_SYSTEM_PROMPT``
469
+ with the serialised ``SystemContext`` and stores it as the sole entry in
470
+ ``self.messages``. Called once during ``__init__`` and again by
471
+ ``clear`` to reset the conversation.
472
+ """
473
+ sys_prompt = build_chat_system_prompt(self.context.to_prompt_context())
474
+ # Replace the entire history with just the system message
475
+ self.messages = [{"role": "system", "content": sys_prompt}]
476
+
477
+ def send_message(self, user_text: str) -> ChatMessageResult:
478
+ """Send a user message and receive the assistant's reply.
479
+
480
+ Appends ``user_text`` to the message history, calls ``GroqClient.chat``
481
+ with the full history, appends the assistant's reply, and then extracts
482
+ any shell commands embedded in bash fenced code blocks within the reply.
483
+ Each extracted command is also passed through ``SafetyGuard.inspect``.
484
+
485
+ Args:
486
+ user_text: The user's input message for this turn of the
487
+ conversation.
488
+
489
+ Returns:
490
+ A ``ChatMessageResult`` containing the full assistant reply text,
491
+ the list of extracted shell commands, and a parallel list of
492
+ ``SafetyReport`` objects (one per command).
493
+
494
+ Raises:
495
+ groq.APIError: If the underlying Groq API call fails.
496
+ """
497
+ self.messages.append({"role": "user", "content": user_text})
498
+
499
+ response_text = self.client.chat(
500
+ messages=self.messages,
501
+ model=self.config.model,
502
+ temperature=self.config.temperature,
503
+ max_tokens=self.config.max_tokens,
504
+ )
505
+ # Persist the assistant reply so subsequent turns have full context
506
+ self.messages.append({"role": "assistant", "content": response_text})
507
+
508
+ commands = extract_bash_commands(response_text)
509
+ # Inspect each suggested command for destructive or risky patterns
510
+ safety_reports = [SafetyGuard.inspect(cmd) for cmd in commands]
511
+
512
+ return ChatMessageResult(
513
+ content=response_text,
514
+ suggested_commands=commands,
515
+ safety_reports=safety_reports,
516
+ )
517
+
518
+ def clear(self) -> None:
519
+ """Reset the conversation history while preserving the system environment.
520
+
521
+ Discards all user and assistant messages and re-injects the system
522
+ prompt so the next ``send_message`` call starts a fresh conversation
523
+ in the same ``SystemContext``.
524
+ """
525
+ self._init_session()