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,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
|
+
]
|