agentram 0.1.3 → 0.1.6

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
@@ -53,9 +53,10 @@ If using the Claude plugin, `.mcp.json` sets `AGENTRAM_AUTO_INIT=true`, so these
53
53
 
54
54
  ## Terminal UI CLI
55
55
 
56
- AgentRAM also includes a dependency-free terminal UI with a Claude Code-like chat frame and button menu:
56
+ AgentRAM includes a terminal UI with Claude-like slash autocomplete. It uses `prompt_toolkit` when installed and falls back to plain `input()` otherwise:
57
57
 
58
58
  ```bash
59
+ pip install -e ".[tui]"
59
60
  agentram
60
61
  ```
61
62
 
@@ -357,12 +358,20 @@ Minimum usage pattern:
357
358
 
358
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.
359
360
 
360
- Bind models:
361
-
362
- ```bash
363
- agentram bind-model claude claude-3-5-sonnet-latest --role reviewer --api-key-env ANTHROPIC_API_KEY
364
- agentram bind-model openai-compatible qwen2.5-coder --role coder --base-url http://localhost:8000/v1 --api-key-env OPENAI_API_KEY
365
- ```
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
+ ```
366
375
 
367
376
  Build a dry-run orchestration plan:
368
377
 
@@ -387,10 +396,11 @@ agentram_run_coding_orchestration
387
396
 
388
397
  TUI slash commands. Plain text only records/retrieves RAM; use `/ask` to call bound models:
389
398
 
390
- ```text
391
- /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY
392
- /models
393
- /orchestrate review CLI orchestration
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
394
404
  /ask xin chào
395
405
  ```
396
406
 
@@ -870,3 +880,121 @@ Inside TUI:
870
880
  ```
871
881
 
872
882
  Do not paste raw API keys into `/bind-model`. `api_key_env` means environment variable name, e.g. `OPENAI_API_KEY`.
883
+
884
+
885
+ ### Optional Rich TUI
886
+
887
+ For live slash-command dropdown and command metadata, install the optional TUI extra:
888
+
889
+ ```bash
890
+ pip install agentram[tui]
891
+ ```
892
+
893
+ Without `prompt_toolkit`, AgentRAM still works with basic terminal input; `/` then Enter opens the full slash palette.
894
+
895
+
896
+ ### Fix Bad Model Endpoints
897
+
898
+ If `/ask` shows `base_url is required`, old model endpoints were bound without a URL. Remove or clear them:
899
+
900
+ ```text
901
+ /models
902
+ /remove-model model_xxx
903
+ # or
904
+ /clear-models
905
+ ```
906
+
907
+ If `/ask` shows connection refused, the configured OpenAI-compatible server is not running at `base_url`:
908
+
909
+ ```text
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
912
+ ```
913
+
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
+
1000
+ Small agent should not own big direction. It records plans, decisions, files, risks, next tasks, and coding facts into RAM.
package/agentram/cli.py CHANGED
@@ -1,15 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
- import argparse
4
- import os
5
- import shlex
6
- import shutil
7
- import textwrap
3
+ import argparse
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ import json
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import textwrap
10
+ import time
8
11
  from pathlib import Path
9
12
  from typing import Any
10
13
 
11
- from .config import default_ram_root
12
- from .mcp_server import McpContext, call_tool
14
+ from .config import default_ram_root
15
+ from .mcp_server import McpContext, call_tool
16
+ from .orchestration import AgentModelEndpoint, MultiModelCodingOrchestrator
13
17
 
