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,180 @@
1
+ """Structural integrity checks for on-disk markdown memory trees.
2
+
3
+ Used by ``scripts/verify_memory.py``, pytest evals (T1), and future tooling.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ _INDEX_LINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
13
+ _TYPED_FOLDERS = frozenset({"episodic", "semantic", "procedural", "working"})
14
+ DEFAULT_MIN_SUMMARY_CHARS = 20
15
+
16
+
17
+ def _load_index_entries(memory_root: Path) -> list[tuple[str, str]]:
18
+ """Return [(raw_link, full_line), ...] for every [[...]] entry in INDEX.md."""
19
+ index = memory_root / "INDEX.md"
20
+ if not index.exists():
21
+ return []
22
+ entries: list[tuple[str, str]] = []
23
+ for line in index.read_text(encoding="utf-8").splitlines():
24
+ m = _INDEX_LINK_RE.search(line)
25
+ if m:
26
+ entries.append((m.group(1), line.strip()))
27
+ return entries
28
+
29
+
30
+ def _typed_folders_present(memory_root: Path) -> list[Path]:
31
+ return [memory_root / f for f in sorted(_TYPED_FOLDERS) if (memory_root / f).is_dir()]
32
+
33
+
34
+ def _collect_typed_files(memory_root: Path) -> set[str]:
35
+ """Relative paths (e.g. 'episodic/foo.md') for all files in typed folders."""
36
+ found: set[str] = set()
37
+ for folder in _typed_folders_present(memory_root):
38
+ for f in folder.rglob("*.md"):
39
+ if f.is_file():
40
+ found.add(str(f.relative_to(memory_root)))
41
+ return found
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class IntegrityResult:
46
+ """Counts from :meth:`MemoryIntegrityChecker.run`."""
47
+
48
+ orphan_count: int
49
+ unindexed_count: int
50
+ short_summary_count: int
51
+ raw_only_count: int
52
+ total_index_entries: int
53
+ total_typed_files: int
54
+
55
+ @property
56
+ def total_issues(self) -> int:
57
+ return (
58
+ self.orphan_count
59
+ + self.unindexed_count
60
+ + self.short_summary_count
61
+ + self.raw_only_count
62
+ )
63
+
64
+
65
+ class MemoryIntegrityChecker:
66
+ """Verify memory directory structure (INDEX vs typed folders vs raw)."""
67
+
68
+ def __init__(self, memory_root: Path, min_summary_chars: int = DEFAULT_MIN_SUMMARY_CHARS) -> None:
69
+ self._memory_root = memory_root
70
+ self._min_summary_chars = min_summary_chars
71
+
72
+ def run(self) -> IntegrityResult:
73
+ """Run all checks. Missing ``memory_root`` yields zero issues (no crash)."""
74
+ root = self._memory_root
75
+ if not root.exists():
76
+ return IntegrityResult(0, 0, 0, 0, 0, 0)
77
+
78
+ index_entries = _load_index_entries(root)
79
+ indexed_paths: set[str] = set()
80
+
81
+ orphan_count = 0
82
+ for link, _line in index_entries:
83
+ target = root / link
84
+ indexed_paths.add(link)
85
+ if not target.exists():
86
+ orphan_count += 1
87
+
88
+ all_typed = _collect_typed_files(root)
89
+ unindexed_count = 0
90
+ for rel in sorted(all_typed):
91
+ if rel not in indexed_paths:
92
+ unindexed_count += 1
93
+
94
+ short_summary_count = 0
95
+ for link, _line in index_entries:
96
+ target = root / link
97
+ if not target.exists():
98
+ continue
99
+ text = target.read_text(encoding="utf-8").strip()
100
+ if len(text) < self._min_summary_chars:
101
+ short_summary_count += 1
102
+
103
+ raw_only_count = 0
104
+ processed_dir = root / "raw" / "processed"
105
+ if processed_dir.exists():
106
+ processed_names = {f.name for f in processed_dir.iterdir() if f.is_file()}
107
+ summarised_names = {Path(p).name for p in all_typed}
108
+ orphaned_raw = processed_names - summarised_names
109
+ raw_only_count = len(orphaned_raw)
110
+
111
+ return IntegrityResult(
112
+ orphan_count=orphan_count,
113
+ unindexed_count=unindexed_count,
114
+ short_summary_count=short_summary_count,
115
+ raw_only_count=raw_only_count,
116
+ total_index_entries=len(index_entries),
117
+ total_typed_files=len(all_typed),
118
+ )
119
+
120
+
121
+ def verify_memory_cli(memory_root: Path) -> int:
122
+ """CLI entry: print human-readable report; return exit code (0 = ok).
123
+
124
+ If ``memory_root`` does not exist, prints an error and returns ``1``.
125
+ """
126
+ if not memory_root.exists():
127
+ print(f"ERROR: memory directory not found: {memory_root}")
128
+ return 1
129
+
130
+ index_entries = _load_index_entries(memory_root)
131
+ indexed_paths: set[str] = set()
132
+ issues = 0
133
+
134
+ for link, line in index_entries:
135
+ target = memory_root / link
136
+ indexed_paths.add(link)
137
+ if not target.exists():
138
+ print(f"[ORPHAN] INDEX.md references missing file: {link}")
139
+ print(f" entry: {line}")
140
+ issues += 1
141
+
142
+ all_typed = _collect_typed_files(memory_root)
143
+ for rel in sorted(all_typed):
144
+ if rel not in indexed_paths:
145
+ print(f"[UNINDEXED] file exists but has no INDEX.md entry: {rel}")
146
+ issues += 1
147
+
148
+ min_chars = DEFAULT_MIN_SUMMARY_CHARS
149
+ for link, _line in index_entries:
150
+ target = memory_root / link
151
+ if not target.exists():
152
+ continue
153
+ text = target.read_text(encoding="utf-8").strip()
154
+ if len(text) < min_chars:
155
+ print(f"[SHORT] summary file may be truncated ({len(text)} chars): {link}")
156
+ print(f" content: {text!r}")
157
+ issues += 1
158
+
159
+ processed_dir = memory_root / "raw" / "processed"
160
+ if processed_dir.exists():
161
+ processed_names = {f.name for f in processed_dir.iterdir() if f.is_file()}
162
+ summarised_names = {Path(p).name for p in all_typed}
163
+ orphaned_raw = processed_names - summarised_names
164
+ for name in sorted(orphaned_raw):
165
+ print(
166
+ f"[RAW_ONLY] processed raw exists but no summary in typed folder: {name}"
167
+ )
168
+ issues += 1
169
+
170
+ total_indexed = len(index_entries)
171
+ total_files = len(all_typed)
172
+ print(
173
+ f"\nMemory root : {memory_root}"
174
+ f"\nINDEX entries : {total_indexed}"
175
+ f"\nTyped files : {total_files}"
176
+ f"\nIssues found : {issues}"
177
+ )
178
+ if issues == 0:
179
+ print("All checks passed.")
180
+ return issues
@@ -0,0 +1,270 @@
1
+ """Memory organizer — async post-processor for agent memory.
2
+
3
+ Reads raw observation files from ``raw/``, summarizes each,
4
+ classifies into a typed folder, and updates INDEX.md for deterministic retrieval.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from collections.abc import Sequence
12
+ from contextlib import aclosing
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Protocol, cast
16
+
17
+ from monkeybot.core.memory.index_format import (
18
+ INDEX_ARCHIVE_FILENAME,
19
+ INDEX_FILENAME,
20
+ DEFAULT_INDEX_HEADER,
21
+ append_index_entries,
22
+ apply_index_entry_cap,
23
+ format_index_document,
24
+ index_cap_from_env,
25
+ merge_archive_content,
26
+ split_index_document,
27
+ )
28
+ from monkeybot.core.types.content_blocks import Text
29
+ from monkeybot.core.types.interfaces import MonkeybotError
30
+ from monkeybot.core.llm.provider import Done, Message, Provider, TextDelta
31
+ from monkeybot.core.workspace.protocol import WorkspaceStorage
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ BUILT_IN_FOLDERS: list[str] = ["episodic", "semantic", "procedural", "working"]
36
+
37
+ _SUMMARIZE_PROMPT = (
38
+ "Summarize the following agent observation in 3-5 sentences. "
39
+ "Be concrete and factual. Do not add opinions.\n\n"
40
+ "<observation>\n{content}\n</observation>\n\nSummary:"
41
+ )
42
+
43
+ _CLASSIFY_PROMPT = (
44
+ "Classify the following summary into exactly one memory folder. "
45
+ "Reply with ONLY the folder name — nothing else.\n\n"
46
+ "Folders:\n{folder_list}\n\nSummary:\n{summary}\n\nFolder:"
47
+ )
48
+
49
+ _INDEX_ENTRY_PROMPT = (
50
+ "Generate an INDEX.md entry. Reply in EXACTLY this format:\n"
51
+ "tags: <comma_separated>\nsummary: <one_sentence>\n\n"
52
+ "File: {filename}\nContent: {summary}\n\nEntry:"
53
+ )
54
+
55
+
56
+ class _CustomMemoryFolderLike(Protocol):
57
+ name: str
58
+ description: str
59
+
60
+
61
+ class MemoryOrganizerError(MonkeybotError):
62
+ """Raised when INDEX.md cannot be written after an organizer run."""
63
+
64
+
65
+ @dataclass
66
+ class MemoryOrganizerResult:
67
+ files_processed: int
68
+ files_written: int
69
+ index_updated: bool
70
+ errors: list[str] = field(default_factory=list)
71
+
72
+
73
+ @dataclass
74
+ class IndexEntry:
75
+ folder: str # destination folder name
76
+ filename: str # filename inside folder (not full path)
77
+ tags: str # "comma,separated"
78
+ summary: str # one sentence
79
+
80
+
81
+ async def _complete_text(
82
+ provider: Provider,
83
+ *,
84
+ model: str,
85
+ prompt: str,
86
+ ) -> str:
87
+ """Single-turn text completion (no tools) from streaming deltas."""
88
+ parts: list[str] = []
89
+ async with aclosing(
90
+ cast(Any, provider.stream([Message(role="user", content=[Text(text=prompt)])], [], model=model))
91
+ ) as stream:
92
+ async for ev in stream:
93
+ if isinstance(ev, TextDelta):
94
+ parts.append(ev.text)
95
+ elif isinstance(ev, Done):
96
+ break
97
+ return "".join(parts).strip()
98
+
99
+
100
+ class MemoryOrganizer:
101
+ """Async post-processor that organises raw agent observations into typed memory."""
102
+
103
+ def __init__(
104
+ self,
105
+ provider: Provider,
106
+ model: str,
107
+ storage: WorkspaceStorage,
108
+ custom_folders: Sequence[_CustomMemoryFolderLike] | None = None,
109
+ ) -> None:
110
+ self._provider = provider
111
+ self._model = model
112
+ self._storage = storage
113
+ self._all_folders: list[str] = BUILT_IN_FOLDERS + [
114
+ cf.name for cf in (custom_folders or [])
115
+ ]
116
+ self._custom_folder_descriptions: dict[str, str] = {
117
+ cf.name: cf.description for cf in (custom_folders or [])
118
+ }
119
+
120
+ async def run(self) -> MemoryOrganizerResult:
121
+ """Process all unprocessed raw .md files directly under ``raw/``.
122
+
123
+ Returns immediately with zeros if ``raw/`` is missing or empty.
124
+ Per-file errors are captured in MemoryOrganizerResult.errors, not propagated.
125
+ """
126
+ all_under_raw = await self._storage.list_files("raw/")
127
+ raw_files = [
128
+ p
129
+ for p in all_under_raw
130
+ if p.startswith("raw/")
131
+ and p.endswith(".md")
132
+ and not p.startswith("raw/processed/")
133
+ and p.count("/") == 1
134
+ ]
135
+ if not raw_files:
136
+ return MemoryOrganizerResult(0, 0, False)
137
+
138
+ entries: list[IndexEntry] = []
139
+ files_written = 0
140
+ errors: list[str] = []
141
+
142
+ for raw_rel in sorted(raw_files):
143
+ raw_name = Path(raw_rel).name
144
+ try:
145
+ content = await self._storage.read_text(raw_rel)
146
+ summary = await self._summarize(content)
147
+ folder = await self._classify(summary)
148
+ filename = self._generate_filename(raw_name, folder)
149
+
150
+ dest_path = f"{folder}/{filename}"
151
+ await self._storage.write_text(dest_path, summary)
152
+
153
+ entries.append(
154
+ IndexEntry(folder=folder, filename=filename, tags="", summary=summary)
155
+ )
156
+
157
+ processed_rel = f"raw/processed/{raw_name}"
158
+ await self._storage.move(raw_rel, processed_rel)
159
+
160
+ files_written += 1
161
+
162
+ except Exception as e:
163
+ logger.warning(
164
+ "Memory organizer failed to process %s: %s", raw_name, str(e)
165
+ )
166
+ errors.append(raw_name)
167
+
168
+ if entries:
169
+ await self._update_index(entries)
170
+
171
+ return MemoryOrganizerResult(
172
+ files_processed=len(raw_files),
173
+ files_written=files_written,
174
+ index_updated=bool(entries),
175
+ errors=errors,
176
+ )
177
+
178
+ def _generate_filename(self, raw_name: str, folder: str) -> str:
179
+ del folder
180
+ stem = Path(raw_name).stem
181
+ return f"{stem}.md"
182
+
183
+ async def _summarize(self, raw_content: str) -> str:
184
+ return await _complete_text(
185
+ self._provider,
186
+ model=self._model,
187
+ prompt=_SUMMARIZE_PROMPT.format(content=raw_content),
188
+ )
189
+
190
+ async def _classify(self, summary: str) -> str:
191
+ folder_list = "\n".join(
192
+ f"- {f}: {self._custom_folder_descriptions.get(f, f)}"
193
+ for f in self._all_folders
194
+ )
195
+ text = await _complete_text(
196
+ self._provider,
197
+ model=self._model,
198
+ prompt=_CLASSIFY_PROMPT.format(folder_list=folder_list, summary=summary),
199
+ )
200
+ folder = text.strip().lower()
201
+ if folder not in self._all_folders:
202
+ logger.warning(
203
+ "Classify returned unknown folder '%s', using 'episodic'", folder
204
+ )
205
+ return "episodic"
206
+ return folder
207
+
208
+ async def _update_index(self, entries: list[IndexEntry]) -> None:
209
+ existing_content = ""
210
+ if await self._storage.exists(INDEX_FILENAME):
211
+ try:
212
+ existing_content = await self._storage.read_text(INDEX_FILENAME)
213
+ except Exception:
214
+ existing_content = ""
215
+
216
+ if not existing_content.strip():
217
+ existing_content = f"{DEFAULT_INDEX_HEADER}\n"
218
+ new_lines: list[str] = []
219
+ for entry in entries:
220
+ try:
221
+ response = await _complete_text(
222
+ self._provider,
223
+ model=self._model,
224
+ prompt=_INDEX_ENTRY_PROMPT.format(
225
+ filename=entry.filename, summary=entry.summary
226
+ ),
227
+ )
228
+
229
+ tags = ""
230
+ summary_line = entry.summary
231
+ for line in response.splitlines():
232
+ if line.lower().startswith("tags:"):
233
+ tags = line[5:].strip()
234
+ elif line.lower().startswith("summary:"):
235
+ summary_line = line[8:].strip()
236
+
237
+ entry.tags = tags
238
+ entry.summary = summary_line
239
+ except Exception as exc:
240
+ logger.debug("index entry LLM call failed for %s: %r", entry.filename, exc)
241
+
242
+ formatted = (
243
+ f"- [[{entry.folder}/{entry.filename}]]"
244
+ f" | tags: {entry.tags} | {entry.summary}"
245
+ )
246
+ new_lines.append(formatted)
247
+
248
+ if not new_lines:
249
+ return
250
+
251
+ merged = append_index_entries(existing_content, new_lines)
252
+ _header, entry_lines = split_index_document(merged)
253
+ cap = index_cap_from_env()
254
+ kept, archived = apply_index_entry_cap(entry_lines, cap)
255
+ if archived:
256
+ archive_raw = ""
257
+ if await self._storage.exists(INDEX_ARCHIVE_FILENAME):
258
+ try:
259
+ archive_raw = await self._storage.read_text(INDEX_ARCHIVE_FILENAME)
260
+ except Exception:
261
+ archive_raw = ""
262
+ archive_out = merge_archive_content(archive_raw, archived)
263
+ await self._storage.write_text(INDEX_ARCHIVE_FILENAME, archive_out)
264
+
265
+ final_content = format_index_document(_header, kept)
266
+
267
+ try:
268
+ await self._storage.write_text(INDEX_FILENAME, final_content)
269
+ except Exception as e:
270
+ raise MemoryOrganizerError(f"Failed to write INDEX.md: {e}") from e
@@ -0,0 +1,139 @@
1
+ """Async memory operations on :class:`~monkeybot.core.workspace.protocol.WorkspaceStorage`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from monkeybot.core.memory.index_format import INDEX_FILENAME, parse_index_entry_lines
9
+ from monkeybot.core.workspace.protocol import WorkspaceStorage
10
+
11
+
12
+ class MemoryPromotionError(RuntimeError):
13
+ """Raised when promotion preconditions fail (path, run_id guard, missing file, etc.)."""
14
+
15
+
16
+ def _parse_index_lines(raw: str) -> list[str]:
17
+ return parse_index_entry_lines(raw)
18
+
19
+
20
+ async def async_load_index(storage: WorkspaceStorage) -> list[str]:
21
+ """Load ``INDEX.md`` lines, or ``[]`` if absent."""
22
+ if not await storage.exists(INDEX_FILENAME):
23
+ return []
24
+ raw = await storage.read_text(INDEX_FILENAME)
25
+ return _parse_index_lines(raw)
26
+
27
+
28
+ def _matches_query(line_lower: str, tokens: list[str]) -> bool:
29
+ return all(tok in line_lower for tok in tokens)
30
+
31
+
32
+ def _memory_rel_skipped(rel_posix: str, skip_relative_prefixes: tuple[str, ...]) -> bool:
33
+ if not skip_relative_prefixes:
34
+ return False
35
+ for raw_p in skip_relative_prefixes:
36
+ p = raw_p.replace("\\", "/").strip("/")
37
+ if not p:
38
+ continue
39
+ if rel_posix == p or rel_posix.startswith(p + "/"):
40
+ return True
41
+ return False
42
+
43
+
44
+ async def async_search_memory(query: str, storage: WorkspaceStorage, top_k: int = 5) -> list[str]:
45
+ if top_k <= 0:
46
+ return []
47
+ q = query.strip()
48
+ if not q:
49
+ return []
50
+ tokens = [t.lower() for t in q.split() if t]
51
+ if not tokens:
52
+ return []
53
+
54
+ candidates = await async_load_index(storage)
55
+ matches: list[str] = []
56
+ for line in candidates:
57
+ if _matches_query(line.lower(), tokens):
58
+ matches.append(line)
59
+ if len(matches) >= top_k:
60
+ break
61
+ return matches
62
+
63
+
64
+ async def async_search_memory_files(
65
+ storage: WorkspaceStorage,
66
+ query: str,
67
+ *,
68
+ max_hits: int = 40,
69
+ skip_relative_prefixes: tuple[str, ...] = (),
70
+ ) -> dict[str, Any]:
71
+ q = query.lower().strip()
72
+ if not q:
73
+ return {"ok": True, "query": query, "hits": [], "note": "empty query"}
74
+
75
+ all_paths = await storage.list_files("")
76
+ if not all_paths:
77
+ return {"ok": True, "query": query, "hits": [], "note": "empty memory tree"}
78
+
79
+ suffixes = {".md", ".txt", ".markdown"}
80
+ hits: list[dict[str, Any]] = []
81
+ for rel in sorted(all_paths):
82
+ if len(hits) >= max_hits:
83
+ break
84
+ low = rel.lower()
85
+ if not any(low.endswith(s) for s in suffixes):
86
+ continue
87
+ rel_posix = rel.replace("\\", "/")
88
+ if _memory_rel_skipped(rel_posix, skip_relative_prefixes):
89
+ continue
90
+ try:
91
+ text = await storage.read_text(rel)
92
+ except OSError:
93
+ continue
94
+ except FileNotFoundError:
95
+ continue
96
+ lower = text.lower()
97
+ pos = lower.find(q)
98
+ if pos < 0:
99
+ continue
100
+ start = max(0, pos - 60)
101
+ end = min(len(text), pos + len(q) + 80)
102
+ snippet = text[start:end].replace("\n", " ")
103
+ hits.append({"path": rel, "snippet": snippet, "match_offset": pos})
104
+ return {"ok": True, "query": query, "hits": hits, "truncated": len(hits) >= max_hits}
105
+
106
+
107
+ async def async_promote_to_memory(run_id: str, file: Path, storage: WorkspaceStorage) -> None:
108
+ """Copy a local scratch file into ``semantic/`` on ``storage``, then delete the source.
109
+
110
+ ``file`` must be a path on the process filesystem (e.g. agent workspace scratch), not a
111
+ storage-relative key. The destination is always ``semantic/{filename}`` on ``storage``.
112
+ A future storage-key source variant would be needed if the artifact already lives only
113
+ in GCS/S3.
114
+ """
115
+ src = file.resolve()
116
+ if not src.is_file():
117
+ raise MemoryPromotionError("Source path must be an existing regular file.")
118
+
119
+ if run_id not in src.parts:
120
+ raise MemoryPromotionError(
121
+ "Source path must include the run identifier as a path segment.",
122
+ )
123
+
124
+ dest_key = f"semantic/{src.name}"
125
+ text = src.read_text(encoding="utf-8")
126
+ await storage.write_text(dest_key, text)
127
+ try:
128
+ src.unlink()
129
+ except OSError as exc:
130
+ raise MemoryPromotionError(f"Failed to remove source after promote: {exc}") from exc
131
+
132
+
133
+ __all__ = [
134
+ "INDEX_FILENAME",
135
+ "MemoryPromotionError",
136
+ "async_load_index",
137
+ "async_search_memory_files",
138
+ "async_promote_to_memory",
139
+ ]
@@ -0,0 +1,91 @@
1
+ """Single injection point for memory (storage + hook + organizer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from monkeybot.core.hooks import HookManager
9
+ from monkeybot.core.llm.provider import Provider
10
+ from monkeybot.core.memory.hook import MemoryHook
11
+ from monkeybot.core.memory.organizer import MemoryOrganizer
12
+ from monkeybot.core.memory.storage_ops import (
13
+ async_load_index,
14
+ async_promote_to_memory,
15
+ async_search_memory_files,
16
+ )
17
+ from monkeybot.core.workspace.protocol import WorkspaceStorage
18
+
19
+
20
+ class MemorySubsystem:
21
+ """Owns :class:`WorkspaceStorage`, :class:`MemoryHook`, and :class:`MemoryOrganizer`.
22
+
23
+ External callers inject this object into :func:`~monkeybot.core.context.build_context`
24
+ and :class:`~monkeybot.core.tools.core_tool_executor.CoreToolExecutor` instead of
25
+ passing paths or storage separately.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ storage: WorkspaceStorage,
31
+ provider: Provider,
32
+ model: str,
33
+ *,
34
+ memory_uri: str,
35
+ max_retrieval_hits: int = 3,
36
+ ) -> None:
37
+ self._storage = storage
38
+ self._memory_uri = memory_uri.strip()
39
+ self._organizer = MemoryOrganizer(
40
+ provider=provider,
41
+ model=model,
42
+ storage=storage,
43
+ )
44
+ self._hook = MemoryHook(
45
+ storage=storage,
46
+ organizer_runner=self._organizer.run,
47
+ max_retrieval_hits=max_retrieval_hits,
48
+ )
49
+
50
+ @property
51
+ def uri(self) -> str:
52
+ """URI string used to reconstruct storage in subprocess workers."""
53
+ return self._memory_uri
54
+
55
+ @property
56
+ def storage(self) -> WorkspaceStorage:
57
+ return self._storage
58
+
59
+ def register_hooks(self, manager: HookManager) -> None:
60
+ self._hook.register(manager)
61
+
62
+ async def load_index(self) -> list[str]:
63
+ return await async_load_index(self._storage)
64
+
65
+ async def search_files(
66
+ self,
67
+ query: str,
68
+ *,
69
+ max_hits: int = 40,
70
+ skip_raw: bool = True,
71
+ ) -> dict[str, Any]:
72
+ skip: tuple[str, ...] = ("raw",) if skip_raw else ()
73
+ return await async_search_memory_files(
74
+ self._storage,
75
+ query,
76
+ max_hits=max_hits,
77
+ skip_relative_prefixes=skip,
78
+ )
79
+
80
+ async def promote(self, run_id: str, file: Path) -> None:
81
+ await async_promote_to_memory(run_id, file, self._storage)
82
+
83
+ async def gc_processed(self, *, max_age_sec: float = 7 * 24 * 60 * 60) -> dict[str, int]:
84
+ return await self._storage.gc_prefix("raw/processed/", max_age_sec)
85
+
86
+ async def flush(self) -> None:
87
+ """Await pending memory organizer task. See :meth:`MemoryHook.flush`."""
88
+ await self._hook.flush()
89
+
90
+
91
+ __all__ = ["MemorySubsystem"]
@@ -0,0 +1,16 @@
1
+ """Message normalization helpers for provider replay.
2
+
3
+ Pipeline (Pi-style)::
4
+
5
+ history Messages → transform_context() → convert_to_provider() → Provider.stream
6
+ """
7
+
8
+ from monkeybot.core.messages.convert_provider import convert_to_provider
9
+ from monkeybot.core.messages.tool_integrity import repair_tool_turn_integrity
10
+ from monkeybot.core.messages.transform_context import transform_context
11
+
12
+ __all__ = [
13
+ "convert_to_provider",
14
+ "repair_tool_turn_integrity",
15
+ "transform_context",
16
+ ]