agentram 0.1.20 → 0.1.29

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
@@ -1,10 +1,11 @@
1
- from __future__ import annotations
1
+ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import asyncio
5
5
  from concurrent.futures import ThreadPoolExecutor
6
6
  import json
7
7
  import os
8
+ import re
8
9
  import shlex
9
10
  import shutil
10
11
  import textwrap
@@ -16,6 +17,51 @@ from .config import default_ram_root
16
17
  from .mcp_server import McpContext, call_tool
17
18
  from .orchestration import AgentModelEndpoint, MultiModelCodingOrchestrator
18
19
 
20
+
21
+ ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
22
+ RAW_KEY_PREFIXES = ("sk-", "sk_", "sk-proj-", "gsk_", "xai-", "AIza")
23
+
24
+
25
+ def looks_like_raw_api_key(value: str) -> bool:
26
+ stripped = value.strip()
27
+ if not stripped:
28
+ return False
29
+ if stripped.startswith(RAW_KEY_PREFIXES):
30
+ return True
31
+ return not bool(ENV_NAME_PATTERN.fullmatch(stripped))
32
+
33
+
34
+ def update_env_file_value(path: Path, key: str, value: str) -> None:
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
37
+ assignment = f"{key}={value}"
38
+ updated = False
39
+ output: list[str] = []
40
+ for line in lines:
41
+ if line.strip().startswith(f"{key}="):
42
+ output.append(assignment)
43
+ updated = True
44
+ else:
45
+ output.append(line)
46
+ if not updated:
47
+ if output and output[-1].strip():
48
+ output.append("")
49
+ output.append("# Added by AgentRAM TUI setup")
50
+ output.append(assignment)
51
+ path.write_text("\n".join(output) + "\n", encoding="utf-8")
52
+
53
+
54
+ def resolve_api_key_env(slot: str, value: str) -> tuple[str, str | None]:
55
+ stripped = value.strip()
56
+ if not stripped:
57
+ return "", None
58
+ if looks_like_raw_api_key(stripped):
59
+ env_name = f"AGENTRAM_{slot.upper()}_API_KEY"
60
+ update_env_file_value(Path(".env"), env_name, stripped)
61
+ os.environ[env_name] = stripped
62
+ return env_name, env_name
63
+ return stripped, None
64
+
19
65
  SUBAGENTS: dict[str, str] = {
20
66
  "memory": "Summarize important facts, decisions, files, risks, and next tasks into AgentRAM events. Do not edit code.",
21
67
  "planner": "Break the coding request into small ordered steps. Check AgentRAM goal drift before scope changes.",
@@ -55,6 +101,135 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
55
101
  ]
56
102
 
57
103
 
