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,369 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .adapter import HeuristicMemoryAdapter
12
+ from .config import default_ram_root
13
+ from .merger import MemoryMerger
14
+ from .retriever import MemoryRetriever
15
+ from .schema import Event, EventType
16
+ from .storage import JsonGoalStore, JsonMemoryStore, JsonlEventLog, ensure_ram_storage
17
+
18
+ JSONRPC_VERSION = "2.0"
19
+ SERVER_NAME = "agentram"
20
+ SERVER_VERSION = "0.1.0"
21
+
22
+ def env_flag(name: str, default: bool = False) -> bool:
23
+ value = os.getenv(name)
24
+ if value is None:
25
+ return default
26
+ return value.strip().lower() in {"1", "true", "yes", "on"}
27
+
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class McpContext:
32
+ ram_root: Path
33
+
34
+ @property
35
+ def memory_store(self) -> JsonMemoryStore:
36
+ return JsonMemoryStore(self.ram_root / "memory.json")
37
+
38
+ @property
39
+ def event_log(self) -> JsonlEventLog:
40
+ return JsonlEventLog(self.ram_root / "events.jsonl")
41
+
42
+ @property
43
+ def goal_store(self) -> JsonGoalStore:
44
+ return JsonGoalStore(self.ram_root / "goal_state.json")
45
+
46
+ @property
47
+ def merger(self) -> MemoryMerger:
48
+ return MemoryMerger(self.memory_store)
49
+
50
+ def init_storage(self) -> dict[str, object]:
51
+ return ensure_ram_storage(self.ram_root)
52
+
53
+ def ingest_events(self, events: list[Event]) -> dict[str, Any]:
54
+ current = self.memory_store.list_items()
55
+ patches = HeuristicMemoryAdapter().create_patches(events, current)
56
+ items = self.merger.apply(patches)
57
+ return {
58
+ "patch_count": len(patches),
59
+ "memory_count": len(items),
60
+ "patches": [
61
+ {
62
+ "op": patch.op,
63
+ "item": patch.item.to_dict() if patch.item else None,
64
+ "target_id": patch.target_id,
65
+ }
66
+ for patch in patches
67
+ ],
68
+ }
69
+
70
+ def replay_events(self, task_id: str | None = None) -> dict[str, Any]:
71
+ events = self.event_log.read_all()
72
+ if task_id:
73
+ events = [event for event in events if event.task_id == task_id]
74
+ ingest = self.ingest_events(events)
75
+ return {"event_count": len(events), **ingest}
76
+
77
+
78
+ def text_content(text: str) -> list[dict[str, str]]:
79
+ return [{"type": "text", "text": text}]
80
+
81
+
82
+ def tool_result(payload: dict[str, Any]) -> dict[str, Any]:
83
+ return {
84
+ "content": text_content(json.dumps(payload, indent=2, ensure_ascii=False)),
85
+ "structuredContent": payload,
86
+ }
87
+
88
+ def tokenize(text: str) -> set[str]:
89
+ cleaned = "".join(char.lower() if char.isalnum() else " " for char in text)
90
+ return {token for token in cleaned.split() if len(token) >= 3}
91
+
92
+
93
+ def check_goal_drift(task: str, context: McpContext) -> dict[str, Any]:
94
+ state = context.goal_store.load()
95
+ anchors = [
96
+ state.mission,
97
+ state.project_goal,
98
+ state.current_milestone,
99
+ state.current_goal,
100
+ state.current_task,
101
+ " ".join(state.next_tasks),
102
+ " ".join(state.completed_milestones),
103
+ " ".join(state.goal_history[-8:]),
104
+ " ".join(decision.content for decision in state.decision_history[-8:]),
105
+ ]
106
+ goal_text = " ".join(anchor for anchor in anchors if anchor)
107
+ if not goal_text.strip():
108
+ return {
109
+ "status": "unknown",
110
+ "aligned": None,
111
+ "score": 0,
112
+ "reason": "No goal_state.json content yet.",
113
+ "warning": None,
114
+ "next_actions": ["Set mission, project_goal, current_goal, and current_task first."],
115
+ }
116
+
117
+ task_terms = tokenize(task)
118
+ goal_terms = tokenize(goal_text)
119
+ overlaps = sorted(task_terms & goal_terms)
120
+ score = len(overlaps)
121
+ if score > 0:
122
+ return {
123
+ "status": "ok",
124
+ "aligned": True,
125
+ "score": score,
126
+ "overlap_terms": overlaps[:20],
127
+ "reason": "Task overlaps existing goal stack.",
128
+ "warning": None,
129
+ "next_actions": [],
130
+ }
131
+
132
+ return {
133
+ "status": "drift_risk",
134
+ "aligned": False,
135
+ "score": 0,
136
+ "overlap_terms": [],
137
+ "reason": "Task has no lexical overlap with mission, project goal, current milestone, current goal, current task, history, or decisions.",
138
+ "warning": "Potential goal drift. Confirm scope change or update goal_state before continuing.",
139
+ "next_actions": ["Confirm task belongs to current goal stack.", "If intentional, call agentram_update_goal_state first."],
140
+ }
141
+
142
+
143
+
144
+ def tools_list() -> dict[str, Any]:
145
+ return {
146
+ "tools": [
147
+ {
148
+ "name": "agentram_init",
149
+ "description": "Create project-local .agentram files without overwriting existing memory.",
150
+ "inputSchema": {"type": "object", "properties": {}},
151
+ },
152
+ {
153
+ "name": "agentram_retrieve",
154
+ "description": "Retrieve relevant AgentRAM notes for a query and task_id.",
155
+ "inputSchema": {
156
+ "type": "object",
157
+ "properties": {
158
+ "query": {"type": "string"},
159
+ "task_id": {"type": "string", "default": "codex"},
160
+ "limit": {"type": "integer", "default": 8, "minimum": 1, "maximum": 50},
161
+ },
162
+ "required": ["query"],
163
+ },
164
+ },
165
+ {
166
+ "name": "agentram_emit_event",
167
+ "description": "Append an AgentRAM event such as USER_MESSAGE, DECISION, READ_FILE, WRITE_FILE, TOOL_CALL, or AGENT_MESSAGE.",
168
+ "inputSchema": {
169
+ "type": "object",
170
+ "properties": {
171
+ "type": {"type": "string", "enum": [event_type.value for event_type in EventType]},
172
+ "payload": {"type": "object"},
173
+ "task_id": {"type": "string", "default": "codex"},
174
+ "ingest": {"type": "boolean", "default": True},
175
+ },
176
+ "required": ["type", "payload"],
177
+ },
178
+ },
179
+ {
180
+ "name": "agentram_replay_events",
181
+ "description": "Replay events.jsonl into normalized RAM notes using the local heuristic adapter.",
182
+ "inputSchema": {
183
+ "type": "object",
184
+ "properties": {
185
+ "task_id": {"type": "string", "description": "Optional task id filter."},
186
+ },
187
+ },
188
+ },
189
+ {
190
+ "name": "agentram_read_task_state",
191
+ "description": "Read all stored RAM items for a task_id.",
192
+ "inputSchema": {
193
+ "type": "object",
194
+ "properties": {
195
+ "task_id": {"type": "string", "default": "codex"},
196
+ },
197
+ },
198
+ },
199
+ {
200
+ "name": "agentram_read_goal_state",
201
+ "description": "Read project goal hierarchy, current task, goal history, and decision history.",
202
+ "inputSchema": {"type": "object", "properties": {}},
203
+ },
204
+ {
205
+ "name": "agentram_update_goal_state",
206
+ "description": "Update project goal hierarchy fields to reduce goal drift.",
207
+ "inputSchema": {
208
+ "type": "object",
209
+ "properties": {
210
+ "mission": {"type": "string"},
211
+ "project_goal": {"type": "string"},
212
+ "milestones": {"type": "array"},
213
+ "completed_milestones": {"type": "array", "items": {"type": "string"}},
214
+ "current_milestone": {"type": "string"},
215
+ "current_goal": {"type": "string"},
216
+ "current_task": {"type": "string"},
217
+ "next_tasks": {"type": "array", "items": {"type": "string"}},
218
+ "goal_history": {"type": "array", "items": {"type": "string"}},
219
+ "decision_history": {"type": "array"}
220
+ },
221
+ },
222
+ },
223
+ {
224
+ "name": "agentram_check_goal_drift",
225
+ "description": "Check whether a proposed task aligns with the current Goal Stack. Returns no suggestions when aligned.",
226
+ "inputSchema": {
227
+ "type": "object",
228
+ "properties": {
229
+ "task": {"type": "string"},
230
+ },
231
+ "required": ["task"],
232
+ },
233
+ },
234
+ ]
235
+ }
236
+
237
+
238
+ def call_tool(context: McpContext, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
239
+ if name == "agentram_init":
240
+ return tool_result({"ok": True, **context.init_storage()})
241
+
242
+ if name == "agentram_retrieve":
243
+ query = str(arguments.get("query", ""))
244
+ task_id = str(arguments.get("task_id", "codex"))
245
+ limit = int(arguments.get("limit", 8))
246
+ retriever = MemoryRetriever(context.memory_store, context.goal_store)
247
+ items = retriever.retrieve(query=query, task_id=task_id, limit=limit)
248
+ return tool_result(
249
+ {
250
+ "task_id": task_id,
251
+ "query": query,
252
+ "context": retriever.context_block(query=query, task_id=task_id, limit=limit),
253
+ "items": [item.to_dict() for item in items],
254
+ }
255
+ )
256
+
257
+ if name == "agentram_emit_event":
258
+ event_type = EventType(str(arguments["type"]))
259
+ payload = arguments.get("payload", {})
260
+ if not isinstance(payload, dict):
261
+ raise ValueError("payload must be an object")
262
+ task_id = str(arguments.get("task_id", "codex"))
263
+ event = Event(type=event_type, payload=payload, task_id=task_id)
264
+ context.event_log.append(event)
265
+ ingest_result = context.ingest_events([event]) if bool(arguments.get("ingest", True)) else None
266
+ return tool_result({"ok": True, "event": event.to_dict(), "ingest": ingest_result})
267
+
268
+ if name == "agentram_replay_events":
269
+ task_id = arguments.get("task_id")
270
+ if task_id is not None:
271
+ task_id = str(task_id)
272
+ return tool_result({"ok": True, **context.replay_events(task_id=task_id)})
273
+
274
+ if name == "agentram_read_task_state":
275
+ task_id = str(arguments.get("task_id", "codex"))
276
+ items = [item.to_dict() for item in context.memory_store.list_items() if item.task_id == task_id]
277
+ return tool_result({"task_id": task_id, "items": items})
278
+
279
+ if name == "agentram_read_goal_state":
280
+ state = context.goal_store.load()
281
+ return tool_result({"goal_state": state.to_dict(), "context": state.context_block()})
282
+
283
+ if name == "agentram_update_goal_state":
284
+ state = context.goal_store.update(arguments)
285
+ return tool_result({"ok": True, "goal_state": state.to_dict(), "context": state.context_block()})
286
+
287
+ if name == "agentram_check_goal_drift":
288
+ task = str(arguments.get("task", ""))
289
+ if not task.strip():
290
+ raise ValueError("task is required")
291
+ return tool_result({"task": task, **check_goal_drift(task, context)})
292
+
293
+ raise ValueError(f"unknown tool: {name}")
294
+
295
+
296
+ def handle_request(context: McpContext, request: dict[str, Any]) -> dict[str, Any] | None:
297
+ request_id = request.get("id")
298
+ method = request.get("method")
299
+ params = request.get("params", {})
300
+
301
+ try:
302
+ if method == "initialize":
303
+ result = {
304
+ "protocolVersion": "2024-11-05",
305
+ "capabilities": {"tools": {}},
306
+ "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
307
+ }
308
+ elif method == "notifications/initialized":
309
+ return None
310
+ elif method == "tools/list":
311
+ result = tools_list()
312
+ elif method == "tools/call":
313
+ if not isinstance(params, dict):
314
+ raise ValueError("params must be an object")
315
+ name = str(params.get("name", ""))
316
+ arguments = params.get("arguments", {})
317
+ if not isinstance(arguments, dict):
318
+ raise ValueError("arguments must be an object")
319
+ result = call_tool(context, name, arguments)
320
+ elif method == "ping":
321
+ result = {}
322
+ else:
323
+ return error_response(request_id, -32601, f"method not found: {method}")
324
+ if request_id is None:
325
+ return None
326
+ return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": result}
327
+ except Exception as error: # noqa: BLE001 - JSON-RPC boundary
328
+ if request_id is None:
329
+ return None
330
+ return error_response(request_id, -32000, str(error))
331
+
332
+
333
+ def error_response(request_id: Any, code: int, message: str) -> dict[str, Any]:
334
+ return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "error": {"code": code, "message": message}}
335
+
336
+
337
+ def run_stdio(ram_root: str | None = None) -> None:
338
+ context = McpContext(ram_root=Path(ram_root) if ram_root else default_ram_root())
339
+ if env_flag("AGENTRAM_AUTO_INIT"):
340
+ context.init_storage()
341
+ for line in sys.stdin:
342
+ line = line.strip()
343
+ if not line:
344
+ continue
345
+ try:
346
+ request = json.loads(line)
347
+ except json.JSONDecodeError as error:
348
+ response = error_response(None, -32700, str(error))
349
+ else:
350
+ response = handle_request(context, request)
351
+ if response is not None:
352
+ sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
353
+ sys.stdout.flush()
354
+
355
+
356
+ def build_parser() -> argparse.ArgumentParser:
357
+ parser = argparse.ArgumentParser(description="AgentRAM MCP stdio server.")
358
+ parser.add_argument("--ram-root", default=None, help="AgentRAM storage root. Defaults to .agentram in the current project directory.")
359
+ return parser
360
+
361
+
362
+ def main(argv: list[str] | None = None) -> int:
363
+ args = build_parser().parse_args(argv)
364
+ run_stdio(ram_root=args.ram_root)
365
+ return 0
366
+
367
+
368
+ if __name__ == "__main__":
369
+ raise SystemExit(main())
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import replace
4
+ from collections.abc import Iterable
5
+
6
+ from .schema import MemoryItem, MemoryPatch, utc_now
7
+ from .storage import JsonMemoryStore
8
+
9
+
10
+ class MemoryValidator:
11
+ def validate(self, patch: MemoryPatch) -> bool:
12
+ if patch.op == "delete":
13
+ return bool(patch.target_id)
14
+ if patch.item is None:
15
+ return False
16
+ if not patch.item.content.strip():
17
+ return False
18
+ if not patch.item.source_events:
19
+ return False
20
+ return 0.0 <= patch.item.confidence <= 1.0
21
+
22
+
23
+ class MemoryMerger:
24
+ def __init__(self, store: JsonMemoryStore, validator: MemoryValidator | None = None) -> None:
25
+ self.store = store
26
+ self.validator = validator or MemoryValidator()
27
+
28
+ def apply(self, patches: list[MemoryPatch]) -> list[MemoryItem]:
29
+ items = self.store.list_items()
30
+ by_id = {item.id: item for item in items}
31
+
32
+ for patch in patches:
33
+ if not self.validator.validate(patch):
34
+ continue
35
+ if patch.op == "delete" and patch.target_id:
36
+ by_id.pop(patch.target_id, None)
37
+ continue
38
+ if patch.item is None:
39
+ continue
40
+ duplicate = self._find_duplicate(by_id.values(), patch.item)
41
+ if duplicate:
42
+ by_id[duplicate.id] = self._merge_duplicate(duplicate, patch.item)
43
+ else:
44
+ by_id[patch.item.id] = patch.item
45
+
46
+ merged = list(by_id.values())
47
+ self.store.save_items(merged)
48
+ return merged
49
+
50
+ def _find_duplicate(self, items: Iterable[MemoryItem], incoming: MemoryItem) -> MemoryItem | None:
51
+ normalized = incoming.content.strip().lower()
52
+ for item in items:
53
+ if item.task_id == incoming.task_id and item.type == incoming.type and item.content.strip().lower() == normalized:
54
+ return item
55
+ return None
56
+
57
+ def _merge_duplicate(self, current: MemoryItem, incoming: MemoryItem) -> MemoryItem:
58
+ source_events = sorted(set(current.source_events + incoming.source_events))
59
+ related_files = sorted(set(current.related_files + incoming.related_files))
60
+ return replace(
61
+ current,
62
+ source_events=source_events,
63
+ related_files=related_files,
64
+ confidence=max(current.confidence, incoming.confidence),
65
+ updated_at=utc_now(),
66
+ )
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ from .adapter import HeuristicMemoryAdapter, MemoryModelAdapter
7
+ from .merger import MemoryMerger
8
+ from .schema import Event
9
+ from .storage import JsonMemoryStore, JsonlEventLog
10
+ from .worker import MemoryWorker
11
+
12
+
13
+ class AgentRAM:
14
+ def __init__(self, root: str | Path = ".agentram", adapter: MemoryModelAdapter | None = None) -> None:
15
+ self.root = Path(root)
16
+ self.event_log = JsonlEventLog(self.root / "events.jsonl")
17
+ self.memory_store = JsonMemoryStore(self.root / "memory.json")
18
+ self.queue: asyncio.Queue[Event] = asyncio.Queue()
19
+ self.merger = MemoryMerger(self.memory_store)
20
+ self.adapter = adapter or HeuristicMemoryAdapter()
21
+ self.worker = MemoryWorker(self.queue, self.adapter, self.merger)
22
+ self._task: asyncio.Task[None] | None = None
23
+
24
+ async def start(self) -> None:
25
+ if self._task is None or self._task.done():
26
+ self._task = asyncio.create_task(self.worker.run())
27
+
28
+ async def stop(self) -> None:
29
+ await self.queue.join()
30
+ self.worker.stop()
31
+ if self._task:
32
+ self._task.cancel()
33
+ try:
34
+ await self._task
35
+ except asyncio.CancelledError:
36
+ pass
37
+
38
+ def emit(self, event: Event) -> None:
39
+ self.event_log.append(event)
40
+ self.queue.put_nowait(event)
41
+
42
+ def replay(self) -> None:
43
+ events = self.event_log.read_all()
44
+ patches = self.adapter.create_patches(events, [])
45
+ self.merger.apply(patches)
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from .schema import MemoryItem, MemoryType
4
+ from .storage import JsonGoalStore, JsonMemoryStore
5
+
6
+
7
+ class MemoryRetriever:
8
+ def __init__(self, store: JsonMemoryStore, goal_store: JsonGoalStore | None = None) -> None:
9
+ self.store = store
10
+ self.goal_store = goal_store
11
+
12
+ def retrieve(
13
+ self,
14
+ query: str,
15
+ task_id: str = "default",
16
+ limit: int = 8,
17
+ types: set[MemoryType] | None = None,
18
+ ) -> list[MemoryItem]:
19
+ terms = {term.lower() for term in query.replace("/", " ").replace("_", " ").split() if term}
20
+ candidates = [item for item in self.store.list_items() if item.task_id == task_id]
21
+ if types:
22
+ candidates = [item for item in candidates if item.type in types]
23
+ ranked = sorted(candidates, key=lambda item: self._score(item, terms), reverse=True)
24
+ return [item for item in ranked if self._score(item, terms) > 0][:limit]
25
+
26
+ def context_block(self, query: str, task_id: str = "default", limit: int = 8) -> str:
27
+ blocks: list[str] = []
28
+ if self.goal_store is not None:
29
+ goal_block = self.goal_store.load().context_block()
30
+ if goal_block:
31
+ blocks.append(goal_block)
32
+ items = self.retrieve(query=query, task_id=task_id, limit=limit)
33
+ if items:
34
+ lines = ["Relevant AgentRAM notes:"]
35
+ for item in items:
36
+ files = f" files={','.join(item.related_files)}" if item.related_files else ""
37
+ lines.append(f"- [{item.type.value} confidence={item.confidence:.2f}{files}] {item.content}")
38
+ blocks.append("\n".join(lines))
39
+ return "\n\n".join(blocks)
40
+
41
+ def _score(self, item: MemoryItem, terms: set[str]) -> float:
42
+ haystack = " ".join([item.content, " ".join(item.related_files), item.type.value]).lower()
43
+ lexical = sum(1 for term in terms if term in haystack)
44
+ priority = {
45
+ MemoryType.TASK_STATE: 1.0,
46
+ MemoryType.DECISION: 0.9,
47
+ MemoryType.OPEN_QUESTION: 0.8,
48
+ MemoryType.FILE_SUMMARY: 0.7,
49
+ MemoryType.FACT: 0.6,
50
+ }[item.type]
51
+ return lexical + priority + item.confidence