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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- 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"]
|