kolega-code 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 (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,341 @@
1
+ """FileSystem implementation for sandbox environments."""
2
+
3
+ import os
4
+ import base64
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Iterator, List, Optional, Union, BinaryIO
7
+ from contextlib import contextmanager
8
+ from datetime import datetime
9
+
10
+ from ..services.file_system import FileSystem
11
+
12
+
13
+ class SandboxFileSystem(FileSystem):
14
+ """FileSystem implementation that operates within a sandbox."""
15
+
16
+ def __init__(self, sandbox: Any, root_path: str = "/home/user/workspace"):
17
+ """
18
+ Initialize sandbox filesystem.
19
+
20
+ Args:
21
+ sandbox: The sandbox instance (e.g., E2B Sandbox)
22
+ root_path: Root path within the sandbox
23
+ """
24
+ self.sandbox = sandbox
25
+ self.root_path = root_path
26
+
27
+ def _resolve_path(self, path: str) -> str:
28
+ """Resolve path relative to root."""
29
+ if os.path.isabs(path):
30
+ return path
31
+ if path == ".":
32
+ return self.root_path
33
+ return os.path.join(self.root_path, path)
34
+
35
+ # Synchronous methods using E2B v1.5.0 API
36
+ def open(self, path: str, mode: str = "r", encoding: Optional[str] = None) -> Any:
37
+ """Open is not directly supported in sandbox - use read/write methods instead."""
38
+ raise NotImplementedError("Direct file handles not supported in sandbox. Use read_text/write_text instead.")
39
+
40
+ def read_text(self, path: str, encoding: str = "utf-8") -> str:
41
+ """Read text from file."""
42
+ full_path = self._resolve_path(path)
43
+ try:
44
+ content = self.sandbox.files.read(full_path)
45
+ if isinstance(content, bytes):
46
+ return content.decode(encoding)
47
+ return content
48
+ except Exception as e:
49
+ raise FileNotFoundError(f"Could not read file {path}: {e}")
50
+
51
+ def read_bytes(self, path: str) -> bytes:
52
+ """Read bytes from file."""
53
+ full_path = self._resolve_path(path)
54
+ try:
55
+ # For binary data, use base64 encoding via shell commands
56
+ # to avoid E2B files API text encoding issues
57
+ result = self.sandbox.commands.run(f"base64 {full_path}")
58
+
59
+ if result.exit_code != 0:
60
+ raise FileNotFoundError(f"Could not read file {path}")
61
+
62
+ # Decode the base64 content
63
+ return base64.b64decode(result.stdout.strip())
64
+
65
+ except Exception as e:
66
+ raise FileNotFoundError(f"Could not read file {path}: {e}")
67
+
68
+ def write_text(self, path: str, content: str, encoding: str = "utf-8") -> None:
69
+ """Write text to file."""
70
+ full_path = self._resolve_path(path)
71
+ try:
72
+ # Ensure parent directory exists
73
+ parent_dir = os.path.dirname(full_path)
74
+ if parent_dir != self.root_path:
75
+ self.sandbox.commands.run(f"mkdir -p {parent_dir}")
76
+
77
+ self.sandbox.files.write(full_path, content)
78
+ except Exception as e:
79
+ raise OSError(f"Could not write file {path}: {e}")
80
+
81
+ def write_bytes(self, path: str, content: bytes) -> None:
82
+ """Write bytes to file."""
83
+ full_path = self._resolve_path(path)
84
+ try:
85
+ # Ensure parent directory exists
86
+ parent_dir = os.path.dirname(full_path)
87
+ if parent_dir != self.root_path:
88
+ self.sandbox.commands.run(f"mkdir -p {parent_dir}")
89
+
90
+ # For binary data, use base64 encoding and write via shell commands
91
+ # to avoid E2B files API text encoding issues
92
+ encoded_content = base64.b64encode(content).decode("ascii")
93
+
94
+ # Write the base64 encoded content and decode it
95
+ result = self.sandbox.commands.run(f"echo '{encoded_content}' | base64 -d > {full_path}")
96
+
97
+ if result.exit_code != 0:
98
+ raise OSError(f"Failed to write binary file {path}: {result.stderr}")
99
+
100
+ except Exception as e:
101
+ raise OSError(f"Could not write file {path}: {e}")
102
+
103
+ def exists(self, path: str) -> bool:
104
+ """Check if path exists."""
105
+ full_path = self._resolve_path(path)
106
+ try:
107
+ result = self.sandbox.commands.run(f"test -e {full_path}")
108
+ return result.exit_code == 0
109
+ except:
110
+ return False
111
+
112
+ def is_file(self, path: str) -> bool:
113
+ """Check if path is a file."""
114
+ full_path = self._resolve_path(path)
115
+ try:
116
+ result = self.sandbox.commands.run(f"test -f {full_path}")
117
+ return result.exit_code == 0
118
+ except:
119
+ return False
120
+
121
+ def is_dir(self, path: str) -> bool:
122
+ """Check if path is a directory."""
123
+ full_path = self._resolve_path(path)
124
+ try:
125
+ result = self.sandbox.commands.run(f"test -d {full_path}")
126
+ return result.exit_code == 0
127
+ except:
128
+ return False
129
+
130
+ def stat(self, path: str) -> Dict[str, Any]:
131
+ """Get file statistics."""
132
+ full_path = self._resolve_path(path)
133
+ try:
134
+ # Use stat command to get file info
135
+ result = self.sandbox.commands.run(f"stat -c '%s %Y %Z' {full_path}")
136
+ if result.exit_code != 0:
137
+ raise FileNotFoundError(f"File not found: {path}")
138
+
139
+ size, mtime, ctime = result.stdout.strip().split()
140
+
141
+ return {
142
+ "size": int(size),
143
+ "modified_time": int(mtime),
144
+ "created_time": int(ctime),
145
+ "is_file": self.is_file(path),
146
+ "is_directory": self.is_dir(path),
147
+ }
148
+ except Exception as e:
149
+ raise OSError(f"Could not stat {path}: {e}")
150
+
151
+ def mkdir(self, path: str, parents: bool = False, exist_ok: bool = False) -> None:
152
+ """Create directory."""
153
+ full_path = self._resolve_path(path)
154
+
155
+ # Check if directory already exists
156
+ if self.exists(path):
157
+ if not exist_ok:
158
+ raise FileExistsError(f"Directory already exists: {path}")
159
+ return
160
+
161
+ try:
162
+ if parents:
163
+ result = self.sandbox.commands.run(f"mkdir -p {full_path}")
164
+ else:
165
+ result = self.sandbox.commands.run(f"mkdir {full_path}")
166
+
167
+ if result.exit_code != 0:
168
+ raise OSError(f"Failed to create directory {path}: {result.stderr}")
169
+ except Exception as e:
170
+ raise OSError(f"Could not create directory {path}: {e}")
171
+
172
+ def remove(self, path: str, missing_ok: bool = False) -> None:
173
+ """Remove file."""
174
+ full_path = self._resolve_path(path)
175
+
176
+ if not self.exists(path):
177
+ if not missing_ok:
178
+ raise FileNotFoundError(f"File not found: {path}")
179
+ return
180
+
181
+ try:
182
+ result = self.sandbox.commands.run(f"rm -f {full_path}")
183
+ if result.exit_code != 0:
184
+ raise OSError(f"Failed to remove file {path}: {result.stderr}")
185
+ except Exception as e:
186
+ raise OSError(f"Could not remove file {path}: {e}")
187
+
188
+ def rmdir(self, path: str) -> None:
189
+ """Remove empty directory."""
190
+ full_path = self._resolve_path(path)
191
+ try:
192
+ result = self.sandbox.commands.run(f"rmdir {full_path}")
193
+ if result.exit_code != 0:
194
+ raise OSError(f"Failed to remove directory {path}: {result.stderr}")
195
+ except Exception as e:
196
+ raise OSError(f"Could not remove directory {path}: {e}")
197
+
198
+ def rmtree(self, path: str) -> None:
199
+ """Remove directory tree."""
200
+ full_path = self._resolve_path(path)
201
+ try:
202
+ result = self.sandbox.commands.run(f"rm -rf {full_path}")
203
+ if result.exit_code != 0:
204
+ raise OSError(f"Failed to remove directory tree {path}: {result.stderr}")
205
+ except Exception as e:
206
+ raise OSError(f"Could not remove directory tree {path}: {e}")
207
+
208
+ def listdir(self, path: str) -> List[str]:
209
+ """List directory contents."""
210
+ full_path = self._resolve_path(path)
211
+
212
+ if not self.is_dir(path):
213
+ raise NotADirectoryError(f"Not a directory: {path}")
214
+
215
+ try:
216
+ result = self.sandbox.commands.run(f"ls -1 {full_path}")
217
+ if result.exit_code != 0:
218
+ raise FileNotFoundError(f"Directory not found: {path}")
219
+
220
+ if not result.stdout.strip():
221
+ return []
222
+
223
+ return [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
224
+ except Exception as e:
225
+ raise OSError(f"Could not list directory {path}: {e}")
226
+
227
+ def iterdir(self, path: str) -> Iterator[str]:
228
+ """Iterate directory contents."""
229
+ return iter(self.listdir(path))
230
+
231
+ def glob(self, pattern: str) -> List[str]:
232
+ """Find paths matching pattern."""
233
+ try:
234
+ # Handle different types of glob patterns
235
+ if pattern.startswith("**/"):
236
+ # Recursive pattern like **/*.py or **/*
237
+ remaining_pattern = pattern[3:] # Remove '**/'
238
+
239
+ if remaining_pattern == "*":
240
+ # Pattern is **/* - find all files and directories recursively
241
+ result = self.sandbox.commands.run(
242
+ f"cd {self.root_path} && find . -mindepth 1 2>/dev/null | sed 's|^./||' | sort"
243
+ )
244
+ else:
245
+ # Pattern like **/*.py - find files matching pattern recursively
246
+ if "*" in remaining_pattern or "?" in remaining_pattern:
247
+ # Use find with -name for pattern matching
248
+ result = self.sandbox.commands.run(
249
+ f"cd {self.root_path} && find . -name '{remaining_pattern}' 2>/dev/null | sed 's|^./||' | sort"
250
+ )
251
+ else:
252
+ # Exact filename search recursively
253
+ result = self.sandbox.commands.run(
254
+ f"cd {self.root_path} && find . -name '{remaining_pattern}' 2>/dev/null | sed 's|^./||' | sort"
255
+ )
256
+
257
+ elif "*" in pattern or "?" in pattern:
258
+ # Simple glob pattern like *.py or test*.txt
259
+ if "/" in pattern:
260
+ # Pattern has directory component like subdir/*.txt
261
+ parent_dir = os.path.dirname(pattern)
262
+ filename_pattern = os.path.basename(pattern)
263
+ result = self.sandbox.commands.run(
264
+ f"cd {self.root_path} && find {parent_dir} -maxdepth 1 -name '{filename_pattern}' 2>/dev/null | sort"
265
+ )
266
+ else:
267
+ # Simple pattern like *.py in current directory
268
+ result = self.sandbox.commands.run(
269
+ f"cd {self.root_path} && find . -maxdepth 1 -name '{pattern}' 2>/dev/null | sed 's|^./||' | sort"
270
+ )
271
+
272
+ else:
273
+ # No wildcards - check if exact path exists
274
+ if self.exists(pattern):
275
+ return [pattern]
276
+ else:
277
+ return []
278
+
279
+ # Process the result
280
+ if result.exit_code == 0 and result.stdout.strip():
281
+ paths = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
282
+ # Filter out empty strings and current directory
283
+ return [path for path in paths if path and path != "."]
284
+ else:
285
+ return []
286
+
287
+ except Exception as e:
288
+ # If any error occurs, return empty list to match expected behavior
289
+ return []
290
+
291
+ def is_binary_file(self, path: str) -> bool:
292
+ """Check if file is binary."""
293
+ full_path = self._resolve_path(path)
294
+ try:
295
+ # Use file command to detect binary
296
+ result = self.sandbox.commands.run(f"file -b --mime {full_path}")
297
+ return result.exit_code == 0 and "charset=binary" in result.stdout
298
+ except:
299
+ return False
300
+
301
+ def get_name(self, path: str) -> str:
302
+ """Get basename of path."""
303
+ return os.path.basename(path)
304
+
305
+ def get_suffix(self, path: str) -> str:
306
+ """Get file extension."""
307
+ return os.path.splitext(path)[1]
308
+
309
+ def get_parent(self, path: str) -> str:
310
+ """Get parent directory."""
311
+ parent = os.path.dirname(path)
312
+ # Return "." for root-level files to match LocalFileSystem behavior
313
+ return parent if parent else "."
314
+
315
+ def get_parents(self, path: str) -> List[str]:
316
+ """Get all parent directories."""
317
+ parents = []
318
+ current = os.path.dirname(path)
319
+ while current and current != "/":
320
+ parents.append(current)
321
+ current = os.path.dirname(current)
322
+ return parents
323
+
324
+ def relative_to(self, path: str, other: str) -> str:
325
+ """Get relative path."""
326
+ return os.path.relpath(path, other)
327
+
328
+ def join_path(self, *parts: str) -> str:
329
+ """Join path components."""
330
+ return os.path.join(*parts)
331
+
332
+ def is_absolute(self, path: str) -> bool:
333
+ """Check if path is absolute."""
334
+ return os.path.isabs(path)
335
+
336
+ def get_path(self, path: str) -> Path:
337
+ """Get a Path object for the given path."""
338
+ resolved = self._resolve_path(path)
339
+ # Since _resolve_path returns a string for sandbox filesystem,
340
+ # we need to create a Path object from it
341
+ return Path(resolved)
@@ -0,0 +1,118 @@
1
+ """Local development implementation of sandbox manager."""
2
+
3
+ from typing import Dict, Any, Optional, List
4
+
5
+ from .base import SandboxConfig, SandboxManager
6
+ from ..services.file_system import FileSystem, LocalFileSystem
7
+ from ..services.base import TerminalManager, BrowserManager
8
+ from ..services.terminal import LocalTerminalManager
9
+ from ..services.browser import PlaywrightBrowserManager
10
+
11
+
12
+ class LocalSandboxManager(SandboxManager):
13
+ """Local development implementation that doesn't use actual sandboxes."""
14
+
15
+ def __init__(self, connection_manager=None):
16
+ """Initialize local sandbox manager."""
17
+ self.connection_manager = connection_manager
18
+ # Use local services
19
+ self.filesystem = None
20
+ self.terminal_manager = None
21
+ self.browser_manager = None
22
+
23
+ async def create_sandbox(
24
+ self,
25
+ workspace_id: str,
26
+ thread_id: str,
27
+ config: Optional[SandboxConfig] = None,
28
+ workspace: Optional[Any] = None,
29
+ connection_manager: Optional[Any] = None,
30
+ ) -> str:
31
+ """Create a 'local' sandbox (just returns a fake ID)."""
32
+ # In local mode, we don't actually create sandboxes
33
+ # Use a simple consistent ID
34
+ sandbox_id = "local"
35
+
36
+ # Initialize local services if not already done
37
+ if self.filesystem is None and workspace:
38
+ self.filesystem = LocalFileSystem(root_path=workspace.directory_path)
39
+ if self.terminal_manager is None:
40
+ self.terminal_manager = LocalTerminalManager(workspace_id, thread_id, self.connection_manager)
41
+ if self.browser_manager is None:
42
+ self.browser_manager = PlaywrightBrowserManager()
43
+
44
+ return sandbox_id
45
+
46
+ async def destroy_sandbox(self, sandbox_id: str) -> None:
47
+ """Destroy a 'local' sandbox (no-op in local mode)."""
48
+ # In local mode, we don't need to destroy anything
49
+ pass
50
+
51
+ async def get_sandbox_status(self, sandbox_id: str) -> Dict[str, Any]:
52
+ """Get sandbox status (always 'alive' in local mode)."""
53
+ return {
54
+ "sandbox_id": sandbox_id,
55
+ "is_alive": True,
56
+ "is_local": True,
57
+ }
58
+
59
+ async def commit_changes(self, sandbox_id: str, message: str, files: Optional[List[str]] = None) -> str:
60
+ """Commit changes (no-op in local mode, returns empty string)."""
61
+ # In local mode, we don't manage git
62
+ return ""
63
+
64
+ async def push_changes(self, sandbox_id: str) -> bool:
65
+ """Push changes (no-op in local mode, returns False)."""
66
+ # In local mode, we don't manage git
67
+ return False
68
+
69
+ def get_filesystem(self, sandbox_id: str) -> FileSystem:
70
+ """Get filesystem for local development."""
71
+ if self.filesystem is None:
72
+ raise ValueError("Filesystem not initialized - call create_sandbox first")
73
+ return self.filesystem
74
+
75
+ def get_terminal_manager(self, sandbox_id: str) -> TerminalManager:
76
+ """Get terminal manager for local development."""
77
+ if self.terminal_manager is None:
78
+ raise ValueError("Terminal manager not initialized - call create_sandbox first")
79
+ return self.terminal_manager
80
+
81
+ def get_browser_manager(self, sandbox_id: str) -> BrowserManager:
82
+ """Get browser manager for local development."""
83
+ if self.browser_manager is None:
84
+ raise ValueError("Browser manager not initialized - call create_sandbox first")
85
+ return self.browser_manager
86
+
87
+ async def get_host(self, sandbox_id: str, port: int) -> str:
88
+ """Always returns localhost for local development."""
89
+ return f"localhost:{port}"
90
+
91
+ async def pause_sandbox(self, sandbox_id: str) -> str:
92
+ """Pause sandbox (no-op in local mode, returns sandbox_id)."""
93
+ # In local mode, we don't pause sandboxes
94
+ # Just return the sandbox_id as the "persistent" ID
95
+ return sandbox_id
96
+
97
+ async def resume_sandbox(self, persistent_sandbox_id: str, workspace_id: str, thread_id: str) -> str:
98
+ """Resume sandbox (no-op in local mode, returns same ID)."""
99
+ # In local mode, we don't actually resume sandboxes
100
+ # Just return the same ID
101
+ return persistent_sandbox_id
102
+
103
+ def has_sandbox(self, sandbox_id: str) -> bool:
104
+ """Check if a sandbox is currently active (always true in local mode if initialized)."""
105
+ # In local mode, if we have services initialized, the "sandbox" is active
106
+ return self.filesystem is not None
107
+
108
+ async def adopt_sandbox(self, sandbox_id: str, workspace_id: str, thread_id: str) -> str:
109
+ """Adopt sandbox (no-op in local mode, returns same ID)."""
110
+ # In local mode, we don't actually adopt sandboxes
111
+ # Just return the same ID
112
+ return sandbox_id
113
+
114
+ async def sync_sandbox_env_vars(self, sandbox_id: str, workspace_id: str, sandbox: Optional[Any] = None, skip_integration_env_sync: bool = False) -> None:
115
+ """Sync environment variables (no-op in local mode)."""
116
+ # In local mode, environment variables are managed by the local system
117
+ # No need to sync to a sandbox profile
118
+ pass
@@ -0,0 +1,175 @@
1
+ """Terminal manager state serialization utilities."""
2
+
3
+ from typing import Dict, Any, Optional, List
4
+ from datetime import datetime, timezone
5
+ from kolega_code.models.sandbox_terminal_state import SandboxTerminalState, TerminalInfo, TerminalOutput
6
+
7
+
8
+ class TerminalStateSerializer:
9
+ """Handles serialization and deserialization of terminal manager state."""
10
+
11
+ @staticmethod
12
+ def serialize_to_model(terminal_manager, workspace_id: str, sandbox_id: str) -> SandboxTerminalState:
13
+ """Serialize terminal manager state to a SandboxTerminalState model."""
14
+ state = SandboxTerminalState(workspace_id=workspace_id, sandbox_id=sandbox_id)
15
+
16
+ if not hasattr(terminal_manager, "terminals"):
17
+ return state # Can't serialize local terminal managers
18
+
19
+ # Serialize terminals
20
+ for terminal_id, info in terminal_manager.terminals.items():
21
+ state.terminals[terminal_id] = TerminalInfo(
22
+ terminal_id=terminal_id,
23
+ created_at=info["created_at"],
24
+ cwd=info["cwd"],
25
+ env=info["env"],
26
+ last_command=info.get("last_command", ""),
27
+ last_command_purpose=info.get("last_command_purpose", ""),
28
+ )
29
+
30
+ # Serialize outputs with size tracking
31
+ total_size = 0
32
+ for terminal_id, outputs in terminal_manager.outputs.items():
33
+ terminal_outputs = []
34
+ terminal_size = 0
35
+
36
+ # Process outputs in reverse order (keep most recent)
37
+ for output in reversed(outputs):
38
+ terminal_output = TerminalOutput(
39
+ type=output["type"],
40
+ data=output["data"],
41
+ timestamp=output["timestamp"],
42
+ purpose=output.get("purpose"),
43
+ exit_code=output.get("exit_code"),
44
+ )
45
+ output_size = len(output["data"].encode("utf-8"))
46
+
47
+ # Check if adding this output would exceed limits
48
+ if (
49
+ terminal_size + output_size > state.MAX_OUTPUT_PER_TERMINAL
50
+ or total_size + output_size > state.MAX_OUTPUT_SIZE
51
+ ):
52
+ # Add truncation notice at the beginning
53
+ if not any(o.type == "truncation" for o in terminal_outputs):
54
+ terminal_outputs.insert(
55
+ 0,
56
+ TerminalOutput(
57
+ type="truncation",
58
+ data="[Earlier terminal output truncated due to size limits]",
59
+ timestamp=datetime.now(timezone.utc),
60
+ ),
61
+ )
62
+ break
63
+
64
+ terminal_outputs.insert(0, terminal_output) # Insert at beginning to maintain order
65
+ terminal_size += output_size
66
+ total_size += output_size
67
+
68
+ state.outputs[terminal_id] = terminal_outputs
69
+
70
+ # Break if we've hit total size limit
71
+ if total_size >= state.MAX_OUTPUT_SIZE:
72
+ break
73
+
74
+ state.total_output_size = total_size
75
+ state.default_terminal_id = getattr(terminal_manager, "_default_terminal_id", None)
76
+
77
+ return state
78
+
79
+ @staticmethod
80
+ def restore_from_model(terminal_manager, state: SandboxTerminalState) -> None:
81
+ """Restore terminal manager state from a SandboxTerminalState model."""
82
+ if not state or not hasattr(terminal_manager, "terminals"):
83
+ return
84
+
85
+ # Clear existing state
86
+ terminal_manager.terminals.clear()
87
+ terminal_manager.outputs.clear()
88
+
89
+ # Restore terminals
90
+ for terminal_id, terminal_info in state.terminals.items():
91
+ terminal_manager.terminals[terminal_id] = {
92
+ "created_at": terminal_info.created_at,
93
+ "cwd": terminal_info.cwd,
94
+ "env": terminal_info.env,
95
+ "process": None, # Process references can't be restored
96
+ "last_command": terminal_info.last_command,
97
+ "last_command_purpose": terminal_info.last_command_purpose,
98
+ "active_commands": {}, # Active commands start fresh
99
+ }
100
+
101
+ # Restore outputs
102
+ for terminal_id, outputs in state.outputs.items():
103
+ terminal_manager.outputs[terminal_id] = [
104
+ {
105
+ "type": output.type,
106
+ "data": output.data,
107
+ "timestamp": output.timestamp,
108
+ "purpose": output.purpose,
109
+ "exit_code": output.exit_code,
110
+ }
111
+ for output in outputs
112
+ if output.type != "truncation" # Skip truncation notices
113
+ ]
114
+
115
+ # Restore default terminal ID
116
+ if hasattr(terminal_manager, "_default_terminal_id"):
117
+ terminal_manager._default_terminal_id = state.default_terminal_id
118
+
119
+ @staticmethod
120
+ def to_frontend_format(state: SandboxTerminalState) -> Dict[str, Any]:
121
+ """Convert terminal state to frontend format."""
122
+ terminal_tabs = []
123
+
124
+ for terminal_id, outputs in state.outputs.items():
125
+ content = ""
126
+ for output in outputs:
127
+ if output.type == "command":
128
+ content += f"$ {output.data}\n"
129
+ elif output.type == "truncation":
130
+ content += f"{output.data}\n"
131
+ elif output.type in ["stdout", "stderr"]:
132
+ content += output.data
133
+ if not output.data.endswith("\n"):
134
+ content += "\n"
135
+ elif output.type == "exit":
136
+ content += f"{output.data}\n"
137
+
138
+ if content: # Only add tabs with content
139
+ terminal_tabs.append({"id": terminal_id, "content": content.rstrip()}) # Remove trailing whitespace
140
+
141
+ return {"terminals": terminal_tabs}
142
+
143
+ @staticmethod
144
+ def get_recent_outputs(outputs: List[Dict[str, Any]], max_lines: int = 100) -> List[Dict[str, Any]]:
145
+ """Get the most recent outputs, limited by line count."""
146
+ if not outputs:
147
+ return []
148
+
149
+ recent_outputs = []
150
+ line_count = 0
151
+
152
+ # Process outputs in reverse order
153
+ for output in reversed(outputs):
154
+ if output["type"] in ["stdout", "stderr"]:
155
+ lines = output["data"].count("\n") + 1
156
+ if line_count + lines > max_lines:
157
+ # Partial output to fit within limit
158
+ remaining_lines = max_lines - line_count
159
+ if remaining_lines > 0:
160
+ lines_data = output["data"].split("\n")
161
+ partial_output = output.copy()
162
+ partial_output["data"] = "\n".join(lines_data[-remaining_lines:])
163
+ recent_outputs.insert(0, partial_output)
164
+ break
165
+ line_count += lines
166
+
167
+ recent_outputs.insert(0, output)
168
+
169
+ if output["type"] == "command":
170
+ line_count += 1
171
+
172
+ if line_count >= max_lines:
173
+ break
174
+
175
+ return recent_outputs