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 +3 -0
- ai_comm/adapters/__init__.py +41 -0
- ai_comm/adapters/aider.py +63 -0
- ai_comm/adapters/base.py +132 -0
- ai_comm/adapters/claude.py +72 -0
- ai_comm/adapters/codex.py +50 -0
- ai_comm/adapters/cursor.py +71 -0
- ai_comm/adapters/gemini.py +56 -0
- ai_comm/adapters/generic.py +42 -0
- ai_comm/adapters/opencode.py +151 -0
- ai_comm/cli.py +94 -0
- ai_comm/commands/__init__.py +11 -0
- ai_comm/commands/response.py +122 -0
- ai_comm/commands/send.py +120 -0
- ai_comm/commands/window.py +42 -0
- ai_comm/kitten/ai_comm_kitten.py +254 -0
- ai_comm/kitten_client.py +199 -0
- ai_comm/parsers/__init__.py +27 -0
- ai_comm/parsers/base.py +108 -0
- ai_comm/parsers/utils.py +42 -0
- ai_comm/polling.py +88 -0
- ai_comm/registry.py +75 -0
- ai_comm/services/__init__.py +7 -0
- ai_comm/services/interaction.py +115 -0
- ai_comm-0.2.4.dist-info/METADATA +132 -0
- ai_comm-0.2.4.dist-info/RECORD +29 -0
- ai_comm-0.2.4.dist-info/WHEEL +4 -0
- ai_comm-0.2.4.dist-info/entry_points.txt +2 -0
- ai_comm-0.2.4.dist-info/licenses/LICENSE +21 -0
ai_comm/__init__.py
ADDED
|
@@ -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))
|
ai_comm/adapters/base.py
ADDED
|
@@ -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 ""
|