ragnarbot-ai 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.
Files changed (56) hide show
  1. ragnarbot/__init__.py +6 -0
  2. ragnarbot/__main__.py +8 -0
  3. ragnarbot/agent/__init__.py +8 -0
  4. ragnarbot/agent/context.py +223 -0
  5. ragnarbot/agent/loop.py +365 -0
  6. ragnarbot/agent/memory.py +109 -0
  7. ragnarbot/agent/skills.py +228 -0
  8. ragnarbot/agent/subagent.py +241 -0
  9. ragnarbot/agent/tools/__init__.py +6 -0
  10. ragnarbot/agent/tools/base.py +102 -0
  11. ragnarbot/agent/tools/cron.py +114 -0
  12. ragnarbot/agent/tools/filesystem.py +191 -0
  13. ragnarbot/agent/tools/message.py +86 -0
  14. ragnarbot/agent/tools/registry.py +73 -0
  15. ragnarbot/agent/tools/shell.py +141 -0
  16. ragnarbot/agent/tools/spawn.py +65 -0
  17. ragnarbot/agent/tools/web.py +163 -0
  18. ragnarbot/bus/__init__.py +6 -0
  19. ragnarbot/bus/events.py +37 -0
  20. ragnarbot/bus/queue.py +81 -0
  21. ragnarbot/channels/__init__.py +6 -0
  22. ragnarbot/channels/base.py +121 -0
  23. ragnarbot/channels/manager.py +129 -0
  24. ragnarbot/channels/telegram.py +302 -0
  25. ragnarbot/cli/__init__.py +1 -0
  26. ragnarbot/cli/commands.py +568 -0
  27. ragnarbot/config/__init__.py +6 -0
  28. ragnarbot/config/loader.py +95 -0
  29. ragnarbot/config/schema.py +114 -0
  30. ragnarbot/cron/__init__.py +6 -0
  31. ragnarbot/cron/service.py +346 -0
  32. ragnarbot/cron/types.py +59 -0
  33. ragnarbot/heartbeat/__init__.py +5 -0
  34. ragnarbot/heartbeat/service.py +130 -0
  35. ragnarbot/providers/__init__.py +6 -0
  36. ragnarbot/providers/base.py +69 -0
  37. ragnarbot/providers/litellm_provider.py +135 -0
  38. ragnarbot/providers/transcription.py +67 -0
  39. ragnarbot/session/__init__.py +5 -0
  40. ragnarbot/session/manager.py +202 -0
  41. ragnarbot/skills/README.md +24 -0
  42. ragnarbot/skills/cron/SKILL.md +40 -0
  43. ragnarbot/skills/github/SKILL.md +48 -0
  44. ragnarbot/skills/skill-creator/SKILL.md +371 -0
  45. ragnarbot/skills/summarize/SKILL.md +67 -0
  46. ragnarbot/skills/tmux/SKILL.md +121 -0
  47. ragnarbot/skills/tmux/scripts/find-sessions.sh +112 -0
  48. ragnarbot/skills/tmux/scripts/wait-for-text.sh +83 -0
  49. ragnarbot/skills/weather/SKILL.md +49 -0
  50. ragnarbot/utils/__init__.py +5 -0
  51. ragnarbot/utils/helpers.py +91 -0
  52. ragnarbot_ai-0.1.0.dist-info/METADATA +28 -0
  53. ragnarbot_ai-0.1.0.dist-info/RECORD +56 -0
  54. ragnarbot_ai-0.1.0.dist-info/WHEEL +4 -0
  55. ragnarbot_ai-0.1.0.dist-info/entry_points.txt +2 -0
  56. ragnarbot_ai-0.1.0.dist-info/licenses/LICENSE +22 -0
