feat: implement AI session management, fix UI rendering, and release 5.0b6

- Bump version to 5.0b6 and regenerate HTML documentation via pdoc3.
- Add persistent AI chat sessions (list, resume, delete) stored locally.
- Fix 'rich' library console rendering and routing 'error()' to stderr.
- Update Architect UI color theme to medium_purple.
- Sanitize caching metadata (cache_control) for compatibility with non-Anthropic models.
- Fix .folder config path redirection mapping and fzf-wrapper compatibility.
- Ensure context plugin correctly filters node lists upon load.
- Inject config instance directly into API components instead of instantiating globally.
- Fix edge-case in plugin loading preventing startup when folder is missing.
- Add comprehensive test coverage for printer module and AI sessions.
This commit is contained in:
2026-04-06 15:52:09 -03:00
parent af85051eb7
commit 85b23526cd
23 changed files with 1092 additions and 263 deletions
+68 -28
View File
@@ -18,14 +18,15 @@ class NoAliasDumper(yaml.SafeDumper):
def ignore_aliases(self, data):
return True
from rich.markdown import Markdown
from rich.console import Console, Group
from rich.markdown import Markdown
from rich.panel import Panel
from rich.text import Text
from rich.rule import Rule
from rich.style import Style
from rich.prompt import Prompt
mdprint = Console().print
console = Console()
mdprint = printer.console.print
console = printer.console
try:
from pyfzf.pyfzf import FzfPrompt
except ImportError:
@@ -135,6 +136,10 @@ class connapp:
aiparser.add_argument("--architect-model", nargs=1, help="Override architect model")
aiparser.add_argument("--architect-api-key", nargs=1, help="Override architect api key")
aiparser.add_argument("--debug", action="store_true", help="Show AI reasoning and tool calls")
aiparser.add_argument("--list", "--list-sessions", dest="list_sessions", action="store_true", help="List saved AI sessions")
aiparser.add_argument("--session", nargs=1, help="Resume a specific AI session by ID")
aiparser.add_argument("--resume", action="store_true", help="Resume the most recent AI session")
aiparser.add_argument("--delete", "--delete-session", dest="delete_session", nargs=1, help="Delete an AI session by ID")
aiparser.set_defaults(func=self._func_ai)
#RUNPARSER
runparser = subparsers.add_parser("run", description="Run scripts or commands on nodes", formatter_class=argparse.RawTextHelpFormatter)
@@ -188,8 +193,10 @@ class connapp:
for preload in self.plugins.preloads.values():
preload.Preload(self)
if not os.path.exists(self.config.fzf_cachefile):
self.config._generate_nodes_cache()
# Update internal state and force cache generation after all preloads
self.nodes_list = self.config._getallnodes()
self.folders = self.config._getallfolders()
self.config._generate_nodes_cache()
#Generate helps
nodeparser.usage = self._help("usage", subparsers)
@@ -656,7 +663,7 @@ class connapp:
if not os.path.isdir(args.data[0]):
raise argparse.ArgumentTypeError(f"readable_dir:{args.data[0]} is not a valid path")
else:
pathfile = self.config.defaultdir + "/.folder"
pathfile = self.config.anchor_path + "/.folder"
folder = os.path.abspath(args.data[0]).rstrip('/')
with open(pathfile, "w") as f:
f.write(str(folder))
@@ -803,13 +810,15 @@ class connapp:
plugins = {}
# Iterate over all files in the specified folder
for file in os.listdir(self.config.defaultdir + "/plugins"):
# Check if the file is a Python file
if file.endswith('.py'):
enabled_files.append(os.path.splitext(file)[0])
# Check if the file is a Python backup file
elif file.endswith('.py.bkp'):
disabled_files.append(os.path.splitext(os.path.splitext(file)[0])[0])
plugins_dir = self.config.defaultdir + "/plugins"
if os.path.exists(plugins_dir):
for file in os.listdir(plugins_dir):
# Check if the file is a Python file
if file.endswith('.py'):
enabled_files.append(os.path.splitext(file)[0])
# Check if the file is a Python backup file
elif file.endswith('.py.bkp'):
disabled_files.append(os.path.splitext(os.path.splitext(file)[0])[0])
if enabled_files:
plugins["Enabled"] = enabled_files
if disabled_files:
@@ -899,17 +908,35 @@ class connapp:
self.myai = self.ai(self.config, **arguments)
# 1. Gestionar comandos de sesión (Listar/Borrar)
if args.list_sessions:
self.myai.list_sessions()
return
if args.delete_session:
self.myai.delete_session(args.delete_session[0])
return
# 2. Determinar session_id para retomar
session_id = None
if args.resume:
session_id = self.myai.get_last_session_id()
if not session_id:
printer.warning("No previous session found to resume.")
elif args.session:
session_id = args.session[0]
if args.ask:
# Single question mode
query = " ".join(args.ask)
with console.status("[bold green]Agent is thinking and analyzing...") as status:
result = self.myai.ask(query, status=status, debug=args.debug)
result = self.myai.ask(query, status=status, debug=args.debug, session_id=session_id)
# Determine title and color based on responder
responder = result.get("responder", "engineer")
if responder == "architect":
title = "[bold purple]Network Architect[/bold purple]"
border_style = "purple"
title = "[bold medium_purple]Network Architect[/bold medium_purple]"
border_style = "medium_purple"
else:
title = "[bold blue]Network Engineer[/bold blue]"
border_style = "blue"
@@ -927,9 +954,20 @@ class connapp:
else:
# Interactive chat mode
history = None
mdprint(Rule(style="bold blue"))
mdprint(Markdown("**Networking Expert Agent**: Hi! I'm your assistant. I can help you diagnose issues, run commands, and manage your nodes.\nType 'exit' to quit.\n"))
mdprint(Rule(style="bold blue"))
if session_id:
session_data = self.myai.load_session_data(session_id)
if session_data:
history = session_data.get("history", [])
mdprint(Rule(title=f"[bold cyan] Resuming Session: {session_data.get('title')} [/bold cyan]", style="cyan"))
else:
printer.error(f"Could not load session {session_id}. Starting clean.")
if not history:
mdprint(Rule(style="bold blue"))
mdprint(Markdown("**Networking Expert Agent**: Hi! I'm your assistant. I can help you diagnose issues, run commands, and manage your nodes.\nType 'exit' to quit.\n"))
mdprint(Rule(style="bold blue"))
else:
mdprint(f"[dim]Analyzing {len(history)} previous messages...[/dim]\n")
while True:
try:
@@ -984,18 +1022,18 @@ class connapp:
return True
def _func_api(self, args):
if args.command == "stop" or args.command == "restart":
if args.command == "stop" or args.command == "restart" or args.command == "stop":
args.data = self.stop_api()
if args.command == "start" or args.command == "restart":
if args.data:
self.start_api(args.data)
self.start_api(args.data, config=self.config)
else:
self.start_api()
self.start_api(config=self.config)
if args.command == "debug":
if args.data:
self.debug_api(args.data)
self.debug_api(args.data, config=self.config)
else:
self.debug_api()
self.debug_api(config=self.config)
return
def _node_run(self, args):
@@ -1577,8 +1615,9 @@ compdef _conn connpy
connpy() {
if [ $# -eq 0 ]; then
local selected
if [ -f ~/.config/conn/.fzf_nodes_cache.txt ]; then
selected=$(cat ~/.config/conn/.fzf_nodes_cache.txt | fzf-tmux -d 25% --reverse)
local configdir=$(cat ~/.config/conn/.folder 2>/dev/null || echo ~/.config/conn)
if [ -s "$configdir/.fzf_nodes_cache.txt" ]; then
selected=$(cat "$configdir/.fzf_nodes_cache.txt" | fzf-tmux -i -d 25%)
else
command connpy
return
@@ -1598,8 +1637,9 @@ alias c="connpy"
connpy() {
if [ $# -eq 0 ]; then
local selected
if [ -f ~/.config/conn/.fzf_nodes_cache.txt ]; then
selected=$(cat ~/.config/conn/.fzf_nodes_cache.txt | fzf-tmux -d 25% --reverse)
local configdir=$(cat ~/.config/conn/.folder 2>/dev/null || echo ~/.config/conn)
if [ -s "$configdir/.fzf_nodes_cache.txt" ]; then
selected=$(cat "$configdir/.fzf_nodes_cache.txt" | fzf-tmux -i -d 25%)
else
command connpy
return