agentram 0.1.39 → 0.1.43

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/agentram/cli.py CHANGED
@@ -13,7 +13,7 @@ import time
13
13
  from pathlib import Path
14
14
  from typing import Any
15
15
 
16
- from .config import default_ram_root
16
+ from .config import default_ram_root, global_agent_profile_enabled
17
17
  from .mcp_server import McpContext, call_tool
18
18
  from .orchestration import AgentModelEndpoint, MultiModelCodingOrchestrator
19
19
 
@@ -612,6 +612,7 @@ def run_textual_tui(context: McpContext) -> bool:
612
612
  modalities=["text"],
613
613
  )
614
614
  self.context.profile_store.set_agent(slot, endpoint)
615
+ set_global_agent_if_available(self.context, slot, endpoint)
615
616
  if saved_env_name:
616
617
  return f"{slot}={provider}:{model} key=.env:{saved_env_name}"
617
618
  return f"{slot}={provider}:{model}"
@@ -1248,6 +1249,16 @@ def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
1248
1249
  return f"Bound model: {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')}"
1249
1250
 
1250
1251
 
1252
+ def set_global_agent_if_available(context: McpContext, slot: str, endpoint: AgentModelEndpoint) -> bool:
1253
+ if not global_agent_profile_enabled():
1254
+ return False
1255
+ try:
1256
+ context.global_profile_store.set_agent(slot, endpoint)
1257
+ return True
1258
+ except OSError:
1259
+ return False
1260
+
1261
+
1251
1262
  def bind_agent_text(context: McpContext, options: dict[str, object]) -> str:
1252
1263
  slot = str(options.get("slot", "")).strip().lower()
1253
1264
  if slot not in {"supervisor", "main", "small"}:
@@ -1265,7 +1276,9 @@ def bind_agent_text(context: McpContext, options: dict[str, object]) -> str:
1265
1276
  modalities=["text"],
1266
1277
  )
1267
1278
  context.profile_store.set_agent(slot, endpoint)
1268
- return f"Bound {slot} agent: provider={endpoint.provider} model={endpoint.model} role={endpoint.role}"
1279
+ global_saved = set_global_agent_if_available(context, slot, endpoint)
1280
+ scope = "project+global" if global_saved else "project"
1281
+ return f"Bound {slot} agent: provider={endpoint.provider} model={endpoint.model} role={endpoint.role} scope={scope}"
1269
1282
 
1270
1283
  def workflow_text(context: McpContext) -> str:
1271
1284
  agents = context.profile_store.list_agents()
@@ -20,6 +20,19 @@ def default_ram_root(cwd: str | Path | None = None) -> Path:
20
20
  base = Path.cwd() if cwd is None else Path(cwd)
21
21
  return base / DEFAULT_RAM_DIR_NAME
22
22
 
23
+ def global_agent_profile_path() -> Path:
24
+ override = os.getenv("AGENTRAM_GLOBAL_PROFILE_PATH")
25
+ if override:
26
+ return Path(override)
27
+ base = os.getenv("APPDATA")
28
+ if base:
29
+ return Path(base) / "AgentRAM" / "agent_profile.jsonl"
30
+ return Path.home() / ".agentram" / "agent_profile.jsonl"
31
+
32
+ def global_agent_profile_enabled() -> bool:
33
+ value = os.getenv("AGENTRAM_DISABLE_GLOBAL_PROFILE", "").strip().lower()
34
+ return value not in {"1", "true", "yes", "on"}
35
+
23
36
  def load_env_file(path: str | Path = ".env") -> dict[str, str]:
24
37
  env_path = Path(path)
25
38
  if not env_path.exists():
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  from typing import Any
10
10
 
11
11
  from .adapter import HeuristicMemoryAdapter
12
- from .config import default_ram_root
12
+ from .config import default_ram_root, global_agent_profile_enabled, global_agent_profile_path
13
13
  from .merger import MemoryMerger
14
14
  from .orchestration import AgentModelEndpoint, CodingOrchestrationRequest, MultiModelCodingOrchestrator
15
15
  from .retriever import MemoryRetriever
