sourcebot 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 (110) hide show
  1. sourcebot/__init__.py +9 -0
  2. sourcebot/__main__.py +17 -0
  3. sourcebot/bus/__init__.py +4 -0
  4. sourcebot/bus/channel_adapter.py +21 -0
  5. sourcebot/bus/event_bus.py +15 -0
  6. sourcebot/bus/message_models.py +33 -0
  7. sourcebot/bus/outbound_dispatcher.py +15 -0
  8. sourcebot/bus/session_manager.py +20 -0
  9. sourcebot/cli/commands/core/__init__.py +3 -0
  10. sourcebot/cli/commands/core/command_line.py +26 -0
  11. sourcebot/cli/commands/init_commands/__init__.py +3 -0
  12. sourcebot/cli/commands/init_commands/init_global_config.py +30 -0
  13. sourcebot/cli/commands/init_commands/init_workspace_config.py +18 -0
  14. sourcebot/cli/commands/run_commands/__init__.py +3 -0
  15. sourcebot/cli/commands/run_commands/command_line_tool.py +345 -0
  16. sourcebot/cli/commands/run_commands/safe_runner.py +47 -0
  17. sourcebot/cli/main.py +28 -0
  18. sourcebot/config/__init__.py +15 -0
  19. sourcebot/config/base.py +13 -0
  20. sourcebot/config/config_manager.py +367 -0
  21. sourcebot/config/exceptions.py +4 -0
  22. sourcebot/config/global_config.py +55 -0
  23. sourcebot/config/provider_config.py +62 -0
  24. sourcebot/config/workspace_config.py +106 -0
  25. sourcebot/context/__init__.py +5 -0
  26. sourcebot/context/context_builder.py +78 -0
  27. sourcebot/context/identity.py +19 -0
  28. sourcebot/context/message_builder.py +154 -0
  29. sourcebot/context/skill/__init__.py +7 -0
  30. sourcebot/context/skill/skill.py +11 -0
  31. sourcebot/context/skill/skill_context.py +10 -0
  32. sourcebot/context/skill/skill_loader.py +57 -0
  33. sourcebot/context/skill/skill_metadata.py +27 -0
  34. sourcebot/context/skill/skill_requirements.py +25 -0
  35. sourcebot/context/skill/skill_summary.py +31 -0
  36. sourcebot/conversation/__init__.py +2 -0
  37. sourcebot/conversation/service.py +191 -0
  38. sourcebot/docker_sandbox/__init__.py +3 -0
  39. sourcebot/docker_sandbox/docker_sandbox.py +113 -0
  40. sourcebot/llm/__init__.py +3 -0
  41. sourcebot/llm/anthropic/__init__.py +2 -0
  42. sourcebot/llm/anthropic/adapter.py +30 -0
  43. sourcebot/llm/anthropic/anthropic_llm_client.py +38 -0
  44. sourcebot/llm/anthropic/converter.py +59 -0
  45. sourcebot/llm/core/adapter.py +16 -0
  46. sourcebot/llm/core/client.py +16 -0
  47. sourcebot/llm/core/delta.py +12 -0
  48. sourcebot/llm/core/message.py +53 -0
  49. sourcebot/llm/core/message_converter.py +33 -0
  50. sourcebot/llm/core/response.py +30 -0
  51. sourcebot/llm/core/tool.py +7 -0
  52. sourcebot/llm/core/tool_converter.py +30 -0
  53. sourcebot/llm/core/tool_delta_aggregator.py +38 -0
  54. sourcebot/llm/llm_client_factory.py +13 -0
  55. sourcebot/llm/openai/__init__.py +2 -0
  56. sourcebot/llm/openai/adapter.py +27 -0
  57. sourcebot/llm/openai/converter.py +53 -0
  58. sourcebot/llm/openai/openai_llm_client.py +47 -0
  59. sourcebot/logging/__init__.py +3 -0
  60. sourcebot/logging/setup.py +33 -0
  61. sourcebot/memory/__init__.py +5 -0
  62. sourcebot/memory/file_store.py +23 -0
  63. sourcebot/memory/llm_consolidator.py +79 -0
  64. sourcebot/memory/service.py +116 -0
  65. sourcebot/memory/window_policy.py +36 -0
  66. sourcebot/prompt/__init__.py +4 -0
  67. sourcebot/prompt/deeomposer_prompt.py +420 -0
  68. sourcebot/prompt/identity_prompt.py +98 -0
  69. sourcebot/prompt/subagent_prompt.py +25 -0
  70. sourcebot/runtime/__init__.py +3 -0
  71. sourcebot/runtime/agent/__init__.py +3 -0
  72. sourcebot/runtime/agent/agent.py +130 -0
  73. sourcebot/runtime/agent/agent_factory.py +83 -0
  74. sourcebot/runtime/dag/planner/__init__.py +3 -0
  75. sourcebot/runtime/dag/planner/dag_planner.py +26 -0
  76. sourcebot/runtime/dag/planner/execution_scheduler.py +35 -0
  77. sourcebot/runtime/dag/planner/parallelism_optimizer.py +44 -0
  78. sourcebot/runtime/dag/planner/task_decomposer.py +37 -0
  79. sourcebot/runtime/dag/scheduler/__init__.py +3 -0
  80. sourcebot/runtime/dag/scheduler/dag_scheduler.py +319 -0
  81. sourcebot/runtime/dag/scheduler/retry_policy.py +27 -0
  82. sourcebot/runtime/dag/scheduler/run_store.py +58 -0
  83. sourcebot/runtime/dag/scheduler/state_store.py +40 -0
  84. sourcebot/runtime/dag/scheduler/task_graph.py +29 -0
  85. sourcebot/runtime/init_system.py +182 -0
  86. sourcebot/runtime/tool_executor.py +30 -0
  87. sourcebot/security/policy.py +23 -0
  88. sourcebot/session/__init__.py +4 -0
  89. sourcebot/session/jsonl_repository.py +142 -0
  90. sourcebot/session/repository.py +19 -0
  91. sourcebot/session/service.py +44 -0
  92. sourcebot/session/session.py +53 -0
  93. sourcebot/storage/__init__.py +3 -0
  94. sourcebot/storage/rules_loader.py +72 -0
  95. sourcebot/storage/skill_storage.py +51 -0
  96. sourcebot/tools/__init__.py +7 -0
  97. sourcebot/tools/base.py +182 -0
  98. sourcebot/tools/registry.py +81 -0
  99. sourcebot/tools/rule_detail.py +70 -0
  100. sourcebot/tools/rule_list.py +57 -0
  101. sourcebot/tools/shell.py +93 -0
  102. sourcebot/tools/skill_detail.py +61 -0
  103. sourcebot/tools/skill_list.py +68 -0
  104. sourcebot/utils/__init__.py +2 -0
  105. sourcebot/utils/output.py +79 -0
  106. sourcebot-0.1.0.dist-info/METADATA +318 -0
  107. sourcebot-0.1.0.dist-info/RECORD +110 -0
  108. sourcebot-0.1.0.dist-info/WHEEL +5 -0
  109. sourcebot-0.1.0.dist-info/entry_points.txt +2 -0
  110. sourcebot-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,78 @@
