agentram 0.1.21 → 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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # AgentRAM
1
+ # AgentRAM
2
2
 
3
3
  Async, model-agnostic Working RAM for coding agents such as Codex, Claude Code, Cursor, and custom MCP clients.
4
4
 
@@ -358,20 +358,20 @@ Minimum usage pattern:
358
358
 
359
359
  AgentRAM can bind multiple coding model endpoints and let Claude Code or MCP clients orchestrate them in parallel. This keeps the `free-code` style command registry, but adds project RAM, Goal Stack, and multi-agent model routing.
360
360
 
361
- Bind models:
362
-
363
- ```bash
364
- agentram bind-model claude-subagent agentram-reviewer --role reviewer
365
- agentram bind-model claude claude-3-5-sonnet-latest --role reviewer --api-key-env ANTHROPIC_API_KEY
366
- agentram bind-model openai-compatible qwen2.5-coder --role coder --base-url http://localhost:8000/v1 --api-key-env OPENAI_API_KEY
367
- ```
368
-
369
- Claude Code subagent endpoint uses your local Claude CLI entrypoint. `model` is the subagent name from `.claude/agents`, for example `agentram-reviewer` or `agentram-planner`. Default command is `claude -p`. Override command when needed:
370
-
371
- ```bash
372
- set AGENTRAM_CLAUDE_COMMAND=claude
373
- agentram bind-model claude-subagent agentram-reviewer --role reviewer
374
- ```
361
+ Bind models:
362
+
363
+ ```bash
364
+ agentram bind-model claude-subagent agentram-reviewer --role reviewer
365
+ agentram bind-model claude claude-3-5-sonnet-latest --role reviewer --api-key-env ANTHROPIC_API_KEY
366
+ agentram bind-model openai-compatible qwen2.5-coder --role coder --base-url http://localhost:8000/v1 --api-key-env OPENAI_API_KEY
367
+ ```
368
+
369
+ Claude Code subagent endpoint uses your local Claude CLI entrypoint. `model` is the subagent name from `.claude/agents`, for example `agentram-reviewer` or `agentram-planner`. Default command is `claude -p`. Override command when needed:
370
+
371
+ ```bash
372
+ set AGENTRAM_CLAUDE_COMMAND=claude
373
+ agentram bind-model claude-subagent agentram-reviewer --role reviewer
374
+ ```
375
375
 
376
376
  Build a dry-run orchestration plan:
377
377
 
@@ -396,12 +396,12 @@ agentram_run_coding_orchestration
396
396
 
397
397
  TUI slash commands. Plain text only records/retrieves RAM; use `/ask` to call bound models:
398
398
 
399
- ```text
400
- /bind-model claude-subagent agentram-reviewer role=reviewer
401
- /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY
402
- /models
403
- /orchestrate review CLI orchestration
404
- /ask xin chào
399
+ ```text
400
+ /bind-model claude-subagent agentram-reviewer role=reviewer
401
+ /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY
402
+ /models
403
+ /orchestrate review CLI orchestration
404
+ /ask xin chào
405
405
  ```
406
406
 
407
407
  Storage:
@@ -876,7 +876,7 @@ Inside TUI:
876
876
 
877
877
  ```text
878
878
  /bind-model openai-compatible Agentic --role coder --base-url http://localhost:8000/v1 --api-key-env OPENAI_API_KEY
879
- /ask xin chào
879
+ /ask xin chào
880
880
  ```
881
881
 
882
882
  Do not paste raw API keys into `/bind-model`. `api_key_env` means environment variable name, e.g. `OPENAI_API_KEY`.
@@ -908,133 +908,146 @@ If `/ask` shows connection refused, the configured OpenAI-compatible server is n
908
908
 
909
909
  ```text
910
910
  /bind-model openai-compatible Agentic --role coder --base-url http://localhost:8000/v1 --api-key-env OPENAI_API_KEY
