agentram 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agentram/cli.py CHANGED
@@ -1,20 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
- import argparse
4
- import asyncio
5
- from concurrent.futures import ThreadPoolExecutor
6
- import json
7
- import os
8
- import shlex
9
- import shutil
10
- import textwrap
11
- import time
3
+ import argparse
4
+ import asyncio
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ import json
7
+ import os
8
+ import shlex
9
+ import shutil
10
+ import textwrap
11
+ import time
12
12
  from pathlib import Path
13
13
  from typing import Any
14
14
 
15
- from .config import default_ram_root
16
- from .mcp_server import McpContext, call_tool
17
- from .orchestration import AgentModelEndpoint, MultiModelCodingOrchestrator
15
+ from .config import default_ram_root
16
+ from .mcp_server import McpContext, call_tool
17
+ from .orchestration import AgentModelEndpoint, MultiModelCodingOrchestrator
18
18
 
19
19
  SUBAGENTS: dict[str, str] = {
20
20
  "memory": "Summarize important facts, decisions, files, risks, and next tasks into AgentRAM events. Do not edit code.",
@@ -38,17 +38,17 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
38
38
  ("/models", "List bound coding models"),
39
39
  ("/remove-model <id>", "Remove bound coding model"),
40
40
  ("/disable-model <id>", "Disable bound coding model"),
41
- ("/clear-models", "Remove all bound coding models"),
42
- ("/bind-model provider model", "Bind model endpoint"),
43
- ("/bind-router provider model", "Bind intent router model"),
44
- ("/bind-agent slot provider model", "Bind supervisor/main/small agent"),
45
- ("/workflow", "Show supervisor workflow agents"),
46
- ("/route on|off|status", "Toggle auto prompt routing"),
47
- ("/orchestrate <task>", "Build multi-model agent plan"),
48
- ("/ask <prompt>", "Execute bound models and return outputs"),
49
- ("/claude-install [path]", "Install .claude commands and agents"),
50
- ("/note <text>", "Save decision note"),
51
- ("/clear", "Clear chat"),
41
+ ("/clear-models", "Remove all bound coding models"),
42
+ ("/bind-model provider model", "Bind model endpoint"),
43
+ ("/bind-router provider model", "Bind intent router model"),
44
+ ("/bind-agent slot provider model", "Bind supervisor/main/small agent"),
45
+ ("/workflow", "Show supervisor workflow agents"),
46
+ ("/route on|off|status", "Toggle auto prompt routing"),
47
+ ("/orchestrate <task>", "Build multi-model agent plan"),
48
+ ("/ask <prompt>", "Execute bound models and return outputs"),
49
+ ("/claude-install [path]", "Install .claude commands and agents"),
50
+ ("/note <text>", "Save decision note"),
51
+ ("/clear", "Clear chat"),
52
52
  ("/exit", "Quit"),
53
53
  ("Plain text", "Capture coding chat message and retrieve context"),
54
54
  ]
