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,109 @@
1
+ """MCP client port — structural contract for Story 5 implementation."""
2
+
3
+ from collections.abc import Mapping
4
+ from pathlib import Path
5
+ from typing import Any, Protocol
6
+
7
+ from monkeybot.core.types.types_tools import ToolDef
8
+
9
+
10
+ class MCPClientPort(Protocol):
11
+ """Async MCP session lifecycle and tool discovery (Story 5 implements this)."""
12
+
13
+ async def connect(
14
+ self,
15
+ name: str,
16
+ command: str,
17
+ args: list[str],
18
+ env: dict[str, str],
19
+ ) -> list[ToolDef]:
20
+ """Connect server; return tools discovered for that server.
21
+
22
+ Implementations may prefix tool names to avoid collisions.
23
+ """
24
+ ...
25
+
26
+ async def connect_streamable_http(
27
+ self,
28
+ name: str,
29
+ url: str,
30
+ headers: dict[str, str] | None = None,
31
+ *,
32
+ auth: object | None = None,
33
+ ) -> list[ToolDef]:
34
+ """Connect a remote MCP server over Streamable HTTP (``url`` in mcp.json)."""
35
+ ...
36
+
37
+ async def disconnect(self, name: str) -> None:
38
+ """Disconnect and tear down the named server session."""
39
+ ...
40
+
41
+ async def call_tool(
42
+ self,
43
+ server_name: str,
44
+ tool_name: str,
45
+ args: Mapping[str, object],
46
+ ) -> str:
47
+ """Invoke a tool on a connected server; returns serialized result text."""
48
+ ...
49
+
50
+ def all_tools(self) -> list[ToolDef]:
51
+ """Aggregate tool list for the provider / tool-calling layer (sync snapshot)."""
52
+ ...
53
+
54
+ def catalog_names(self) -> list[str]:
55
+ """Names of servers known from the last ``load_from_config``."""
56
+ ...
57
+
58
+ def known_server_names(self) -> list[str]:
59
+ """Catalog + ever-connected server names (for tool-list refresh prefixes)."""
60
+ ...
61
+
62
+ def is_connected(self, name: str) -> bool:
63
+ """True when ``name`` has an active session."""
64
+ ...
65
+
66
+ def split_prefixed_tool(self, prefixed_name: str) -> tuple[str, str] | None:
67
+ """If ``prefixed_name`` belongs to a connected server, return ``(server_name, tool_name)``."""
68
+ ...
69
+
70
+ async def connect_from_catalog(self, name: str) -> list[ToolDef]:
71
+ """Connect a catalogued server by name (no-op if already connected)."""
72
+ ...
73
+
74
+ def status(self, name: str | None = None) -> list[dict[str, Any]] | dict[str, Any]:
75
+ """Return server status snapshot(s) (connected / catalogued / failed / …)."""
76
+ ...
77
+
78
+ async def list_resources(self, server_name: str | None = None) -> list[dict[str, Any]]:
79
+ """List MCP resources from connected servers."""
80
+ ...
81
+
82
+ async def list_resource_templates(
83
+ self, server_name: str | None = None
84
+ ) -> list[dict[str, Any]]:
85
+ """List MCP resource templates from connected servers."""
86
+ ...
87
+
88
+ async def read_resource(self, server_name: str, uri: str) -> dict[str, Any]:
89
+ """Read one MCP resource by server name and URI."""
90
+ ...
91
+
92
+ async def list_prompts(self, server_name: str | None = None) -> list[dict[str, Any]]:
93
+ """List MCP prompts from connected servers."""
94
+ ...
95
+
96
+ async def get_prompt(
97
+ self,
98
+ server_name: str,
99
+ prompt_name: str,
100
+ arguments: Mapping[str, str] | None = None,
101
+ ) -> dict[str, Any]:
102
+ """Fetch a named MCP prompt (optional string arguments)."""
103
+ ...
104
+
105
+ async def load_from_config(
106
+ self, path: Path, *, raise_on_error: bool = False
107
+ ) -> None:
108
+ """Load mcp.json if present; no-op when path is missing (Story 5 semantics)."""
109
+ ...
@@ -0,0 +1,24 @@
1
+ """Memory package: :class:`MemorySubsystem` is the public integration surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from monkeybot.core.memory.integrity import IntegrityResult, MemoryIntegrityChecker
6
+ from monkeybot.core.memory.storage_ops import (
7
+ INDEX_FILENAME,
8
+ MemoryPromotionError,
9
+ async_load_index,
10
+ async_promote_to_memory,
11
+ async_search_memory_files,
12
+ )
13
+ from monkeybot.core.memory.subsystem import MemorySubsystem
14
+
15
+ __all__ = [
16
+ "MemorySubsystem",
17
+ "MemoryIntegrityChecker",
18
+ "IntegrityResult",
19
+ "INDEX_FILENAME",
20
+ "MemoryPromotionError",
21
+ "async_load_index",
22
+ "async_search_memory_files",
23
+ "async_promote_to_memory",
24
+ ]
@@ -0,0 +1,413 @@
1
+ """Memory subsystem hook: captures observations and injects retrieved context.
2
+
3
+ The :class:`MemoryHook` is the canonical subscriber for :mod:`monkeybot.core.hooks`
4
+ events. It implements both halves of the memory lifecycle:
5
+
6
+ **Write path** (cheap, fire-and-forget, no LLM in the hot path)
7
+
8
+ * ``USER_MESSAGE`` → append the user's prompt to ``memory/chat_log.md``.
9
+ * ``POST_TOOL`` → append the tool name, args, and a truncated result/error to
10
+ ``memory/raw/<ts>_post_tool.md``.
11
+ * ``POST_TURN`` → schedule the organizer (debounced; at most one pending run at
12
+ a time, run in the background so the user does not wait).
13
+
14
+ **Read path** (substring-only retrieval; no LLM, ~ms latency)
15
+
16
+ * ``PRE_TURN`` → for the user's message, search the memory tree and add hits
17
+ to ``payload.inject_memory_lines`` so they appear in the system prompt.
18
+ * ``PRE_TOOL`` → for tools that have a path/query argument
19
+ (``read_file`` / ``write_file`` / ``search_memory`` / ``run_command``), look
20
+ up file-specific or query-specific memories and put them on
21
+ ``payload.inject_text`` so they appear in the next provider call's system
22
+ prompt (the tool result itself is never modified — it stays ground truth).
23
+
24
+ All write methods serialise on a single :class:`asyncio.Lock` shared with the
25
+ organizer to avoid races on ``INDEX.md`` and ``memory/raw/``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import hashlib
32
+ import json
33
+ import logging
34
+ import re
35
+ import time
36
+ import uuid
37
+ from collections.abc import Awaitable, Callable
38
+ from datetime import datetime, timezone
39
+ from typing import Any
40
+
41
+ from monkeybot.core.hooks import HookEvent, HookManager, HookPayload
42
+ from monkeybot.core.memory.storage_ops import async_search_memory_files
43
+ from monkeybot.core.workspace.protocol import WorkspaceStorage
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ _RESULT_PREVIEW_CHARS = 4000
48
+ _RAW_FILENAME_RE = re.compile(r"[^a-zA-Z0-9_-]+")
49
+
50
+ _DEDUP_TTL_SEC = 300.0
51
+ """5-minute window over which identical (tool, args) calls are deduped."""
52
+
53
+ _POST_TOOL_SKIP_ON_SUCCESS = frozenset(
54
+ {"read_file", "search_memory", "list_skills"}
55
+ )
56
+ """Read-only tools whose successful calls are not captured (errors still are)."""
57
+
58
+ _CHAT_LOG_FILENAME = "chat_log.md"
59
+ """User messages append here as one line each; the organizer never reads it."""
60
+
61
+ _CHAT_LOG_HEADER = "# Chat Log\n\nAppend-only log of user messages, newest at bottom.\n"
62
+ """Written once on first append so search has context."""
63
+
64
+ OrganizerRunner = Callable[[], Awaitable[Any]]
65
+ """Async callable that runs the memory organizer once. Result is ignored."""
66
+
67
+
68
+ def _truncate(value: str | None, limit: int) -> str:
69
+ if not value:
70
+ return ""
71
+ if len(value) <= limit:
72
+ return value
73
+ return value[:limit] + "\n[...truncated]"
74
+
75
+
76
+ def _safe_segment(value: str, max_len: int = 60) -> str:
77
+ cleaned = _RAW_FILENAME_RE.sub("-", value).strip("-")
78
+ return cleaned[:max_len] or "x"
79
+
80
+
81
+ def _format_dict(data: dict[str, Any]) -> str:
82
+ try:
83
+ return json.dumps(data, ensure_ascii=False, indent=2, default=str)
84
+ except (TypeError, ValueError):
85
+ return repr(data)
86
+
87
+
88
+ def _extract_keywords(text: str, *, max_keywords: int = 5) -> list[str]:
89
+ """Pick a handful of longish word tokens for substring retrieval.
90
+
91
+ Drops short stopwords. Phase-1 cheap retrieval, no NLP.
92
+ """
93
+ tokens = re.findall(r"[A-Za-z][A-Za-z0-9_./-]{3,}", text or "")
94
+ seen: dict[str, None] = {}
95
+ for tok in tokens:
96
+ low = tok.lower()
97
+ if low in _STOPWORDS:
98
+ continue
99
+ if low not in seen:
100
+ seen[low] = None
101
+ if len(seen) >= max_keywords:
102
+ break
103
+ return list(seen.keys())
104
+
105
+
106
+ _STOPWORDS = frozenset(
107
+ {
108
+ "this",
109
+ "that",
110
+ "with",
111
+ "have",
112
+ "from",
113
+ "your",
114
+ "about",
115
+ "their",
116
+ "there",
117
+ "would",
118
+ "could",
119
+ "should",
120
+ "where",
121
+ "which",
122
+ "while",
123
+ "what",
124
+ "when",
125
+ "then",
126
+ "than",
127
+ "they",
128
+ "them",
129
+ "into",
130
+ "been",
131
+ "were",
132
+ "will",
133
+ "just",
134
+ "also",
135
+ "some",
136
+ "more",
137
+ "very",
138
+ "much",
139
+ "really",
140
+ "thing",
141
+ "things",
142
+ "please",
143
+ }
144
+ )
145
+
146
+
147
+ _PRE_TOOL_TOOLS = frozenset(
148
+ {"read_file", "write_file", "search_memory", "run_command"}
149
+ )
150
+
151
+
152
+ class _DedupCache:
153
+ """SHA-256 dedup cache with TTL eviction (agentmemory's 5-min pattern)."""
154
+
155
+ def __init__(self, ttl_sec: float = _DEDUP_TTL_SEC) -> None:
156
+ self._ttl = float(ttl_sec)
157
+ self._seen: dict[str, float] = {}
158
+
159
+ @staticmethod
160
+ def hash(tool_name: str, args: dict[str, Any] | None) -> str:
161
+ """Stable hash over ``(tool_name, sorted-json(args))``."""
162
+ try:
163
+ args_blob = json.dumps(
164
+ args or {}, sort_keys=True, ensure_ascii=False, default=str
165
+ )
166
+ except (TypeError, ValueError):
167
+ args_blob = repr(args)
168
+ payload = f"{tool_name}\0{args_blob}".encode()
169
+ return hashlib.sha256(payload).hexdigest()
170
+
171
+ def check_and_record(self, key: str) -> bool:
172
+ """Return ``True`` when ``key`` was seen within TTL (i.e. duplicate)."""
173
+ now = time.monotonic()
174
+ self._evict(now)
175
+ previous = self._seen.get(key)
176
+ self._seen[key] = now
177
+ return previous is not None
178
+
179
+ def _evict(self, now: float) -> None:
180
+ if not self._seen:
181
+ return
182
+ expired = [k for k, t in self._seen.items() if (now - t) > self._ttl]
183
+ for k in expired:
184
+ self._seen.pop(k, None)
185
+
186
+
187
+ class MemoryHook:
188
+ """Wire memory writes/reads to :class:`HookManager` events.
189
+
190
+ Args:
191
+ storage: Pluggable memory tree backend.
192
+ organizer_runner: Async callable that runs the organizer once. Injected
193
+ so tests can supply a no-op. In production this is
194
+ ``MemoryOrganizer.run``.
195
+ max_retrieval_hits: Cap on hits injected per ``PRE_TURN`` /
196
+ ``PRE_TOOL`` lookup. Default 3.
197
+ """
198
+
199
+ def __init__(
200
+ self,
201
+ *,
202
+ storage: WorkspaceStorage,
203
+ organizer_runner: OrganizerRunner | None = None,
204
+ max_retrieval_hits: int = 3,
205
+ dedup_ttl_sec: float = _DEDUP_TTL_SEC,
206
+ ) -> None:
207
+ self._storage = storage
208
+ self._organizer_runner = organizer_runner
209
+ self._max_hits = max(0, int(max_retrieval_hits))
210
+ self._lock = asyncio.Lock()
211
+ self._organizer_pending = False
212
+ self._organizer_task: asyncio.Task[None] | None = None
213
+ self._dedup = _DedupCache(ttl_sec=dedup_ttl_sec)
214
+
215
+ def register(self, manager: HookManager) -> None:
216
+ """Attach to every relevant event. Idempotent across distinct managers."""
217
+ manager.register(HookEvent.USER_MESSAGE, self.on_user_message)
218
+ manager.register(HookEvent.PRE_TURN, self.on_pre_turn)
219
+ manager.register(HookEvent.PRE_TOOL, self.on_pre_tool)
220
+ manager.register(HookEvent.POST_TOOL, self.on_post_tool)
221
+ manager.register(HookEvent.POST_TURN, self.on_post_turn)
222
+ manager.register(HookEvent.SESSION_END, self.on_session_end)
223
+
224
+ # --------------------------------------------------------------- write
225
+
226
+ async def on_user_message(self, payload: HookPayload) -> None:
227
+ """Append the message to ``memory/chat_log.md`` (Lever 3).
228
+
229
+ User messages are high-volume and low-signal per message, so they
230
+ bypass the organizer entirely. They land in a flat, append-only log
231
+ that search can still scan when context is needed.
232
+ """
233
+ if not payload.user_message:
234
+ return
235
+ text = payload.user_message.strip()
236
+ if not text:
237
+ return
238
+ await self._append_chat_log(payload, text)
239
+
240
+ async def _append_chat_log(self, payload: HookPayload, text: str) -> None:
241
+ ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
242
+ flat = " ".join(text.split())
243
+ line = f"- [{ts}] [{payload.thread_id}] {flat}\n"
244
+ async with self._lock:
245
+ try:
246
+ if not await self._storage.exists(_CHAT_LOG_FILENAME):
247
+ await self._storage.write_text(_CHAT_LOG_FILENAME, _CHAT_LOG_HEADER)
248
+ await self._storage.append_text(_CHAT_LOG_FILENAME, line)
249
+ except OSError as exc:
250
+ logger.warning("memory: failed appending chat_log: %r", exc)
251
+
252
+ async def on_post_tool(self, payload: HookPayload) -> None:
253
+ if not payload.tool_name:
254
+ return
255
+ if payload.tool_error is None and payload.tool_name in _POST_TOOL_SKIP_ON_SUCCESS:
256
+ return
257
+ dedup_key = self._dedup.hash(
258
+ payload.tool_name,
259
+ {
260
+ "args": payload.tool_args or {},
261
+ "error": payload.tool_error or "",
262
+ },
263
+ )
264
+ if self._dedup.check_and_record(dedup_key):
265
+ logger.debug(
266
+ "memory: dedup skip for %s (key=%s...)", payload.tool_name, dedup_key[:8]
267
+ )
268
+ return
269
+ result_preview = _truncate(payload.tool_result, _RESULT_PREVIEW_CHARS)
270
+ error_preview = _truncate(payload.tool_error, _RESULT_PREVIEW_CHARS)
271
+ args_str = _format_dict(payload.tool_args or {})
272
+ body = (
273
+ f"# post_tool\n\n"
274
+ f"thread_id: {payload.thread_id}\n"
275
+ f"request_id: {payload.request_id}\n"
276
+ f"timestamp: {int(time.time() * 1000)}\n"
277
+ f"tool: {payload.tool_name}\n\n"
278
+ f"## args\n\n```json\n{args_str}\n```\n\n"
279
+ f"## result\n\n{result_preview or '(empty)'}\n\n"
280
+ f"## error\n\n{error_preview or '(none)'}\n"
281
+ )
282
+ await self._write_raw(f"post_tool_{_safe_segment(payload.tool_name)}", payload.thread_id, body)
283
+
284
+ async def on_post_turn(self, payload: HookPayload) -> None:
285
+ del payload
286
+ self._schedule_organizer()
287
+
288
+ async def on_session_end(self, payload: HookPayload) -> None:
289
+ del payload
290
+ runner = self._organizer_runner
291
+ if runner is None:
292
+ return
293
+ try:
294
+ async with self._lock:
295
+ await runner()
296
+ except Exception as exc:
297
+ logger.warning("session-end organizer run failed: %r", exc)
298
+
299
+ # ---------------------------------------------------------------- read
300
+
301
+ async def on_pre_turn(self, payload: HookPayload) -> None:
302
+ if self._max_hits <= 0 or not payload.user_message:
303
+ return
304
+ keywords = _extract_keywords(payload.user_message)
305
+ if not keywords:
306
+ return
307
+ seen: set[str] = set()
308
+ lines: list[str] = []
309
+ for kw in keywords:
310
+ hits = await self._search(kw)
311
+ for hit in hits:
312
+ snippet = hit.get("snippet", "").strip()
313
+ if not snippet or snippet in seen:
314
+ continue
315
+ seen.add(snippet)
316
+ lines.append(f"- {snippet}")
317
+ if len(lines) >= self._max_hits:
318
+ break
319
+ if len(lines) >= self._max_hits:
320
+ break
321
+ if lines:
322
+ payload.inject_memory_lines = list(payload.inject_memory_lines) + lines
323
+
324
+ async def on_pre_tool(self, payload: HookPayload) -> None:
325
+ if self._max_hits <= 0 or payload.tool_name not in _PRE_TOOL_TOOLS:
326
+ return
327
+ args = payload.tool_args or {}
328
+ candidates: list[str] = []
329
+ for key in ("path", "file_path", "file", "query", "q", "command", "shell", "script"):
330
+ val = args.get(key)
331
+ if isinstance(val, str) and val.strip():
332
+ candidates.append(val.strip())
333
+ if not candidates:
334
+ return
335
+ seen: set[str] = set()
336
+ snippets: list[str] = []
337
+ for cand in candidates:
338
+ hits = await self._search(cand)
339
+ for hit in hits:
340
+ snippet = hit.get("snippet", "").strip()
341
+ if not snippet or snippet in seen:
342
+ continue
343
+ seen.add(snippet)
344
+ snippets.append(f"- {snippet}")
345
+ if len(snippets) >= self._max_hits:
346
+ break
347
+ if len(snippets) >= self._max_hits:
348
+ break
349
+ if snippets:
350
+ heading = (
351
+ f"Relevant memory for {payload.tool_name} "
352
+ f"on {candidates[0]!r}:"
353
+ )
354
+ payload.inject_text = heading + "\n" + "\n".join(snippets)
355
+
356
+ # --------------------------------------------------------------- inner
357
+
358
+ async def _search(self, query: str) -> list[dict[str, Any]]:
359
+ try:
360
+ result = await async_search_memory_files(
361
+ self._storage,
362
+ query,
363
+ max_hits=self._max_hits * 2,
364
+ skip_relative_prefixes=("raw",),
365
+ )
366
+ except Exception as exc:
367
+ logger.warning("memory search failed for %r: %r", query, exc)
368
+ return []
369
+ hits = result.get("hits") or []
370
+ return hits if isinstance(hits, list) else []
371
+
372
+ async def _write_raw(self, kind: str, thread_id: str, body: str) -> None:
373
+ async with self._lock:
374
+ try:
375
+ fname = (
376
+ f"raw/{int(time.time() * 1000)}_{_safe_segment(thread_id, 20)}_{kind}_"
377
+ f"{uuid.uuid4().hex[:8]}.md"
378
+ )
379
+ await self._storage.write_text(fname, body)
380
+ except Exception as exc:
381
+ logger.warning("memory raw write failed (%s): %r", kind, exc)
382
+
383
+ def _schedule_organizer(self) -> None:
384
+ runner = self._organizer_runner
385
+ if runner is None:
386
+ return
387
+ if self._organizer_pending:
388
+ return
389
+ self._organizer_pending = True
390
+
391
+ async def _run() -> None:
392
+ try:
393
+ async with self._lock:
394
+ await runner()
395
+ except Exception as exc:
396
+ logger.warning("background organizer run failed: %r", exc)
397
+ finally:
398
+ self._organizer_pending = False
399
+
400
+ self._organizer_task = asyncio.create_task(_run())
401
+
402
+ async def flush(self) -> None:
403
+ """Await the pending organizer task if one is running.
404
+
405
+ Call before returning from a short-lived handler (Lambda, Cloud Functions)
406
+ so the organizer finishes. The FastAPI gateway does not need this.
407
+ """
408
+ t = self._organizer_task
409
+ if t is not None and not t.done():
410
+ await t
411
+
412
+
413
+ __all__ = ["MemoryHook", "OrganizerRunner"]
@@ -0,0 +1,104 @@
1
+ """INDEX.md line format: append-only entries, sliding cap, and archive helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ INDEX_FILENAME = "INDEX.md"
8
+ INDEX_ARCHIVE_FILENAME = "INDEX.archive.md"
9
+ DEFAULT_INDEX_HEADER = "# Memory Index"
10
+
11
+
12
+ def index_cap_from_env() -> int:
13
+ """Max INDEX.md entries kept before older ones move to the archive file."""
14
+ raw = os.getenv("MEMORY_INDEX_CAP", "200").strip()
15
+ try:
16
+ return max(1, int(raw))
17
+ except ValueError:
18
+ return 200
19
+
20
+
21
+ def is_index_entry_line(line: str) -> bool:
22
+ """True for canonical index rows: ``- [[folder/file.md]] | tags: ... | summary``."""
23
+ stripped = line.strip()
24
+ return stripped.startswith("- [[") and "]]" in stripped
25
+
26
+
27
+ def is_legacy_index_line(line: str) -> bool:
28
+ """Non-header lines from pre-recency INDEX files (plain bullets or free text)."""
29
+ stripped = line.strip()
30
+ if not stripped:
31
+ return False
32
+ if stripped.startswith("#"):
33
+ return False
34
+ return True
35
+
36
+
37
+ def parse_index_entry_lines(raw: str) -> list[str]:
38
+ """Return index entry lines in file order (newest last when append-only)."""
39
+ entries: list[str] = []
40
+ for line in raw.splitlines():
41
+ stripped = line.strip()
42
+ if is_index_entry_line(stripped) or is_legacy_index_line(stripped):
43
+ entries.append(stripped)
44
+ return entries
45
+
46
+
47
+ def split_index_document(raw: str) -> tuple[str, list[str]]:
48
+ """Split INDEX.md into header preamble and ordered entry lines."""
49
+ entries = parse_index_entry_lines(raw)
50
+ if not entries:
51
+ return (raw.strip() or DEFAULT_INDEX_HEADER), []
52
+ first = entries[0]
53
+ pos = raw.find(first)
54
+ if pos <= 0:
55
+ return DEFAULT_INDEX_HEADER, entries
56
+ header = raw[:pos].strip() or DEFAULT_INDEX_HEADER
57
+ return header, entries
58
+
59
+
60
+ def format_index_document(header: str, entry_lines: list[str]) -> str:
61
+ """Serialize header + append-ordered entry lines."""
62
+ hdr = (header or DEFAULT_INDEX_HEADER).strip()
63
+ if not entry_lines:
64
+ return hdr + "\n"
65
+ return hdr + "\n\n" + "\n".join(entry_lines) + "\n"
66
+
67
+
68
+ def append_index_entries(existing_raw: str, new_entries: list[str]) -> str:
69
+ """Append new entries to the end (recency order: oldest first, newest last)."""
70
+ header, entries = split_index_document(existing_raw)
71
+ for entry in new_entries:
72
+ line = entry.strip()
73
+ if line and line not in entries:
74
+ entries.append(line)
75
+ return format_index_document(header, entries)
76
+
77
+
78
+ def apply_index_entry_cap(
79
+ entry_lines: list[str],
80
+ cap: int,
81
+ ) -> tuple[list[str], list[str]]:
82
+ """Return ``(kept_recent, archived_older)`` keeping the last *cap* entries."""
83
+ if cap <= 0 or len(entry_lines) <= cap:
84
+ return list(entry_lines), []
85
+ archived = entry_lines[: len(entry_lines) - cap]
86
+ kept = entry_lines[-cap:]
87
+ return kept, archived
88
+
89
+
90
+ def merge_archive_content(existing_archive: str, overflow: list[str]) -> str:
91
+ """Prepend newly archived lines (older) before existing archive content."""
92
+ if not overflow:
93
+ return existing_archive
94
+ new_block = "\n".join(overflow)
95
+ if not existing_archive.strip():
96
+ return new_block + "\n"
97
+ return new_block + "\n" + existing_archive.rstrip() + "\n"
98
+
99
+
100
+ def memory_window_slice(entry_lines: list[str], window: int) -> list[str]:
101
+ """Return the most recent *window* index entries."""
102
+ if window <= 0 or len(entry_lines) <= window:
103
+ return list(entry_lines)
104
+ return entry_lines[-window:]