14
18
  SUBAGENTS: dict[str, str] = {
15
19
  "memory": "Summarize important facts, decisions, files, risks, and next tasks into AgentRAM events. Do not edit code.",
@@ -18,6 +22,37 @@ SUBAGENTS: dict[str, str] = {
18
22
  "documenter": "Update README or usage notes from current implementation. Keep docs concise and runnable.",
19
23
  }
20
24
 
25
+ SLASH_COMMANDS: list[tuple[str, str]] = [
26
+ ("/help", "Show commands"),
27
+ ("/init", "Create .agentram files"),
28
+ ("/status", "Show RAM status"),
29
+ ("/retrieve <query>", "Print relevant RAM context"),
30
+ ("/goal", "Show Goal Stack"),
31
+ ("/set-goal key=value ...", "Update mission/current_goal/current_task/etc"),
32
+ ("/drift <task>", "Check goal drift"),
33
+ ("/events", "Show latest events"),
34
+ ("/replay", "Replay events into memory"),
35
+ ("/agents", "List Claude-style subagent presets"),
36
+ ("/agent <name> <task>", "Build Claude Code subagent prompt"),
37
+ ("/models", "List bound coding models"),
38
+ ("/remove-model <id>", "Remove bound coding model"),
39
+ ("/disable-model <id>", "Disable bound coding model"),
40
+ ("/clear-models", "Remove all bound coding models"),
41
+ ("/bind-model provider model", "Bind model endpoint"),
42
+ ("/bind-router provider model", "Bind intent router model"),
43
+ ("/bind-agent slot provider model", "Bind supervisor/main/small agent"),
44
+ ("/workflow", "Show supervisor workflow agents"),
45
+ ("/route on|off|status", "Toggle auto prompt routing"),
46
+ ("/orchestrate <task>", "Build multi-model agent plan"),
47
+ ("/ask <prompt>", "Execute bound models and return outputs"),
48
+ ("/claude-install [path]", "Install .claude commands and agents"),
49
+ ("/note <text>", "Save decision note"),
50
+ ("/clear", "Clear chat"),
51
+ ("/exit", "Quit"),
52
+ ("Plain text", "Capture coding chat message and retrieve context"),
53
+ ]
54
+
55
+
21
56
  SLASH_HELP = """Slash commands:
22
57
  /help Show commands
23
58
  /init Create .agentram files
@@ -31,15 +66,22 @@ SLASH_HELP = """Slash commands:
31
66
  /agents List subagent presets
32
67
  /agent <name> <task> Build Claude Code subagent prompt
33
68
  /models List bound coding models
34
- /bind-model provider model Bind model endpoint
35
- /orchestrate <task> Build multi-model agent plan
69
+ /remove-model <id> Remove bound coding model
70
+ /disable-model <id> Disable bound coding model
71
+ /clear-models Remove all bound coding models
72
+ /bind-model provider model Bind model endpoint
73
+ /bind-router provider model Bind intent router model
74
+ /bind-agent slot provider model Bind supervisor/main/small agent
75
+ /workflow Show supervisor workflow agents
76
+ /route on|off|status Toggle auto prompt routing
77
+ /orchestrate <task> Build multi-model agent plan
36
78
  /ask <prompt> Execute bound models and return outputs
37
79
  /claude-install [path] Install .claude commands and agents
38
80
  /note <text> Save decision note
39
81
  /clear Clear chat
40
82
  /exit Quit
41
- Plain text Capture coding chat message and retrieve context
42
- """.strip()
83
+ Plain text Capture coding chat message and retrieve context
84
+ """.strip()
43
85
 
44
86
 
45
87
  def build_parser() -> argparse.ArgumentParser:
@@ -53,14 +95,39 @@ def build_parser() -> argparse.ArgumentParser:
53
95
  subparsers.add_parser("goal", help="Show goal stack.")
54
96
  subparsers.add_parser("agents", help="List subagent presets.")
55
97
  subparsers.add_parser("models", help="List bound coding model endpoints.")
98
+ subparsers.add_parser("clear-models", help="Remove all bound coding model endpoints.")
99
+
100
+ remove_model = subparsers.add_parser("remove-model", help="Remove one bound coding model endpoint.")
101
+ remove_model.add_argument("id", help="Endpoint id.")
102
+
103
+ disable_model = subparsers.add_parser("disable-model", help="Disable one bound coding model endpoint.")
104
+ disable_model.add_argument("id", help="Endpoint id.")
56
105
 
57
106
  bind_model = subparsers.add_parser("bind-model", help="Bind a coding model endpoint.")
58
- bind_model.add_argument("provider", help="Provider name, e.g. claude, openai-compatible, groq.")
59
- bind_model.add_argument("model", help="Model name.")
107
+ bind_model.add_argument("provider", help="Provider name, e.g. claude, openai-compatible, groq, claude-subagent.")
108
+ bind_model.add_argument("model", help="Model name or Claude subagent name.")
60
109
  bind_model.add_argument("--role", default="coder", help="Agent role.")
61
110
  bind_model.add_argument("--base-url", default="", help="Provider base URL.")
62
- bind_model.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
63
- bind_model.add_argument("--modalities", default="text", help="Comma-separated modalities.")
111
+ bind_model.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
112
+ bind_model.add_argument("--modalities", default="text", help="Comma-separated modalities.")
113
+
114
+ bind_router = subparsers.add_parser("bind-router", help="Bind a separate intent router model endpoint.")
115
+ bind_router.add_argument("provider", help="Provider name, e.g. openai-compatible, groq, claude-subagent.")
116
+ bind_router.add_argument("model", help="Router model name.")
117
+ bind_router.add_argument("--base-url", default="", help="Provider base URL or local command for claude-subagent.")
118
+ bind_router.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
119
+
120
+ bind_agent = subparsers.add_parser("bind-agent", help="Bind supervisor/main/small workflow agent.")
121
+ bind_agent.add_argument("slot", choices=["supervisor", "main", "small"], help="Workflow slot.")
122
+ bind_agent.add_argument("provider", help="Provider name, e.g. claude-subagent, openai-compatible, groq.")
123
+ bind_agent.add_argument("model", help="Model or subagent name.")
124
+ bind_agent.add_argument("--base-url", default="", help="Provider base URL or local command for claude-subagent.")
125
+ bind_agent.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
126
+
127
+ subparsers.add_parser("workflow", help="Show supervisor/main/small workflow agents.")
128
+
129
+ route = subparsers.add_parser("route", help="Toggle or show auto prompt routing.")
130
+ route.add_argument("mode", nargs="?", default="status", choices=["on", "off", "status"], help="Auto route mode.")
64
131
 
65
132
  orchestrate = subparsers.add_parser("orchestrate", help="Build or run multi-model coding orchestration.")
66
133
  orchestrate.add_argument("task", nargs="?", default="", help="Coding task.")
@@ -132,9 +199,30 @@ def run_command(args: argparse.Namespace) -> int:
132
199
  if command == "models":
133
200
  print(models_text(context))
134
201
  return 0
135
- if command == "bind-model":
136
- print(bind_model_text(context, vars(args)))
202
+ if command == "remove-model":
203
+ print(remove_model_text(context, args.id))
204
+ return 0
205
+ if command == "disable-model":
206
+ print(disable_model_text(context, args.id))
137
207
  return 0
208
+ if command == "clear-models":
209
+ print(clear_models_text(context))
210
+ return 0
211
+ if command == "bind-model":
212
+ print(bind_model_text(context, vars(args)))
213
+ return 0
214
+ if command == "bind-router":
215
+ print(bind_router_text(context, vars(args)))
216
+ return 0
217
+ if command == "bind-agent":
218
+ print(bind_agent_text(context, vars(args)))
219
+ return 0
220
+ if command == "workflow":
221
+ print(workflow_text(context))
222
+ return 0
223
+ if command == "route":
224
+ print(route_text(context, args.mode))
225
+ return 0
138
226
  if command == "orchestrate":
139
227
  task = args.task or input("Task > ").strip()
140
228
  print(orchestrate_text(context, task, files=args.file, execute=args.execute))
@@ -151,37 +239,98 @@ def run_command(args: argparse.Namespace) -> int:
151
239
 
152
240
  def run_tui(context: McpContext) -> None:
153
241
  context.init_storage()
154
- messages = ["system > AgentRAM coding-agent CLI ready", f"system > RAM root: {context.ram_root}", "system > Type /help, /agent reviewer <task>, or plain coding request."]
242
+ prompt_session = create_prompt_session()
243
+ input_hint = "live slash autocomplete enabled" if prompt_session else "basic input fallback; install prompt_toolkit for live slash dropdown"
244
+ messages = [
245
+ "system > AgentRAM coding-agent CLI ready",
246
+ f"system > RAM root: {context.ram_root}",
247
+ f"system > {input_hint}",
248
+ ]
155
249
  while True:
156
250
  draw_screen(context, messages)
157
- user_input = input("You > ").strip()
251
+ try:
252
+ user_input = read_tui_input(prompt_session).strip()
253
+ except (EOFError, KeyboardInterrupt):
254
+ return
158
255
  if user_input in {"0", "q", "quit", "exit", "/exit", "/quit"}:
159
256
  return
160
257
  if user_input in BUTTONS:
161
258
  user_input = BUTTONS[user_input]
162
259
  if user_input:
163
260
  messages.append(f"user > {user_input}")
164
- response = handle_chat_input(context, user_input, messages)
261
+ response = run_with_activity(context, messages, user_input)
165
262
  if response == "__clear__":
166
263
  messages.clear()
167
264
  messages.append("system > Chat cleared.")
168
265
  elif response:
169
266
  messages.append("agentram > " + response)
170
- del messages[:-10]
267
+ del messages[:-10]
268
+
269
+ def run_with_activity(context: McpContext, messages: list[str], user_input: str) -> str:
270
+ with ThreadPoolExecutor(max_workers=1) as executor:
271
+ future = executor.submit(handle_chat_input, context, user_input, messages)
272
+ frames = ["thinking", "thinking.", "thinking..", "thinking...", "acting", "acting.", "acting..", "acting..."]
273
+ index = 0
274
+ while not future.done():
275
+ draw_screen(context, messages, activity=f"agentram > {frames[index % len(frames)]}")
276
+ index += 1
277
+ time.sleep(0.18)
278
+ return future.result()
279
+
280
+
281
+
282
+ def slash_command_names() -> list[str]:
283
+ return [command.split()[0] for command, _ in SLASH_COMMANDS if command.startswith("/")]
284
+
285
+
286
+ def slash_command_meta() -> dict[str, str]:
287
+ return {command.split()[0]: description for command, description in SLASH_COMMANDS if command.startswith("/")}
288
+
289
+
290
+ def create_prompt_session() -> Any | None:
291
+ try:
292
+ from prompt_toolkit import PromptSession
293
+ from prompt_toolkit.completion import WordCompleter
294
+ except ImportError:
295
+ return None
296
+
297
+ completer = WordCompleter(
298
+ slash_command_names(),
299
+ meta_dict=slash_command_meta(),
300
+ ignore_case=True,
301
+ sentence=True,
302
+ )
303
+ return PromptSession(
304
+ completer=completer,
305
+ complete_while_typing=True,
306
+ reserve_space_for_menu=12,
307
+ bottom_toolbar="Type / for slash commands · /agents for subagents · /ask to call bound models",
308
+ )
309
+
171
310
 
311
+ def read_tui_input(prompt_session: Any | None) -> str:
312
+ if prompt_session is None:
313
+ return input("You > ")
314
+ return prompt_session.prompt("You > ")
172
315
 
173
- def handle_chat_input(context: McpContext, text: str, messages: list[str] | None = None) -> str:
316
+ def handle_chat_input(context: McpContext, text: str, messages: list[str] | None = None) -> str:
174
317
  if not text:
175
318
  return SLASH_HELP
319
+ if text == "/":
320
+ return slash_palette_text()
176
321
  if text.startswith("/"):
177
322
  return handle_slash_command(context, text, messages)
178
- call_tool(
179
- context,
180
- "agentram_emit_event",
181
- {"type": "USER_MESSAGE", "payload": {"content": text}, "task_id": "codex", "ingest": False},
182
- )
183
- context_block = retrieve_text(context, text, task_id="codex", limit=6)
184
- return "Coding chat captured. Relevant context:\n" + context_block
323
+ call_tool(
324
+ context,
325
+ "agentram_emit_event",
326
+ {"type": "USER_MESSAGE", "payload": {"content": text}, "task_id": "codex", "ingest": False},
327
+ )
328
+ if context.profile_store.list_agents():
329
+ return supervisor_workflow_text(context, text)
330
+ if context.profile_store.auto_route_enabled():
331
+ return route_plain_prompt_text(context, text)
332
+ context_block = retrieve_text(context, text, task_id="codex", limit=6)
333
+ return "Coding chat captured. Relevant context:\n" + context_block
185
334
 
186
335
 
187
336
  def handle_slash_command(context: McpContext, text: str, messages: list[str] | None = None) -> str:
@@ -212,10 +361,30 @@ def handle_slash_command(context: McpContext, text: str, messages: list[str] | N
212
361
  return agents_text()
213
362
  if command == "/models":
214
363
  return models_text(context)
215
- if command == "/bind-model":
216
- if len(rest) < 2:
217
- return "Usage: /bind-model <provider> <model> [--role coder] [--base-url URL] [--api-key-env ENV_NAME]"
218
- return bind_model_text(context, parse_bind_model_args(rest))
364
+ if command == "/remove-model":
365
+ return remove_model_text(context, raw_rest)
366
+ if command == "/disable-model":
367
+ return disable_model_text(context, raw_rest)
368
+ if command == "/clear-models":
369
+ return clear_models_text(context)
370
+ if command == "/bind-model":
371
+ if len(rest) < 2:
372
+ return "Usage: /bind-model <provider> <model> [--role coder] [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
373
+ return bind_model_text(context, parse_bind_model_args(rest))
374
+ if command == "/bind-router":
375
+ if len(rest) < 2:
376
+ return "Usage: /bind-router <provider> <model> [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
377
+ return bind_router_text(context, parse_bind_model_args(rest))
378
+ if command == "/bind-agent":
379
+ if len(rest) < 3:
380
+ return "Usage: /bind-agent <supervisor|main|small> <provider> <model> [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
381
+ options = parse_bind_model_args(rest[1:])
382
+ options["slot"] = rest[0]
383
+ return bind_agent_text(context, options)
384
+ if command == "/workflow":
385
+ return workflow_text(context)
386
+ if command == "/route":
387
+ return route_text(context, rest[0] if rest else "status")
219
388
  if command == "/orchestrate":
220
389
  execute = "--execute" in rest
221
390
  task = raw_rest.replace("--execute", "").strip()
@@ -313,8 +482,34 @@ def replay_text(context: McpContext, arguments: dict[str, object]) -> str:
313
482
 
314
483
 
315
484
  def agents_text() -> str:
316
- lines = ["Subagent presets:"]
317
- lines.extend(f"- {name}: {description}" for name, description in SUBAGENTS.items())
485
+ rows = [
486
+ ("agentram-memory", "Memory", "Records durable notes. Never edits code."),
487
+ ("agentram-planner", "Planner", "Checks Goal Stack and decomposes work."),
488
+ ("agentram-reviewer", "Reviewer", "Reviews changes, tests, and drift risk."),
489
+ ("agentram-docs", "Docs", "Updates README and usage docs."),
490
+ ]
491
+ lines = ["Claude Code subagents", ""]
492
+ for name, label, description in rows:
493
+ lines.append(f" {name.ljust(22)} {label}")
494
+ lines.append(f" {description}")
495
+ lines.append("")
496
+ lines.append("Use: /agent reviewer <task>")
497
+ lines.append("Install: /claude-install .")
498
+ return "\n".join(lines)
499
+
500
+
501
+ def slash_palette_text(filter_text: str = "") -> str:
502
+ normalized = filter_text.strip().lower().lstrip("/")
503
+ rows = []
504
+ for command, description in SLASH_COMMANDS:
505
+ command_key = command.lower().lstrip("/")
506
+ if normalized and normalized not in command_key:
507
+ continue
508
+ rows.append((command, description))
509
+ command_width = max([len(command) for command, _ in rows] + [12])
510
+ lines = ["Slash commands", ""]
511
+ for command, description in rows:
512
+ lines.append(f" {command.ljust(command_width)} {description}")
318
513
  return "\n".join(lines)
319
514
 
320
515
 
@@ -343,7 +538,7 @@ def models_text(context: McpContext) -> str:
343
538
  data = payload(call_tool(context, "agentram_read_coding_models", {}))
344
539
  endpoints = data.get("endpoints", [])
345
540
  if not endpoints:
346
- return "No coding models bound. Use /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY"
541
+ return "No coding models bound. Use /bind-model claude-subagent agentram-reviewer role=reviewer or /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY"
347
542
  lines = ["Bound coding models:"]
348
543
  for endpoint in endpoints:
349
544
  lines.append(f"- {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')} modalities={','.join(endpoint.get('modalities', []))}")
@@ -372,6 +567,28 @@ def parse_bind_model_args(parts: list[str]) -> dict[str, object]:
372
567
  return options
373
568
 
374
569
 
570
+
571
+
572
+ def remove_model_text(context: McpContext, endpoint_id: str) -> str:
573
+ endpoint_id = endpoint_id.strip()
574
+ if not endpoint_id:
575
+ return "Usage: /remove-model <id>"
576
+ data = payload(call_tool(context, "agentram_remove_coding_model", {"id": endpoint_id}))
577
+ return f"Removed models: {data.get('removed', 0)}"
578
+
579
+
580
+ def disable_model_text(context: McpContext, endpoint_id: str) -> str:
581
+ endpoint_id = endpoint_id.strip()
582
+ if not endpoint_id:
583
+ return "Usage: /disable-model <id>"
584
+ data = payload(call_tool(context, "agentram_disable_coding_model", {"id": endpoint_id}))
585
+ return f"Disabled models: {data.get('changed', 0)}"
586
+
587
+
588
+ def clear_models_text(context: McpContext) -> str:
589
+ payload(call_tool(context, "agentram_clear_coding_models", {}))
590
+ return "Cleared all bound coding models."
591
+
375
592
  def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
376
593
  api_key_env = str(options.get("api_key_env", ""))
377
594
  if api_key_env.startswith(("sk-", "gsk_", "sk_")):
@@ -393,7 +610,194 @@ def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
393
610
  return f"Bound model: {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')}"
394
611
 
395
612
 
396
- def orchestrate_text(context: McpContext, task: str, files: list[str] | None = None, execute: bool = False) -> str:
613
+ def bind_agent_text(context: McpContext, options: dict[str, object]) -> str:
614
+ slot = str(options.get("slot", "")).strip().lower()
615
+ if slot not in {"supervisor", "main", "small"}:
616
+ return "Usage: /bind-agent <supervisor|main|small> <provider> <model>"
617
+ api_key_env = str(options.get("api_key_env", ""))
618
+ if api_key_env.startswith(("sk-", "gsk_", "sk_")):
619
+ return "Do not paste raw API key into api_key_env. Set env var first, then use api_key_env=ENV_NAME."
620
+ role = {"supervisor": "supervisor", "main": "coder", "small": "memory"}[slot]
621
+ endpoint = AgentModelEndpoint(
622
+ provider=str(options.get("provider", "openai-compatible")),
623
+ model=str(options.get("model", "")),
624
+ role=role,
625
+ base_url=str(options.get("base_url", "")),
626
+ api_key_env=api_key_env,
627
+ modalities=["text"],
628
+ )
629
+ context.profile_store.set_agent(slot, endpoint)
630
+ return f"Bound {slot} agent: provider={endpoint.provider} model={endpoint.model} role={endpoint.role}"
631
+
632
+ def workflow_text(context: McpContext) -> str:
633
+ agents = context.profile_store.list_agents()
634
+ if not agents:
635
+ return "No supervisor workflow agents bound. Use /bind-agent supervisor|main|small <provider> <model>."
636
+ lines = ["Supervisor workflow agents:"]
637
+ for slot in ["supervisor", "main", "small"]:
638
+ endpoint = agents.get(slot)
639
+ if endpoint:
640
+ lines.append(f"- {slot}: {endpoint.provider}:{endpoint.model} role={endpoint.role}")
641
+ else:
642
+ lines.append(f"- {slot}: not bound")
643
+ return "\n".join(lines)
644
+
645
+ def supervisor_workflow_text(context: McpContext, prompt: str) -> str:
646
+ agents = context.profile_store.list_agents()
647
+ supervisor = agents.get("supervisor")
648
+ main_agent = agents.get("main")
649
+ small_agent = agents.get("small")
650
+ memory_context = retrieve_text(context, prompt, task_id="codex", limit=8)
651
+ plan = supervisor_plan(supervisor, prompt, memory_context)
652
+ outputs: dict[str, str] = {}
653
+ with ThreadPoolExecutor(max_workers=2) as executor:
654
+ futures = {}
655
+ if main_agent:
656
+ futures[executor.submit(call_agent_endpoint, main_agent, build_main_agent_prompt(prompt, plan, memory_context))] = "main"
657
+ if small_agent:
658
+ futures[executor.submit(call_agent_endpoint, small_agent, build_small_agent_prompt(prompt, plan, memory_context))] = "small"
659
+ for future, slot in list(futures.items()):
660
+ try:
661
+ outputs[slot] = future.result()
662
+ except Exception as error: # noqa: BLE001 - agent boundary
663
+ outputs[slot] = f"error: {error}"
664
+ if not main_agent:
665
+ outputs["main"] = "No main agent bound. Use /bind-agent main <provider> <model>."
666
+ if small_agent and outputs.get("small") and not outputs["small"].startswith("error:"):
667
+ call_tool(
668
+ context,
669
+ "agentram_emit_event",
670
+ {"type": "DECISION", "payload": {"content": outputs["small"]}, "task_id": "codex", "ingest": True},
671
+ )
672
+ return "\n".join(
673
+ [
674
+ "Workflow: supervisor -> main + small -> merge",
675
+ "Supervisor plan:",
676
+ plan,
677
+ "",
678
+ "Main agent result:",
679
+ outputs.get("main", "-"),
680
+ "",
681
+ "Small memory agent notes:",
682
+ outputs.get("small", "No small agent bound."),
683
+ ]
684
+ )
685
+
686
+ def supervisor_plan(supervisor: AgentModelEndpoint | None, prompt: str, memory_context: str) -> str:
687
+ if not supervisor:
688
+ return "No supervisor bound. Fallback plan: handle user request, run main coding agent, ask small agent to update RAM notes."
689
+ try:
690
+ return call_agent_endpoint(supervisor, build_supervisor_prompt(prompt, memory_context))
691
+ except Exception as error: # noqa: BLE001 - supervisor boundary
692
+ return f"Supervisor error: {error}\nFallback plan: handle user request, run main coding agent, ask small agent to update RAM notes."
693
+
694
+ def call_agent_endpoint(endpoint: AgentModelEndpoint, prompt: str) -> str:
695
+ return MultiModelCodingOrchestrator([endpoint])._call_endpoint(endpoint, prompt)
696
+
697
+ def build_supervisor_prompt(prompt: str, memory_context: str) -> str:
698
+ 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])
699
+
700
+ def build_main_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
701
+ 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])
702
+
703
+ def build_small_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
704
+ 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])
705
+
706
+ def bind_router_text(context: McpContext, options: dict[str, object]) -> str:
707
+ api_key_env = str(options.get("api_key_env", ""))
708
+ if api_key_env.startswith(("sk-", "gsk_", "sk_")):
709
+ return "Do not paste raw API key into api_key_env. Set env var first, then use api_key_env=ENV_NAME."
710
+ endpoint = AgentModelEndpoint(
711
+ provider=str(options.get("provider", "openai-compatible")),
712
+ model=str(options.get("model", "")),
713
+ role="router",
714
+ base_url=str(options.get("base_url", "")),
715
+ api_key_env=api_key_env,
716
+ modalities=["text"],
717
+ )
718
+ context.profile_store.set_router(endpoint)
719
+ return f"Bound router: provider={endpoint.provider} model={endpoint.model}"
720
+
721
+ def route_text(context: McpContext, mode: str) -> str:
722
+ normalized = mode.strip().lower() or "status"
723
+ if normalized == "on":
724
+ context.profile_store.set_auto_route(True)
725
+ elif normalized == "off":
726
+ context.profile_store.set_auto_route(False)
727
+ elif normalized != "status":
728
+ return "Usage: /route on|off|status"
729
+ router = context.profile_store.get_router()
730
+ return "\n".join(
731
+ [
732
+ f"Auto route: {'on' if context.profile_store.auto_route_enabled() else 'off'}",
733
+ f"Router model: {router.provider}:{router.model}" if router else "Router model: heuristic fallback",
734
+ ]
735
+ )
736
+
737
+ def route_plain_prompt_text(context: McpContext, prompt: str) -> str:
738
+ decision = classify_prompt_intent(context, prompt)
739
+ intent = str(decision.get("intent", "chat"))
740
+ confidence = float(decision.get("confidence", 0.0))
741
+ prefix = f"Route: {intent} confidence={confidence:.2f}"
742
+ if intent == "coding":
743
+ if context.profile_store.list_endpoints():
744
+ return prefix + "\n" + orchestrate_text(context, prompt, execute=True)
745
+ context_block = retrieve_text(context, prompt, task_id="codex", limit=6)
746
+ return prefix + "\nNo coding models bound. Use /bind-model.\nCoding chat captured. Relevant context:\n" + context_block
747
+ if intent == "planning":
748
+ return prefix + "\n" + drift_text(context, prompt) + "\n\n" + goal_text(context)
749
+ if intent == "memory":
750
+ return prefix + "\n" + note_text(context, prompt)
751
+ context_block = retrieve_text(context, prompt, task_id="codex", limit=6)
752
+ return prefix + "\nCoding chat captured. Relevant context:\n" + context_block
753
+
754
+ def classify_prompt_intent(context: McpContext, prompt: str) -> dict[str, object]:
755
+ router = context.profile_store.get_router()
756
+ if router:
757
+ try:
758
+ response = MultiModelCodingOrchestrator([router])._call_endpoint(router, build_router_prompt(prompt))
759
+ parsed = json.loads(extract_json_object(response))
760
+ if isinstance(parsed, dict) and parsed.get("intent") in {"coding", "planning", "memory", "chat"}:
761
+ return {"intent": parsed.get("intent"), "confidence": float(parsed.get("confidence", 0.0)), "source": "model"}
762
+ except Exception as error: # noqa: BLE001 - router fallback boundary
763
+ return {**heuristic_intent(prompt), "source": "heuristic", "router_error": str(error)}
764
+ return {**heuristic_intent(prompt), "source": "heuristic"}
765
+
766
+ def build_router_prompt(prompt: str) -> str:
767
+ return "\n".join(
768
+ [
769
+ "Classify user prompt for AgentRAM routing.",
770
+ "Return only JSON, no markdown.",
771
+ "Schema: {\"intent\":\"coding|planning|memory|chat\",\"confidence\":0.0,\"reason\":\"short\"}",
772
+ "coding = edit/review/debug/refactor/test/code/file requests.",
773
+ "planning = goal stack, milestone, mission, next task, drift, roadmap.",
774
+ "memory = remember/note/decision/fact to save.",
775
+ "chat = other normal conversation.",
776
+ f"Prompt: {prompt}",
777
+ ]
778
+ )
779
+
780
+ def extract_json_object(text: str) -> str:
781
+ start = text.find("{")
782
+ end = text.rfind("}")
783
+ if start == -1 or end == -1 or end < start:
784
+ return "{}"
785
+ return text[start : end + 1]
786
+
787
+ def heuristic_intent(prompt: str) -> dict[str, object]:
788
+ lower = prompt.lower()
789
+ memory_words = ["ghi nhớ", "remember", "note", "decision", "lưu lại", "save this"]
790
+ planning_words = ["goal", "kế hoạch", "ke hoach", "milestone", "mission", "drift", "next task", "roadmap", "mục tiêu", "muc tieu"]
791
+ coding_words = ["sửa", "sua", "fix", "bug", "code", "test", "refactor", "review", "file", ".py", ".js", "endpoint", "class", "function"]
792
+ if any(word in lower for word in memory_words):
793
+ return {"intent": "memory", "confidence": 0.75}
794
+ if any(word in lower for word in planning_words):
795
+ return {"intent": "planning", "confidence": 0.75}
796
+ if any(word in lower for word in coding_words):
797
+ return {"intent": "coding", "confidence": 0.72}
798
+ return {"intent": "chat", "confidence": 0.55}
799
+
800
+ def orchestrate_text(context: McpContext, task: str, files: list[str] | None = None, execute: bool = False) -> str:
397
801
  if not task.strip():