@@ -69,20 +69,20 @@ SLASH_HELP = """Slash commands:
69
69
  /models List bound coding models
70
70
  /remove-model <id> Remove bound coding model
71
71
  /disable-model <id> Disable bound coding model
72
- /clear-models Remove all bound coding models
73
- /bind-model provider model Bind model endpoint
74
- /bind-router provider model Bind intent router model
75
- /bind-agent slot provider model Bind supervisor/main/small agent
76
- /workflow Show supervisor workflow agents
77
- /route on|off|status Toggle auto prompt routing
78
- /orchestrate <task> Build multi-model agent plan
72
+ /clear-models Remove all bound coding models
73
+ /bind-model provider model Bind model endpoint
74
+ /bind-router provider model Bind intent router model
75
+ /bind-agent slot provider model Bind supervisor/main/small agent
76
+ /workflow Show supervisor workflow agents
77
+ /route on|off|status Toggle auto prompt routing
78
+ /orchestrate <task> Build multi-model agent plan
79
79
  /ask <prompt> Execute bound models and return outputs
80
- /claude-install [path] Install .claude commands and agents
81
- /note <text> Save decision note
82
- /clear Clear chat
80
+ /claude-install [path] Install .claude commands and agents
81
+ /note <text> Save decision note
82
+ /clear Clear chat
83
83
  /exit Quit
84
- Plain text Capture coding chat message and retrieve context
85
- """.strip()
84
+ Plain text Capture coding chat message and retrieve context
85
+ """.strip()
86
86
 
87
87
 
88
88
  def build_parser() -> argparse.ArgumentParser:
@@ -105,30 +105,30 @@ def build_parser() -> argparse.ArgumentParser:
105
105
  disable_model.add_argument("id", help="Endpoint id.")
106
106
 
107
107
  bind_model = subparsers.add_parser("bind-model", help="Bind a coding model endpoint.")
108
- bind_model.add_argument("provider", help="Provider name, e.g. claude, openai-compatible, groq, claude-subagent.")
109
- bind_model.add_argument("model", help="Model name or Claude subagent name.")
108
+ bind_model.add_argument("provider", help="Provider name, e.g. claude, openai-compatible, groq, claude-subagent.")
109
+ bind_model.add_argument("model", help="Model name or Claude subagent name.")
110
110
  bind_model.add_argument("--role", default="coder", help="Agent role.")
111
111
  bind_model.add_argument("--base-url", default="", help="Provider base URL.")
112
- bind_model.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
113
- bind_model.add_argument("--modalities", default="text", help="Comma-separated modalities.")
114
-
115
- bind_router = subparsers.add_parser("bind-router", help="Bind a separate intent router model endpoint.")
116
- bind_router.add_argument("provider", help="Provider name, e.g. openai-compatible, groq, claude-subagent.")
117
- bind_router.add_argument("model", help="Router model name.")
118
- bind_router.add_argument("--base-url", default="", help="Provider base URL or local command for claude-subagent.")
119
- bind_router.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
120
-
121
- bind_agent = subparsers.add_parser("bind-agent", help="Bind supervisor/main/small workflow agent.")
122
- bind_agent.add_argument("slot", choices=["supervisor", "main", "small"], help="Workflow slot.")
123
- bind_agent.add_argument("provider", help="Provider name, e.g. claude-subagent, openai-compatible, groq.")
124
- bind_agent.add_argument("model", help="Model or subagent name.")
125
- bind_agent.add_argument("--base-url", default="", help="Provider base URL or local command for claude-subagent.")
126
- bind_agent.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
127
-
128
- subparsers.add_parser("workflow", help="Show supervisor/main/small workflow agents.")
129
-
130
- route = subparsers.add_parser("route", help="Toggle or show auto prompt routing.")
131
- route.add_argument("mode", nargs="?", default="status", choices=["on", "off", "status"], help="Auto route mode.")
112
+ bind_model.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
113
+ bind_model.add_argument("--modalities", default="text", help="Comma-separated modalities.")
114
+
115
+ bind_router = subparsers.add_parser("bind-router", help="Bind a separate intent router model endpoint.")
116
+ bind_router.add_argument("provider", help="Provider name, e.g. openai-compatible, groq, claude-subagent.")
117
+ bind_router.add_argument("model", help="Router model name.")
118
+ bind_router.add_argument("--base-url", default="", help="Provider base URL or local command for claude-subagent.")
119
+ bind_router.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
120
+
121
+ bind_agent = subparsers.add_parser("bind-agent", help="Bind supervisor/main/small workflow agent.")
122
+ bind_agent.add_argument("slot", choices=["supervisor", "main", "small"], help="Workflow slot.")
123
+ bind_agent.add_argument("provider", help="Provider name, e.g. claude-subagent, openai-compatible, groq.")
124
+ bind_agent.add_argument("model", help="Model or subagent name.")
125
+ bind_agent.add_argument("--base-url", default="", help="Provider base URL or local command for claude-subagent.")
126
+ bind_agent.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
127
+
128
+ subparsers.add_parser("workflow", help="Show supervisor/main/small workflow agents.")
129
+
130
+ route = subparsers.add_parser("route", help="Toggle or show auto prompt routing.")
131
+ route.add_argument("mode", nargs="?", default="status", choices=["on", "off", "status"], help="Auto route mode.")
132
132
 
133
133
  orchestrate = subparsers.add_parser("orchestrate", help="Build or run multi-model coding orchestration.")
134
134
  orchestrate.add_argument("task", nargs="?", default="", help="Coding task.")
@@ -209,21 +209,21 @@ def run_command(args: argparse.Namespace) -> int:
209
209
  if command == "clear-models":
210
210
  print(clear_models_text(context))
211
211
  return 0
212
- if command == "bind-model":
213
- print(bind_model_text(context, vars(args)))
214
- return 0
215
- if command == "bind-router":
216
- print(bind_router_text(context, vars(args)))
217
- return 0
218
- if command == "bind-agent":
219
- print(bind_agent_text(context, vars(args)))
220
- return 0
221
- if command == "workflow":
222
- print(workflow_text(context))
223
- return 0
224
- if command == "route":
225
- print(route_text(context, args.mode))
226
- return 0
212
+ if command == "bind-model":
213
+ print(bind_model_text(context, vars(args)))
214
+ return 0
215
+ if command == "bind-router":
216
+ print(bind_router_text(context, vars(args)))
217
+ return 0
218
+ if command == "bind-agent":
219
+ print(bind_agent_text(context, vars(args)))
220
+ return 0
221
+ if command == "workflow":
222
+ print(workflow_text(context))
223
+ return 0
224
+ if command == "route":
225
+ print(route_text(context, args.mode))
226
+ return 0
227
227
  if command == "orchestrate":
228
228
  task = args.task or input("Task > ").strip()
229
229
  print(orchestrate_text(context, task, files=args.file, execute=args.execute))
@@ -238,132 +238,246 @@ def run_command(args: argparse.Namespace) -> int:
238
238
  raise SystemExit(f"unknown command: {command}")
239
239
 
240
240
 
241
- def run_tui(context: McpContext) -> None:
242
- if run_textual_tui(context):
243
- return
244
- context.init_storage()
241
+ def run_tui(context: McpContext) -> None:
242
+ if run_textual_tui(context):
243
+ return
244
+ context.init_storage()
245
245
  prompt_session = create_prompt_session()
246
246
  input_hint = "live slash autocomplete enabled" if prompt_session else "basic input fallback; install prompt_toolkit for live slash dropdown"
247
- messages = [
248
- "system > AgentRAM coding-agent CLI ready",
249
- f"system > RAM root: {context.ram_root}",
250
- f"system > {input_hint}",
251
- ]
252
- while True:
253
- draw_screen(context, messages)
247
+ messages = [
248
+ "system > AgentRAM coding-agent CLI ready",
249
+ f"system > RAM root: {context.ram_root}",
250
+ f"system > {input_hint}",
251
+ ]
252
+ while True:
253
+ draw_screen(context, messages)
254
254
  try:
255
255
  user_input = read_tui_input(prompt_session).strip()
256
256
  except (EOFError, KeyboardInterrupt):
257
257
  return
258
- if user_input in {"0", "q", "quit", "exit", "/exit", "/quit"}:
259
- return
260
- if user_input in BUTTONS:
261
- user_input = BUTTONS[user_input]
262
- if user_input:
263
- messages.append(f"user > {user_input}")
264
- response = run_with_activity(context, messages, user_input)
258
+ if user_input in {"0", "q", "quit", "exit", "/exit", "/quit"}:
259
+ return
260
+ if user_input in BUTTONS:
261
+ user_input = BUTTONS[user_input]
262
+ if user_input:
263
+ messages.append(f"user > {user_input}")
264
+ response = run_with_activity(context, messages, user_input)
265
265
  if response == "__clear__":
266
266
  messages.clear()
267
267
  messages.append("system > Chat cleared.")
268
268
  elif response:
269
269
  messages.append("agentram > " + response)
270
- del messages[:-200]
271
-
272
- def run_textual_tui(context: McpContext) -> bool:
273
- try:
274
- from textual.app import App, ComposeResult
275
- from textual.containers import Horizontal, Vertical
276
- from textual.widgets import Footer, Header, Input, RichLog, Static
277
- except ImportError:
278
- return False
279
-
280
- class AgentRAMTextualApp(App[None]):
281
- CSS = """
282
- Screen { background: #0b0f14; color: #d7dde8; }
283
- #layout { height: 1fr; }
284
- #sidebar { width: 32; border: solid #263241; padding: 1; }
285
- #main { width: 1fr; }
286
- #chat { height: 1fr; border: solid #263241; padding: 1; overflow-y: scroll; }
287
- #input { dock: bottom; border: solid #3b82f6; }
288
- .muted { color: #8b9bb0; }
289
- """
290
- BINDINGS = [("ctrl+c", "quit", "Quit")]
291
-
292
- def __init__(self, mcp_context: McpContext) -> None:
293
- super().__init__()
294
- self.context = mcp_context
295
- self.messages: list[str] = []
296
- self.busy = False
297
-
298
- def compose(self) -> ComposeResult:
299
- yield Header(show_clock=True)
300
- with Horizontal(id="layout"):
301
- yield Static(id="sidebar")
302
- with Vertical(id="main"):
303
- yield RichLog(id="chat", wrap=True, highlight=True, markup=False, auto_scroll=True)
304
- yield Input(placeholder="Prompt hoặc /command. Mouse wheel scroll trong khung chat.", id="input")
305
- yield Footer()
306
-
307
- def on_mount(self) -> None:
308
- self.context.init_storage()
309
- self.chat.write("system > AgentRAM Textual TUI ready")
310
- self.chat.write(f"system > RAM root: {self.context.ram_root}")
311
- self.refresh_sidebar()
312
- self.set_interval(1.0, self.refresh_sidebar)
313
-
314
- @property
315
- def chat(self) -> RichLog:
316
- return self.query_one("#chat", RichLog)
317
-
318
- def refresh_sidebar(self) -> None:
319
- self.query_one("#sidebar", Static).update("\n".join(sidebar_lines(self.context)))
320
-
321
- async def on_input_submitted(self, event: Input.Submitted) -> None:
322
- text = event.value.strip()
323
- event.input.value = ""
324
- if not text:
325
- return
326
- if text in {"0", "q", "quit", "exit", "/exit", "/quit"}:
327
- self.exit()
328
- return
329
- if text in BUTTONS:
330
- text = BUTTONS[text]
331
- self.chat.write(f"user > {text}")
332
- if text == "/clear":
333
- self.chat.clear()
334
- self.chat.write("system > Chat cleared.")
335
- return
336
- if self.busy:
337
- self.chat.write("agentram > busy, wait current task")
338
- return
339
- self.busy = True
340
- self.chat.write("agentram > thinking...")
341
- try:
342
- response = await asyncio.to_thread(handle_chat_input, self.context, text, self.messages)
343
- except Exception as error: # noqa: BLE001 - TUI boundary
344
- response = f"error: {error}"
345
- finally:
346
- self.busy = False
347
- if response == "__clear__":
348
- self.chat.clear()
349
- self.chat.write("system > Chat cleared.")
350
- elif response:
351
- self.chat.write("agentram > " + response)
352
- self.refresh_sidebar()
353
-
354
- AgentRAMTextualApp(context).run()
355
- return True
356
-
357
- def run_with_activity(context: McpContext, messages: list[str], user_input: str) -> str:
358
- with ThreadPoolExecutor(max_workers=1) as executor:
359
- future = executor.submit(handle_chat_input, context, user_input, messages)
360
- frames = ["thinking", "thinking.", "thinking..", "thinking...", "acting", "acting.", "acting..", "acting..."]
361
- index = 0
362
- while not future.done():
363
- draw_screen(context, messages, activity=f"agentram > {frames[index % len(frames)]}")
364
- index += 1
365
- time.sleep(0.18)
366
- return future.result()
270
+ del messages[:-200]
271
+
272
+ def run_textual_tui(context: McpContext) -> bool:
273
+ try:
274
+ from textual.app import App, ComposeResult
275
+ from textual.containers import Horizontal, Vertical
276
+ from textual.widgets import Button, Footer, Header, Input, RichLog, Static
277
+ except ImportError:
278
+ return False
279
+
280
+ class AgentRAMTextualApp(App[None]):
281
+ CSS = """
282
+ Screen { background: #0b0f14; color: #d7dde8; }
283
+ #layout { height: 1fr; }
284
+ #sidebar { width: 32; border: solid #263241; padding: 1; }
285
+ #main { width: 1fr; }
286
+ #setup { height: auto; border: solid #3b82f6; padding: 1; background: #101826; }
287
+ #setup-title { height: auto; color: #dbeafe; }
288
+ #setup-actions { height: auto; }
289
+ .setup-input { height: 3; }
290
+ #chat { height: 1fr; border: solid #263241; padding: 1; overflow-y: scroll; }
291
+ #palette { height: auto; max-height: 12; border: solid #3b82f6; padding: 1; background: #101826; display: none; }
292
+ #input { dock: bottom; border: solid #3b82f6; }
293
+ .muted { color: #8b9bb0; }
294
+ """
295
+ BINDINGS = [("ctrl+c", "quit", "Quit"), ("tab", "accept_suggestion", "Complete slash"), ("ctrl+s", "save_setup", "Save setup")]
296
+
297
+ def __init__(self, mcp_context: McpContext) -> None:
298
+ super().__init__()
299
+ self.context = mcp_context
300
+ self.messages: list[str] = []
301
+ self.busy = False
302
+ self.suggestions: list[str] = []
303
+ self.setup_done = False
304
+
305
+ def compose(self) -> ComposeResult:
306
+ yield Header(show_clock=True)
307
+ with Horizontal(id="layout"):
308
+ yield Static(id="sidebar")
309
+ with Vertical(id="main"):
310
+ yield RichLog(id="chat", wrap=True, highlight=True, markup=False, auto_scroll=True)
311
+ yield Input(placeholder="Prompt hoặc /command. Mouse wheel scroll trong khung chat.", id="input")
312
+ yield Footer()
313
+
314
+ def on_mount(self) -> None:
315
+ self.context.init_storage()
316
+ self.chat.write("system > AgentRAM Textual TUI ready")
317
+ self.chat.write(f"system > RAM root: {self.context.ram_root}")
318
+ self.prefill_setup_from_profile()
319
+ self.refresh_sidebar()
320
+ self.set_interval(1.0, self.refresh_sidebar)
321
+ self.query_one("#setup-provider", Input).focus()
322
+
323
+ @property
324
+ def chat(self) -> RichLog:
325
+ return self.query_one("#chat", RichLog)
326
+
327
+ def refresh_sidebar(self) -> None:
328
+ self.query_one("#sidebar", Static).update("\n".join(sidebar_lines(self.context)))
329
+
330
+ def prefill_setup_from_profile(self) -> None:
331
+ agents = self.context.profile_store.list_agents()
332
+ if not agents:
333
+ return
334
+ first = next(iter(agents.values()))
335
+ self.query_one("#setup-provider", Input).value = first.provider
336
+ self.query_one("#setup-base-url", Input).value = first.base_url
337
+ self.query_one("#setup-api-key-env", Input).value = first.api_key_env
338
+ for slot in ("supervisor", "main", "small"):
339
+ endpoint = agents.get(slot)
340
+ if endpoint:
341
+ self.query_one(f"#setup-{slot}", Input).value = endpoint.model
342
+ self.chat.write("system > Existing workflow models loaded. Save to update, or Skip to keep.")
343
+
344
+ def setup_value(self, widget_id: str) -> str:
345
+ return self.query_one(f"#{widget_id}", Input).value.strip()
346
+
347
+ def action_save_setup(self) -> None:
348
+ provider = self.setup_value("setup-provider") or "openai-compatible"
349
+ base_url = self.setup_value("setup-base-url")
350
+ api_key_env = self.setup_value("setup-api-key-env")
351
+ if api_key_env.startswith(("sk-", "gsk_", "sk_")):
352
+ self.chat.write("agentram > setup error: api key env must be variable name, not raw key")
353
+ return
354
+ role_by_slot = {"supervisor": "supervisor", "main": "coder", "small": "memory"}
355
+ bound: list[str] = []
356
+ for slot, role in role_by_slot.items():
357
+ model = self.setup_value(f"setup-{slot}")
358
+ if not model:
359
+ continue
360
+ endpoint = AgentModelEndpoint(
361
+ provider=provider,
362
+ model=model,
363
+ role=role,
364
+ base_url=base_url,
365
+ api_key_env=api_key_env,
366
+ modalities=["text"],
367
+ )
368
+ self.context.profile_store.set_agent(slot, endpoint)
369
+ bound.append(f"{slot}={model}")
370
+ if not bound:
371
+ self.chat.write("agentram > setup skipped: no model entered")
372
+ else:
373
+ self.chat.write("agentram > setup saved: " + ", ".join(bound))
374
+ self.hide_setup()
375
+ self.refresh_sidebar()
376
+ self.query_one("#input", Input).focus()
377
+
378
+ def hide_setup(self) -> None:
379
+ self.setup_done = True
380
+ self.query_one("#setup", Vertical).styles.display = "none"
381
+
382
+ def on_button_pressed(self, event: Button.Pressed) -> None:
383
+ if event.button.id == "setup-save":
384
+ self.action_save_setup()
385
+ elif event.button.id == "setup-skip":
386
+ self.chat.write("system > Model setup skipped. Use /bind-agent later.")
387
+ self.hide_setup()
388
+ self.query_one("#input", Input).focus()
389
+
390
+ def hide_palette(self) -> None:
391
+ palette = self.query_one("#palette", Static)
392
+ palette.update("")
393
+ palette.styles.display = "none"
394
+
395
+ def show_palette(self, value: str) -> None:
396
+ palette = self.query_one("#palette", Static)
397
+ query = value.strip().lower().lstrip("/")
398
+ matches = []
399
+ for command, description in SLASH_COMMANDS:
400
+ if not command.startswith("/"):
401
+ continue
402
+ command_name = command.split()[0]
403
+ haystack = command_name.lower().lstrip("/")
404
+ if query and query not in haystack:
405
+ continue
406
+ matches.append((command_name, description))
407
+ self.suggestions = [command for command, _ in matches[:8]]
408
+ if not matches:
409
+ palette.update("No matching slash command")
410
+ else:
411
+ lines = ["Slash commands ? Tab completes first match"]
412
+ lines.extend(f" {command.ljust(18)} {description}" for command, description in matches[:8])
413
+ palette.update("\n".join(lines))
414
+ palette.styles.display = "block"
415
+
416
+ def on_input_changed(self, event: Input.Changed) -> None:
417
+ value = event.value.strip()
418
+ if value.startswith("/"):
419
+ self.show_palette(value)
420
+ else:
421
+ self.hide_palette()
422
+
423
+ def action_accept_suggestion(self) -> None:
424
+ input_widget = self.query_one("#input", Input)
425
+ if not input_widget.value.strip().startswith("/") or not self.suggestions:
426
+ return
427
+ input_widget.value = self.suggestions[0] + " "
428
+ input_widget.cursor_position = len(input_widget.value)
429
+ self.hide_palette()
430
+
431
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
432
+ if event.input.id and str(event.input.id).startswith("setup-"):
433
+ self.action_save_setup()
434
+ return
435
+ text = event.value.strip()
436
+ event.input.value = ""
437
+ self.hide_palette()
438
+ if not text:
439
+ return
440
+ if text in {"0", "q", "quit", "exit", "/exit", "/quit"}:
441
+ self.exit()
442
+ return
443
+ if text in BUTTONS:
444
+ text = BUTTONS[text]
445
+ self.chat.write(f"user > {text}")
446
+ if text == "/clear":
447
+ self.chat.clear()
448
+ self.chat.write("system > Chat cleared.")
449
+ return
450
+ if self.busy:
451
+ self.chat.write("agentram > busy, wait current task")
452
+ return
453
+ self.busy = True
454
+ self.chat.write("agentram > thinking...")
455
+ try:
456
+ response = await asyncio.to_thread(handle_chat_input, self.context, text, self.messages)
457
+ except Exception as error: # noqa: BLE001 - TUI boundary
458
+ response = f"error: {error}"
459
+ finally:
460
+ self.busy = False
461
+ if response == "__clear__":
462
+ self.chat.clear()
463
+ self.chat.write("system > Chat cleared.")
464
+ elif response:
465
+ self.chat.write("agentram > " + response)
466
+ self.refresh_sidebar()
467
+
468
+ AgentRAMTextualApp(context).run()
469
+ return True
470
+
471
+ def run_with_activity(context: McpContext, messages: list[str], user_input: str) -> str:
472
+ with ThreadPoolExecutor(max_workers=1) as executor:
473
+ future = executor.submit(handle_chat_input, context, user_input, messages)
474
+ frames = ["thinking", "thinking.", "thinking..", "thinking...", "acting", "acting.", "acting..", "acting..."]
475
+ index = 0
476
+ while not future.done():
477
+ draw_screen(context, messages, activity=f"agentram > {frames[index % len(frames)]}")
478
+ index += 1
479
+ time.sleep(0.18)
480
+ return future.result()
367
481
 
368
482
 
369
483
 
@@ -401,24 +515,24 @@ def read_tui_input(prompt_session: Any | None) -> str:
401
515
  return input("You > ")
402
516
  return prompt_session.prompt("You > ")
403
517
 
404
- def handle_chat_input(context: McpContext, text: str, messages: list[str] | None = None) -> str:
518
+ def handle_chat_input(context: McpContext, text: str, messages: list[str] | None = None) -> str:
405
519
  if not text:
406
520
  return SLASH_HELP
407
521
  if text == "/":
408
522
  return slash_palette_text()
409
523
  if text.startswith("/"):
410
524
  return handle_slash_command(context, text, messages)
411
- call_tool(
412
- context,
413
- "agentram_emit_event",
414
- {"type": "USER_MESSAGE", "payload": {"content": text}, "task_id": "codex", "ingest": False},
415
- )
416
- if context.profile_store.list_agents():
417
- return supervisor_workflow_text(context, text)
418
- if context.profile_store.auto_route_enabled():
419
- return route_plain_prompt_text(context, text)
420
- context_block = retrieve_text(context, text, task_id="codex", limit=6)
421
- return "Coding chat captured. Relevant context:\n" + context_block
525
+ call_tool(
526
+ context,
527
+ "agentram_emit_event",
528
+ {"type": "USER_MESSAGE", "payload": {"content": text}, "task_id": "codex", "ingest": False},
529
+ )
530
+ if context.profile_store.list_agents():
531
+ return supervisor_workflow_text(context, text)
532
+ if context.profile_store.auto_route_enabled():
533
+ return route_plain_prompt_text(context, text)
534
+ context_block = retrieve_text(context, text, task_id="codex", limit=6)
535
+ return "Coding chat captured. Relevant context:\n" + context_block
422
536
 
423
537
 
424
538
  def handle_slash_command(context: McpContext, text: str, messages: list[str] | None = None) -> str:
@@ -455,24 +569,24 @@ def handle_slash_command(context: McpContext, text: str, messages: list[str] | N
455
569
  return disable_model_text(context, raw_rest)
456
570
  if command == "/clear-models":
457
571
  return clear_models_text(context)
458
- if command == "/bind-model":
459
- if len(rest) < 2:
460
- return "Usage: /bind-model <provider> <model> [--role coder] [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
461
- return bind_model_text(context, parse_bind_model_args(rest))
462
- if command == "/bind-router":
463
- if len(rest) < 2:
464
- return "Usage: /bind-router <provider> <model> [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
465
- return bind_router_text(context, parse_bind_model_args(rest))
466
- if command == "/bind-agent":
467
- if len(rest) < 3:
468
- return "Usage: /bind-agent <supervisor|main|small> <provider> <model> [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
469
- options = parse_bind_model_args(rest[1:])
470
- options["slot"] = rest[0]
471
- return bind_agent_text(context, options)
472
- if command == "/workflow":
473
- return workflow_text(context)
474
- if command == "/route":
475
- return route_text(context, rest[0] if rest else "status")
572
+ if command == "/bind-model":
573
+ if len(rest) < 2:
574
+ return "Usage: /bind-model <provider> <model> [--role coder] [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
575
+ return bind_model_text(context, parse_bind_model_args(rest))
576
+ if command == "/bind-router":
577
+ if len(rest) < 2:
578
+ return "Usage: /bind-router <provider> <model> [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
579
+ return bind_router_text(context, parse_bind_model_args(rest))
580
+ if command == "/bind-agent":
581
+ if len(rest) < 3:
582
+ return "Usage: /bind-agent <supervisor|main|small> <provider> <model> [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
583
+ options = parse_bind_model_args(rest[1:])
584
+ options["slot"] = rest[0]
585
+ return bind_agent_text(context, options)
586
+ if command == "/workflow":
587
+ return workflow_text(context)
588
+ if command == "/route":
589
+ return route_text(context, rest[0] if rest else "status")
476
590
  if command == "/orchestrate":
477
591
  execute = "--execute" in rest
478
592
  task = raw_rest.replace("--execute", "").strip()
@@ -626,7 +740,7 @@ def models_text(context: McpContext) -> str:
626
740
  data = payload(call_tool(context, "agentram_read_coding_models", {}))
627
741
  endpoints = data.get("endpoints", [])
628
742
  if not endpoints:
629
- 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"
743
+ 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"
630
744
  lines = ["Bound coding models:"]
631
745
  for endpoint in endpoints:
632
746
  lines.append(f"- {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')} modalities={','.join(endpoint.get('modalities', []))}")
@@ -698,211 +812,211 @@ def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
698
812
  return f"Bound model: {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')}"
699
813
 
700
814
 
701
- def bind_agent_text(context: McpContext, options: dict[str, object]) -> str:
702
- slot = str(options.get("slot", "")).strip().lower()
703
- if slot not in {"supervisor", "main", "small"}:
704
- return "Usage: /bind-agent <supervisor|main|small> <provider> <model>"
705
- api_key_env = str(options.get("api_key_env", ""))
706
- if api_key_env.startswith(("sk-", "gsk_", "sk_")):
707
- return "Do not paste raw API key into api_key_env. Set env var first, then use api_key_env=ENV_NAME."
708
- role = {"supervisor": "supervisor", "main": "coder", "small": "memory"}[slot]
709
- endpoint = AgentModelEndpoint(
710
- provider=str(options.get("provider", "openai-compatible")),
711
- model=str(options.get("model", "")),
712
- role=role,
713
- base_url=str(options.get("base_url", "")),
714
- api_key_env=api_key_env,
715
- modalities=["text"],
716
- )
717
- context.profile_store.set_agent(slot, endpoint)
718
- return f"Bound {slot} agent: provider={endpoint.provider} model={endpoint.model} role={endpoint.role}"
719
-
720
- def workflow_text(context: McpContext) -> str:
721
- agents = context.profile_store.list_agents()
722
- if not agents:
723
- return "No supervisor workflow agents bound. Use /bind-agent supervisor|main|small <provider> <model>."
724
- lines = ["Supervisor workflow agents:"]
725
- for slot in ["supervisor", "main", "small"]:
726
- endpoint = agents.get(slot)
727
- if endpoint:
728
- lines.append(f"- {slot}: {endpoint.provider}:{endpoint.model} role={endpoint.role}")
729
- else:
730
- lines.append(f"- {slot}: not bound")
731
- return "\n".join(lines)
732
-
733
- def supervisor_workflow_text(context: McpContext, prompt: str) -> str:
734
- agents = context.profile_store.list_agents()
735
- supervisor = agents.get("supervisor")
736
- main_agent = agents.get("main")
737
- small_agent = agents.get("small")
738
- memory_context = retrieve_text(context, prompt, task_id="codex", limit=8)
739
- plan = supervisor_plan(supervisor, prompt, memory_context)
740
- outputs: dict[str, str] = {}
741
- with ThreadPoolExecutor(max_workers=2) as executor:
742
- futures = {}
743
- if main_agent:
744
- futures[executor.submit(call_agent_endpoint, main_agent, build_main_agent_prompt(prompt, plan, memory_context))] = "main"
745
- if small_agent:
746
- futures[executor.submit(call_agent_endpoint, small_agent, build_small_agent_prompt(prompt, plan, memory_context))] = "small"
747
- for future, slot in list(futures.items()):
748
- try:
749
- outputs[slot] = future.result()
750
- except Exception as error: # noqa: BLE001 - agent boundary
751
- outputs[slot] = f"error: {model_error_hint(str(error))}"
752
- if not main_agent:
753
- outputs["main"] = "No main agent bound. Use /bind-agent main <provider> <model>."
754
- persist_workflow_memory(context, prompt, plan, outputs)
755
- return "\n".join(
756
- [
757
- "Workflow: supervisor -> main + small -> merge",
758
- "Supervisor plan:",
759
- plan,
760
- "",
761
- "Main agent result:",
762
- outputs.get("main", "-"),
763
- "",
764
- "Small memory agent notes:",
765
- outputs.get("small", "No small agent bound."),
766
- ]
767
- )
768
-
769
- def persist_workflow_memory(context: McpContext, prompt: str, plan: str, outputs: dict[str, str]) -> None:
770
- small_notes = outputs.get("small", "").strip()
771
- main_result = outputs.get("main", "").strip()
772
- note_parts = [
773
- f"User request: {prompt}",
774
- f"Supervisor plan: {trim(plan.strip(), 1200)}" if plan.strip() else "Supervisor plan: -",
775
- ]
776
- if small_notes and not small_notes.startswith("error:"):
777
- note_parts.append(f"Small memory notes: {trim(small_notes, 1200)}")
778
- else:
779
- note_parts.append("Small memory notes: unavailable; persisted supervisor plan as fallback RAM note.")
780
- if main_result and not main_result.startswith("error:"):
781
- note_parts.append(f"Main result summary: {trim(main_result, 600)}")
782
- content = "\n".join(note_parts).strip()
783
- if not content:
784
- return
785
- call_tool(
786
- context,
787
- "agentram_emit_event",
788
- {"type": "DECISION", "payload": {"content": content}, "task_id": "codex", "ingest": True},
789
- )
790
-
791
- def supervisor_plan(supervisor: AgentModelEndpoint | None, prompt: str, memory_context: str) -> str:
792
- if not supervisor:
793
- return "No supervisor bound. Fallback plan: handle user request, run main coding agent, ask small agent to update RAM notes."
794
- try:
795
- return call_agent_endpoint(supervisor, build_supervisor_prompt(prompt, memory_context))
796
- except Exception as error: # noqa: BLE001 - supervisor boundary
797
- return f"Supervisor error: {error}\nFallback plan: handle user request, run main coding agent, ask small agent to update RAM notes."
798
-
799
- def call_agent_endpoint(endpoint: AgentModelEndpoint, prompt: str) -> str:
800
- return MultiModelCodingOrchestrator([endpoint])._call_endpoint(endpoint, prompt)
801
-
802
- def build_supervisor_prompt(prompt: str, memory_context: str) -> str:
803
- 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])
804
-
805
- def build_main_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
806
- 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])
807
-
808
- def build_small_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
809
- 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])
810
-
811
- def bind_router_text(context: McpContext, options: dict[str, object]) -> str:
812
- api_key_env = str(options.get("api_key_env", ""))
813
- if api_key_env.startswith(("sk-", "gsk_", "sk_")):
814
- return "Do not paste raw API key into api_key_env. Set env var first, then use api_key_env=ENV_NAME."
815
- endpoint = AgentModelEndpoint(
816
- provider=str(options.get("provider", "openai-compatible")),
817
- model=str(options.get("model", "")),
818
- role="router",
819
- base_url=str(options.get("base_url", "")),
820
- api_key_env=api_key_env,
821
- modalities=["text"],
822
- )
823
- context.profile_store.set_router(endpoint)
824
- return f"Bound router: provider={endpoint.provider} model={endpoint.model}"
825
-
826
- def route_text(context: McpContext, mode: str) -> str:
827
- normalized = mode.strip().lower() or "status"
828
- if normalized == "on":
829
- context.profile_store.set_auto_route(True)
830
- elif normalized == "off":
831
- context.profile_store.set_auto_route(False)
832
- elif normalized != "status":
833
- return "Usage: /route on|off|status"
834
- router = context.profile_store.get_router()
835
- return "\n".join(
836
- [
837
- f"Auto route: {'on' if context.profile_store.auto_route_enabled() else 'off'}",
838
- f"Router model: {router.provider}:{router.model}" if router else "Router model: heuristic fallback",
839
- ]
840
- )
841
-
842
- def route_plain_prompt_text(context: McpContext, prompt: str) -> str:
843
- decision = classify_prompt_intent(context, prompt)
844
- intent = str(decision.get("intent", "chat"))
845
- confidence = float(decision.get("confidence", 0.0))
846
- prefix = f"Route: {intent} confidence={confidence:.2f}"
847
- if intent == "coding":
848
- if context.profile_store.list_endpoints():
849
- return prefix + "\n" + orchestrate_text(context, prompt, execute=True)
850
- context_block = retrieve_text(context, prompt, task_id="codex", limit=6)
851
- return prefix + "\nNo coding models bound. Use /bind-model.\nCoding chat captured. Relevant context:\n" + context_block
852
- if intent == "planning":
853
- return prefix + "\n" + drift_text(context, prompt) + "\n\n" + goal_text(context)
854
- if intent == "memory":
855
- return prefix + "\n" + note_text(context, prompt)
856
- context_block = retrieve_text(context, prompt, task_id="codex", limit=6)
857
- return prefix + "\nCoding chat captured. Relevant context:\n" + context_block
858
-
859
- def classify_prompt_intent(context: McpContext, prompt: str) -> dict[str, object]:
860
- router = context.profile_store.get_router()
861
- if router:
862
- try:
863
- response = MultiModelCodingOrchestrator([router])._call_endpoint(router, build_router_prompt(prompt))
864
- parsed = json.loads(extract_json_object(response))
865
- if isinstance(parsed, dict) and parsed.get("intent") in {"coding", "planning", "memory", "chat"}:
866
- return {"intent": parsed.get("intent"), "confidence": float(parsed.get("confidence", 0.0)), "source": "model"}
867
- except Exception as error: # noqa: BLE001 - router fallback boundary
868
- return {**heuristic_intent(prompt), "source": "heuristic", "router_error": str(error)}
869
- return {**heuristic_intent(prompt), "source": "heuristic"}
870
-
871
- def build_router_prompt(prompt: str) -> str:
872
- return "\n".join(
873
- [
874
- "Classify user prompt for AgentRAM routing.",
875
- "Return only JSON, no markdown.",
876
- "Schema: {\"intent\":\"coding|planning|memory|chat\",\"confidence\":0.0,\"reason\":\"short\"}",
877
- "coding = edit/review/debug/refactor/test/code/file requests.",
878
- "planning = goal stack, milestone, mission, next task, drift, roadmap.",
879
- "memory = remember/note/decision/fact to save.",
880
- "chat = other normal conversation.",
881
- f"Prompt: {prompt}",
882
- ]
883
- )
884
-
885
- def extract_json_object(text: str) -> str:
886
- start = text.find("{")
887
- end = text.rfind("}")
888
- if start == -1 or end == -1 or end < start:
889
- return "{}"
890
- return text[start : end + 1]
891
-
892
- def heuristic_intent(prompt: str) -> dict[str, object]:
893
- lower = prompt.lower()
894
- memory_words = ["ghi nhớ", "remember", "note", "decision", "lưu lại", "save this"]
895
- planning_words = ["goal", "kế hoạch", "ke hoach", "milestone", "mission", "drift", "next task", "roadmap", "mục tiêu", "muc tieu"]
896
- coding_words = ["sửa", "sua", "fix", "bug", "code", "test", "refactor", "review", "file", ".py", ".js", "endpoint", "class", "function"]
897
- if any(word in lower for word in memory_words):
898
- return {"intent": "memory", "confidence": 0.75}
899
- if any(word in lower for word in planning_words):
900
- return {"intent": "planning", "confidence": 0.75}
901
- if any(word in lower for word in coding_words):
902
- return {"intent": "coding", "confidence": 0.72}
903
- return {"intent": "chat", "confidence": 0.55}
904
-
905
- def orchestrate_text(context: McpContext, task: str, files: list[str] | None = None, execute: bool = False) -> str:
815
+ def bind_agent_text(context: McpContext, options: dict[str, object]) -> str:
816
+ slot = str(options.get("slot", "")).strip().lower()
817
+ if slot not in {"supervisor", "main", "small"}:
818
+ return "Usage: /bind-agent <supervisor|main|small> <provider> <model>"
819
+ api_key_env = str(options.get("api_key_env", ""))
820
+ if api_key_env.startswith(("sk-", "gsk_", "sk_")):
821
+ return "Do not paste raw API key into api_key_env. Set env var first, then use api_key_env=ENV_NAME."
822
+ role = {"supervisor": "supervisor", "main": "coder", "small": "memory"}[slot]
823
+ endpoint = AgentModelEndpoint(
824
+ provider=str(options.get("provider", "openai-compatible")),
825
+ model=str(options.get("model", "")),
826
+ role=role,
827
+ base_url=str(options.get("base_url", "")),
828
+ api_key_env=api_key_env,
829
+ modalities=["text"],
830
+ )
831
+ context.profile_store.set_agent(slot, endpoint)
832
+ return f"Bound {slot} agent: provider={endpoint.provider} model={endpoint.model} role={endpoint.role}"
833
+
834
+ def workflow_text(context: McpContext) -> str:
835
+ agents = context.profile_store.list_agents()
836
+ if not agents:
837
+ return "No supervisor workflow agents bound. Use /bind-agent supervisor|main|small <provider> <model>."
838
+ lines = ["Supervisor workflow agents:"]
839
+ for slot in ["supervisor", "main", "small"]:
840
+ endpoint = agents.get(slot)
841
+ if endpoint:
842
+ lines.append(f"- {slot}: {endpoint.provider}:{endpoint.model} role={endpoint.role}")
843
+ else:
844
+ lines.append(f"- {slot}: not bound")
845
+ return "\n".join(lines)
846
+
847
+ def supervisor_workflow_text(context: McpContext, prompt: str) -> str:
848
+ agents = context.profile_store.list_agents()
849
+ supervisor = agents.get("supervisor")
850
+ main_agent = agents.get("main")
851
+ small_agent = agents.get("small")
852
+ memory_context = retrieve_text(context, prompt, task_id="codex", limit=8)
853
+ plan = supervisor_plan(supervisor, prompt, memory_context)
854
+ outputs: dict[str, str] = {}
855
+ with ThreadPoolExecutor(max_workers=2) as executor:
856
+ futures = {}
857
+ if main_agent:
858
+ futures[executor.submit(call_agent_endpoint, main_agent, build_main_agent_prompt(prompt, plan, memory_context))] = "main"
859
+ if small_agent:
860
+ futures[executor.submit(call_agent_endpoint, small_agent, build_small_agent_prompt(prompt, plan, memory_context))] = "small"
861
+ for future, slot in list(futures.items()):
862
+ try:
863
+ outputs[slot] = future.result()
864
+ except Exception as error: # noqa: BLE001 - agent boundary
865
+ outputs[slot] = f"error: {model_error_hint(str(error))}"
866
+ if not main_agent:
867
+ outputs["main"] = "No main agent bound. Use /bind-agent main <provider> <model>."
868
+ persist_workflow_memory(context, prompt, plan, outputs)
869
+ return "\n".join(
870
+ [
871
+ "Workflow: supervisor -> main + small -> merge",
872
+ "Supervisor plan:",
873
+ plan,
874
+ "",
875
+ "Main agent result:",
876
+ outputs.get("main", "-"),
877
+ "",
878
+ "Small memory agent notes:",
879
+ outputs.get("small", "No small agent bound."),
880
+ ]
881
+ )
882
+
883
+ def persist_workflow_memory(context: McpContext, prompt: str, plan: str, outputs: dict[str, str]) -> None:
884
+ small_notes = outputs.get("small", "").strip()
885
+ main_result = outputs.get("main", "").strip()
886
+ note_parts = [
887
+ f"User request: {prompt}",
888
+ f"Supervisor plan: {trim(plan.strip(), 1200)}" if plan.strip() else "Supervisor plan: -",
889
+ ]
890
+ if small_notes and not small_notes.startswith("error:"):
891
+ note_parts.append(f"Small memory notes: {trim(small_notes, 1200)}")
892
+ else:
893
+ note_parts.append("Small memory notes: unavailable; persisted supervisor plan as fallback RAM note.")
894
+ if main_result and not main_result.startswith("error:"):
895
+ note_parts.append(f"Main result summary: {trim(main_result, 600)}")
896
+ content = "\n".join(note_parts).strip()
897
+ if not content:
898
+ return
899
+ call_tool(
900
+ context,
901
+ "agentram_emit_event",
902
+ {"type": "DECISION", "payload": {"content": content}, "task_id": "codex", "ingest": True},
903
+ )
904
+
905
+ def supervisor_plan(supervisor: AgentModelEndpoint | None, prompt: str, memory_context: str) -> str:
906
+ if not supervisor:
907
+ return "No supervisor bound. Fallback plan: handle user request, run main coding agent, ask small agent to update RAM notes."
908
+ try:
909
+ return call_agent_endpoint(supervisor, build_supervisor_prompt(prompt, memory_context))
910
+ except Exception as error: # noqa: BLE001 - supervisor boundary
911
+ return f"Supervisor error: {error}\nFallback plan: handle user request, run main coding agent, ask small agent to update RAM notes."
912
+
913
+ def call_agent_endpoint(endpoint: AgentModelEndpoint, prompt: str) -> str:
914
+ return MultiModelCodingOrchestrator([endpoint])._call_endpoint(endpoint, prompt)
915
+
916
+ def build_supervisor_prompt(prompt: str, memory_context: str) -> str:
917
+ 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])
918
+
919
+ def build_main_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
920
+ 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])
921
+
922
+ def build_small_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
923
+ 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])
924
+
925
+ def bind_router_text(context: McpContext, options: dict[str, object]) -> str:
926
+ api_key_env = str(options.get("api_key_env", ""))
927
+ if api_key_env.startswith(("sk-", "gsk_", "sk_")):
928
+ return "Do not paste raw API key into api_key_env. Set env var first, then use api_key_env=ENV_NAME."
929
+ endpoint = AgentModelEndpoint(
930
+ provider=str(options.get("provider", "openai-compatible")),
931
+ model=str(options.get("model", "")),
932
+ role="router",
933
+ base_url=str(options.get("base_url", "")),
934
+ api_key_env=api_key_env,
935
+ modalities=["text"],
936
+ )
937
+ context.profile_store.set_router(endpoint)
938
+ return f"Bound router: provider={endpoint.provider} model={endpoint.model}"
939
+
940
+ def route_text(context: McpContext, mode: str) -> str:
941
+ normalized = mode.strip().lower() or "status"
942
+ if normalized == "on":
943
+ context.profile_store.set_auto_route(True)
944
+ elif normalized == "off":
945
+ context.profile_store.set_auto_route(False)
946
+ elif normalized != "status":
947
+ return "Usage: /route on|off|status"
948
+ router = context.profile_store.get_router()
949
+ return "\n".join(
950
+ [
951
+ f"Auto route: {'on' if context.profile_store.auto_route_enabled() else 'off'}",
952
+ f"Router model: {router.provider}:{router.model}" if router else "Router model: heuristic fallback",
953
+ ]
954
+ )
955
+
956
+ def route_plain_prompt_text(context: McpContext, prompt: str) -> str:
957
+ decision = classify_prompt_intent(context, prompt)
958
+ intent = str(decision.get("intent", "chat"))
959
+ confidence = float(decision.get("confidence", 0.0))
960
+ prefix = f"Route: {intent} confidence={confidence:.2f}"
961
+ if intent == "coding":
962
+ if context.profile_store.list_endpoints():
963
+ return prefix + "\n" + orchestrate_text(context, prompt, execute=True)
964
+ context_block = retrieve_text(context, prompt, task_id="codex", limit=6)
965
+ return prefix + "\nNo coding models bound. Use /bind-model.\nCoding chat captured. Relevant context:\n" + context_block
966
+ if intent == "planning":
967
+ return prefix + "\n" + drift_text(context, prompt) + "\n\n" + goal_text(context)
968
+ if intent == "memory":
969
+ return prefix + "\n" + note_text(context, prompt)
970
+ context_block = retrieve_text(context, prompt, task_id="codex", limit=6)
971
+ return prefix + "\nCoding chat captured. Relevant context:\n" + context_block
972
+
973
+ def classify_prompt_intent(context: McpContext, prompt: str) -> dict[str, object]:
974
+ router = context.profile_store.get_router()
975
+ if router:
976
+ try:
977
+ response = MultiModelCodingOrchestrator([router])._call_endpoint(router, build_router_prompt(prompt))
978
+ parsed = json.loads(extract_json_object(response))
979
+ if isinstance(parsed, dict) and parsed.get("intent") in {"coding", "planning", "memory", "chat"}:
980
+ return {"intent": parsed.get("intent"), "confidence": float(parsed.get("confidence", 0.0)), "source": "model"}
981
+ except Exception as error: # noqa: BLE001 - router fallback boundary
982
+ return {**heuristic_intent(prompt), "source": "heuristic", "router_error": str(error)}
983
+ return {**heuristic_intent(prompt), "source": "heuristic"}
984
+
985
+ def build_router_prompt(prompt: str) -> str:
986
+ return "\n".join(
987
+ [
988
+ "Classify user prompt for AgentRAM routing.",
989
+ "Return only JSON, no markdown.",
990
+ "Schema: {\"intent\":\"coding|planning|memory|chat\",\"confidence\":0.0,\"reason\":\"short\"}",
991
+ "coding = edit/review/debug/refactor/test/code/file requests.",
992
+ "planning = goal stack, milestone, mission, next task, drift, roadmap.",
993
+ "memory = remember/note/decision/fact to save.",
994
+ "chat = other normal conversation.",
995
+ f"Prompt: {prompt}",
996
+ ]
997
+ )
998
+
999
+ def extract_json_object(text: str) -> str:
1000
+ start = text.find("{")
1001
+ end = text.rfind("}")
1002
+ if start == -1 or end == -1 or end < start:
1003
+ return "{}"
1004
+ return text[start : end + 1]
1005
+
1006
+ def heuristic_intent(prompt: str) -> dict[str, object]:
1007
+ lower = prompt.lower()
1008
+ memory_words = ["ghi nhớ", "remember", "note", "decision", "lưu lại", "save this"]
1009
+ planning_words = ["goal", "kế hoạch", "ke hoach", "milestone", "mission", "drift", "next task", "roadmap", "mục tiêu", "muc tieu"]
1010
+ coding_words = ["sửa", "sua", "fix", "bug", "code", "test", "refactor", "review", "file", ".py", ".js", "endpoint", "class", "function"]
1011
+ if any(word in lower for word in memory_words):
1012
+ return {"intent": "memory", "confidence": 0.75}
1013
+ if any(word in lower for word in planning_words):
1014
+ return {"intent": "planning", "confidence": 0.75}
1015
+ if any(word in lower for word in coding_words):
1016
+ return {"intent": "coding", "confidence": 0.72}
1017
+ return {"intent": "chat", "confidence": 0.55}
1018
+
1019
+ def orchestrate_text(context: McpContext, task: str, files: list[str] | None = None, execute: bool = False) -> str:
906
1020
  if not task.strip():
907
1021
  return "Usage: /orchestrate <coding task>"
908
1022
  tool = "agentram_run_coding_orchestration" if execute else "agentram_build_coding_orchestration"
@@ -926,16 +1040,16 @@ def model_error_hint(message: str) -> str:
926
1040
  lower = message.lower()
927
1041
  if "base_url is required" in lower:
928
1042
  return message + " | Fix: /remove-model <id> or re-bind with --base-url http://localhost:8000/v1"
929
- if "actively refused" in lower or "connection refused" in lower or "winerror 10061" in lower:
930
- return message + " | Fix: start your OpenAI-compatible server at base_url, or change --base-url."
931
- if "http 404" in lower or "404: not found" in lower:
932
- return message + " | Fix: verify base_url includes /v1, provider supports /chat/completions, and model name exists."
933
- if "http 400" in lower and "not supported" in lower:
934
- return message + " | Fix: this account/provider rejects that model. Bind a supported model name from your local endpoint."
935
- if "returned non-json" in lower or "expecting value" in lower:
936
- return message + " | Fix: endpoint returned empty/HTML/non-JSON. Check base_url, auth key, proxy, and server logs."
937
- if "missing api key env" in lower:
938
- return message + " | Fix: set environment variable, then bind using --api-key-env ENV_NAME, not raw key."
1043
+ if "actively refused" in lower or "connection refused" in lower or "winerror 10061" in lower:
1044
+ return message + " | Fix: start your OpenAI-compatible server at base_url, or change --base-url."
1045
+ if "http 404" in lower or "404: not found" in lower:
1046
+ return message + " | Fix: verify base_url includes /v1, provider supports /chat/completions, and model name exists."
1047
+ if "http 400" in lower and "not supported" in lower:
1048
+ return message + " | Fix: this account/provider rejects that model. Bind a supported model name from your local endpoint."
1049
+ if "returned non-json" in lower or "expecting value" in lower:
1050
+ return message + " | Fix: endpoint returned empty/HTML/non-JSON. Check base_url, auth key, proxy, and server logs."
1051
+ if "missing api key env" in lower:
1052
+ return message + " | Fix: set environment variable, then bind using --api-key-env ENV_NAME, not raw key."
939
1053
  return message
940
1054
 
941
1055
  def claude_template_root() -> Path:
@@ -1004,7 +1118,7 @@ BUTTONS = {
1004
1118
  }
1005
1119
 
1006
1120
 
1007
- def draw_screen(context: McpContext, messages: list[str], activity: str | None = None) -> None:
1121
+ def draw_screen(context: McpContext, messages: list[str], activity: str | None = None) -> None:
1008
1122
  clear_terminal()
1009
1123
  width = min(max(shutil.get_terminal_size((110, 34)).columns, 86), 132)
1010
1124
  left_width = 30
@@ -1013,17 +1127,17 @@ def draw_screen(context: McpContext, messages: list[str], activity: str | None =
1013
1127
  print(box_text("◆ AgentRAM Code | RAM + Claude subagents + slash commands", width))
1014
1128
  print("+" + "-" * (width - 2) + "+")
1015
1129
 
1016
- sidebar = sidebar_lines(context)
1017
- display_messages = [*messages, activity] if activity else messages
1018
- chat = wrap_messages(display_messages, right_width - 4)[-22:]
1130
+ sidebar = sidebar_lines(context)
1131
+ display_messages = [*messages, activity] if activity else messages
1132
+ chat = wrap_messages(display_messages, right_width - 4)[-22:]
1019
1133
  rows = max(len(sidebar), len(chat), 22)
1020
1134
  for index in range(rows):
1021
1135
  left = sidebar[index] if index < len(sidebar) else ""
1022
1136
  right = chat[index] if index < len(chat) else ""
1023
1137
  print("| " + left[: left_width - 2].ljust(left_width - 2) + "| " + right[: right_width - 2].ljust(right_width - 2) + "|")
1024
1138
  print("+" + "-" * (left_width - 1) + "+" + "-" * (right_width) + "+")
1025
- footer = "Thinking/acting..." if activity else "Input: type / for commands. Use mouse wheel or terminal scrollbar for previous output."
1026
- print(box_text(footer, width))
1139
+ footer = "Thinking/acting..." if activity else "Input: type / for commands. Use mouse wheel or terminal scrollbar for previous output."
1140
+ print(box_text(footer, width))
1027
1141
  print("+" + "-" * (width - 2) + "+")
1028
1142
 
1029
1143
 
@@ -1062,8 +1176,8 @@ def sidebar_lines(context: McpContext) -> list[str]:
1062
1176
  " /ask <prompt>",
1063
1177
  ]
1064
1178
 
1065
-
1066
-
1179
+
1180
+
1067
1181
  def trim(text: str, limit: int) -> str:
1068
1182
  return text if len(text) <= limit else text[: limit - 1] + "…"
1069
1183
 
package/package.json CHANGED
@@ -1,52 +1,52 @@
1
- {
2
- "name": "agentram",
3
- "version": "0.1.13",
4
- "description": "Async, model-agnostic Working RAM for coding agents.",
5
- "license": "MIT",
6
- "homepage": "https://github.com/trancongnghia/AGENT_RAM#readme",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/trancongnghia/AGENT_RAM.git"
10
- },
11
- "bugs": {
12
- "url": "https://github.com/trancongnghia/AGENT_RAM/issues"
13
- },
14
- "keywords": [
15
- "agent",
16
- "memory",
17
- "codex",
18
- "claude",
19
- "mcp",
20
- "llm"
21
- ],
22
- "bin": {
23
- "agentram": "bin/agentram.js",
24
- "agentram-mcp": "bin/agentram-mcp.js"
25
- },
26
- "scripts": {
27
- "agentram": "node bin/agentram.js",
28
- "agentram:mcp": "node bin/agentram-mcp.js",
29
- "test": "python -m pytest -q",
30
- "pack:dry-run": "npm pack --dry-run",
31
- "prepublishOnly": "npm test",
32
- "postinstall": "node bin/agentram-postinstall.js"
33
- },
34
- "files": [
35
- "agentram/*.py",
36
- "bin/*.js",
37
- ".claude/agents/*.md",
38
- ".claude/commands/*.md",
39
- "agentram_mcp_server.py",
40
- "agentram_daemon.py",
41
- "codex_ram.py",
42
- "pyproject.toml",
43
- "README.md",
44
- "LICENSE"
45
- ],
46
- "engines": {
47
- "node": ">=16"
48
- },
49
- "publishConfig": {
50
- "access": "public"
51
- }
52
- }
1
+ {
2
+ "name": "agentram",
3
+ "version": "0.1.15",
4
+ "description": "Async, model-agnostic Working RAM for coding agents.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/trancongnghia/AGENT_RAM#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/trancongnghia/AGENT_RAM.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/trancongnghia/AGENT_RAM/issues"
13
+ },
14
+ "keywords": [
15
+ "agent",
16
+ "memory",
17
+ "codex",
18
+ "claude",
19
+ "mcp",
20
+ "llm"
21
+ ],
22
+ "bin": {
23
+ "agentram": "bin/agentram.js",
24
+ "agentram-mcp": "bin/agentram-mcp.js"
25
+ },
26
+ "scripts": {
27
+ "agentram": "node bin/agentram.js",
28
+ "agentram:mcp": "node bin/agentram-mcp.js",
29
+ "test": "python -m pytest -q",
30
+ "pack:dry-run": "npm pack --dry-run",
31
+ "prepublishOnly": "npm test",
32
+ "postinstall": "node bin/agentram-postinstall.js"
33
+ },
34
+ "files": [
35
+ "agentram/*.py",
36
+ "bin/*.js",
37
+ ".claude/agents/*.md",
38
+ ".claude/commands/*.md",
39
+ "agentram_mcp_server.py",
40
+ "agentram_daemon.py",
41
+ "codex_ram.py",
42
+ "pyproject.toml",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "engines": {
47
+ "node": ">=16"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }
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.13"
7
+ version = "0.1.15"
8
8
  description = "Async, model-agnostic RAM layer for agentic coding tools."
9
9
  readme = "README.md"
10
10
  license = "MIT"