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,163 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import subprocess
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from .adapter import HeuristicMemoryAdapter, OllamaMemoryAdapter
11
+ from .config import anthropic_from_env, default_ram_root, env_value, merged_env, openai_compatible_from_env
12
+ from .orchestrator import AgentRAM
13
+ from .retriever import MemoryRetriever
14
+ from .schema import Event, EventType
15
+ from .storage import JsonGoalStore, JsonMemoryStore
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class WrapperResult:
20
+ prompt: str
21
+ stdout: str
22
+ stderr: str
23
+ returncode: int
24
+
25
+
26
+ def build_injected_prompt(user_prompt: str, context_block: str) -> str:
27
+ if not context_block:
28
+ return user_prompt
29
+ return (
30
+ "Relevant AgentRAM notes:\n"
31
+ "Use only when relevant. Prefer current files over RAM if conflict.\n\n"
32
+ f"{context_block}\n\n"
33
+ "User request:\n"
34
+ f"{user_prompt}"
35
+ )
36
+
37
+
38
+ def create_adapter(kind: str, env: dict[str, str]):
39
+ if kind == "heuristic":
40
+ return HeuristicMemoryAdapter()
41
+ if kind == "ollama":
42
+ return OllamaMemoryAdapter(
43
+ model=env_value(env, "OLLAMA_MODEL", default="qwen2.5:7b") or "qwen2.5:7b",
44
+ host=env_value(env, "OLLAMA_HOST", default="http://localhost:11434") or "http://localhost:11434",
45
+ timeout_seconds=float(env_value(env, "AGENTRAM_TIMEOUT", default="120") or "120"),
46
+ )
47
+ if kind == "openai-compatible":
48
+ return openai_compatible_from_env()
49
+ if kind == "anthropic":
50
+ return anthropic_from_env()
51
+ raise ValueError(f"unknown adapter: {kind}")
52
+
53
+
54
+ async def run_wrapper(
55
+ user_prompt: str,
56
+ *,
57
+ task_id: str = "codex",
58
+ ram_root: str | None = None,
59
+ adapter_kind: str = "heuristic",
60
+ command: list[str] | None = None,
61
+ prompt_as_arg: bool = False,
62
+ dry_run: bool = False,
63
+ ) -> WrapperResult:
64
+ merged = merged_env()
65
+ resolved_ram_root = Path(ram_root) if ram_root else default_ram_root()
66
+ store = JsonMemoryStore(resolved_ram_root / "memory.json")
67
+ goal_store = JsonGoalStore(resolved_ram_root / "goal_state.json")
68
+ context_block = MemoryRetriever(store, goal_store).context_block(user_prompt, task_id=task_id, limit=10)
69
+ injected_prompt = build_injected_prompt(user_prompt, context_block)
70
+
71
+ adapter = create_adapter(adapter_kind, merged)
72
+ ram = AgentRAM(root=resolved_ram_root, adapter=adapter)
73
+ await ram.start()
74
+ ram.emit(Event(type=EventType.USER_MESSAGE, payload={"content": user_prompt}, task_id=task_id))
75
+
76
+ stdout = ""
77
+ stderr = ""
78
+ returncode = 0
79
+ if dry_run or not command:
80
+ stdout = injected_prompt
81
+ else:
82
+ run_command = command + ([injected_prompt] if prompt_as_arg else [])
83
+ completed = subprocess.run(
84
+ run_command,
85
+ input=None if prompt_as_arg else injected_prompt,
86
+ text=True,
87
+ encoding="utf-8",
88
+ errors="replace",
89
+ capture_output=True,
90
+ check=False,
91
+ )
92
+ stdout = completed.stdout or ""
93
+ stderr = completed.stderr or ""
94
+ returncode = completed.returncode
95
+ ram.emit(
96
+ Event(
97
+ type=EventType.TOOL_CALL,
98
+ payload={"command": command, "returncode": returncode},
99
+ task_id=task_id,
100
+ )
101
+ )
102
+ output = stdout.strip() or stderr.strip()
103
+ if output:
104
+ ram.emit(
105
+ Event(
106
+ type=EventType.AGENT_MESSAGE,
107
+ payload={"content": output[:4000]},
108
+ task_id=task_id,
109
+ )
110
+ )
111
+
112
+ await ram.stop()
113
+ return WrapperResult(prompt=injected_prompt, stdout=stdout, stderr=stderr, returncode=returncode)
114
+
115
+
116
+ def build_parser() -> argparse.ArgumentParser:
117
+ parser = argparse.ArgumentParser(description="Inject AgentRAM context into Codex or another CLI command.")
118
+ parser.add_argument("prompt", help="User prompt to send to Codex/command.")
119
+ parser.add_argument("--task-id", default="codex", help="AgentRAM task id.")
120
+ parser.add_argument("--ram-root", default=None, help="AgentRAM storage root. Defaults to .agentram in the current project directory.")
121
+ parser.add_argument(
122
+ "--adapter",
123
+ choices=["heuristic", "ollama", "openai-compatible", "anthropic"],
124
+ default="heuristic",
125
+ help="Memory writer adapter.",
126
+ )
127
+ parser.add_argument("--dry-run", action="store_true", help="Print injected prompt without running command.")
128
+ parser.add_argument("--prompt-as-arg", action="store_true", help="Append injected prompt as final command argument instead of stdin.")
129
+ return parser
130
+
131
+
132
+ def split_command_args(argv: list[str] | None) -> tuple[list[str] | None, list[str]]:
133
+ args = list(sys.argv[1:] if argv is None else argv)
134
+ if "--" not in args:
135
+ return args, []
136
+ separator_index = args.index("--")
137
+ return args[:separator_index], args[separator_index + 1:]
138
+
139
+
140
+ def main(argv: list[str] | None = None) -> int:
141
+ parser = build_parser()
142
+ parser_args, command = split_command_args(argv)
143
+ args = parser.parse_args(parser_args)
144
+ result = asyncio.run(
145
+ run_wrapper(
146
+ args.prompt,
147
+ task_id=args.task_id,
148
+ ram_root=args.ram_root,
149
+ adapter_kind=args.adapter,
150
+ command=command or None,
151
+ prompt_as_arg=args.prompt_as_arg,
152
+ dry_run=args.dry_run,
153
+ )
154
+ )
155
+ if result.stdout:
156
+ print(result.stdout, end="" if result.stdout.endswith("\n") else "\n")
157
+ if result.stderr:
158
+ print(result.stderr, file=sys.stderr, end="" if result.stderr.endswith("\n") else "\n")
159
+ return result.returncode
160
+
161
+
162
+ if __name__ == "__main__":
163
+ raise SystemExit(main())
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from .adapter import AnthropicMemoryAdapter, OpenAICompatibleMemoryAdapter
7
+
8
+ DEFAULT_AGENT_MODEL_ENV_NAMES = (
9
+ "CODEX_MODEL",
10
+ "CODEX_DEFAULT_MODEL",
11
+ "CLAUDE_MODEL",
12
+ "CURSOR_MODEL",
13
+ "AGENT_MODEL",
14
+ )
15
+
16
+
17
+ DEFAULT_RAM_DIR_NAME = ".agentram"
18
+
19
+ def default_ram_root(cwd: str | Path | None = None) -> Path:
20
+ base = Path.cwd() if cwd is None else Path(cwd)
21
+ return base / DEFAULT_RAM_DIR_NAME
22
+
23
+ def load_env_file(path: str | Path = ".env") -> dict[str, str]:
24
+ env_path = Path(path)
25
+ if not env_path.exists():
26
+ return {}
27
+ values: dict[str, str] = {}
28
+ for line in env_path.read_text(encoding="utf-8").splitlines():
29
+ line = line.strip()
30
+ if not line or line.startswith("#") or "=" not in line:
31
+ continue
32
+ key, value = line.split("=", 1)
33
+ values[key.strip()] = value.strip().strip('"').strip("'")
34
+ return values
35
+
36
+
37
+ def merged_env(path: str | Path = ".env") -> dict[str, str]:
38
+ return {**os.environ, **load_env_file(path)}
39
+
40
+
41
+ def env_value(env: dict[str, str], *names: str, default: str | None = None) -> str | None:
42
+ for name in names:
43
+ value = env.get(name)
44
+ if value:
45
+ return value
46
+ return default
47
+
48
+
49
+ def openai_compatible_from_env(path: str | Path = ".env") -> OpenAICompatibleMemoryAdapter:
50
+ env = merged_env(path)
51
+ model = env_value(env, "AGENTRAM_MODEL", "Model", "MODEL", *DEFAULT_AGENT_MODEL_ENV_NAMES)
52
+ base_url = env_value(env, "OPENAI_BASE_URL", "BASE_URL")
53
+ api_key = env_value(env, "OPENAI_API_KEY", "API_KEY", "GROQ_API_KEY")
54
+ if not model:
55
+ raise ValueError(
56
+ "Missing model. Set AGENTRAM_MODEL, Model, MODEL, or current agent model env such as CODEX_MODEL in .env."
57
+ )
58
+ if not base_url:
59
+ raise ValueError("Missing base URL. Set OPENAI_BASE_URL or BASE_URL in .env.")
60
+ if not api_key:
61
+ raise ValueError("Missing API key. Set OPENAI_API_KEY, API_KEY, or GROQ_API_KEY in .env.")
62
+ return OpenAICompatibleMemoryAdapter(
63
+ model=model,
64
+ base_url=base_url,
65
+ api_key=api_key,
66
+ timeout_seconds=float(env_value(env, "AGENTRAM_TIMEOUT", default="120") or "120"),
67
+ use_response_format=(env_value(env, "AGENTRAM_RESPONSE_FORMAT", default="false") or "false").lower() == "true",
68
+ )
69
+
70
+
71
+ def anthropic_from_env(path: str | Path = ".env") -> AnthropicMemoryAdapter:
72
+ env = merged_env(path)
73
+ model = env_value(env, "ANTHROPIC_MODEL", "CLAUDE_MODEL", "AGENTRAM_MODEL", "Model", "MODEL")
74
+ api_key = env_value(env, "ANTHROPIC_API_KEY")
75
+ base_url = env_value(env, "ANTHROPIC_BASE_URL", default="https://api.anthropic.com/v1")
76
+ if not model:
77
+ raise ValueError("Missing Anthropic model. Set ANTHROPIC_MODEL, CLAUDE_MODEL, AGENTRAM_MODEL, Model, or MODEL in .env.")
78
+ if not api_key:
79
+ raise ValueError("Missing Anthropic API key. Set ANTHROPIC_API_KEY in .env.")
80
+ return AnthropicMemoryAdapter(
81
+ model=model,
82
+ base_url=base_url or "https://api.anthropic.com/v1",
83
+ api_key=api_key,
84
+ timeout_seconds=float(env_value(env, "AGENTRAM_TIMEOUT", default="120") or "120"),
85
+ max_tokens=int(env_value(env, "AGENTRAM_MAX_TOKENS", default="512") or "512"),
86
+ anthropic_version=env_value(env, "ANTHROPIC_VERSION", default="2023-06-01") or "2023-06-01",
87
+ )
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .adapter import HeuristicMemoryAdapter
11
+ from .config import default_ram_root
12
+ from .merger import MemoryMerger
13
+ from .schema import Event
14
+ from .storage import JsonMemoryStore, JsonlEventLog
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class DaemonState:
19
+ offset: int = 0
20
+
21
+ def to_dict(self) -> dict[str, int]:
22
+ return {"offset": self.offset}
23
+
24
+ @classmethod
25
+ def from_dict(cls, data: dict[str, Any]) -> "DaemonState":
26
+ return cls(offset=int(data.get("offset", 0)))
27
+
28
+
29
+ class AgentRAMDaemon:
30
+ def __init__(self, ram_root: str | Path | None = None) -> None:
31
+ self.ram_root = Path(ram_root) if ram_root else default_ram_root()
32
+ self.event_log = JsonlEventLog(self.ram_root / "events.jsonl")
33
+ self.memory_store = JsonMemoryStore(self.ram_root / "memory.json")
34
+ self.state_path = self.ram_root / "daemon_state.json"
35
+ self.adapter = HeuristicMemoryAdapter()
36
+ self.merger = MemoryMerger(self.memory_store)
37
+
38
+ def load_state(self) -> DaemonState:
39
+ if not self.state_path.exists():
40
+ return DaemonState()
41
+ return DaemonState.from_dict(json.loads(self.state_path.read_text(encoding="utf-8")))
42
+
43
+ def save_state(self, state: DaemonState) -> None:
44
+ self.state_path.parent.mkdir(parents=True, exist_ok=True)
45
+ self.state_path.write_text(json.dumps(state.to_dict(), indent=2), encoding="utf-8")
46
+
47
+ def ingest_new_events(self, limit: int | None = None) -> dict[str, Any]:
48
+ state = self.load_state()
49
+ events = self.event_log.read_all()
50
+ new_events = events[state.offset:]
51
+ if limit is not None:
52
+ new_events = new_events[:limit]
53
+ patches = self.adapter.create_patches(new_events, self.memory_store.list_items())
54
+ memory_items = self.merger.apply(patches)
55
+ next_offset = state.offset + len(new_events)
56
+ self.save_state(DaemonState(offset=next_offset))
57
+ return {
58
+ "read_offset": state.offset,
59
+ "write_offset": next_offset,
60
+ "event_count": len(new_events),
61
+ "patch_count": len(patches),
62
+ "memory_count": len(memory_items),
63
+ }
64
+
65
+ def replay_all(self) -> dict[str, Any]:
66
+ events = self.event_log.read_all()
67
+ patches = self.adapter.create_patches(events, [])
68
+ memory_items = self.merger.apply(patches)
69
+ self.save_state(DaemonState(offset=len(events)))
70
+ return {
71
+ "write_offset": len(events),
72
+ "event_count": len(events),
73
+ "patch_count": len(patches),
74
+ "memory_count": len(memory_items),
75
+ }
76
+
77
+ def run_forever(self, interval_seconds: float = 1.0, once: bool = False) -> None:
78
+ while True:
79
+ result = self.ingest_new_events()
80
+ print(json.dumps(result, ensure_ascii=False), flush=True)
81
+ if once:
82
+ return
83
+ time.sleep(interval_seconds)
84
+
85
+
86
+ def build_parser() -> argparse.ArgumentParser:
87
+ parser = argparse.ArgumentParser(description="AgentRAM background event ingestion daemon.")
88
+ parser.add_argument("--ram-root", default=None, help="AgentRAM storage root. Defaults to .agentram in the current project directory.")
89
+ parser.add_argument("--interval", type=float, default=1.0, help="Polling interval in seconds.")
90
+ parser.add_argument("--once", action="store_true", help="Ingest pending events once and exit.")
91
+ parser.add_argument("--replay-all", action="store_true", help="Replay all events and reset daemon offset.")
92
+ return parser
93
+
94
+
95
+ def main(argv: list[str] | None = None) -> int:
96
+ args = build_parser().parse_args(argv)
97
+ daemon = AgentRAMDaemon(ram_root=args.ram_root)
98
+ if args.replay_all:
99
+ print(json.dumps(daemon.replay_all(), ensure_ascii=False))
100
+ return 0
101
+ daemon.run_forever(interval_seconds=args.interval, once=args.once)
102
+ return 0
103
+
104
+
105
+ if __name__ == "__main__":
106
+ raise SystemExit(main())
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ from .orchestrator import AgentRAM
7
+ from .retriever import MemoryRetriever
8
+ from .schema import Event, EventType
9
+
10
+
11
+ async def async_main() -> None:
12
+ ram = AgentRAM(root=Path(".agentram_demo"))
13
+ await ram.start()
14
+ ram.emit(Event(type=EventType.USER_MESSAGE, payload={"content": "Build model-agnostic AgentRAM for Codex/Claude/Cursor."}))
15
+ ram.emit(Event(type=EventType.READ_FILE, payload={"path": "agentram/orchestrator.py"}))
16
+ ram.emit(Event(type=EventType.DECISION, payload={"content": "Use Event Log as source of truth and Memory Patch as model contract."}))
17
+ ram.emit(Event(type=EventType.WRITE_FILE, payload={"path": "agentram/schema.py", "summary": "added normalized memory schema"}))
18
+ await ram.stop()
19
+
20
+ retriever = MemoryRetriever(ram.memory_store)
21
+ print(retriever.context_block("AgentRAM schema orchestrator", limit=10))
22
+
23
+
24
+ def main() -> None:
25
+ asyncio.run(async_main())
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()