@@ -46,7 +46,12 @@ class McpContext:
46
46
 
47
47
  @property
48
48
  def profile_store(self) -> JsonAgentProfileStore:
49
- return JsonAgentProfileStore(self.ram_root / "agent_profile.jsonl")
49
+ fallback = global_agent_profile_path() if global_agent_profile_enabled() else None
50
+ return JsonAgentProfileStore(self.ram_root / "agent_profile.jsonl", fallback_path=fallback)
51
+
52
+ @property
53
+ def global_profile_store(self) -> JsonAgentProfileStore:
54
+ return JsonAgentProfileStore(global_agent_profile_path())
50
55
 
51
56
  @property
52
57
  def merger(self) -> MemoryMerger:
@@ -3,12 +3,12 @@
3
3
  from concurrent.futures import ThreadPoolExecutor, as_completed
4
4
  from dataclasses import dataclass, field
5
5
  import json
6
- import os
7
- from pathlib import Path
8
- import re
9
- import shlex
10
- import subprocess
11
- import tempfile
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+ import shlex
10
+ import subprocess
11
+ import tempfile
12
12
  import urllib.error
13
13
  import urllib.request
14
14
  from typing import Any, Callable
@@ -46,7 +46,7 @@ def normalize_provider_name(provider: str) -> str:
46
46
  return PROVIDER_ALIASES.get(normalized, normalized)
47
47
 
48
48
 
49
- def split_cli_command(command: str) -> list[str]:
49
+ def split_cli_command(command: str) -> list[str]:
50
50
  cleaned = command.strip().strip('"')
51
51
  if not cleaned:
52
52
  return []
@@ -59,23 +59,23 @@ def split_cli_command(command: str) -> list[str]:
59
59
  program = cleaned[:end]
60
60
  rest = cleaned[end:].strip()
61
61
  return [program, *shlex.split(rest)] if rest else [program]
62
- return shlex.split(cleaned)
63
-
64
- def env_value(name: str) -> str:
65
- value = os.getenv(name, "")
66
- if value:
67
- return value
68
- env_path = Path(".env")
69
- if not env_path.exists():
70
- return ""
71
- for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
72
- stripped = line.strip()
73
- if not stripped or stripped.startswith("#") or "=" not in stripped:
74
- continue
75
- key, raw_value = stripped.split("=", 1)
76
- if key.strip() == name:
77
- return raw_value.strip().strip('"').strip("'")
78
- return ""
62
+ return shlex.split(cleaned)
63
+
64
+ def env_value(name: str) -> str:
65
+ value = os.getenv(name, "")
66
+ if value:
67
+ return value
68
+ env_path = Path(".env")
69
+ if not env_path.exists():
70
+ return ""
71
+ for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
72
+ stripped = line.strip()
73
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
74
+ continue
75
+ key, raw_value = stripped.split("=", 1)
76
+ if key.strip() == name:
77
+ return raw_value.strip().strip('"').strip("'")
78
+ return ""
79
79
 
80
80
 
81
81
  @dataclass(frozen=True)
@@ -182,7 +182,7 @@ class MultiModelCodingOrchestrator:
182
182
  raise ValueError(f"Unsupported provider for execution: {endpoint.provider}")
183
183
 
