Merge branch 'main' into feat/honcho-integration
This commit is contained in:
commit
4a9086b848
73 changed files with 7080 additions and 280 deletions
|
|
@ -830,6 +830,19 @@ def set_config_value(key: str, value: str):
|
|||
with open(config_path, 'w') as f:
|
||||
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
|
||||
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
|
||||
_config_to_env_sync = {
|
||||
"terminal.backend": "TERMINAL_ENV",
|
||||
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
|
||||
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
||||
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
|
||||
"terminal.cwd": "TERMINAL_CWD",
|
||||
"terminal.timeout": "TERMINAL_TIMEOUT",
|
||||
}
|
||||
if key in _config_to_env_sync:
|
||||
save_env_value(_config_to_env_sync[key], str(value))
|
||||
|
||||
print(f"✓ Set {key} = {value} in {config_path}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -61,8 +61,11 @@ def _has_any_provider_configured() -> bool:
|
|||
"""Check if at least one inference provider is usable."""
|
||||
from hermes_cli.config import get_env_path, get_hermes_home
|
||||
|
||||
# Check env vars (may be set by .env or shell)
|
||||
if os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY"):
|
||||
# Check env vars (may be set by .env or shell).
|
||||
# OPENAI_BASE_URL alone counts — local models (vLLM, llama.cpp, etc.)
|
||||
# often don't require an API key.
|
||||
provider_env_vars = ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_BASE_URL")
|
||||
if any(os.getenv(v) for v in provider_env_vars):
|
||||
return True
|
||||
|
||||
# Check .env file for keys
|
||||
|
|
@ -75,7 +78,7 @@ def _has_any_provider_configured() -> bool:
|
|||
continue
|
||||
key, _, val = line.partition("=")
|
||||
val = val.strip().strip("'\"")
|
||||
if key.strip() in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY") and val:
|
||||
if key.strip() in provider_env_vars and val:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -751,12 +754,31 @@ def cmd_update(args):
|
|||
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
|
||||
# Auto-restart gateway if it's running as a systemd service
|
||||
try:
|
||||
check = subprocess.run(
|
||||
["systemctl", "--user", "is-active", "hermes-gateway"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if check.stdout.strip() == "active":
|
||||
print()
|
||||
print("→ Gateway service is running — restarting to pick up changes...")
|
||||
restart = subprocess.run(
|
||||
["systemctl", "--user", "restart", "hermes-gateway"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if restart.returncode == 0:
|
||||
print("✓ Gateway restarted.")
|
||||
else:
|
||||
print(f"⚠ Gateway restart failed: {restart.stderr.strip()}")
|
||||
print(" Try manually: hermes gateway restart")
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass # No systemd (macOS, WSL1, etc.) — skip silently
|
||||
|
||||
print()
|
||||
print("Tip: You can now log in with Nous Portal for inference:")
|
||||
print(" hermes login # Authenticate with Nous Portal")
|
||||
print()
|
||||
print("Note: If you have the gateway service running, restart it:")
|
||||
print(" hermes gateway restart")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"✗ Update failed: {e}")
|
||||
|
|
|
|||
|
|
@ -1015,6 +1015,14 @@ def run_setup_wizard(args):
|
|||
print_success("Terminal set to SSH")
|
||||
# else: Keep current (selected_backend is None)
|
||||
|
||||
# Sync terminal backend to .env so terminal_tool picks it up directly.
|
||||
# config.yaml is the source of truth, but terminal_tool reads TERMINAL_ENV.
|
||||
if selected_backend:
|
||||
save_env_value("TERMINAL_ENV", selected_backend)
|
||||
docker_image = config.get('terminal', {}).get('docker_image')
|
||||
if docker_image:
|
||||
save_env_value("TERMINAL_DOCKER_IMAGE", docker_image)
|
||||
|
||||
# =========================================================================
|
||||
# Step 5: Agent Settings
|
||||
# =========================================================================
|
||||
|
|
@ -1078,6 +1086,82 @@ def run_setup_wizard(args):
|
|||
|
||||
print_success(f"Context compression threshold set to {config['compression'].get('threshold', 0.85)}")
|
||||
|
||||
# =========================================================================
|
||||
# Step 6b: Session Reset Policy (Messaging)
|
||||
# =========================================================================
|
||||
print_header("Session Reset Policy")
|
||||
print_info("Messaging sessions (Telegram, Discord, etc.) accumulate context over time.")
|
||||
print_info("Each message adds to the conversation history, which means growing API costs.")
|
||||
print_info("")
|
||||
print_info("To manage this, sessions can automatically reset after a period of inactivity")
|
||||
print_info("or at a fixed time each day. When a reset happens, the agent saves important")
|
||||
print_info("things to its persistent memory first — but the conversation context is cleared.")
|
||||
print_info("")
|
||||
print_info("You can also manually reset anytime by typing /reset in chat.")
|
||||
print_info("")
|
||||
|
||||
reset_choices = [
|
||||
"Inactivity + daily reset (recommended — reset whichever comes first)",
|
||||
"Inactivity only (reset after N minutes of no messages)",
|
||||
"Daily only (reset at a fixed hour each day)",
|
||||
"Never auto-reset (context lives until /reset or context compression)",
|
||||
"Keep current settings",
|
||||
]
|
||||
|
||||
current_policy = config.get('session_reset', {})
|
||||
current_mode = current_policy.get('mode', 'both')
|
||||
current_idle = current_policy.get('idle_minutes', 1440)
|
||||
current_hour = current_policy.get('at_hour', 4)
|
||||
|
||||
default_reset = {"both": 0, "idle": 1, "daily": 2, "none": 3}.get(current_mode, 0)
|
||||
|
||||
reset_idx = prompt_choice("Session reset mode:", reset_choices, default_reset)
|
||||
|
||||
config.setdefault('session_reset', {})
|
||||
|
||||
if reset_idx == 0: # Both
|
||||
config['session_reset']['mode'] = 'both'
|
||||
idle_str = prompt(" Inactivity timeout (minutes)", str(current_idle))
|
||||
try:
|
||||
idle_val = int(idle_str)
|
||||
if idle_val > 0:
|
||||
config['session_reset']['idle_minutes'] = idle_val
|
||||
except ValueError:
|
||||
pass
|
||||
hour_str = prompt(" Daily reset hour (0-23, local time)", str(current_hour))
|
||||
try:
|
||||
hour_val = int(hour_str)
|
||||
if 0 <= hour_val <= 23:
|
||||
config['session_reset']['at_hour'] = hour_val
|
||||
except ValueError:
|
||||
pass
|
||||
print_success(f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min idle or daily at {config['session_reset'].get('at_hour', 4)}:00")
|
||||
elif reset_idx == 1: # Idle only
|
||||
config['session_reset']['mode'] = 'idle'
|
||||
idle_str = prompt(" Inactivity timeout (minutes)", str(current_idle))
|
||||
try:
|
||||
idle_val = int(idle_str)
|
||||
if idle_val > 0:
|
||||
config['session_reset']['idle_minutes'] = idle_val
|
||||
except ValueError:
|
||||
pass
|
||||
print_success(f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min of inactivity")
|
||||
elif reset_idx == 2: # Daily only
|
||||
config['session_reset']['mode'] = 'daily'
|
||||
hour_str = prompt(" Daily reset hour (0-23, local time)", str(current_hour))
|
||||
try:
|
||||
hour_val = int(hour_str)
|
||||
if 0 <= hour_val <= 23:
|
||||
config['session_reset']['at_hour'] = hour_val
|
||||
except ValueError:
|
||||
pass
|
||||
print_success(f"Sessions reset daily at {config['session_reset'].get('at_hour', 4)}:00")
|
||||
elif reset_idx == 3: # None
|
||||
config['session_reset']['mode'] = 'none'
|
||||
print_info("Sessions will never auto-reset. Context is managed only by compression.")
|
||||
print_warning("Long conversations will grow in cost. Use /reset manually when needed.")
|
||||
# else: keep current (idx == 4)
|
||||
|
||||
# =========================================================================
|
||||
# Step 7: Messaging Platforms (Optional)
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -134,74 +134,171 @@ def _prompt_choice(question: str, choices: list, default: int = 0) -> int:
|
|||
sys.exit(0)
|
||||
|
||||
|
||||
def _toolset_has_keys(ts_key: str) -> bool:
|
||||
"""Check if a toolset's required API keys are configured."""
|
||||
requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, [])
|
||||
if not requirements:
|
||||
return True
|
||||
return all(get_env_value(var) for var, _ in requirements)
|
||||
|
||||
|
||||
def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str]:
|
||||
"""Multi-select checklist of toolsets. Returns set of selected toolset keys."""
|
||||
print(color(f"Tools for {platform_label}", Colors.YELLOW))
|
||||
print(color(" SPACE to toggle, ENTER to confirm.", Colors.DIM))
|
||||
print()
|
||||
import platform as _platform
|
||||
|
||||
labels = []
|
||||
for ts_key, ts_label, ts_desc in CONFIGURABLE_TOOLSETS:
|
||||
labels.append(f"{ts_label} ({ts_desc})")
|
||||
suffix = ""
|
||||
if not _toolset_has_keys(ts_key) and TOOLSET_ENV_REQUIREMENTS.get(ts_key):
|
||||
suffix = " ⚠ no API key"
|
||||
labels.append(f"{ts_label} ({ts_desc}){suffix}")
|
||||
|
||||
pre_selected_indices = [
|
||||
i for i, (ts_key, _, _) in enumerate(CONFIGURABLE_TOOLSETS)
|
||||
if ts_key in enabled
|
||||
]
|
||||
|
||||
try:
|
||||
from simple_term_menu import TerminalMenu
|
||||
# simple_term_menu multi-select has rendering bugs on macOS terminals,
|
||||
# so we use a curses-based fallback there.
|
||||
use_term_menu = _platform.system() != "Darwin"
|
||||
|
||||
menu_items = [f" {label}" for label in labels]
|
||||
preselected = [menu_items[i] for i in pre_selected_indices if i < len(menu_items)]
|
||||
if use_term_menu:
|
||||
try:
|
||||
from simple_term_menu import TerminalMenu
|
||||
|
||||
menu = TerminalMenu(
|
||||
menu_items,
|
||||
multi_select=True,
|
||||
show_multi_select_hint=False,
|
||||
multi_select_cursor="[✓] ",
|
||||
multi_select_select_on_accept=False,
|
||||
multi_select_empty_ok=True,
|
||||
preselected_entries=preselected if preselected else None,
|
||||
menu_cursor="→ ",
|
||||
menu_cursor_style=("fg_green", "bold"),
|
||||
menu_highlight_style=("fg_green",),
|
||||
cycle_cursor=True,
|
||||
clear_screen=False,
|
||||
)
|
||||
|
||||
menu.show()
|
||||
|
||||
if menu.chosen_menu_entries is None:
|
||||
return enabled
|
||||
|
||||
selected_indices = list(menu.chosen_menu_indices or [])
|
||||
|
||||
return {CONFIGURABLE_TOOLSETS[i][0] for i in selected_indices}
|
||||
|
||||
except (ImportError, NotImplementedError):
|
||||
# Fallback: numbered toggle
|
||||
selected = set(pre_selected_indices)
|
||||
while True:
|
||||
for i, label in enumerate(labels):
|
||||
marker = color("[✓]", Colors.GREEN) if i in selected else "[ ]"
|
||||
print(f" {marker} {i + 1}. {label}")
|
||||
print(color(f"Tools for {platform_label}", Colors.YELLOW))
|
||||
print(color(" SPACE to toggle, ENTER to confirm.", Colors.DIM))
|
||||
print()
|
||||
try:
|
||||
val = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip()
|
||||
if not val:
|
||||
break
|
||||
idx = int(val) - 1
|
||||
if 0 <= idx < len(labels):
|
||||
if idx in selected:
|
||||
selected.discard(idx)
|
||||
else:
|
||||
selected.add(idx)
|
||||
except (ValueError, KeyboardInterrupt, EOFError):
|
||||
|
||||
menu_items = [f" {label}" for label in labels]
|
||||
menu = TerminalMenu(
|
||||
menu_items,
|
||||
multi_select=True,
|
||||
show_multi_select_hint=False,
|
||||
multi_select_cursor="[✓] ",
|
||||
multi_select_select_on_accept=False,
|
||||
multi_select_empty_ok=True,
|
||||
preselected_entries=pre_selected_indices if pre_selected_indices else None,
|
||||
menu_cursor="→ ",
|
||||
menu_cursor_style=("fg_green", "bold"),
|
||||
menu_highlight_style=("fg_green",),
|
||||
cycle_cursor=True,
|
||||
clear_screen=False,
|
||||
clear_menu_on_exit=False,
|
||||
)
|
||||
|
||||
menu.show()
|
||||
|
||||
if menu.chosen_menu_entries is None:
|
||||
return enabled
|
||||
print()
|
||||
|
||||
return {CONFIGURABLE_TOOLSETS[i][0] for i in selected}
|
||||
selected_indices = list(menu.chosen_menu_indices or [])
|
||||
return {CONFIGURABLE_TOOLSETS[i][0] for i in selected_indices}
|
||||
|
||||
except (ImportError, NotImplementedError):
|
||||
pass # fall through to curses/numbered fallback
|
||||
|
||||
# Curses-based multi-select — arrow keys + space to toggle + enter to confirm.
|
||||
# Used on macOS (where simple_term_menu ghosts) and as a fallback.
|
||||
try:
|
||||
import curses
|
||||
selected = set(pre_selected_indices)
|
||||
result_holder = [None]
|
||||
|
||||
def _curses_checklist(stdscr):
|
||||
curses.curs_set(0)
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(3, 8, -1) # dim gray
|
||||
cursor = 0
|
||||
scroll_offset = 0
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
header = f"Tools for {platform_label} — ↑↓ navigate, SPACE toggle, ENTER confirm"
|
||||
try:
|
||||
stdscr.addnstr(0, 0, header, max_x - 1, curses.A_BOLD | curses.color_pair(2) if curses.has_colors() else curses.A_BOLD)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
visible_rows = max_y - 3
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor - visible_rows + 1
|
||||
|
||||
for draw_i, i in enumerate(range(scroll_offset, min(len(labels), scroll_offset + visible_rows))):
|
||||
y = draw_i + 2
|
||||
if y >= max_y - 1:
|
||||
break
|
||||
check = "✓" if i in selected else " "
|
||||
arrow = "→" if i == cursor else " "
|
||||
line = f" {arrow} [{check}] {labels[i]}"
|
||||
|
||||
attr = curses.A_NORMAL
|
||||
if i == cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in (curses.KEY_UP, ord('k')):
|
||||
cursor = (cursor - 1) % len(labels)
|
||||
elif key in (curses.KEY_DOWN, ord('j')):
|
||||
cursor = (cursor + 1) % len(labels)
|
||||
elif key == ord(' '):
|
||||
if cursor in selected:
|
||||
selected.discard(cursor)
|
||||
else:
|
||||
selected.add(cursor)
|
||||
elif key in (curses.KEY_ENTER, 10, 13):
|
||||
result_holder[0] = {CONFIGURABLE_TOOLSETS[i][0] for i in selected}
|
||||
return
|
||||
elif key in (27, ord('q')): # ESC or q
|
||||
result_holder[0] = enabled
|
||||
return
|
||||
|
||||
curses.wrapper(_curses_checklist)
|
||||
return result_holder[0] if result_holder[0] is not None else enabled
|
||||
|
||||
except Exception:
|
||||
pass # fall through to numbered toggle
|
||||
|
||||
# Final fallback: numbered toggle (Windows without curses, etc.)
|
||||
selected = set(pre_selected_indices)
|
||||
print(color(f"\n Tools for {platform_label}", Colors.YELLOW))
|
||||
print(color(" Toggle by number, Enter to confirm.\n", Colors.DIM))
|
||||
|
||||
while True:
|
||||
for i, label in enumerate(labels):
|
||||
marker = color("[✓]", Colors.GREEN) if i in selected else "[ ]"
|
||||
print(f" {marker} {i + 1:>2}. {label}")
|
||||
print()
|
||||
try:
|
||||
val = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip()
|
||||
if not val:
|
||||
break
|
||||
idx = int(val) - 1
|
||||
if 0 <= idx < len(labels):
|
||||
if idx in selected:
|
||||
selected.discard(idx)
|
||||
else:
|
||||
selected.add(idx)
|
||||
except (ValueError, KeyboardInterrupt, EOFError):
|
||||
return enabled
|
||||
print()
|
||||
|
||||
return {CONFIGURABLE_TOOLSETS[i][0] for i in selected}
|
||||
|
||||
|
||||
# Map toolset keys to the env vars they require and where to get them
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue