ace-git-copilot 0.1.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.
ace/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,70 @@
1
+ from typing import Optional
2
+ from langchain_core.messages import SystemMessage, HumanMessage
3
+ from ace.core.git_ops import GitOps
4
+ from ace.ai.llm_factory import get_llm
5
+ from ace.ai.prompts.changelog import CHANGELOG_SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
6
+
7
+ class ChangelogGeneratorError(Exception):
8
+ """Raised when changelog generation fails."""
9
+ pass
10
+
11
+ class ChangelogGenerator:
12
+ def __init__(self, git_ops: GitOps):
13
+ self.git_ops = git_ops
14
+
15
+ def get_commits_in_range(self, from_ref: Optional[str] = None, to_ref: Optional[str] = None) -> str:
16
+ """
17
+ Retrieve formatted commit log between from_ref and to_ref.
18
+ If from_ref is None, attempts to find the latest tag.
19
+ If no latest tag exists, falls back to the last 30 commits.
20
+ """
21
+ to_revision = to_ref or "HEAD"
22
+ from_revision = from_ref
23
+
24
+ # If from_ref is not provided, try to find the latest tag
25
+ if not from_revision:
26
+ try:
27
+ # git describe --tags --abbrev=0 to get latest tag
28
+ latest_tag = self.git_ops.execute("describe --tags --abbrev=0").strip()
29
+ from_revision = latest_tag
30
+ except Exception:
31
+ # No tags found
32
+ from_revision = None
33
+
34
+ log_args = ["log"]
35
+ if from_revision:
36
+ log_args.append(f"{from_revision}..{to_revision}")
37
+ else:
38
+ # Fallback to last 30 commits
39
+ log_args.extend(["-n", "30"])
40
+
41
+ # Format: hash|date|author|subject\nbody
42
+ log_args.append('--format=%H|%ad|%an|%s%n%b')
43
+
44
+ try:
45
+ cmd = " ".join(log_args)
46
+ return self.git_ops.execute(cmd).strip()
47
+ except Exception as e:
48
+ raise ChangelogGeneratorError(f"Failed to fetch commit log: {e}")
49
+
50
+ def generate_changelog(self, from_ref: Optional[str] = None, to_ref: Optional[str] = None, offline: bool = False) -> str:
51
+ """
52
+ Retrieve commits and generate a release changelog in Markdown.
53
+ """
54
+ commit_log = self.get_commits_in_range(from_ref, to_ref)
55
+ if not commit_log.strip():
56
+ return "No commits found in the specified range."
57
+
58
+ llm = get_llm(offline_override=offline)
59
+
60
+ user_prompt = USER_PROMPT_TEMPLATE.format(
61
+ commit_log=commit_log[:20000] # Cap log length to respect context window
62
+ )
63
+
64
+ messages = [
65
+ SystemMessage(content=CHANGELOG_SYSTEM_PROMPT),
66
+ HumanMessage(content=user_prompt)
67
+ ]
68
+
69
+ response = llm.invoke(messages)
70
+ return response.content.strip()
@@ -0,0 +1,81 @@
1
+ from typing import Dict, Any, List, Tuple
2
+ from langchain_core.messages import SystemMessage, HumanMessage
3
+ from ace.core.git_ops import GitOps
4
+ from ace.utils.diff_parser import split_diff_by_file, trim_diff
5
+ from ace.ai.llm_factory import get_llm
6
+ from ace.ai.prompts.review import REVIEW_SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
7
+ from ace.utils.json_utils import extract_json
8
+
9
+ class CodeReviewerError(Exception):
10
+ """Raised when code review processing fails."""
11
+ pass
12
+
13
+ class CodeReviewer:
14
+ def __init__(self, git_ops: GitOps):
15
+ self.git_ops = git_ops
16
+
17
+ def review_diff(self, diff_text: str, offline: bool = False) -> Tuple[List[Dict[str, Any]], float]:
18
+ """
19
+ Perform a code review on a raw diff string.
20
+
21
+ Returns a tuple of (findings, overall_score).
22
+ """
23
+ if not diff_text.strip():
24
+ return [], 10.0
25
+
26
+ # Split diff by file
27
+ file_diffs = split_diff_by_file(diff_text)
28
+ if not file_diffs:
29
+ return [], 10.0
30
+
31
+ all_findings = []
32
+ scores = []
33
+ llm = get_llm(offline_override=offline)
34
+
35
+ for filename, diff_content in file_diffs.items():
36
+ # Skip binary diffs or very small metadata updates
37
+ if "Binary files" in diff_content or len(diff_content.strip()) < 20:
38
+ continue
39
+
40
+ user_prompt = USER_PROMPT_TEMPLATE.format(
41
+ filename=filename,
42
+ diff_content=trim_diff(diff_content, max_chars=15000) # Cap file diff size
43
+ )
44
+
45
+ messages = [
46
+ SystemMessage(content=REVIEW_SYSTEM_PROMPT),
47
+ HumanMessage(content=user_prompt)
48
+ ]
49
+
50
+ try:
51
+ response = llm.invoke(messages)
52
+ parsed = extract_json(response.content)
53
+
54
+ # Extract score and findings
55
+ score = float(parsed.get("score", 10.0))
56
+ scores.append(score)
57
+
58
+ findings = parsed.get("findings", [])
59
+ if isinstance(findings, list):
60
+ for finding in findings:
61
+ # Annotate with the filename
62
+ finding["file"] = filename
63
+ all_findings.append(finding)
64
+ except Exception as e:
65
+ # Log or handle exception per file, but don't crash the whole review
66
+ # We can add a fallback warning finding
67
+ all_findings.append({
68
+ "file": filename,
69
+ "category": "suggestion",
70
+ "severity": "info",
71
+ "line": None,
72
+ "description": f"AI review failed for this file: {e}",
73
+ "fix": None
74
+ })
75
+ scores.append(10.0)
76
+
77
+ overall_score = sum(scores) / len(scores) if scores else 10.0
78
+ # Round overall score
79
+ overall_score = round(overall_score, 1)
80
+
81
+ return all_findings, overall_score
@@ -0,0 +1,73 @@
1
+ from langchain_core.messages import SystemMessage, HumanMessage
2
+ from ace.core.git_ops import GitOps
3
+ from ace.core.context import RepoContext
4
+ from ace.ai.llm_factory import get_llm
5
+ from ace.utils.diff_parser import trim_diff
6
+ from ace.ai.prompts.commit import (
7
+ CONVENTIONAL_COMMIT_SYSTEM_PROMPT,
8
+ SIMPLE_COMMIT_SYSTEM_PROMPT,
9
+ DETAILED_COMMIT_SYSTEM_PROMPT,
10
+ USER_PROMPT_TEMPLATE,
11
+ )
12
+
13
+ class NoStagedChangesError(Exception):
14
+ """Raised when trying to generate a commit message but no changes are staged."""
15
+ pass
16
+
17
+ class CommitGenerator:
18
+ def __init__(self, git_ops: GitOps):
19
+ self.git_ops = git_ops
20
+ self.context_builder = RepoContext(git_ops)
21
+
22
+ def generate_message(self, format_type: str = "conventional", offline: bool = False) -> str:
23
+ """
24
+ Analyze staged changes and generate a commit message using the configured AI.
25
+ """
26
+ # Ensure we have staged changes
27
+ status = self.git_ops.get_status()
28
+ if not status["staged"]:
29
+ raise NoStagedChangesError("No changes are staged for commit. Stage files first using 'git add'.")
30
+
31
+ staged_diff = self.git_ops.get_staged_diff()
32
+ if not staged_diff.strip():
33
+ # Sometimes status has staged but diff is empty (e.g. only file permissions or empty files)
34
+ raise NoStagedChangesError("Staged diff is empty. Cannot generate commit message.")
35
+
36
+ # Format context
37
+ repo_context = self.context_builder.format_context_for_prompt()
38
+
39
+ # Select system prompt based on format
40
+ if format_type == "conventional":
41
+ system_prompt = CONVENTIONAL_COMMIT_SYSTEM_PROMPT
42
+ elif format_type == "simple":
43
+ system_prompt = SIMPLE_COMMIT_SYSTEM_PROMPT
44
+ elif format_type == "detailed":
45
+ system_prompt = DETAILED_COMMIT_SYSTEM_PROMPT
46
+ else:
47
+ system_prompt = CONVENTIONAL_COMMIT_SYSTEM_PROMPT
48
+
49
+ user_prompt = USER_PROMPT_TEMPLATE.format(
50
+ repo_context=repo_context,
51
+ staged_diff=trim_diff(staged_diff, max_chars=25000) # Cap diff to avoid context window limit
52
+ )
53
+
54
+ messages = [
55
+ SystemMessage(content=system_prompt),
56
+ HumanMessage(content=user_prompt)
57
+ ]
58
+
59
+ # Get LLM and run inference
60
+ llm = get_llm(offline_override=offline)
61
+ response = llm.invoke(messages)
62
+
63
+ # Clean response (remove extra leading/trailing whitespace or markdown fences)
64
+ message = response.content.strip()
65
+ if message.startswith("```"):
66
+ lines = message.splitlines()
67
+ if lines[0].startswith("```"):
68
+ lines = lines[1:]
69
+ if lines and lines[-1].startswith("```"):
70
+ lines = lines[:-1]
71
+ message = "\n".join(lines).strip()
72
+
73
+ return message
@@ -0,0 +1,115 @@
1
+ from pathlib import Path
2
+ from typing import Dict, Any, List, Tuple
3
+ from langchain_core.messages import SystemMessage, HumanMessage
4
+ from ace.core.git_ops import GitOps
5
+ from ace.utils.conflict_parser import parse_conflict_file
6
+ from ace.ai.llm_factory import get_llm
7
+ from ace.ai.prompts.conflict import CONFLICT_SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
8
+ from ace.utils.json_utils import extract_json
9
+
10
+ class ConflictResolverError(Exception):
11
+ """Raised when conflict resolution fails."""
12
+ pass
13
+
14
+ class ConflictResolver:
15
+ def __init__(self, git_ops: GitOps):
16
+ self.git_ops = git_ops
17
+
18
+ def get_suggestions(self, file_path: str, offline: bool = False) -> List[Dict[str, Any]]:
19
+ """
20
+ Scan a file for conflicts and return AI resolution suggestions for each.
21
+
22
+ Returns a list of dicts:
23
+ [
24
+ {
25
+ "full_block": str,
26
+ "head": str,
27
+ "incoming": str,
28
+ "suggested_merged": str,
29
+ "explanation": str
30
+ }
31
+ ]
32
+ """
33
+ full_path = Path(self.git_ops.working_dir) / file_path
34
+ if not full_path.exists():
35
+ raise FileNotFoundError(f"File not found: {file_path}")
36
+
37
+ try:
38
+ content = full_path.read_text(encoding="utf-8")
39
+ except Exception as e:
40
+ raise ConflictResolverError(f"Failed to read file {file_path}: {e}")
41
+
42
+ blocks = parse_conflict_file(content)
43
+ if not blocks:
44
+ return []
45
+
46
+ llm = get_llm(offline_override=offline)
47
+ suggestions = []
48
+
49
+ for block in blocks:
50
+ sys_prompt = CONFLICT_SYSTEM_PROMPT.format(filename=file_path)
51
+ usr_prompt = USER_PROMPT_TEMPLATE.format(
52
+ head_content=block["head"],
53
+ incoming_content=block["incoming"]
54
+ )
55
+
56
+ messages = [
57
+ SystemMessage(content=sys_prompt),
58
+ HumanMessage(content=usr_prompt)
59
+ ]
60
+
61
+ try:
62
+ response = llm.invoke(messages)
63
+ parsed = extract_json(response.content)
64
+
65
+ suggestions.append({
66
+ "full_block": block["full_block"],
67
+ "head": block["head"],
68
+ "incoming": block["incoming"],
69
+ "suggested_merged": parsed.get("merged_content", block["head"]), # Fallback to head
70
+ "explanation": parsed.get("explanation", "AI suggested resolution.")
71
+ })
72
+ except Exception as e:
73
+ # Fallback in case of AI failures
74
+ suggestions.append({
75
+ "full_block": block["full_block"],
76
+ "head": block["head"],
77
+ "incoming": block["incoming"],
78
+ "suggested_merged": block["head"], # Keep head as fallback
79
+ "explanation": f"AI suggestion failed: {e}. Keeping local branch version as default suggestion."
80
+ })
81
+
82
+ return suggestions
83
+
84
+ def apply_resolution(self, file_path: str, block_replacements: List[Tuple[str, str]]) -> None:
85
+ """
86
+ Apply resolutions to a conflicted file.
87
+
88
+ block_replacements: List of tuples (full_conflict_block, replacement_content)
89
+ """
90
+ full_path = Path(self.git_ops.working_dir) / file_path
91
+ if not full_path.exists():
92
+ raise FileNotFoundError(f"File not found: {file_path}")
93
+
94
+ content = full_path.read_text(encoding="utf-8")
95
+
96
+ for full_block, replacement in block_replacements:
97
+ if full_block in content:
98
+ content = content.replace(full_block, replacement)
99
+ else:
100
+ # Try with normalized line endings
101
+ norm_block = full_block.replace("\r\n", "\n")
102
+ norm_content = content.replace("\r\n", "\n")
103
+ if norm_block in norm_content:
104
+ norm_content = norm_content.replace(norm_block, replacement)
105
+ # Restore Windows line endings if they were originally present
106
+ if "\r\n" in content:
107
+ content = norm_content.replace("\n", "\r\n")
108
+ else:
109
+ content = norm_content
110
+ else:
111
+ raise ConflictResolverError(
112
+ "Conflict block not found in file. Has it been edited already?"
113
+ )
114
+
115
+ full_path.write_text(content, encoding="utf-8")
@@ -0,0 +1,45 @@
1
+ import os
2
+ from typing import Dict, Any
3
+ from langchain_core.messages import SystemMessage, HumanMessage
4
+ from ace.core.git_ops import GitOps
5
+ from ace.ai.llm_factory import get_llm
6
+ from ace.ai.prompts.ignore import IGNORE_SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
7
+ from ace.utils.json_utils import extract_json
8
+
9
+ class GitignoreGenerator:
10
+ def __init__(self, git_ops: GitOps):
11
+ self.git_ops = git_ops
12
+
13
+ def generate_rules(self, query: str, offline: bool = False) -> Dict[str, Any]:
14
+ """
15
+ Reads the current .gitignore file (if any), queries the LLM with the user's
16
+ ignore request, and returns generated rules to append along with an explanation.
17
+ """
18
+ gitignore_path = os.path.join(self.git_ops.working_dir, ".gitignore")
19
+ current_content = ""
20
+ if os.path.exists(gitignore_path):
21
+ try:
22
+ with open(gitignore_path, "r", encoding="utf-8") as f:
23
+ current_content = f.read()
24
+ except Exception:
25
+ current_content = ""
26
+
27
+ llm = get_llm(offline_override=offline)
28
+
29
+ usr_prompt = USER_PROMPT_TEMPLATE.format(
30
+ query=query,
31
+ current_gitignore=current_content.strip() or "(empty)"
32
+ )
33
+
34
+ messages = [
35
+ SystemMessage(content=IGNORE_SYSTEM_PROMPT),
36
+ HumanMessage(content=usr_prompt)
37
+ ]
38
+
39
+ response = llm.invoke(messages)
40
+ parsed = extract_json(response.content)
41
+
42
+ if "rules" not in parsed or "explanation" not in parsed:
43
+ raise Exception("AI response JSON missing 'rules' or 'explanation' keys.")
44
+
45
+ return parsed
@@ -0,0 +1,188 @@
1
+ import collections
2
+ import os
3
+ import datetime
4
+ from typing import Dict, Any
5
+ from langchain_core.messages import SystemMessage, HumanMessage
6
+ from ace.core.git_ops import GitOps
7
+ from ace.ai.llm_factory import get_llm
8
+
9
+ HISTORY_SUMMARY_SYSTEM_PROMPT = """
10
+ You are "Ace", the AI Git Copilot.
11
+ The user asked a question about the repository's Git history, and we ran a Git command to fetch the raw data.
12
+ Your job is to analyze the raw Git output and summarize it for the user in a beautiful, helpful, and concise format.
13
+
14
+ User Query: "{query}"
15
+ Git Command Run: "{command}"
16
+
17
+ Analyze the Git output below and write the response. Use markdown, emojis, lists, or tables where helpful. Keep it concise.
18
+ """.strip()
19
+
20
+ class HistoryAnalyzer:
21
+ def __init__(self, git_ops: GitOps):
22
+ self.git_ops = git_ops
23
+
24
+ def summarize_query(self, query: str, command: str, command_output: str, offline: bool = False) -> str:
25
+ """
26
+ Summarize raw Git command output in response to a user's question.
27
+ """
28
+ if not command_output.strip():
29
+ return "The Git command returned no output, meaning there is no matching history found."
30
+
31
+ llm = get_llm(offline_override=offline)
32
+
33
+ sys_prompt = HISTORY_SUMMARY_SYSTEM_PROMPT.format(query=query, command=command)
34
+ user_prompt = f"Raw Git Output:\n\"\"\"\n{command_output[:20000]}\n\"\"\""
35
+
36
+ messages = [
37
+ SystemMessage(content=sys_prompt),
38
+ HumanMessage(content=user_prompt)
39
+ ]
40
+
41
+ response = llm.invoke(messages)
42
+ return response.content.strip()
43
+
44
+ def get_repo_stats(self) -> Dict[str, Any]:
45
+ """
46
+ Calculate repository statistics:
47
+ - Total commits
48
+ - Unique contributors
49
+ - Most active authors
50
+ - Recent commit frequency
51
+ """
52
+ try:
53
+ # Get list of all commits (hash|author|date)
54
+ log_data = self.git_ops.execute("log --format=%H|%an|%ad")
55
+ except Exception:
56
+ return {}
57
+
58
+ commits = [line.split("|") for line in log_data.splitlines() if "|" in line]
59
+ if not commits:
60
+ return {}
61
+
62
+ total_commits = len(commits)
63
+
64
+ # Contributors count
65
+ authors = [c[1] for c in commits]
66
+ author_counts = collections.Counter(authors)
67
+ contributors = list(author_counts.items())
68
+ # Sort contributors by commit count descending
69
+ contributors.sort(key=lambda x: x[1], reverse=True)
70
+
71
+ # Active branches
72
+ branches = self.git_ops.get_branches()
73
+
74
+ # Files changed counts (approximate from diff-tree of recent commits)
75
+ status = self.git_ops.get_status()
76
+
77
+ # Lines of code changes per author
78
+ try:
79
+ numstat_data = self.git_ops.execute('log --numstat --format=AUTHOR:%an')
80
+ except Exception:
81
+ numstat_data = ""
82
+
83
+ author_lines = {}
84
+ current_author = None
85
+ for line in numstat_data.splitlines():
86
+ line = line.strip().replace('"', '')
87
+ if not line:
88
+ continue
89
+ if line.startswith("AUTHOR:"):
90
+ current_author = line[7:]
91
+ if current_author not in author_lines:
92
+ author_lines[current_author] = {"added": 0, "deleted": 0}
93
+ elif current_author:
94
+ parts = line.split()
95
+ if len(parts) >= 3:
96
+ try:
97
+ added = int(parts[0])
98
+ deleted = int(parts[1])
99
+ author_lines[current_author]["added"] += added
100
+ author_lines[current_author]["deleted"] += deleted
101
+ except ValueError:
102
+ pass
103
+
104
+ # File type distribution (extensions)
105
+ try:
106
+ files_list = self.git_ops.execute("ls-files")
107
+ except Exception:
108
+ files_list = ""
109
+
110
+ extensions = []
111
+ for f in files_list.splitlines():
112
+ f = f.strip()
113
+ if not f:
114
+ continue
115
+ _, ext = os.path.splitext(f)
116
+ if ext:
117
+ extensions.append(ext.lower())
118
+ else:
119
+ extensions.append("(no extension)")
120
+
121
+ extension_counts = collections.Counter(extensions)
122
+
123
+ # Commit timeline (last 14 days)
124
+ try:
125
+ dates_data = self.git_ops.execute("log --format=%ad --date=short")
126
+ except Exception:
127
+ dates_data = ""
128
+
129
+ date_counts = collections.Counter(dates_data.splitlines())
130
+ today = datetime.date.today()
131
+ last_14_days = [today - datetime.timedelta(days=i) for i in range(13, -1, -1)]
132
+
133
+ timeline = []
134
+ for d in last_14_days:
135
+ date_str = d.isoformat()
136
+ count = date_counts.get(date_str, 0)
137
+ timeline.append((date_str, count))
138
+
139
+ return {
140
+ "total_commits": total_commits,
141
+ "total_branches": len(branches),
142
+ "contributors": contributors,
143
+ "staged_count": len(status["staged"]),
144
+ "unstaged_count": len(status["unstaged"]),
145
+ "untracked_count": len(status["untracked"]),
146
+ "lines_per_author": author_lines,
147
+ "extension_counts": dict(extension_counts.most_common(5)),
148
+ "timeline": timeline,
149
+ }
150
+
151
+
152
+ def semantic_search(self, query: str, limit: int = 50, offline: bool = False) -> Dict[str, Any]:
153
+ """
154
+ Retrieves the last `limit` commits, sends them to the LLM along with the search query,
155
+ and returns the semantically matching commits.
156
+ """
157
+ commits = self.git_ops.get_log(n=limit)
158
+ if not commits:
159
+ return {"matches": []}
160
+
161
+ history_lines = []
162
+ for c in commits:
163
+ history_lines.append(
164
+ f"Commit: {c['hexsha']}\nAuthor: {c['author']}\nDate: {c['date']}\nSummary: {c['summary']}\nMessage: {c['message']}\n---"
165
+ )
166
+ commit_history_text = "\n".join(history_lines)
167
+
168
+ from ace.ai.prompts.search import SEARCH_SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
169
+ from ace.utils.json_utils import extract_json
170
+
171
+ llm = get_llm(offline_override=offline)
172
+
173
+ if len(commit_history_text) > 25000:
174
+ commit_history_text = commit_history_text[:25000] + "\n\n... (history truncated due to size) ..."
175
+
176
+ usr_prompt = USER_PROMPT_TEMPLATE.format(
177
+ query=query,
178
+ commit_history=commit_history_text
179
+ )
180
+
181
+ messages = [
182
+ SystemMessage(content=SEARCH_SYSTEM_PROMPT),
183
+ HumanMessage(content=usr_prompt)
184
+ ]
185
+
186
+ response = llm.invoke(messages)
187
+ return extract_json(response.content)
188
+
@@ -0,0 +1,65 @@
1
+ from typing import Dict, Any
2
+ from langchain_core.messages import SystemMessage, HumanMessage
3
+ from ace.core.git_ops import GitOps
4
+ from ace.core.context import RepoContext
5
+ from ace.ai.llm_factory import get_llm
6
+ from ace.ai.prompts.intent import INTENT_SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
7
+ from ace.utils.json_utils import extract_json, JSONExtractError
8
+
9
+ class IntentParserError(Exception):
10
+ """Raised when intent parsing fails."""
11
+ pass
12
+
13
+ class IntentParser:
14
+ def __init__(self, git_ops: GitOps):
15
+ self.git_ops = git_ops
16
+ self.context_builder = RepoContext(git_ops)
17
+
18
+ def parse_intent(self, query: str, offline: bool = False) -> Dict[str, Any]:
19
+ """
20
+ Convert a natural language query into structured Git commands.
21
+
22
+ Returns a dict:
23
+ {
24
+ "commands": List[str],
25
+ "explanation": str,
26
+ "risk_level": "safe" | "moderate" | "destructive",
27
+ "alternatives": Optional[str]
28
+ }
29
+ """
30
+ # Fetch current context
31
+ repo_context = self.context_builder.format_context_for_prompt()
32
+
33
+ user_prompt = USER_PROMPT_TEMPLATE.format(
34
+ repo_context=repo_context,
35
+ query=query
36
+ )
37
+
38
+ messages = [
39
+ SystemMessage(content=INTENT_SYSTEM_PROMPT),
40
+ HumanMessage(content=user_prompt)
41
+ ]
42
+
43
+ # Initialize and call LLM
44
+ llm = get_llm(offline_override=offline)
45
+ response = llm.invoke(messages)
46
+
47
+ raw_content = response.content.strip()
48
+
49
+ # Parse the output using shared utility
50
+ try:
51
+ parsed = extract_json(raw_content)
52
+ except JSONExtractError as e:
53
+ raise IntentParserError(str(e))
54
+
55
+ # Validate keys and default types
56
+ commands = parsed.get("commands", [])
57
+ if not isinstance(commands, list):
58
+ commands = [str(commands)] if commands else []
59
+
60
+ return {
61
+ "commands": [cmd.strip() for cmd in commands],
62
+ "explanation": str(parsed.get("explanation", "No explanation provided.")),
63
+ "risk_level": str(parsed.get("risk_level", "safe")).lower(),
64
+ "alternatives": parsed.get("alternatives", None)
65
+ }