911
- /ask xin chào
911
+ /ask xin chào
912
912
  ```
913
913
 
914
914
  Start your local server first, or change `--base-url` to the provider URL.
915
-
916
- ## Prompt Router Model
917
-
918
- AgentRAM can use a separate small router model before normal prompts. The router classifies each prompt, then sends it to the right path.
919
-
920
- ```text
921
- coding -> bound coding endpoints / Claude subagents
922
- planning -> Goal Stack and drift check
923
- memory -> AgentRAM note ingest
924
- chat -> RAM retrieve only
925
- ```
926
-
927
- Bind a router model:
928
-
929
- ```text
930
- /bind-router openai-compatible qwen2.5:1.5b base_url=http://localhost:8000/v1 api_key_env=OPENAI_API_KEY
931
- /route on
932
- ```
933
-
934
- Then prompt without slash:
935
-
936
- ```text
937
- fix adapter.py
938
- c?p nh?t goal stack
939
- ghi nh? d�ng Claude subagent
940
- ```
941
-
942
- Disable auto routing:
943
-
944
- ```text
945
- /route off
946
- ```
947
-
948
- ## Supervisor + Small Agent Workflow
949
-
950
- AgentRAM can run as a two-flow coding agent system, not only as a flat multi-model router.
951
-
952
- ```text
953
- agentram
954
- |
955
- v
956
- Parse User Request
957
- |
958
- v
959
- Supervisor Memory
960
- |
961
- v
962
- Task Classifier / Supervisor Plan
963
- |----------------------|
964
- v v
965
- Main Agent Small Agent
966
- (GPT-5.5 Codex) (Qwen Codex)
967
- planning/coding/fix notes/RAM/coding facts
968
- | |
969
- |----------|-----------|
970
- v
971
- Merge + Update Memory
972
- |
973
- v
974
- Return Result
975
- ```
976
-
977
- Bind workflow agents:
978
-
979
- ```text
980
- /bind-agent supervisor claude-subagent agentram-planner
981
- /bind-agent main openai-compatible gpt-5.5-codex base_url=https://api.example.com/v1 api_key_env=MAIN_AGENT_KEY
982
- /bind-agent small openai-compatible qwen-codex base_url=http://localhost:8001/v1 api_key_env=SMALL_AGENT_KEY
983
- /workflow
984
- ```
985
-
986
- Then use normal prompts without slash:
987
-
988
- ```text
989
- refactor orchestration workflow
990
- fix bug in adapter.py
991
- plan next milestone
992
- ```
993
-
994
- Runtime behavior:
995
-
996
- ```text
997
- Supervisor creates plan -> Main agent executes -> Small agent records RAM notes -> AgentRAM merges result
998
- ```
999
-
915
+
916
+ ## Prompt Router Model
917
+
918
+ AgentRAM can use a separate small router model before normal prompts. The router classifies each prompt, then sends it to the right path.
919
+
920
+ ```text
921
+ coding -> bound coding endpoints / Claude subagents
922
+ planning -> Goal Stack and drift check
923
+ memory -> AgentRAM note ingest
924
+ chat -> RAM retrieve only
925
+ ```
926
+
927
+ Bind a router model:
928
+
929
+ ```text
930
+ /bind-router openai-compatible qwen2.5:1.5b base_url=http://localhost:8000/v1 api_key_env=OPENAI_API_KEY
931
+ /route on
932
+ ```
933
+
934
+ Then prompt without slash:
935
+
936
+ ```text
937
+ fix adapter.py
938
+ cap nhat goal stack
939
+ ghi nho dung Claude subagent
940
+ ```
941
+
942
+ Disable auto routing:
943
+
944
+ ```text
945
+ /route off
946
+ ```
947
+
948
+ ## Supervisor + Small Agent Workflow
949
+
950
+ AgentRAM can run as a two-flow coding agent system, not only as a flat multi-model router.
951
+
952
+ ```text
953
+ agentram
954
+ |
955
+ v
956
+ Parse User Request
957
+ |
958
+ v
959
+ Supervisor Memory
960
+ |
961
+ v
962
+ Task Classifier / Supervisor Plan
963
+ |----------------------|
964
+ v v
965
+ Main Agent
966
+ (Codex CLI / Claude Code / OpenAI-compatible)
967
+ planning/coding/fix, may edit files when bound to CLI provider
968
+ |
969
+ v
970
+ Small Agent
971
+ (Qwen/OpenAI-compatible/Anthropic)
972
+ notes/RAM/coding facts only
973
+ |
974
+ v
975
+ Merge + Update Memory
976
+ |
977
+ v
978
+ Return Result
979
+ ```
980
+
981
+ Bind workflow agents:
982
+
983
+ ```text
984
+ /bind-agent supervisor openai-compatible supervisor-model base_url=https://api.example.com/v1 api_key_env=SUPERVISOR_AGENT_KEY
985
+ /bind-agent main codex-cli current base_url="codex exec"
986
+ /bind-agent small openai-compatible qwen-codex base_url=http://localhost:8001/v1 api_key_env=SMALL_AGENT_KEY
987
+ /workflow
988
+ ```
989
+
990
+ Then use normal prompts without slash:
991
+
992
+ ```text
993
+ refactor orchestration workflow
994
+ fix bug in adapter.py
995
+ plan next milestone
996
+ ```
997
+
998
+ Runtime behavior:
999
+
1000
+ ```text
1001
+ Supervisor creates plan -> Main CLI coding agent edits/returns result -> Small agent reads plan + main result and records RAM notes -> AgentRAM merges result
1002
+ ```
1003
+
1000
1004
  Small agent should not own big direction. It records plans, decisions, files, risks, next tasks, and coding facts into RAM.
1001
-
1002
- ## Textual TUI
1003
-
1004
- Install rich terminal UI with native scrollable chat pane:
1005
-
1006
- ```bash
1007
- pip install agentram[tui]
1008
- agentram tui
1009
- ```
1010
-
1005
+
1006
+ CLI coding providers for file edits:
1007
+
1008
+ ```text
1009
+ /bind-agent main codex-cli current base_url="codex exec"
1010
+ /bind-agent main claude-code claude base_url="claude"
1011
+ ```
1012
+
1013
+ Use `codex-cli` or `claude-code` only for the main agent when you want a coding agent subprocess that can inspect and edit files. Keep the small agent on an LLM-only provider so it records notes but does not edit code.
1014
+
1015
+ ## Textual TUI
1016
+
1017
+ Install rich terminal UI with native scrollable chat pane:
1018
+
1019
+ ```bash
1020
+ pip install agentram[tui]
1021
+ agentram tui
1022
+ ```
1023
+
1011
1024
  When `textual` is installed, AgentRAM uses a real full-screen TUI with mouse wheel scrolling inside the chat frame. If `textual` is missing, it falls back to the basic print-based terminal UI.
1012
-
1013
- ## npm Python Dependencies
1014
-
1015
- When installed through npm, AgentRAM runs a `postinstall` step that tries to install Python TUI dependencies for the Python interpreter used by `agentram`:
1016
-
1017
- ```text
1018
- textual>=0.80
1019
- prompt_toolkit>=3.0
1020
- ```
1021
-
1022
- Set target Python before install when needed:
1023
-
1024
- ```powershell
1025
- $env:PYTHON="C:\Users\admin\anaconda3\python.exe"
1026
- npm install -g agentram@latest
1027
- ```
1028
-
1029
- Skip automatic Python dependency install:
1030
-
1031
- ```powershell
1032
- $env:AGENTRAM_SKIP_PY_DEPS="1"
1033
- npm install -g agentram@latest
1034
- ```
1035
-
1036
- If auto install fails, install manually:
1037
-
1038
- ```powershell
1039
- python -m pip install textual prompt_toolkit
1025
+
1026
+ ## npm Python Dependencies
1027
+
1028
+ When installed through npm, AgentRAM runs a `postinstall` step that tries to install Python TUI dependencies for the Python interpreter used by `agentram`:
1029
+
1030
+ ```text
1031
+ textual>=0.80
1032
+ prompt_toolkit>=3.0
1033
+ ```
1034
+
1035
+ Set target Python before install when needed:
1036
+
1037
+ ```powershell
1038
+ $env:PYTHON="C:\Users\admin\anaconda3\python.exe"
1039
+ npm install -g agentram@latest
1040
+ ```
1041
+
1042
+ Skip automatic Python dependency install:
1043
+
1044
+ ```powershell
1045
+ $env:AGENTRAM_SKIP_PY_DEPS="1"
1046
+ npm install -g agentram@latest
1047
+ ```
1048
+
1049
+ If auto install fails, install manually:
1050
+
1051
+ ```powershell
1052
+ python -m pip install textual prompt_toolkit
1040
1053
  ```