184
184
  def _call_openai_compatible(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
185
- api_key = env_value(endpoint.api_key_env) if endpoint.api_key_env else ""
185
+ api_key = env_value(endpoint.api_key_env) if endpoint.api_key_env else ""
186
186
  if not endpoint.base_url:
187
187
  raise ValueError("base_url is required")
188
188
  if endpoint.api_key_env and not api_key:
@@ -204,20 +204,20 @@ class MultiModelCodingOrchestrator:
204
204
  if error.code == 404:
205
205
  hint += " | base_url should include /v1 but not /chat/completions; verify provider URL and model name"
206
206
  raise RuntimeError(f"{hint} | response={body[:500]}") from error
207
- try:
208
- data = json.loads(raw)
209
- except json.JSONDecodeError as error:
210
- mixed_json = parse_openai_mixed_json(raw)
211
- if mixed_json:
212
- return extract_openai_message(mixed_json)
213
- streamed = parse_openai_sse(raw)
214
- if streamed:
215
- return streamed
216
- raise RuntimeError(f"OpenAI-compatible returned non-JSON at {url} | response={raw[:500]}") from error
217
- return extract_openai_message(data)
207
+ try:
208
+ data = json.loads(raw)
209
+ except json.JSONDecodeError as error:
210
+ mixed_json = parse_openai_mixed_json(raw)
211
+ if mixed_json:
212
+ return extract_openai_message(mixed_json)
213
+ streamed = parse_openai_sse(raw)
214
+ if streamed:
215
+ return streamed
216
+ raise RuntimeError(f"OpenAI-compatible returned non-JSON at {url} | response={raw[:500]}") from error
217
+ return extract_openai_message(data)
218
218
 
219
219
  def _call_anthropic(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
220
- api_key = env_value(endpoint.api_key_env) if endpoint.api_key_env else ""
220
+ api_key = env_value(endpoint.api_key_env) if endpoint.api_key_env else ""
221
221
  if endpoint.api_key_env and not api_key:
222
222
  raise ValueError(f"missing API key env: {endpoint.api_key_env}")
223
223
  base_url = endpoint.base_url.rstrip("/") if endpoint.base_url else "https://api.anthropic.com/v1"
@@ -252,13 +252,13 @@ class MultiModelCodingOrchestrator:
252
252
  ]
253
253
  )
254
254
  try:
