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.
- ezshell-0.2.0.dist-info/METADATA +174 -0
- ezshell-0.2.0.dist-info/RECORD +37 -0
- ezshell-0.2.0.dist-info/WHEEL +5 -0
- ezshell-0.2.0.dist-info/entry_points.txt +3 -0
- ezshell-0.2.0.dist-info/licenses/LICENSE +21 -0
- ezshell-0.2.0.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/app.py +286 -0
- src/core/__init__.py +0 -0
- src/core/assistant.py +525 -0
- src/core/capture.py +213 -0
- src/core/chat_bridge.py +107 -0
- src/core/config.py +238 -0
- src/core/context.py +229 -0
- src/core/groq_client.py +376 -0
- src/core/llm_client.py +111 -0
- src/core/prompts.py +262 -0
- src/core/provider.py +233 -0
- src/core/proxy_client.py +166 -0
- src/core/pty_shell.py +273 -0
- src/core/redact.py +95 -0
- src/core/risk.py +455 -0
- src/core/safety.py +268 -0
- src/core/sanitize.py +39 -0
- src/core/schema.py +65 -0
- src/core/shell_hook.py +388 -0
- src/core/suggest.py +94 -0
- src/core/updater.py +428 -0
- src/i18n.py +422 -0
- src/main.py +346 -0
- src/ui/chat_tui.py +634 -0
- src/ui/gui_window.py +1018 -0
- src/ui/inline.py +330 -0
- src/ui/logo.py +194 -0
- src/ui/panel.py +163 -0
- src/ui/prompt.py +184 -0
- src/ui/render.py +269 -0
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()
|