398
802
  return "Usage: /orchestrate <coding task>"
399
803
  tool = "agentram_run_coding_orchestration" if execute else "agentram_build_coding_orchestration"
@@ -409,9 +813,20 @@ def orchestrate_text(context: McpContext, task: str, files: list[str] | None = N
409
813
  endpoint = result.get("endpoint", {})
410
814
  status = "ok" if result.get("ok") else "error"
411
815
  output = result.get("output") or result.get("error", "")
412
- lines.append(f"- {endpoint.get('id')} {status}: {str(output)[:300]}")
816
+ lines.append(f"- {endpoint.get('id')} {status}: {model_error_hint(str(output))[:360]}")
413
817
  return "\n".join(lines)
414
818
 
819
+
820
+ def model_error_hint(message: str) -> str:
821
+ lower = message.lower()
822
+ if "base_url is required" in lower:
823
+ return message + " | Fix: /remove-model <id> or re-bind with --base-url http://localhost:8000/v1"
824
+ if "actively refused" in lower or "connection refused" in lower or "winerror 10061" in lower:
825
+ return message + " | Fix: start your OpenAI-compatible server at base_url, or change --base-url."
826
+ if "missing api key env" in lower:
827
+ return message + " | Fix: set environment variable, then bind using --api-key-env ENV_NAME, not raw key."
828
+ return message
829
+
415
830
  def claude_template_root() -> Path:
416
831
  return Path(__file__).resolve().parent.parent / ".claude"
417
832
 
@@ -478,24 +893,26 @@ BUTTONS = {
478
893
  }
479
894
 
480
895
 
481
- def draw_screen(context: McpContext, messages: list[str]) -> None:
896
+ def draw_screen(context: McpContext, messages: list[str], activity: str | None = None) -> None:
482
897
  clear_terminal()
483
898
  width = min(max(shutil.get_terminal_size((110, 34)).columns, 86), 132)
484
899
  left_width = 30
485
900
  right_width = width - left_width - 3
486
901
  print("+" + "-" * (width - 2) + "+")
487
- print(box_text("AgentRAM Code Agent CLI | RAM + Claude subagents + slash commands", width))
902
+ print(box_text("AgentRAM Code | RAM + Claude subagents + slash commands", width))
488
903
  print("+" + "-" * (width - 2) + "+")
489
904
 
490
905
  sidebar = sidebar_lines(context)
491
- chat = wrap_messages(messages, right_width - 4)[-22:]
906
+ display_messages = [*messages, activity] if activity else messages
907
+ chat = wrap_messages(display_messages, right_width - 4)[-22:]
492
908
  rows = max(len(sidebar), len(chat), 22)
493
909
  for index in range(rows):
494
910
  left = sidebar[index] if index < len(sidebar) else ""
495
911
  right = chat[index] if index < len(chat) else ""
496
912
  print("| " + left[: left_width - 2].ljust(left_width - 2) + "| " + right[: right_width - 2].ljust(right_width - 2) + "|")
497
913
  print("+" + "-" * (left_width - 1) + "+" + "-" * (right_width) + "+")
498
- print(box_text("Input: plain coding request, /help, /agent reviewer <task>, /claude-install .", width))
914
+ footer = "Thinking/acting..." if activity else "Input: type / for live dropdown. Enter '/' for full palette. /ask calls models."
915
+ print(box_text(footer, width))
499
916
  print("+" + "-" * (width - 2) + "+")
500
917
 
501
918
 
@@ -526,10 +943,11 @@ def sidebar_lines(context: McpContext) -> list[str]:
526
943
  " 9 help 0 exit",
527
944
  "",
528
945
  "CLAUDE",
946
+ " / = command palette",
947
+ " /agents",
529
948
  " /claude-install .",
530
949
  " /models",
531
- " /bind-model claude <model>",
532
- " /orchestrate <task>",
950
+ " /clear-models",
533
951
  " /ask <prompt>",
534
952
  ]