1
+
2
+ # sourcebot/context/context_builder.py
3
+ from pathlib import Path
4
+ from typing import Optional, List
5
+ from sourcebot.context.identity import get_identity
6
+ from sourcebot.context.skill import SkillLoader, SkillSummary
7
+ from sourcebot.prompt import DECOMPOSER_PROMPT, SUBAGENT_PROMPT
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ class ContextBuilder:
13
+ """Build system prompts, including identity, rules, memories, and skills."""
14
+ def __init__(
15
+ self,
16
+ workspace: Path,
17
+ skill_storage,
18
+ rules_loader,
19
+ ):
20
+ self.workspace = workspace
21
+ self.rules_loader = rules_loader
22
+ self.skill_loader = SkillLoader(skill_storage)
23
+
24
+
25
+ def build_chat_prompt(self):
26
+ parts = [get_identity(str(self.workspace))]
27
+ return "\n\n---\n\n".join(parts)
28
+
29
+ # identity + rules + skill
30
+ def build_system_prompt(self, skill_names: Optional[List[str]] = None) -> str:
31
+ """Assemble the complete system prompt."""
32
+
33
+ parts = [get_identity(str(self.workspace))]
34
+ # Bootstrap rules
35
+ rules = self.build_rulse()
36
+ if rules:
37
+ parts.append(rules)
38
+
39
+ skills_summary = self.build_skills_summary()
40
+ if skills_summary:
41
+ parts.append(skills_summary)
42
+ return "\n\n---\n\n".join(parts)
43
+
44
+ def build_subagent_prompt(self, workspace: Optional[Path] = None) -> str:
45
+ """Build a focused system prompt for the subagent."""
46
+ from datetime import datetime
47
+ import time as _time
48
+ now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
49
+ tz = _time.strftime("%Z") or "UTC"
50
+
51
+ return SUBAGENT_PROMPT.format(
52
+ now = now,
53
+ tz = tz,
54
+ workspace = "/workspace"
55
+ )
56
+
57
+
58
+ def build_skills_summary(self) -> str:
59
+ all_skills = self.skill_loader.list_skills(filter_unavailable=False)
60
+ skills_summary = SkillSummary.generate(all_skills, pretty=True)
61
+ return f"""# Skills
62
+ The following skills extend your capabilities. To use a skill, get its SKILL.md file using the skill_detail tool.
63
+ Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
64
+ {skills_summary}"""
65
+
66
+ def build_rulse(self, skill_names: Optional[List[str]] = None) -> str:
67
+ return self.rules_loader.load_common_rules()
68
+
69
+ def build_decomposer_prompt(self, skill_names: Optional[List[str]] = None) -> str:
70
+
71
+ skills_summary = self.build_skills_summary()
72
+ rules = self.build_rulse()
73
+
74
+ return DECOMPOSER_PROMPT.format(
75
+ skills_summary = skills_summary,
76
+ rules = rules
77
+ )
78
+
@@ -0,0 +1,19 @@
1
+ # sourcebot/context/identity.py
2
+ from sourcebot.prompt import IDENTITY_PROMPT
3
+ from datetime import datetime
4
+ import platform
5
+ import time
6
+
7
+ def get_identity(workspace_path: str) -> str:
8
+ """Return bot identity/system prompt header."""
9
+ now = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%A)")
10
+ tz = time.strftime("%Z") or "UTC"
11
+ system = platform.system()
12
+ runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
13
+
14
+ return IDENTITY_PROMPT.format(
15
+ now = now,
16
+ tz = tz,
17
+ runtime = runtime,
18
+ workspace_path = "/workspace"
19
+ )
@@ -0,0 +1,154 @@
1
+ # domain/context/message_builder.py
2
+ from pathlib import Path
3
+ from typing import Any, List, Optional, Dict
4
+ import base64
5
+ import mimetypes
6
+ from sourcebot.context import ContextBuilder
7
+ from sourcebot.llm.core.message import Message, ToolCall, ToolResult
8
+ from sourcebot.llm.core.message_converter import dict_to_message
9
+
10
+ import logging
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class MessageBuilder:
14
+ """Build messages for LLM including system, user, and tool messages."""
15
+
16
+ def __init__(self, context_builder: ContextBuilder):
17
+ self.context_builder = context_builder
18
+
19
+ def _build_user_content(
20
+ self,
21
+ text: str,
22
+ media: Optional[List[str]] = None
23
+ ) -> Message:
24
+
25
+ metadata: Dict[str, Any] = {}
26
+ media_list: List[Dict[str, Any]] = []
27
+
28
+ if media:
29
+ for path_str in media:
30
+ p = Path(path_str)
31
+ mime, _ = mimetypes.guess_type(str(p))
32
+
33
+ if not p.is_file() or not mime or not mime.startswith("image/"):
34
+ logger.warning(f"Skipping invalid media: {path_str}")
35
+ continue
36
+
37
+ b64 = base64.b64encode(p.read_bytes()).decode()
38
+
39
+ media_list.append({
40
+ "filename": p.name,
41
+ "mime": mime,
42
+ "base64": b64
43
+ })
44
+
45
+ if media_list:
46
+ metadata["media"] = media_list
47
+
48
+ return Message(
49
+ role = "user",
50
+ content = text,
51
+ metadata = metadata,
52
+ )
53
+
54
+ def build_chat_messages(
55
+ self,
56
+ current_message: str,
57
+ history: List[Any] = None,
58
+ skill_names: Optional[List[str]] = None,
59
+ media: Optional[List[str]] = None,
60
+ channel: Optional[str] = None,
61
+ conversation_id: Optional[str] = None,
62
+ ) -> List[Message]:
63
+
64
+ messages: List[Message] = []
65
+
66
+ chat_prompt = self.context_builder.build_chat_prompt()
67
+ if channel and conversation_id:
68
+ chat_prompt += f"\n\n## Current Session\nChannel: {channel}\nConversation ID: {conversation_id}"
69
+
70
+ messages.append(Message(role="system", content=chat_prompt))
71
+
72
+ if history:
73
+ for msg in history:
74
+ if isinstance(msg, Message):
75
+ messages.append(msg)
76
+ else:
77
+ messages.append(dict_to_message(msg))
78
+
79
+ user_content = self._build_user_content(current_message, media)
80
+
81
+ messages.append(
82
+ Message(
83
+ role = user_content.role,
84
+ content = user_content.content,
85
+ )
86
+ )
87
+
88
+ return messages
89
+
90
+ # skills + rules
91
+ def build_messages(
92
+ self,
93
+ current_message: str,
94
+ history: List[Dict[str, Any]] = None,
95
+ skill_names: Optional[List[str]] = None,
96
+ media: Optional[List[str]] = None,
97
+ channel: Optional[str] = None,
98
+ conversation_id: Optional[str] = None,
99
+ ) -> List[Dict[str, Any]]:
100
+ messages: List[Message] = []
101
+
102
+ system_prompt = self.context_builder.build_system_prompt(skill_names)
103
+
104
+ if channel and conversation_id:
105
+ system_prompt += f"\n\n## Current Session\nChannel: {channel}\nConversation ID: {conversation_id}"
106
+
107
+ messages.append(Message(role="system", content=system_prompt))
108
+
109
+ user_content = self._build_user_content(current_message, media)
110
+
111
+ messages.append(
112
+ Message(
113
+ role = "user",
114
+ content = user_content["content"],
115
+ )
116
+ )
117
+
118
+ return messages
119
+
120
+ def add_tool_result(
121
+ self,
122
+ messages: List[Message],
123
+ tool_call_id: str,
124
+ result: str
125
+ ) -> List[Message]:
126
+
127
+ messages.append(
128
+ Message(
129
+ role="tool",
130
+ tool_results=[
131
+ ToolResult(
132
+ tool_call_id=tool_call_id,
133
+ content=result
134
+ )
135
+ ],
136
+ )
137
+ )
138
+
139
+ return messages
140
+ def add_assistant_message(
141
+ self,
142
+ messages,
143
+ content=None,
144
+ tool_calls=None,
145
+ ):
146
+
147
+ msg = Message(
148
+ role="assistant",
149
+ content=content,
150
+ tool_calls=tool_calls or [],
151
+ )
152
+
153
+ messages.append(msg)
154
+ return messages
@@ -0,0 +1,7 @@
1
+ from .skill_context import strip_frontmatter
2
+ from .skill_loader import SkillLoader
3
+ from .skill_metadata import SkillMetadataParser
4
+ from .skill_requirements import check_requirements, missing_requirements
5
+ from .skill_summary import SkillSummary
6
+ from .skill import Skill
7
+ __all__ = ["strip_frontmatter", "SkillLoader", "SkillMetadataParser", "check_requirements", "missing_requirements", "generate", "Skill",]
@@ -0,0 +1,11 @@
1
+ # sourcebot/skill/skill.py
2
+ from dataclasses import dataclass
3
+
4
+ @dataclass
5
+ class Skill:
6
+ name: str
7
+ description: str
8
+ content: str
9
+ requirements: dict
10
+ always: bool = False
11
+ source: str = "root"
@@ -0,0 +1,10 @@
1
+ # sourcebot/skill/skill_context.py
2
+ import re
3
+
4
+ def strip_frontmatter(content: str) -> str:
5
+ """Remove YAML frontmatter from markdown content."""
6
+ if content.startswith("---"):
7
+ match = re.match(r"^---\s*\n.*?\n---\s*\n", content, re.DOTALL)
8
+ if match:
9
+ return content[match.end():].strip()
10
+ return content
@@ -0,0 +1,57 @@
1
+ # sourcebot/context/skill/skill_loader.py
2
+ from typing import List, Optional
3
+ from sourcebot.context.skill.skill import Skill
4
+ from sourcebot.context.skill.skill_metadata import SkillMetadataParser
5
+ from sourcebot.context.skill.skill_requirements import check_requirements
6
+ from sourcebot.storage.skill_storage import SkillStorage
7
+ from sourcebot.context.skill.skill_context import strip_frontmatter
8
+
9
+ class SkillLoader:
10
+ """Load skills as Skill instances."""
11
+ def __init__(self, skill_storage: SkillStorage):
12
+ self.skill_storage = skill_storage
13
+ self._cache: dict[str, Skill] = {}
14
+
15
+ def list_skills(self, filter_unavailable: bool = True) -> List[Skill]:
16
+ skills: List[Skill] = []
17
+ for name, path, source in self.skill_storage.list_skill_dirs(source = "all"):
18
+ content = path.read_text(encoding="utf-8")
19
+ meta = SkillMetadataParser.parse(content)
20
+ skill = Skill(
21
+ name=name,
22
+ description=meta.get("description", name),
23
+ content=content,
24
+ requirements=meta.get("requires", {}),
25
+ always=meta.get("always", False),
26
+ source=source
27
+ )
28
+ if not filter_unavailable or check_requirements(skill):
29
+ skills.append(skill)
30
+ self._cache[name] = skill
31
+ return skills
32
+
33
+ def load_skill(self, name: str) -> Optional[Skill]:
34
+ if name in self._cache:
35
+ return self._cache[name]
36
+ content = self.skill_storage.read_skill(name)
37
+ if not content:
38
+ return None
39
+ meta = SkillMetadataParser.parse(content)
40
+ skill = Skill(
41
+ name=name,
42
+ description=meta.get("description", name),
43
+ content=content,
44
+ requirements=meta.get("requires", {}),
45
+ always=meta.get("always", False),
46
+ source="root"
47
+ )
48
+ self._cache[name] = skill
49
+ return skill
50
+
51
+ def load_skills_for_context(self, skill_names: List[str]) -> str:
52
+ parts = []
53
+ for name in skill_names:
54
+ skill = self.load_skill(name)
55
+ if skill:
56
+ parts.append(f"### Skill: {skill.name}\n\n{strip_frontmatter(skill.content)}")
57
+ return "\n\n---\n\n".join(parts) if parts else ""
@@ -0,0 +1,27 @@
1
+ # sourcebot/skill/skill_metadata.py
2
+ import re, json
3
+ from typing import Dict
4
+
5
+ class SkillMetadataParser:
6
+ """Parse YAML/JSON frontmatter from skill markdown files."""
7
+ FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
8
+
9
+ @classmethod
10
+ def parse(cls, content: str) -> Dict:
11
+ match = cls.FRONTMATTER_RE.match(content)
12
+ if not match:
13
+ return {}
14
+ front = match.group(1)
15
+ metadata = {}
16
+ for line in front.split("\n"):
17
+ if ":" in line:
18
+ k, v = line.split(":", 1)
19
+ metadata[k.strip()] = v.strip().strip('"\'')
20
+ raw_meta = metadata.get("metadata")
21
+ if raw_meta:
22
+ try:
23
+ data = json.loads(raw_meta)
24
+ return data.get("sourcebot", data.get("openclaw", {}))
25
+ except (json.JSONDecodeError, TypeError):
26
+ return {}
27
+ return metadata
@@ -0,0 +1,25 @@
1
+ # sourcebot/context/skill/skill_requirements.py
2
+
3
+ import os, shutil
4
+ from sourcebot.context.skill.skill import Skill
5
+
6
+ def check_requirements(skill: Skill) -> bool:
7
+ req = skill.requirements or {}
8
+ for b in req.get("bins", []):
9
+ if not shutil.which(b):
10
+ return False
11
+ for e in req.get("env", []):
12
+ if not os.environ.get(e):
13
+ return False
14
+ return True
15
+
16
+ def missing_requirements(skill: Skill) -> str:
17
+ req = skill.requirements or {}
18
+ missing = []
19
+ for b in req.get("bins", []):
20
+ if not shutil.which(b):
21
+ missing.append(f"CLI:{b}")
22
+ for e in req.get("env", []):
23
+ if not os.environ.get(e):
24
+ missing.append(f"ENV:{e}")
25
+ return ", ".join(missing)
@@ -0,0 +1,31 @@
1
+ # sourcebot/context/skill/skill_summary.py
2
+
3
+ from xml.etree.ElementTree import Element, SubElement, tostring
4
+ from typing import List
5
+ from sourcebot.context.skill.skill import Skill
6
+ from sourcebot.context.skill.skill_requirements import check_requirements, missing_requirements
7
+
8
+ class SkillSummary:
9
+ """Generate XML summary of a list of skills."""
10
+
11
+ @staticmethod
12
+ def generate(skills: List[Skill], pretty: bool = False) -> str:
13
+ root = Element("skills")
14
+ for skill in skills:
15
+ skill_elem = SubElement(root, "skill")
16
+ available = check_requirements(skill)
17
+ skill_elem.set("available", str(available).lower())
18
+ SubElement(skill_elem, "name").text = skill.name
19
+ SubElement(skill_elem, "description").text = skill.description
20
+ SubElement(skill_elem, "location").text = skill.source
21
+ if not available:
22
+ reqs = missing_requirements(skill)
23
+ if reqs:
24
+ SubElement(skill_elem, "requires").text = reqs
25
+ xml_bytes = tostring(root, encoding="unicode")
26
+ if pretty:
27
+ import xml.dom.minidom
28
+ dom = xml.dom.minidom.parseString(xml_bytes)
29
+ return dom.toprettyxml(indent=" ")
30
+
31
+ return xml_bytes
@@ -0,0 +1,2 @@
1
+ from sourcebot.conversation.service import ConversationService
2
+ __all__ = ["ConversationService"]
@@ -0,0 +1,191 @@
1
+ # sourcebot/conversation/service.py
2
+
3
+ import asyncio
4
+ from typing import Callable, Awaitable
5
+ import logging
6
+ from sourcebot.session.session import Session
7
+ from sourcebot.session.service import SessionService
8
+ from sourcebot.memory.service import MemoryService
9
+ from sourcebot.runtime.agent import Agent
10
+ from sourcebot.bus import InboundMessage, OutboundMessage
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class ConversationService:
15
+ """
16
+ Orchestrates a full user conversation turn.
17
+
18
+ Responsibilities:
19
+ - Command routing
20
+ - Session load/save
21
+ - Context building
22
+ - Agent execution
23
+ - Memory consolidation scheduling
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ session_service: SessionService,
29
+ memory_service: MemoryService,
30
+ agent: Agent,
31
+ message_builder,
32
+ bus = None,
33
+ memory_window: int = 50,
34
+ ):
35
+ self.session_service = session_service
36
+ self.memory_service = memory_service
37
+ self.agent = agent
38
+ self.message_builder = message_builder
39
+ # self.bus = bus
40
+ self.memory_window = memory_window
41
+
42
+ self._consolidating: set[str] = set()
43
+
44
+
45
+ # Entry point
46
+ async def handle_message(
47
+ self,
48
+ msg: InboundMessage,
49
+ on_progress: Callable[[str], Awaitable[None]] | None = None,
50
+ ) -> OutboundMessage | None:
51
+
52
+ if self._is_command(msg):
53
+ return await self._handle_command(msg)
54
+
55
+ if msg.channel == "system":
56
+ return await self._handle_system_message(msg)
57
+ return await self._handle_conversation(msg, on_progress)
58
+
59
+
60
+ # Conversation flow
61
+ async def _handle_conversation(
62
+ self,
63
+ msg: InboundMessage,
64
+ on_progress: Callable[[str], Awaitable[None]] | None,
65
+ ) -> OutboundMessage | None:
66
+ # Session service
67
+ session = self.session_service.get_history(msg.session_key)
68
+ await self._maybe_schedule_consolidation(session)
69
+ messages = self.message_builder.build_chat_messages(
70
+ history = session.get_history(max_messages=self.memory_window),
71
+ current_message = msg.content,
72
+ media = msg.metadata if msg.metadata else None,
73
+ channel = msg.channel,
74
+ conversation_id = msg.conversation_id,
75
+ )
76
+ async def _bus_progress(content: str):
77
+ if not self.bus:
78
+ return
79
+ metadata = dict(msg.metadata or {})
80
+ metadata["_progress"] = True
81
+ await self.bus.publish_outbound(
82
+ OutboundMessage(
83
+ channel = msg.channel,
84
+ conversation_id = msg.conversation_id,
85
+ content = content,
86
+ metadata = data,
87
+ )
88
+ )
89
+ final_content, _, tools_used = await self.agent.run(messages)
90
+
91
+ if final_content is None:
92
+ final_content = "I've completed processing but have no response."
93
+
94
+ self.session_service.append_turn(
95
+ session.key,
96
+ user = msg.content,
97
+ assistant = final_content,
98
+ tools_used = tools_used,
99
+ )
100
+ return OutboundMessage(
101
+ channel = msg.channel,
102
+ conversation_id = msg.conversation_id,
103
+ content = final_content,
104
+ metadata = msg.metadata or {},
105
+ )
106
+
107
+
108
+ # System message
109
+ async def _handle_system_message(
110
+ self,
111
+ msg: InboundMessage,
112
+ ) -> OutboundMessage:
113
+
114
+ key = msg.session_key
115
+ session = self.session_service.get(key)
116
+
117
+ messages = self.message_builder.build_messages(
118
+ history = session.get_history(max_messages = self.memory_window),
119
+ current_message = msg.content,
120
+ channel = msg.channel,
121
+ conversation_id = msg.conversation_id,
122
+ )
123
+
124
+ final_content, _ = await self.agent.run(messages)
125
+
126
+ session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
127
+ session.add_message("assistant", final_content)
128
+
129
+ self.session_service.save(session)
130
+
131
+ return OutboundMessage(
132
+ channel = msg.channel,
133
+ conversation_id = msg.conversation_id,
134
+ content = final_content or "Background task completed.",
135
+ )
136
+
137
+
138
+ # commands
139
+ def _is_command(self, msg: InboundMessage) -> bool:
140
+ return msg.content.strip().startswith("/")
141
+
142
+ async def _handle_command(self, msg: InboundMessage) -> OutboundMessage:
143
+
144
+ cmd = msg.content.strip().lower()
145
+ session = self.session_service.get_history(msg.session_key)
146
+
147
+ if cmd == "/new":
148
+ archived = session.messages.copy()
149
+ session.clear()
150
+ self.session_service.save(session)
151
+ await self.memory_service.consolidate_archived(
152
+ session.key,
153
+ archived
154
+ )
155
+ return OutboundMessage(
156
+ channel = msg.channel,
157
+ conversation_id = msg.conversation_id,
158
+ content = "New session started. Memory consolidation in progress.",
159
+ )
160
+
161
+ if cmd == "/help":
162
+ return OutboundMessage(
163
+ channel = msg.channel,
164
+ conversation_id = msg.conversation_id,
165
+ content = (
166
+ "Available commands:\n"
167
+ "/new — Start a new conversation\n"
168
+ "/help — Show help"
169
+ ),
170
+ )
171
+ return OutboundMessage(
172
+ channel = msg.channel,
173
+ conversation_id = msg.conversation_id,
174
+ content = "Unknown command.",
175
+ )
176
+
177
+ # Memory consolidation
178
+ async def _maybe_schedule_consolidation(self, session: Session):
179
+ if (
180
+ len(session.messages) <= self.memory_window
181
+ or session.key in self._consolidating
182
+ ):
183
+ return
184
+ self._consolidating.add(session.key)
185
+
186
+ async def _task():
187
+ try:
188
+ await self.memory_service.maybe_consolidate(session)
189
+ finally:
190
+ self._consolidating.discard(session.key)
191
+ await _task()
@@ -0,0 +1,3 @@
1
+ from sourcebot.docker_sandbox.docker_sandbox import DockerSandbox
2
+
3
+ __all__ = ["DockerSandbox"]