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
monkeybot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """monkeybot — thin owned agent harness."""
2
+
3
+ __version__ = "2.0.0"
@@ -0,0 +1,3 @@
1
+ """MonkeyBot CLI."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,8 @@
1
+ """Entrypoint for ``python -m monkeybot.cli``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from monkeybot.cli.main import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,8 @@
1
+ """Shim — implementation lives in ``monkeybot_cli.realtime.audio_io``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from monkeybot_cli.realtime.audio_io import * # noqa: F403
6
+ from monkeybot_cli.realtime.audio_io import AudioIOError, AudioPlayer, AudioRecorder
7
+
8
+ __all__ = ["AudioIOError", "AudioPlayer", "AudioRecorder"]
@@ -0,0 +1,17 @@
1
+ """Shim — implementation lives in ``monkeybot_cli.realtime.gateway_manager``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from monkeybot_cli.realtime.gateway_manager import ( # noqa: F401
6
+ _find_workspace_dir,
7
+ _url_is_local,
8
+ start_gateway_if_needed,
9
+ stop_gateway,
10
+ )
11
+
12
+ __all__ = [
13
+ "_find_workspace_dir",
14
+ "_url_is_local",
15
+ "start_gateway_if_needed",
16
+ "stop_gateway",
17
+ ]
monkeybot/cli/main.py ADDED
@@ -0,0 +1,22 @@
1
+ """Shim — realtime talk helpers live in ``monkeybot_cli.realtime.session``.
2
+
3
+ The user-facing ``monkeybot`` console script lives in the ``monkeybot-cli`` package
4
+ (``cli/``). This module re-exports so ``python -m monkeybot.cli`` and legacy imports
5
+ keep working when ``monkeybot-cli`` is installed (root ``dev`` group / ``cli/`` sync).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from monkeybot_cli.realtime.session import ( # noqa: F401
11
+ app,
12
+ main,
13
+ run_talk_session,
14
+ talk,
15
+ version,
16
+ )
17
+
18
+ __all__ = ["app", "main", "run_talk_session", "talk", "version"]
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()
@@ -0,0 +1,12 @@
1
+ """Shim — implementation lives in ``monkeybot_cli.realtime.push_to_talk``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from monkeybot_cli.realtime import push_to_talk as _impl
6
+ from monkeybot_cli.realtime.push_to_talk import PushToTalkError, PushToTalkGate
7
+
8
+ # Re-export module attrs so tests can patch ``monkeybot.cli.push_to_talk._HAS_PYNPUT``.
9
+ _HAS_PYNPUT = _impl._HAS_PYNPUT
10
+ keyboard = _impl.keyboard
11
+
12
+ __all__ = ["PushToTalkError", "PushToTalkGate", "_HAS_PYNPUT", "keyboard"]
@@ -0,0 +1,13 @@
1
+ """Shim — encode helpers live in ``monkeybot_cli.realtime``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from monkeybot_cli.exit_commands import is_exit_command
6
+ from monkeybot_cli.realtime.client import RealtimeClientError
7
+ from monkeybot_cli.realtime.wire_encode import encode_client_frame
8
+
9
+ __all__ = [
10
+ "RealtimeClientError",
11
+ "encode_client_frame",
12
+ "is_exit_command",
13
+ ]
@@ -0,0 +1,19 @@
1
+ """Core agent components for monkeybot v2.
2
+
3
+ Subpackages (import concrete modules from these paths, e.g.
4
+ ``monkeybot.core.runtime.loop``):
5
+
6
+ - ``types`` — protocols, errors, content blocks, tool definitions
7
+ - ``config`` — bot config, secrets, runtime env mapping
8
+ - ``llm`` — streaming ``Provider`` protocol and per-turn usage; vendor backends in ``monkeybot.providers``
9
+ - ``runtime`` — agent loop and typed streaming events
10
+ - ``context`` — per-turn context assembly and curator
11
+ - ``memory`` — filesystem memory, hook, organizer
12
+ - ``persistence`` — SQLite schema, conversation history, run ids, durable runs
13
+ - ``tools`` — tool executor, workspace I/O, sandbox, terminal, inspector
14
+ - ``mcp`` — MCP client and port types
15
+ - ``prompts`` — system prompt and harness helpers
16
+ - ``hooks`` — lifecycle hook manager
17
+ - ``subagents`` — subprocess worker and spawn protocol
18
+ - ``testing`` — in-repo test doubles (mocks)
19
+ """
@@ -0,0 +1,22 @@
1
+ """Session-scoped attachment upload, resolve, freeze, and catalog."""
2
+
3
+ from .catalog import AttachmentRecord, SessionAttachmentCatalog
4
+ from .config import attachments_enabled_from_env
5
+ from .freeze import freeze_attachments_in_history
6
+ from .resolve import AttachmentResolveError, resolve_messages_for_provider
7
+ from .store import AttachmentStore, FilesystemAttachmentStore, sniff_mime
8
+ from .text import parse_attachment_descriptor_text, render_attachment_descriptor_text
9
+
10
+ __all__ = [
11
+ "AttachmentRecord",
12
+ "AttachmentResolveError",
13
+ "AttachmentStore",
14
+ "FilesystemAttachmentStore",
15
+ "SessionAttachmentCatalog",
16
+ "attachments_enabled_from_env",
17
+ "freeze_attachments_in_history",
18
+ "parse_attachment_descriptor_text",
19
+ "render_attachment_descriptor_text",
20
+ "resolve_messages_for_provider",
21
+ "sniff_mime",
22
+ ]
@@ -0,0 +1,62 @@
1
+ """In-memory session attachment catalog (rebuilt from history on load)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass, field
7
+
8
+ from monkeybot.core.llm.provider import Message
9
+ from monkeybot.core.types.content_blocks import Text
10
+
11
+ from .text import parse_attachment_descriptor_text
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class AttachmentRecord:
16
+ attachment_id: str
17
+ filename: str
18
+ mime_type: str
19
+ description: str
20
+ storage_path: str
21
+ uploaded_at_ms: int | None = None
22
+ file_missing: bool = False
23
+
24
+
25
+ @dataclass
26
+ class SessionAttachmentCatalog:
27
+ """Derived cache; history frozen Text lines are the durable source of truth."""
28
+
29
+ session_id: str
30
+ records: dict[str, AttachmentRecord] = field(default_factory=dict)
31
+
32
+ def list_records(self) -> list[AttachmentRecord]:
33
+ return list(self.records.values())
34
+
35
+ def contains(self, attachment_id: str) -> bool:
36
+ return attachment_id in self.records
37
+
38
+ def get(self, attachment_id: str) -> AttachmentRecord | None:
39
+ return self.records.get(attachment_id)
40
+
41
+ def upsert(self, record: AttachmentRecord) -> None:
42
+ self.records[record.attachment_id] = record
43
+
44
+ def rebuild_from_history(self, messages: Sequence[Message]) -> None:
45
+ self.records.clear()
46
+ for msg in messages:
47
+ for block in msg.content:
48
+ if not isinstance(block, Text):
49
+ continue
50
+ parsed = parse_attachment_descriptor_text(block.text)
51
+ if parsed is None:
52
+ continue
53
+ storage_path = (
54
+ f".monkeybot/attachments/{self.session_id}/{parsed.attachment_id}"
55
+ )
56
+ self.records[parsed.attachment_id] = AttachmentRecord(
57
+ attachment_id=parsed.attachment_id,
58
+ filename=parsed.filename,
59
+ mime_type=parsed.mime_type,
60
+ description=parsed.description,
61
+ storage_path=storage_path,
62
+ )
@@ -0,0 +1,52 @@
1
+ """Attachment feature flags and limits from environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ ALLOWED_MIME_TYPES: frozenset[str] = frozenset(
8
+ {
9
+ "image/jpeg",
10
+ "image/png",
11
+ "image/gif",
12
+ "image/webp",
13
+ "application/pdf",
14
+ }
15
+ )
16
+
17
+ IMAGE_MIME_TYPES: frozenset[str] = frozenset(
18
+ t for t in ALLOWED_MIME_TYPES if t.startswith("image/")
19
+ )
20
+
21
+
22
+ def attachments_enabled_from_env() -> bool:
23
+ raw = os.getenv("ATTACHMENTS_ENABLED", "true").strip().lower()
24
+ return raw not in {"0", "false", "no", "off", ""}
25
+
26
+
27
+ def _env_int(name: str, default: int) -> int:
28
+ raw = os.getenv(name, str(default)).strip()
29
+ try:
30
+ return max(0, int(raw))
31
+ except ValueError:
32
+ return default
33
+
34
+
35
+ def max_image_bytes() -> int:
36
+ return _env_int("ATTACHMENT_MAX_IMAGE_BYTES", 20 * 1024 * 1024)
37
+
38
+
39
+ def max_pdf_bytes() -> int:
40
+ return _env_int("ATTACHMENT_MAX_PDF_BYTES", 50 * 1024 * 1024)
41
+
42
+
43
+ def max_attachments_per_session() -> int:
44
+ return max(1, _env_int("ATTACHMENT_MAX_PER_SESSION", 50))
45
+
46
+
47
+ def max_attachments_per_reply() -> int:
48
+ return max(1, _env_int("ATTACHMENT_MAX_PER_REPLY", 5))
49
+
50
+
51
+ def attachment_ttl_hours() -> int:
52
+ return max(1, _env_int("ATTACHMENT_TTL_HOURS", 48))
@@ -0,0 +1,158 @@
1
+ """Freeze attachment refs and tool-result media to Text in history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from monkeybot.core.llm.provider import Message
8
+ from monkeybot.core.persistence.backends import HistoryStore
9
+ from monkeybot.core.runtime.events import AttachmentDescriptorEvent
10
+ from monkeybot.core.types.content_blocks import (
11
+ AttachmentRef,
12
+ ContentBlock,
13
+ File,
14
+ Image,
15
+ Text,
16
+ ToolResponse,
17
+ )
18
+
19
+ from .catalog import AttachmentRecord, SessionAttachmentCatalog
20
+ from .text import (
21
+ filename_from_metadata,
22
+ render_attachment_descriptor_text,
23
+ render_tool_media_freeze_text,
24
+ )
25
+
26
+
27
+ def _freeze_user_row(
28
+ msg: Message,
29
+ *,
30
+ thread_id: str,
31
+ last_assistant_text: str,
32
+ catalog: SessionAttachmentCatalog | None,
33
+ events: list[AttachmentDescriptorEvent],
34
+ ) -> Message:
35
+ user_text = " ".join(
36
+ b.text.strip() for b in msg.content if isinstance(b, Text) and b.text.strip()
37
+ )
38
+ new_content: list[ContentBlock] = []
39
+ for block in msg.content:
40
+ if isinstance(block, Text):
41
+ new_content.append(block)
42
+ continue
43
+ if not isinstance(block, AttachmentRef):
44
+ new_content.append(block)
45
+ continue
46
+ filename = filename_from_metadata(block.metadata, fallback=block.attachment_id)
47
+ description = last_assistant_text.strip() or user_text
48
+ if not description:
49
+ description = f"{filename} ({block.mime_type}) attached by user."
50
+ frozen_text = render_attachment_descriptor_text(
51
+ attachment_id=block.attachment_id,
52
+ mime_type=block.mime_type,
53
+ filename=filename,
54
+ description=description,
55
+ )
56
+ new_content.append(Text(text=frozen_text))
57
+ events.append(
58
+ AttachmentDescriptorEvent(
59
+ attachment_id=block.attachment_id,
60
+ mime_type=block.mime_type,
61
+ filename=filename,
62
+ description=description[:500],
63
+ )
64
+ )
65
+ if catalog is not None:
66
+ catalog.upsert(
67
+ AttachmentRecord(
68
+ attachment_id=block.attachment_id,
69
+ filename=filename,
70
+ mime_type=block.mime_type,
71
+ description=description[:500],
72
+ storage_path=f".monkeybot/attachments/{thread_id}/{block.attachment_id}",
73
+ )
74
+ )
75
+ return Message(role=msg.role, content=new_content)
76
+
77
+
78
+ def _freeze_tool_responses(msg: Message) -> Message:
79
+ new_content: list[ContentBlock] = []
80
+ changed = False
81
+ for block in msg.content:
82
+ if not isinstance(block, ToolResponse):
83
+ new_content.append(block)
84
+ continue
85
+ if not any(isinstance(b, (Image, File)) for b in block.result):
86
+ new_content.append(block)
87
+ continue
88
+ kind = "image" if any(isinstance(b, Image) for b in block.result) else "file"
89
+ if any(
90
+ isinstance(b, File) and b.mime_type == "application/pdf" for b in block.result
91
+ ):
92
+ kind = "pdf"
93
+ attachment_id: str | None = None
94
+ for b in block.result:
95
+ if isinstance(b, Image):
96
+ meta = b.metadata or {}
97
+ att_raw = meta.get("attachment_id")
98
+ if isinstance(att_raw, str) and att_raw.strip():
99
+ attachment_id = att_raw.strip()
100
+ break
101
+ summary = render_tool_media_freeze_text(
102
+ tool_name=block.tool_name,
103
+ attachment_id=attachment_id,
104
+ kind=kind,
105
+ )
106
+ changed = True
107
+ new_content.append(
108
+ ToolResponse(
109
+ id=block.id,
110
+ tool_name=block.tool_name,
111
+ result=[Text(text=summary)],
112
+ is_error=block.is_error,
113
+ )
114
+ )
115
+ if not changed:
116
+ return msg
117
+ return Message(role=msg.role, content=new_content)
118
+
119
+
120
+ async def freeze_attachments_in_history(
121
+ *,
122
+ thread_id: str,
123
+ history: HistoryStore,
124
+ catalog: SessionAttachmentCatalog | None,
125
+ last_assistant_text: str,
126
+ ) -> list[AttachmentDescriptorEvent]:
127
+ """Rewrite user attachmentRef rows and tool-result media to Text; return SSE events."""
128
+ rows = await history.load(thread_id)
129
+ mutated: list[Message] = []
130
+ events: list[AttachmentDescriptorEvent] = []
131
+ changed = False
132
+
133
+ for msg in rows:
134
+ frozen_msg = msg
135
+ if msg.role == "user" and any(isinstance(b, AttachmentRef) for b in msg.content):
136
+ frozen_msg = _freeze_user_row(
137
+ msg,
138
+ thread_id=thread_id,
139
+ last_assistant_text=last_assistant_text,
140
+ catalog=catalog,
141
+ events=events,
142
+ )
143
+ changed = True
144
+ tool_frozen = _freeze_tool_responses(frozen_msg)
145
+ if tool_frozen is not frozen_msg:
146
+ changed = True
147
+ mutated.append(tool_frozen)
148
+
149
+ if changed:
150
+ await history.reset(thread_id, mutated)
151
+ return events
152
+
153
+
154
+ def ensure_catalog_from_history(
155
+ catalog: SessionAttachmentCatalog,
156
+ messages: Sequence[Message],
157
+ ) -> None:
158
+ catalog.rebuild_from_history(messages)
@@ -0,0 +1,70 @@
1
+ """Resolve attachmentRef blocks to Image/File for provider calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from collections.abc import Sequence
7
+
8
+ from monkeybot.core.llm.provider import Message
9
+ from monkeybot.core.types.content_blocks import (
10
+ AttachmentRef,
11
+ ContentBlock,
12
+ File,
13
+ Image,
14
+ Text,
15
+ ToolResponse,
16
+ )
17
+ from monkeybot.core.types.interfaces import MonkeybotError
18
+
19
+ from .config import IMAGE_MIME_TYPES
20
+ from .store import AttachmentStore
21
+
22
+
23
+ class AttachmentResolveError(MonkeybotError):
24
+ """Failed to load attachment bytes for provider resolution."""
25
+
26
+
27
+ def _ref_to_media(
28
+ store: AttachmentStore,
29
+ session_id: str,
30
+ ref: AttachmentRef,
31
+ ) -> Image | File:
32
+ try:
33
+ data_b64, mime, _filename = store.read_base64(session_id, ref.attachment_id)
34
+ except FileNotFoundError as exc:
35
+ raise AttachmentResolveError(str(exc)) from exc
36
+ mime_use = ref.mime_type or mime
37
+ meta = dict(ref.metadata) if ref.metadata else None
38
+ if mime_use in IMAGE_MIME_TYPES:
39
+ return Image(mime_type=mime_use, data=data_b64, metadata=meta)
40
+ return File(mime_type=mime_use, data=data_b64, metadata=meta)
41
+
42
+
43
+ def _resolve_user_content(blocks: list[ContentBlock], store: AttachmentStore, session_id: str) -> list[ContentBlock]:
44
+ out: list[ContentBlock] = []
45
+ for block in blocks:
46
+ if isinstance(block, AttachmentRef):
47
+ out.append(_ref_to_media(store, session_id, block))
48
+ else:
49
+ out.append(block)
50
+ return out
51
+
52
+
53
+ def resolve_messages_for_provider(
54
+ messages: Sequence[Message],
55
+ *,
56
+ attachment_store: AttachmentStore | None,
57
+ session_id: str,
58
+ ) -> list[Message]:
59
+ """Return a copy of messages with live attachmentRef rows resolved to Image/File."""
60
+ if attachment_store is None:
61
+ return list(messages)
62
+
63
+ resolved: list[Message] = []
64
+ for msg in copy.deepcopy(list(messages)):
65
+ if msg.role != "user" or not any(isinstance(b, AttachmentRef) for b in msg.content):
66
+ resolved.append(msg)
67
+ continue
68
+ new_content = _resolve_user_content(list(msg.content), attachment_store, session_id)
69
+ resolved.append(Message(role=msg.role, content=new_content))
70
+ return resolved
@@ -0,0 +1,180 @@
1
+ """Filesystem-backed session attachment store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import time
8
+ import uuid
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Protocol
12
+
13
+ from monkeybot.core.path_safety import sanitize_path_component
14
+ from monkeybot.core.attachments.config import (
15
+ ALLOWED_MIME_TYPES,
16
+ IMAGE_MIME_TYPES,
17
+ attachment_ttl_hours,
18
+ max_attachments_per_session,
19
+ max_image_bytes,
20
+ max_pdf_bytes,
21
+ )
22
+
23
+
24
+ class AttachmentStoreError(Exception):
25
+ """Base error for attachment store operations."""
26
+
27
+
28
+ class AttachmentTooLargeError(AttachmentStoreError):
29
+ pass
30
+
31
+
32
+ class UnsupportedAttachmentTypeError(AttachmentStoreError):
33
+ pass
34
+
35
+
36
+ class AttachmentSessionLimitError(AttachmentStoreError):
37
+ pass
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class StoredAttachment:
42
+ attachment_id: str
43
+ mime_type: str
44
+ size_bytes: int
45
+ filename: str
46
+ created_at_ms: int
47
+
48
+
49
+ def sniff_mime(header: bytes) -> str | None:
50
+ if header[:8] == b"\x89PNG\r\n\x1a\n":
51
+ return "image/png"
52
+ if header[:3] == b"\xff\xd8\xff":
53
+ return "image/jpeg"
54
+ if header[:6] in (b"GIF87a", b"GIF89a"):
55
+ return "image/gif"
56
+ if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WEBP":
57
+ return "image/webp"
58
+ if header[:5] == b"%PDF-":
59
+ return "application/pdf"
60
+ return None
61
+
62
+
63
+ def new_attachment_id() -> str:
64
+ return f"att_{uuid.uuid4().hex}"
65
+
66
+
67
+ class AttachmentStore(Protocol):
68
+ def exists(self, session_id: str, attachment_id: str) -> bool: ...
69
+
70
+ def count_session(self, session_id: str) -> int: ...
71
+
72
+ def save(
73
+ self,
74
+ session_id: str,
75
+ *,
76
+ data: bytes,
77
+ mime_type: str,
78
+ filename: str,
79
+ ) -> StoredAttachment: ...
80
+
81
+ def read(self, session_id: str, attachment_id: str) -> tuple[bytes, str, str]: ...
82
+
83
+ def read_base64(self, session_id: str, attachment_id: str) -> tuple[str, str, str]: ...
84
+
85
+
86
+ class FilesystemAttachmentStore:
87
+ def __init__(self, workspace_root: Path) -> None:
88
+ self._root = workspace_root.resolve()
89
+
90
+ def _session_dir(self, session_id: str) -> Path:
91
+ return self._root / ".monkeybot" / "attachments" / sanitize_path_component(session_id)
92
+
93
+ def _path_for(self, session_id: str, attachment_id: str) -> Path:
94
+ return self._session_dir(session_id) / sanitize_path_component(attachment_id)
95
+
96
+ def exists(self, session_id: str, attachment_id: str) -> bool:
97
+ return self._path_for(session_id, attachment_id).is_file()
98
+
99
+ def count_session(self, session_id: str) -> int:
100
+ d = self._session_dir(session_id)
101
+ if not d.is_dir():
102
+ return 0
103
+ return sum(1 for p in d.iterdir() if p.is_file() and not p.name.endswith(".json"))
104
+
105
+ def save(
106
+ self,
107
+ session_id: str,
108
+ *,
109
+ data: bytes,
110
+ mime_type: str,
111
+ filename: str,
112
+ ) -> StoredAttachment:
113
+ if mime_type not in ALLOWED_MIME_TYPES:
114
+ raise UnsupportedAttachmentTypeError(f"unsupported mime type: {mime_type}")
115
+ max_bytes = max_image_bytes() if mime_type in IMAGE_MIME_TYPES else max_pdf_bytes()
116
+ if len(data) > max_bytes:
117
+ raise AttachmentTooLargeError(f"attachment exceeds {max_bytes} bytes")
118
+ sniffed = sniff_mime(data[:512])
119
+ if sniffed is not None and sniffed != mime_type:
120
+ raise UnsupportedAttachmentTypeError(
121
+ f"declared mime {mime_type!r} does not match content ({sniffed})"
122
+ )
123
+ if self.count_session(session_id) >= max_attachments_per_session():
124
+ raise AttachmentSessionLimitError("session attachment limit reached")
125
+
126
+ attachment_id = new_attachment_id()
127
+ path = self._path_for(session_id, attachment_id)
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ path.write_bytes(data)
130
+ created_at_ms = int(time.time() * 1000)
131
+ meta_path = path.with_suffix(path.suffix + ".json")
132
+ meta_path.write_text(
133
+ json.dumps(
134
+ {
135
+ "attachment_id": attachment_id,
136
+ "mime_type": mime_type,
137
+ "filename": filename,
138
+ "size_bytes": len(data),
139
+ "created_at_ms": created_at_ms,
140
+ }
141
+ ),
142
+ encoding="utf-8",
143
+ )
144
+ return StoredAttachment(
145
+ attachment_id=attachment_id,
146
+ mime_type=mime_type,
147
+ size_bytes=len(data),
148
+ filename=filename,
149
+ created_at_ms=created_at_ms,
150
+ )
151
+
152
+ def read(self, session_id: str, attachment_id: str) -> tuple[bytes, str, str]:
153
+ path = self._path_for(session_id, attachment_id)
154
+ if not path.is_file():
155
+ raise FileNotFoundError(f"attachment not found: {attachment_id}")
156
+ self._check_ttl(path)
157
+ meta = self._read_meta(path)
158
+ mime = str(meta.get("mime_type", "application/octet-stream"))
159
+ filename = str(meta.get("filename", attachment_id))
160
+ return path.read_bytes(), mime, filename
161
+
162
+ def read_base64(self, session_id: str, attachment_id: str) -> tuple[str, str, str]:
163
+ data, mime, filename = self.read(session_id, attachment_id)
164
+ return base64.b64encode(data).decode("ascii"), mime, filename
165
+
166
+ def _read_meta(self, path: Path) -> dict[str, object]:
167
+ meta_path = path.with_suffix(path.suffix + ".json")
168
+ if meta_path.is_file():
169
+ raw = json.loads(meta_path.read_text(encoding="utf-8"))
170
+ if isinstance(raw, dict):
171
+ return raw
172
+ return {}
173
+
174
+ def _check_ttl(self, path: Path) -> None:
175
+ meta = self._read_meta(path)
176
+ created = meta.get("created_at_ms")
177
+ if isinstance(created, (int, float)):
178
+ age_ms = int(time.time() * 1000) - int(created)
179
+ if age_ms > attachment_ttl_hours() * 3600 * 1000:
180
+ raise FileNotFoundError("attachment expired")