monkeybot 2.1.1__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 (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,156 @@
1
+ """Shared interfaces for monkeybot agent components.
2
+
3
+ This module defines interfaces and data structures used across the project.
4
+ The default **SSE gateway** path uses native :class:`~monkeybot.core.llm.provider.Provider`
5
+ implementations and SQLite-backed history rather than a separate graph-based
6
+ orchestration runtime.
7
+
8
+ Remaining interfaces support backward compatibility.
9
+ """
10
+
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass
13
+
14
+ # ============================================================================
15
+ # Data Classes
16
+ # ============================================================================
17
+
18
+
19
+ @dataclass
20
+ class Message:
21
+ """Conversation message.
22
+
23
+ Attributes:
24
+ role: Message role ("user", "assistant", or "system")
25
+ content: Message text content
26
+ timestamp: ISO8601 timestamp when message was created
27
+ trace_id: Request trace ID for debugging and log correlation
28
+ """
29
+
30
+ role: str
31
+ content: str
32
+ timestamp: str
33
+ trace_id: str
34
+
35
+
36
+ @dataclass
37
+ class ExecutionResult:
38
+ """Result from terminal command execution.
39
+
40
+ Attributes:
41
+ stdout: Standard output from command
42
+ stderr: Standard error from command
43
+ exit_code: Process exit code (0 = success, non-zero = error)
44
+ """
45
+
46
+ stdout: str
47
+ stderr: str
48
+ exit_code: int
49
+
50
+
51
+ # ============================================================================
52
+ # Exceptions
53
+ # ============================================================================
54
+
55
+
56
+ class MonkeybotError(Exception):
57
+ """Base exception for all Monkeybot errors.
58
+
59
+ All custom exceptions in Monkeybot inherit from this base class.
60
+ This allows catching all Monkeybot-specific errors with a single except clause.
61
+ """
62
+
63
+ pass
64
+
65
+
66
+ class AgentError(MonkeybotError):
67
+ """Raised when agent processing fails.
68
+
69
+ Examples:
70
+ - LLM call fails
71
+ - Message processing fails
72
+ - Graph execution error
73
+ """
74
+
75
+ pass
76
+
77
+
78
+ class LLMError(MonkeybotError):
79
+ """Raised when LLM API call fails.
80
+
81
+ Examples:
82
+ - Vertex AI timeout
83
+ - Rate limit exceeded (429)
84
+ - Model unavailable (503)
85
+ - Invalid API credentials
86
+
87
+ Note: Provider-level LLM errors are handled by the streaming adapter; this
88
+ exception is kept for explicit error handling when needed.
89
+ """
90
+
91
+ pass
92
+
93
+
94
+ class SecurityError(MonkeybotError):
95
+ """Raised when security validation fails.
96
+
97
+ Examples:
98
+ - Command not in ALLOWED_COMMANDS
99
+ - Path not in ALLOWED_PATHS
100
+ - Unauthorized user access attempt
101
+
102
+ This is a critical security boundary - all SecurityErrors should be logged
103
+ and investigated.
104
+ """
105
+
106
+ pass
107
+
108
+
109
+ # ============================================================================
110
+ # Agent Core Interface (for Gateway compatibility)
111
+ # ============================================================================
112
+
113
+
114
+ class AgentCoreInterface(ABC):
115
+ """Contract that Gateway calls.
116
+
117
+ This interface defines how external components (Gateway) interact with
118
+ the agent. Implemented by AgentWrapper in src/core/agent.py.
119
+
120
+ Key responsibilities:
121
+ - Process user messages
122
+ - Maintain conversation context (implementation-specific storage)
123
+ - Execute tools/skills
124
+ - Return formatted responses
125
+ """
126
+
127
+ @abstractmethod
128
+ async def process_message(self, user_id: str, content: str, trace_id: str) -> str:
129
+ """Process user message and return response.
130
+
131
+ This is the main entry point for all user interactions. The Gateway
132
+ filters PII before calling this method, so user_id is already hashed
133
+ and content contains only safe user input.
134
+
135
+ Args:
136
+ user_id: Hashed user identifier (NOT email - already filtered by Gateway)
137
+ content: Message text (PII already filtered by Gateway)
138
+ trace_id: Request trace ID for debugging and log correlation
139
+
140
+ Returns:
141
+ Response text to send back to user via Gateway
142
+
143
+ Raises:
144
+ AgentError: If processing fails (LLM error, tool error, etc.)
145
+
146
+ Example:
147
+ >>> agent = build_agent(model, tools)
148
+ >>> response = await agent.process_message(
149
+ ... user_id="abc123",
150
+ ... content="What can you help me with?",
151
+ ... trace_id="trace_xyz"
152
+ ... )
153
+ >>> print(response)
154
+ "I can help you with..."
155
+ """
156
+ pass
@@ -0,0 +1,29 @@
1
+ """Shared tool definitions for the v2 harness.
2
+
3
+ `ToolDef` is referenced by ``monkeybot.core.llm.provider`` and MCP/context layers per
4
+ architecture docs; defining it once avoids import cycles across those modules.
5
+
6
+ See Story 2 ``types_tools`` task for canonical fields.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ToolDef:
14
+ """Tool schema surfaced to providers (JSON-schema ``input_schema``).
15
+
16
+ ``parallel_safe`` marks read-only (or otherwise concurrent-safe) tools that
17
+ the harness may execute together in one batch. Mutating tools stay serial
18
+ unless explicitly opted in. Providers ignore this field.
19
+
20
+ ``doom_loop_exempt`` skips the identical name+args doom-loop guard for tools
21
+ that are expected to repeat with the same arguments (e.g. ``loop_status``
22
+ polling). Providers ignore this field.
23
+ """
24
+
25
+ name: str
26
+ description: str
27
+ input_schema: dict[str, object]
28
+ parallel_safe: bool = False
29
+ doom_loop_exempt: bool = False
@@ -0,0 +1,8 @@
1
+ """Pluggable workspace storage (memory tree backends)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from monkeybot.core.workspace.factory import create_workspace_storage
6
+ from monkeybot.core.workspace.protocol import WorkspaceStorage
7
+
8
+ __all__ = ["WorkspaceStorage", "create_workspace_storage"]
@@ -0,0 +1,45 @@
1
+ """Factory for :class:`~monkeybot.core.workspace.protocol.WorkspaceStorage` from a URI string."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from monkeybot.core.workspace.local import LocalWorkspaceStorage
8
+ from monkeybot.core.workspace.protocol import WorkspaceStorage
9
+
10
+
11
+ def _bucket_and_prefix(scheme_body: str) -> tuple[str, str]:
12
+ body = scheme_body.strip().lstrip("/")
13
+ if not body:
14
+ raise ValueError("URI must include a bucket name")
15
+ bucket, _, prefix = body.partition("/")
16
+ return bucket.strip(), prefix.strip().strip("/")
17
+
18
+
19
+ def create_workspace_storage(uri: str) -> WorkspaceStorage:
20
+ """Return storage for ``local://``, ``gcs://``, ``s3://``, or a bare filesystem path.
21
+
22
+ Lazy-imports optional cloud SDKs so local-only installs pay no import cost.
23
+ """
24
+ u = uri.strip()
25
+ if not u:
26
+ raise ValueError("workspace storage URI is empty")
27
+
28
+ if u.startswith("gcs://"):
29
+ from monkeybot.core.workspace.gcs import GCSWorkspaceStorage
30
+
31
+ bucket, prefix = _bucket_and_prefix(u[len("gcs://") :])
32
+ return GCSWorkspaceStorage(bucket=bucket, prefix=prefix)
33
+
34
+ if u.startswith("s3://"):
35
+ from monkeybot.core.workspace.s3 import S3WorkspaceStorage
36
+
37
+ bucket, prefix = _bucket_and_prefix(u[len("s3://") :])
38
+ return S3WorkspaceStorage(bucket=bucket, prefix=prefix)
39
+
40
+ path_str = u.removeprefix("local://").strip()
41
+ root = Path(path_str).expanduser()
42
+ return LocalWorkspaceStorage(root)
43
+
44
+
45
+ __all__ = ["create_workspace_storage"]
@@ -0,0 +1,130 @@
1
+ """Google Cloud Storage :class:`WorkspaceStorage` (``google-cloud-storage``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+
8
+ from google.cloud.exceptions import NotFound
9
+
10
+ from monkeybot.core.workspace.protocol import WorkspaceStorage
11
+
12
+ _log = logging.getLogger(__name__)
13
+
14
+
15
+ class GCSWorkspaceStorage:
16
+ """Object-prefix layout: ``gs://bucket/{prefix}/{path}``."""
17
+
18
+ def __init__(self, bucket: str, prefix: str = "") -> None:
19
+ from google.cloud.storage import Client
20
+
21
+ self._client = Client()
22
+ self._bucket = self._client.bucket(bucket)
23
+ self._prefix = prefix.strip().strip("/").replace("\\", "/")
24
+ if self._prefix:
25
+ self._prefix = self._prefix + "/"
26
+
27
+ def _key(self, path: str) -> str:
28
+ key = path.strip().replace("\\", "/").lstrip("/")
29
+ return self._prefix + key if self._prefix else key
30
+
31
+ async def read_text(self, path: str) -> str:
32
+ key = self._key(path)
33
+
34
+ def _read() -> str:
35
+ blob = self._bucket.blob(key)
36
+ data = blob.download_as_bytes()
37
+ text: str = data.decode("utf-8")
38
+ return text
39
+
40
+ try:
41
+ return await asyncio.to_thread(_read)
42
+ except NotFound as exc:
43
+ raise FileNotFoundError(key) from exc
44
+
45
+ async def write_text(self, path: str, content: str) -> None:
46
+ key = self._key(path)
47
+
48
+ def _write() -> None:
49
+ blob = self._bucket.blob(key)
50
+ blob.upload_from_string(content, content_type="text/plain; charset=utf-8")
51
+
52
+ await asyncio.to_thread(_write)
53
+
54
+ async def append_text(self, path: str, content: str) -> None:
55
+ key = self._key(path)
56
+ blob = self._bucket.blob(key)
57
+
58
+ def _append() -> None:
59
+ try:
60
+ cur = blob.download_as_text(encoding="utf-8")
61
+ except NotFound:
62
+ cur = ""
63
+ blob.upload_from_string(cur + content, content_type="text/plain; charset=utf-8")
64
+
65
+ await asyncio.to_thread(_append)
66
+
67
+ async def exists(self, path: str) -> bool:
68
+ key = self._key(path)
69
+
70
+ def _exists() -> bool:
71
+ return bool(self._bucket.blob(key).exists())
72
+
73
+ return await asyncio.to_thread(_exists)
74
+
75
+ async def list_files(self, prefix: str = "") -> list[str]:
76
+ pfx = self._key(prefix).rstrip("/")
77
+ if pfx:
78
+ pfx = pfx + "/"
79
+
80
+ def _list() -> list[str]:
81
+ out: list[str] = []
82
+ for blob in self._client.list_blobs(self._bucket.name, prefix=pfx):
83
+ name = blob.name
84
+ if name.endswith("/"):
85
+ continue
86
+ rel = name[len(self._prefix) :] if self._prefix else name
87
+ out.append(rel.lstrip("/"))
88
+ return sorted(out)
89
+
90
+ return await asyncio.to_thread(_list)
91
+
92
+ async def delete(self, path: str) -> None:
93
+ key = self._key(path)
94
+
95
+ def _del() -> None:
96
+ self._bucket.blob(key).delete()
97
+
98
+ try:
99
+ await asyncio.to_thread(_del)
100
+ except NotFound:
101
+ return
102
+
103
+ async def move(self, src: str, dest: str) -> None:
104
+ sk = self._key(src)
105
+ dk = self._key(dest)
106
+ sb = self._bucket.blob(sk)
107
+ db = self._bucket.blob(dk)
108
+
109
+ def _copy() -> None:
110
+ rewrite_token = None
111
+ while True:
112
+ rewrite_token, _, _ = db.rewrite(sb, token=rewrite_token)
113
+ if rewrite_token is None:
114
+ break
115
+
116
+ await asyncio.to_thread(_copy)
117
+ try:
118
+ await asyncio.to_thread(sb.delete)
119
+ except Exception as exc:
120
+ _log.warning("gcs move: delete source failed after copy src=%s dest=%s: %r", sk, dk, exc)
121
+
122
+ async def gc_prefix(self, prefix: str, max_age_sec: float) -> dict[str, int]:
123
+ del prefix, max_age_sec
124
+ _log.info(
125
+ "gcs gc_prefix: skipped — configure object lifecycle rules on the bucket for prefix cleanup"
126
+ )
127
+ return {"scanned": 0, "deleted": 0, "errors": 0}
128
+
129
+
130
+ __all__ = ["GCSWorkspaceStorage"]
@@ -0,0 +1,162 @@
1
+ """Local filesystem :class:`WorkspaceStorage` (zero extra dependencies)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import shutil
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from monkeybot.core.workspace.protocol import WorkspaceStorage
12
+
13
+ _log = logging.getLogger(__name__)
14
+
15
+
16
+ def _posix_rel(root: Path, path: Path) -> str:
17
+ rel = path.resolve().relative_to(root.resolve())
18
+ return rel.as_posix()
19
+
20
+
21
+ class LocalWorkspaceStorage:
22
+ """``pathlib``-backed storage; blocking I/O runs in ``asyncio.to_thread``."""
23
+
24
+ def __init__(self, root: Path) -> None:
25
+ self._root = Path(root).expanduser().resolve()
26
+
27
+ @property
28
+ def root(self) -> Path:
29
+ return self._root
30
+
31
+ def _abs(self, path: str) -> Path:
32
+ key = path.strip().replace("\\", "/").lstrip("/")
33
+ return (self._root / key).resolve()
34
+
35
+ def _ensure_under_root(self, path: Path) -> None:
36
+ path.relative_to(self._root)
37
+
38
+ async def read_text(self, path: str) -> str:
39
+ p = self._abs(path)
40
+ self._ensure_under_root(p)
41
+
42
+ def _read() -> str:
43
+ if not p.is_file():
44
+ raise FileNotFoundError(str(p))
45
+ return p.read_text(encoding="utf-8")
46
+
47
+ return await asyncio.to_thread(_read)
48
+
49
+ async def write_text(self, path: str, content: str) -> None:
50
+ p = self._abs(path)
51
+ self._ensure_under_root(p)
52
+
53
+ def _write() -> None:
54
+ p.parent.mkdir(parents=True, exist_ok=True)
55
+ p.write_text(content, encoding="utf-8")
56
+
57
+ await asyncio.to_thread(_write)
58
+
59
+ async def append_text(self, path: str, content: str) -> None:
60
+ p = self._abs(path)
61
+ self._ensure_under_root(p)
62
+
63
+ def _append() -> None:
64
+ p.parent.mkdir(parents=True, exist_ok=True)
65
+ with p.open("a", encoding="utf-8") as fh:
66
+ fh.write(content)
67
+
68
+ await asyncio.to_thread(_append)
69
+
70
+ async def exists(self, path: str) -> bool:
71
+ p = self._abs(path)
72
+ self._ensure_under_root(p)
73
+
74
+ def _exists() -> bool:
75
+ return p.is_file()
76
+
77
+ return await asyncio.to_thread(_exists)
78
+
79
+ async def list_files(self, prefix: str = "") -> list[str]:
80
+ root = self._root
81
+ pre = prefix.strip().replace("\\", "/")
82
+ if pre and not pre.endswith("/"):
83
+ pre = pre + "/"
84
+
85
+ def _list() -> list[str]:
86
+ base = root if not pre else (root / pre).resolve()
87
+ if not base.exists():
88
+ return []
89
+ self._ensure_under_root(base)
90
+ out: list[str] = []
91
+ for path in sorted(base.rglob("*")):
92
+ if not path.is_file():
93
+ continue
94
+ try:
95
+ rel = _posix_rel(root, path)
96
+ except ValueError:
97
+ continue
98
+ out.append(rel)
99
+ return out
100
+
101
+ return await asyncio.to_thread(_list)
102
+
103
+ async def delete(self, path: str) -> None:
104
+ p = self._abs(path)
105
+ self._ensure_under_root(p)
106
+
107
+ def _unlink() -> None:
108
+ try:
109
+ p.unlink(missing_ok=True)
110
+ except OSError as exc:
111
+ _log.warning("delete failed for %s: %r", p, exc)
112
+
113
+ await asyncio.to_thread(_unlink)
114
+
115
+ async def move(self, src: str, dest: str) -> None:
116
+ sp = self._abs(src)
117
+ dp = self._abs(dest)
118
+ self._ensure_under_root(sp)
119
+ self._ensure_under_root(dp)
120
+
121
+ def _mv() -> None:
122
+ dp.parent.mkdir(parents=True, exist_ok=True)
123
+ try:
124
+ sp.replace(dp)
125
+ except OSError:
126
+ shutil.move(str(sp), str(dp))
127
+
128
+ await asyncio.to_thread(_mv)
129
+
130
+ async def gc_prefix(self, prefix: str, max_age_sec: float) -> dict[str, int]:
131
+ root = self._root
132
+ pre = prefix.strip().replace("\\", "/")
133
+ if pre and not pre.endswith("/"):
134
+ pre = pre + "/"
135
+ cutoff = time.time() - float(max_age_sec)
136
+
137
+ def _sweep() -> dict[str, int]:
138
+ counts = {"scanned": 0, "deleted": 0, "errors": 0}
139
+ base = root if not pre else (root / pre).resolve()
140
+ if not base.exists():
141
+ return counts
142
+ try:
143
+ base.relative_to(root.resolve())
144
+ except ValueError:
145
+ return counts
146
+ for path in sorted(base.rglob("*")):
147
+ if not path.is_file():
148
+ continue
149
+ counts["scanned"] += 1
150
+ try:
151
+ if path.stat().st_mtime < cutoff:
152
+ path.unlink()
153
+ counts["deleted"] += 1
154
+ except OSError:
155
+ counts["errors"] += 1
156
+ _log.debug("gc_prefix: skip %s", path.name)
157
+ return counts
158
+
159
+ return await asyncio.to_thread(_sweep)
160
+
161
+
162
+ __all__ = ["LocalWorkspaceStorage"]
@@ -0,0 +1,45 @@
1
+ """Pluggable workspace storage for durable markdown memory (local FS, GCS, S3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+
8
+ @runtime_checkable
9
+ class WorkspaceStorage(Protocol):
10
+ """Async key-value file storage under a logical root (memory tree).
11
+
12
+ Paths are always POSIX-style relative keys (e.g. ``raw/foo.md``, ``INDEX.md``).
13
+ Implementations must not require a running event loop at import time.
14
+ """
15
+
16
+ async def read_text(self, path: str) -> str:
17
+ """Read UTF-8 text; raise ``FileNotFoundError`` if missing."""
18
+
19
+ async def write_text(self, path: str, content: str) -> None:
20
+ """Write or replace file at ``path`` (parent prefixes created as needed)."""
21
+
22
+ async def append_text(self, path: str, content: str) -> None:
23
+ """Append ``content`` to ``path`` (create file if absent)."""
24
+
25
+ async def exists(self, path: str) -> bool:
26
+ """Return whether ``path`` exists as a file."""
27
+
28
+ async def list_files(self, prefix: str = "") -> list[str]:
29
+ """List all file paths under ``prefix``, recursive, POSIX relative paths."""
30
+
31
+ async def delete(self, path: str) -> None:
32
+ """Delete file at ``path`` if it exists (ignore missing)."""
33
+
34
+ async def move(self, src: str, dest: str) -> None:
35
+ """Move/rename ``src`` to ``dest`` (atomic when the backend allows)."""
36
+
37
+ async def gc_prefix(self, prefix: str, max_age_sec: float) -> dict[str, int]:
38
+ """Best-effort GC of files under ``prefix`` older than ``max_age_sec``.
39
+
40
+ Returns ``{"scanned": int, "deleted": int, "errors": int}``.
41
+ Cloud backends may return zeros and rely on bucket lifecycle rules.
42
+ """
43
+
44
+
45
+ __all__ = ["WorkspaceStorage"]