ragnarbot/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ ragnarbot - A lightweight AI agent framework
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+ __logo__ = "🤖"
ragnarbot/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ Entry point for running ragnarbot as a module: python -m ragnarbot
3
+ """
4
+
5
+ from ragnarbot.cli.commands import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
@@ -0,0 +1,8 @@
1
+ """Agent core module."""
2
+
3
+ from ragnarbot.agent.loop import AgentLoop
4
+ from ragnarbot.agent.context import ContextBuilder
5
+ from ragnarbot.agent.memory import MemoryStore
6
+ from ragnarbot.agent.skills import SkillsLoader
7
+
8
+ __all__ = ["AgentLoop", "ContextBuilder", "MemoryStore", "SkillsLoader"]
@@ -0,0 +1,223 @@
1
+ """Context builder for assembling agent prompts."""
2
+
3
+ import base64
4
+ import mimetypes
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ragnarbot.agent.memory import MemoryStore
9
+ from ragnarbot.agent.skills import SkillsLoader
10
+
11
+
12
+ class ContextBuilder:
13
+ """
14
+ Builds the context (system prompt + messages) for the agent.
15
+
16
+ Assembles bootstrap files, memory, skills, and conversation history
17
+ into a coherent prompt for the LLM.
18
+ """
19
+
20
+ BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
21
+
22
+ def __init__(self, workspace: Path):
23
+ self.workspace = workspace
24
+ self.memory = MemoryStore(workspace)
25
+ self.skills = SkillsLoader(workspace)
26
+
27
+ def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
28
+ """
29
+ Build the system prompt from bootstrap files, memory, and skills.
30
+
31
+ Args:
32
+ skill_names: Optional list of skills to include.
33
+
34
+ Returns:
35
+ Complete system prompt.
36
+ """
37
+ parts = []
38
+
39
+ # Core identity
40
+ parts.append(self._get_identity())
41
+
42
+ # Bootstrap files
43
+ bootstrap = self._load_bootstrap_files()
44
+ if bootstrap:
45
+ parts.append(bootstrap)
46
+
47
+ # Memory context
48
+ memory = self.memory.get_memory_context()
49
+ if memory:
50
+ parts.append(f"# Memory\n\n{memory}")
51
+
52
+ # Skills - progressive loading
53
+ # 1. Always-loaded skills: include full content
54
+ always_skills = self.skills.get_always_skills()
55
+ if always_skills:
56
+ always_content = self.skills.load_skills_for_context(always_skills)
57
+ if always_content:
58
+ parts.append(f"# Active Skills\n\n{always_content}")
59
+
60
+ # 2. Available skills: only show summary (agent uses read_file to load)
61
+ skills_summary = self.skills.build_skills_summary()
62
+ if skills_summary:
63
+ parts.append(f"""# Skills
64
+
65
+ The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
66
+ Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
67
+
68
+ {skills_summary}""")
69
+
70
+ return "\n\n---\n\n".join(parts)
71
+
72
+ def _get_identity(self) -> str:
73
+ """Get the core identity section."""
74
+ from datetime import datetime
75
+ now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
76
+ workspace_path = str(self.workspace.expanduser().resolve())
77
+
78
+ return f"""# ragnarbot 🤖
79
+
80
+ You are ragnarbot, a helpful AI assistant. You have access to tools that allow you to:
81
+ - Read, write, and edit files
82
+ - Execute shell commands
83
+ - Search the web and fetch web pages
84
+ - Send messages to users on chat channels
85
+ - Spawn subagents for complex background tasks
86
+
87
+ ## Current Time
88
+ {now}
89
+
90
+ ## Workspace
91
+ Your workspace is at: {workspace_path}
92
+ - Memory files: {workspace_path}/memory/MEMORY.md
93
+ - Daily notes: {workspace_path}/memory/YYYY-MM-DD.md
94
+ - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
95
+
96
+ IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
97
+ Only use the 'message' tool when you need to send a message to a specific chat channel.
98
+ For normal conversation, just respond with text - do not call the message tool.
99
+
100
+ Always be helpful, accurate, and concise. When using tools, explain what you're doing.
101
+ When remembering something, write to {workspace_path}/memory/MEMORY.md"""
102
+
103
+ def _load_bootstrap_files(self) -> str:
104
+ """Load all bootstrap files from workspace."""
105
+ parts = []
106
+
107
+ for filename in self.BOOTSTRAP_FILES:
108
+ file_path = self.workspace / filename
109
+ if file_path.exists():
110
+ content = file_path.read_text(encoding="utf-8")
111
+ parts.append(f"## {filename}\n\n{content}")
112
+
113
+ return "\n\n".join(parts) if parts else ""
114
+
115
+ def build_messages(
116
+ self,
117
+ history: list[dict[str, Any]],
118
+ current_message: str,
119
+ skill_names: list[str] | None = None,
120
+ media: list[str] | None = None,
121
+ channel: str | None = None,
122
+ chat_id: str | None = None,
123
+ ) -> list[dict[str, Any]]:
124
+ """
125
+ Build the complete message list for an LLM call.
126
+
127
+ Args:
128
+ history: Previous conversation messages.
129
+ current_message: The new user message.
130
+ skill_names: Optional skills to include.
131
+ media: Optional list of local file paths for images/media.
132
+ channel: Current channel (e.g. telegram).
133
+ chat_id: Current chat/user ID.
134
+
135
+ Returns:
136
+ List of messages including system prompt.
137
+ """
138
+ messages = []
139
+
140
+ # System prompt
141
+ system_prompt = self.build_system_prompt(skill_names)
142
+ if channel and chat_id:
143
+ system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}"
144
+ messages.append({"role": "system", "content": system_prompt})
145
+
146
+ # History
147
+ messages.extend(history)
148
+
149
+ # Current message (with optional image attachments)
150
+ user_content = self._build_user_content(current_message, media)
151
+ messages.append({"role": "user", "content": user_content})
152
+
153
+ return messages
154
+
155
+ def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
156
+ """Build user message content with optional base64-encoded images."""
157
+ if not media:
158
+ return text
159
+
160
+ images = []
161
+ for path in media:
162
+ p = Path(path)
163
+ mime, _ = mimetypes.guess_type(path)
164
+ if not p.is_file() or not mime or not mime.startswith("image/"):
165
+ continue
166
+ b64 = base64.b64encode(p.read_bytes()).decode()
167
+ images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
168
+
169
+ if not images:
170
+ return text
171
+ return images + [{"type": "text", "text": text}]
172
+
173
+ def add_tool_result(
174
+ self,
175
+ messages: list[dict[str, Any]],
176
+ tool_call_id: str,
177
+ tool_name: str,
178
+ result: str
179
+ ) -> list[dict[str, Any]]:
180
+ """
181
+ Add a tool result to the message list.
182
+
183
+ Args:
184
+ messages: Current message list.
185
+ tool_call_id: ID of the tool call.
186
+ tool_name: Name of the tool.
187
+ result: Tool execution result.
188
+
189
+ Returns:
190
+ Updated message list.
191
+ """
192
+ messages.append({
193
+ "role": "tool",
194
+ "tool_call_id": tool_call_id,
195
+ "name": tool_name,
196
+ "content": result
197
+ })
198
+ return messages
199
+
200
+ def add_assistant_message(
201
+ self,
202
+ messages: list[dict[str, Any]],
203
+ content: str | None,
204
+ tool_calls: list[dict[str, Any]] | None = None
205
+ ) -> list[dict[str, Any]]:
206
+ """
207
+ Add an assistant message to the message list.
208
+
209
+ Args:
210
+ messages: Current message list.
211
+ content: Message content.
212
+ tool_calls: Optional tool calls.
213
+
214
+ Returns:
215
+ Updated message list.
216
+ """
217
+ msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
218
+
219
+ if tool_calls:
220
+ msg["tool_calls"] = tool_calls
221
+
222
+ messages.append(msg)
223
+ return messages
@@ -0,0 +1,365 @@
1
+ """Agent loop: the core processing engine."""
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from loguru import logger
9
+
10
+ from ragnarbot.bus.events import InboundMessage, OutboundMessage
11
+ from ragnarbot.bus.queue import MessageBus
12
+ from ragnarbot.providers.base import LLMProvider
13
+ from ragnarbot.agent.context import ContextBuilder
14
+ from ragnarbot.agent.tools.registry import ToolRegistry
15
+ from ragnarbot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
16
+ from ragnarbot.agent.tools.shell import ExecTool
17
+ from ragnarbot.agent.tools.web import WebSearchTool, WebFetchTool
18
+ from ragnarbot.agent.tools.message import MessageTool
19
+ from ragnarbot.agent.tools.spawn import SpawnTool
20
+ from ragnarbot.agent.tools.cron import CronTool
21
+ from ragnarbot.agent.subagent import SubagentManager
22
+ from ragnarbot.session.manager import SessionManager
23
+
24
+
25
+ class AgentLoop:
26
+ """
27
+ The agent loop is the core processing engine.
28
+
29
+ It:
30
+ 1. Receives messages from the bus
31
+ 2. Builds context with history, memory, skills
32
+ 3. Calls the LLM
33
+ 4. Executes tool calls
34
+ 5. Sends responses back
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ bus: MessageBus,
40
+ provider: LLMProvider,
41
+ workspace: Path,
42
+ model: str | None = None,
43
+ max_iterations: int = 20,
44
+ brave_api_key: str | None = None,
45
+ exec_config: "ExecToolConfig | None" = None,
46
+ cron_service: "CronService | None" = None,
47
+ ):
48
+ from ragnarbot.config.schema import ExecToolConfig
49
+ from ragnarbot.cron.service import CronService
50
+ self.bus = bus
51
+ self.provider = provider
52
+ self.workspace = workspace
53
+ self.model = model or provider.get_default_model()
54
+ self.max_iterations = max_iterations
55
+ self.brave_api_key = brave_api_key
56
+ self.exec_config = exec_config or ExecToolConfig()
57
+ self.cron_service = cron_service
58
+
59
+ self.context = ContextBuilder(workspace)
60
+ self.sessions = SessionManager(workspace)
61
+ self.tools = ToolRegistry()
62
+ self.subagents = SubagentManager(
63
+ provider=provider,
64
+ workspace=workspace,
65
+ bus=bus,
66
+ model=self.model,
67
+ brave_api_key=brave_api_key,
68
+ exec_config=self.exec_config,
69
+ )
70
+
71
+ self._running = False
72
+ self._register_default_tools()
73
+
74
+ def _register_default_tools(self) -> None:
75
+ """Register the default set of tools."""
76
+ # File tools
77
+ self.tools.register(ReadFileTool())
78
+ self.tools.register(WriteFileTool())
79
+ self.tools.register(EditFileTool())
80
+ self.tools.register(ListDirTool())
81
+
82
+ # Shell tool
83
+ self.tools.register(ExecTool(
84
+ working_dir=str(self.workspace),
85
+ timeout=self.exec_config.timeout,
86
+ restrict_to_workspace=self.exec_config.restrict_to_workspace,
87
+ ))
88
+
89
+ # Web tools
90
+ self.tools.register(WebSearchTool(api_key=self.brave_api_key))
91
+ self.tools.register(WebFetchTool())
92
+
93
+ # Message tool
94
+ message_tool = MessageTool(send_callback=self.bus.publish_outbound)
95
+ self.tools.register(message_tool)
96
+
97
+ # Spawn tool (for subagents)
98
+ spawn_tool = SpawnTool(manager=self.subagents)
99
+ self.tools.register(spawn_tool)
100
+
101
+ # Cron tool (for scheduling)
102
+ if self.cron_service:
103
+ self.tools.register(CronTool(self.cron_service))
104
+
105
+ async def run(self) -> None:
106
+ """Run the agent loop, processing messages from the bus."""
107
+ self._running = True
108
+ logger.info("Agent loop started")
109
+
110
+ while self._running:
111
+ try:
112
+ # Wait for next message
113
+ msg = await asyncio.wait_for(
114
+ self.bus.consume_inbound(),
115
+ timeout=1.0
116
+ )
117
+
118
+ # Process it
119
+ try:
120
+ response = await self._process_message(msg)
121
+ if response:
122
+ await self.bus.publish_outbound(response)
123
+ except Exception as e:
124
+ logger.error(f"Error processing message: {e}")
125
+ # Send error response
126
+ await self.bus.publish_outbound(OutboundMessage(
127
+ channel=msg.channel,
128
+ chat_id=msg.chat_id,
129
+ content=f"Sorry, I encountered an error: {str(e)}"
130
+ ))
131
+ except asyncio.TimeoutError:
132
+ continue
133
+
134
+ def stop(self) -> None:
135
+ """Stop the agent loop."""
136
+ self._running = False
137
+ logger.info("Agent loop stopping")
138
+
139
+ async def _process_message(self, msg: InboundMessage) -> OutboundMessage | None:
140
+ """
141
+ Process a single inbound message.
142
+
143
+ Args:
144
+ msg: The inbound message to process.
145
+
146
+ Returns:
147
+ The response message, or None if no response needed.
148
+ """
149
+ # Handle system messages (subagent announces)
150
+ # The chat_id contains the original "channel:chat_id" to route back to
151
+ if msg.channel == "system":
152
+ return await self._process_system_message(msg)
153
+
154
+ logger.info(f"Processing message from {msg.channel}:{msg.sender_id}")
155
+
156
+ # Get or create session
157
+ session = self.sessions.get_or_create(msg.session_key)
158
+
159
+ # Update tool contexts
160
+ message_tool = self.tools.get("message")
161
+ if isinstance(message_tool, MessageTool):
162
+ message_tool.set_context(msg.channel, msg.chat_id)
163
+
164
+ spawn_tool = self.tools.get("spawn")
165
+ if isinstance(spawn_tool, SpawnTool):
166
+ spawn_tool.set_context(msg.channel, msg.chat_id)
167
+
168
+ cron_tool = self.tools.get("cron")
169
+ if isinstance(cron_tool, CronTool):
170
+ cron_tool.set_context(msg.channel, msg.chat_id)
171
+
172
+ # Build initial messages (use get_history for LLM-formatted messages)
173
+ messages = self.context.build_messages(
174
+ history=session.get_history(),
175
+ current_message=msg.content,
176
+ media=msg.media if msg.media else None,
177
+ channel=msg.channel,
178
+ chat_id=msg.chat_id,
179
+ )
180
+
181
+ # Agent loop
182
+ iteration = 0
183
+ final_content = None
184
+
185
+ while iteration < self.max_iterations:
186
+ iteration += 1
187
+
188
+ # Call LLM
189
+ response = await self.provider.chat(
190
+ messages=messages,
191
+ tools=self.tools.get_definitions(),
192
+ model=self.model
193
+ )
194
+
195
+ # Handle tool calls
196
+ if response.has_tool_calls:
197
+ # Add assistant message with tool calls
198
+ tool_call_dicts = [
199
+ {
200
+ "id": tc.id,
201
+ "type": "function",
202
+ "function": {
203
+ "name": tc.name,
204
+ "arguments": json.dumps(tc.arguments) # Must be JSON string
205
+ }
206
+ }
207
+ for tc in response.tool_calls
208
+ ]
209
+ messages = self.context.add_assistant_message(
210
+ messages, response.content, tool_call_dicts
211
+ )
212
+
213
+ # Execute tools
214
+ for tool_call in response.tool_calls:
215
+ args_str = json.dumps(tool_call.arguments)
216
+ logger.debug(f"Executing tool: {tool_call.name} with arguments: {args_str}")
217
+ result = await self.tools.execute(tool_call.name, tool_call.arguments)
218
+ messages = self.context.add_tool_result(
219
+ messages, tool_call.id, tool_call.name, result
220
+ )
221
+ else:
222
+ # No tool calls, we're done
223
+ final_content = response.content
224
+ break
225
+
226
+ if final_content is None:
227
+ final_content = "I've completed processing but have no response to give."
228
+
229
+ # Save to session
230
+ session.add_message("user", msg.content)
231
+ session.add_message("assistant", final_content)
232
+ self.sessions.save(session)
233
+
234
+ return OutboundMessage(
235
+ channel=msg.channel,
236
+ chat_id=msg.chat_id,
237
+ content=final_content
238
+ )
239
+
240
+ async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
241
+ """
242
+ Process a system message (e.g., subagent announce).
243
+
244
+ The chat_id field contains "original_channel:original_chat_id" to route
245
+ the response back to the correct destination.
246
+ """
247
+ logger.info(f"Processing system message from {msg.sender_id}")
248
+
249
+ # Parse origin from chat_id (format: "channel:chat_id")
250
+ if ":" in msg.chat_id:
251
+ parts = msg.chat_id.split(":", 1)
252
+ origin_channel = parts[0]
253
+ origin_chat_id = parts[1]
254
+ else:
255
+ # Fallback
256
+ origin_channel = "cli"
257
+ origin_chat_id = msg.chat_id
258
+
259
+ # Use the origin session for context
260
+ session_key = f"{origin_channel}:{origin_chat_id}"
261
+ session = self.sessions.get_or_create(session_key)
262
+
263
+ # Update tool contexts
264
+ message_tool = self.tools.get("message")
265
+ if isinstance(message_tool, MessageTool):
266
+ message_tool.set_context(origin_channel, origin_chat_id)
267
+
268
+ spawn_tool = self.tools.get("spawn")
269
+ if isinstance(spawn_tool, SpawnTool):
270
+ spawn_tool.set_context(origin_channel, origin_chat_id)
271
+
272
+ cron_tool = self.tools.get("cron")
273
+ if isinstance(cron_tool, CronTool):
274
+ cron_tool.set_context(origin_channel, origin_chat_id)
275
+
276
+ # Build messages with the announce content
277
+ messages = self.context.build_messages(
278
+ history=session.get_history(),
279
+ current_message=msg.content,
280
+ channel=origin_channel,
281
+ chat_id=origin_chat_id,
282
+ )
283
+
284
+ # Agent loop (limited for announce handling)
285
+ iteration = 0
286
+ final_content = None
287
+
288
+ while iteration < self.max_iterations:
289
+ iteration += 1
290
+
291
+ response = await self.provider.chat(
292
+ messages=messages,
293
+ tools=self.tools.get_definitions(),
294
+ model=self.model
295
+ )
296
+
297
+ if response.has_tool_calls:
298
+ tool_call_dicts = [
299
+ {
300
+ "id": tc.id,
301
+ "type": "function",
302
+ "function": {
303
+ "name": tc.name,
304
+ "arguments": json.dumps(tc.arguments)
305
+ }
306
+ }
307
+ for tc in response.tool_calls
308
+ ]
309
+ messages = self.context.add_assistant_message(
310
+ messages, response.content, tool_call_dicts
311
+ )
312
+
313
+ for tool_call in response.tool_calls:
314
+ args_str = json.dumps(tool_call.arguments)
315
+ logger.debug(f"Executing tool: {tool_call.name} with arguments: {args_str}")
316
+ result = await self.tools.execute(tool_call.name, tool_call.arguments)
317
+ messages = self.context.add_tool_result(
318
+ messages, tool_call.id, tool_call.name, result
319
+ )
320
+ else:
321
+ final_content = response.content
322
+ break
323
+
324
+ if final_content is None:
325
+ final_content = "Background task completed."
326
+
327
+ # Save to session (mark as system message in history)
328
+ session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
329
+ session.add_message("assistant", final_content)
330
+ self.sessions.save(session)
331
+
332
+ return OutboundMessage(
333
+ channel=origin_channel,
334
+ chat_id=origin_chat_id,
335
+ content=final_content
336
+ )
337
+
338
+ async def process_direct(
339
+ self,
340
+ content: str,
341
+ session_key: str = "cli:direct",
342
+ channel: str = "cli",
343
+ chat_id: str = "direct",
344
+ ) -> str:
345
+ """
346
+ Process a message directly (for CLI or cron usage).
347
+
348
+ Args:
349
+ content: The message content.
350
+ session_key: Session identifier.
351
+ channel: Source channel (for context).
352
+ chat_id: Source chat ID (for context).
353
+
354
+ Returns:
355
+ The agent's response.
356
+ """
357
+ msg = InboundMessage(
358
+ channel=channel,
359
+ sender_id="user",
360
+ chat_id=chat_id,
361
+ content=content
362
+ )
363
+
364
+ response = await self._process_message(msg)
365
+ return response.content if response else ""