max-cli 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.
@@ -0,0 +1,541 @@
1
+ import os
2
+ import json
3
+ import typer
4
+ from pathlib import Path
5
+ from typing import Dict, Any, List, Optional
6
+ from openai import OpenAI
7
+ from max_cli.config import settings
8
+ from max_cli.common.exceptions import MaxError
9
+ from max_cli.common.utils import encode_image_to_base64
10
+ from max_cli.common.cache import get_default_cache
11
+
12
+
13
+ class AIEngine:
14
+ def __init__(self):
15
+ self.client = None
16
+ if settings.OPENAI_API_KEY:
17
+ self.client = OpenAI(
18
+ api_key=settings.OPENAI_API_KEY, base_url=settings.OPENAI_BASE_URL
19
+ )
20
+ self.history: List[Dict[str, str]] = []
21
+ self._history_file = Path.home() / ".max_cli" / "chat_history.json"
22
+ self._history_file.parent.mkdir(parents=True, exist_ok=True)
23
+ self._load_history()
24
+
25
+ def _load_history(self) -> None:
26
+ """Load conversation history from disk."""
27
+ if self._history_file.exists():
28
+ try:
29
+ data = json.loads(self._history_file.read_text(encoding="utf-8"))
30
+ self.history = data.get("history", [])
31
+ except Exception:
32
+ self.history = []
33
+
34
+ def _save_history(self) -> None:
35
+ """Save conversation history to disk."""
36
+ data = {"history": self.history}
37
+ self._history_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
38
+
39
+ def clear_history(self) -> None:
40
+ """Clear conversation history from memory and disk."""
41
+ self.history = []
42
+ if self._history_file.exists():
43
+ self._history_file.unlink()
44
+
45
+ def export_history(self, output_path: Path) -> None:
46
+ """Export conversation history to a JSON file."""
47
+ data = {"history": self.history, "exported_at": str(Path.cwd())}
48
+ output_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
49
+
50
+ def import_history(self, input_path: Path) -> None:
51
+ """Import conversation history from a JSON file."""
52
+ data = json.loads(input_path.read_text(encoding="utf-8"))
53
+ self.history = data.get("history", [])
54
+ self._save_history()
55
+
56
+ def get_suggestions(self) -> List[str]:
57
+ """Get context-aware suggestions based on conversation history."""
58
+ if not self.history or not self.client:
59
+ return [
60
+ "Help me organize my files",
61
+ "Compress these images",
62
+ "Extract audio from this video",
63
+ ]
64
+
65
+ recent_topics = " ".join(
66
+ [msg["content"] for msg in self.history[-4:] if msg.get("role") == "user"]
67
+ )
68
+
69
+ prompt = f"""Based on this conversation context: "{recent_topics}"
70
+
71
+ Suggest 3 relevant follow-up commands the user might want to run.
72
+ Keep suggestions brief and related to file management, media processing, or AI features.
73
+ Return as a JSON array of strings."""
74
+
75
+ try:
76
+ response = self.client.chat.completions.create(
77
+ model=settings.AI_MODEL,
78
+ messages=[{"role": "user", "content": prompt}],
79
+ )
80
+ result = json.loads(response.choices[0].message.content)
81
+ return result if isinstance(result, list) else []
82
+ except Exception:
83
+ return [
84
+ "Show me what you can do",
85
+ "Help me with files",
86
+ "Process some media",
87
+ ]
88
+
89
+ def _get_local_context(self) -> str:
90
+ """Scans current directory to give AI 'eyes'."""
91
+ try:
92
+ files = os.listdir(".")
93
+ # Filter for non-hidden files and limit to 30 for token safety
94
+ visible_files = [f for f in files if not f.startswith(".")][:30]
95
+
96
+ context = "\n[USER'S CURRENT ENVIRONMENT]\n"
97
+ context += f"Path: {os.getcwd()}\n"
98
+ context += f"Files in Folder: {', '.join(visible_files)}\n"
99
+ if len(files) > 30:
100
+ context += f"(...and {len(files) - 30} more files)\n"
101
+ return context
102
+ except Exception:
103
+ return ""
104
+
105
+ def generate_cli_schema(self, app: typer.Typer, parent_name: str = "max") -> str:
106
+ """
107
+ Dynamically traverses the Typer app to build a documentation string.
108
+ """
109
+ schema_lines = ["Available Commands:"]
110
+
111
+ # 1. Traverse Registered Groups (Sub-commands like 'max images ...')
112
+ # Typer stores these in .registered_groups
113
+ for group in app.registered_groups:
114
+ if group.hidden or not group.typer_instance:
115
+ continue
116
+
117
+ group_name = group.name
118
+
119
+ # Access the commands inside the sub-typer
120
+ for cmd_info in group.typer_instance.registered_commands:
121
+ if cmd_info.hidden:
122
+ continue
123
+
124
+ full_cmd = f"{parent_name} {group_name} {cmd_info.name}"
125
+ description = cmd_info.help or "No description provided."
126
+
127
+ # We strip newlines to keep the prompt clean
128
+ description = description.split("\n")[0]
129
+
130
+ schema_lines.append(f"- {full_cmd}: {description}")
131
+
132
+ return "\n".join(schema_lines)
133
+
134
+ def interpret_intent(
135
+ self, user_prompt: str, app_instance: Any, explain: bool = False
136
+ ) -> Dict[str, Any]:
137
+ """Translates natural language to CLI commands with local context."""
138
+ if not self.client:
139
+ raise MaxError(
140
+ "Missing AI Configuration.\n"
141
+ "Please set OPENAI_API_KEY in your .env file."
142
+ )
143
+
144
+ tools = (
145
+ self.generate_cli_schema(app_instance)
146
+ if hasattr(app_instance, "registered_groups")
147
+ else ""
148
+ )
149
+ context = self._get_local_context()
150
+
151
+ # UPDATED PROMPT: Tell Max he CAN chat, but must use the JSON structure.
152
+ system_msg = f"""
153
+ You are "Max", a CLI agent.
154
+ TOOLS: {tools}
155
+ {context}
156
+
157
+ INSTRUCTIONS:
158
+ 1. If the user asks a tool-related question, return the "command".
159
+ 2. If the user is just chatting (e.g., "hello", "who are you?"), use the "thought" field for your response and leave "command" as null.
160
+ 3. ALWAYS return a JSON object. No markdown. No outside text.
161
+
162
+ JSON STRUCTURE:
163
+ {{
164
+ "thought": "Your conversational response or reasoning",
165
+ "command": "The shell command or null",
166
+ "explanation": "Briefly explain what the flags do (only if requested)",
167
+ "dangerous": true/false
168
+ }}
169
+
170
+ If the request is unrelated to the tools or ambiguous, return:
171
+ {{ "error": "I cannot handle this request with current tools. (and you have to explain the reason why not)" }}
172
+ """
173
+
174
+ try:
175
+ messages = [{"role": "system", "content": system_msg}]
176
+ messages.extend(self.history)
177
+ messages.append({"role": "user", "content": user_prompt})
178
+
179
+ response = self.client.chat.completions.create(
180
+ model=settings.AI_MODEL,
181
+ messages=messages,
182
+ )
183
+
184
+ raw_content = response.choices[0].message.content
185
+
186
+ # --- SAFETY CATCH ---
187
+ try:
188
+ result = json.loads(raw_content)
189
+ except json.JSONDecodeError:
190
+ # If AI fails to send JSON, wrap its text into a result dict manually
191
+ result = {
192
+ "thought": raw_content.strip(),
193
+ "command": None,
194
+ "dangerous": False,
195
+ }
196
+
197
+ # Update history with the response
198
+ self.history.append({"role": "user", "content": user_prompt})
199
+ self.history.append(
200
+ {"role": "assistant", "content": result.get("thought", "")}
201
+ )
202
+
203
+ return result
204
+ except Exception as e:
205
+ raise MaxError(f"AI Interpretation Error: {e}")
206
+
207
+ def categorize_files(self, file_list: List[str]) -> Dict[str, str]:
208
+ """AI-powered semantic grouping of files."""
209
+ cache = get_default_cache()
210
+ cache_key = f"categorize:{','.join(sorted(file_list))}"
211
+ cached_result = cache.get(cache_key)
212
+ if cached_result is not None:
213
+ return cached_result
214
+
215
+ prompt = f"Categorize these files into logical folders (e.g., Invoices, Photos, Scripts). Return a JSON map: {{filename: category_name}}\nFiles: {file_list}"
216
+
217
+ try:
218
+ response = self.client.chat.completions.create(
219
+ model=settings.AI_MODEL,
220
+ messages=[{"role": "user", "content": prompt}],
221
+ )
222
+ result = json.loads(response.choices[0].message.content)
223
+ cache.set(cache_key, result, ttl=3600)
224
+ return result
225
+ except Exception:
226
+ return {f: "Other" for f in file_list}
227
+
228
+ def analyze_image_content(self, image_path: Path, prompt: str) -> str:
229
+ """
230
+ Sends an image + prompt to the Vision Model (Gemini/GPT-4o).
231
+ Returns the text description.
232
+ """
233
+ if not self.client:
234
+ raise MaxError("Missing AI Configuration. Check your .env file.")
235
+
236
+ # 1. Encode Image
237
+ try:
238
+ base64_image = encode_image_to_base64(image_path)
239
+ except Exception as e:
240
+ raise MaxError(f"Failed to process image: {e}")
241
+
242
+ # 2. Build Payload
243
+ # Note: We do NOT force JSON mode here, as we want natural language description.
244
+ messages = [
245
+ {
246
+ "role": "user",
247
+ "content": [
248
+ {"type": "text", "text": prompt},
249
+ {
250
+ "type": "image_url",
251
+ "image_url": {
252
+ # JPEG header usually works for PNG/WEBP in OpenAI/Gemini APIs
253
+ "url": f"data:image/jpeg;base64,{base64_image}"
254
+ },
255
+ },
256
+ ],
257
+ }
258
+ ]
259
+
260
+ # 3. Call API
261
+ try:
262
+ response = self.client.chat.completions.create(
263
+ model=settings.AI_MODEL,
264
+ messages=messages,
265
+ )
266
+ return response.choices[0].message.content
267
+ except Exception as e:
268
+ raise MaxError(f"AI Vision Error: {str(e)}")
269
+
270
+ def generate_image(self, prompt: str, model: Optional[str] = None) -> str:
271
+ """
272
+ Generates an image. Uses the dedicated IMAGE_MODEL by default.
273
+ """
274
+ if not self.client:
275
+ raise MaxError("AI Client not configured.")
276
+
277
+ # If no specific model override is passed in the command, use the config value
278
+ target_model = model or settings.AI_IMAGE_MODEL
279
+
280
+ try:
281
+ response = self.client.chat.completions.create(
282
+ model=target_model, messages=[{"role": "user", "content": prompt}]
283
+ )
284
+
285
+ content = response.choices[0].message.content
286
+ return self._extract_image_url(content, response)
287
+ except Exception as e:
288
+ raise MaxError(f"Image Generation Failed using {target_model}: {e}")
289
+
290
+ def edit_image(
291
+ self, image_path: Path, prompt: str, model: Optional[str] = None
292
+ ) -> str:
293
+ """
294
+ Edits an image. Uses the dedicated IMAGE_MODEL by default.
295
+ """
296
+ if not self.client:
297
+ raise MaxError("AI Client not configured.")
298
+
299
+ target_model = model or settings.AI_IMAGE_MODEL
300
+ base64_img = encode_image_to_base64(image_path)
301
+
302
+ messages = [
303
+ {
304
+ "role": "user",
305
+ "content": [
306
+ {"type": "text", "text": prompt},
307
+ {
308
+ "type": "image_url",
309
+ "image_url": {"url": f"data:image/jpeg;base64,{base64_img}"},
310
+ },
311
+ ],
312
+ }
313
+ ]
314
+
315
+ try:
316
+ response = self.client.chat.completions.create(
317
+ model=target_model, messages=messages
318
+ )
319
+ content = response.choices[0].message.content
320
+ return self._extract_image_url(content, response)
321
+ except Exception as e:
322
+ raise MaxError(f"Image Editing Failed using {target_model}: {e}")
323
+
324
+ def _extract_image_url(self, content: str, raw_response: Any) -> str:
325
+ """
326
+ Helper to find image URL in Nano Banana response.
327
+ """
328
+ # 1. Check for standard Markdown URL: ![alt text](url)
329
+ import re
330
+
331
+ match = re.search(r"\((https?://[^\s)]+)\)", content)
332
+ if match:
333
+ return match.group(1)
334
+
335
+ # 2. Check for raw URL in text
336
+ url_match = re.search(r"https?://[^\s]+", content)
337
+ if url_match:
338
+ return url_match.group(0)
339
+
340
+ # 3. Check raw response dictionary (Advanced Google implementation)
341
+ raw_dict = raw_response.model_dump()
342
+ if "images" in raw_dict and raw_dict["images"]:
343
+ return raw_dict["images"][0].get("url")
344
+
345
+ raise MaxError("AI generated a response, but no image URL was found.")
346
+
347
+ def run_pipeline(
348
+ self, operations: List[Dict[str, Any]], input_data: Any = None
349
+ ) -> List[Dict[str, Any]]:
350
+ """
351
+ Run a pipeline of AI operations.
352
+
353
+ Args:
354
+ operations: List of operation dicts with 'type' and 'params'
355
+ input_data: Initial input data
356
+
357
+ Returns:
358
+ List of results from each operation
359
+ """
360
+ if not self.client:
361
+ raise MaxError("AI Client not configured.")
362
+
363
+ current_data = input_data
364
+ results = []
365
+
366
+ for i, op in enumerate(operations):
367
+ op_type = op.get("type", "").lower()
368
+ params = op.get("params", {})
369
+
370
+ try:
371
+ if op_type == "categorize":
372
+ files = params.get("files", [])
373
+ result = self.categorize_files(files)
374
+ results.append(
375
+ {"step": i + 1, "operation": "categorize", "result": result}
376
+ )
377
+
378
+ elif op_type == "analyze_image":
379
+ image_path = params.get("image_path")
380
+ prompt = params.get("prompt", "Describe this image")
381
+ if image_path:
382
+ result = self.analyze_image_content(Path(image_path), prompt)
383
+ results.append(
384
+ {
385
+ "step": i + 1,
386
+ "operation": "analyze_image",
387
+ "result": result,
388
+ }
389
+ )
390
+
391
+ elif op_type == "generate_image":
392
+ prompt = params.get("prompt", "")
393
+ if prompt:
394
+ result = self.generate_image(prompt)
395
+ results.append(
396
+ {
397
+ "step": i + 1,
398
+ "operation": "generate_image",
399
+ "result": result,
400
+ }
401
+ )
402
+
403
+ elif op_type == "chat":
404
+ message = params.get("message", "")
405
+ if message:
406
+ result = self.interpret_intent(message, None)
407
+ results.append(
408
+ {"step": i + 1, "operation": "chat", "result": result}
409
+ )
410
+
411
+ elif op_type == "transform":
412
+ transform_prompt = params.get("prompt", "")
413
+ input_text = params.get("input", current_data)
414
+ if transform_prompt and input_text:
415
+ response = self.client.chat.completions.create(
416
+ model=settings.AI_MODEL,
417
+ messages=[
418
+ {
419
+ "role": "user",
420
+ "content": f"{transform_prompt}\n\n{input_text}",
421
+ }
422
+ ],
423
+ )
424
+ result = response.choices[0].message.content
425
+ current_data = result
426
+ results.append(
427
+ {"step": i + 1, "operation": "transform", "result": result}
428
+ )
429
+
430
+ else:
431
+ results.append(
432
+ {
433
+ "step": i + 1,
434
+ "operation": op_type,
435
+ "error": f"Unknown operation: {op_type}",
436
+ }
437
+ )
438
+
439
+ except Exception as e:
440
+ results.append({"step": i + 1, "operation": op_type, "error": str(e)})
441
+
442
+ return results
443
+
444
+ def semantic_search(self, query: str, files: List[Path]) -> List[Dict[str, Any]]:
445
+ """
446
+ Search files by content using AI.
447
+
448
+ Args:
449
+ query: Natural language search query
450
+ files: List of files to search
451
+
452
+ Returns:
453
+ List of matching results with relevance scores
454
+ """
455
+ if not self.client:
456
+ raise MaxError("AI Client not configured.")
457
+
458
+ results = []
459
+
460
+ for file_path in files:
461
+ try:
462
+ if file_path.suffix.lower() in [
463
+ ".txt",
464
+ ".md",
465
+ ".py",
466
+ ".json",
467
+ ".yaml",
468
+ ".yml",
469
+ ]:
470
+ file_content = file_path.read_text(
471
+ encoding="utf-8", errors="ignore"
472
+ )[:5000]
473
+ elif file_path.suffix.lower() == ".pdf":
474
+ continue
475
+ else:
476
+ continue
477
+
478
+ prompt = f"""Search Query: {query}
479
+
480
+ File: {file_path.name}
481
+
482
+ Content:
483
+ {file_content}
484
+
485
+ Does this file match the query? Reply with YES or NO followed by a brief explanation."""
486
+
487
+ response = self.client.chat.completions.create(
488
+ model=settings.AI_MODEL,
489
+ messages=[{"role": "user", "content": prompt}],
490
+ )
491
+
492
+ answer = response.choices[0].message.content or ""
493
+
494
+ if answer.strip().upper().startswith("YES"):
495
+ results.append(
496
+ {"file": str(file_path), "match": True, "reasoning": answer}
497
+ )
498
+
499
+ except Exception:
500
+ continue
501
+
502
+ return results
503
+
504
+ def extract_structured_data(
505
+ self, image_path: Path, schema: Dict[str, str]
506
+ ) -> Dict[str, Any]:
507
+ """
508
+ Extract structured data from an image using AI vision.
509
+
510
+ Args:
511
+ image_path: Path to image file
512
+ schema: Dict mapping field names to descriptions
513
+
514
+ Returns:
515
+ Extracted structured data
516
+ """
517
+ if not self.client:
518
+ raise MaxError("AI Client not configured.")
519
+
520
+ schema_text = "\n".join(
521
+ [f"- {field}: {desc}" for field, desc in schema.items()]
522
+ )
523
+
524
+ prompt = f"""Extract structured data from this image.
525
+
526
+ Schema:
527
+ {schema_text}
528
+
529
+ Return a JSON object with the extracted data."""
530
+
531
+ try:
532
+ result = self.analyze_image_content(image_path, prompt)
533
+ import json
534
+
535
+ try:
536
+ data = json.loads(result)
537
+ return data
538
+ except json.JSONDecodeError:
539
+ return {"raw_text": result, "error": "Could not parse as JSON"}
540
+ except Exception as e:
541
+ raise MaxError(f"Data extraction failed: {e}")