package/agentram/cli.py CHANGED
@@ -1,4 +1,4 @@
1
- from __future__ import annotations
1
+ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import asyncio
@@ -101,6 +101,135 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
101
101
  ]
102
102
 
103
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
+
104
233
  SLASH_HELP = """Slash commands:
105
234
  /help Show commands
106
235
  /init Create .agentram files
@@ -353,6 +482,8 @@ def run_textual_tui(context: McpContext) -> bool:
353
482
  self.prompt_history: list[str] = []
354
483
  self.history_index: int | None = None
355
484
  self.last_ctrl_c_at = 0.0
485
+ self.setup_slots = ("supervisor", "main", "small")
486
+ self.setup_index = 0
356
487
 
357
488
  def compose(self) -> ComposeResult:
358
489
  yield Header(show_clock=True)
@@ -360,18 +491,20 @@ def run_textual_tui(context: McpContext) -> bool:
360
491
  yield Static(id="sidebar")
361
492
  with Vertical(id="main"):
362
493
  with Vertical(id="setup"):
363
- yield Static("Model setup - configure each agent separately", id="setup-title")
494
+ yield Static("Model setup - 1/3 Supervisor", id="setup-title")
364
495
  for slot, label, default_model in [
365
- ("supervisor", "Supervisor", ""),
366
- ("main", "Main coding", ""),
367
- ("small", "Small memory", ""),
496
+ ("supervisor", "1. Supervisor", ""),
497
+ ("main", "2. Main coding", ""),
498
+ ("small", "3. Small memory", ""),
368
499
  ]:
369
- with Vertical(classes="agent-setup"):
500
+ with Vertical(id=f"setup-{slot}-panel", classes="agent-setup"):
370
501
  yield Static(f"{label} agent")
371
502
  yield Select(
372
503
  [
373
504
  ("OpenAI Compatible", "openai-compatible"),
374
505
  ("Anthropic Compatible", "anthropic"),
506
+ ("Claude Code CLI", "claude-code"),
507
+ ("Codex CLI", "codex-cli"),
375
508
  ("Custom", "custom"),
376
509
  ],
377
510
  value="openai-compatible",
@@ -380,14 +513,16 @@ def run_textual_tui(context: McpContext) -> bool:
380
513
  )
381
514
  yield Input(placeholder="Custom provider if preset=Custom", id=f"setup-{slot}-custom-provider", classes="setup-input")
382
515
  yield Input(value=default_model, placeholder="Model name", id=f"setup-{slot}-model", classes="setup-input")
383
- yield Input(placeholder="Base URL, e.g. http://localhost:20128/v1", id=f"setup-{slot}-base-url", 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")
384
517
  yield Input(value="OPENAI_API_KEY", placeholder="API key or env name", id=f"setup-{slot}-api-key-env", classes="setup-input")
385
518
  with Horizontal(id="setup-actions"):
386
- 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")
387
522
  yield Button("Skip", id="setup-skip")
388
523
  yield RichLog(id="chat", wrap=True, highlight=True, markup=False, auto_scroll=True)
389
524
  yield Static(id="palette")
390
- 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")
391
526
  yield Footer()
392
527
 
393
528
  def on_mount(self) -> None:
@@ -395,10 +530,11 @@ def run_textual_tui(context: McpContext) -> bool:
395
530
  self.chat.write("system > AgentRAM Textual TUI ready")
396
531
  self.chat.write(f"system > RAM root: {self.context.ram_root}")
397
532
  self.prefill_setup_from_profile()
533
+ self.refresh_setup_step()
398
534
  self.refresh_sidebar()
399
535
  self.set_interval(1.0, self.refresh_sidebar)
400
536
  try:
401
- self.query_one("#setup-supervisor-model", Input).focus()
537
+ self.focus_current_setup_model()
402
538
  except Exception: # noqa: BLE001 - fallback if setup is hidden/unmounted
403
539
  self.query_one("#input", Input).focus()
404
540
 
@@ -418,7 +554,7 @@ def run_textual_tui(context: McpContext) -> bool:
418
554
  if not endpoint:
419
555
  continue
420
556
  provider = endpoint.provider
421
- 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"
422
558
  self.query_one(f"#setup-{slot}-provider", Select).value = preset
423
559
  self.query_one(f"#setup-{slot}-custom-provider", Input).value = "" if preset != "custom" else provider
424
560
  self.query_one(f"#setup-{slot}-model", Input).value = endpoint.model
@@ -426,6 +562,37 @@ def run_textual_tui(context: McpContext) -> bool:
426
562
  self.query_one(f"#setup-{slot}-api-key-env", Input).value = endpoint.api_key_env
427
563
  self.chat.write("system > Existing workflow models loaded. Save to update, or Skip to keep.")
428
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
+
429
596
  def setup_value(self, widget_id: str) -> str:
430
597
  return self.query_one(f"#{widget_id}", Input).value.strip()
431
598
 
@@ -473,8 +640,10 @@ def run_textual_tui(context: McpContext) -> bool:
473
640
 
474
641
  def show_setup(self) -> None:
475
642
  self.setup_done = False
643
+ self.setup_index = 0
476
644
  self.query_one("#setup", Vertical).styles.display = "block"
477
- self.query_one("#setup-supervisor-model", Input).focus()
645
+ self.refresh_setup_step()
646
+ self.focus_current_setup_model()
478
647
 
479
648
  def action_history_prev(self) -> None:
480
649
  input_widget = self.query_one("#input", Input)
@@ -515,6 +684,10 @@ def run_textual_tui(context: McpContext) -> bool:
515
684
  def on_button_pressed(self, event: Button.Pressed) -> None:
516
685
  if event.button.id == "setup-save":
517
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()
518
691
  elif event.button.id == "setup-skip":
519
692
  self.chat.write("system > Model setup skipped. Use /bind-agent later.")
520
693
  self.hide_setup()
@@ -535,7 +708,7 @@ def run_textual_tui(context: McpContext) -> bool:
535
708
  return
536
709
  query = value.strip().lower().lstrip("/")
537
710
  matches = []
538
- for command, description in SLASH_COMMANDS:
711
+ for command, description in slash_entries():
539
712
  if not command.startswith("/"):
540
713
  continue
541
714
  command_name = command.split()[0]
@@ -547,7 +720,7 @@ def run_textual_tui(context: McpContext) -> bool:
547
720
  if not matches:
548
721
  palette.update("No matching slash command")
549
722
  else:
550
- lines = ["Slash commands ? Tab completes first match"]
723
+ lines = ["Slash commands - Tab completes first match"]
551
724
  lines.extend(f" {command.ljust(18)} {description}" for command, description in matches[:8])
552
725
  palette.update("\n".join(lines))
553
726
  palette.styles.display = "block"
@@ -571,7 +744,7 @@ def run_textual_tui(context: McpContext) -> bool:
571
744
 
572
745
  async def on_input_submitted(self, event: Input.Submitted) -> None:
573
746
  if event.input.id and str(event.input.id).startswith("setup-"):
574
- self.action_save_setup()
747
+ self.action_next_setup()
575
748
  return
576
749
  text = event.value.strip()
577
750
  event.input.value = ""
@@ -631,11 +804,11 @@ def run_with_activity(context: McpContext, messages: list[str], user_input: str)
631
804
 
632
805
 
633
806
  def slash_command_names() -> list[str]:
634
- 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("/")]
635
808
 
636
809
 
637
810
  def slash_command_meta() -> dict[str, str]:
638
- 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("/")}
639
812
 
640
813
 
641
814
  def create_prompt_session() -> Any | None:
@@ -655,7 +828,7 @@ def create_prompt_session() -> Any | None:
655
828
  completer=completer,
656
829
  complete_while_typing=True,
657
830
  reserve_space_for_menu=12,
658
- 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",
659
832
  )
660
833
 
661
834
 
@@ -755,9 +928,25 @@ def handle_slash_command(context: McpContext, text: str, messages: list[str] | N
755
928
  return note_text(context, raw_rest)
756
929
  if command == "/clear":
757
930
  return "__clear__"
931
+ if command in CLAUDE_COMPAT_NAMES:
932
+ return claude_compat_command_text(command, raw_rest)
758
933
  return f"Unknown slash command: {command}\n\n{SLASH_HELP}"
759
934
 
760
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
+
761
950
  def init_text(context: McpContext) -> str:
762
951
  data = payload(call_tool(context, "agentram_init", {}))
763
952
  created = data.get("created", [])
@@ -852,7 +1041,7 @@ def agents_text() -> str:
852
1041
  def slash_palette_text(filter_text: str = "") -> str:
853
1042
  normalized = filter_text.strip().lower().lstrip("/")
854
1043
  rows = []
855
- for command, description in SLASH_COMMANDS:
1044
+ for command, description in slash_entries():
856
1045
  command_key = command.lower().lstrip("/")
857
1046
  if normalized and normalized not in command_key:
858
1047
  continue
@@ -993,45 +1182,154 @@ def workflow_text(context: McpContext) -> str:
993
1182
  lines.append(f"- {slot}: not bound")
994
1183
  return "\n".join(lines)
995
1184
 
996
- def supervisor_workflow_text(context: McpContext, prompt: str) -> str:
997
- agents = context.profile_store.list_agents()
998
- supervisor = agents.get("supervisor")
999
- main_agent = agents.get("main")
1000
- small_agent = agents.get("small")
1001
- memory_context = retrieve_text(context, prompt, task_id="codex", limit=8)
1002
- plan = supervisor_plan(supervisor, prompt, memory_context)
1003
- outputs: dict[str, str] = {}
1004
- with ThreadPoolExecutor(max_workers=2) as executor:
1005
- futures = {}
1006
- if main_agent:
1007
- futures[executor.submit(call_agent_endpoint, main_agent, build_main_agent_prompt(prompt, plan, memory_context))] = "main"
1008
- if small_agent:
1009
- futures[executor.submit(call_agent_endpoint, small_agent, build_small_agent_prompt(prompt, plan, memory_context))] = "small"
1010
- for future, slot in list(futures.items()):
1011
- try:
1012
- outputs[slot] = future.result()
1013
- except Exception as error: # noqa: BLE001 - agent boundary
1014
- outputs[slot] = f"error: {model_error_hint(str(error))}"
1015
- if not main_agent:
1016
- outputs["main"] = "No main agent bound. Use /bind-agent main <provider> <model>."
1017
- persist_workflow_memory(context, prompt, plan, outputs)
1018
- return "\n".join(
1019
- [
1020
- "Workflow: supervisor -> main + small -> merge",
1021
- "Supervisor plan:",
1022
- plan,
1023
- "",
1024
- "Main agent result:",
1025
- outputs.get("main", "-"),
1026
- "",
1027
- "Small memory agent notes:",
1028
- outputs.get("small", "No small agent bound."),
1029
- ]
1030
- )
1031
-
1032
- def persist_workflow_memory(context: McpContext, prompt: str, plan: str, outputs: dict[str, str]) -> None:
1033
- small_notes = outputs.get("small", "").strip()
1034
- 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()
1035
1333
  note_parts = [
1036
1334
  f"User request: {prompt}",
1037
1335
  f"Supervisor plan: {trim(plan.strip(), 1200)}" if plan.strip() else "Supervisor plan: -",
@@ -1062,14 +1360,14 @@ def supervisor_plan(supervisor: AgentModelEndpoint | None, prompt: str, memory_c
1062
1360
  def call_agent_endpoint(endpoint: AgentModelEndpoint, prompt: str) -> str:
1063
1361
  return MultiModelCodingOrchestrator([endpoint])._call_endpoint(endpoint, prompt)
1064
1362
 
1065
- def build_supervisor_prompt(prompt: str, memory_context: str) -> str:
1066
- 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])
1067
-
1068
- def build_main_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
1069
- 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])
1070
-
1071
- def build_small_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
1072
- 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])
1073
1371
 
1074
1372
  def bind_router_text(context: McpContext, options: dict[str, object]) -> str:
1075
1373
  api_key_env = str(options.get("api_key_env", ""))
@@ -1154,9 +1452,9 @@ def extract_json_object(text: str) -> str:
1154
1452
 
1155
1453
  def heuristic_intent(prompt: str) -> dict[str, object]:
1156
1454
  lower = prompt.lower()
1157
- memory_words = ["ghi nhớ", "remember", "note", "decision", "lưu lại", "save this"]
1158
- planning_words = ["goal", "kế hoạch", "ke hoach", "milestone", "mission", "drift", "next task", "roadmap", "mục tiêu", "muc tieu"]
1159
- 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"]
1160
1458
  if any(word in lower for word in memory_words):
1161
1459
  return {"intent": "memory", "confidence": 0.75}
1162
1460
  if any(word in lower for word in planning_words):
@@ -1191,12 +1489,16 @@ def model_error_hint(message: str) -> str:
1191
1489
  return message + " | Fix: /remove-model <id> or re-bind with --base-url http://localhost:8000/v1"
1192
1490
  if "actively refused" in lower or "connection refused" in lower or "winerror 10061" in lower:
1193
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."
1194
1494
  if "http 404" in lower or "404: not found" in lower:
1195
1495
  return message + " | Fix: verify base_url includes /v1, provider supports /chat/completions, and model name exists."
1196
1496
  if "http 400" in lower and "not supported" in lower:
1197
1497
  return message + " | Fix: this account/provider rejects that model. Bind a supported model name from your local endpoint."
1198
1498
  if "returned non-json" in lower or "expecting value" in lower:
1199
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."
1200
1502
  if "missing api key env" in lower:
1201
1503
  return message + " | Fix: set environment variable, then bind using --api-key-env ENV_NAME, not raw key."
1202
1504
  return message
@@ -1273,7 +1575,7 @@ def draw_screen(context: McpContext, messages: list[str], activity: str | None =
1273
1575
  left_width = 30
1274
1576
  right_width = width - left_width - 3
1275
1577
  print("+" + "-" * (width - 2) + "+")
1276
- print(box_text("AgentRAM Code | RAM + Claude subagents + slash commands", width))
1578
+ print(box_text("AgentRAM Code | RAM + Claude subagents + slash commands", width))
1277
1579
  print("+" + "-" * (width - 2) + "+")
1278
1580
 
1279
1581
  sidebar = sidebar_lines(context)
@@ -1328,7 +1630,7 @@ def sidebar_lines(context: McpContext) -> list[str]:
1328
1630
 
1329
1631
 
1330
1632
  def trim(text: str, limit: int) -> str:
1331
- return text if len(text) <= limit else text[: limit - 1] + ""
1633
+ return text if len(text) <= limit else text[: limit - 3] + "..."
1332
1634
 
1333
1635
 
1334
1636
  def menu_lines() -> list[str]:
@@ -1,12 +1,14 @@
1
- from __future__ import annotations
1
+ from __future__ import annotations
2
2
 
3
3
  from concurrent.futures import ThreadPoolExecutor, as_completed
4
4
  from dataclasses import dataclass, field
5
5
  import json
6
- import os
7
- import re
8
- import shlex
9
- import subprocess
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+ import shlex
10
+ import subprocess
11
+ import tempfile
10
12
  import urllib.error
11
13
  import urllib.request
12
14
  from typing import Any, Callable
@@ -30,6 +32,12 @@ PROVIDER_ALIASES = {
30
32
  "claudesubagent": "claude-subagent",
31
33
  "claude_subagent": "claude-subagent",
32
34
  "claude-subagent": "claude-subagent",
35
+ "codex": "codex-cli",
36
+ "codexcli": "codex-cli",
37
+ "codex_cli": "codex-cli",
38
+ "codex-code": "codex-cli",
39
+ "codex_cli_agent": "codex-cli",
40
+ "codex-cli": "codex-cli",
33
41
  }
34
42
 
35
43
 
@@ -38,6 +46,38 @@ def normalize_provider_name(provider: str) -> str:
38
46
  return PROVIDER_ALIASES.get(normalized, normalized)
39
47
 
40
48
 
49
+ def split_cli_command(command: str) -> list[str]:
50
+ cleaned = command.strip().strip('"')
51
+ if not cleaned:
52
+ return []
53
+ if os.name == "nt":
54
+ lowered = cleaned.lower()
55
+ for marker in (".cmd", ".exe", ".bat", ".ps1"):
56
+ index = lowered.find(marker)
57
+ if index != -1:
58
+ end = index + len(marker)
59
+ program = cleaned[:end]
60
+ rest = cleaned[end:].strip()
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 ""
79
+
80
+
41
81
  @dataclass(frozen=True)
42
82
  class AgentModelEndpoint:
43
83
  provider: str
@@ -137,10 +177,12 @@ class MultiModelCodingOrchestrator:
137
177
  return self._call_anthropic(endpoint, prompt)
138
178
  if provider in {"claude-code", "claude-cli", "claude-subagent", "claude-code-subagent"}:
139
179
  return self._call_claude_code_subagent(endpoint, prompt)
180
+ if provider in {"codex-cli"}:
181
+ return self._call_codex_cli_agent(endpoint, prompt)
140
182
  raise ValueError(f"Unsupported provider for execution: {endpoint.provider}")
141
183
 
142
184
  def _call_openai_compatible(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
143
- api_key = os.getenv(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 ""
144
186
  if not endpoint.base_url:
145
187
  raise ValueError("base_url is required")
146
188
  if endpoint.api_key_env and not api_key:
@@ -162,17 +204,20 @@ class MultiModelCodingOrchestrator:
162
204
  if error.code == 404:
163
205
  hint += " | base_url should include /v1 but not /chat/completions; verify provider URL and model name"
164
206
  raise RuntimeError(f"{hint} | response={body[:500]}") from error
165
- try:
166
- data = json.loads(raw)
167
- except json.JSONDecodeError as error:
168
- streamed = parse_openai_sse(raw)
169
- if streamed:
170
- return streamed
171
- raise RuntimeError(f"OpenAI-compatible returned non-JSON at {url} | response={raw[:500]}") from error
172
- return str(data.get("choices", [{}])[0].get("message", {}).get("content", ""))
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)
173
218
 
174
219
  def _call_anthropic(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
175
- api_key = os.getenv(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 ""
176
221
  if endpoint.api_key_env and not api_key:
177
222
  raise ValueError(f"missing API key env: {endpoint.api_key_env}")
178
223
  base_url = endpoint.base_url.rstrip("/") if endpoint.base_url else "https://api.anthropic.com/v1"
@@ -192,7 +237,7 @@ class MultiModelCodingOrchestrator:
192
237
 
193
238
  def _call_claude_code_subagent(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
194
239
  command = endpoint.base_url.strip() or os.getenv("AGENTRAM_CLAUDE_COMMAND", "claude")
195
- args = shlex.split(command)
240
+ args = split_cli_command(command)
196
241
  if not args:
197
242
  raise ValueError("claude command is required")
198
243
  subagent_prompt = "\n".join(
@@ -200,25 +245,81 @@ class MultiModelCodingOrchestrator:
200
245
  f"Use Claude Code subagent entrypoint: {endpoint.model}",
201
246
  f"Subagent role: {endpoint.role}",
202
247
  "If that subagent exists in .claude/agents, follow it. If not, act as this role.",
203
- "Return concise coding-agent output only.",
248
+ "If role is coder/main, you may inspect and edit files using Claude Code tools. If role is memory/small, do not edit files; write notes only.",
249
+ "Return concise coding-agent output, changed files, and verification status.",
204
250
  "",
205
251
  prompt,
206
252
  ]
207
253
  )
208
- process = subprocess.run(
209
- [*args, "-p", subagent_prompt],
210
- check=False,
211
- capture_output=True,
212
- encoding="utf-8",
213
- timeout=300,
214
- )
254
+ try:
255
+ process = subprocess.run(
256
+ [*args, "-p", subagent_prompt],
257
+ check=False,
258
+ capture_output=True,
259
+ encoding="utf-8",
260
+ timeout=300,
261
+ )
262
+ except FileNotFoundError as error:
263
+ 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
215
264
  output = (process.stdout or "").strip()
216
265
  error = (process.stderr or "").strip()
217
266
  if process.returncode != 0:
218
267
  raise RuntimeError(error or f"claude command failed with exit code {process.returncode}")
219
268
  return output or error
220
269
 
221
- def parse_openai_sse(raw: str) -> str:
270
+ def _call_codex_cli_agent(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
271
+ command = endpoint.base_url.strip() or os.getenv("AGENTRAM_CODEX_COMMAND", "codex exec")
272
+ args = split_cli_command(command)
273
+ if not args:
274
+ raise ValueError("codex command is required")
275
+ if len(args) == 1:
276
+ args.append("exec")
277
+ codex_prompt = "\n".join(
278
+ [
279
+ f"You are AgentRAM {endpoint.role} agent running through Codex CLI.",
280
+ f"Model hint: {endpoint.model}",
281
+ "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.",
282
+ "Return concise result, changed files, tests/verification, and blockers.",
283
+ "",
284
+ prompt,
285
+ ]
286
+ )
287
+ prompt_path = ""
288
+ try:
289
+ with tempfile.NamedTemporaryFile(
290
+ "w",
291
+ encoding="utf-8",
292
+ delete=False,
293
+ dir=os.getcwd(),
294
+ prefix=".agentram_codex_prompt_",
295
+ suffix=".txt",
296
+ ) as prompt_file:
297
+ prompt_file.write(codex_prompt)
298
+ prompt_path = prompt_file.name
299
+ short_prompt = f"Read AgentRAM task from file and execute it: {prompt_path}"
300
+ process = subprocess.run(
301
+ [*args, short_prompt],
302
+ check=False,
303
+ capture_output=True,
304
+ encoding="utf-8",
305
+ timeout=int(os.getenv("AGENTRAM_CLI_TIMEOUT", "900")),
306
+ )
307
+ except FileNotFoundError as error:
308
+ raise RuntimeError(f"Codex CLI command not found: {args[0]}. Install Codex CLI or set base_url to the full command path.") from error
309
+ finally:
310
+ if prompt_path:
311
+ try:
312
+ os.remove(prompt_path)
313
+ except OSError:
314
+ pass
315
+ output = (process.stdout or "").strip()
316
+ error = (process.stderr or "").strip()
317
+ if process.returncode != 0:
318
+ raise RuntimeError(error or f"codex command failed with exit code {process.returncode}")
319
+ return output or error
320
+
321
+
322
+ def parse_openai_sse(raw: str) -> str:
222
323
  parts: list[str] = []
223
324
  for line in raw.splitlines():
224
325
  stripped = line.strip()
@@ -245,9 +346,39 @@ def parse_openai_sse(raw: str) -> str:
245
346
  message = choice.get("message", {})
246
347
  if isinstance(message, dict) and message.get("content"):
247
348
  parts.append(str(message.get("content")))
248
- return strip_thinking_blocks("".join(parts)).strip()
249
-
250
- def strip_thinking_blocks(text: str) -> str:
349
+ return strip_thinking_blocks("".join(parts)).strip()
350
+
351
+ def parse_openai_mixed_json(raw: str) -> dict[str, Any]:
352
+ stripped = raw.strip()
353
+ if not stripped.startswith("{"):
354
+ return {}
355
+ for marker in ("\ndata:", "\r\ndata:"):
356
+ if marker not in stripped:
357
+ continue
358
+ candidate = stripped.split(marker, 1)[0].strip()
359
+ try:
360
+ value = json.loads(candidate)
361
+ except json.JSONDecodeError:
362
+ return {}
363
+ return value if isinstance(value, dict) else {}
364
+ return {}
365
+
366
+ def extract_openai_message(data: dict[str, Any]) -> str:
367
+ choices = data.get("choices", [])
368
+ if not choices or not isinstance(choices, list):
369
+ return ""
370
+ first = choices[0]
371
+ if not isinstance(first, dict):
372
+ return ""
373
+ message = first.get("message", {})
374
+ if isinstance(message, dict):
375
+ return strip_thinking_blocks(str(message.get("content", ""))).strip()
376
+ delta = first.get("delta", {})
377
+ if isinstance(delta, dict):
378
+ return strip_thinking_blocks(str(delta.get("content", ""))).strip()
379
+ return ""
380
+
381
+ def strip_thinking_blocks(text: str) -> str:
251
382
  text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL | re.IGNORECASE)
252
383
  text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE)
253
384
  text = re.sub(r"^\s*</?(thinking|think)>\s*", "", text, flags=re.IGNORECASE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentram",
3
- "version": "0.1.21",
3
+ "version": "0.1.29",
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.21"
7
+ version = "0.1.29"
8
8
  description = "Async, model-agnostic RAM layer for agentic coding tools."
9
9
  readme = "README.md"
10
10
  license = "MIT"