255
- process = subprocess.run(
256
- [*args, "-p", subagent_prompt],
257
- check=False,
258
- stdin=subprocess.DEVNULL,
259
- capture_output=True,
260
- encoding="utf-8",
261
- timeout=300,
255
+ process = subprocess.run(
256
+ [*args, "-p", subagent_prompt],
257
+ check=False,
258
+ stdin=subprocess.DEVNULL,
259
+ capture_output=True,
260
+ encoding="utf-8",
261
+ timeout=300,
262
262
  )
263
263
  except FileNotFoundError as error:
264
264
  raise RuntimeError(f"Claude Code command not found: {args[0]}. Install Claude Code CLI or set base_url to the full command path.") from error
@@ -275,45 +275,46 @@ class MultiModelCodingOrchestrator:
275
275
  raise ValueError("codex command is required")
276
276
  if len(args) == 1:
277
277
  args.append("exec")
278
- codex_prompt = "\n".join(
279
- [
280
- f"You are AgentRAM {endpoint.role} agent running through Codex CLI.",
278
+ args = ensure_codex_skip_git_repo_check(args)
279
+ codex_prompt = "\n".join(
280
+ [
281
+ f"You are AgentRAM {endpoint.role} agent running through Codex CLI.",
281
282
  f"Model hint: {endpoint.model}",
282
283
  "If role is coder/main, inspect and edit files as needed in the current repo. If role is memory/small, do not edit files; write notes only.",
283
284
  "Return concise result, changed files, tests/verification, and blockers.",
284
285
  "",
285
- prompt,
286
- ]
287
- )
288
- prompt_path = ""
289
- try:
290
- with tempfile.NamedTemporaryFile(
291
- "w",
292
- encoding="utf-8",
293
- delete=False,
294
- dir=os.getcwd(),
295
- prefix=".agentram_codex_prompt_",
296
- suffix=".txt",
297
- ) as prompt_file:
298
- prompt_file.write(codex_prompt)
299
- prompt_path = prompt_file.name
300
- short_prompt = f"Read AgentRAM task from file and execute it: {prompt_path}"
301
- process = subprocess.run(
302
- [*args, short_prompt],
303
- check=False,
304
- stdin=subprocess.DEVNULL,
305
- capture_output=True,
306
- encoding="utf-8",
307
- timeout=int(os.getenv("AGENTRAM_CLI_TIMEOUT", "900")),
308
- )
286
+ prompt,
287
+ ]
288
+ )
289
+ prompt_path = ""
290
+ try:
291
+ with tempfile.NamedTemporaryFile(
292
+ "w",
293
+ encoding="utf-8",
294
+ delete=False,
295
+ dir=os.getcwd(),
296
+ prefix=".agentram_codex_prompt_",
297
+ suffix=".txt",
298
+ ) as prompt_file:
299
+ prompt_file.write(codex_prompt)
300
+ prompt_path = prompt_file.name
301
+ short_prompt = f"Read AgentRAM task from file and execute it: {prompt_path}"
302
+ process = subprocess.run(
303
+ [*args, short_prompt],
304
+ check=False,
305
+ stdin=subprocess.DEVNULL,
306
+ capture_output=True,
307
+ encoding="utf-8",
308
+ timeout=int(os.getenv("AGENTRAM_CLI_TIMEOUT", "900")),
309
+ )
309
310
  except FileNotFoundError as error:
310
- raise RuntimeError(f"Codex CLI command not found: {args[0]}. Install Codex CLI or set base_url to the full command path.") from error
311
- finally:
312
- if prompt_path:
313
- try:
314
- os.remove(prompt_path)
315
- except OSError:
316
- pass
311
+ raise RuntimeError(f"Codex CLI command not found: {args[0]}. Install Codex CLI or set base_url to the full command path.") from error
312
+ finally:
313
+ if prompt_path:
314
+ try:
315
+ os.remove(prompt_path)
316
+ except OSError:
317
+ pass
317
318
  output = (process.stdout or "").strip()
318
319
  error = (process.stderr or "").strip()
319
320
  if process.returncode != 0:
@@ -321,7 +322,15 @@ class MultiModelCodingOrchestrator:
321
322
  return output or error
322
323
 
323
324
 
324
- def parse_openai_sse(raw: str) -> str:
325
+ def ensure_codex_skip_git_repo_check(args: list[str]) -> list[str]:
326
+ if "--skip-git-repo-check" in args:
327
+ return args
328
+ if len(args) >= 2 and args[1] == "exec":
329
+ return [args[0], args[1], "--skip-git-repo-check", *args[2:]]
330
+ return args
331
+
332
+
333
+ def parse_openai_sse(raw: str) -> str:
325
334
  parts: list[str] = []
326
335
  for line in raw.splitlines():
327
336
  stripped = line.strip()
@@ -348,39 +357,39 @@ def parse_openai_sse(raw: str) -> str:
348
357
  message = choice.get("message", {})
349
358
  if isinstance(message, dict) and message.get("content"):
350
359
  parts.append(str(message.get("content")))
351
- return strip_thinking_blocks("".join(parts)).strip()
352
-
353
- def parse_openai_mixed_json(raw: str) -> dict[str, Any]:
354
- stripped = raw.strip()
355
- if not stripped.startswith("{"):
356
- return {}
357
- for marker in ("\ndata:", "\r\ndata:"):
358
- if marker not in stripped:
359
- continue
360
- candidate = stripped.split(marker, 1)[0].strip()
361
- try:
362
- value = json.loads(candidate)
363
- except json.JSONDecodeError:
364
- return {}
365
- return value if isinstance(value, dict) else {}
366
- return {}
367
-
368
- def extract_openai_message(data: dict[str, Any]) -> str:
369
- choices = data.get("choices", [])
370
- if not choices or not isinstance(choices, list):
371
- return ""
372
- first = choices[0]
373
- if not isinstance(first, dict):
374
- return ""
375
- message = first.get("message", {})
376
- if isinstance(message, dict):
377
- return strip_thinking_blocks(str(message.get("content", ""))).strip()
378
- delta = first.get("delta", {})
379
- if isinstance(delta, dict):
380
- return strip_thinking_blocks(str(delta.get("content", ""))).strip()
381
- return ""
382
-
383
- def strip_thinking_blocks(text: str) -> str:
360
+ return strip_thinking_blocks("".join(parts)).strip()
361
+
362
+ def parse_openai_mixed_json(raw: str) -> dict[str, Any]:
363
+ stripped = raw.strip()
364
+ if not stripped.startswith("{"):
365
+ return {}
366
+ for marker in ("\ndata:", "\r\ndata:"):
367
+ if marker not in stripped:
368
+ continue
369
+ candidate = stripped.split(marker, 1)[0].strip()
370
+ try:
371
+ value = json.loads(candidate)
372
+ except json.JSONDecodeError:
373
+ return {}
374
+ return value if isinstance(value, dict) else {}
375
+ return {}
376
+
377
+ def extract_openai_message(data: dict[str, Any]) -> str:
378
+ choices = data.get("choices", [])
379
+ if not choices or not isinstance(choices, list):
380
+ return ""
381
+ first = choices[0]
382
+ if not isinstance(first, dict):
383
+ return ""
384
+ message = first.get("message", {})
385
+ if isinstance(message, dict):
386
+ return strip_thinking_blocks(str(message.get("content", ""))).strip()
387
+ delta = first.get("delta", {})
388
+ if isinstance(delta, dict):
389
+ return strip_thinking_blocks(str(delta.get("content", ""))).strip()
390
+ return ""
391
+
392
+ def strip_thinking_blocks(text: str) -> str:
384
393
  text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL | re.IGNORECASE)
