ai-comm 0.2.4__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.
ai_comm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Cross-AI CLI communication tool for kitty terminal."""
2
+
3
+ __version__ = "0.2.4"
@@ -0,0 +1,41 @@
1
+ """AI CLI adapters registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+
7
+ from .base import AIAdapter, ResponseCollector
8
+ from .generic import GenericAdapter
9
+
10
+
11
+ def get_adapter(name: str) -> AIAdapter:
12
+ """Get an adapter instance by name.
13
+
14
+ Loads adapter dynamically from registry to avoid duplication.
15
+ """
16
+ from ai_comm.registry import CLI_REGISTRY
17
+
18
+ info = CLI_REGISTRY.get(name)
19
+ if info is None:
20
+ return GenericAdapter()
21
+
22
+ module = importlib.import_module(info.adapter_module)
23
+ class_name = f"{name.capitalize()}Adapter"
24
+ adapter_class: type[AIAdapter] = getattr(module, class_name)
25
+ return adapter_class()
26
+
27
+
28
+ def list_adapters() -> list[str]:
29
+ """List available adapter names."""
30
+ from ai_comm.registry import list_cli_names
31
+
32
+ return list_cli_names()
33
+
34
+
35
+ __all__ = [
36
+ "AIAdapter",
37
+ "GenericAdapter",
38
+ "ResponseCollector",
39
+ "get_adapter",
40
+ "list_adapters",
41
+ ]
@@ -0,0 +1,63 @@
1
+ """Adapter for Aider CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import ClassVar
7
+
8
+ from ai_comm.parsers.utils import is_separator_line, strip_trailing_empty
9
+
10
+ from .base import AIAdapter
11
+
12
+
13
+ class AiderAdapter(AIAdapter):
14
+ """Adapter for Aider CLI responses."""
15
+
16
+ name: ClassVar[str] = "aider"
17
+
18
+ TOKEN_PATTERN = re.compile(r"^Tokens:\s+[\d.]+[kM]?\s+sent")
19
+
20
+ def format_message(self, message: str, sender: str | None = None) -> str:
21
+ """Prepend /ask to prevent automatic file edits."""
22
+ base_message = super().format_message(message, sender)
23
+ return "/ask " + base_message
24
+
25
+ def extract_last_response(self, text: str) -> str:
26
+ """Extract the last response from Aider CLI output."""
27
+ lines = text.split("\n")
28
+
29
+ input_end_indices: list[int] = []
30
+ in_input = False
31
+
32
+ for i, line in enumerate(lines):
33
+ if line.startswith("> ") and line.strip() != ">":
34
+ in_input = True
35
+ elif in_input:
36
+ input_end_indices.append(i)
37
+ in_input = False
38
+
39
+ if not input_end_indices:
40
+ return ""
41
+
42
+ last_input_end = input_end_indices[-1]
43
+ response_lines: list[str] = []
44
+
45
+ for i in range(last_input_end, len(lines)):
46
+ line = lines[i]
47
+ stripped = line.strip()
48
+
49
+ if not response_lines and not stripped:
50
+ continue
51
+
52
+ if is_separator_line(stripped):
53
+ break
54
+ if self.TOKEN_PATTERN.match(stripped):
55
+ break
56
+ if stripped == ">":
57
+ break
58
+ if line.startswith("> "):
59
+ break
60
+
61
+ response_lines.append(line)
62
+
63
+ return self.finalize_response(strip_trailing_empty(response_lines))
@@ -0,0 +1,132 @@
1
+ """Base adapter for AI CLI interactions using composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import TYPE_CHECKING, ClassVar
7
+
8
+ from ai_comm.parsers.utils import (
9
+ clean_response_lines,
10
+ join_response,
11
+ remove_base_indent,
12
+ )
13
+
14
+ if TYPE_CHECKING:
15
+ from ai_comm.kitten_client import KittenClient
16
+
17
+
18
+ class AIAdapter(ABC):
19
+ """Abstract base class for AI CLI adapters.
20
+
21
+ Uses composition: wraps a ResponseParser for parsing logic.
22
+ Adds CLI-specific message formatting and response fetching.
23
+ """
24
+
25
+ name: ClassVar[str] = "base"
26
+
27
+ STATUS_INDICATORS: ClassVar[list[str]] = []
28
+ BASE_INDENT: ClassVar[int] = 0
29
+
30
+ def format_message(self, message: str, sender: str | None = None) -> str:
31
+ """Format outgoing message for this CLI.
32
+
33
+ Default: Add sender header if available.
34
+ Override: e.g., Aider adds /ask prefix.
35
+ """
36
+ if sender:
37
+ header = (
38
+ f"[ai-comm: Cross-AI Message]\n"
39
+ f"From: {sender}\n"
40
+ f"Note: This is NOT user input. Another AI assistant is sending "
41
+ f"you this message programmatically via the ai-comm tool.\n"
42
+ f"---\n"
43
+ )
44
+ return header + message
45
+ return message
46
+
47
+ def fetch_response(
48
+ self,
49
+ client: KittenClient,
50
+ window_id: int,
51
+ extent: str = "all",
52
+ ) -> str:
53
+ """Fetch and parse response from window.
54
+
55
+ Default: Get terminal text and parse.
56
+ Override: e.g., OpenCode uses export command.
57
+ """
58
+ text = client.get_text(window_id, extent)
59
+ return self.extract_last_response(text)
60
+
61
+ @abstractmethod
62
+ def extract_last_response(self, text: str) -> str:
63
+ """Extract the last response from terminal output."""
64
+ raise NotImplementedError
65
+
66
+ def is_status_line(self, line: str) -> bool:
67
+ """Check if line is a status bar line (should be skipped)."""
68
+ if not self.STATUS_INDICATORS:
69
+ return False
70
+ stripped = line.strip()
71
+ return any(indicator in stripped for indicator in self.STATUS_INDICATORS)
72
+
73
+ def strip_indent(self, line: str) -> str:
74
+ """Remove base indentation from line."""
75
+ return remove_base_indent(line, self.BASE_INDENT)
76
+
77
+ def finalize_response(self, lines: list[str]) -> str:
78
+ """Clean up and join response lines."""
79
+ cleaned = clean_response_lines(lines)
80
+ return join_response(cleaned)
81
+
82
+
83
+ class ResponseCollector:
84
+ """Helper for collecting multi-block responses.
85
+
86
+ Common pattern used by most adapters:
87
+ - Track multiple response blocks
88
+ - Handle in_response state
89
+ - Save current block when new one starts or control element found
90
+ """
91
+
92
+ def __init__(self) -> None:
93
+ self.responses: list[list[str]] = []
94
+ self.current: list[str] = []
95
+ self.in_response: bool = False
96
+
97
+ def start_new(self, first_line: str | None = None) -> None:
98
+ """Start a new response block, saving current if exists."""
99
+ if self.in_response and self.current:
100
+ self.responses.append(self.current)
101
+ self.current = []
102
+ self.in_response = True
103
+ if first_line is not None:
104
+ self.current.append(first_line)
105
+
106
+ def end_current(self) -> None:
107
+ """End current response block."""
108
+ if self.in_response and self.current:
109
+ self.responses.append(self.current)
110
+ self.current = []
111
+ self.in_response = False
112
+
113
+ def add_line(self, line: str) -> None:
114
+ """Add line to current response if collecting."""
115
+ if self.in_response:
116
+ self.current.append(line)
117
+
118
+ def add_empty(self) -> None:
119
+ """Add empty line to current response if collecting."""
120
+ if self.in_response:
121
+ self.current.append("")
122
+
123
+ def finalize(self) -> list[str]:
124
+ """Get the last response block."""
125
+ if self.in_response and self.current:
126
+ self.responses.append(self.current)
127
+ self.current = []
128
+ self.in_response = False
129
+
130
+ if self.responses:
131
+ return self.responses[-1]
132
+ return []
@@ -0,0 +1,72 @@
1
+ """Adapter for Claude Code CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import ClassVar
7
+
8
+ from ai_comm.parsers.utils import is_separator_line
9
+
10
+ from .base import AIAdapter, ResponseCollector
11
+
12
+
13
+ class ClaudeAdapter(AIAdapter):
14
+ """Adapter for Claude Code CLI responses."""
15
+
16
+ name: ClassVar[str] = "claude"
17
+ BASE_INDENT: ClassVar[int] = 2
18
+ STATUS_INDICATORS: ClassVar[list[str]] = ["tokens", "§", "☉", "$", "◔", "⎇"]
19
+
20
+ TOOL_CALL_PATTERN = re.compile(
21
+ r"^⏺\s*(Read|Write|Edit|Bash|Glob|Grep|Task|WebFetch|WebSearch|"
22
+ r"TodoWrite|NotebookEdit|AskUserQuestion|KillShell|TaskOutput)\s*\("
23
+ )
24
+
25
+ SPINNER_CHARS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
26
+
27
+ def _is_control_element(self, stripped: str) -> bool:
28
+ """Check if line is a control element that ends a response."""
29
+ if stripped.startswith(">"):
30
+ return True
31
+ if stripped.startswith("∴"):
32
+ return True
33
+ if stripped.startswith("⎿"):
34
+ return True
35
+ if stripped.startswith(("╭", "╰")):
36
+ return True
37
+ return is_separator_line(stripped)
38
+
39
+ def extract_last_response(self, text: str) -> str:
40
+ """Extract the last response from Claude Code output."""
41
+ lines = text.split("\n")
42
+ collector = ResponseCollector()
43
+
44
+ for line in lines:
45
+ stripped = line.strip()
46
+
47
+ if self.is_status_line(line):
48
+ continue
49
+
50
+ if stripped and stripped[0] in self.SPINNER_CHARS:
51
+ continue
52
+
53
+ if stripped.startswith("⏺"):
54
+ if self.TOOL_CALL_PATTERN.match(stripped):
55
+ collector.end_current()
56
+ continue
57
+
58
+ content = stripped[1:].strip()
59
+ collector.start_new(content if content else None)
60
+ continue
61
+
62
+ if self._is_control_element(stripped):
63
+ collector.end_current()
64
+ continue
65
+
66
+ if collector.in_response:
67
+ if not stripped:
68
+ collector.add_empty()
69
+ else:
70
+ collector.add_line(self.strip_indent(line))
71
+
72
+ return self.finalize_response(collector.finalize())
@@ -0,0 +1,50 @@
1
+ """Adapter for OpenAI Codex CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from .base import AIAdapter
8
+
9
+
10
+ class CodexAdapter(AIAdapter):
11
+ """Adapter for OpenAI Codex CLI responses."""
12
+
13
+ name: ClassVar[str] = "codex"
14
+ BASE_INDENT: ClassVar[int] = 2
15
+
16
+ def extract_last_response(self, text: str) -> str:
17
+ """Extract the last response block starting with bullet."""
18
+ lines = text.split("\n")
19
+
20
+ response_start_indices = [
21
+ i for i, line in enumerate(lines) if line.strip().startswith("•")
22
+ ]
23
+
24
+ if not response_start_indices:
25
+ return ""
26
+
27
+ last_start = response_start_indices[-1]
28
+ response_lines: list[str] = []
29
+
30
+ for i in range(last_start, len(lines)):
31
+ line = lines[i]
32
+ stripped = line.strip()
33
+
34
+ if i == last_start:
35
+ response_lines.append(stripped[1:].strip())
36
+ elif line.startswith(" " * self.BASE_INDENT) and not stripped.startswith(
37
+ "›"
38
+ ):
39
+ response_lines.append(self.strip_indent(line))
40
+ elif stripped.startswith("›"):
41
+ break
42
+ elif stripped == "":
43
+ remaining = lines[i + 1 :] if i + 1 < len(lines) else []
44
+ next_content = next((ln for ln in remaining if ln.strip()), "")
45
+ if next_content.strip().startswith("›"):
46
+ break
47
+ else:
48
+ break
49
+
50
+ return self.finalize_response(response_lines)
@@ -0,0 +1,71 @@
1
+ """Adapter for Cursor Agent CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from ai_comm.parsers.utils import strip_trailing_empty
8
+
9
+ from .base import AIAdapter
10
+
11
+
12
+ class CursorAdapter(AIAdapter):
13
+ """Adapter for Cursor Agent CLI responses."""
14
+
15
+ name: ClassVar[str] = "cursor"
16
+ STATUS_INDICATORS: ClassVar[list[str]] = [
17
+ "Composer",
18
+ "/ commands",
19
+ "@ files",
20
+ "! shell",
21
+ ]
22
+
23
+ def _is_box_end(self, line: str) -> bool:
24
+ """Check if line is a box end."""
25
+ stripped = line.strip()
26
+ return stripped.startswith("└") and "─" in stripped and stripped.endswith("┘")
27
+
28
+ def _is_box_start(self, line: str) -> bool:
29
+ """Check if line is a box start."""
30
+ stripped = line.strip()
31
+ return stripped.startswith("┌") and "─" in stripped
32
+
33
+ def extract_last_response(self, text: str) -> str:
34
+ """Extract the last response from Cursor Agent output."""
35
+ lines = text.split("\n")
36
+
37
+ box_ends = [i for i, line in enumerate(lines) if self._is_box_end(line)]
38
+
39
+ if not box_ends:
40
+ return ""
41
+
42
+ user_input_end = -1
43
+ for end_idx in box_ends:
44
+ is_prompt_box = any(
45
+ "→" in lines[j] for j in range(max(0, end_idx - 5), end_idx)
46
+ )
47
+ if not is_prompt_box:
48
+ user_input_end = end_idx
49
+
50
+ if user_input_end == -1:
51
+ return ""
52
+
53
+ response_lines: list[str] = []
54
+ in_response = False
55
+
56
+ for i in range(user_input_end + 1, len(lines)):
57
+ line = lines[i]
58
+ stripped = line.strip()
59
+
60
+ if not in_response and not stripped:
61
+ continue
62
+
63
+ if self._is_box_start(line):
64
+ break
65
+ if self.is_status_line(line):
66
+ break
67
+
68
+ in_response = True
69
+ response_lines.append(line)
70
+
71
+ return self.finalize_response(strip_trailing_empty(response_lines))
@@ -0,0 +1,56 @@
1
+ """Adapter for Gemini CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from .base import AIAdapter, ResponseCollector
8
+
9
+
10
+ class GeminiAdapter(AIAdapter):
11
+ """Adapter for Gemini CLI responses."""
12
+
13
+ name: ClassVar[str] = "gemini"
14
+ BASE_INDENT: ClassVar[int] = 2
15
+ STATUS_INDICATORS: ClassVar[list[str]] = [
16
+ "no sandbox",
17
+ "/model",
18
+ "Type your message",
19
+ "accepting edits",
20
+ ]
21
+
22
+ def extract_last_response(self, text: str) -> str:
23
+ """Extract the last response from Gemini CLI output."""
24
+ lines = text.split("\n")
25
+ collector = ResponseCollector()
26
+
27
+ for line in lines:
28
+ stripped = line.strip()
29
+
30
+ if not stripped:
31
+ collector.add_empty()
32
+ continue
33
+
34
+ if self.is_status_line(line):
35
+ continue
36
+
37
+ if not collector.in_response and stripped.startswith(("╭", "╰", "│", "ℹ")):
38
+ continue
39
+
40
+ if collector.in_response and stripped.startswith("╭─"):
41
+ collector.end_current()
42
+ continue
43
+
44
+ if stripped.startswith("✦"):
45
+ content = stripped[1:].strip()
46
+ collector.start_new(content if content else None)
47
+ continue
48
+
49
+ if stripped.startswith(">"):
50
+ collector.end_current()
51
+ continue
52
+
53
+ if collector.in_response:
54
+ collector.add_line(self.strip_indent(line))
55
+
56
+ return self.finalize_response(collector.finalize())
@@ -0,0 +1,42 @@
1
+ """Generic adapter using common prompt patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import ClassVar
7
+
8
+ from .base import AIAdapter
9
+
10
+
11
+ class GenericAdapter(AIAdapter):
12
+ """Generic adapter that tries to extract the last response block."""
13
+
14
+ name: ClassVar[str] = "generic"
15
+
16
+ PROMPT_PATTERN = re.compile(r"^[>$%›»❯➜]\s*", re.MULTILINE)
17
+ EMPTY_PROMPT_PATTERN = re.compile(r"^[>$%›»❯➜]\s*$", re.MULTILINE)
18
+
19
+ def extract_last_response(self, text: str) -> str:
20
+ """Extract content after the last prompt-like pattern."""
21
+ lines = text.strip().split("\n")
22
+
23
+ prompt_lines: list[tuple[int, bool]] = []
24
+ for i, line in enumerate(lines):
25
+ normalized = line.replace("\u00a0", " ").strip()
26
+ if self.PROMPT_PATTERN.match(normalized):
27
+ is_empty = bool(self.EMPTY_PROMPT_PATTERN.fullmatch(normalized))
28
+ prompt_lines.append((i, is_empty))
29
+
30
+ if not prompt_lines:
31
+ non_empty = [line for line in lines if line.strip()]
32
+ return self.finalize_response(non_empty[-10:]) if non_empty else ""
33
+
34
+ last_idx, last_is_empty = prompt_lines[-1]
35
+
36
+ if last_is_empty:
37
+ if len(prompt_lines) < 2:
38
+ return self.finalize_response(lines[last_idx + 1 :])
39
+ second_last_idx = prompt_lines[-2][0]
40
+ return self.finalize_response(lines[second_last_idx + 1 : last_idx])
41
+
42
+ return self.finalize_response(lines[last_idx + 1 :])
@@ -0,0 +1,151 @@
1
+ """Adapter for OpenCode CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, ClassVar
10
+
11
+ from ai_comm.parsers.utils import clean_response_lines
12
+
13
+ from .base import AIAdapter, ResponseCollector
14
+
15
+ if TYPE_CHECKING:
16
+ from ai_comm.kitten_client import KittenClient
17
+
18
+
19
+ class OpenCodeAdapter(AIAdapter):
20
+ """Adapter for OpenCode CLI responses."""
21
+
22
+ name: ClassVar[str] = "opencode"
23
+ STATUS_INDICATORS: ClassVar[list[str]] = [
24
+ "OpenCode",
25
+ "tab switch agent",
26
+ "ctrl+p commands",
27
+ ]
28
+
29
+ RIGHT_PANEL_MARKERS: ClassVar[list[str]] = [
30
+ "Greeting",
31
+ "Context",
32
+ r"[\d,]+\s*tokens",
33
+ r"\d+%\s*used",
34
+ r"\$[\d.]+\s*spent",
35
+ "LSP",
36
+ "LSPs will activate",
37
+ ]
38
+
39
+ def fetch_response(
40
+ self,
41
+ client: KittenClient,
42
+ window_id: int,
43
+ extent: str = "all",
44
+ ) -> str:
45
+ """Use OpenCode export command for complete response with fallback."""
46
+ export_response = self._get_via_export()
47
+ if export_response:
48
+ return export_response
49
+
50
+ return super().fetch_response(client, window_id, extent)
51
+
52
+ def _get_via_export(self) -> str | None:
53
+ """Get last response via opencode export command."""
54
+ session_dir = Path.home() / ".local/share/opencode/storage/session/global"
55
+ if not session_dir.exists():
56
+ return None
57
+
58
+ session_files = sorted(
59
+ session_dir.glob("ses_*.json"), key=lambda p: p.stat().st_mtime
60
+ )
61
+ if not session_files:
62
+ return None
63
+
64
+ session_id = session_files[-1].stem
65
+
66
+ try:
67
+ result = subprocess.run(
68
+ ["opencode", "export", session_id],
69
+ check=False,
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=10,
73
+ )
74
+ if result.returncode != 0:
75
+ return None
76
+
77
+ output = result.stdout
78
+ json_start = output.find("{")
79
+ if json_start == -1:
80
+ return None
81
+
82
+ data = json.loads(output[json_start:])
83
+ messages = data.get("messages", [])
84
+
85
+ for msg in reversed(messages):
86
+ if msg.get("info", {}).get("role") != "assistant":
87
+ continue
88
+ for part in msg.get("parts", []):
89
+ if part.get("type") == "text":
90
+ text: str = part.get("text", "")
91
+ return text
92
+
93
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError):
94
+ pass
95
+
96
+ return None
97
+
98
+ def _detect_right_panel_column(self, lines: list[str]) -> int:
99
+ """Detect the starting column of the right panel."""
100
+ min_col = float("inf")
101
+
102
+ for line in lines:
103
+ for marker in self.RIGHT_PANEL_MARKERS:
104
+ match = re.search(marker, line)
105
+ if match:
106
+ col = match.start()
107
+ if col > 20 and line[col - 1] == " ":
108
+ min_col = min(min_col, col)
109
+
110
+ return int(min_col) if min_col != float("inf") else -1
111
+
112
+ def _strip_right_panel(self, line: str, panel_col: int) -> str:
113
+ """Remove right panel by truncating at detected column."""
114
+ if panel_col > 0 and len(line) > panel_col:
115
+ return line[:panel_col].rstrip()
116
+ return line.rstrip()
117
+
118
+ def extract_last_response(self, text: str) -> str:
119
+ """Extract the last response from OpenCode output."""
120
+ lines = text.split("\n")
121
+ panel_col = self._detect_right_panel_column(lines)
122
+ collector = ResponseCollector()
123
+
124
+ for line in lines:
125
+ stripped = line.strip()
126
+
127
+ if self.is_status_line(line):
128
+ continue
129
+
130
+ if "╹" in stripped or "▀" in stripped:
131
+ continue
132
+
133
+ if stripped.startswith("▣"):
134
+ collector.end_current()
135
+ continue
136
+
137
+ if stripped.startswith("┃"):
138
+ collector.end_current()
139
+ continue
140
+
141
+ if not stripped:
142
+ collector.add_empty()
143
+ continue
144
+
145
+ if not collector.in_response:
146
+ collector.start_new()
147
+ collector.add_line(self._strip_right_panel(line, panel_col))
148
+
149
+ result = collector.finalize()
150
+ cleaned = clean_response_lines(result)
151
+ return "\n".join(cleaned) if cleaned else ""