aitextaroo 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.
aitextaroo/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """AI Text-a-roo — give any AI agent a phone number.
2
+
3
+ Connect to the AI Text-a-roo SMS gateway to receive and send
4
+ text messages through any AI agent framework.
5
+
6
+ Example:
7
+ >>> from aitextaroo import TextarooClient
8
+ >>> client = TextarooClient(api_key="your-key")
9
+ >>> async for message in client.listen():
10
+ ... print(f"Got: {message.text}")
11
+ ... await client.send(f"You said: {message.text}")
12
+ """
13
+
14
+ from aitextaroo.client import TextarooClient
15
+ from aitextaroo.models import InboundMessage, StreamEvent
16
+
17
+ __version__ = "0.1.0"
18
+ __all__ = ["TextarooClient", "InboundMessage", "StreamEvent"]
aitextaroo/agents.py ADDED
@@ -0,0 +1,235 @@
1
+ """Agent abstraction for the AI Text-a-roo bridge.
2
+
3
+ Defines how the bridge communicates with AI agents. Each agent
4
+ takes a text message in and produces a text response.
5
+
6
+ The only implementation today is CliAgent — a one-shot subprocess
7
+ invocation per message. New agent types (HTTP API, long-running
8
+ process, etc.) can be added by implementing the Agent protocol.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import shutil
16
+ from abc import ABC, abstractmethod
17
+ from dataclasses import dataclass, field
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Maximum time to wait for an agent to respond (seconds).
22
+ DEFAULT_TIMEOUT = 120
23
+
24
+
25
+ class Agent(ABC):
26
+ """Interface for an AI agent that processes text messages.
27
+
28
+ Implementations must be able to:
29
+ 1. Accept a text prompt and return a text response.
30
+ 2. Provide a display name and optional system prompt.
31
+ 3. Release any held resources on close.
32
+
33
+ The bridge reads system_prompt to build conversation context.
34
+ The agent receives the fully-assembled prompt — it does not
35
+ need to manage conversation state or system instructions.
36
+ """
37
+
38
+ @property
39
+ @abstractmethod
40
+ def name(self) -> str:
41
+ """Human-readable name for display and logging."""
42
+
43
+ @property
44
+ def system_prompt(self) -> str:
45
+ """System instructions prepended to every prompt by the bridge.
46
+
47
+ Override to provide agent-specific context (e.g., "respond
48
+ concisely, these are SMS messages"). Default is empty.
49
+ """
50
+ return ""
51
+
52
+ @abstractmethod
53
+ async def ask(self, text: str) -> str:
54
+ """Send a fully-assembled prompt and return the response.
55
+
56
+ The prompt already includes system instructions and
57
+ conversation history (assembled by the bridge). The agent
58
+ just needs to run it.
59
+
60
+ Args:
61
+ text: The complete prompt to send.
62
+
63
+ Returns:
64
+ The agent's response text.
65
+
66
+ Raises:
67
+ asyncio.TimeoutError: If the agent doesn't respond in time.
68
+ RuntimeError: If the agent process fails.
69
+ """
70
+
71
+ async def close(self) -> None:
72
+ """Release any resources held by this agent.
73
+
74
+ Default implementation does nothing. Override if your agent
75
+ holds open connections or processes.
76
+ """
77
+
78
+
79
+ # ── CLI Agent ────────────────────────────────────────────────────
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class CliAgentConfig:
84
+ """How to invoke a CLI agent.
85
+
86
+ Attributes:
87
+ name: Human-readable name (for logging and display).
88
+ command: Executable name (must be on PATH).
89
+ args: Arguments placed before the prompt.
90
+ system_prompt: Context the bridge prepends to prompts.
91
+ timeout: Max seconds to wait for a response.
92
+ """
93
+
94
+ name: str
95
+ command: str
96
+ args: list[str] = field(default_factory=list)
97
+ system_prompt: str = ""
98
+ timeout: float = DEFAULT_TIMEOUT
99
+
100
+
101
+ class CliAgent(Agent):
102
+ """Runs a CLI agent as a one-shot subprocess per message.
103
+
104
+ Each call to ask() spawns a fresh process:
105
+ {command} {args} "{prompt}"
106
+
107
+ Stdout is captured as the response. Stderr is logged on failure.
108
+ This is the simplest and most reliable approach — no process
109
+ lifecycle management, no stdin/stdout protocol, no state leaks.
110
+
111
+ The agent receives the complete prompt from the bridge. It does
112
+ not manage system prompts or conversation context — that's the
113
+ bridge's responsibility.
114
+
115
+ Args:
116
+ config: How to invoke the agent CLI.
117
+ """
118
+
119
+ def __init__(self, config: CliAgentConfig) -> None:
120
+ self._config = config
121
+
122
+ @property
123
+ def name(self) -> str:
124
+ return self._config.name
125
+
126
+ @property
127
+ def system_prompt(self) -> str:
128
+ return self._config.system_prompt
129
+
130
+ async def ask(self, text: str) -> str:
131
+ """Spawn the agent CLI with the prompt and return its output."""
132
+ cmd = [self._config.command, *self._config.args, text]
133
+
134
+ logger.debug("Running: %s %s '%s...'", cmd[0], " ".join(cmd[1:-1]), text[:40])
135
+
136
+ process = await asyncio.create_subprocess_exec(
137
+ *cmd,
138
+ stdout=asyncio.subprocess.PIPE,
139
+ stderr=asyncio.subprocess.PIPE,
140
+ )
141
+
142
+ try:
143
+ stdout, stderr = await asyncio.wait_for(
144
+ process.communicate(),
145
+ timeout=self._config.timeout,
146
+ )
147
+ except asyncio.TimeoutError:
148
+ process.kill()
149
+ await process.wait()
150
+ raise
151
+
152
+ if process.returncode != 0:
153
+ error_text = stderr.decode(errors="replace")[:200]
154
+ logger.warning(
155
+ "Agent %s exited with code %d: %s",
156
+ self._config.name, process.returncode, error_text,
157
+ )
158
+
159
+ return stdout.decode(errors="replace").strip()
160
+
161
+
162
+ # ── Agent Registry ───────────────────────────────────────────────
163
+
164
+ # Built-in agent configurations. Each entry defines how to invoke
165
+ # a known CLI agent. The bridge uses these to auto-detect and
166
+ # instantiate agents.
167
+ #
168
+ # To add a new agent: add an entry here and it works automatically.
169
+ # No other code changes needed.
170
+
171
+ BUILTIN_AGENTS: dict[str, CliAgentConfig] = {
172
+ "claude": CliAgentConfig(
173
+ name="Claude Code",
174
+ command="claude",
175
+ args=["-p"],
176
+ system_prompt=(
177
+ "You are receiving SMS text messages from a user. "
178
+ "Respond concisely — these are text messages with a 1600 character limit."
179
+ ),
180
+ ),
181
+ "hermes": CliAgentConfig(
182
+ name="Hermes",
183
+ command="hermes",
184
+ args=["--pipe"],
185
+ system_prompt=(
186
+ "You are receiving SMS text messages from a user via AI Text-a-roo. "
187
+ "Respond conversationally. Keep responses concise — they're text messages."
188
+ ),
189
+ ),
190
+ "openclaw": CliAgentConfig(
191
+ name="OpenClaw",
192
+ command="openclaw",
193
+ args=["--pipe"],
194
+ system_prompt="You are receiving SMS text messages. Respond concisely.",
195
+ ),
196
+ "nanoclaw": CliAgentConfig(
197
+ name="NanoClaw",
198
+ command="nanoclaw",
199
+ args=["--pipe"],
200
+ system_prompt="You are receiving SMS text messages. Respond concisely.",
201
+ ),
202
+ }
203
+
204
+
205
+ def detect_agents() -> list[str]:
206
+ """Find which supported agents are installed on this system.
207
+
208
+ Returns:
209
+ Agent names found on PATH, in preference order.
210
+ """
211
+ found = []
212
+ for name, config in BUILTIN_AGENTS.items():
213
+ if shutil.which(config.command):
214
+ found.append(name)
215
+ logger.debug("Detected agent: %s (%s)", config.name, config.command)
216
+ return found
217
+
218
+
219
+ def create_agent(name: str) -> CliAgent:
220
+ """Create an agent instance by name.
221
+
222
+ Args:
223
+ name: Key in BUILTIN_AGENTS (e.g., "claude", "hermes").
224
+
225
+ Returns:
226
+ A ready-to-use CliAgent.
227
+
228
+ Raises:
229
+ ValueError: If the agent name is not recognized.
230
+ """
231
+ config = BUILTIN_AGENTS.get(name)
232
+ if config is None:
233
+ available = ", ".join(BUILTIN_AGENTS.keys())
234
+ raise ValueError(f"Unknown agent: {name!r}. Available: {available}")
235
+ return CliAgent(config)
aitextaroo/bridge.py ADDED
@@ -0,0 +1,157 @@
1
+ """SMS-to-agent bridge for AI Text-a-roo.
2
+
3
+ Connects to the AI Text-a-roo SSE stream, forwards inbound SMS
4
+ messages to a local AI agent (with conversation context), and sends
5
+ the agent's formatted responses back as SMS.
6
+
7
+ This module is orchestration — it delegates to:
8
+ - agents.py for agent communication
9
+ - conversation.py for history management
10
+ - commands.py for /slash command handling
11
+ - formatting.py for SMS-friendly output
12
+ - client.py for API communication
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+
19
+ from aitextaroo.agents import Agent
20
+ from aitextaroo.client import TextarooClient
21
+ from aitextaroo.commands import CommandRouter
22
+ from aitextaroo.conversation import Conversation
23
+ from aitextaroo.formatting import format_for_sms
24
+ from aitextaroo.models import InboundMessage
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # SMS character limit. Responses exceeding this are truncated.
29
+ MAX_SMS_LENGTH = 1600
30
+
31
+
32
+ class Bridge:
33
+ """Bridges SMS messages between AI Text-a-roo and a local AI agent.
34
+
35
+ The bridge's message flow:
36
+ 1. Receive inbound SMS via SSE stream.
37
+ 2. If it's a /command, handle locally (no agent call).
38
+ 3. Otherwise, build full prompt (system + history + message).
39
+ 4. Send to agent, get response.
40
+ 5. Record both messages in conversation history.
41
+ 6. Format the response for SMS and send it back.
42
+
43
+ The bridge owns prompt construction — the agent just receives
44
+ a complete prompt and returns a response. This keeps system
45
+ prompt and conversation context management in one place.
46
+
47
+ Args:
48
+ client: An authenticated TextarooClient.
49
+ agent: An Agent implementation that processes messages.
50
+ max_history: Max messages to keep in conversation context.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ client: TextarooClient,
56
+ agent: Agent,
57
+ max_history: int = 20,
58
+ ) -> None:
59
+ self._client = client
60
+ self._agent = agent
61
+ self._conversation = Conversation(max_messages=max_history)
62
+ self._commands = CommandRouter(
63
+ agent_name=agent.name,
64
+ conversation=self._conversation,
65
+ )
66
+
67
+ async def run(self) -> None:
68
+ """Listen for SMS and bridge them to the agent.
69
+
70
+ Runs indefinitely. The TextarooClient handles SSE reconnection
71
+ automatically. Call this from asyncio.run() or as a task.
72
+ """
73
+ try:
74
+ async for message in self._client.listen():
75
+ await self._handle(message)
76
+ finally:
77
+ await self._agent.close()
78
+ await self._client.close()
79
+
80
+ async def _handle(self, message: InboundMessage) -> None:
81
+ """Route an inbound SMS to commands or the agent."""
82
+ text = message.text.strip()
83
+
84
+ if self._commands.is_command(text):
85
+ response = self._commands.handle(text)
86
+ await self._send(response)
87
+ return
88
+
89
+ await self._handle_conversation(message.id, text)
90
+
91
+ async def _handle_conversation(self, message_id: str, text: str) -> None:
92
+ """Build prompt, call agent, record history, send reply."""
93
+ logger.info("Inbound SMS [%s]: %s", message_id, text[:50])
94
+
95
+ prompt = self._build_prompt(text)
96
+
97
+ # Call the agent
98
+ try:
99
+ response = await self._agent.ask(prompt)
100
+ except TimeoutError:
101
+ logger.error("Agent timed out on message %s", message_id)
102
+ await self._send("Sorry, I took too long to respond. Try again?")
103
+ return
104
+ except Exception:
105
+ logger.exception("Agent error on message %s", message_id)
106
+ await self._send("Sorry, something went wrong. Try again?")
107
+ return
108
+
109
+ if not response:
110
+ logger.warning("Agent returned empty response for %s", message_id)
111
+ return
112
+
113
+ # Record both sides only after a successful response.
114
+ # This keeps history balanced — no orphaned user messages.
115
+ self._conversation.add_user_message(text)
116
+ self._conversation.add_assistant_message(response)
117
+ self._commands.increment_message_count()
118
+
119
+ # Format for SMS and send
120
+ formatted = format_for_sms(response)
121
+ if len(formatted) > MAX_SMS_LENGTH:
122
+ formatted = formatted[: MAX_SMS_LENGTH - 3] + "..."
123
+ logger.warning("Response truncated to %d chars", MAX_SMS_LENGTH)
124
+
125
+ await self._send(formatted)
126
+ logger.info("Reply sent for %s", message_id)
127
+
128
+ def _build_prompt(self, text: str) -> str:
129
+ """Assemble the complete prompt: system + history + new message.
130
+
131
+ The bridge owns prompt construction so that system instructions
132
+ and conversation context are always included, regardless of
133
+ which agent is being used or how many messages have been sent.
134
+ """
135
+ parts: list[str] = []
136
+
137
+ # System prompt — always included so the agent stays in character
138
+ system = self._agent.system_prompt
139
+ if system:
140
+ parts.append(f"[System]\n{system}")
141
+
142
+ # Conversation history — provides multi-turn context
143
+ context = self._conversation.format_as_context()
144
+ if context:
145
+ parts.append(context)
146
+
147
+ # The new message
148
+ parts.append(f"[New message]\nUser: {text}")
149
+
150
+ return "\n\n".join(parts)
151
+
152
+ async def _send(self, text: str) -> None:
153
+ """Send a reply, logging but not raising on failure."""
154
+ try:
155
+ await self._client.send(text)
156
+ except Exception:
157
+ logger.exception("Failed to send reply")
aitextaroo/cli.py ADDED
@@ -0,0 +1,108 @@
1
+ """CLI entry point for the AI Text-a-roo bridge.
2
+
3
+ Usage:
4
+ aitextaroo-bridge --api-key YOUR_KEY
5
+ aitextaroo-bridge --api-key YOUR_KEY --agent claude
6
+ AITEXTAROO_API_KEY=your_key aitextaroo-bridge
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import asyncio
13
+ import logging
14
+ import os
15
+ import sys
16
+
17
+ from aitextaroo.agents import BUILTIN_AGENTS, create_agent, detect_agents
18
+ from aitextaroo.bridge import Bridge
19
+ from aitextaroo.client import TextarooClient
20
+
21
+
22
+ def main() -> None:
23
+ """Parse arguments, wire dependencies, run the bridge."""
24
+ args = _parse_args()
25
+ _configure_logging(args.verbose)
26
+
27
+ api_key = args.api_key
28
+ if not api_key:
29
+ _exit_error("API key required. Use --api-key or set AITEXTAROO_API_KEY.")
30
+
31
+ agent_name = _resolve_agent(args.agent)
32
+
33
+ # Wire dependencies
34
+ client = TextarooClient(api_key=api_key, base_url=args.base_url)
35
+ agent = create_agent(agent_name)
36
+ bridge = Bridge(client=client, agent=agent)
37
+
38
+ print(f"Starting bridge with {agent.name}...")
39
+ print("Listening for SMS messages. Ctrl+C to stop.")
40
+
41
+ try:
42
+ asyncio.run(bridge.run())
43
+ except KeyboardInterrupt:
44
+ print("\nBridge stopped.")
45
+
46
+
47
+ def _parse_args() -> argparse.Namespace:
48
+ """Build and parse CLI arguments."""
49
+ parser = argparse.ArgumentParser(
50
+ prog="aitextaroo-bridge",
51
+ description="Bridge SMS messages to your AI agent. Your agent gets a phone number.",
52
+ )
53
+ parser.add_argument(
54
+ "--api-key",
55
+ default=os.environ.get("AITEXTAROO_API_KEY", ""),
56
+ help="AI Text-a-roo API key (or set AITEXTAROO_API_KEY env var)",
57
+ )
58
+ parser.add_argument(
59
+ "--agent",
60
+ choices=["auto", *BUILTIN_AGENTS.keys()],
61
+ default="auto",
62
+ help="Which agent to use (default: auto-detect)",
63
+ )
64
+ parser.add_argument(
65
+ "--base-url",
66
+ default=os.environ.get("AITEXTAROO_BASE_URL", "https://api.aitextaroo.com"),
67
+ help="API base URL (default: https://api.aitextaroo.com)",
68
+ )
69
+ parser.add_argument(
70
+ "--verbose", "-v",
71
+ action="store_true",
72
+ help="Enable debug logging",
73
+ )
74
+ return parser.parse_args()
75
+
76
+
77
+ def _configure_logging(verbose: bool) -> None:
78
+ """Set up logging for the bridge process."""
79
+ logging.basicConfig(
80
+ level=logging.DEBUG if verbose else logging.INFO,
81
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
82
+ datefmt="%H:%M:%S",
83
+ )
84
+
85
+
86
+ def _resolve_agent(choice: str) -> str:
87
+ """Resolve 'auto' to a detected agent name, or validate the explicit choice."""
88
+ if choice != "auto":
89
+ return choice
90
+
91
+ agents = detect_agents()
92
+ if not agents:
93
+ _exit_error(
94
+ "No supported AI agent found on PATH. Install one of:\n"
95
+ + "".join(f" - {name}\n" for name in BUILTIN_AGENTS)
96
+ )
97
+ print(f"Auto-detected agent: {agents[0]}")
98
+ return agents[0]
99
+
100
+
101
+ def _exit_error(message: str) -> None:
102
+ """Print error to stderr and exit."""
103
+ print(f"Error: {message}", file=sys.stderr)
104
+ sys.exit(1)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()