agentram 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +729 -0
- package/agentram/__init__.py +26 -0
- package/agentram/adapter.py +368 -0
- package/agentram/cli.py +242 -0
- package/agentram/codex_wrapper.py +163 -0
- package/agentram/config.py +87 -0
- package/agentram/daemon.py +106 -0
- package/agentram/demo.py +29 -0
- package/agentram/mcp_server.py +369 -0
- package/agentram/merger.py +66 -0
- package/agentram/orchestrator.py +45 -0
- package/agentram/retriever.py +51 -0
- package/agentram/schema.py +258 -0
- package/agentram/storage.py +104 -0
- package/agentram/worker.py +40 -0
- package/agentram_daemon.py +4 -0
- package/agentram_mcp_server.py +4 -0
- package/bin/agentram-mcp.js +24 -0
- package/bin/agentram.js +24 -0
- package/codex_ram.py +4 -0
- package/package.json +49 -0
- package/pyproject.toml +38 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .adapter import AnthropicMemoryAdapter, HeuristicMemoryAdapter, OllamaMemoryAdapter, OpenAICompatibleMemoryAdapter
|
|
2
|
+
from .config import anthropic_from_env, load_env_file, merged_env, openai_compatible_from_env
|
|
3
|
+
from .daemon import AgentRAMDaemon
|
|
4
|
+
from .orchestrator import AgentRAM
|
|
5
|
+
from .retriever import MemoryRetriever
|
|
6
|
+
from .schema import Event, EventType, MemoryItem, MemoryPatch, MemoryScope, MemoryType
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AgentRAM",
|
|
10
|
+
"AgentRAMDaemon",
|
|
11
|
+
"AnthropicMemoryAdapter",
|
|
12
|
+
"anthropic_from_env",
|
|
13
|
+
"Event",
|
|
14
|
+
"EventType",
|
|
15
|
+
"HeuristicMemoryAdapter",
|
|
16
|
+
"load_env_file",
|
|
17
|
+
"merged_env",
|
|
18
|
+
"MemoryItem",
|
|
19
|
+
"MemoryPatch",
|
|
20
|
+
"MemoryRetriever",
|
|
21
|
+
"MemoryScope",
|
|
22
|
+
"MemoryType",
|
|
23
|
+
"OllamaMemoryAdapter",
|
|
24
|
+
"openai_compatible_from_env",
|
|
25
|
+
"OpenAICompatibleMemoryAdapter",
|
|
26
|
+
]
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
from .schema import Event, EventType, MemoryItem, MemoryPatch, MemoryScope, MemoryType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MemoryModelAdapter(ABC):
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def create_patches(self, events: list[Event], current: list[MemoryItem]) -> list[MemoryPatch]:
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OllamaMemoryAdapter(MemoryModelAdapter):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
model: str = "qwen2.5:1.5b",
|
|
25
|
+
host: str = "http://localhost:11434",
|
|
26
|
+
timeout_seconds: float = 30.0,
|
|
27
|
+
client: Callable[[str], str] | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.model = model
|
|
30
|
+
self.host = host.rstrip("/")
|
|
31
|
+
self.timeout_seconds = timeout_seconds
|
|
32
|
+
self.client = client or self._call_ollama
|
|
33
|
+
|
|
34
|
+
def create_patches(self, events: list[Event], current: list[MemoryItem]) -> list[MemoryPatch]:
|
|
35
|
+
if not events:
|
|
36
|
+
return []
|
|
37
|
+
prompt = self._build_prompt(events, current)
|
|
38
|
+
response_text = self.client(prompt)
|
|
39
|
+
data = self._parse_json(response_text)
|
|
40
|
+
return self._patches_from_data(data, events)
|
|
41
|
+
|
|
42
|
+
def _call_ollama(self, prompt: str) -> str:
|
|
43
|
+
payload = {
|
|
44
|
+
"model": self.model,
|
|
45
|
+
"prompt": prompt,
|
|
46
|
+
"stream": False,
|
|
47
|
+
"format": "json",
|
|
48
|
+
"options": {"temperature": 0.0, "num_predict": 512},
|
|
49
|
+
}
|
|
50
|
+
request = urllib.request.Request(
|
|
51
|
+
f"{self.host}/api/generate",
|
|
52
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
53
|
+
headers={"Content-Type": "application/json", "User-Agent": "AgentRAM/0.1"},
|
|
54
|
+
method="POST",
|
|
55
|
+
)
|
|
56
|
+
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
|
|
57
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
58
|
+
return str(body.get("response", ""))
|
|
59
|
+
|
|
60
|
+
def _build_prompt(self, events: list[Event], current: list[MemoryItem]) -> str:
|
|
61
|
+
contract = {
|
|
62
|
+
"patches": [
|
|
63
|
+
{
|
|
64
|
+
"op": "upsert",
|
|
65
|
+
"type": "decision|fact|task_state|file_summary|open_question",
|
|
66
|
+
"content": "short factual note",
|
|
67
|
+
"source_events": ["event id from input"],
|
|
68
|
+
"related_files": ["optional/path.py"],
|
|
69
|
+
"confidence": 0.8,
|
|
70
|
+
"scope": "session|task|project",
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
event_payload = [event.to_dict() for event in events]
|
|
75
|
+
current_payload = [item.to_dict() for item in current[-20:]]
|
|
76
|
+
return (
|
|
77
|
+
"You are AgentRAM memory writer. Convert events into normalized memory patches.\n"
|
|
78
|
+
"Rules: output only JSON, no markdown, no reasoning, no chain-of-thought. "
|
|
79
|
+
"Use only source_events from input events. Keep notes concise and factual. "
|
|
80
|
+
"Do not invent files or facts. Return empty patches if nothing important.\n"
|
|
81
|
+
f"Output schema example: {json.dumps(contract, ensure_ascii=False)}\n"
|
|
82
|
+
f"Events: {json.dumps(event_payload, ensure_ascii=False)}\n"
|
|
83
|
+
f"Current memory: {json.dumps(current_payload, ensure_ascii=False)}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _parse_json(self, text: str) -> dict[str, object]:
|
|
87
|
+
try:
|
|
88
|
+
parsed = json.loads(text)
|
|
89
|
+
return parsed if isinstance(parsed, dict) else {"patches": []}
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
|
92
|
+
if not match:
|
|
93
|
+
return {"patches": []}
|
|
94
|
+
try:
|
|
95
|
+
parsed = json.loads(match.group(0))
|
|
96
|
+
return parsed if isinstance(parsed, dict) else {"patches": []}
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
return {"patches": []}
|
|
99
|
+
|
|
100
|
+
def _patches_from_data(self, data: dict[str, object], events: list[Event]) -> list[MemoryPatch]:
|
|
101
|
+
event_ids = {event.id for event in events}
|
|
102
|
+
default_task_id = events[-1].task_id
|
|
103
|
+
patches_data = data.get("patches", [])
|
|
104
|
+
if not isinstance(patches_data, list):
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
patches: list[MemoryPatch] = []
|
|
108
|
+
for patch_data in patches_data:
|
|
109
|
+
if not isinstance(patch_data, dict):
|
|
110
|
+
continue
|
|
111
|
+
op = patch_data.get("op")
|
|
112
|
+
if op == "delete":
|
|
113
|
+
target_id = patch_data.get("target_id")
|
|
114
|
+
if isinstance(target_id, str):
|
|
115
|
+
patches.append(MemoryPatch(op="delete", target_id=target_id))
|
|
116
|
+
continue
|
|
117
|
+
if op != "upsert":
|
|
118
|
+
continue
|
|
119
|
+
item = self._item_from_patch(patch_data, event_ids, default_task_id)
|
|
120
|
+
if item:
|
|
121
|
+
patches.append(MemoryPatch(op="upsert", item=item))
|
|
122
|
+
return patches
|
|
123
|
+
|
|
124
|
+
def _item_from_patch(
|
|
125
|
+
self,
|
|
126
|
+
patch_data: dict[str, object],
|
|
127
|
+
event_ids: set[str],
|
|
128
|
+
default_task_id: str,
|
|
129
|
+
) -> MemoryItem | None:
|
|
130
|
+
content = patch_data.get("content")
|
|
131
|
+
source_events = patch_data.get("source_events")
|
|
132
|
+
if not isinstance(content, str) or not content.strip():
|
|
133
|
+
return None
|
|
134
|
+
if not isinstance(source_events, list):
|
|
135
|
+
return None
|
|
136
|
+
clean_source_events = [event_id for event_id in source_events if isinstance(event_id, str) and event_id in event_ids]
|
|
137
|
+
if not clean_source_events:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
memory_type = MemoryType(str(patch_data.get("type", "fact")))
|
|
142
|
+
scope = MemoryScope(str(patch_data.get("scope", "task")))
|
|
143
|
+
except ValueError:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
related_files = patch_data.get("related_files", [])
|
|
147
|
+
clean_related_files = [path for path in related_files if isinstance(path, str)] if isinstance(related_files, list) else []
|
|
148
|
+
confidence = patch_data.get("confidence", 0.7)
|
|
149
|
+
if not isinstance(confidence, int | float):
|
|
150
|
+
confidence = 0.7
|
|
151
|
+
|
|
152
|
+
return MemoryItem(
|
|
153
|
+
task_id=default_task_id,
|
|
154
|
+
type=memory_type,
|
|
155
|
+
content=content.strip(),
|
|
156
|
+
source_events=clean_source_events,
|
|
157
|
+
related_files=clean_related_files,
|
|
158
|
+
confidence=max(0.0, min(1.0, float(confidence))),
|
|
159
|
+
scope=scope,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class OpenAICompatibleMemoryAdapter(OllamaMemoryAdapter):
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
model: str,
|
|
169
|
+
base_url: str = "http://localhost:8000/v1",
|
|
170
|
+
api_key: str | None = None,
|
|
171
|
+
timeout_seconds: float = 30.0,
|
|
172
|
+
max_tokens: int = 512,
|
|
173
|
+
use_response_format: bool = True,
|
|
174
|
+
client: Callable[[dict[str, object]], str] | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
super().__init__(model=model, host=base_url, timeout_seconds=timeout_seconds)
|
|
177
|
+
self.base_url = base_url.rstrip("/")
|
|
178
|
+
self.api_key = api_key if api_key is not None else os.getenv("OPENAI_API_KEY", "EMPTY")
|
|
179
|
+
self.max_tokens = max_tokens
|
|
180
|
+
self.use_response_format = use_response_format
|
|
181
|
+
self.openai_client = client or self._call_openai_compatible
|
|
182
|
+
|
|
183
|
+
def create_patches(self, events: list[Event], current: list[MemoryItem]) -> list[MemoryPatch]:
|
|
184
|
+
if not events:
|
|
185
|
+
return []
|
|
186
|
+
payload = self._build_chat_payload(events, current)
|
|
187
|
+
response_text = self.openai_client(payload)
|
|
188
|
+
data = self._parse_json(response_text)
|
|
189
|
+
return self._patches_from_data(data, events)
|
|
190
|
+
|
|
191
|
+
def _build_chat_payload(self, events: list[Event], current: list[MemoryItem]) -> dict[str, object]:
|
|
192
|
+
payload: dict[str, object] = {
|
|
193
|
+
"model": self.model,
|
|
194
|
+
"messages": [
|
|
195
|
+
{
|
|
196
|
+
"role": "system",
|
|
197
|
+
"content": "You are AgentRAM memory writer. Output only JSON. No markdown, no reasoning, no chain-of-thought.",
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"role": "user",
|
|
201
|
+
"content": self._build_prompt(events, current),
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
"temperature": 0,
|
|
205
|
+
"max_tokens": self.max_tokens,
|
|
206
|
+
}
|
|
207
|
+
if self.use_response_format:
|
|
208
|
+
payload["response_format"] = {"type": "json_object"}
|
|
209
|
+
return payload
|
|
210
|
+
|
|
211
|
+
def _call_openai_compatible(self, payload: dict[str, object]) -> str:
|
|
212
|
+
request = urllib.request.Request(
|
|
213
|
+
f"{self.base_url}/chat/completions",
|
|
214
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
215
|
+
headers={
|
|
216
|
+
"Content-Type": "application/json",
|
|
217
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
218
|
+
"User-Agent": "AgentRAM/0.1",
|
|
219
|
+
"Accept": "application/json",
|
|
220
|
+
},
|
|
221
|
+
method="POST",
|
|
222
|
+
)
|
|
223
|
+
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
|
|
224
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
225
|
+
choices = body.get("choices", [])
|
|
226
|
+
if not isinstance(choices, list) or not choices:
|
|
227
|
+
return ""
|
|
228
|
+
first_choice = choices[0]
|
|
229
|
+
if not isinstance(first_choice, dict):
|
|
230
|
+
return ""
|
|
231
|
+
message = first_choice.get("message", {})
|
|
232
|
+
if not isinstance(message, dict):
|
|
233
|
+
return ""
|
|
234
|
+
content = message.get("content", "")
|
|
235
|
+
return content if isinstance(content, str) else ""
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class AnthropicMemoryAdapter(OllamaMemoryAdapter):
|
|
240
|
+
def __init__(
|
|
241
|
+
self,
|
|
242
|
+
model: str,
|
|
243
|
+
api_key: str | None = None,
|
|
244
|
+
base_url: str = "https://api.anthropic.com/v1",
|
|
245
|
+
timeout_seconds: float = 30.0,
|
|
246
|
+
max_tokens: int = 512,
|
|
247
|
+
anthropic_version: str = "2023-06-01",
|
|
248
|
+
client: Callable[[dict[str, object]], str] | None = None,
|
|
249
|
+
) -> None:
|
|
250
|
+
super().__init__(model=model, host=base_url, timeout_seconds=timeout_seconds)
|
|
251
|
+
self.base_url = base_url.rstrip("/")
|
|
252
|
+
self.api_key = api_key if api_key is not None else os.getenv("ANTHROPIC_API_KEY", "")
|
|
253
|
+
self.max_tokens = max_tokens
|
|
254
|
+
self.anthropic_version = anthropic_version
|
|
255
|
+
self.anthropic_client = client or self._call_anthropic
|
|
256
|
+
|
|
257
|
+
def create_patches(self, events: list[Event], current: list[MemoryItem]) -> list[MemoryPatch]:
|
|
258
|
+
if not events:
|
|
259
|
+
return []
|
|
260
|
+
payload = self._build_messages_payload(events, current)
|
|
261
|
+
response_text = self.anthropic_client(payload)
|
|
262
|
+
data = self._parse_json(response_text)
|
|
263
|
+
return self._patches_from_data(data, events)
|
|
264
|
+
|
|
265
|
+
def _build_messages_payload(self, events: list[Event], current: list[MemoryItem]) -> dict[str, object]:
|
|
266
|
+
return {
|
|
267
|
+
"model": self.model,
|
|
268
|
+
"system": "You are AgentRAM memory writer. Output only JSON. No markdown, no reasoning, no chain-of-thought.",
|
|
269
|
+
"messages": [
|
|
270
|
+
{
|
|
271
|
+
"role": "user",
|
|
272
|
+
"content": self._build_prompt(events, current),
|
|
273
|
+
}
|
|
274
|
+
],
|
|
275
|
+
"temperature": 0,
|
|
276
|
+
"max_tokens": self.max_tokens,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
def _call_anthropic(self, payload: dict[str, object]) -> str:
|
|
280
|
+
request = urllib.request.Request(
|
|
281
|
+
f"{self.base_url}/messages",
|
|
282
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
283
|
+
headers={
|
|
284
|
+
"Content-Type": "application/json",
|
|
285
|
+
"x-api-key": self.api_key,
|
|
286
|
+
"anthropic-version": self.anthropic_version,
|
|
287
|
+
"User-Agent": "AgentRAM/0.1",
|
|
288
|
+
"Accept": "application/json",
|
|
289
|
+
},
|
|
290
|
+
method="POST",
|
|
291
|
+
)
|
|
292
|
+
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
|
|
293
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
294
|
+
content = body.get("content", [])
|
|
295
|
+
if not isinstance(content, list):
|
|
296
|
+
return ""
|
|
297
|
+
parts: list[str] = []
|
|
298
|
+
for block in content:
|
|
299
|
+
if not isinstance(block, dict):
|
|
300
|
+
continue
|
|
301
|
+
if block.get("type") == "text" and isinstance(block.get("text"), str):
|
|
302
|
+
parts.append(str(block["text"]))
|
|
303
|
+
return "\n".join(parts)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class HeuristicMemoryAdapter(MemoryModelAdapter):
|
|
307
|
+
def create_patches(self, events: list[Event], current: list[MemoryItem]) -> list[MemoryPatch]:
|
|
308
|
+
patches: list[MemoryPatch] = []
|
|
309
|
+
for event in events:
|
|
310
|
+
item = self._event_to_item(event)
|
|
311
|
+
if item:
|
|
312
|
+
patches.append(MemoryPatch(op="upsert", item=item))
|
|
313
|
+
return patches
|
|
314
|
+
|
|
315
|
+
def _event_to_item(self, event: Event) -> MemoryItem | None:
|
|
316
|
+
payload = event.payload
|
|
317
|
+
if event.type == EventType.READ_FILE:
|
|
318
|
+
path = str(payload.get("path", ""))
|
|
319
|
+
if not path:
|
|
320
|
+
return None
|
|
321
|
+
return MemoryItem(
|
|
322
|
+
task_id=event.task_id,
|
|
323
|
+
type=MemoryType.FILE_SUMMARY,
|
|
324
|
+
content=f"Main agent opened file `{path}`.",
|
|
325
|
+
related_files=[path],
|
|
326
|
+
source_events=[event.id],
|
|
327
|
+
confidence=0.95,
|
|
328
|
+
scope=MemoryScope.TASK,
|
|
329
|
+
)
|
|
330
|
+
if event.type == EventType.WRITE_FILE:
|
|
331
|
+
path = str(payload.get("path", ""))
|
|
332
|
+
summary = str(payload.get("summary", "updated"))
|
|
333
|
+
if not path:
|
|
334
|
+
return None
|
|
335
|
+
return MemoryItem(
|
|
336
|
+
task_id=event.task_id,
|
|
337
|
+
type=MemoryType.FILE_SUMMARY,
|
|
338
|
+
content=f"Main agent changed `{path}`: {summary}.",
|
|
339
|
+
related_files=[path],
|
|
340
|
+
source_events=[event.id],
|
|
341
|
+
confidence=0.9,
|
|
342
|
+
scope=MemoryScope.TASK,
|
|
343
|
+
)
|
|
344
|
+
if event.type == EventType.DECISION:
|
|
345
|
+
content = str(payload.get("content", "")).strip()
|
|
346
|
+
if not content:
|
|
347
|
+
return None
|
|
348
|
+
return MemoryItem(
|
|
349
|
+
task_id=event.task_id,
|
|
350
|
+
type=MemoryType.DECISION,
|
|
351
|
+
content=content,
|
|
352
|
+
source_events=[event.id],
|
|
353
|
+
confidence=0.9,
|
|
354
|
+
scope=MemoryScope.TASK,
|
|
355
|
+
)
|
|
356
|
+
if event.type == EventType.USER_MESSAGE:
|
|
357
|
+
content = str(payload.get("content", "")).strip()
|
|
358
|
+
if not content:
|
|
359
|
+
return None
|
|
360
|
+
return MemoryItem(
|
|
361
|
+
task_id=event.task_id,
|
|
362
|
+
type=MemoryType.TASK_STATE,
|
|
363
|
+
content=f"User request: {content}",
|
|
364
|
+
source_events=[event.id],
|
|
365
|
+
confidence=0.85,
|
|
366
|
+
scope=MemoryScope.TASK,
|
|
367
|
+
)
|
|
368
|
+
return None
|
package/agentram/cli.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import textwrap
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .config import default_ram_root
|
|
11
|
+
from .mcp_server import McpContext, call_tool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(description="AgentRAM terminal UI and CLI.")
|
|
16
|
+
parser.add_argument("--ram-root", default=None, help="AgentRAM storage root. Defaults to .agentram in the current project directory.")
|
|
17
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
18
|
+
|
|
19
|
+
subparsers.add_parser("tui", help="Open terminal chat UI.")
|
|
20
|
+
subparsers.add_parser("init", help="Create AgentRAM files.")
|
|
21
|
+
subparsers.add_parser("status", help="Show RAM file and item counts.")
|
|
22
|
+
subparsers.add_parser("goal", help="Show goal stack.")
|
|
23
|
+
|
|
24
|
+
retrieve = subparsers.add_parser("retrieve", help="Retrieve memory context.")
|
|
25
|
+
retrieve.add_argument("query", nargs="?", default="", help="Search query.")
|
|
26
|
+
retrieve.add_argument("--task-id", default="codex", help="Task id.")
|
|
27
|
+
retrieve.add_argument("--limit", type=int, default=8, help="Max items.")
|
|
28
|
+
|
|
29
|
+
drift = subparsers.add_parser("drift", help="Check goal drift for a task.")
|
|
30
|
+
drift.add_argument("task", nargs="?", default="", help="Task to check.")
|
|
31
|
+
|
|
32
|
+
replay = subparsers.add_parser("replay", help="Replay events into memory.")
|
|
33
|
+
replay.add_argument("--task-id", default=None, help="Optional task id filter.")
|
|
34
|
+
|
|
35
|
+
return parser
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def context_from_args(args: argparse.Namespace) -> McpContext:
|
|
39
|
+
ram_root = Path(args.ram_root) if args.ram_root else default_ram_root()
|
|
40
|
+
return McpContext(ram_root=ram_root)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def payload(result: dict[str, Any]) -> dict[str, Any]:
|
|
44
|
+
return dict(result.get("structuredContent", {}))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_status(context: McpContext) -> None:
|
|
48
|
+
context.init_storage()
|
|
49
|
+
memory_items = context.memory_store.list_items()
|
|
50
|
+
events = context.event_log.read_all()
|
|
51
|
+
goal_state = context.goal_store.load()
|
|
52
|
+
print(f"AgentRAM root: {context.ram_root}")
|
|
53
|
+
print(f"Events: {len(events)}")
|
|
54
|
+
print(f"Memory items: {len(memory_items)}")
|
|
55
|
+
print(f"Mission: {goal_state.mission or '-'}")
|
|
56
|
+
print(f"Current goal: {goal_state.current_goal or '-'}")
|
|
57
|
+
print(f"Current task: {goal_state.current_task or '-'}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_command(args: argparse.Namespace) -> int:
|
|
61
|
+
context = context_from_args(args)
|
|
62
|
+
command = args.command or "tui"
|
|
63
|
+
|
|
64
|
+
if command == "tui":
|
|
65
|
+
run_tui(context)
|
|
66
|
+
return 0
|
|
67
|
+
if command == "init":
|
|
68
|
+
data = payload(call_tool(context, "agentram_init", {}))
|
|
69
|
+
print(f"AgentRAM root: {data['ram_root']}")
|
|
70
|
+
created = data.get("created", [])
|
|
71
|
+
print("Created: " + (", ".join(created) if created else "none"))
|
|
72
|
+
return 0
|
|
73
|
+
if command == "status":
|
|
74
|
+
print_status(context)
|
|
75
|
+
return 0
|
|
76
|
+
if command == "retrieve":
|
|
77
|
+
query = args.query or input("Query > ").strip()
|
|
78
|
+
data = payload(call_tool(context, "agentram_retrieve", {"query": query, "task_id": args.task_id, "limit": args.limit}))
|
|
79
|
+
print(data.get("context") or "No matching memory.")
|
|
80
|
+
return 0
|
|
81
|
+
if command == "goal":
|
|
82
|
+
data = payload(call_tool(context, "agentram_read_goal_state", {}))
|
|
83
|
+
print(data.get("context") or "No goal stack yet.")
|
|
84
|
+
return 0
|
|
85
|
+
if command == "drift":
|
|
86
|
+
task = args.task or input("Task > ").strip()
|
|
87
|
+
data = payload(call_tool(context, "agentram_check_goal_drift", {"task": task}))
|
|
88
|
+
print(f"Status: {data.get('status')}")
|
|
89
|
+
print(f"Aligned: {data.get('aligned')}")
|
|
90
|
+
print(f"Reason: {data.get('reason')}")
|
|
91
|
+
if data.get("warning"):
|
|
92
|
+
print(f"Warning: {data['warning']}")
|
|
93
|
+
return 0
|
|
94
|
+
if command == "replay":
|
|
95
|
+
arguments = {"task_id": args.task_id} if args.task_id else {}
|
|
96
|
+
data = payload(call_tool(context, "agentram_replay_events", arguments))
|
|
97
|
+
print(f"Events: {data.get('event_count', 0)}")
|
|
98
|
+
print(f"Patches: {data.get('patch_count', 0)}")
|
|
99
|
+
print(f"Memory items: {data.get('memory_count', 0)}")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
raise SystemExit(f"unknown command: {command}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_tui(context: McpContext) -> None:
|
|
106
|
+
context.init_storage()
|
|
107
|
+
messages = ["AgentRAM terminal UI ready.", f"RAM root: {context.ram_root}"]
|
|
108
|
+
while True:
|
|
109
|
+
draw_screen(context, messages)
|
|
110
|
+
choice = input("You > ").strip()
|
|
111
|
+
if choice in {"0", "q", "quit", "exit"}:
|
|
112
|
+
return
|
|
113
|
+
handle_tui_choice(context, choice, messages)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def handle_tui_choice(context: McpContext, choice: str, messages: list[str]) -> None:
|
|
117
|
+
try:
|
|
118
|
+
if choice == "1":
|
|
119
|
+
data = payload(call_tool(context, "agentram_init", {}))
|
|
120
|
+
created = data.get("created", [])
|
|
121
|
+
messages.append("Init: " + (", ".join(created) if created else "already ready"))
|
|
122
|
+
elif choice == "2":
|
|
123
|
+
messages.append(status_text(context))
|
|
124
|
+
elif choice == "3":
|
|
125
|
+
query = input("Query > ").strip()
|
|
126
|
+
data = payload(call_tool(context, "agentram_retrieve", {"query": query, "task_id": "codex", "limit": 8}))
|
|
127
|
+
messages.append(data.get("context") or "No matching memory.")
|
|
128
|
+
elif choice == "4":
|
|
129
|
+
data = payload(call_tool(context, "agentram_read_goal_state", {}))
|
|
130
|
+
messages.append(data.get("context") or "No goal stack yet.")
|
|
131
|
+
elif choice == "5":
|
|
132
|
+
update_goal_from_prompt(context, messages)
|
|
133
|
+
elif choice == "6":
|
|
134
|
+
task = input("Task > ").strip()
|
|
135
|
+
data = payload(call_tool(context, "agentram_check_goal_drift", {"task": task}))
|
|
136
|
+
messages.append(f"Drift: {data.get('status')} | aligned={data.get('aligned')} | {data.get('reason')}")
|
|
137
|
+
elif choice == "7":
|
|
138
|
+
events = context.event_log.read_all()
|
|
139
|
+
latest = events[-5:]
|
|
140
|
+
lines = [f"Events: {len(events)}"]
|
|
141
|
+
lines.extend(f"- {event.type.value} {event.payload}" for event in latest)
|
|
142
|
+
messages.append("\n".join(lines))
|
|
143
|
+
elif choice == "8":
|
|
144
|
+
data = payload(call_tool(context, "agentram_replay_events", {}))
|
|
145
|
+
messages.append(f"Replay: events={data.get('event_count', 0)} patches={data.get('patch_count', 0)} memory={data.get('memory_count', 0)}")
|
|
146
|
+
elif choice == "9" or not choice:
|
|
147
|
+
messages.append("Choose button number. Use retrieve before injecting context into agent prompt.")
|
|
148
|
+
else:
|
|
149
|
+
messages.append(f"Unknown button: {choice}")
|
|
150
|
+
except Exception as error: # noqa: BLE001 - terminal boundary
|
|
151
|
+
messages.append(f"Error: {error}")
|
|
152
|
+
del messages[:-8]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def update_goal_from_prompt(context: McpContext, messages: list[str]) -> None:
|
|
156
|
+
patch: dict[str, object] = {}
|
|
157
|
+
fields = ["mission", "project_goal", "current_milestone", "current_goal", "current_task"]
|
|
158
|
+
for field in fields:
|
|
159
|
+
value = input(f"{field} (blank skip) > ").strip()
|
|
160
|
+
if value:
|
|
161
|
+
patch[field] = value
|
|
162
|
+
next_tasks = input("next_tasks comma list (blank skip) > ").strip()
|
|
163
|
+
if next_tasks:
|
|
164
|
+
patch["next_tasks"] = [item.strip() for item in next_tasks.split(",") if item.strip()]
|
|
165
|
+
if not patch:
|
|
166
|
+
messages.append("Goal update skipped.")
|
|
167
|
+
return
|
|
168
|
+
data = payload(call_tool(context, "agentram_update_goal_state", patch))
|
|
169
|
+
messages.append(data.get("context") or "Goal updated.")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def status_text(context: McpContext) -> str:
|
|
173
|
+
context.init_storage()
|
|
174
|
+
events = context.event_log.read_all()
|
|
175
|
+
items = context.memory_store.list_items()
|
|
176
|
+
goal_state = context.goal_store.load()
|
|
177
|
+
return "\n".join(
|
|
178
|
+
[
|
|
179
|
+
f"RAM root: {context.ram_root}",
|
|
180
|
+
f"Events: {len(events)}",
|
|
181
|
+
f"Memory items: {len(items)}",
|
|
182
|
+
f"Current goal: {goal_state.current_goal or '-'}",
|
|
183
|
+
f"Current task: {goal_state.current_task or '-'}",
|
|
184
|
+
]
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def draw_screen(context: McpContext, messages: list[str]) -> None:
|
|
189
|
+
clear_terminal()
|
|
190
|
+
width = min(max(shutil.get_terminal_size((100, 30)).columns, 72), 120)
|
|
191
|
+
print(box_line("┌", "─", "┐", width))
|
|
192
|
+
print(box_text("AgentRAM CLI", width))
|
|
193
|
+
print(box_text(f"root: {context.ram_root}", width))
|
|
194
|
+
print(box_line("├", "─", "┤", width))
|
|
195
|
+
for line in menu_lines():
|
|
196
|
+
print(box_text(line, width))
|
|
197
|
+
print(box_line("├", "─", "┤", width))
|
|
198
|
+
chat_lines = wrap_messages(messages, width - 4)
|
|
199
|
+
visible = chat_lines[-14:]
|
|
200
|
+
for line in visible:
|
|
201
|
+
print(box_text(line, width))
|
|
202
|
+
for _ in range(max(0, 14 - len(visible))):
|
|
203
|
+
print(box_text("", width))
|
|
204
|
+
print(box_line("└", "─", "┘", width))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def menu_lines() -> list[str]:
|
|
208
|
+
return [
|
|
209
|
+
"[1] Init RAM [2] Status [3] Retrieve",
|
|
210
|
+
"[4] Goal Show [5] Goal Set [6] Drift Check",
|
|
211
|
+
"[7] Events [8] Replay [9] Help [0] Exit",
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def wrap_messages(messages: list[str], width: int) -> list[str]:
|
|
216
|
+
lines: list[str] = []
|
|
217
|
+
for message in messages:
|
|
218
|
+
for raw_line in str(message).splitlines() or [""]:
|
|
219
|
+
wrapped = textwrap.wrap(raw_line, width=width, replace_whitespace=False) or [""]
|
|
220
|
+
lines.extend(wrapped)
|
|
221
|
+
return lines
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def box_line(left: str, fill: str, right: str, width: int) -> str:
|
|
225
|
+
return left + fill * (width - 2) + right
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def box_text(text: str, width: int) -> str:
|
|
229
|
+
return "│ " + text[: width - 4].ljust(width - 4) + " │"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def clear_terminal() -> None:
|
|
233
|
+
os.system("cls" if os.name == "nt" else "clear")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main(argv: list[str] | None = None) -> int:
|
|
237
|
+
args = build_parser().parse_args(argv)
|
|
238
|
+
return run_command(args)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
raise SystemExit(main())
|