first implementation phase 1 and 2

This commit is contained in:
2026-05-07 15:17:00 -03:00
parent f6078b6fde
commit c4d704f473
7 changed files with 511 additions and 25 deletions
+107
View File
@@ -1211,5 +1211,112 @@ class ai:
"streamed": streamed_response "streamed": streamed_response
} }
@MethodHook
def ask_copilot(self, terminal_buffer, user_question, node_info=None):
"""Single-shot copilot for augmented terminal sessions.
Args:
terminal_buffer: Sanitized terminal screen content (últimas N líneas).
user_question: Pregunta del usuario sobre la sesión activa.
node_info: Optional dict con metadata del nodo (os, name, etc.)
Returns:
dict: {commands: list[str], guide: str, risk_level: str, error: str|None}
"""
import json
node_info = node_info or {}
os_info = node_info.get("os", "unknown")
node_name = node_info.get("name", "unknown")
system_prompt = f"""Role: TERMINAL COPILOT. You assist a network engineer during a live SSH session.
Rules:
1. Answer the user's question directly based on the Terminal Context.
2. If the user asks you to analyze, parse, or extract data from the Terminal Context, DO IT directly in the 'guide' section (you can use markdown tables or lists). Do NOT just give them a command to do it themselves.
3. If the user wants to execute an action, provide the required CLI commands in the 'commands' array. If no commands are needed, leave it empty.
4. ULTRA-CONCISE. Keep your guide to the point.
5. You MUST call provide_copilot_assistance with your response.
6. risk_level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops.
Terminal Context:
{terminal_buffer}
Device OS: {os_info}
Node: {node_name}"""
tools = [{
"type": "function",
"function": {
"name": "provide_copilot_assistance",
"description": "Provide terminal copilot assistance with suggested commands and a brief guide.",
"parameters": {
"type": "object",
"properties": {
"commands": {
"type": "array",
"items": {"type": "string"},
"description": "Ordered list of CLI commands. Each item is one command line."
},
"guide": {
"type": "string",
"description": "Brief tactical guide in markdown. 3-4 sentences max."
},
"risk_level": {
"type": "string",
"enum": ["low", "high", "destructive"],
"description": "Risk level: low=read-only, high=config change, destructive=dangerous."
}
},
"required": ["commands", "guide", "risk_level"]
}
}
}]
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_question}
]
try:
response = completion(
model=self.engineer_model,
messages=messages,
tools=tools,
tool_choice={"type": "function", "function": {"name": "provide_copilot_assistance"}},
api_key=self.engineer_key,
stream=False
)
message = response.choices[0].message
if hasattr(message, "tool_calls") and message.tool_calls:
for tool_call in message.tool_calls:
if tool_call.function.name == "provide_copilot_assistance":
try:
args = json.loads(tool_call.function.arguments)
return {
"commands": args.get("commands", []),
"guide": args.get("guide", ""),
"risk_level": args.get("risk_level", "low"),
"error": None
}
except json.JSONDecodeError:
pass
# Fallback if no tool called or decode error
return {
"commands": [],
"guide": getattr(message, "content", "") or "Could not parse response.",
"risk_level": "low",
"error": None
}
except Exception as e:
return {
"commands": [],
"guide": "",
"risk_level": "low",
"error": str(e)
}
@MethodHook @MethodHook
def confirm(self, user_input): return True def confirm(self, user_input): return True
+298 -3
View File
@@ -75,6 +75,7 @@ class node:
- jumphost (str): Reference another node to be used as a jumphost - jumphost (str): Reference another node to be used as a jumphost
''' '''
self.config = config
if config == '': if config == '':
self.idletime = 0 self.idletime = 0
self.key = None self.key = None
@@ -356,7 +357,7 @@ class node:
with open(self.logfile, "w") as f: with open(self.logfile, "w") as f:
f.write(self._logclean(self.mylog.getvalue().decode(), True)) f.write(self._logclean(self.mylog.getvalue().decode(), True))
async def _async_interact_loop(self, local_stream, resize_callback): async def _async_interact_loop(self, local_stream, resize_callback, copilot_handler=None):
local_stream.setup(resize_callback=resize_callback) local_stream.setup(resize_callback=resize_callback)
try: try:
child_fd = self.child.child_fd child_fd = self.child.child_fd
@@ -411,8 +412,30 @@ class node:
data = await local_stream.read() data = await local_stream.read()
if not data: if not data:
break break
# Copilot interception
if copilot_handler and b'\x00' in data:
# Extract clean buffer from session log
buffer = ""
if hasattr(self, 'mylog'):
raw = self.mylog.getvalue().decode(errors='replace')
buffer = self._logclean(raw, var=True)
# Pass the full buffer to the handler so the user can adjust context size interactively
# Build node info from available metadata
node_info = {"name": getattr(self, 'unique', 'unknown'), "host": getattr(self, 'host', 'unknown')}
if isinstance(getattr(self, 'tags', None), dict):
node_info["os"] = self.tags.get("os", "unknown")
# Invoke copilot (async callback handles UI)
await copilot_handler(buffer, node_info, local_stream, child_fd)
continue
# Remove any stray \x00 bytes and forward normally
clean_data = data.replace(b'\x00', b'')
if clean_data:
try: try:
os.write(child_fd, data) os.write(child_fd, clean_data)
except OSError: except OSError:
break break
self.lastinput = time() self.lastinput = time()
@@ -505,7 +528,10 @@ class node:
except Exception: except Exception:
pass pass
asyncio.run(self._async_interact_loop(local_stream, resize_callback)) # Build local copilot handler
copilot_handler = self._build_local_copilot_handler()
asyncio.run(self._async_interact_loop(local_stream, resize_callback, copilot_handler=copilot_handler))
finally: finally:
self._teardown_interact_environment() self._teardown_interact_environment()
else: else:
@@ -515,6 +541,275 @@ class node:
printer.error(f"Connection failed: {str(connect)}") printer.error(f"Connection failed: {str(connect)}")
sys.exit(1) sys.exit(1)
def _build_local_copilot_handler(self):
"""Build copilot handler for local CLI sessions using rich for rendering."""
config = getattr(self, 'config', None) if hasattr(self, 'config') else None
if not config:
return None
# Persistent history across copilot invocations within the same session
from prompt_toolkit.history import InMemoryHistory
copilot_history = InMemoryHistory()
async def handler(buffer, node_info, stream, child_fd):
import termios, tty
import asyncio
import os
import sys
try:
# Disable LocalStream reader so it doesn't steal keystrokes from Prompt
loop = asyncio.get_running_loop()
loop.remove_reader(sys.stdin.fileno())
# Override SIGINT so asyncio doesn't kill the event loop when we press Ctrl+C
import signal
orig_sigint = signal.getsignal(signal.SIGINT)
def custom_sigint(sig, frame):
raise KeyboardInterrupt()
signal.signal(signal.SIGINT, custom_sigint)
# 1. Salir de raw mode para poder usar input() y rich
stdin_fd = sys.stdin.fileno()
# Get true original settings saved before entering raw mode
original_settings = getattr(stream, 'original_tty_settings', None)
if original_settings:
import copy
new_settings = copy.deepcopy(original_settings)
new_settings[3] = new_settings[3] & ~termios.ECHOCTL
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, new_settings)
# Remove O_NONBLOCK from stdin so Prompt.ask() works
import fcntl
flags = fcntl.fcntl(stdin_fd, fcntl.F_GETFL)
fcntl.fcntl(stdin_fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
# Force a carriage return so the UI doesn't start mid-line
sys.stdout.write('\r\n')
sys.stdout.flush()
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
from rich.prompt import Prompt
from .printer import connpy_theme
console = Console(theme=connpy_theme)
console.print("\n")
console.print(Panel(
"[bold cyan]AI Terminal Copilot[/bold cyan]\n"
"[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel.\n"
"Ctrl+\u2191/\u2193 to adjust context lines. \u2191\u2193 for question history.[/dim]",
border_style="cyan"
))
# 2. Capturar pregunta del usuario
total_lines = len(buffer.split('\n'))
context_lines = [min(50, total_lines)]
cancelled = [False]
from prompt_toolkit import PromptSession
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.formatted_text import HTML
bindings = KeyBindings()
@bindings.add('c-up')
def _(event):
if context_lines[0] >= total_lines:
context_lines[0] = min(50, total_lines)
else:
context_lines[0] = min(context_lines[0] + 50, total_lines)
event.app.invalidate()
@bindings.add('c-down')
def _(event):
if context_lines[0] <= min(50, total_lines):
context_lines[0] = total_lines
else:
context_lines[0] = max(context_lines[0] - 50, min(50, total_lines))
event.app.invalidate()
@bindings.add('escape')
def _(event):
cancelled[0] = True
event.app.exit(result='')
def get_prompt_text():
return HTML(f"<ansicyan>Ask [Ctx: {context_lines[0]}/{total_lines}L]: </ansicyan>")
session = PromptSession(history=copilot_history)
question = await session.prompt_async(get_prompt_text, key_bindings=bindings)
if cancelled[0] or not question.strip():
console.print("\n[dim]Copilot cancelled.[/dim]")
os.write(child_fd, b'\x15\r')
return
# Slice the buffer dynamically based on selected context
buffer_lines = buffer.split('\n')
active_buffer = '\n'.join(buffer_lines[-context_lines[0]:])
# 3. Llamar al AI con spinner
from .services.ai_service import AIService
service = AIService(config)
past_questions = copilot_history.get_strings()
if len(past_questions) > 1:
# Limit history to last 5 questions to save tokens, excluding current
recent_history = past_questions[-6:-1]
history_text = "\n".join(f"- {q}" for q in recent_history)
enriched_question = f"Previous questions in this session:\n{history_text}\n\nCurrent Question:\n{question}"
else:
enriched_question = question
with console.status("[bold cyan]Thinking...[/bold cyan]", spinner="dots"):
result = await asyncio.to_thread(service.ask_copilot, active_buffer, enriched_question, node_info)
if result.get("error"):
console.print(f"[red]Error: {result['error']}[/red]")
return
# 4. Renderizar respuesta
if result.get("guide"):
console.print(Panel(
Markdown(result["guide"]),
title="[bold cyan]Copilot Guide[/bold cyan]",
border_style="cyan"
))
commands = result.get("commands", [])
risk = result.get("risk_level", "low")
risk_style = {"low": "green", "high": "yellow", "destructive": "red"}.get(risk, "green")
if commands:
cmd_text = "\n".join(f" {i+1}. {cmd}" for i, cmd in enumerate(commands))
console.print(Panel(
cmd_text,
title=f"[bold {risk_style}]Suggested Commands [{risk.upper()}][/bold {risk_style}]",
border_style=risk_style
))
# 5. Preguntar si inyectar (usando prompt_toolkit)
confirm_session = PromptSession()
confirm_bindings = KeyBindings()
@confirm_bindings.add('escape')
def _(event):
event.app.exit(result='n')
pt_color = "ansi" + risk_style
action = await confirm_session.prompt_async(
HTML(f"<{pt_color}>Send commands? (y/n/e/number/range) [n]: </{pt_color}>"),
key_bindings=confirm_bindings
)
if not action.strip():
action = "n"
console.print("[dim]Returning to session...[/dim]\n")
action_l = action.lower().strip()
if action_l in ('y', 'yes', 'all'):
os.write(child_fd, b'\x15') # Ctrl+U to clear line
await asyncio.sleep(0.1)
for cmd in commands:
os.write(child_fd, (cmd + "\n").encode())
await asyncio.sleep(0.3)
elif action_l.startswith('e'):
# Edit mode
edit_session = PromptSession()
cmds_to_edit = []
if len(action_l) > 1 and action_l[1:].isdigit():
idx = int(action_l[1:]) - 1
if 0 <= idx < len(commands):
cmds_to_edit = [commands[idx]]
else:
cmds_to_edit = commands
if cmds_to_edit:
target_cmd = "\n".join(cmds_to_edit)
try:
edited_cmd = await edit_session.prompt_async(
HTML("<ansicyan>Edit commands (Alt+Enter or Esc,Enter to submit):\n</ansicyan>"),
default=target_cmd,
multiline=True
)
if edited_cmd.strip():
os.write(child_fd, b'\x15')
await asyncio.sleep(0.1)
for cmd in edited_cmd.split('\n'):
if cmd.strip():
os.write(child_fd, (cmd.strip() + "\n").encode())
await asyncio.sleep(0.3)
else:
os.write(child_fd, b'\x15\r')
except KeyboardInterrupt:
os.write(child_fd, b'\x15\r')
else:
os.write(child_fd, b'\x15\r')
elif action_l not in ('n', 'no', ''):
try:
selected_indices = set()
for part in action_l.split(','):
part = part.strip()
if not part: continue
if '-' in part:
start_str, end_str = part.split('-', 1)
start = int(start_str) - 1
end = int(end_str) - 1
for i in range(start, end + 1):
selected_indices.add(i)
else:
selected_indices.add(int(part) - 1)
valid_indices = sorted([i for i in selected_indices if 0 <= i < len(commands)])
if valid_indices:
os.write(child_fd, b'\x15') # Ctrl+U to clear line
await asyncio.sleep(0.1)
if len(valid_indices) == 1:
os.write(child_fd, (commands[valid_indices[0]] + "\n").encode())
else:
for idx in valid_indices:
os.write(child_fd, (commands[idx] + "\n").encode())
await asyncio.sleep(0.3)
else:
os.write(child_fd, b'\x15\r') # Ctrl+U + Enter to abort line and get new prompt
except ValueError:
os.write(child_fd, b'\x15\r')
else:
os.write(child_fd, b'\x15\r')
else:
console.print("[dim]Returning to session...[/dim]\n")
os.write(child_fd, b'\x15\r')
except KeyboardInterrupt:
if 'console' in locals():
console.print("\n[dim]Copilot cancelled via Ctrl+C.[/dim]\n")
else:
print("\n[dim]Copilot cancelled via Ctrl+C.[/dim]\n")
os.write(child_fd, b'\x15\r')
except Exception as e:
import traceback
print(f"\n[ERROR in Copilot Handler] {e}", flush=True)
traceback.print_exc()
finally:
# 6. Restaurar raw mode, O_NONBLOCK y SIGINT
tty.setraw(stdin_fd)
fcntl.fcntl(stdin_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
if 'orig_sigint' in locals():
signal.signal(signal.SIGINT, orig_sigint)
# Re-enable LocalStream reader
try:
loop = asyncio.get_running_loop()
loop.add_reader(stdin_fd, stream._read_ready)
except Exception:
pass
return handler
@MethodHook @MethodHook
def run(self, commands, vars = None,*, folder = '', prompt = r'>$|#$|\$$|>.$|#.$|\$.$', stdout = False, timeout = 10, logger = None): def run(self, commands, vars = None,*, folder = '', prompt = r'>$|#$|\$$|>.$|#.$|\$.$', stdout = False, timeout = 10, logger = None):
File diff suppressed because one or more lines are too long
+45 -1
View File
@@ -2,7 +2,8 @@
"""Client and server classes corresponding to protobuf-defined services.""" """Client and server classes corresponding to protobuf-defined services."""
import grpc import grpc
import warnings import warnings
from . import connpy_pb2 as connpy__pb2
import connpy_pb2 as connpy__pb2
from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2
GRPC_GENERATED_VERSION = '1.80.0' GRPC_GENERATED_VERSION = '1.80.0'
@@ -1895,6 +1896,11 @@ class AIServiceStub(object):
request_serializer=connpy__pb2.StringRequest.SerializeToString, request_serializer=connpy__pb2.StringRequest.SerializeToString,
response_deserializer=connpy__pb2.BoolResponse.FromString, response_deserializer=connpy__pb2.BoolResponse.FromString,
_registered_method=True) _registered_method=True)
self.ask_copilot = channel.unary_unary(
'/connpy.AIService/ask_copilot',
request_serializer=connpy__pb2.CopilotRequest.SerializeToString,
response_deserializer=connpy__pb2.CopilotResponse.FromString,
_registered_method=True)
self.list_sessions = channel.unary_unary( self.list_sessions = channel.unary_unary(
'/connpy.AIService/list_sessions', '/connpy.AIService/list_sessions',
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
@@ -1932,6 +1938,12 @@ class AIServiceServicer(object):
context.set_details('Method not implemented!') context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!') raise NotImplementedError('Method not implemented!')
def ask_copilot(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def list_sessions(self, request, context): def list_sessions(self, request, context):
"""Missing associated documentation comment in .proto file.""" """Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_code(grpc.StatusCode.UNIMPLEMENTED)
@@ -1969,6 +1981,11 @@ def add_AIServiceServicer_to_server(servicer, server):
request_deserializer=connpy__pb2.StringRequest.FromString, request_deserializer=connpy__pb2.StringRequest.FromString,
response_serializer=connpy__pb2.BoolResponse.SerializeToString, response_serializer=connpy__pb2.BoolResponse.SerializeToString,
), ),
'ask_copilot': grpc.unary_unary_rpc_method_handler(
servicer.ask_copilot,
request_deserializer=connpy__pb2.CopilotRequest.FromString,
response_serializer=connpy__pb2.CopilotResponse.SerializeToString,
),
'list_sessions': grpc.unary_unary_rpc_method_handler( 'list_sessions': grpc.unary_unary_rpc_method_handler(
servicer.list_sessions, servicer.list_sessions,
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
@@ -2054,6 +2071,33 @@ class AIService(object):
metadata, metadata,
_registered_method=True) _registered_method=True)
@staticmethod
def ask_copilot(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AIService/ask_copilot',
connpy__pb2.CopilotRequest.SerializeToString,
connpy__pb2.CopilotResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod @staticmethod
def list_sessions(request, def list_sessions(request,
target, target,
+16
View File
@@ -748,6 +748,22 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
res = self.service.confirm(request.value) res = self.service.confirm(request.value)
return connpy_pb2.BoolResponse(value=res) return connpy_pb2.BoolResponse(value=res)
@handle_errors
def ask_copilot(self, request, context):
import json
node_info = json.loads(request.node_info_json) if request.node_info_json else None
result = self.service.ask_copilot(
request.terminal_buffer,
request.user_question,
node_info
)
return connpy_pb2.CopilotResponse(
commands=result.get("commands", []),
guide=result.get("guide", ""),
risk_level=result.get("risk_level", "low"),
error=result.get("error") or ""
)
@handle_errors @handle_errors
def list_sessions(self, request, context): def list_sessions(self, request, context):
return connpy_pb2.ValueResponse(data=to_value(self.service.list_sessions())) return connpy_pb2.ValueResponse(data=to_value(self.service.list_sessions()))
+14
View File
@@ -65,6 +65,7 @@ service ImportExportService {
service AIService { service AIService {
rpc ask (stream AskRequest) returns (stream AIResponse) {} rpc ask (stream AskRequest) returns (stream AIResponse) {}
rpc confirm (StringRequest) returns (BoolResponse) {} rpc confirm (StringRequest) returns (BoolResponse) {}
rpc ask_copilot (CopilotRequest) returns (CopilotResponse) {}
rpc list_sessions (google.protobuf.Empty) returns (ValueResponse) {} rpc list_sessions (google.protobuf.Empty) returns (ValueResponse) {}
rpc delete_session (StringRequest) returns (google.protobuf.Empty) {} rpc delete_session (StringRequest) returns (google.protobuf.Empty) {}
rpc configure_provider (ProviderRequest) returns (google.protobuf.Empty) {} rpc configure_provider (ProviderRequest) returns (google.protobuf.Empty) {}
@@ -257,3 +258,16 @@ message FullReplaceRequest {
google.protobuf.Struct connections = 1; google.protobuf.Struct connections = 1;
google.protobuf.Struct profiles = 2; google.protobuf.Struct profiles = 2;
} }
message CopilotRequest {
string terminal_buffer = 1;
string user_question = 2;
string node_info_json = 3;
}
message CopilotResponse {
repeated string commands = 1;
string guide = 2;
string risk_level = 3;
string error = 4;
}
+6
View File
@@ -17,6 +17,12 @@ class AIService(BaseService):
agent = ai(self.config, console=console) agent = ai(self.config, console=console)
return agent.confirm(input_text) return agent.confirm(input_text)
def ask_copilot(self, terminal_buffer, user_question, node_info=None):
"""Ask the AI copilot for terminal assistance."""
from connpy.ai import ai
agent = ai(self.config)
return agent.ask_copilot(terminal_buffer, user_question, node_info)
def list_sessions(self): def list_sessions(self):
"""Return a list of all saved AI sessions.""" """Return a list of all saved AI sessions."""