535
953
 
@@ -244,6 +244,29 @@ def tools_list() -> dict[str, Any]:
244
244
  "required": ["provider", "model"],
245
245
  },
246
246
  },
247
+ {
248
+ "name": "agentram_remove_coding_model",
249
+ "description": "Remove one bound coding model endpoint by id.",
250
+ "inputSchema": {
251
+ "type": "object",
252
+ "properties": {"id": {"type": "string"}},
253
+ "required": ["id"],
254
+ },
255
+ },
256
+ {
257
+ "name": "agentram_disable_coding_model",
258
+ "description": "Disable one bound coding model endpoint by id without deleting it.",
259
+ "inputSchema": {
260
+ "type": "object",
261
+ "properties": {"id": {"type": "string"}},
262
+ "required": ["id"],
263
+ },
264
+ },
265
+ {
266
+ "name": "agentram_clear_coding_models",
267
+ "description": "Remove all bound coding model endpoints from this project RAM.",
268
+ "inputSchema": {"type": "object", "properties": {}},
269
+ },
247
270
  {
248
271
  "name": "agentram_read_coding_models",
249
272
  "description": "Read coding-agent model endpoints bound to this project RAM.",
@@ -349,6 +372,24 @@ def call_tool(context: McpContext, name: str, arguments: dict[str, Any]) -> dict
349
372
  data = context.profile_store.upsert(endpoint)
350
373
  return tool_result({"ok": True, "endpoint": endpoint.to_dict(), "profile": data})
351
374
 
375
+ if name == "agentram_remove_coding_model":
376
+ endpoint_id = str(arguments.get("id", ""))
377
+ if not endpoint_id.strip():
378
+ raise ValueError("id is required")
379
+ data = context.profile_store.remove(endpoint_id)
380
+ return tool_result({"ok": True, **data})
381
+
382
+ if name == "agentram_disable_coding_model":
383
+ endpoint_id = str(arguments.get("id", ""))
384
+ if not endpoint_id.strip():
385
+ raise ValueError("id is required")
386
+ data = context.profile_store.set_enabled(endpoint_id, False)
387
+ return tool_result({"ok": True, **data})
388
+
389
+ if name == "agentram_clear_coding_models":
390
+ data = context.profile_store.clear()
391
+ return tool_result({"ok": True, **data})
392
+
352
393
  if name == "agentram_read_coding_models":
353
394
  endpoints = context.profile_store.list_endpoints()
354
395
  return tool_result({"endpoints": [endpoint.to_dict() for endpoint in endpoints]})
@@ -4,6 +4,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
4
4
  from dataclasses import dataclass, field
5
5
  import json
6
6
  import os
7
+ import shlex
8
+ import subprocess
7
9
  import urllib.request
8
10
  from typing import Any, Callable
9
11
  from uuid import uuid4
@@ -106,6 +108,8 @@ class MultiModelCodingOrchestrator:
106
108
  return self._call_openai_compatible(endpoint, prompt)
107
109
  if provider in {"anthropic", "claude"}:
108
110
  return self._call_anthropic(endpoint, prompt)
111
+ if provider in {"claude-code", "claude-cli", "claude-subagent", "claude-code-subagent"}:
112
+ return self._call_claude_code_subagent(endpoint, prompt)
109
113
  raise ValueError(f"Unsupported provider for execution: {endpoint.provider}")
110
114
 
111
115
  def _call_openai_compatible(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
@@ -142,4 +146,32 @@ class MultiModelCodingOrchestrator:
142
146
  content = data.get("content", [])
143
147
  if content and isinstance(content, list):
144
148
  return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
145
- return str(data)
149
+ return str(data)
150
+
151
+ def _call_claude_code_subagent(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
152
+ command = endpoint.base_url.strip() or os.getenv("AGENTRAM_CLAUDE_COMMAND", "claude")
153
+ args = shlex.split(command)
154
+ if not args:
155
+ raise ValueError("claude command is required")
156
+ subagent_prompt = "\n".join(
157
+ [
158
+ f"Use Claude Code subagent entrypoint: {endpoint.model}",
159
+ f"Subagent role: {endpoint.role}",
160
+ "If that subagent exists in .claude/agents, follow it. If not, act as this role.",
161
+ "Return concise coding-agent output only.",
162
+ "",
163
+ prompt,
164
+ ]
165
+ )
166
+ process = subprocess.run(
167
+ [*args, "-p", subagent_prompt],
168
+ check=False,
169
+ capture_output=True,
170
+ encoding="utf-8",
171
+ timeout=300,
172
+ )
173
+ output = (process.stdout or "").strip()
174
+ error = (process.stderr or "").strip()
175
+ if process.returncode != 0:
176
+ raise RuntimeError(error or f"claude command failed with exit code {process.returncode}")
177
+ return output or error
@@ -80,15 +80,27 @@ class JsonAgentProfileStore:
80
80
  self.path = Path(path)
81
81
  self.path.parent.mkdir(parents=True, exist_ok=True)
82
82
 
83
- def load(self) -> dict[str, object]:
84
- if not self.path.exists():
85
- return {"updated_at": utc_now(), "endpoints": []}
86
- return json.loads(self.path.read_text(encoding="utf-8"))
87
-
88
- def save(self, endpoints: list[AgentModelEndpoint]) -> dict[str, object]:
89
- payload = {"updated_at": utc_now(), "endpoints": [endpoint.to_dict() for endpoint in endpoints]}
90
- self.path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
91
- return payload
83
+ def load(self) -> dict[str, object]:
84
+ if not self.path.exists():
85
+ return {"updated_at": utc_now(), "endpoints": [], "router": None, "auto_route": True, "agents": {}}
86
+ data = json.loads(self.path.read_text(encoding="utf-8"))
87
+ data.setdefault("endpoints", [])
88
+ data.setdefault("router", None)
89
+ data.setdefault("auto_route", True)
90
+ data.setdefault("agents", {})
91
+ return data
92
+
93
+ def save(self, endpoints: list[AgentModelEndpoint]) -> dict[str, object]:
94
+ current = self.load()
95
+ payload = {
96
+ "updated_at": utc_now(),
97
+ "endpoints": [endpoint.to_dict() for endpoint in endpoints],
98
+ "router": current.get("router"),
99
+ "auto_route": bool(current.get("auto_route", True)),
100
+ "agents": current.get("agents", {}),
101
+ }
102
+ self.path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
103
+ return payload
92
104
 
93
105
  def list_endpoints(self) -> list[AgentModelEndpoint]:
94
106
  data = self.load()
@@ -108,6 +120,86 @@ class JsonAgentProfileStore:
108
120
  updated.append(endpoint)
109
121
  return self.save(updated)
110
122
 
123
+ def remove(self, endpoint_id: str) -> dict[str, object]:
124
+ endpoints = self.list_endpoints()
125
+ kept = [endpoint for endpoint in endpoints if endpoint.id != endpoint_id]
126
+ payload = self.save(kept)
127
+ payload["removed"] = len(endpoints) - len(kept)
128
+ return payload
129
+
130
+ def set_enabled(self, endpoint_id: str, enabled: bool) -> dict[str, object]:
131
+ endpoints = self.list_endpoints()
132
+ updated: list[AgentModelEndpoint] = []
133
+ changed = 0
134
+ for endpoint in endpoints:
135
+ if endpoint.id == endpoint_id:
136
+ data = endpoint.to_dict()
137
+ data["enabled"] = enabled
138
+ updated.append(AgentModelEndpoint.from_dict(data))
139
+ changed += 1
140
+ else:
141
+ updated.append(endpoint)
142
+ payload = self.save(updated)
143
+ payload["changed"] = changed
144
+ return payload
145
+
146
+ def clear(self) -> dict[str, object]:
147
+ payload = self.save([])
148
+ payload["cleared"] = True
149
+ return payload
150
+
151
+ def get_router(self) -> AgentModelEndpoint | None:
152
+ router = self.load().get("router")
153
+ if not isinstance(router, dict):
154
+ return None
155
+ return AgentModelEndpoint.from_dict(router)
156
+
157
+ def set_router(self, endpoint: AgentModelEndpoint | None) -> dict[str, object]:
158
+ current = self.load()
159
+ current["updated_at"] = utc_now()
160
+ current["router"] = endpoint.to_dict() if endpoint else None
161
+ self.path.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
162
+ return current
163
+
164
+ def auto_route_enabled(self) -> bool:
165
+ return bool(self.load().get("auto_route", True))
166
+
167
+ def set_auto_route(self, enabled: bool) -> dict[str, object]:
168
+ current = self.load()
169
+ current["updated_at"] = utc_now()
170
+ current["auto_route"] = enabled
171
+ self.path.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
172
+ return current
173
+
174
+ def get_agent(self, slot: str) -> AgentModelEndpoint | None:
175
+ agents = self.load().get("agents", {})
176
+ if not isinstance(agents, dict):
177
+ return None
178
+ item = agents.get(slot)
179
+ if not isinstance(item, dict):
180
+ return None
181
+ return AgentModelEndpoint.from_dict(item)
182
+
183
+ def set_agent(self, slot: str, endpoint: AgentModelEndpoint | None) -> dict[str, object]:
184
+ current = self.load()
185
+ agents = current.get("agents", {})
186
+ if not isinstance(agents, dict):
187
+ agents = {}
188
+ if endpoint is None:
189
+ agents.pop(slot, None)
190
+ else:
191
+ agents[slot] = endpoint.to_dict()
192
+ current["agents"] = agents
193
+ current["updated_at"] = utc_now()
194
+ self.path.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
195
+ return current
196
+
197
+ def list_agents(self) -> dict[str, AgentModelEndpoint]:
198
+ agents = self.load().get("agents", {})
199
+ if not isinstance(agents, dict):
200
+ return {}
201
+ return {slot: AgentModelEndpoint.from_dict(item) for slot, item in agents.items() if isinstance(item, dict)}
202
+
111
203
 
112
204
  def ensure_ram_storage(root: str | Path) -> dict[str, object]:
113
205
  ram_root = Path(root)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentram",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
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.3"
7
+ version = "0.1.6"
8
8
  description = "Async, model-agnostic RAM layer for agentic coding tools."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -21,6 +21,9 @@ classifiers = [
21
21
  requires-python = ">=3.10"
22
22
  dependencies = []
23
23
 
24
+ [project.optional-dependencies]
25
+ tui = ["prompt_toolkit>=3.0"]
26
+
24
27
  [project.urls]
25
28
  Homepage = "https://github.com/your-org/agentram"
26
29
  Repository = "https://github.com/your-org/agentram"