104
+
105
+ CLAUDE_COMPAT_COMMANDS: list[tuple[str, str]] = [
106
+ ("/add-dir <path>", "Claude: add a working directory for file access"),
107
+ ("/advisor [model|off]", "Claude: enable or disable advisor model"),
108
+ ("/autofix-pr [prompt]", "Claude: cloud session fixes PR CI/review comments"),
109
+ ("/background [prompt]", "Claude: detach session as background agent"),
110
+ ("/bg [prompt]", "Claude alias: /background"),
111
+ ("/batch <instruction>", "Claude workflow: parallel codebase change"),
112
+ ("/branch [name]", "Claude: fork conversation branch"),
113
+ ("/btw <question>", "Claude: side question without history"),
114
+ ("/cd <path>", "Claude: move session to another directory"),
115
+ ("/chrome", "Claude: configure Claude in Chrome"),
116
+ ("/claude-api [migrate|managed-agents-onboard]", "Claude skill: API reference and migration"),
117
+ ("/reset [name]", "Claude alias: /clear"),
118
+ ("/new [name]", "Claude alias: /clear"),
119
+ ("/code-review [level] [--fix] [--comment] [target]", "Claude skill: review current diff"),
120
+ ("/color [color|default]", "Claude: prompt bar color"),
121
+ ("/compact [instructions]", "Claude: summarize context to free space"),
122
+ ("/config [key=value ...]", "Claude: settings interface"),
123
+ ("/settings [key=value ...]", "Claude alias: /config"),
124
+ ("/context [all]", "Claude: visualize context usage"),
125
+ ("/copy [N]", "Claude: copy assistant response"),
126
+ ("/cost", "Claude alias: /usage"),
127
+ ("/dataviz [request]", "Claude skill: chart/dashboard design guidance"),
128
+ ("/debug [description]", "Claude skill: enable and inspect debug logs"),
129
+ ("/deep-research <question>", "Claude workflow: web research report"),
130
+ ("/design-login", "Claude: authorize design-system access"),
131
+ ("/design-sync [hint]", "Claude skill: sync React design system"),
132
+ ("/desktop", "Claude: continue in desktop app"),
133
+ ("/app", "Claude alias: /desktop"),
134
+ ("/diff", "Claude: interactive diff viewer"),
135
+ ("/doctor", "Claude: diagnose install/runtime"),
136
+ ("/effort [level|auto]", "Claude: set reasoning effort"),
137
+ ("/export [filename]", "Claude: export conversation"),
138
+ ("/fast [on|off]", "Claude: toggle fast mode"),
139
+ ("/feedback [report]", "Claude: send feedback"),
140
+ ("/bug [report]", "Claude alias: /feedback"),
141
+ ("/share [report]", "Claude alias: /feedback"),
142
+ ("/fewer-permission-prompts", "Claude skill: reduce permission prompts"),
143
+ ("/focus", "Claude: toggle focus view"),
144
+ ("/fork <directive>", "Claude: forked background subagent"),
145
+ ("/heapdump", "Claude: write heap snapshot"),
146
+ ("/hooks", "Claude: view hook config"),
147
+ ("/ide", "Claude: manage IDE integration"),
148
+ ("/insights", "Claude: analyze Claude Code sessions"),
149
+ ("/install-github-app", "Claude: install GitHub app"),
150
+ ("/install-slack-app", "Claude: install Slack app"),
151
+ ("/keybindings", "Claude: open keyboard shortcut file"),
152
+ ("/login", "Claude: sign in"),
153
+ ("/logout", "Claude: sign out"),
154
+ ("/loop [interval] [prompt]", "Claude skill: repeated prompt loop"),
155
+ ("/proactive [interval] [prompt]", "Claude alias: /loop"),
156
+ ("/mcp [reconnect|enable|disable]", "Claude: manage MCP servers"),
157
+ ("/memory", "Claude: edit CLAUDE.md memory"),
158
+ ("/mobile", "Claude: show mobile app QR"),
159
+ ("/ios", "Claude alias: /mobile"),
160
+ ("/android", "Claude alias: /mobile"),
161
+ ("/model [model]", "Claude: switch model"),
162
+ ("/passes", "Claude: share Claude Code pass"),
163
+ ("/permissions", "Claude: manage tool permissions"),
164
+ ("/allowed-tools", "Claude alias: /permissions"),
165
+ ("/plan [description]", "Claude: enter plan mode"),
166
+ ("/plugin [subcommand]", "Claude: manage plugins"),
167
+ ("/powerup", "Claude: feature lessons"),
168
+ ("/pr-comments [PR]", "Claude legacy: PR comments"),
169
+ ("/privacy-settings", "Claude: privacy settings"),
170
+ ("/radio", "Claude: Claude FM radio"),
171
+ ("/recap", "Claude: one-line session summary"),
172
+ ("/release-notes", "Claude: changelog picker"),
173
+ ("/reload-plugins [--force]", "Claude: reload plugins"),
174
+ ("/reload-skills", "Claude: reload skills and commands"),
175
+ ("/remote-control", "Claude: remote control from claude.ai"),
176
+ ("/rc", "Claude alias: /remote-control"),
177
+ ("/remote-env", "Claude: cloud agent environment"),
178
+ ("/rename [name]", "Claude: rename session"),
179
+ ("/resume [session]", "Claude: resume conversation"),
180
+ ("/continue [session]", "Claude alias: /resume"),
181
+ ("/review [PR]", "Claude: review GitHub PR"),
182
+ ("/rewind", "Claude: rewind conversation/code"),
183
+ ("/checkpoint", "Claude alias: /rewind"),
184
+ ("/undo", "Claude alias: /rewind"),
185
+ ("/run", "Claude skill: run app and verify change"),
186
+ ("/run-skill-generator", "Claude skill: generate run/verify skill"),
187
+ ("/sandbox", "Claude: toggle sandbox mode"),
188
+ ("/schedule [description]", "Claude: cloud routine"),
189
+ ("/routines [description]", "Claude alias: /schedule"),
190
+ ("/scroll-speed", "Claude: adjust mouse wheel speed"),
191
+ ("/security-review", "Claude: security review current diff"),
192
+ ("/setup-bedrock", "Claude: Bedrock setup wizard"),
193
+ ("/setup-vertex", "Claude: Vertex setup wizard"),
194
+ ("/simplify [target]", "Claude skill: cleanup review and fixes"),
195
+ ("/skills", "Claude: list skills"),
196
+ ("/stats", "Claude alias: /usage"),
197
+ ("/statusline", "Claude: configure status line"),
198
+ ("/stickers", "Claude: order stickers"),
199
+ ("/stop", "Claude: stop background session"),
200
+ ("/tasks", "Claude: view background work"),
201
+ ("/bashes", "Claude alias: /tasks"),
202
+ ("/team-onboarding", "Claude: generate team onboarding"),
203
+ ("/teleport", "Claude: pull web session into terminal"),
204
+ ("/tp", "Claude alias: /teleport"),
205
+ ("/terminal-setup", "Claude: terminal keybindings setup"),
206
+ ("/theme", "Claude: change color theme"),
207
+ ("/tui [default|fullscreen]", "Claude: switch renderer"),
208
+ ("/ultraplan <prompt>", "Claude: draft cloud plan"),
209
+ ("/ultrareview [PR]", "Claude: deep cloud review"),
210
+ ("/upgrade", "Claude: open upgrade page"),
211
+ ("/usage", "Claude: show usage and cost"),
212
+ ("/usage-credits", "Claude: configure usage credits"),
213
+ ("/verify", "Claude skill: verify app behavior"),
214
+ ("/vim", "Claude legacy: use /config for editor mode"),
215
+ ("/voice [hold|tap|off]", "Claude: voice dictation"),
216
+ ("/web-setup", "Claude: connect GitHub for web"),
217
+ ("/workflows", "Claude: workflow progress view"),
218
+ ]
219
+
220
+ CLAUDE_COMPAT_NAMES = {command.split()[0] for command, _ in CLAUDE_COMPAT_COMMANDS}
221
+
222
+ def slash_entries() -> list[tuple[str, str]]:
223
+ seen: set[str] = set()
224
+ entries: list[tuple[str, str]] = []
225
+ for command, description in [*SLASH_COMMANDS, *CLAUDE_COMPAT_COMMANDS]:
226
+ name = command.split()[0]
227
+ if name in seen:
228
+ continue
229
+ seen.add(name)
230
+ entries.append((command, description))
231
+ return entries
232
+
58
233
  SLASH_HELP = """Slash commands:
59
234
  /help Show commands
60
235
  /init Create .agentram files
@@ -307,6 +482,8 @@ def run_textual_tui(context: McpContext) -> bool:
307
482
  self.prompt_history: list[str] = []
308
483
  self.history_index: int | None = None
309
484
  self.last_ctrl_c_at = 0.0
485
+ self.setup_slots = ("supervisor", "main", "small")
486
+ self.setup_index = 0
310
487
 
311
488
  def compose(self) -> ComposeResult:
312
489
  yield Header(show_clock=True)
@@ -314,18 +491,20 @@ def run_textual_tui(context: McpContext) -> bool:
314
491
  yield Static(id="sidebar")
315
492
  with Vertical(id="main"):
316
493
  with Vertical(id="setup"):
317
- yield Static("Model setup - configure each agent separately", id="setup-title")
494
+ yield Static("Model setup - 1/3 Supervisor", id="setup-title")
318
495
  for slot, label, default_model in [
319
- ("supervisor", "Supervisor", ""),
320
- ("main", "Main coding", ""),
321
- ("small", "Small memory", ""),
496
+ ("supervisor", "1. Supervisor", ""),
497
+ ("main", "2. Main coding", ""),
498
+ ("small", "3. Small memory", ""),
322
499
  ]:
323
- with Vertical(classes="agent-setup"):
500
+ with Vertical(id=f"setup-{slot}-panel", classes="agent-setup"):
324
501
  yield Static(f"{label} agent")
325
502
  yield Select(
326
503
  [
327
504
  ("OpenAI Compatible", "openai-compatible"),
328
505
  ("Anthropic Compatible", "anthropic"),
506
+ ("Claude Code CLI", "claude-code"),
507
+ ("Codex CLI", "codex-cli"),
329
508
  ("Custom", "custom"),
330
509
  ],
331
510
  value="openai-compatible",
@@ -334,14 +513,16 @@ def run_textual_tui(context: McpContext) -> bool:
334
513
  )
335
514
  yield Input(placeholder="Custom provider if preset=Custom", id=f"setup-{slot}-custom-provider", classes="setup-input")
336
515
  yield Input(value=default_model, placeholder="Model name", id=f"setup-{slot}-model", classes="setup-input")
337
- yield Input(placeholder="Base URL, e.g. http://localhost:20128/v1", id=f"setup-{slot}-base-url", classes="setup-input")
338
- yield Input(value="OPENAI_API_KEY", placeholder="API key env name, not raw key", id=f"setup-{slot}-api-key-env", classes="setup-input")
516
+ yield Input(placeholder="Base URL or CLI command, e.g. http://localhost:20128/v1 / claude / codex exec", id=f"setup-{slot}-base-url", classes="setup-input")
517
+ yield Input(value="OPENAI_API_KEY", placeholder="API key or env name", id=f"setup-{slot}-api-key-env", classes="setup-input")
339
518
  with Horizontal(id="setup-actions"):
340
- yield Button("Save setup", id="setup-save", variant="primary")
519
+ yield Button("Back", id="setup-prev")
520
+ yield Button("Next", id="setup-next", variant="primary")
521
+ yield Button("Save setup", id="setup-save", variant="success")
341
522
  yield Button("Skip", id="setup-skip")
342
523
  yield RichLog(id="chat", wrap=True, highlight=True, markup=False, auto_scroll=True)
343
524
  yield Static(id="palette")
344
- yield Input(placeholder="Prompt ho?c /command. Mouse wheel scroll trong khung chat.", id="input")
525
+ yield Input(placeholder="Prompt or /command. Mouse wheel scroll in chat.", id="input")
345
526
  yield Footer()
346
527
 
347
528
  def on_mount(self) -> None:
@@ -349,10 +530,11 @@ def run_textual_tui(context: McpContext) -> bool:
349
530
  self.chat.write("system > AgentRAM Textual TUI ready")
350
531
  self.chat.write(f"system > RAM root: {self.context.ram_root}")
351
532
  self.prefill_setup_from_profile()
533
+ self.refresh_setup_step()
352
534
  self.refresh_sidebar()
353
535
  self.set_interval(1.0, self.refresh_sidebar)
354
536
  try:
355
- self.query_one("#setup-supervisor-model", Input).focus()
537
+ self.focus_current_setup_model()
356
538
  except Exception: # noqa: BLE001 - fallback if setup is hidden/unmounted
357
539
  self.query_one("#input", Input).focus()
358
540
 
@@ -372,7 +554,7 @@ def run_textual_tui(context: McpContext) -> bool:
372
554
  if not endpoint:
373
555
  continue
374
556
  provider = endpoint.provider
375
- preset = provider if provider in {"openai-compatible", "anthropic"} else "custom"
557
+ preset = provider if provider in {"openai-compatible", "anthropic", "claude-code", "codex-cli"} else "custom"
376
558
  self.query_one(f"#setup-{slot}-provider", Select).value = preset
377
559
  self.query_one(f"#setup-{slot}-custom-provider", Input).value = "" if preset != "custom" else provider
378
560
  self.query_one(f"#setup-{slot}-model", Input).value = endpoint.model
@@ -380,6 +562,37 @@ def run_textual_tui(context: McpContext) -> bool:
380
562
  self.query_one(f"#setup-{slot}-api-key-env", Input).value = endpoint.api_key_env
381
563
  self.chat.write("system > Existing workflow models loaded. Save to update, or Skip to keep.")
382
564
 
565
+ def current_setup_slot(self) -> str:
566
+ return self.setup_slots[self.setup_index]
567
+
568
+ def current_setup_label(self) -> str:
569
+ labels = {"supervisor": "Supervisor", "main": "Main coding", "small": "Small memory"}
570
+ return labels[self.current_setup_slot()]
571
+
572
+ def refresh_setup_step(self) -> None:
573
+ total = len(self.setup_slots)
574
+ slot = self.current_setup_slot()
575
+ self.query_one("#setup-title", Static).update(f"Model setup - {self.setup_index + 1}/{total} {self.current_setup_label()}")
576
+ for item in self.setup_slots:
577
+ self.query_one(f"#setup-{item}-panel", Vertical).styles.display = "block" if item == slot else "none"
578
+
579
+ def focus_current_setup_model(self) -> None:
580
+ self.query_one(f"#setup-{self.current_setup_slot()}-model", Input).focus()
581
+
582
+ def action_next_setup(self) -> None:
583
+ if self.setup_index < len(self.setup_slots) - 1:
584
+ self.setup_index += 1
585
+ self.refresh_setup_step()
586
+ self.focus_current_setup_model()
587
+ else:
588
+ self.action_save_setup()
589
+
590
+ def action_prev_setup(self) -> None:
591
+ if self.setup_index > 0:
592
+ self.setup_index -= 1
593
+ self.refresh_setup_step()
594
+ self.focus_current_setup_model()
595
+
383
596
  def setup_value(self, widget_id: str) -> str:
384
597
  return self.query_one(f"#{widget_id}", Input).value.strip()
385
598
 
@@ -396,10 +609,8 @@ def run_textual_tui(context: McpContext) -> bool:
396
609
  provider = self.setup_provider(slot)
397
610
  model = self.setup_value(f"setup-{slot}-model")
398
611
  base_url = self.setup_value(f"setup-{slot}-base-url")
399
- api_key_env = self.setup_value(f"setup-{slot}-api-key-env")
400
- if api_key_env.startswith(("sk-", "gsk_", "sk_")):
401
- self.chat.write(f"agentram > setup error: {slot} api key env must be variable name, not raw key")
402
- return
612
+ api_key_input = self.setup_value(f"setup-{slot}-api-key-env")
613
+ api_key_env, saved_env_name = resolve_api_key_env(slot, api_key_input)
403
614
  if not model:
404
615
  continue
405
616
  endpoint = AgentModelEndpoint(
@@ -411,7 +622,10 @@ def run_textual_tui(context: McpContext) -> bool:
411
622
  modalities=["text"],
412
623
  )
413
624
  self.context.profile_store.set_agent(slot, endpoint)
414
- bound.append(f"{slot}={provider}:{model}")
625
+ if saved_env_name:
626
+ bound.append(f"{slot}={provider}:{model} key=.env:{saved_env_name}")
627
+ else:
628
+ bound.append(f"{slot}={provider}:{model}")
415
629
  if not bound:
416
630
  self.chat.write("agentram > setup skipped: no model entered")
417
631
  else:
@@ -426,8 +640,10 @@ def run_textual_tui(context: McpContext) -> bool:
426
640
 
427
641
  def show_setup(self) -> None:
428
642
  self.setup_done = False
643
+ self.setup_index = 0
429
644
  self.query_one("#setup", Vertical).styles.display = "block"
430
- self.query_one("#setup-supervisor-model", Input).focus()
645
+ self.refresh_setup_step()
646
+ self.focus_current_setup_model()
431
647
 
432
648
  def action_history_prev(self) -> None:
433
649
  input_widget = self.query_one("#input", Input)
@@ -468,6 +684,10 @@ def run_textual_tui(context: McpContext) -> bool:
468
684
  def on_button_pressed(self, event: Button.Pressed) -> None:
469
685
  if event.button.id == "setup-save":
470
686
  self.action_save_setup()
687
+ elif event.button.id == "setup-next":
688
+ self.action_next_setup()
689
+ elif event.button.id == "setup-prev":
690
+ self.action_prev_setup()
471
691
  elif event.button.id == "setup-skip":
472
692
  self.chat.write("system > Model setup skipped. Use /bind-agent later.")
473
693
  self.hide_setup()
@@ -488,7 +708,7 @@ def run_textual_tui(context: McpContext) -> bool:
488
708
  return
489
709
  query = value.strip().lower().lstrip("/")
490
710
  matches = []
491
- for command, description in SLASH_COMMANDS:
711
+ for command, description in slash_entries():
492
712
  if not command.startswith("/"):
493
713
  continue
494
714
  command_name = command.split()[0]
@@ -500,7 +720,7 @@ def run_textual_tui(context: McpContext) -> bool:
500
720
  if not matches:
501
721
  palette.update("No matching slash command")
502
722
  else:
503
- lines = ["Slash commands ? Tab completes first match"]
723
+ lines = ["Slash commands - Tab completes first match"]
504
724
  lines.extend(f" {command.ljust(18)} {description}" for command, description in matches[:8])
505
725
  palette.update("\n".join(lines))
506
726
  palette.styles.display = "block"
@@ -524,7 +744,7 @@ def run_textual_tui(context: McpContext) -> bool:
524
744
 
525
745
  async def on_input_submitted(self, event: Input.Submitted) -> None:
526
746
  if event.input.id and str(event.input.id).startswith("setup-"):
527
- self.action_save_setup()
747
+ self.action_next_setup()
528
748
  return
529
749
  text = event.value.strip()
530
750
  event.input.value = ""
@@ -584,11 +804,11 @@ def run_with_activity(context: McpContext, messages: list[str], user_input: str)
584
804
 
585
805
 
586
806
  def slash_command_names() -> list[str]:
587
- return [command.split()[0] for command, _ in SLASH_COMMANDS if command.startswith("/")]
807
+ return [command.split()[0] for command, _ in slash_entries() if command.startswith("/")]
588
808
 
589
809
 
590
810
  def slash_command_meta() -> dict[str, str]:
591
- return {command.split()[0]: description for command, description in SLASH_COMMANDS if command.startswith("/")}
811
+ return {command.split()[0]: description for command, description in slash_entries() if command.startswith("/")}
592
812
 
593
813
 
594
814
  def create_prompt_session() -> Any | None:
@@ -608,7 +828,7 @@ def create_prompt_session() -> Any | None:
608
828
  completer=completer,
609
829
  complete_while_typing=True,
610
830
  reserve_space_for_menu=12,
611
- bottom_toolbar="Type / for slash commands · /agents for subagents · /ask to call bound models",
831
+ bottom_toolbar="Type / for slash commands | /agents for subagents | /ask to call bound models",
612
832
  )
613
833
 
614
834
 
@@ -708,9 +928,25 @@ def handle_slash_command(context: McpContext, text: str, messages: list[str] | N
708
928
  return note_text(context, raw_rest)
709
929
  if command == "/clear":
710
930
  return "__clear__"
931
+ if command in CLAUDE_COMPAT_NAMES:
932
+ return claude_compat_command_text(command, raw_rest)
711
933
  return f"Unknown slash command: {command}\n\n{SLASH_HELP}"
712
934
 
713
935
 
936
+ def claude_compat_command_text(command: str, arguments: str) -> str:
937
+ descriptions = slash_command_meta()
938
+ description = descriptions.get(command, "Claude Code command")
939
+ return "\n".join(
940
+ [
941
+ f"Claude-compatible command: {command}",
942
+ f"Purpose: {description}",
943
+ f"Args: {arguments or '-'}",
944
+ "Status: listed for Claude parity in AgentRAM TUI.",
945
+ "Native execution is not implemented here unless AgentRAM has its own handler for this command.",
946
+ "Run the same command inside Claude Code for Claude-native behavior.",
947
+ ]
948
+ )
949
+
714
950
  def init_text(context: McpContext) -> str:
715
951
  data = payload(call_tool(context, "agentram_init", {}))
716
952
  created = data.get("created", [])
@@ -805,7 +1041,7 @@ def agents_text() -> str:
805
1041
  def slash_palette_text(filter_text: str = "") -> str:
806
1042
  normalized = filter_text.strip().lower().lstrip("/")
807
1043
  rows = []
808
- for command, description in SLASH_COMMANDS:
1044
+ for command, description in slash_entries():
809
1045
  command_key = command.lower().lstrip("/")
810
1046
  if normalized and normalized not in command_key:
811
1047
  continue
@@ -946,45 +1182,154 @@ def workflow_text(context: McpContext) -> str:
946
1182
  lines.append(f"- {slot}: not bound")
947
1183
  return "\n".join(lines)
948
1184
 
949
- def supervisor_workflow_text(context: McpContext, prompt: str) -> str:
950
- agents = context.profile_store.list_agents()
951
- supervisor = agents.get("supervisor")
952
- main_agent = agents.get("main")
953
- small_agent = agents.get("small")
954
- memory_context = retrieve_text(context, prompt, task_id="codex", limit=8)
955
- plan = supervisor_plan(supervisor, prompt, memory_context)
956
- outputs: dict[str, str] = {}
957
- with ThreadPoolExecutor(max_workers=2) as executor:
958
- futures = {}
959
- if main_agent:
960
- futures[executor.submit(call_agent_endpoint, main_agent, build_main_agent_prompt(prompt, plan, memory_context))] = "main"
961
- if small_agent:
962
- futures[executor.submit(call_agent_endpoint, small_agent, build_small_agent_prompt(prompt, plan, memory_context))] = "small"
963
- for future, slot in list(futures.items()):
964
- try:
965
- outputs[slot] = future.result()
966
- except Exception as error: # noqa: BLE001 - agent boundary
967
- outputs[slot] = f"error: {model_error_hint(str(error))}"
968
- if not main_agent:
969
- outputs["main"] = "No main agent bound. Use /bind-agent main <provider> <model>."
970
- persist_workflow_memory(context, prompt, plan, outputs)
971
- return "\n".join(
972
- [
973
- "Workflow: supervisor -> main + small -> merge",
974
- "Supervisor plan:",
975
- plan,
976
- "",
977
- "Main agent result:",
978
- outputs.get("main", "-"),
979
- "",
980
- "Small memory agent notes:",
981
- outputs.get("small", "No small agent bound."),
982
- ]
983
- )
984
-
985
- def persist_workflow_memory(context: McpContext, prompt: str, plan: str, outputs: dict[str, str]) -> None:
986
- small_notes = outputs.get("small", "").strip()
987
- main_result = outputs.get("main", "").strip()
1185
+ def supervisor_workflow_text(context: McpContext, prompt: str) -> str:
1186
+ agents = context.profile_store.list_agents()
1187
+ supervisor = agents.get("supervisor")
1188
+ main_agent = agents.get("main")
1189
+ small_agent = agents.get("small")
1190
+ memory_context = retrieve_text(context, prompt, task_id="codex", limit=8)
1191
+ route = classify_agent_runtime_route(context, prompt)
1192
+ intent = str(route.get("intent", "chat"))
1193
+ outputs: dict[str, str] = {}
1194
+
1195
+ if intent == "planning":
1196
+ plan = supervisor_plan(supervisor, prompt, memory_context)
1197
+ outputs["supervisor"] = plan
1198
+ outputs["small"] = small_agent_observe(context, small_agent, prompt, "supervisor_plan", plan, plan, memory_context)
1199
+ sync_goal_state_from_small_agent(context, prompt, plan, outputs.get("small", ""))
1200
+ persist_workflow_memory(context, prompt, plan, outputs)
1201
+ return "\n".join(
1202
+ [
1203
+ f"Workflow: route=planning confidence={float(route.get('confidence', 0.0)):.2f} -> supervisor -> small memory note -> merge",
1204
+ "Supervisor plan:",
1205
+ plan,
1206
+ "",
1207
+ "Small memory agent notes:",
1208
+ outputs.get("small", "No small agent bound."),
1209
+ ]
1210
+ )
1211
+
1212
+ if intent == "memory":
1213
+ plan = "Memory-only event. Small agent writes RAM note directly."
1214
+ outputs["small"] = small_agent_observe(context, small_agent, prompt, "memory_event", prompt, plan, memory_context)
1215
+ sync_goal_state_from_small_agent(context, prompt, plan, outputs.get("small", ""))
1216
+ persist_workflow_memory(context, prompt, plan, outputs)
1217
+ return "\n".join(
1218
+ [
1219
+ f"Workflow: route=memory confidence={float(route.get('confidence', 0.0)):.2f} -> small memory note -> merge",
1220
+ "Small memory agent notes:",
1221
+ outputs.get("small", "No small agent bound."),
1222
+ ]
1223
+ )
1224
+
1225
+ plan = runtime_execution_plan(context, supervisor, prompt, memory_context, route)
1226
+ outputs["supervisor"] = plan
1227
+ outputs["small_after_plan"] = small_agent_observe(context, small_agent, prompt, "supervisor_plan", plan, plan, memory_context)
1228
+ if main_agent:
1229
+ try:
1230
+ outputs["main"] = call_agent_endpoint(main_agent, build_main_agent_prompt(prompt, plan, memory_context))
1231
+ except Exception as error: # noqa: BLE001 - agent boundary
1232
+ outputs["main"] = f"error: {model_error_hint(str(error))}"
1233
+ else:
1234
+ outputs["main"] = "No main agent bound. Use /bind-agent main <provider> <model>."
1235
+ outputs["small"] = small_agent_observe(context, small_agent, prompt, "main_result", outputs.get("main", ""), plan, memory_context)
1236
+ sync_goal_state_from_small_agent(context, prompt, plan, outputs.get("small", ""))
1237
+ persist_workflow_memory(context, prompt, plan, outputs)
1238
+ return "\n".join(
1239
+ [
1240
+ f"Workflow: route={intent} confidence={float(route.get('confidence', 0.0)):.2f} -> main -> small memory note -> merge",
1241
+ "Supervisor plan:",
1242
+ plan,
1243
+ "",
1244
+ "Main agent result:",
1245
+ outputs.get("main", "-"),
1246
+ "",
1247
+ "Small memory agent after plan:",
1248
+ outputs.get("small_after_plan", "No small agent bound."),
1249
+ "",
1250
+ "Small memory agent notes:",
1251
+ outputs.get("small", "No small agent bound."),
1252
+ ]
1253
+ )
1254
+
1255
+ def classify_agent_runtime_route(context: McpContext, prompt: str) -> dict[str, object]:
1256
+ lower = prompt.lower()
1257
+ execute_words = [
1258
+ "bắt đầu làm", "bat dau lam", "làm đi", "lam di", "tiến hành", "tien hanh", "thực hiện", "thuc hien",
1259
+ "chốt kế hoạch", "chot ke hoach", "bắt tay", "start", "execute", "implement", "do it", "go ahead",
1260
+ ]
1261
+ plan_words = ["lên kế hoạch", "len ke hoach", "kế hoạch", "ke hoach", "plan", "roadmap", "milestone", "thiết kế", "thiet ke"]
1262
+ if any(word in lower for word in execute_words):
1263
+ return {"intent": "execution", "confidence": 0.9, "source": "heuristic"}
1264
+ if any(word in lower for word in plan_words):
1265
+ return {"intent": "planning", "confidence": 0.85, "source": "heuristic"}
1266
+ decision = classify_prompt_intent(context, prompt)
1267
+ intent = str(decision.get("intent", "chat"))
1268
+ if intent == "coding":
1269
+ decision["intent"] = "execution"
1270
+ return decision
1271
+
1272
+ def runtime_execution_plan(context: McpContext, supervisor: AgentModelEndpoint | None, prompt: str, memory_context: str, route: dict[str, object]) -> str:
1273
+ state = context.goal_store.load().context_block()
1274
+ if state:
1275
+ return "\n".join(["Use current accepted Goal Stack as execution plan.", state])
1276
+ if str(route.get("intent")) == "execution" and any(word in prompt.lower() for word in ["bắt đầu", "bat dau", "làm đi", "lam di", "execute", "do it"]):
1277
+ return "User approved previous plan. Use AgentRAM memory context and current prompt to execute next concrete task."
1278
+ return supervisor_plan(supervisor, prompt, memory_context)
1279
+
1280
+ def small_agent_observe(context: McpContext, small_agent: AgentModelEndpoint | None, prompt: str, event_type: str, event_content: str, plan: str, memory_context: str) -> str:
1281
+ if not small_agent:
1282
+ return "No small agent bound."
1283
+ try:
1284
+ return call_agent_endpoint(
1285
+ small_agent,
1286
+ build_small_agent_prompt(prompt, plan, memory_context, event_content, event_type=event_type),
1287
+ )
1288
+ except Exception as error: # noqa: BLE001 - agent boundary
1289
+ return f"error: {model_error_hint(str(error))}"
1290
+
1291
+ def sync_goal_state_from_small_agent(context: McpContext, prompt: str, plan: str, small_notes: str) -> None:
1292
+ if not small_notes.strip() or small_notes.strip().startswith("error:"):
1293
+ return
1294
+ state = context.goal_store.load()
1295
+ goal = extract_labeled_value(small_notes, ("current_goal", "goal", "mục tiêu", "muc tieu")) or first_nonempty_line(plan)
1296
+ task = extract_labeled_value(small_notes, ("current_task", "task", "nhiệm vụ", "nhiem vu")) or first_nonempty_line(small_notes) or prompt
1297
+ patch: dict[str, object] = {
1298
+ "current_task": trim(task.strip(), 180),
1299
+ "goal_history": [trim(goal.strip() or prompt.strip(), 180)],
1300
+ }
1301
+ if not state.mission:
1302
+ patch["mission"] = "Xây dựng và vận hành AgentRAM cho coding agents."
1303
+ if not state.project_goal:
1304
+ patch["project_goal"] = "AgentRAM workflow: supervisor lập kế hoạch, main coding agent thực thi, small memory agent ghi RAM."
1305
+ if goal:
1306
+ patch["current_goal"] = trim(goal.strip(), 180)
1307
+ context.goal_store.update(patch)
1308
+
1309
+ def extract_labeled_value(text: str, labels: tuple[str, ...]) -> str:
1310
+ for line in text.splitlines():
1311
+ cleaned = line.strip().lstrip("-*").strip()
1312
+ for label in labels:
1313
+ pattern = rf"^{re.escape(label)}\s*[:=]\s*(.+)$"
1314
+ match = re.match(pattern, cleaned, flags=re.IGNORECASE)
1315
+ if match:
1316
+ return match.group(1).strip().strip("`*_-")
1317
+ return ""
1318
+
1319
+ def first_nonempty_line(value: str) -> str:
1320
+ for line in value.splitlines():
1321
+ cleaned = line.strip().lstrip("-0123456789. )")
1322
+ if cleaned and not cleaned.lower().startswith(("supervisor error", "fallback plan")):
1323
+ return cleaned
1324
+ return ""
1325
+
1326
+ def persist_workflow_memory(context: McpContext, prompt: str, plan: str, outputs: dict[str, str]) -> None:
1327
+ small_notes = "\n".join(
1328
+ value.strip()
1329
+ for key, value in outputs.items()
1330
+ if key.startswith("small") and value.strip() and not value.strip().startswith("error:")
1331
+ )
1332
+ main_result = outputs.get("main", "").strip()
988
1333
  note_parts = [
989
1334
  f"User request: {prompt}",
990
1335
  f"Supervisor plan: {trim(plan.strip(), 1200)}" if plan.strip() else "Supervisor plan: -",
@@ -1015,14 +1360,14 @@ def supervisor_plan(supervisor: AgentModelEndpoint | None, prompt: str, memory_c
1015
1360
  def call_agent_endpoint(endpoint: AgentModelEndpoint, prompt: str) -> str:
1016
1361
  return MultiModelCodingOrchestrator([endpoint])._call_endpoint(endpoint, prompt)
1017
1362
 
1018
- def build_supervisor_prompt(prompt: str, memory_context: str) -> str:
1019
- return "\n".join(["You are AgentRAM supervisor. Act like a senior coding-agent boss.", "Create concise plan for main coding agent and small memory agent.", "Return only actionable plan. No markdown table.", "User request:", prompt, "AgentRAM context:", memory_context])
1020
-
1021
- def build_main_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
1022
- return "\n".join(["You are AgentRAM main coding agent.", "Follow supervisor plan. Focus on planning, orchestration, coding, refactor, bug fix, and final result.", "Do not invent repo facts. Mention files to inspect/change.", "User request:", prompt, "Supervisor plan:", plan, "AgentRAM context:", memory_context])
1023
-
1024
- def build_small_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
1025
- return "\n".join(["You are AgentRAM small memory agent.", "Do not edit code. Note plans, decisions, risks, files, next tasks, and coding facts for RAM.", "Return concise memory notes only.", "User request:", prompt, "Supervisor plan:", plan, "AgentRAM context:", memory_context])
1363
+ def build_supervisor_prompt(prompt: str, memory_context: str) -> str:
1364
+ return "\n".join(["You are AgentRAM supervisor. Act like a senior coding-agent boss.", "Nếu user prompt bằng tiếng Việt, trả lời bằng tiếng Việt. Giữ code, command, path, errors nguyên văn.", "Create a concise plan only. Do not code. Main agent runs later only after user approves/starts execution.", "Small memory agent observes this plan and writes RAM notes.", "Return only actionable plan. No markdown table.", "User request:", prompt, "AgentRAM context:", memory_context])
1365
+
1366
+ def build_main_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
1367
+ return "\n".join(["You are AgentRAM main coding agent.", "Nếu user prompt bằng tiếng Việt, trả lời bằng tiếng Việt. Giữ code, command, path, errors nguyên văn.", "Follow supervisor plan. Focus on planning, orchestration, coding, refactor, bug fix, and final result.", "Do not invent repo facts. Mention files to inspect/change.", "User request:", prompt, "Supervisor plan:", plan, "AgentRAM context:", memory_context])
1368
+
1369
+ def build_small_agent_prompt(prompt: str, plan: str, memory_context: str, event_content: str = "", event_type: str = "main_result") -> str:
1370
+ return "\n".join(["You are AgentRAM small memory agent.", "Nếu user prompt bằng tiếng Việt, ghi note RAM bằng tiếng Việt. Giữ code, command, path, errors nguyên văn.", "You participate in every AgentRAM event directly: user request, supervisor plan, main result, compact/merge.", "Do not edit code. Observe event content, then note decisions, risks, files, next tasks, and coding facts for RAM.", "You are responsible for setting the visible RAM goal/task from the latest event.", "Return concise memory notes only. Không dùng tiếng Anh nếu user đang dùng tiếng Việt.", "Bắt buộc gồm 2 dòng đầu theo format: current_goal: ... và current_task: ...", "User request:", prompt, "Supervisor plan:", plan, "Observed event type:", event_type, "Observed event content:", event_content or "-", "AgentRAM context:", memory_context])
1026
1371
 
1027
1372
  def bind_router_text(context: McpContext, options: dict[str, object]) -> str:
1028
1373
  api_key_env = str(options.get("api_key_env", ""))
@@ -1107,9 +1452,9 @@ def extract_json_object(text: str) -> str:
1107
1452
 
1108
1453
  def heuristic_intent(prompt: str) -> dict[str, object]:
1109
1454
  lower = prompt.lower()
1110
- memory_words = ["ghi nhớ", "remember", "note", "decision", "lưu lại", "save this"]
1111
- planning_words = ["goal", "kế hoạch", "ke hoach", "milestone", "mission", "drift", "next task", "roadmap", "mục tiêu", "muc tieu"]
1112
- coding_words = ["sửa", "sua", "fix", "bug", "code", "test", "refactor", "review", "file", ".py", ".js", "endpoint", "class", "function"]
1455
+ memory_words = ["ghi nhớ", "remember", "note", "decision", "lưu lại", "save this"]
1456
+ planning_words = ["goal", "kế hoạch", "ke hoach", "milestone", "mission", "drift", "next task", "roadmap", "mục tiêu", "muc tieu"]
1457
+ coding_words = ["sá»­a", "sua", "fix", "bug", "code", "test", "refactor", "review", "file", ".py", ".js", "endpoint", "class", "function"]
1113
1458
  if any(word in lower for word in memory_words):
1114
1459
  return {"intent": "memory", "confidence": 0.75}
1115
1460
  if any(word in lower for word in planning_words):
@@ -1144,12 +1489,16 @@ def model_error_hint(message: str) -> str:
1144
1489
  return message + " | Fix: /remove-model <id> or re-bind with --base-url http://localhost:8000/v1"
1145
1490
  if "actively refused" in lower or "connection refused" in lower or "winerror 10061" in lower:
1146
1491
  return message + " | Fix: start your OpenAI-compatible server at base_url, or change --base-url."
1492
+ if "http 403" in lower or "403" in lower and "groq" in lower:
1493
+ return message + " | Fix: Groq rejected auth/model. Rotate/check API key, ensure provider=openai-compatible, base_url=https://api.groq.com/openai/v1, and use a Groq-supported model name."
1147
1494
  if "http 404" in lower or "404: not found" in lower:
1148
1495
  return message + " | Fix: verify base_url includes /v1, provider supports /chat/completions, and model name exists."
1149
1496
  if "http 400" in lower and "not supported" in lower:
1150
1497
  return message + " | Fix: this account/provider rejects that model. Bind a supported model name from your local endpoint."
1151
1498
  if "returned non-json" in lower or "expecting value" in lower:
1152
1499
  return message + " | Fix: endpoint returned empty/HTML/non-JSON. Check base_url, auth key, proxy, and server logs."
1500
+ if "command not found" in lower or "winerror 2" in lower or "the system cannot find the file" in lower:
1501
+ return message + r" | Fix: install the CLI or set Main base_url to full command path. For Codex use base_url='C:\Users\admin\AppData\Roaming\npm\codex.cmd exec' or paste codex.cmd path; AgentRAM auto-adds exec when missing."
1153
1502
  if "missing api key env" in lower:
1154
1503
  return message + " | Fix: set environment variable, then bind using --api-key-env ENV_NAME, not raw key."
1155
1504
  return message
@@ -1226,7 +1575,7 @@ def draw_screen(context: McpContext, messages: list[str], activity: str | None =
1226
1575
  left_width = 30
1227
1576
  right_width = width - left_width - 3
1228
1577
  print("+" + "-" * (width - 2) + "+")
1229
- print(box_text("AgentRAM Code | RAM + Claude subagents + slash commands", width))
1578
+ print(box_text("AgentRAM Code | RAM + Claude subagents + slash commands", width))
1230
1579
  print("+" + "-" * (width - 2) + "+")
1231
1580
 
1232
1581
  sidebar = sidebar_lines(context)
@@ -1281,7 +1630,7 @@ def sidebar_lines(context: McpContext) -> list[str]:
1281
1630
 
1282
1631
 
1283
1632
  def trim(text: str, limit: int) -> str:
1284
- return text if len(text) <= limit else text[: limit - 1] + ""
1633
+ return text if len(text) <= limit else text[: limit - 3] + "..."
1285
1634
 
1286
1635
 
1287
1636
  def menu_lines() -> list[str]: