feat: openclaw-style secrets (env.vars + \) and per-task model routing
- Replace python-dotenv with config.json env.vars block + \ substitution - Add models section for per-task model routing (heartbeat, subagent, default) - Heartbeat/subagent tasks can use different models/providers than main chat - Remove python-dotenv from dependencies - Update all docs to reflect new config approach - Reorganize docs into project/ and research/ subdirectories
This commit is contained in:
340
config.py
340
config.py
@@ -1,28 +1,48 @@
|
||||
"""
|
||||
Aetheel Configuration
|
||||
=====================
|
||||
Loads configuration from ~/.aetheel/config.json with .env fallback for secrets.
|
||||
Loads configuration from ~/.aetheel/config.json.
|
||||
|
||||
Secrets (tokens, API keys) live in config.json using ``${VAR}`` env-var
|
||||
references or directly in the ``env.vars`` block — no separate ``.env``
|
||||
file required.
|
||||
|
||||
Config hierarchy (highest priority wins):
|
||||
1. CLI arguments (--model, --claude, etc.)
|
||||
2. Environment variables
|
||||
3. ~/.aetheel/config.json
|
||||
4. Defaults
|
||||
2. Process environment variables
|
||||
3. ``env.vars`` block in config.json (applied to process env when not
|
||||
already set)
|
||||
4. ``${VAR}`` substitution in any config string value
|
||||
5. ~/.aetheel/config.json static values
|
||||
6. Defaults
|
||||
|
||||
Secrets (tokens, passwords) should stay in .env — they are never written
|
||||
to the config file. Everything else goes in config.json.
|
||||
Example config.json snippet::
|
||||
|
||||
{
|
||||
"env": {
|
||||
"vars": {
|
||||
"DISCORD_BOT_TOKEN": "MTQ3...",
|
||||
"SLACK_BOT_TOKEN": "xoxb-..."
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"bot_token": "${DISCORD_BOT_TOKEN}"
|
||||
}
|
||||
}
|
||||
|
||||
Usage:
|
||||
from config import load_config, AetheelConfig
|
||||
|
||||
cfg = load_config()
|
||||
print(cfg.runtime.model)
|
||||
print(cfg.slack.bot_token) # from .env
|
||||
print(cfg.slack.bot_token) # resolved from ${SLACK_BOT_TOKEN}
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -31,6 +51,92 @@ logger = logging.getLogger("aetheel.config")
|
||||
CONFIG_DIR = os.path.expanduser("~/.aetheel")
|
||||
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ${VAR} environment variable substitution (openclaw-style)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}")
|
||||
_ESCAPED_ENV_VAR = re.compile(r"\$\$\{([A-Z_][A-Z0-9_]*)\}")
|
||||
|
||||
|
||||
class MissingEnvVarError(Exception):
|
||||
"""Raised when a ${VAR} reference cannot be resolved."""
|
||||
|
||||
def __init__(self, var_name: str, config_path: str = ""):
|
||||
self.var_name = var_name
|
||||
self.config_path = config_path
|
||||
super().__init__(
|
||||
f'Missing env var "{var_name}" referenced at config path: {config_path}'
|
||||
)
|
||||
|
||||
|
||||
def _substitute_string(value: str, env: dict[str, str], path: str = "") -> str:
|
||||
"""Resolve ``${VAR}`` references in a single string.
|
||||
|
||||
- ``${VAR}`` → env value (warns and keeps original if missing)
|
||||
- ``$${VAR}`` → literal ``${VAR}`` (escape sequence)
|
||||
"""
|
||||
if "$" not in value:
|
||||
return value
|
||||
|
||||
# First handle escapes: $${VAR} → ${VAR}
|
||||
result = _ESCAPED_ENV_VAR.sub(r"${\1}", value)
|
||||
|
||||
def _replace(m: re.Match) -> str:
|
||||
var_name = m.group(1)
|
||||
env_val = env.get(var_name)
|
||||
if env_val is None or env_val == "":
|
||||
logger.warning(
|
||||
"Config %s references ${%s} but it is not set — keeping literal",
|
||||
path,
|
||||
var_name,
|
||||
)
|
||||
return m.group(0)
|
||||
return env_val
|
||||
|
||||
return _ENV_VAR_PATTERN.sub(_replace, result)
|
||||
|
||||
|
||||
def _substitute_any(value, env: dict[str, str], path: str = ""):
|
||||
"""Recursively resolve ``${VAR}`` references in a parsed JSON structure."""
|
||||
if isinstance(value, str):
|
||||
return _substitute_string(value, env, path)
|
||||
if isinstance(value, list):
|
||||
return [_substitute_any(item, env, f"{path}[{i}]") for i, item in enumerate(value)]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
k: _substitute_any(v, env, f"{path}.{k}" if path else k)
|
||||
for k, v in value.items()
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def _apply_env_vars_block(data: dict, env: dict[str, str] | None = None) -> None:
|
||||
"""Apply ``env.vars`` from config to process env (if not already set).
|
||||
|
||||
This mirrors openclaw's ``applyConfigEnvVars``: inline vars are injected
|
||||
into the process environment so that ``${VAR}`` references elsewhere in
|
||||
the config (and in the rest of the application) can resolve them.
|
||||
"""
|
||||
if env is None:
|
||||
env = os.environ
|
||||
|
||||
env_block = data.get("env", {})
|
||||
if not isinstance(env_block, dict):
|
||||
return
|
||||
|
||||
vars_block = env_block.get("vars", {})
|
||||
if not isinstance(vars_block, dict):
|
||||
return
|
||||
|
||||
for key, value in vars_block.items():
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
continue
|
||||
# Only set if not already present in the environment
|
||||
if env.get(key, "").strip():
|
||||
continue
|
||||
env[key] = value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config Dataclasses
|
||||
@@ -48,6 +154,8 @@ class RuntimeConfig:
|
||||
server_url: str = "http://localhost:4096"
|
||||
workspace: str | None = None
|
||||
format: str = "json"
|
||||
agent: str | None = None # OpenCode agent name
|
||||
attach: str | None = None # Attach to running server (e.g. "http://localhost:4096")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -67,25 +175,54 @@ class ClaudeConfig:
|
||||
|
||||
@dataclass
|
||||
class SlackConfig:
|
||||
"""Slack adapter configuration. Tokens come from .env."""
|
||||
enabled: bool = True # auto-enabled when tokens present
|
||||
bot_token: str = "" # from .env: SLACK_BOT_TOKEN
|
||||
app_token: str = "" # from .env: SLACK_APP_TOKEN
|
||||
"""Slack adapter configuration. Tokens via config.json ${VAR} or env.vars."""
|
||||
enabled: bool = True
|
||||
bot_token: str = "" # ${SLACK_BOT_TOKEN} or env.vars
|
||||
app_token: str = "" # ${SLACK_APP_TOKEN} or env.vars
|
||||
|
||||
|
||||
@dataclass
|
||||
class TelegramConfig:
|
||||
"""Telegram adapter configuration."""
|
||||
enabled: bool = False # enable in config or set token
|
||||
bot_token: str = "" # from .env: TELEGRAM_BOT_TOKEN
|
||||
enabled: bool = False
|
||||
bot_token: str = "" # ${TELEGRAM_BOT_TOKEN} or env.vars
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscordChannelOverride:
|
||||
"""Per-channel config overrides for Discord."""
|
||||
history_limit: int | None = None # override history for this channel
|
||||
history_enabled: bool | None = None # enable/disable history for this channel
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscordConfig:
|
||||
"""Discord adapter configuration."""
|
||||
enabled: bool = False # enable in config or set token
|
||||
bot_token: str = "" # from .env: DISCORD_BOT_TOKEN
|
||||
enabled: bool = False
|
||||
bot_token: str = "" # ${DISCORD_BOT_TOKEN} or env.vars
|
||||
listen_channels: list[str] = field(default_factory=list)
|
||||
# Reply threading: "off", "first", "all"
|
||||
reply_to_mode: str = "first"
|
||||
# History context injection
|
||||
history_enabled: bool = True
|
||||
history_limit: int = 20 # messages to inject as context
|
||||
# Per-channel overrides: {"channel_id": {"history_limit": 10, "history_enabled": false}}
|
||||
channel_overrides: dict[str, DiscordChannelOverride] = field(default_factory=dict)
|
||||
# Ack reaction emoji sent while processing (empty string to disable)
|
||||
ack_reaction: str = "👀"
|
||||
# Typing indicator while processing
|
||||
typing_indicator: bool = True
|
||||
# Reaction handling: "off", "own", "all"
|
||||
reaction_mode: str = "own"
|
||||
# Exec approvals: require button confirmation for AI tool use
|
||||
exec_approvals: bool = False
|
||||
exec_approval_tools: list[str] = field(default_factory=lambda: [
|
||||
"Bash", "Write", "Edit",
|
||||
])
|
||||
# Slash commands
|
||||
slash_commands: bool = True
|
||||
# Interactive components (buttons, selects, modals)
|
||||
components_enabled: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -115,6 +252,39 @@ class MCPConfig:
|
||||
servers: dict[str, MCPServerConfig] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelRouteConfig:
|
||||
"""Per-task model override.
|
||||
|
||||
When set, the task uses a dedicated runtime with this model/engine
|
||||
instead of the global default. Leave fields as ``None`` to inherit
|
||||
from the top-level ``runtime`` / ``claude`` config.
|
||||
"""
|
||||
engine: str | None = None # "opencode" or "claude" — None inherits global
|
||||
model: str | None = None # e.g. "ollama/llama3.2", "minimax/minimax-m1"
|
||||
provider: str | None = None # e.g. "ollama", "minimax"
|
||||
timeout_seconds: int | None = None # None inherits global
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelRoutingConfig:
|
||||
"""Named model routes for different task types.
|
||||
|
||||
Example config.json::
|
||||
|
||||
"models": {
|
||||
"heartbeat": { "engine": "opencode", "model": "ollama/llama3.2", "provider": "ollama" },
|
||||
"subagent": { "engine": "opencode", "model": "minimax/minimax-m1", "provider": "minimax" },
|
||||
"default": null
|
||||
}
|
||||
|
||||
Any task type not listed (or set to ``null``) uses the global runtime.
|
||||
"""
|
||||
heartbeat: ModelRouteConfig | None = None
|
||||
subagent: ModelRouteConfig | None = None
|
||||
default: ModelRouteConfig | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeartbeatConfig:
|
||||
"""Heartbeat / proactive system configuration."""
|
||||
@@ -123,6 +293,38 @@ class HeartbeatConfig:
|
||||
default_channel_id: str = ""
|
||||
silent: bool = False
|
||||
|
||||
@dataclass
|
||||
class ModelRouteConfig:
|
||||
"""Per-task model override.
|
||||
|
||||
When set, the task uses a dedicated runtime with this model/engine
|
||||
instead of the global default. Leave fields as ``None`` to inherit
|
||||
from the top-level ``runtime`` / ``claude`` config.
|
||||
"""
|
||||
engine: str | None = None # "opencode" or "claude" — None inherits global
|
||||
model: str | None = None # e.g. "ollama/llama3.2", "minimax/minimax-m1"
|
||||
provider: str | None = None # e.g. "ollama", "minimax"
|
||||
timeout_seconds: int | None = None # None inherits global
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelRoutingConfig:
|
||||
"""Named model routes for different task types.
|
||||
|
||||
Example config.json::
|
||||
|
||||
"models": {
|
||||
"heartbeat": { "engine": "opencode", "model": "ollama/llama3.2", "provider": "ollama" },
|
||||
"subagent": { "engine": "opencode", "model": "minimax/minimax-m1", "provider": "minimax" },
|
||||
"default": null
|
||||
}
|
||||
|
||||
Any task type not listed (or set to ``null``) uses the global runtime.
|
||||
"""
|
||||
heartbeat: ModelRouteConfig | None = None
|
||||
subagent: ModelRouteConfig | None = None
|
||||
default: ModelRouteConfig | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebChatConfig:
|
||||
@@ -153,6 +355,7 @@ class AetheelConfig:
|
||||
log_level: str = "INFO"
|
||||
runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
|
||||
claude: ClaudeConfig = field(default_factory=ClaudeConfig)
|
||||
models: ModelRoutingConfig = field(default_factory=ModelRoutingConfig)
|
||||
slack: SlackConfig = field(default_factory=SlackConfig)
|
||||
telegram: TelegramConfig = field(default_factory=TelegramConfig)
|
||||
discord: DiscordConfig = field(default_factory=DiscordConfig)
|
||||
@@ -181,10 +384,12 @@ def _deep_get(data: dict, *keys, default=None):
|
||||
|
||||
def load_config() -> AetheelConfig:
|
||||
"""
|
||||
Load configuration from config.json + environment variables.
|
||||
Load configuration from config.json with ``${VAR}`` env-var substitution.
|
||||
|
||||
Config file provides non-secret settings. Environment variables
|
||||
(from .env or shell) provide secrets and can override any setting.
|
||||
1. Parse config.json
|
||||
2. Apply ``env.vars`` block to process env (if not already set)
|
||||
3. Resolve ``${VAR}`` references throughout the config
|
||||
4. Process environment variables still override everything
|
||||
"""
|
||||
cfg = AetheelConfig()
|
||||
file_data: dict = {}
|
||||
@@ -198,7 +403,13 @@ def load_config() -> AetheelConfig:
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning(f"Failed to load {CONFIG_PATH}: {e}")
|
||||
|
||||
# 2. Apply config.json values
|
||||
# 2. Apply env.vars block to process env (openclaw-style)
|
||||
_apply_env_vars_block(file_data)
|
||||
|
||||
# 3. Resolve ${VAR} references throughout the config
|
||||
file_data = _substitute_any(file_data, dict(os.environ))
|
||||
|
||||
# 4. Apply config.json values
|
||||
cfg.log_level = _deep_get(file_data, "log_level", default=cfg.log_level)
|
||||
|
||||
# Runtime
|
||||
@@ -211,6 +422,8 @@ def load_config() -> AetheelConfig:
|
||||
cfg.runtime.server_url = rt.get("server_url", cfg.runtime.server_url)
|
||||
cfg.runtime.workspace = rt.get("workspace", cfg.runtime.workspace)
|
||||
cfg.runtime.format = rt.get("format", cfg.runtime.format)
|
||||
cfg.runtime.agent = rt.get("agent", cfg.runtime.agent)
|
||||
cfg.runtime.attach = rt.get("attach", cfg.runtime.attach)
|
||||
|
||||
# Claude
|
||||
cl = file_data.get("claude", {})
|
||||
@@ -219,18 +432,41 @@ def load_config() -> AetheelConfig:
|
||||
cfg.claude.max_turns = cl.get("max_turns", cfg.claude.max_turns)
|
||||
cfg.claude.no_tools = cl.get("no_tools", cfg.claude.no_tools)
|
||||
|
||||
# Discord (non-secret settings from config)
|
||||
# Discord
|
||||
dc = file_data.get("discord", {})
|
||||
cfg.discord.enabled = dc.get("enabled", cfg.discord.enabled)
|
||||
cfg.discord.bot_token = dc.get("bot_token", cfg.discord.bot_token)
|
||||
cfg.discord.listen_channels = dc.get("listen_channels", cfg.discord.listen_channels)
|
||||
cfg.discord.reply_to_mode = dc.get("reply_to_mode", cfg.discord.reply_to_mode)
|
||||
cfg.discord.history_enabled = dc.get("history_enabled", cfg.discord.history_enabled)
|
||||
cfg.discord.history_limit = dc.get("history_limit", cfg.discord.history_limit)
|
||||
cfg.discord.ack_reaction = dc.get("ack_reaction", cfg.discord.ack_reaction)
|
||||
cfg.discord.typing_indicator = dc.get("typing_indicator", cfg.discord.typing_indicator)
|
||||
cfg.discord.reaction_mode = dc.get("reaction_mode", cfg.discord.reaction_mode)
|
||||
cfg.discord.exec_approvals = dc.get("exec_approvals", cfg.discord.exec_approvals)
|
||||
cfg.discord.slash_commands = dc.get("slash_commands", cfg.discord.slash_commands)
|
||||
cfg.discord.components_enabled = dc.get("components_enabled", cfg.discord.components_enabled)
|
||||
dc_exec_tools = dc.get("exec_approval_tools")
|
||||
if dc_exec_tools is not None:
|
||||
cfg.discord.exec_approval_tools = dc_exec_tools
|
||||
# Per-channel overrides
|
||||
dc_overrides = dc.get("channel_overrides", {})
|
||||
for ch_id, ch_cfg in dc_overrides.items():
|
||||
cfg.discord.channel_overrides[ch_id] = DiscordChannelOverride(
|
||||
history_limit=ch_cfg.get("history_limit"),
|
||||
history_enabled=ch_cfg.get("history_enabled"),
|
||||
)
|
||||
|
||||
# Slack (enabled flag from config)
|
||||
# Slack
|
||||
sl = file_data.get("slack", {})
|
||||
cfg.slack.enabled = sl.get("enabled", cfg.slack.enabled)
|
||||
cfg.slack.bot_token = sl.get("bot_token", cfg.slack.bot_token)
|
||||
cfg.slack.app_token = sl.get("app_token", cfg.slack.app_token)
|
||||
|
||||
# Telegram (enabled flag from config)
|
||||
# Telegram
|
||||
tg = file_data.get("telegram", {})
|
||||
cfg.telegram.enabled = tg.get("enabled", cfg.telegram.enabled)
|
||||
cfg.telegram.bot_token = tg.get("bot_token", cfg.telegram.bot_token)
|
||||
|
||||
# Memory
|
||||
mem = file_data.get("memory", {})
|
||||
@@ -241,6 +477,19 @@ def load_config() -> AetheelConfig:
|
||||
sched = file_data.get("scheduler", {})
|
||||
cfg.scheduler.db_path = sched.get("db_path", cfg.scheduler.db_path)
|
||||
|
||||
# Model routing (per-task model overrides)
|
||||
models_data = file_data.get("models", {})
|
||||
for task_name in ("heartbeat", "subagent", "default"):
|
||||
route_data = models_data.get(task_name)
|
||||
if isinstance(route_data, dict):
|
||||
route = ModelRouteConfig(
|
||||
engine=route_data.get("engine"),
|
||||
model=route_data.get("model"),
|
||||
provider=route_data.get("provider"),
|
||||
timeout_seconds=route_data.get("timeout_seconds"),
|
||||
)
|
||||
setattr(cfg.models, task_name, route)
|
||||
|
||||
# Claude allowed_tools
|
||||
cl_allowed = cl.get("allowed_tools")
|
||||
if cl_allowed is not None:
|
||||
@@ -280,7 +529,7 @@ def load_config() -> AetheelConfig:
|
||||
cfg.webhooks.host = wh.get("host", cfg.webhooks.host)
|
||||
cfg.webhooks.token = wh.get("token", cfg.webhooks.token)
|
||||
|
||||
# 3. Environment variables override everything (secrets + overrides)
|
||||
# 5. Environment variables override everything (secrets + overrides)
|
||||
cfg.log_level = os.environ.get("LOG_LEVEL", cfg.log_level)
|
||||
|
||||
cfg.runtime.engine = os.environ.get("AETHEEL_ENGINE", cfg.runtime.engine)
|
||||
@@ -308,11 +557,12 @@ def load_config() -> AetheelConfig:
|
||||
"CLAUDE_NO_TOOLS", str(cfg.claude.no_tools)
|
||||
).lower() == "true"
|
||||
|
||||
# Secrets from .env only
|
||||
cfg.slack.bot_token = os.environ.get("SLACK_BOT_TOKEN", cfg.slack.bot_token)
|
||||
cfg.slack.app_token = os.environ.get("SLACK_APP_TOKEN", cfg.slack.app_token)
|
||||
cfg.telegram.bot_token = os.environ.get("TELEGRAM_BOT_TOKEN", cfg.telegram.bot_token)
|
||||
cfg.discord.bot_token = os.environ.get("DISCORD_BOT_TOKEN", cfg.discord.bot_token)
|
||||
# Secrets — resolved from config.json ${VAR} refs or env.vars block.
|
||||
# Process env still overrides if set.
|
||||
cfg.slack.bot_token = os.environ.get("SLACK_BOT_TOKEN") or cfg.slack.bot_token
|
||||
cfg.slack.app_token = os.environ.get("SLACK_APP_TOKEN") or cfg.slack.app_token
|
||||
cfg.telegram.bot_token = os.environ.get("TELEGRAM_BOT_TOKEN") or cfg.telegram.bot_token
|
||||
cfg.discord.bot_token = os.environ.get("DISCORD_BOT_TOKEN") or cfg.discord.bot_token
|
||||
|
||||
# Discord listen channels: env overrides config
|
||||
env_channels = os.environ.get("DISCORD_LISTEN_CHANNELS", "").strip()
|
||||
@@ -336,7 +586,17 @@ def save_default_config() -> str:
|
||||
return CONFIG_PATH
|
||||
|
||||
default = {
|
||||
"$schema": "Aetheel configuration — edit this file, keep secrets in .env",
|
||||
"$schema": "Aetheel configuration — secrets go in env.vars, referenced via ${VAR}",
|
||||
"env": {
|
||||
"vars": {
|
||||
"SLACK_BOT_TOKEN": "",
|
||||
"SLACK_APP_TOKEN": "",
|
||||
"TELEGRAM_BOT_TOKEN": "",
|
||||
"DISCORD_BOT_TOKEN": "",
|
||||
"ANTHROPIC_API_KEY": "",
|
||||
"OPENCODE_SERVER_PASSWORD": "",
|
||||
}
|
||||
},
|
||||
"log_level": "INFO",
|
||||
"runtime": {
|
||||
"engine": "opencode",
|
||||
@@ -345,6 +605,8 @@ def save_default_config() -> str:
|
||||
"timeout_seconds": 120,
|
||||
"server_url": "http://localhost:4096",
|
||||
"format": "json",
|
||||
"agent": None,
|
||||
"attach": None,
|
||||
},
|
||||
"claude": {
|
||||
"model": None,
|
||||
@@ -358,15 +620,35 @@ def save_default_config() -> str:
|
||||
"TeamCreate", "TeamDelete", "SendMessage",
|
||||
],
|
||||
},
|
||||
"models": {
|
||||
"heartbeat": None,
|
||||
"subagent": None,
|
||||
"default": None,
|
||||
},
|
||||
"slack": {
|
||||
"enabled": True,
|
||||
"bot_token": "${SLACK_BOT_TOKEN}",
|
||||
"app_token": "${SLACK_APP_TOKEN}",
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": False,
|
||||
"bot_token": "${TELEGRAM_BOT_TOKEN}",
|
||||
},
|
||||
"discord": {
|
||||
"enabled": False,
|
||||
"bot_token": "${DISCORD_BOT_TOKEN}",
|
||||
"listen_channels": [],
|
||||
"reply_to_mode": "first",
|
||||
"history_enabled": True,
|
||||
"history_limit": 20,
|
||||
"channel_overrides": {},
|
||||
"ack_reaction": "👀",
|
||||
"typing_indicator": True,
|
||||
"reaction_mode": "own",
|
||||
"exec_approvals": False,
|
||||
"exec_approval_tools": ["Bash", "Write", "Edit"],
|
||||
"slash_commands": True,
|
||||
"components_enabled": True,
|
||||
},
|
||||
"memory": {
|
||||
"workspace": "~/.aetheel/workspace",
|
||||
|
||||
Reference in New Issue
Block a user