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.
@@ -0,0 +1,199 @@
1
+ """Kitten client - wrapper for calling the simplified kitten via subprocess."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ class KittenError(Exception):
14
+ """Base exception for kitten errors."""
15
+
16
+ pass
17
+
18
+
19
+ class WindowNotFoundError(KittenError):
20
+ """Window not found."""
21
+
22
+ pass
23
+
24
+
25
+ class KittenCallError(KittenError):
26
+ """Failed to call kitten."""
27
+
28
+ pass
29
+
30
+
31
+ @dataclass
32
+ class KittenResult:
33
+ """Result from kitten call."""
34
+
35
+ status: str
36
+ data: dict[str, Any]
37
+
38
+
39
+ class KittenClient:
40
+ """Client for calling the simplified ai_comm_kitten."""
41
+
42
+ # Default kitten path - bundled within the package
43
+ KITTEN_PATH = Path(__file__).parent / "kitten" / "ai_comm_kitten.py"
44
+
45
+ def __init__(self, socket: str | None = None) -> None:
46
+ """Initialize client.
47
+
48
+ Args:
49
+ socket: Optional kitty socket path. If None, uses KITTY_LISTEN_ON env var.
50
+ """
51
+ self.socket = socket or os.environ.get("KITTY_LISTEN_ON")
52
+ self._kitten_path = str(self.KITTEN_PATH.resolve())
53
+
54
+ def _call(self, *args: str, timeout: float = 15) -> KittenResult:
55
+ """Call kitten and return result.
56
+
57
+ Args:
58
+ *args: Arguments to pass to kitten
59
+ timeout: Subprocess timeout in seconds
60
+
61
+ Returns:
62
+ KittenResult with status and data
63
+
64
+ Raises:
65
+ KittenCallError: If call fails
66
+ WindowNotFoundError: If window not found
67
+ """
68
+ cmd = ["kitty", "@"]
69
+ if self.socket:
70
+ cmd.extend(["--to", self.socket])
71
+ cmd.extend(["kitten", self._kitten_path, *args])
72
+
73
+ try:
74
+ result = subprocess.run(
75
+ cmd,
76
+ check=False,
77
+ capture_output=True,
78
+ text=True,
79
+ timeout=timeout,
80
+ )
81
+ except subprocess.TimeoutExpired as e:
82
+ raise KittenCallError(f"Kitten call timed out after {timeout}s") from e
83
+ except Exception as e:
84
+ raise KittenCallError(f"Failed to call kitten: {e}") from e
85
+
86
+ if result.returncode != 0:
87
+ stderr = result.stderr.strip()
88
+ if "i/o timeout" in stderr.lower():
89
+ raise KittenCallError("Kitty I/O timeout - kitten took too long")
90
+ raise KittenCallError(f"Kitten failed: {stderr or result.stdout}")
91
+
92
+ try:
93
+ data = json.loads(result.stdout)
94
+ except json.JSONDecodeError as e:
95
+ raise KittenCallError(f"Invalid JSON from kitten: {result.stdout}") from e
96
+
97
+ if data.get("status") == "error":
98
+ msg = data.get("message", "Unknown error")
99
+ if "not found" in msg.lower():
100
+ raise WindowNotFoundError(msg)
101
+ raise KittenCallError(msg)
102
+
103
+ return KittenResult(status=data["status"], data=data)
104
+
105
+ def get_text(self, window_id: int, extent: str = "all") -> str:
106
+ """Get text content from window.
107
+
108
+ Args:
109
+ window_id: Target window ID
110
+ extent: "all" or "screen"
111
+
112
+ Returns:
113
+ Window text content
114
+ """
115
+ result = self._call("get-text", "--window", str(window_id), "--extent", extent)
116
+ text: str = result.data.get("text", "")
117
+ return text
118
+
119
+ def get_text_hash(self, window_id: int, extent: str = "all") -> tuple[str, str]:
120
+ """Get text content and hash from window.
121
+
122
+ Args:
123
+ window_id: Target window ID
124
+ extent: "all" or "screen"
125
+
126
+ Returns:
127
+ Tuple of (text, hash)
128
+ """
129
+ result = self._call("get-text", "--window", str(window_id), "--extent", extent)
130
+ text: str = result.data.get("text", "")
131
+ hash_val: str = result.data.get("hash", "")
132
+ return text, hash_val
133
+
134
+ def send_text(self, window_id: int, text: str) -> bool:
135
+ """Send text to window.
136
+
137
+ Args:
138
+ window_id: Target window ID
139
+ text: Text to send
140
+
141
+ Returns:
142
+ True if successful
143
+ """
144
+ result = self._call("send-text", "--window", str(window_id), text)
145
+ return result.status == "ok"
146
+
147
+ def send_key(self, window_id: int, key: str) -> bool:
148
+ """Send key press to window.
149
+
150
+ Args:
151
+ window_id: Target window ID
152
+ key: Key name (e.g., "enter", "escape")
153
+
154
+ Returns:
155
+ True if successful
156
+ """
157
+ result = self._call("send-key", "--window", str(window_id), key)
158
+ return result.status == "ok"
159
+
160
+ def check_idle(self, window_id: int, last_hash: str = "") -> tuple[bool, str]:
161
+ """Check if window content has changed.
162
+
163
+ Args:
164
+ window_id: Target window ID
165
+ last_hash: Previous content hash
166
+
167
+ Returns:
168
+ Tuple of (is_idle, current_hash)
169
+ """
170
+ result = self._call(
171
+ "check-idle", "--window", str(window_id), "--last-hash", last_hash
172
+ )
173
+ return result.data.get("idle", False), result.data.get("hash", "")
174
+
175
+ def list_ai_windows(self) -> list[dict[str, Any]]:
176
+ """List windows running AI CLIs.
177
+
178
+ Returns:
179
+ List of AI window info dicts with id, cli, cwd
180
+ """
181
+ result = self._call("list-ai-windows")
182
+ ai_windows: list[dict[str, Any]] = result.data.get("ai_windows", [])
183
+ return ai_windows
184
+
185
+ def get_window_cli(self, window_id: int) -> str | None:
186
+ """Get CLI type for a window.
187
+
188
+ Args:
189
+ window_id: Target window ID
190
+
191
+ Returns:
192
+ CLI name (e.g., "claude", "codex") or None if not an AI window
193
+ """
194
+ ai_windows = self.list_ai_windows()
195
+ for w in ai_windows:
196
+ if w["id"] == window_id:
197
+ cli: str = w["cli"]
198
+ return cli
199
+ return None
@@ -0,0 +1,27 @@
1
+ """Response parsers - low-level parsing utilities.
2
+
3
+ For AI CLI adapters, use ai_comm.adapters instead.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .base import ResponseCollector, ResponseParser
9
+ from .utils import (
10
+ clean_response_lines,
11
+ is_separator_line,
12
+ join_response,
13
+ remove_base_indent,
14
+ strip_leading_empty,
15
+ strip_trailing_empty,
16
+ )
17
+
18
+ __all__ = [
19
+ "ResponseCollector",
20
+ "ResponseParser",
21
+ "clean_response_lines",
22
+ "is_separator_line",
23
+ "join_response",
24
+ "remove_base_indent",
25
+ "strip_leading_empty",
26
+ "strip_trailing_empty",
27
+ ]
@@ -0,0 +1,108 @@
1
+ """Base class for response parsers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import ClassVar
7
+
8
+ from .utils import clean_response_lines, join_response, remove_base_indent
9
+
10
+
11
+ class ResponseParser(ABC):
12
+ """Abstract base class for AI CLI response parsers.
13
+
14
+ Subclasses should override:
15
+ - name: Parser identifier
16
+ - STATUS_INDICATORS: Strings that indicate status bar lines (filtered out)
17
+ - BASE_INDENT: Number of spaces for base indentation (stripped from content)
18
+ - extract_last_response(): Main parsing logic
19
+ """
20
+
21
+ name: str = "base"
22
+
23
+ # Override in subclasses: strings that indicate status bar lines
24
+ STATUS_INDICATORS: ClassVar[list[str]] = []
25
+
26
+ # Override in subclasses: base indentation to strip (0 = no stripping)
27
+ BASE_INDENT: ClassVar[int] = 0
28
+
29
+ @abstractmethod
30
+ def extract_last_response(self, text: str) -> str:
31
+ """Extract the last response from the terminal output.
32
+
33
+ Args:
34
+ text: Full terminal text content (screen + scrollback)
35
+
36
+ Returns:
37
+ The extracted response text
38
+ """
39
+ raise NotImplementedError
40
+
41
+ def is_status_line(self, line: str) -> bool:
42
+ """Check if line is a status bar line (should be skipped)."""
43
+ if not self.STATUS_INDICATORS:
44
+ return False
45
+ stripped = line.strip()
46
+ return any(indicator in stripped for indicator in self.STATUS_INDICATORS)
47
+
48
+ def strip_indent(self, line: str) -> str:
49
+ """Remove base indentation from line."""
50
+ return remove_base_indent(line, self.BASE_INDENT)
51
+
52
+ def finalize_response(self, lines: list[str]) -> str:
53
+ """Clean up and join response lines."""
54
+ cleaned = clean_response_lines(lines)
55
+ return join_response(cleaned)
56
+
57
+
58
+ class ResponseCollector:
59
+ """Helper for collecting multi-block responses.
60
+
61
+ Common pattern used by most parsers:
62
+ - Track multiple response blocks
63
+ - Handle in_response state
64
+ - Save current block when new one starts or control element found
65
+ """
66
+
67
+ def __init__(self) -> None:
68
+ self.responses: list[list[str]] = []
69
+ self.current: list[str] = []
70
+ self.in_response: bool = False
71
+
72
+ def start_new(self, first_line: str | None = None) -> None:
73
+ """Start a new response block, saving current if exists."""
74
+ if self.in_response and self.current:
75
+ self.responses.append(self.current)
76
+ self.current = []
77
+ self.in_response = True
78
+ if first_line is not None:
79
+ self.current.append(first_line)
80
+
81
+ def end_current(self) -> None:
82
+ """End current response block."""
83
+ if self.in_response and self.current:
84
+ self.responses.append(self.current)
85
+ self.current = []
86
+ self.in_response = False
87
+
88
+ def add_line(self, line: str) -> None:
89
+ """Add line to current response if collecting."""
90
+ if self.in_response:
91
+ self.current.append(line)
92
+
93
+ def add_empty(self) -> None:
94
+ """Add empty line to current response if collecting."""
95
+ if self.in_response:
96
+ self.current.append("")
97
+
98
+ def finalize(self) -> list[str]:
99
+ """Get the last response block."""
100
+ # Don't forget current block
101
+ if self.in_response and self.current:
102
+ self.responses.append(self.current)
103
+ self.current = []
104
+ self.in_response = False
105
+
106
+ if self.responses:
107
+ return self.responses[-1]
108
+ return []
@@ -0,0 +1,42 @@
1
+ """Common utilities for response parsers."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def is_separator_line(line: str) -> bool:
7
+ """Check if line is a separator (all ─ characters)."""
8
+ stripped = line.strip()
9
+ return bool(stripped) and all(c == "─" for c in stripped)
10
+
11
+
12
+ def strip_trailing_empty(lines: list[str]) -> list[str]:
13
+ """Remove trailing empty lines from a list."""
14
+ result = lines.copy()
15
+ while result and not result[-1].strip():
16
+ result.pop()
17
+ return result
18
+
19
+
20
+ def strip_leading_empty(lines: list[str]) -> list[str]:
21
+ """Remove leading empty lines from a list."""
22
+ result = lines.copy()
23
+ while result and not result[0].strip():
24
+ result.pop(0)
25
+ return result
26
+
27
+
28
+ def clean_response_lines(lines: list[str]) -> list[str]:
29
+ """Strip leading and trailing empty lines."""
30
+ return strip_leading_empty(strip_trailing_empty(lines))
31
+
32
+
33
+ def remove_base_indent(line: str, indent: int) -> str:
34
+ """Remove base indentation while preserving relative indent."""
35
+ if indent > 0 and line.startswith(" " * indent):
36
+ return line[indent:]
37
+ return line
38
+
39
+
40
+ def join_response(lines: list[str]) -> str:
41
+ """Join response lines and strip result."""
42
+ return "\n".join(lines).strip()
ai_comm/polling.py ADDED
@@ -0,0 +1,88 @@
1
+ """Polling logic for waiting on window content changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+
8
+
9
+ class PollingTimeoutError(Exception):
10
+ """Raised when polling times out."""
11
+
12
+ def __init__(self, timeout: float, elapsed: float) -> None:
13
+ super().__init__(f"Timeout after {elapsed:.1f}s (limit: {timeout}s)")
14
+ self.timeout = timeout
15
+ self.elapsed = elapsed
16
+
17
+
18
+ def wait_for_idle(
19
+ check_fn: Callable[[str], tuple[bool, str]],
20
+ idle_seconds: int = 3,
21
+ timeout: float = 1800,
22
+ poll_interval: float = 1.0,
23
+ ) -> float:
24
+ """Wait for content to stabilize.
25
+
26
+ Args:
27
+ check_fn: Function that takes last_hash and returns (is_same, current_hash)
28
+ idle_seconds: Number of consecutive stable checks required
29
+ timeout: Maximum wait time in seconds
30
+ poll_interval: Time between checks in seconds
31
+
32
+ Returns:
33
+ Total elapsed time in seconds
34
+
35
+ Raises:
36
+ PollingTimeoutError: If timeout is reached
37
+ """
38
+ start_time = time.time()
39
+ last_hash = ""
40
+ stable_count = 0
41
+
42
+ while True:
43
+ elapsed = time.time() - start_time
44
+ if elapsed >= timeout:
45
+ raise PollingTimeoutError(timeout, elapsed)
46
+
47
+ is_same, current_hash = check_fn(last_hash)
48
+
49
+ if is_same and last_hash:
50
+ stable_count += 1
51
+ if stable_count >= idle_seconds:
52
+ return elapsed
53
+ else:
54
+ stable_count = 0
55
+ last_hash = current_hash
56
+
57
+ time.sleep(poll_interval)
58
+
59
+
60
+ def poll_until(
61
+ condition_fn: Callable[[], bool],
62
+ timeout: float = 1800,
63
+ poll_interval: float = 1.0,
64
+ ) -> float:
65
+ """Poll until condition is true.
66
+
67
+ Args:
68
+ condition_fn: Function that returns True when done
69
+ timeout: Maximum wait time in seconds
70
+ poll_interval: Time between checks in seconds
71
+
72
+ Returns:
73
+ Total elapsed time in seconds
74
+
75
+ Raises:
76
+ PollingTimeoutError: If timeout is reached
77
+ """
78
+ start_time = time.time()
79
+
80
+ while True:
81
+ elapsed = time.time() - start_time
82
+ if elapsed >= timeout:
83
+ raise PollingTimeoutError(timeout, elapsed)
84
+
85
+ if condition_fn():
86
+ return elapsed
87
+
88
+ time.sleep(poll_interval)
ai_comm/registry.py ADDED
@@ -0,0 +1,75 @@
1
+ """Unified CLI registry - single source of truth for CLI metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class CLIInfo:
10
+ """Metadata for a supported AI CLI."""
11
+
12
+ name: str
13
+ display_name: str
14
+ aliases: tuple[str, ...]
15
+ adapter_module: str
16
+
17
+
18
+ CLI_REGISTRY: dict[str, CLIInfo] = {
19
+ "claude": CLIInfo(
20
+ name="claude",
21
+ display_name="Claude Code",
22
+ aliases=("claude",),
23
+ adapter_module="ai_comm.adapters.claude",
24
+ ),
25
+ "codex": CLIInfo(
26
+ name="codex",
27
+ display_name="Codex CLI",
28
+ aliases=("codex",),
29
+ adapter_module="ai_comm.adapters.codex",
30
+ ),
31
+ "gemini": CLIInfo(
32
+ name="gemini",
33
+ display_name="Gemini CLI",
34
+ aliases=("gemini",),
35
+ adapter_module="ai_comm.adapters.gemini",
36
+ ),
37
+ "aider": CLIInfo(
38
+ name="aider",
39
+ display_name="Aider",
40
+ aliases=("aider",),
41
+ adapter_module="ai_comm.adapters.aider",
42
+ ),
43
+ "cursor": CLIInfo(
44
+ name="cursor",
45
+ display_name="Cursor",
46
+ aliases=("cursor", "cursor-cli", "cursor-agent"),
47
+ adapter_module="ai_comm.adapters.cursor",
48
+ ),
49
+ "opencode": CLIInfo(
50
+ name="opencode",
51
+ display_name="OpenCode",
52
+ aliases=("opencode",),
53
+ adapter_module="ai_comm.adapters.opencode",
54
+ ),
55
+ }
56
+
57
+
58
+ def get_display_name(cli_name: str) -> str:
59
+ """Get human-readable display name for CLI."""
60
+ info = CLI_REGISTRY.get(cli_name)
61
+ return info.display_name if info else cli_name.capitalize()
62
+
63
+
64
+ def get_canonical_name(process_name: str) -> str | None:
65
+ """Map process name/alias to canonical CLI name."""
66
+ process_lower = process_name.lower()
67
+ for name, info in CLI_REGISTRY.items():
68
+ if process_lower in info.aliases:
69
+ return name
70
+ return None
71
+
72
+
73
+ def list_cli_names() -> list[str]:
74
+ """List all registered CLI names."""
75
+ return list(CLI_REGISTRY.keys())
@@ -0,0 +1,7 @@
1
+ """Services for ai-comm."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .interaction import InteractionService
6
+
7
+ __all__ = ["InteractionService"]
@@ -0,0 +1,115 @@
1
+ """Interaction service - unified send and response logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from typing import TYPE_CHECKING
8
+
9
+ from ai_comm.adapters import get_adapter
10
+ from ai_comm.polling import wait_for_idle
11
+ from ai_comm.registry import get_display_name
12
+
13
+ if TYPE_CHECKING:
14
+ from ai_comm.kitten_client import KittenClient
15
+
16
+
17
+ class InteractionService:
18
+ """Unified service for AI CLI interactions.
19
+
20
+ Handles message formatting, sending, waiting, and response fetching.
21
+ Accepts KittenClient via dependency injection.
22
+ """
23
+
24
+ def __init__(self, client: KittenClient) -> None:
25
+ """Initialize with injected client."""
26
+ self.client = client
27
+
28
+ def get_sender_name(self) -> str | None:
29
+ """Get the sender CLI name from current window."""
30
+ window_id_str = os.environ.get("KITTY_WINDOW_ID")
31
+ if not window_id_str:
32
+ return None
33
+ try:
34
+ window_id = int(window_id_str)
35
+ cli_type = self.client.get_window_cli(window_id)
36
+ if cli_type:
37
+ return get_display_name(cli_type)
38
+ except (ValueError, Exception):
39
+ pass
40
+ return None
41
+
42
+ def send_message(
43
+ self,
44
+ window_id: int,
45
+ message: str,
46
+ add_sender_header: bool = True,
47
+ ) -> None:
48
+ """Format and send message to target window."""
49
+ cli_type = self.client.get_window_cli(window_id)
50
+ adapter = get_adapter(cli_type or "generic")
51
+
52
+ sender = self.get_sender_name() if add_sender_header else None
53
+ formatted = adapter.format_message(message, sender)
54
+
55
+ self.client.send_text(window_id, formatted)
56
+ time.sleep(0.1)
57
+ self.client.send_key(window_id, "enter")
58
+
59
+ def wait_for_response(
60
+ self,
61
+ window_id: int,
62
+ idle_seconds: int = 3,
63
+ timeout: float = 1800,
64
+ ) -> float:
65
+ """Wait for window content to stabilize."""
66
+ time.sleep(0.5)
67
+
68
+ def check_fn(last_hash: str) -> tuple[bool, str]:
69
+ return self.client.check_idle(window_id, last_hash)
70
+
71
+ return wait_for_idle(check_fn, idle_seconds=idle_seconds, timeout=timeout)
72
+
73
+ def get_response(
74
+ self,
75
+ window_id: int,
76
+ parser: str = "auto",
77
+ extent: str = "all",
78
+ raw: bool = False,
79
+ ) -> tuple[str, str]:
80
+ """Get parsed response from window.
81
+
82
+ Returns:
83
+ Tuple of (response_text, effective_parser_name)
84
+ """
85
+ effective_parser = parser
86
+ if parser == "auto":
87
+ cli_type = self.client.get_window_cli(window_id)
88
+ effective_parser = cli_type or "generic"
89
+
90
+ if raw:
91
+ return self.client.get_text(window_id, extent), effective_parser
92
+
93
+ adapter = get_adapter(effective_parser)
94
+ response = adapter.fetch_response(self.client, window_id, extent)
95
+
96
+ return response, effective_parser
97
+
98
+ def send_and_wait(
99
+ self,
100
+ window_id: int,
101
+ message: str,
102
+ idle_seconds: int = 3,
103
+ timeout: float = 1800,
104
+ parser: str = "auto",
105
+ raw: bool = False,
106
+ ) -> tuple[str, float, str]:
107
+ """Send message and wait for response.
108
+
109
+ Returns:
110
+ Tuple of (response_text, elapsed_time, effective_parser_name)
111
+ """
112
+ self.send_message(window_id, message)
113
+ elapsed = self.wait_for_response(window_id, idle_seconds, timeout)
114
+ response, effective_parser = self.get_response(window_id, parser, raw=raw)
115
+ return response, elapsed, effective_parser