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.
@@ -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
@@ -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())