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