385
394
  text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE)
386
395
  text = re.sub(r"^\s*</?(thinking|think)>\s*", "", text, flags=re.IGNORECASE)
@@ -76,8 +76,9 @@ class JsonGoalStore:
76
76
 
77
77
 
78
78
  class JsonAgentProfileStore:
79
- def __init__(self, path: str | Path) -> None:
79
+ def __init__(self, path: str | Path, fallback_path: str | Path | None = None) -> None:
80
80
  self.path = Path(path)
81
+ self.fallback_path = Path(fallback_path) if fallback_path else None
81
82
  self.path.parent.mkdir(parents=True, exist_ok=True)
82
83
 
83
84
  def default_payload(self) -> dict[str, object]:
@@ -93,18 +94,33 @@ class JsonAgentProfileStore:
93
94
  data.setdefault("agents", {})
94
95
  return data
95
96
 
97
+ def is_empty_payload(self, data: dict[str, object]) -> bool:
98
+ return not data.get("endpoints") and not data.get("router") and not data.get("agents")
99
+
100
+ def load_fallback(self) -> dict[str, object] | None:
101
+ if not self.fallback_path or self.fallback_path == self.path or not self.fallback_path.exists():
102
+ return None
103
+ lines = [line.strip() for line in self.fallback_path.read_text(encoding="utf-8").splitlines() if line.strip()]
104
+ if not lines:
105
+ return None
106
+ data = self.normalize_payload(json.loads(lines[-1]))
107
+ return data if not self.is_empty_payload(data) else None
108
+
96
109
  def load(self) -> dict[str, object]:
97
110
  if self.path.exists():
98
111
  lines = [line.strip() for line in self.path.read_text(encoding="utf-8").splitlines() if line.strip()]
99
112
  if not lines:
100
- return self.default_payload()
101
- return self.normalize_payload(json.loads(lines[-1]))
113
+ return self.load_fallback() or self.default_payload()
114
+ data = self.normalize_payload(json.loads(lines[-1]))
115
+ if self.is_empty_payload(data):
116
+ return self.load_fallback() or data
117
+ return data
102
118
  legacy = self.legacy_json_path()
103
119
  if legacy.exists() and legacy != self.path:
104
120
  data = self.normalize_payload(json.loads(legacy.read_text(encoding="utf-8")))
105
121
  self.write_snapshot(data)
106
122
  return data
107
- return self.default_payload()
123
+ return self.load_fallback() or self.default_payload()
108
124
 
109
125
  def write_snapshot(self, payload: dict[str, object]) -> None:
110
126
  self.path.parent.mkdir(parents=True, exist_ok=True)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentram",
3
- "version": "0.1.39",
3
+ "version": "0.1.43",
4
4
  "description": "Async, model-agnostic Working RAM for coding agents.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/trancongnghia/AGENT_RAM#readme",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentram"
7
- version = "0.1.39"
7
+ version = "0.1.43"
8
8
  description = "Async, model-agnostic RAM layer for agentic coding tools."
9
9
  readme = "README.md"
10
10
  license = "MIT"