Compare commits

...

8 Commits

Author SHA1 Message Date
fluzzi32 3ca6f497d1 bug fix missing manifest 2026-05-18 16:57:02 -03:00
fluzzi32 1db4a045e5 bug fix double sys import 2026-05-18 15:46:21 -03:00
fluzzi32 7b01a0c05f update pdoc 2026-05-18 14:11:28 -03:00
fluzzi32 5a8b744aa8 📌 1. Uncommitted Changes (Staged for next commit)
Focus: UI stability and efficient rendering.
   * Markdown Rendering Rewrite: Removed the dependency on rich.live.Live (which caused flickering and
     high CPU usage by constantly re-rendering the entire panel).
   * New BlockMarkdownRenderer: Implemented in printer.py (alias IncrementalMarkdownParser), it
     accumulates text in a buffer and only prints to the screen when it detects a complete block (e.g.,
     line breaks or
  ` code blocks).
   * UI Optimizations (terminal_ui.py & stubs.py): Waiting spinners now stop cleanly, and the UI
     transitions smoothly to block printing. Fixed visual truncation issues in the bottom "Tab prompt"
     bar for excessively long commands.

  📦 2. Commit History (Last 4 Commits)

  9446baf - improve ai rules
   * Strict Anti-Hallucination (ai.py): Injected MANDATORY rules into the System Prompt for both
     Architect and Engineer agents. Now, if the terminal buffer is empty or only contains an idle prompt
     (e.g., iol#), the AI is strictly forced to state that it lacks data instead of inventing topologies
     or configurations.
   * Language Preference: Explicitly instructed agents to always respond in the same language the user
     used to ask the question.

  64377f7 - move context block logic to server and improvements
   * Context Precision (ai_service.py): Moved context partitioning logic to the service/server side. It
     now calculates exact start_pos and end_pos based on identified commands, preventing mixed outputs
     or residual text from bleeding into the AI's prompt.
   * Token Savings (server.py): The server now selectively strips garbage metadata and UI caches that
     add no value to the LLM before sending the payload over the wire.

  e4fd1ad - fix logclean for 6wind
   * Full ANSI/CSI Support (utils.py): Replaced the legacy rigid escape filter with a complete CSI
     (Control Sequence Introducer) parser. The client can now accurately process numeric cursor
     movements (C, D), inline dynamic erasures (K), and absolute shifts (G), ensuring connpy understands
     exactly what a 6WIND router or other VNFs render on the screen without garbage characters.

  b0a914a - updates al copilot
   * Copilot <> gRPC Integration: Implemented the async plumbing required for the Copilot to work over
     the gRPC tunnel (server.py & stubs.py).
   * Initial Streaming UI: Added the first iteration of chunk callbacks to provide real-time feedback
     while the LLM generates responses, laying the groundwork for the uncommitted block-renderer
     optimizations.
2026-05-18 14:07:24 -03:00
fluzzi32 9446bafc0c improve ai rules 2026-05-15 23:28:22 -03:00
fluzzi32 64377f7f30 move context block logic to server and improvemnets 2026-05-15 18:24:48 -03:00
fluzzi32 e4fd1adba3 fix logclean for6wind 2026-05-15 17:32:09 -03:00
fluzzi32 b0a914ad7f updates al copilot 2026-05-15 16:25:18 -03:00
19 changed files with 1172 additions and 633 deletions
+2
View File
@@ -164,6 +164,8 @@ connpy_roadmap.md
MULTI_USER_PLAN.md MULTI_USER_PLAN.md
COPILOT_PLAN.md COPILOT_PLAN.md
ARCHITECTURAL_DEBT_REFACTOR.md ARCHITECTURAL_DEBT_REFACTOR.md
COPILOT_UI_FEATURES.md
MULTI_USER_IMPLEMENTATION_STEPS.md
#themes #themes
nord.yml nord.yml
+1 -1
View File
@@ -3,6 +3,6 @@ include README.md
include requirements.txt include requirements.txt
recursive-include connpy/core_plugins * recursive-include connpy/core_plugins *
recursive-include connpy/proto * recursive-include connpy/proto *
recursive-include connpy/grpc *.proto recursive-include connpy/grpc_layer *
recursive-exclude * __pycache__ recursive-exclude * __pycache__
recursive-exclude * *.py[co] recursive-exclude * *.py[co]
+1 -1
View File
@@ -1 +1 @@
__version__ = "6.0.0b8" __version__ = "6.0.0b11"
+34 -36
View File
@@ -205,6 +205,7 @@ class ai:
- COMPLETE MISSIONS: Execute ALL steps of a mission before reporting back. - COMPLETE MISSIONS: Execute ALL steps of a mission before reporting back.
- DIAGRAM: Use ASCII art or Unicode box-drawing characters directly in your responses to visualize topologies or paths when helpful. - DIAGRAM: Use ASCII art or Unicode box-drawing characters directly in your responses to visualize topologies or paths when helpful.
- EVIDENCE: Include 'Key Snippets' from tool outputs. Be token-efficient. - EVIDENCE: Include 'Key Snippets' from tool outputs. Be token-efficient.
- LANGUAGE: You MUST respond in the same language used by the user in their question or instruction.
- NO WANDERING: Do not speculate. If stuck, report attempts. - NO WANDERING: Do not speculate. If stuck, report attempts.
- SAFETY: When you use 'run_commands' with configuration commands, the system automatically prompts the user for confirmation. Just execute - don't ask permission first. - SAFETY: When you use 'run_commands' with configuration commands, the system automatically prompts the user for confirmation. Just execute - don't ask permission first.
{architect_instructions} {architect_instructions}
@@ -222,6 +223,7 @@ class ai:
- ENGINEER CAPABILITIES: Your Engineer can: - ENGINEER CAPABILITIES: Your Engineer can:
* Filter nodes (list_nodes), Run CLI commands (run_commands), Get metadata (get_node_info). * Filter nodes (list_nodes), Run CLI commands (run_commands), Get metadata (get_node_info).
- ANALYSIS: Review technical findings to identify patterns or design failures. - ANALYSIS: Review technical findings to identify patterns or design failures.
- LANGUAGE: You MUST respond in the same language used by the user in their question or instruction.
- MEMORY: Update long-term facts ONLY when the user explicitly requests it. - MEMORY: Update long-term facts ONLY when the user explicitly requests it.
CRITICAL - EFFICIENT DELEGATION: CRITICAL - EFFICIENT DELEGATION:
@@ -297,7 +299,6 @@ class ai:
- response: reconstructed ModelResponse (same as non-streaming) - response: reconstructed ModelResponse (same as non-streaming)
- streamed: True if text was rendered to console during streaming - streamed: True if text was rendered to console during streaming
""" """
from rich.live import Live
stream_resp = completion(model=model, messages=messages, tools=tools, api_key=api_key, stream=True, **kwargs) stream_resp = completion(model=model, messages=messages, tools=tools, api_key=api_key, stream=True, **kwargs)
@@ -305,7 +306,7 @@ class ai:
full_content = "" full_content = ""
is_streaming_text = False is_streaming_text = False
has_tool_calls = False has_tool_calls = False
live_display = None header_printed = False
# Determine styling based on current brain # Determine styling based on current brain
role_label = "Network Architect" if "architect" in label.lower() else "Network Engineer" role_label = "Network Architect" if "architect" in label.lower() else "Network Engineer"
@@ -334,7 +335,6 @@ class ai:
if not chunk_callback: if not chunk_callback:
if not is_streaming_text: if not is_streaming_text:
# Stop spinner definitively
if status: if status:
try: try:
status.stop() status.stop()
@@ -343,35 +343,28 @@ class ai:
# Create a stable, direct Console to bypass _ConsoleProxy recreation bugs # Create a stable, direct Console to bypass _ConsoleProxy recreation bugs
from rich.console import Console as RichConsole from rich.console import Console as RichConsole
from .printer import connpy_theme, get_original_stdout from rich.rule import Rule
from .printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
live_display = Live( stable_console.print(Rule(f"[bold {border}]{title}[/bold {border}]", style=border))
Panel(Markdown(full_content), title=title, border_style=border, expand=False), header_printed = True
console=stable_console, md_parser = IncrementalMarkdownParser(console=stable_console)
refresh_per_second=8,
transient=False
)
live_display.start()
is_streaming_text = True is_streaming_text = True
else:
live_display.update( md_parser.feed(delta.content)
Panel(Markdown(full_content), title=title, border_style=border, expand=False)
)
except Exception as e: except Exception as e:
if not chunks: if not chunks:
raise raise
finally: finally:
if live_display: if header_printed:
# Render final state with complete content
try: try:
live_display.update( md_parser.flush()
Panel(Markdown(full_content), title=title, border_style=border, expand=False) from rich.console import Console as RichConsole
) from rich.rule import Rule
except Exception: from .printer import connpy_theme, get_original_stdout
pass stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
try: stable_console.print(Rule(style=border))
live_display.stop()
except Exception: except Exception:
pass pass
@@ -1320,11 +1313,13 @@ class ai:
if persona == "architect": if persona == "architect":
system_prompt = f"""Role: NETWORK ARCHITECT. You act as a senior strategic advisor during a live SSH session. system_prompt = f"""Role: NETWORK ARCHITECT. You act as a senior strategic advisor during a live SSH session.
Rules: Rules:
1. Answer the user's question directly based on the Terminal Context. 1. MANDATORY: You MUST respond in the same language used by the user in their question.
2. Focus on the "why" and "how". Analyze topologies, design patterns, and validate configurations. 2. Answer the user's question directly and EXCLUSIVELY based on the Terminal Context.
3. Do NOT provide commands to execute unless specifically requested. Instead, explain the consequences and best practices. 3. NO HALLUCINATIONS. The Terminal Context is a live buffer. If it contains only a shell prompt (like 'iol#' or 'admin@vrouter>') and no command output, it means YOU DON'T HAVE DATA. In this case, YOU MUST NOT invent any information.
4. Keep your guide concise and authoritative. 4. Focus on the "why" and "how". Analyze topologies, design patterns, and validate configurations.
5. You MUST output your response in the following strict format: 5. Do NOT provide commands to execute unless specifically requested. Instead, explain the consequences and best practices.
6. Keep your guide concise and authoritative.
7. You MUST output your response in the following strict format:
<guide> <guide>
Your brief tactical guide in markdown. Your brief tactical guide in markdown.
</guide> </guide>
@@ -1333,7 +1328,7 @@ Your brief tactical guide in markdown.
<risk> <risk>
low low
</risk> </risk>
6. Risk level is usually "low" for read-only/no commands. 8. Risk level is usually "low" for read-only/no commands.
Terminal Context: Terminal Context:
{terminal_buffer} {terminal_buffer}
@@ -1343,11 +1338,13 @@ Node: {node_name}"""
else: else:
system_prompt = f"""Role: TERMINAL COPILOT. You assist a network engineer during a live SSH session. system_prompt = f"""Role: TERMINAL COPILOT. You assist a network engineer during a live SSH session.
Rules: Rules:
1. Answer the user's question directly based on the Terminal Context. 1. MANDATORY: You MUST respond in the same language used by the user in their question.
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. 2. EXTREMELY IMPORTANT: Answer EXCLUSIVELY based on the provided Terminal Context.
3. If the user wants to execute an action, provide the required CLI commands inside a <commands> block, one command per line. If no commands are needed, leave it empty or omit the block. 3. NO HALLUCINATIONS. The Terminal Context is a live buffer. If it contains only a shell prompt (like 'iol#' or 'admin@vrouter>') and no command output, it means YOU DON'T HAVE DATA. In this case, YOU MUST NOT invent any information. Instead, explicitly state that you don't see the data and offer the correct CLI commands to retrieve it.
4. ULTRA-CONCISE. Keep your guide to the point. 4. 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.
5. You MUST output your response in the following strict format: 5. If the user wants to execute an action, provide the required CLI commands inside a <commands> block, one command per line. If no commands are needed, leave it empty or omit the block.
6. ULTRA-CONCISE. Keep your guide to the point.
7. You MUST output your response in the following strict format:
<guide> <guide>
Your brief tactical guide in markdown. 3-4 sentences max. Your brief tactical guide in markdown. 3-4 sentences max.
</guide> </guide>
@@ -1358,7 +1355,7 @@ command 2
<risk> <risk>
low, high, or destructive low, high, or destructive
</risk> </risk>
6. Risk level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops. 8. Risk level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops.
Terminal Context: Terminal Context:
{terminal_buffer} {terminal_buffer}
@@ -1400,6 +1397,7 @@ Node: {node_name}"""
try: try:
while iteration < max_iterations: while iteration < max_iterations:
iteration += 1 iteration += 1
response = await acompletion( response = await acompletion(
model=current_model, model=current_model,
messages=messages, messages=messages,
+85 -26
View File
@@ -12,7 +12,6 @@ from textwrap import dedent
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.live import Live
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.formatted_text import HTML from prompt_toolkit.formatted_text import HTML
@@ -57,9 +56,10 @@ class CopilotInterface:
async def run_session(self, async def run_session(self,
raw_bytes: bytes, raw_bytes: bytes,
cmd_byte_positions: List[tuple],
node_info: dict, node_info: dict,
on_ai_call: Callable): on_ai_call: Callable,
cmd_byte_positions: List[tuple] = None,
blocks: List[tuple] = None):
""" """
Runs the interactive Copilot session. Runs the interactive Copilot session.
on_ai_call: async function(active_buffer, question) -> result_dict on_ai_call: async function(active_buffer, question) -> result_dict
@@ -69,9 +69,11 @@ class CopilotInterface:
try: try:
# Prepare UI state # Prepare UI state
buffer = log_cleaner(raw_bytes.decode(errors='replace')) buffer = log_cleaner(raw_bytes.decode(errors='replace'))
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info)
# Use pre-calculated blocks if provided (remote mode), otherwise calculate locally (local mode)
if blocks is None:
last_line = buffer.split('\n')[-1].strip() if buffer.strip() else "(prompt)" last_line = buffer.split('\n')[-1].strip() if buffer.strip() else "(prompt)"
blocks.append((len(raw_bytes), last_line[:80])) blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
state = { state = {
'context_cmd': 1, 'context_cmd': 1,
@@ -88,7 +90,7 @@ class CopilotInterface:
self.console.print("") # Salto de línea real self.console.print("") # Salto de línea real
self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan")) self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan"))
self.console.print(Panel( self.console.print(Panel(
"[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel.\n" "[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n"
"Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]", "Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]",
border_style="cyan" border_style="cyan"
)) ))
@@ -128,9 +130,8 @@ class CopilotInterface:
if state['context_mode'] == self.mode_lines: if state['context_mode'] == self.mode_lines:
return '\n'.join(buffer.split('\n')[-state['context_lines']:]) return '\n'.join(buffer.split('\n')[-state['context_lines']:])
idx = max(0, state['total_cmds'] - state['context_cmd']) idx = max(0, state['total_cmds'] - state['context_cmd'])
start, preview = blocks[idx] start, end, preview = blocks[idx]
if state['context_mode'] == self.mode_single and idx + 1 < state['total_cmds']: if state['context_mode'] == self.mode_single:
end = blocks[idx + 1][0]
active_raw = raw_bytes[start:end] active_raw = raw_bytes[start:end]
else: else:
active_raw = raw_bytes[start:] active_raw = raw_bytes[start:]
@@ -172,7 +173,39 @@ class CopilotInterface:
base_str = f'\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]' base_str = f'\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]'
else: else:
idx = max(0, state['total_cmds'] - state['context_cmd']) idx = max(0, state['total_cmds'] - state['context_cmd'])
desc = blocks[idx][1]
def clean_preview(text):
# Limpia saltos de línea y el prompt inicial (todo hasta #, > o $) para que quede solo el comando
original = text.strip().replace('\r', '').replace('\n', ' ')
cleaned = re.sub(r'^.*?[#>\$]\s*', '', original)
# Si limpiar el prompt nos deja con un string vacío (ej: era solo "iol#"), devolvemos el original
return cleaned if cleaned else original
if state['context_mode'] == self.mode_range:
range_blocks = blocks[idx:]
# Si hay más de un bloque, el último es siempre el prompt vacío/actual. Lo omitimos visualmente.
if len(range_blocks) > 1:
range_blocks = range_blocks[:-1]
# Limpiar y truncar comandos muy largos para que no rompan la UI
previews = []
for b in range_blocks:
p = clean_preview(b[2])
if p:
# Truncar comandos individuales largos
if len(p) > 25: p = p[:22] + "..."
previews.append(p)
if not previews:
desc = clean_preview(blocks[idx][2])
elif len(previews) <= 3:
desc = " + ".join(previews)
else:
desc = f"{previews[0]} + {previews[1]} + {previews[2]} ... (+{len(previews)-3})"
else:
# Modo SINGLE original
desc = clean_preview(blocks[idx][2])
base_str = f'\u25b6 {desc} [Tab: {m_label}]' base_str = f'\u25b6 {desc} [Tab: {m_label}]'
# Wrap base_str in a style to maintain consistency and avoid glitches # Wrap base_str in a style to maintain consistency and avoid glitches
@@ -299,39 +332,66 @@ class CopilotInterface:
# Use persona from overrides (one-shot) or from session state # Use persona from overrides (one-shot) or from session state
active_persona = merged_node_info.get('persona', self.session_state.get('persona', 'engineer')) active_persona = merged_node_info.get('persona', self.session_state.get('persona', 'engineer'))
persona_color = self._get_theme_color(active_persona, fallback="cyan") persona_color = self._get_theme_color(active_persona, fallback="cyan")
persona_title = "Network Architect" if active_persona == "architect" else "Network Engineer"
active_buffer = get_active_buffer() active_buffer = get_active_buffer()
live_text = "Thinking..." live_text = ""
panel = Panel(live_text, title=f"[bold {persona_color}]Copilot Guide[/bold {persona_color}]", border_style=persona_color) first_chunk = True
from rich.rule import Rule
from rich.status import Status
from connpy.printer import IncrementalMarkdownParser
md_parser = IncrementalMarkdownParser(console=self.console)
status_spinner = Status(
f"[bold {persona_color}]{persona_title}:[/bold {persona_color}] [dim]Thinking...[/dim]",
console=self.console,
spinner="dots"
)
status_spinner.start()
def on_chunk(text): def on_chunk(text):
nonlocal live_text nonlocal live_text, first_chunk
if live_text == "Thinking...": live_text = "" if first_chunk:
status_spinner.stop()
# Print header rule before first chunk arrives
self.console.print(Rule(
f"[bold {persona_color}]{persona_title}[/bold {persona_color}]",
style=persona_color
))
first_chunk = False
live_text += text live_text += text
md_parser.feed(text)
with Live(panel, console=self.console, refresh_per_second=10) as live:
def update_live(t):
live.update(Panel(Markdown(t), title=f"[bold {persona_color}]Copilot Guide[/bold {persona_color}]", border_style=persona_color))
wrapped_chunk = lambda t: (on_chunk(t), update_live(live_text))
# Check for interruption during AI call # Check for interruption during AI call
ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, wrapped_chunk, merged_node_info)) ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, on_chunk, merged_node_info))
try: try:
while not ai_task.done(): while not ai_task.done():
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
result = await ai_task result = await ai_task
except asyncio.CancelledError: except asyncio.CancelledError:
status_spinner.stop()
return "cancel", None, None return "cancel", None, None
# Ensure spinner is stopped if no chunks arrived
if first_chunk:
status_spinner.stop()
# Close the streamed output with a Rule
if not first_chunk:
md_parser.flush()
self.console.print(Rule(style=persona_color))
if not result or result.get("error"): if not result or result.get("error"):
if result and result.get("error"): self.console.print(f"[red]Error: {result['error']}[/red]") if first_chunk and result and result.get("error"):
self.console.print(f"[red]Error: {result['error']}[/red]")
return "cancel", None, None return "cancel", None, None
# 4. Handle result # If no chunks were streamed but we have a guide, print it as a panel
if live_text == "Thinking..." and result.get("guide"): if first_chunk and result and result.get("guide"):
self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]Copilot Guide[/bold {persona_color}]", border_style=persona_color)) self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color))
commands = result.get("commands", []) commands = result.get("commands", [])
if not commands: if not commands:
@@ -434,5 +494,4 @@ class CopilotInterface:
finally: finally:
state['cancelled'] = True state['cancelled'] = True
self.console.print("[dim]Returning to session...[/dim]")
+48 -21
View File
@@ -211,6 +211,7 @@ class node:
self.output = "" self.output = ""
self.status = 1 self.status = 1
self.result = {} self.result = {}
self.cmd_byte_positions = [(0, None)]
@MethodHook @MethodHook
def _passtx(self, passwords, *, keyfile=None): def _passtx(self, passwords, *, keyfile=None):
@@ -385,9 +386,9 @@ class node:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
child_reader_queue = asyncio.Queue() child_reader_queue = asyncio.Queue()
# Track command byte positions for copilot context navigation # Reset and track command byte positions for copilot context navigation
# Each entry is (byte_position, command_text_or_None) # Each entry is (byte_position, command_text_or_None)
cmd_byte_positions = [(0, None)] self.cmd_byte_positions = [(self.mylog.tell() if hasattr(self, 'mylog') else 0, None)]
def _child_read_ready(): def _child_read_ready():
try: try:
@@ -428,7 +429,7 @@ class node:
node_info["prompt"] = to_str(self.tags.get("prompt", r'>$|#$|\$$|>.$|#.$|\$.$')) node_info["prompt"] = to_str(self.tags.get("prompt", r'>$|#$|\$$|>.$|#.$|\$.$'))
# Invoke copilot (async callback handles UI) # Invoke copilot (async callback handles UI)
await copilot_handler(self.mylog.getvalue(), node_info, local_stream, child_fd, cmd_byte_positions) await copilot_handler(self.mylog.getvalue(), node_info, local_stream, child_fd, self.cmd_byte_positions)
continue continue
# Remove any stray \x00 bytes and forward normally # Remove any stray \x00 bytes and forward normally
@@ -436,10 +437,9 @@ class node:
if clean_data: if clean_data:
# Track command boundaries when user hits Enter # Track command boundaries when user hits Enter
if hasattr(self, 'mylog') and (b'\r' in clean_data or b'\n' in clean_data): if hasattr(self, 'mylog') and (b'\r' in clean_data or b'\n' in clean_data):
cmd_byte_positions.append((self.mylog.tell(), None)) self.cmd_byte_positions.append((self.mylog.tell(), None))
try: try: os.write(child_fd, clean_data)
os.write(child_fd, clean_data)
except OSError: except OSError:
break break
self.lastinput = time() self.lastinput = time()
@@ -561,6 +561,45 @@ class node:
finally: finally:
local_stream.teardown() local_stream.teardown()
@MethodHook
async def inject_commands(self, commands, child_fd, on_inject=None):
"""
Inject a list of commands into the node's PTY.
Handles screen_length_command, history tracking and delays.
"""
if not commands:
return
# 0. Clear line
os.write(child_fd, b'\x15')
await asyncio.sleep(0.1)
# 1. Prepare list (prepend screen_length if exists)
slc = self.tags.get("screen_length_command") if hasattr(self, 'tags') and isinstance(self.tags, dict) else None
to_send = list(commands)
if slc and slc not in to_send: # avoid duplicates if already there
to_send.insert(0, slc)
# 2. Inject one by one
for cmd in to_send:
# Register in node's official history (SKIP if it's the administrative screen length command)
if cmd != slc and hasattr(self, 'cmd_byte_positions') and self.cmd_byte_positions is not None:
log_pos = self.mylog.tell() if hasattr(self, 'mylog') else 0
self.cmd_byte_positions.append((log_pos, cmd))
# Write physically to PTY
os.write(child_fd, (cmd + "\n").encode())
# Notify (e.g., for gRPC or logs) - SKIP for administrative SLC
if on_inject and cmd != slc:
if asyncio.iscoroutinefunction(on_inject):
await on_inject(cmd)
else:
on_inject(cmd)
# Delay to avoid overwhelming the router
await asyncio.sleep(0.8)
@MethodHook @MethodHook
def interact(self, debug=False, logger=None): def interact(self, debug=False, logger=None):
@@ -642,7 +681,7 @@ class node:
while True: while True:
action, commands, custom_cmd = await interface.run_session( action, commands, custom_cmd = await interface.run_session(
raw_bytes=raw_bytes, raw_bytes=raw_bytes,
cmd_byte_positions=cmd_byte_positions, cmd_byte_positions=self.cmd_byte_positions,
node_info=node_info, node_info=node_info,
on_ai_call=on_ai_call on_ai_call=on_ai_call
) )
@@ -650,6 +689,7 @@ class node:
continue continue
break break
finally: finally:
print("\033[2m Returning to session...\033[0m", flush=True)
# Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet # Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet
if hasattr(stream, 'start_reading'): if hasattr(stream, 'start_reading'):
stream.start_reading() stream.start_reading()
@@ -658,20 +698,7 @@ class node:
if action in ("send_all", "custom"): if action in ("send_all", "custom"):
cmds_to_send = commands if action == "send_all" else custom_cmd cmds_to_send = commands if action == "send_all" else custom_cmd
await self.inject_commands(cmds_to_send, child_fd)
if cmds_to_send:
os.write(child_fd, b'\x15') # Ctrl+U
await asyncio.sleep(0.1)
# Prepend screen length command to avoid pagination
if "screen_length_command" in self.tags:
cmds_to_send.insert(0, self.tags["screen_length_command"])
for cmd in cmds_to_send:
if cmd_byte_positions is not None:
cmd_byte_positions.append((self.mylog.tell(), cmd))
os.write(child_fd, (cmd + "\n").encode())
await asyncio.sleep(0.8)
else: else:
os.write(child_fd, b'\x15\r') os.write(child_fd, b'\x15\r')
except Exception as e: except Exception as e:
+51 -34
View File
@@ -207,15 +207,34 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
import json import json
import asyncio import asyncio
import os import os
from ..services.ai_service import AIService
service = AIService(self.service.config)
if node_info is None: if node_info is None:
node_info = {} node_info = {}
# Calculate real command blocks from history using the central service
raw_bytes = n.mylog.getvalue() if hasattr(n, 'mylog') else buffer
if not isinstance(raw_bytes, bytes):
raw_bytes = str(raw_bytes).encode()
from connpy.utils import log_cleaner
last_line = log_cleaner(raw_bytes.decode(errors='replace')).split('\n')[-1].strip()
blocks = service.build_context_blocks(raw_bytes, n.cmd_byte_positions, node_info, last_line=last_line)
node_info["context_blocks"] = blocks
node_info_json = json.dumps(node_info) node_info_json = json.dumps(node_info)
# Convert buffer to string if it's bytes for the preview # Convert buffer to string if it's bytes for the preview
preview_str = buffer[-200:].decode(errors='replace') if isinstance(buffer, bytes) else str(buffer)[-200:] preview_str = buffer[-200:].decode(errors='replace') if isinstance(buffer, bytes) else str(buffer)[-200:]
# Generate a unique session ID for this copilot interaction to prevent race conditions
import uuid
copilot_session_id = str(uuid.uuid4())
node_info["session_id"] = copilot_session_id
node_info_json = json.dumps(node_info)
# 1. Send prompt to client # 1. Send prompt to client
response_queue.put(connpy_pb2.InteractResponse( response_queue.put(connpy_pb2.InteractResponse(
copilot_prompt=True, copilot_prompt=True,
@@ -224,6 +243,13 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
)) ))
while True: while True:
# 0. Drain the queue of any stale messages before starting a new interaction
while not remote_stream.copilot_queue.empty():
try:
remote_stream.copilot_queue.get_nowait()
except:
break
# 2. Await the question from client via the copilot_queue # 2. Await the question from client via the copilot_queue
import threading import threading
def preload_ai_deps(): def preload_ai_deps():
@@ -236,7 +262,16 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
try: try:
req_data = await asyncio.wait_for(remote_stream.copilot_queue.get(), timeout=120) req_data = await asyncio.wait_for(remote_stream.copilot_queue.get(), timeout=120)
if not req_data: return if not req_data: return
if "question" not in req_data or not req_data["question"] or req_data["question"] == "CANCEL" or req_data.get("action") == "cancel":
# Validate session ID if provided by client (skip validation if not provided for CLI compatibility)
req_session_id = req_data.get("session_id")
if req_session_id and req_session_id != copilot_session_id:
continue # Ignore stale request from a previous session
if "question" not in req_data or not req_data["question"] or req_data["question"] == "CANCEL" or req_data.get("action") in ("cancel", "web_cancel"):
if req_data.get("action") == "web_cancel":
os.write(child_fd, b'\x05')
else:
os.write(child_fd, b'\x15\r') os.write(child_fd, b'\x15\r')
return return
question = req_data["question"] question = req_data["question"]
@@ -264,9 +299,6 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
return return
# 3. Call AI Service with streaming # 3. Call AI Service with streaming
from ..services.ai_service import AIService
service = AIService(self.service.config)
def chunk_callback(chunk_text): def chunk_callback(chunk_text):
if chunk_text: if chunk_text:
response_queue.put(connpy_pb2.InteractResponse( response_queue.put(connpy_pb2.InteractResponse(
@@ -287,7 +319,10 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
if wait_action_task in done: if wait_action_task in done:
req_data = wait_action_task.result() req_data = wait_action_task.result()
ai_task.cancel() ai_task.cancel()
if req_data.get("action") == "cancel" or req_data.get("question") == "CANCEL": if req_data.get("action") in ("cancel", "web_cancel") or req_data.get("question") == "CANCEL":
if req_data.get("action") == "web_cancel":
os.write(child_fd, b'\x05')
else:
os.write(child_fd, b'\x15\r') os.write(child_fd, b'\x15\r')
return return
continue # Loop back instead of returning to keep session alive continue # Loop back instead of returning to keep session alive
@@ -312,45 +347,27 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
if action == "continue": if action == "continue":
continue # Loop back for next question continue # Loop back for next question
if action == "cancel": if action in ("cancel", "web_cancel"):
if action == "web_cancel":
os.write(child_fd, b'\x05')
else:
os.write(child_fd, b'\x15\r') os.write(child_fd, b'\x15\r')
return return
except asyncio.TimeoutError: except asyncio.TimeoutError:
os.write(child_fd, b'\x15\r') os.write(child_fd, b'\x15\r')
return return
def on_inject(cmd):
response_queue.put(connpy_pb2.InteractResponse(copilot_injected_command=cmd))
if action == "send_all": if action == "send_all":
commands = result.get("commands", []) commands = result.get("commands", [])
os.write(child_fd, b'\x15') # Ctrl+U to clear line await n.inject_commands(commands, child_fd, on_inject=on_inject)
await asyncio.sleep(0.1)
# Prepend screen length command to avoid pagination
if "screen_length_command" in n.tags:
os.write(child_fd, (n.tags["screen_length_command"] + "\n").encode())
response_queue.put(connpy_pb2.InteractResponse(copilot_injected_command=n.tags["screen_length_command"]))
await asyncio.sleep(0.8)
for cmd in commands:
os.write(child_fd, (cmd + "\n").encode())
response_queue.put(connpy_pb2.InteractResponse(copilot_injected_command=cmd))
await asyncio.sleep(0.8)
return return
elif action.startswith("custom:"): elif action.startswith("custom:"):
custom_cmds = action[7:] custom_cmds_raw = action[7:]
os.write(child_fd, b'\x15') custom_cmds = [cmd.strip() for cmd in custom_cmds_raw.split('\n') if cmd.strip()]
await asyncio.sleep(0.1) await n.inject_commands(custom_cmds, child_fd, on_inject=on_inject)
# Prepend screen length command to avoid pagination
if "screen_length_command" in n.tags:
os.write(child_fd, (n.tags["screen_length_command"] + "\n").encode())
response_queue.put(connpy_pb2.InteractResponse(copilot_injected_command=n.tags["screen_length_command"]))
await asyncio.sleep(0.8)
for cmd in custom_cmds.split('\n'):
if cmd.strip():
os.write(child_fd, (cmd.strip() + "\n").encode())
response_queue.put(connpy_pb2.InteractResponse(copilot_injected_command=cmd.strip()))
await asyncio.sleep(0.8)
return return
else: else:
os.write(child_fd, b'\x15\r') os.write(child_fd, b'\x15\r')
+37 -62
View File
@@ -43,7 +43,7 @@ class NodeStub:
self.remote_host = remote_host self.remote_host = remote_host
self.config = config self.config = config
def _handle_remote_copilot(self, res, request_queue, response_queue, client_buffer_bytes, cmd_byte_positions, pause_generator, resume_generator, old_tty): def _handle_remote_copilot(self, res, request_queue, response_queue, client_buffer_bytes, pause_generator, resume_generator, old_tty):
import json, asyncio, termios, sys, tty, queue import json, asyncio, termios, sys, tty, queue
from ..core import copilot_terminal_mode from ..core import copilot_terminal_mode
from . import connpy_pb2 from . import connpy_pb2
@@ -51,6 +51,10 @@ class NodeStub:
pause_generator() pause_generator()
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty) termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
node_info = json.loads(res.copilot_node_info_json) if res.copilot_node_info_json else {}
blocks = node_info.get("context_blocks", [])
interface = CopilotInterface( interface = CopilotInterface(
self.config, self.config,
history=getattr(self, 'copilot_history', None), history=getattr(self, 'copilot_history', None),
@@ -59,8 +63,6 @@ class NodeStub:
self.copilot_history = interface.history self.copilot_history = interface.history
self.copilot_state = interface.session_state self.copilot_state = interface.session_state
node_info = json.loads(res.copilot_node_info_json) if res.copilot_node_info_json else {}
async def on_ai_call_remote(active_buffer, question, chunk_callback, merged_node_info): async def on_ai_call_remote(active_buffer, question, chunk_callback, merged_node_info):
# Send request to server # Send request to server
request_queue.put(connpy_pb2.InteractRequest( request_queue.put(connpy_pb2.InteractRequest(
@@ -85,9 +87,9 @@ class NodeStub:
while True: while True:
action, commands, custom_cmd = await interface.run_session( action, commands, custom_cmd = await interface.run_session(
raw_bytes=bytes(client_buffer_bytes), raw_bytes=bytes(client_buffer_bytes),
cmd_byte_positions=cmd_byte_positions,
node_info=node_info, node_info=node_info,
on_ai_call=on_ai_call_remote on_ai_call=on_ai_call_remote,
blocks=blocks
) )
if action == "continue": if action == "continue":
@@ -100,6 +102,7 @@ class NodeStub:
with copilot_terminal_mode(): with copilot_terminal_mode():
action, commands, custom_cmd = asyncio.run(run_remote_copilot()) action, commands, custom_cmd = asyncio.run(run_remote_copilot())
print("\033[2m Returning to session...\033[0m", flush=True)
# Prepare final action for server # Prepare final action for server
action_sent = "cancel" action_sent = "cancel"
if action == "send_all" and commands: if action == "send_all" and commands:
@@ -124,7 +127,6 @@ class NodeStub:
request_queue = queue.Queue() request_queue = queue.Queue()
client_buffer_bytes = bytearray() client_buffer_bytes = bytearray()
cmd_byte_positions = [(0, None)]
pause_stdin = [False] pause_stdin = [False]
wake_r, wake_w = os.pipe() wake_r, wake_w = os.pipe()
@@ -171,8 +173,6 @@ class NodeStub:
data = os.read(sys.stdin.fileno(), 1024) data = os.read(sys.stdin.fileno(), 1024)
if not data: if not data:
break break
if b'\r' in data or b'\n' in data:
cmd_byte_positions.append((len(client_buffer_bytes), None))
yield connpy_pb2.InteractRequest(stdin_data=data) yield connpy_pb2.InteractRequest(stdin_data=data)
except OSError: except OSError:
break break
@@ -246,14 +246,11 @@ class NodeStub:
if res.copilot_prompt: if res.copilot_prompt:
self._handle_remote_copilot( self._handle_remote_copilot(
res, request_queue, response_queue, res, request_queue, response_queue,
client_buffer_bytes, cmd_byte_positions, client_buffer_bytes,
pause_generator, resume_generator, old_tty pause_generator, resume_generator, old_tty
) )
continue continue
if res.copilot_injected_command:
cmd_byte_positions.append((len(client_buffer_bytes), res.copilot_injected_command))
if res.stdout_data: if res.stdout_data:
os.write(sys.stdout.fileno(), res.stdout_data) os.write(sys.stdout.fileno(), res.stdout_data)
client_buffer_bytes.extend(res.stdout_data) client_buffer_bytes.extend(res.stdout_data)
@@ -275,7 +272,6 @@ class NodeStub:
params_json = json.dumps(connection_params) params_json = json.dumps(connection_params)
request_queue = queue.Queue() request_queue = queue.Queue()
client_buffer_bytes = bytearray() client_buffer_bytes = bytearray()
cmd_byte_positions = [(0, None)]
pause_stdin = [False] pause_stdin = [False]
wake_r, wake_w = os.pipe() wake_r, wake_w = os.pipe()
@@ -323,8 +319,6 @@ class NodeStub:
data = os.read(sys.stdin.fileno(), 1024) data = os.read(sys.stdin.fileno(), 1024)
if not data: if not data:
break break
if b'\r' in data or b'\n' in data:
cmd_byte_positions.append((len(client_buffer_bytes), None))
yield connpy_pb2.InteractRequest(stdin_data=data) yield connpy_pb2.InteractRequest(stdin_data=data)
except OSError: except OSError:
break break
@@ -397,14 +391,11 @@ class NodeStub:
if res.copilot_prompt: if res.copilot_prompt:
self._handle_remote_copilot( self._handle_remote_copilot(
res, request_queue, response_queue, res, request_queue, response_queue,
client_buffer_bytes, cmd_byte_positions, client_buffer_bytes,
pause_generator, resume_generator, old_tty pause_generator, resume_generator, old_tty
) )
continue continue
if res.copilot_injected_command:
cmd_byte_positions.append((len(client_buffer_bytes), res.copilot_injected_command))
if res.stdout_data: if res.stdout_data:
os.write(sys.stdout.fileno(), res.stdout_data) os.write(sys.stdout.fileno(), res.stdout_data)
client_buffer_bytes.extend(res.stdout_data) client_buffer_bytes.extend(res.stdout_data)
@@ -736,7 +727,6 @@ class AIStub:
import queue import queue
from rich.prompt import Prompt from rich.prompt import Prompt
from rich.text import Text from rich.text import Text
from rich.live import Live
from rich.panel import Panel from rich.panel import Panel
from rich.markdown import Markdown from rich.markdown import Markdown
@@ -767,7 +757,7 @@ class AIStub:
responses = self.stub.ask(request_generator()) responses = self.stub.ask(request_generator())
full_content = "" full_content = ""
live_display = None header_printed = False
final_result = {"response": "", "chat_history": []} final_result = {"response": "", "chat_history": []}
# Background thread to pull responses from gRPC into a local queue # Background thread to pull responses from gRPC into a local queue
@@ -832,69 +822,53 @@ class AIStub:
if response.debug_message: if response.debug_message:
if debug: if debug:
if live_display:
try: live_display.stop()
except: pass
if status: if status:
try: status.stop() try: status.stop()
except: pass except: pass
printer.console.print(Text.from_ansi(response.debug_message)) printer.console.print(Text.from_ansi(response.debug_message))
if live_display: if status:
try: live_display.start()
except: pass
elif status:
try: status.start() try: status.start()
except: pass except: pass
continue continue
if response.important_message: if response.important_message:
if live_display:
try: live_display.stop()
except: pass
if status: if status:
try: status.stop() try: status.stop()
except: pass except: pass
printer.console.print(Text.from_ansi(response.important_message)) printer.console.print(Text.from_ansi(response.important_message))
if live_display: if status:
try: live_display.start()
except: pass
elif status:
try: status.start() try: status.start()
except: pass except: pass
continue continue
if not response.is_final: if not response.is_final:
if response.text_chunk: if response.text_chunk:
full_content += response.text_chunk if not header_printed:
if not live_display:
if status: if status:
try: status.stop() try: status.stop()
except: pass except: pass
from rich.console import Console as RichConsole from rich.console import Console as RichConsole
from ..printer import connpy_theme, get_original_stdout from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# We default to Engineer title during stream, final result will correct it if needed # Print header on first chunk
live_display = Live( stable_console.print(Rule("[bold engineer]Network Engineer[/bold engineer]", style="engineer"))
Panel(Markdown(full_content), title="[bold engineer]Network Engineer[/bold engineer]", border_style="engineer", expand=False), header_printed = True
console=stable_console,
refresh_per_second=8, # Initialize parser
transient=False md_parser = IncrementalMarkdownParser(console=stable_console)
)
live_display.start() full_content += response.text_chunk
else: md_parser.feed(response.text_chunk)
live_display.update(
Panel(Markdown(full_content), title="[bold engineer]Network Engineer[/bold engineer]", border_style="engineer", expand=False)
)
continue continue
if response.is_final: if response.is_final:
if live_display: if header_printed:
try: live_display.stop() from rich.rule import Rule
except: pass md_parser.flush()
# Final stop for status to ensure it disappears before the panel
if status: if status:
try: status.stop() try: status.stop()
except: pass except: pass
@@ -905,13 +879,14 @@ class AIStub:
role_label = "Network Architect" if responder == "architect" else "Network Engineer" role_label = "Network Architect" if responder == "architect" else "Network Engineer"
title = f"[bold {alias}]{role_label}[/bold {alias}]" title = f"[bold {alias}]{role_label}[/bold {alias}]"
content_to_print = full_content or final_result.get("response", "") if header_printed:
if content_to_print: from rich.console import Console as RichConsole
if live_display: from ..printer import connpy_theme, get_original_stdout
# Re-render the final frame with correct title/colors stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
live_display.update(Panel(Markdown(content_to_print), title=title, border_style=alias, expand=False)) stable_console.print(Rule(style=alias))
else: elif not full_content and final_result.get("response"):
printer.console.print(Panel(Markdown(content_to_print), title=title, border_style=alias, expand=False)) # If nothing streamed but we have response (e.g. error or direct guide)
printer.console.print(Panel(Markdown(final_result["response"]), title=title, border_style=alias, expand=False))
break break
except Exception as e: except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch # Check if it was a gRPC error that we should let handle_errors catch
+81
View File
@@ -15,7 +15,17 @@ class ThreadLocalStream:
def write(self, data): def write(self, data):
stream = self._get_stream() stream = self._get_stream()
if stream: if stream:
import time
retries = 0
while True:
try:
stream.write(data) stream.write(data)
break
except BlockingIOError:
if retries > 50:
raise
time.sleep(0.01)
retries += 1
def flush(self): def flush(self):
stream = self._get_stream() stream = self._get_stream()
@@ -496,3 +506,74 @@ class _ThemeProxy:
return getattr(local.theme, name) return getattr(local.theme, name)
connpy_theme = _ThemeProxy() connpy_theme = _ThemeProxy()
class BlockMarkdownRenderer:
"""
Block-buffered streaming markdown renderer.
Accumulates text until block boundaries are detected,
then renders complete blocks using Rich's Markdown.
"""
def __init__(self, console=None):
from rich.console import Console as RichConsole
from .printer import connpy_theme, get_original_stdout
self._console = console or RichConsole(
theme=connpy_theme, file=get_original_stdout()
)
self._line_buf = "" # chars waiting for \n
self._block_lines = [] # complete lines for current block
self._in_code_block = False
def feed(self, text):
self._line_buf += text
while '\n' in self._line_buf:
idx = self._line_buf.index('\n')
line = self._line_buf[:idx + 1]
self._line_buf = self._line_buf[idx + 1:]
self._process_line(line)
def flush(self):
if self._line_buf:
self._block_lines.append(self._line_buf)
self._line_buf = ""
self._flush_block()
def _process_line(self, line):
stripped = line.strip()
if stripped.startswith('```'):
if not self._in_code_block:
# Flush accumulated text before code block
self._flush_block()
self._in_code_block = True
self._block_lines.append(line)
else:
# Include closing fence and flush code block
self._block_lines.append(line)
self._in_code_block = False
self._flush_block()
return
if self._in_code_block:
self._block_lines.append(line)
return
# Blank line = paragraph break
if stripped == '':
self._block_lines.append(line)
self._flush_block()
return
self._block_lines.append(line)
def _flush_block(self):
if not self._block_lines:
return
block_text = ''.join(self._block_lines).strip()
self._block_lines = []
if not block_text:
return
from rich.markdown import Markdown
self._console.print(Markdown(block_text))
# Alias for backward compatibility
IncrementalMarkdownParser = BlockMarkdownRenderer
+38 -4
View File
@@ -6,10 +6,10 @@ from connpy.utils import log_cleaner
class AIService(BaseService): class AIService(BaseService):
"""Business logic for interacting with AI agents and LLM configurations.""" """Business logic for interacting with AI agents and LLM configurations."""
def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict) -> list: def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict, last_line: str = "") -> list:
"""Identifies command blocks in the terminal history.""" """Identifies command blocks in the terminal history."""
blocks = [] blocks = []
if not (cmd_byte_positions and len(cmd_byte_positions) >= 2 and raw_bytes): if not raw_bytes:
return blocks return blocks
default_prompt = r'>$|#$|\$$|>.$|#.$|\$.$' default_prompt = r'>$|#$|\$$|>.$|#.$|\$.$'
@@ -20,6 +20,8 @@ class AIService(BaseService):
except Exception: except Exception:
prompt_re = re.compile(re.sub(r'(?<!\\)\$', '', default_prompt)) prompt_re = re.compile(re.sub(r'(?<!\\)\$', '', default_prompt))
parsed_positions = []
if cmd_byte_positions and len(cmd_byte_positions) >= 1:
for i in range(1, len(cmd_byte_positions)): for i in range(1, len(cmd_byte_positions)):
pos, known_cmd = cmd_byte_positions[i] pos, known_cmd = cmd_byte_positions[i]
prev_pos = cmd_byte_positions[i-1][0] prev_pos = cmd_byte_positions[i-1][0]
@@ -30,7 +32,7 @@ class AIService(BaseService):
prev_lines = [l for l in prev_cleaned.split('\n') if l.strip()] prev_lines = [l for l in prev_cleaned.split('\n') if l.strip()]
prompt_text = prev_lines[-1].strip() if prev_lines else "" prompt_text = prev_lines[-1].strip() if prev_lines else ""
preview = f"{prompt_text}{known_cmd}" if prompt_text else known_cmd preview = f"{prompt_text}{known_cmd}" if prompt_text else known_cmd
blocks.append((pos, preview[:80])) parsed_positions.append({"pos": pos, "type": "VALID_CMD", "preview": preview[:80]})
else: else:
chunk = raw_bytes[prev_pos:pos] chunk = raw_bytes[prev_pos:pos]
cleaned = log_cleaner(chunk.decode(errors='replace')) cleaned = log_cleaner(chunk.decode(errors='replace'))
@@ -42,7 +44,39 @@ class AIService(BaseService):
if match: if match:
cmd_text = preview[match.end():].strip() cmd_text = preview[match.end():].strip()
if cmd_text: if cmd_text:
blocks.append((pos, preview[:80])) parsed_positions.append({"pos": pos, "type": "VALID_CMD", "preview": preview[:80]})
else:
parsed_positions.append({"pos": pos, "type": "EMPTY_PROMPT", "preview": ""})
else:
parsed_positions.append({"pos": pos, "type": "SCROLLING", "preview": ""})
else:
parsed_positions.append({"pos": pos, "type": "SCROLLING", "preview": ""})
last_newline = raw_bytes.rfind(b'\n')
current_prompt_pos = last_newline + 1 if last_newline != -1 else 0
current_end = len(raw_bytes)
for i, item in enumerate(parsed_positions):
if item["type"] == "VALID_CMD":
start_pos = item["pos"]
preview = item["preview"]
# Find the end position: next VALID_CMD or EMPTY_PROMPT
end_pos = current_prompt_pos
for j in range(i + 1, len(parsed_positions)):
next_item = parsed_positions[j]
if next_item["type"] in ("VALID_CMD", "EMPTY_PROMPT"):
end_pos = next_item["pos"]
break
blocks.append((start_pos, end_pos, preview))
# Always ensure there is a final block representing the current prompt
if not blocks:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else "CURRENT CONTEXT"))
elif blocks[-1][0] < current_prompt_pos:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else "CURRENT CONTEXT"))
return blocks return blocks
def process_copilot_input(self, input_text: str, session_state: dict) -> dict: def process_copilot_input(self, input_text: str, session_state: dict) -> dict:
+27 -7
View File
@@ -23,14 +23,34 @@ def log_cleaner(data: str) -> str:
elif token in ('\b', '\x7f'): elif token in ('\b', '\x7f'):
if cursor > 0: if cursor > 0:
cursor -= 1 cursor -= 1
elif token == '\x1B[D': # Left Arrow elif token.startswith('\x1B[') and len(token) >= 3:
if cursor > 0: # Parse CSI: \x1B[ <params> <final_char>
cursor -= 1 final = token[-1]
elif token == '\x1B[C': # Right Arrow param_str = token[2:-1]
if cursor < len(buffer): n = int(param_str) if param_str.isdigit() else 1
cursor += 1
elif token == '\x1B[K': # Clear to end of line if final == 'D': # CUB Cursor Back
cursor = max(0, cursor - n)
elif final == 'C': # CUF Cursor Forward
cursor = min(len(buffer), cursor + n)
elif final == 'K': # EL Erase in Line
if n == 0 or param_str == '': # Clear to end
buffer = buffer[:cursor] buffer = buffer[:cursor]
elif n == 1: # Clear to start
buffer[:cursor] = [' '] * cursor
elif n == 2: # Clear entire line
buffer = []
cursor = 0
elif final == 'G': # CHA Cursor Horizontal Absolute (1-indexed)
cursor = max(0, n - 1)
# Pad buffer if cursor is beyond current length
if cursor > len(buffer):
buffer.extend([' '] * (cursor - len(buffer)))
elif final == 'P': # DCH Delete Characters
del buffer[cursor:cursor + n]
elif final == '@': # ICH Insert Characters
buffer[cursor:cursor] = [' '] * n
# All other CSI sequences are silently discarded
elif token.startswith('\x1B'): elif token.startswith('\x1B'):
continue continue
elif len(token) == 1 and ord(token) < 32: elif len(token) == 1 and ord(token) < 32:
+177 -53
View File
@@ -90,9 +90,10 @@ el.replaceWith(d);
async def run_session(self, async def run_session(self,
raw_bytes: bytes, raw_bytes: bytes,
cmd_byte_positions: List[tuple],
node_info: dict, node_info: dict,
on_ai_call: Callable): on_ai_call: Callable,
cmd_byte_positions: List[tuple] = None,
blocks: List[tuple] = None):
&#34;&#34;&#34; &#34;&#34;&#34;
Runs the interactive Copilot session. Runs the interactive Copilot session.
on_ai_call: async function(active_buffer, question) -&gt; result_dict on_ai_call: async function(active_buffer, question) -&gt; result_dict
@@ -102,9 +103,11 @@ el.replaceWith(d);
try: try:
# Prepare UI state # Prepare UI state
buffer = log_cleaner(raw_bytes.decode(errors=&#39;replace&#39;)) buffer = log_cleaner(raw_bytes.decode(errors=&#39;replace&#39;))
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info)
# Use pre-calculated blocks if provided (remote mode), otherwise calculate locally (local mode)
if blocks is None:
last_line = buffer.split(&#39;\n&#39;)[-1].strip() if buffer.strip() else &#34;(prompt)&#34; last_line = buffer.split(&#39;\n&#39;)[-1].strip() if buffer.strip() else &#34;(prompt)&#34;
blocks.append((len(raw_bytes), last_line[:80])) blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
state = { state = {
&#39;context_cmd&#39;: 1, &#39;context_cmd&#39;: 1,
@@ -121,7 +124,7 @@ el.replaceWith(d);
self.console.print(&#34;&#34;) # Salto de línea real self.console.print(&#34;&#34;) # Salto de línea real
self.console.print(Rule(title=&#34;[bold cyan] AI TERMINAL COPILOT [/bold cyan]&#34;, style=&#34;cyan&#34;)) self.console.print(Rule(title=&#34;[bold cyan] AI TERMINAL COPILOT [/bold cyan]&#34;, style=&#34;cyan&#34;))
self.console.print(Panel( self.console.print(Panel(
&#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel.\n&#34; &#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n&#34;
&#34;Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]&#34;, &#34;Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]&#34;,
border_style=&#34;cyan&#34; border_style=&#34;cyan&#34;
)) ))
@@ -161,9 +164,8 @@ el.replaceWith(d);
if state[&#39;context_mode&#39;] == self.mode_lines: if state[&#39;context_mode&#39;] == self.mode_lines:
return &#39;\n&#39;.join(buffer.split(&#39;\n&#39;)[-state[&#39;context_lines&#39;]:]) return &#39;\n&#39;.join(buffer.split(&#39;\n&#39;)[-state[&#39;context_lines&#39;]:])
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;]) idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
start, preview = blocks[idx] start, end, preview = blocks[idx]
if state[&#39;context_mode&#39;] == self.mode_single and idx + 1 &lt; state[&#39;total_cmds&#39;]: if state[&#39;context_mode&#39;] == self.mode_single:
end = blocks[idx + 1][0]
active_raw = raw_bytes[start:end] active_raw = raw_bytes[start:end]
else: else:
active_raw = raw_bytes[start:] active_raw = raw_bytes[start:]
@@ -205,7 +207,40 @@ el.replaceWith(d);
base_str = f&#39;\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]&#39; base_str = f&#39;\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]&#39;
else: else:
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;]) idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
desc = blocks[idx][1] import re
def clean_preview(text):
# Limpia saltos de línea y el prompt inicial (todo hasta #, &gt; o $) para que quede solo el comando
original = text.strip().replace(&#39;\r&#39;, &#39;&#39;).replace(&#39;\n&#39;, &#39; &#39;)
cleaned = re.sub(r&#39;^.*?[#&gt;\$]\s*&#39;, &#39;&#39;, original)
# Si limpiar el prompt nos deja con un string vacío (ej: era solo &#34;iol#&#34;), devolvemos el original
return cleaned if cleaned else original
if state[&#39;context_mode&#39;] == self.mode_range:
range_blocks = blocks[idx:]
# Si hay más de un bloque, el último es siempre el prompt vacío/actual. Lo omitimos visualmente.
if len(range_blocks) &gt; 1:
range_blocks = range_blocks[:-1]
# Limpiar y truncar comandos muy largos para que no rompan la UI
previews = []
for b in range_blocks:
p = clean_preview(b[2])
if p:
# Truncar comandos individuales largos
if len(p) &gt; 25: p = p[:22] + &#34;...&#34;
previews.append(p)
if not previews:
desc = clean_preview(blocks[idx][2])
elif len(previews) &lt;= 3:
desc = &#34; + &#34;.join(previews)
else:
desc = f&#34;{previews[0]} + {previews[1]} + {previews[2]} ... (+{len(previews)-3})&#34;
else:
# Modo SINGLE original
desc = clean_preview(blocks[idx][2])
base_str = f&#39;\u25b6 {desc} [Tab: {m_label}]&#39; base_str = f&#39;\u25b6 {desc} [Tab: {m_label}]&#39;
# Wrap base_str in a style to maintain consistency and avoid glitches # Wrap base_str in a style to maintain consistency and avoid glitches
@@ -332,39 +367,67 @@ el.replaceWith(d);
# Use persona from overrides (one-shot) or from session state # Use persona from overrides (one-shot) or from session state
active_persona = merged_node_info.get(&#39;persona&#39;, self.session_state.get(&#39;persona&#39;, &#39;engineer&#39;)) active_persona = merged_node_info.get(&#39;persona&#39;, self.session_state.get(&#39;persona&#39;, &#39;engineer&#39;))
persona_color = self._get_theme_color(active_persona, fallback=&#34;cyan&#34;) persona_color = self._get_theme_color(active_persona, fallback=&#34;cyan&#34;)
persona_title = &#34;Network Architect&#34; if active_persona == &#34;architect&#34; else &#34;Network Engineer&#34;
active_buffer = get_active_buffer() active_buffer = get_active_buffer()
live_text = &#34;Thinking...&#34; live_text = &#34;&#34;
panel = Panel(live_text, title=f&#34;[bold {persona_color}]Copilot Guide[/bold {persona_color}]&#34;, border_style=persona_color) first_chunk = True
import sys
from rich.rule import Rule
from rich.status import Status
from connpy.printer import IncrementalMarkdownParser
md_parser = IncrementalMarkdownParser(console=self.console)
status_spinner = Status(
f&#34;[bold {persona_color}]{persona_title}:[/bold {persona_color}] [dim]Thinking...[/dim]&#34;,
console=self.console,
spinner=&#34;dots&#34;
)
status_spinner.start()
def on_chunk(text): def on_chunk(text):
nonlocal live_text nonlocal live_text, first_chunk
if live_text == &#34;Thinking...&#34;: live_text = &#34;&#34; if first_chunk:
status_spinner.stop()
# Print header rule before first chunk arrives
self.console.print(Rule(
f&#34;[bold {persona_color}]{persona_title}[/bold {persona_color}]&#34;,
style=persona_color
))
first_chunk = False
live_text += text live_text += text
md_parser.feed(text)
with Live(panel, console=self.console, refresh_per_second=10) as live:
def update_live(t):
live.update(Panel(Markdown(t), title=f&#34;[bold {persona_color}]Copilot Guide[/bold {persona_color}]&#34;, border_style=persona_color))
wrapped_chunk = lambda t: (on_chunk(t), update_live(live_text))
# Check for interruption during AI call # Check for interruption during AI call
ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, wrapped_chunk, merged_node_info)) ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, on_chunk, merged_node_info))
try: try:
while not ai_task.done(): while not ai_task.done():
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
result = await ai_task result = await ai_task
except asyncio.CancelledError: except asyncio.CancelledError:
status_spinner.stop()
return &#34;cancel&#34;, None, None return &#34;cancel&#34;, None, None
# Ensure spinner is stopped if no chunks arrived
if first_chunk:
status_spinner.stop()
# Close the streamed output with a Rule
if not first_chunk:
md_parser.flush()
self.console.print(Rule(style=persona_color))
if not result or result.get(&#34;error&#34;): if not result or result.get(&#34;error&#34;):
if result and result.get(&#34;error&#34;): self.console.print(f&#34;[red]Error: {result[&#39;error&#39;]}[/red]&#34;) if first_chunk and result and result.get(&#34;error&#34;):
self.console.print(f&#34;[red]Error: {result[&#39;error&#39;]}[/red]&#34;)
return &#34;cancel&#34;, None, None return &#34;cancel&#34;, None, None
# 4. Handle result # If no chunks were streamed but we have a guide, print it as a panel
if live_text == &#34;Thinking...&#34; and result.get(&#34;guide&#34;): if first_chunk and result and result.get(&#34;guide&#34;):
self.console.print(Panel(Markdown(result[&#34;guide&#34;]), title=f&#34;[bold {persona_color}]Copilot Guide[/bold {persona_color}]&#34;, border_style=persona_color)) self.console.print(Panel(Markdown(result[&#34;guide&#34;]), title=f&#34;[bold {persona_color}]{persona_title}[/bold {persona_color}]&#34;, border_style=persona_color))
commands = result.get(&#34;commands&#34;, []) commands = result.get(&#34;commands&#34;, [])
if not commands: if not commands:
@@ -466,14 +529,13 @@ el.replaceWith(d);
return &#34;cancel&#34;, None, None return &#34;cancel&#34;, None, None
finally: finally:
state[&#39;cancelled&#39;] = True state[&#39;cancelled&#39;] = True</code></pre>
self.console.print(&#34;[dim]Returning to session...[/dim]&#34;)</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
<h3>Methods</h3> <h3>Methods</h3>
<dl> <dl>
<dt id="connpy.cli.terminal_ui.CopilotInterface.run_session"><code class="name flex"> <dt id="connpy.cli.terminal_ui.CopilotInterface.run_session"><code class="name flex">
<span>async def <span class="ident">run_session</span></span>(<span>self,<br>raw_bytes: bytes,<br>cmd_byte_positions: List[tuple],<br>node_info: dict,<br>on_ai_call: Callable)</span> <span>async def <span class="ident">run_session</span></span>(<span>self,<br>raw_bytes: bytes,<br>node_info: dict,<br>on_ai_call: Callable,<br>cmd_byte_positions: List[tuple] = None,<br>blocks: List[tuple] = None)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
@@ -482,9 +544,10 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">async def run_session(self, <pre><code class="python">async def run_session(self,
raw_bytes: bytes, raw_bytes: bytes,
cmd_byte_positions: List[tuple],
node_info: dict, node_info: dict,
on_ai_call: Callable): on_ai_call: Callable,
cmd_byte_positions: List[tuple] = None,
blocks: List[tuple] = None):
&#34;&#34;&#34; &#34;&#34;&#34;
Runs the interactive Copilot session. Runs the interactive Copilot session.
on_ai_call: async function(active_buffer, question) -&gt; result_dict on_ai_call: async function(active_buffer, question) -&gt; result_dict
@@ -494,9 +557,11 @@ el.replaceWith(d);
try: try:
# Prepare UI state # Prepare UI state
buffer = log_cleaner(raw_bytes.decode(errors=&#39;replace&#39;)) buffer = log_cleaner(raw_bytes.decode(errors=&#39;replace&#39;))
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info)
# Use pre-calculated blocks if provided (remote mode), otherwise calculate locally (local mode)
if blocks is None:
last_line = buffer.split(&#39;\n&#39;)[-1].strip() if buffer.strip() else &#34;(prompt)&#34; last_line = buffer.split(&#39;\n&#39;)[-1].strip() if buffer.strip() else &#34;(prompt)&#34;
blocks.append((len(raw_bytes), last_line[:80])) blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
state = { state = {
&#39;context_cmd&#39;: 1, &#39;context_cmd&#39;: 1,
@@ -513,7 +578,7 @@ el.replaceWith(d);
self.console.print(&#34;&#34;) # Salto de línea real self.console.print(&#34;&#34;) # Salto de línea real
self.console.print(Rule(title=&#34;[bold cyan] AI TERMINAL COPILOT [/bold cyan]&#34;, style=&#34;cyan&#34;)) self.console.print(Rule(title=&#34;[bold cyan] AI TERMINAL COPILOT [/bold cyan]&#34;, style=&#34;cyan&#34;))
self.console.print(Panel( self.console.print(Panel(
&#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel.\n&#34; &#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n&#34;
&#34;Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]&#34;, &#34;Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]&#34;,
border_style=&#34;cyan&#34; border_style=&#34;cyan&#34;
)) ))
@@ -553,9 +618,8 @@ el.replaceWith(d);
if state[&#39;context_mode&#39;] == self.mode_lines: if state[&#39;context_mode&#39;] == self.mode_lines:
return &#39;\n&#39;.join(buffer.split(&#39;\n&#39;)[-state[&#39;context_lines&#39;]:]) return &#39;\n&#39;.join(buffer.split(&#39;\n&#39;)[-state[&#39;context_lines&#39;]:])
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;]) idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
start, preview = blocks[idx] start, end, preview = blocks[idx]
if state[&#39;context_mode&#39;] == self.mode_single and idx + 1 &lt; state[&#39;total_cmds&#39;]: if state[&#39;context_mode&#39;] == self.mode_single:
end = blocks[idx + 1][0]
active_raw = raw_bytes[start:end] active_raw = raw_bytes[start:end]
else: else:
active_raw = raw_bytes[start:] active_raw = raw_bytes[start:]
@@ -597,7 +661,40 @@ el.replaceWith(d);
base_str = f&#39;\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]&#39; base_str = f&#39;\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]&#39;
else: else:
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;]) idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
desc = blocks[idx][1] import re
def clean_preview(text):
# Limpia saltos de línea y el prompt inicial (todo hasta #, &gt; o $) para que quede solo el comando
original = text.strip().replace(&#39;\r&#39;, &#39;&#39;).replace(&#39;\n&#39;, &#39; &#39;)
cleaned = re.sub(r&#39;^.*?[#&gt;\$]\s*&#39;, &#39;&#39;, original)
# Si limpiar el prompt nos deja con un string vacío (ej: era solo &#34;iol#&#34;), devolvemos el original
return cleaned if cleaned else original
if state[&#39;context_mode&#39;] == self.mode_range:
range_blocks = blocks[idx:]
# Si hay más de un bloque, el último es siempre el prompt vacío/actual. Lo omitimos visualmente.
if len(range_blocks) &gt; 1:
range_blocks = range_blocks[:-1]
# Limpiar y truncar comandos muy largos para que no rompan la UI
previews = []
for b in range_blocks:
p = clean_preview(b[2])
if p:
# Truncar comandos individuales largos
if len(p) &gt; 25: p = p[:22] + &#34;...&#34;
previews.append(p)
if not previews:
desc = clean_preview(blocks[idx][2])
elif len(previews) &lt;= 3:
desc = &#34; + &#34;.join(previews)
else:
desc = f&#34;{previews[0]} + {previews[1]} + {previews[2]} ... (+{len(previews)-3})&#34;
else:
# Modo SINGLE original
desc = clean_preview(blocks[idx][2])
base_str = f&#39;\u25b6 {desc} [Tab: {m_label}]&#39; base_str = f&#39;\u25b6 {desc} [Tab: {m_label}]&#39;
# Wrap base_str in a style to maintain consistency and avoid glitches # Wrap base_str in a style to maintain consistency and avoid glitches
@@ -724,39 +821,67 @@ el.replaceWith(d);
# Use persona from overrides (one-shot) or from session state # Use persona from overrides (one-shot) or from session state
active_persona = merged_node_info.get(&#39;persona&#39;, self.session_state.get(&#39;persona&#39;, &#39;engineer&#39;)) active_persona = merged_node_info.get(&#39;persona&#39;, self.session_state.get(&#39;persona&#39;, &#39;engineer&#39;))
persona_color = self._get_theme_color(active_persona, fallback=&#34;cyan&#34;) persona_color = self._get_theme_color(active_persona, fallback=&#34;cyan&#34;)
persona_title = &#34;Network Architect&#34; if active_persona == &#34;architect&#34; else &#34;Network Engineer&#34;
active_buffer = get_active_buffer() active_buffer = get_active_buffer()
live_text = &#34;Thinking...&#34; live_text = &#34;&#34;
panel = Panel(live_text, title=f&#34;[bold {persona_color}]Copilot Guide[/bold {persona_color}]&#34;, border_style=persona_color) first_chunk = True
import sys
from rich.rule import Rule
from rich.status import Status
from connpy.printer import IncrementalMarkdownParser
md_parser = IncrementalMarkdownParser(console=self.console)
status_spinner = Status(
f&#34;[bold {persona_color}]{persona_title}:[/bold {persona_color}] [dim]Thinking...[/dim]&#34;,
console=self.console,
spinner=&#34;dots&#34;
)
status_spinner.start()
def on_chunk(text): def on_chunk(text):
nonlocal live_text nonlocal live_text, first_chunk
if live_text == &#34;Thinking...&#34;: live_text = &#34;&#34; if first_chunk:
status_spinner.stop()
# Print header rule before first chunk arrives
self.console.print(Rule(
f&#34;[bold {persona_color}]{persona_title}[/bold {persona_color}]&#34;,
style=persona_color
))
first_chunk = False
live_text += text live_text += text
md_parser.feed(text)
with Live(panel, console=self.console, refresh_per_second=10) as live:
def update_live(t):
live.update(Panel(Markdown(t), title=f&#34;[bold {persona_color}]Copilot Guide[/bold {persona_color}]&#34;, border_style=persona_color))
wrapped_chunk = lambda t: (on_chunk(t), update_live(live_text))
# Check for interruption during AI call # Check for interruption during AI call
ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, wrapped_chunk, merged_node_info)) ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, on_chunk, merged_node_info))
try: try:
while not ai_task.done(): while not ai_task.done():
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
result = await ai_task result = await ai_task
except asyncio.CancelledError: except asyncio.CancelledError:
status_spinner.stop()
return &#34;cancel&#34;, None, None return &#34;cancel&#34;, None, None
# Ensure spinner is stopped if no chunks arrived
if first_chunk:
status_spinner.stop()
# Close the streamed output with a Rule
if not first_chunk:
md_parser.flush()
self.console.print(Rule(style=persona_color))
if not result or result.get(&#34;error&#34;): if not result or result.get(&#34;error&#34;):
if result and result.get(&#34;error&#34;): self.console.print(f&#34;[red]Error: {result[&#39;error&#39;]}[/red]&#34;) if first_chunk and result and result.get(&#34;error&#34;):
self.console.print(f&#34;[red]Error: {result[&#39;error&#39;]}[/red]&#34;)
return &#34;cancel&#34;, None, None return &#34;cancel&#34;, None, None
# 4. Handle result # If no chunks were streamed but we have a guide, print it as a panel
if live_text == &#34;Thinking...&#34; and result.get(&#34;guide&#34;): if first_chunk and result and result.get(&#34;guide&#34;):
self.console.print(Panel(Markdown(result[&#34;guide&#34;]), title=f&#34;[bold {persona_color}]Copilot Guide[/bold {persona_color}]&#34;, border_style=persona_color)) self.console.print(Panel(Markdown(result[&#34;guide&#34;]), title=f&#34;[bold {persona_color}]{persona_title}[/bold {persona_color}]&#34;, border_style=persona_color))
commands = result.get(&#34;commands&#34;, []) commands = result.get(&#34;commands&#34;, [])
if not commands: if not commands:
@@ -858,8 +983,7 @@ el.replaceWith(d);
return &#34;cancel&#34;, None, None return &#34;cancel&#34;, None, None
finally: finally:
state[&#39;cancelled&#39;] = True state[&#39;cancelled&#39;] = True</code></pre>
self.console.print(&#34;[dim]Returning to session...[/dim]&#34;)</code></pre>
</details> </details>
<div class="desc"><p>Runs the interactive Copilot session. <div class="desc"><p>Runs the interactive Copilot session.
on_ai_call: async function(active_buffer, question) -&gt; result_dict</p></div> on_ai_call: async function(active_buffer, question) -&gt; result_dict</p></div>
+51 -34
View File
@@ -807,15 +807,34 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
import json import json
import asyncio import asyncio
import os import os
from ..services.ai_service import AIService
service = AIService(self.service.config)
if node_info is None: if node_info is None:
node_info = {} node_info = {}
# Calculate real command blocks from history using the central service
raw_bytes = n.mylog.getvalue() if hasattr(n, &#39;mylog&#39;) else buffer
if not isinstance(raw_bytes, bytes):
raw_bytes = str(raw_bytes).encode()
from connpy.utils import log_cleaner
last_line = log_cleaner(raw_bytes.decode(errors=&#39;replace&#39;)).split(&#39;\n&#39;)[-1].strip()
blocks = service.build_context_blocks(raw_bytes, n.cmd_byte_positions, node_info, last_line=last_line)
node_info[&#34;context_blocks&#34;] = blocks
node_info_json = json.dumps(node_info) node_info_json = json.dumps(node_info)
# Convert buffer to string if it&#39;s bytes for the preview # Convert buffer to string if it&#39;s bytes for the preview
preview_str = buffer[-200:].decode(errors=&#39;replace&#39;) if isinstance(buffer, bytes) else str(buffer)[-200:] preview_str = buffer[-200:].decode(errors=&#39;replace&#39;) if isinstance(buffer, bytes) else str(buffer)[-200:]
# Generate a unique session ID for this copilot interaction to prevent race conditions
import uuid
copilot_session_id = str(uuid.uuid4())
node_info[&#34;session_id&#34;] = copilot_session_id
node_info_json = json.dumps(node_info)
# 1. Send prompt to client # 1. Send prompt to client
response_queue.put(connpy_pb2.InteractResponse( response_queue.put(connpy_pb2.InteractResponse(
copilot_prompt=True, copilot_prompt=True,
@@ -824,6 +843,13 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
)) ))
while True: while True:
# 0. Drain the queue of any stale messages before starting a new interaction
while not remote_stream.copilot_queue.empty():
try:
remote_stream.copilot_queue.get_nowait()
except:
break
# 2. Await the question from client via the copilot_queue # 2. Await the question from client via the copilot_queue
import threading import threading
def preload_ai_deps(): def preload_ai_deps():
@@ -836,7 +862,16 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
try: try:
req_data = await asyncio.wait_for(remote_stream.copilot_queue.get(), timeout=120) req_data = await asyncio.wait_for(remote_stream.copilot_queue.get(), timeout=120)
if not req_data: return if not req_data: return
if &#34;question&#34; not in req_data or not req_data[&#34;question&#34;] or req_data[&#34;question&#34;] == &#34;CANCEL&#34; or req_data.get(&#34;action&#34;) == &#34;cancel&#34;:
# Validate session ID if provided by client (skip validation if not provided for CLI compatibility)
req_session_id = req_data.get(&#34;session_id&#34;)
if req_session_id and req_session_id != copilot_session_id:
continue # Ignore stale request from a previous session
if &#34;question&#34; not in req_data or not req_data[&#34;question&#34;] or req_data[&#34;question&#34;] == &#34;CANCEL&#34; or req_data.get(&#34;action&#34;) in (&#34;cancel&#34;, &#34;web_cancel&#34;):
if req_data.get(&#34;action&#34;) == &#34;web_cancel&#34;:
os.write(child_fd, b&#39;\x05&#39;)
else:
os.write(child_fd, b&#39;\x15\r&#39;) os.write(child_fd, b&#39;\x15\r&#39;)
return return
question = req_data[&#34;question&#34;] question = req_data[&#34;question&#34;]
@@ -864,9 +899,6 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
return return
# 3. Call AI Service with streaming # 3. Call AI Service with streaming
from ..services.ai_service import AIService
service = AIService(self.service.config)
def chunk_callback(chunk_text): def chunk_callback(chunk_text):
if chunk_text: if chunk_text:
response_queue.put(connpy_pb2.InteractResponse( response_queue.put(connpy_pb2.InteractResponse(
@@ -887,7 +919,10 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
if wait_action_task in done: if wait_action_task in done:
req_data = wait_action_task.result() req_data = wait_action_task.result()
ai_task.cancel() ai_task.cancel()
if req_data.get(&#34;action&#34;) == &#34;cancel&#34; or req_data.get(&#34;question&#34;) == &#34;CANCEL&#34;: if req_data.get(&#34;action&#34;) in (&#34;cancel&#34;, &#34;web_cancel&#34;) or req_data.get(&#34;question&#34;) == &#34;CANCEL&#34;:
if req_data.get(&#34;action&#34;) == &#34;web_cancel&#34;:
os.write(child_fd, b&#39;\x05&#39;)
else:
os.write(child_fd, b&#39;\x15\r&#39;) os.write(child_fd, b&#39;\x15\r&#39;)
return return
continue # Loop back instead of returning to keep session alive continue # Loop back instead of returning to keep session alive
@@ -912,45 +947,27 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
if action == &#34;continue&#34;: if action == &#34;continue&#34;:
continue # Loop back for next question continue # Loop back for next question
if action == &#34;cancel&#34;: if action in (&#34;cancel&#34;, &#34;web_cancel&#34;):
if action == &#34;web_cancel&#34;:
os.write(child_fd, b&#39;\x05&#39;)
else:
os.write(child_fd, b&#39;\x15\r&#39;) os.write(child_fd, b&#39;\x15\r&#39;)
return return
except asyncio.TimeoutError: except asyncio.TimeoutError:
os.write(child_fd, b&#39;\x15\r&#39;) os.write(child_fd, b&#39;\x15\r&#39;)
return return
def on_inject(cmd):
response_queue.put(connpy_pb2.InteractResponse(copilot_injected_command=cmd))
if action == &#34;send_all&#34;: if action == &#34;send_all&#34;:
commands = result.get(&#34;commands&#34;, []) commands = result.get(&#34;commands&#34;, [])
os.write(child_fd, b&#39;\x15&#39;) # Ctrl+U to clear line await n.inject_commands(commands, child_fd, on_inject=on_inject)
await asyncio.sleep(0.1)
# Prepend screen length command to avoid pagination
if &#34;screen_length_command&#34; in n.tags:
os.write(child_fd, (n.tags[&#34;screen_length_command&#34;] + &#34;\n&#34;).encode())
response_queue.put(connpy_pb2.InteractResponse(copilot_injected_command=n.tags[&#34;screen_length_command&#34;]))
await asyncio.sleep(0.8)
for cmd in commands:
os.write(child_fd, (cmd + &#34;\n&#34;).encode())
response_queue.put(connpy_pb2.InteractResponse(copilot_injected_command=cmd))
await asyncio.sleep(0.8)
return return
elif action.startswith(&#34;custom:&#34;): elif action.startswith(&#34;custom:&#34;):
custom_cmds = action[7:] custom_cmds_raw = action[7:]
os.write(child_fd, b&#39;\x15&#39;) custom_cmds = [cmd.strip() for cmd in custom_cmds_raw.split(&#39;\n&#39;) if cmd.strip()]
await asyncio.sleep(0.1) await n.inject_commands(custom_cmds, child_fd, on_inject=on_inject)
# Prepend screen length command to avoid pagination
if &#34;screen_length_command&#34; in n.tags:
os.write(child_fd, (n.tags[&#34;screen_length_command&#34;] + &#34;\n&#34;).encode())
response_queue.put(connpy_pb2.InteractResponse(copilot_injected_command=n.tags[&#34;screen_length_command&#34;]))
await asyncio.sleep(0.8)
for cmd in custom_cmds.split(&#39;\n&#39;):
if cmd.strip():
os.write(child_fd, (cmd.strip() + &#34;\n&#34;).encode())
response_queue.put(connpy_pb2.InteractResponse(copilot_injected_command=cmd.strip()))
await asyncio.sleep(0.8)
return return
else: else:
os.write(child_fd, b&#39;\x15\r&#39;) os.write(child_fd, b&#39;\x15\r&#39;)
+66 -119
View File
@@ -104,7 +104,6 @@ el.replaceWith(d);
import queue import queue
from rich.prompt import Prompt from rich.prompt import Prompt
from rich.text import Text from rich.text import Text
from rich.live import Live
from rich.panel import Panel from rich.panel import Panel
from rich.markdown import Markdown from rich.markdown import Markdown
@@ -135,7 +134,7 @@ el.replaceWith(d);
responses = self.stub.ask(request_generator()) responses = self.stub.ask(request_generator())
full_content = &#34;&#34; full_content = &#34;&#34;
live_display = None header_printed = False
final_result = {&#34;response&#34;: &#34;&#34;, &#34;chat_history&#34;: []} final_result = {&#34;response&#34;: &#34;&#34;, &#34;chat_history&#34;: []}
# Background thread to pull responses from gRPC into a local queue # Background thread to pull responses from gRPC into a local queue
@@ -200,69 +199,53 @@ el.replaceWith(d);
if response.debug_message: if response.debug_message:
if debug: if debug:
if live_display:
try: live_display.stop()
except: pass
if status: if status:
try: status.stop() try: status.stop()
except: pass except: pass
printer.console.print(Text.from_ansi(response.debug_message)) printer.console.print(Text.from_ansi(response.debug_message))
if live_display: if status:
try: live_display.start()
except: pass
elif status:
try: status.start() try: status.start()
except: pass except: pass
continue continue
if response.important_message: if response.important_message:
if live_display:
try: live_display.stop()
except: pass
if status: if status:
try: status.stop() try: status.stop()
except: pass except: pass
printer.console.print(Text.from_ansi(response.important_message)) printer.console.print(Text.from_ansi(response.important_message))
if live_display: if status:
try: live_display.start()
except: pass
elif status:
try: status.start() try: status.start()
except: pass except: pass
continue continue
if not response.is_final: if not response.is_final:
if response.text_chunk: if response.text_chunk:
full_content += response.text_chunk if not header_printed:
if not live_display:
if status: if status:
try: status.stop() try: status.stop()
except: pass except: pass
from rich.console import Console as RichConsole from rich.console import Console as RichConsole
from ..printer import connpy_theme, get_original_stdout from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# We default to Engineer title during stream, final result will correct it if needed # Print header on first chunk
live_display = Live( stable_console.print(Rule(&#34;[bold engineer]Network Engineer[/bold engineer]&#34;, style=&#34;engineer&#34;))
Panel(Markdown(full_content), title=&#34;[bold engineer]Network Engineer[/bold engineer]&#34;, border_style=&#34;engineer&#34;, expand=False), header_printed = True
console=stable_console,
refresh_per_second=8, # Initialize parser
transient=False md_parser = IncrementalMarkdownParser(console=stable_console)
)
live_display.start() full_content += response.text_chunk
else: md_parser.feed(response.text_chunk)
live_display.update(
Panel(Markdown(full_content), title=&#34;[bold engineer]Network Engineer[/bold engineer]&#34;, border_style=&#34;engineer&#34;, expand=False)
)
continue continue
if response.is_final: if response.is_final:
if live_display: if header_printed:
try: live_display.stop() from rich.rule import Rule
except: pass md_parser.flush()
# Final stop for status to ensure it disappears before the panel
if status: if status:
try: status.stop() try: status.stop()
except: pass except: pass
@@ -273,13 +256,14 @@ el.replaceWith(d);
role_label = &#34;Network Architect&#34; if responder == &#34;architect&#34; else &#34;Network Engineer&#34; role_label = &#34;Network Architect&#34; if responder == &#34;architect&#34; else &#34;Network Engineer&#34;
title = f&#34;[bold {alias}]{role_label}[/bold {alias}]&#34; title = f&#34;[bold {alias}]{role_label}[/bold {alias}]&#34;
content_to_print = full_content or final_result.get(&#34;response&#34;, &#34;&#34;) if header_printed:
if content_to_print: from rich.console import Console as RichConsole
if live_display: from ..printer import connpy_theme, get_original_stdout
# Re-render the final frame with correct title/colors stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
live_display.update(Panel(Markdown(content_to_print), title=title, border_style=alias, expand=False)) stable_console.print(Rule(style=alias))
else: elif not full_content and final_result.get(&#34;response&#34;):
printer.console.print(Panel(Markdown(content_to_print), title=title, border_style=alias, expand=False)) # If nothing streamed but we have response (e.g. error or direct guide)
printer.console.print(Panel(Markdown(final_result[&#34;response&#34;]), title=title, border_style=alias, expand=False))
break break
except Exception as e: except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch # Check if it was a gRPC error that we should let handle_errors catch
@@ -342,7 +326,6 @@ def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debu
import queue import queue
from rich.prompt import Prompt from rich.prompt import Prompt
from rich.text import Text from rich.text import Text
from rich.live import Live
from rich.panel import Panel from rich.panel import Panel
from rich.markdown import Markdown from rich.markdown import Markdown
@@ -373,7 +356,7 @@ def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debu
responses = self.stub.ask(request_generator()) responses = self.stub.ask(request_generator())
full_content = &#34;&#34; full_content = &#34;&#34;
live_display = None header_printed = False
final_result = {&#34;response&#34;: &#34;&#34;, &#34;chat_history&#34;: []} final_result = {&#34;response&#34;: &#34;&#34;, &#34;chat_history&#34;: []}
# Background thread to pull responses from gRPC into a local queue # Background thread to pull responses from gRPC into a local queue
@@ -438,69 +421,53 @@ def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debu
if response.debug_message: if response.debug_message:
if debug: if debug:
if live_display:
try: live_display.stop()
except: pass
if status: if status:
try: status.stop() try: status.stop()
except: pass except: pass
printer.console.print(Text.from_ansi(response.debug_message)) printer.console.print(Text.from_ansi(response.debug_message))
if live_display: if status:
try: live_display.start()
except: pass
elif status:
try: status.start() try: status.start()
except: pass except: pass
continue continue
if response.important_message: if response.important_message:
if live_display:
try: live_display.stop()
except: pass
if status: if status:
try: status.stop() try: status.stop()
except: pass except: pass
printer.console.print(Text.from_ansi(response.important_message)) printer.console.print(Text.from_ansi(response.important_message))
if live_display: if status:
try: live_display.start()
except: pass
elif status:
try: status.start() try: status.start()
except: pass except: pass
continue continue
if not response.is_final: if not response.is_final:
if response.text_chunk: if response.text_chunk:
full_content += response.text_chunk if not header_printed:
if not live_display:
if status: if status:
try: status.stop() try: status.stop()
except: pass except: pass
from rich.console import Console as RichConsole from rich.console import Console as RichConsole
from ..printer import connpy_theme, get_original_stdout from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# We default to Engineer title during stream, final result will correct it if needed # Print header on first chunk
live_display = Live( stable_console.print(Rule(&#34;[bold engineer]Network Engineer[/bold engineer]&#34;, style=&#34;engineer&#34;))
Panel(Markdown(full_content), title=&#34;[bold engineer]Network Engineer[/bold engineer]&#34;, border_style=&#34;engineer&#34;, expand=False), header_printed = True
console=stable_console,
refresh_per_second=8, # Initialize parser
transient=False md_parser = IncrementalMarkdownParser(console=stable_console)
)
live_display.start() full_content += response.text_chunk
else: md_parser.feed(response.text_chunk)
live_display.update(
Panel(Markdown(full_content), title=&#34;[bold engineer]Network Engineer[/bold engineer]&#34;, border_style=&#34;engineer&#34;, expand=False)
)
continue continue
if response.is_final: if response.is_final:
if live_display: if header_printed:
try: live_display.stop() from rich.rule import Rule
except: pass md_parser.flush()
# Final stop for status to ensure it disappears before the panel
if status: if status:
try: status.stop() try: status.stop()
except: pass except: pass
@@ -511,13 +478,14 @@ def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debu
role_label = &#34;Network Architect&#34; if responder == &#34;architect&#34; else &#34;Network Engineer&#34; role_label = &#34;Network Architect&#34; if responder == &#34;architect&#34; else &#34;Network Engineer&#34;
title = f&#34;[bold {alias}]{role_label}[/bold {alias}]&#34; title = f&#34;[bold {alias}]{role_label}[/bold {alias}]&#34;
content_to_print = full_content or final_result.get(&#34;response&#34;, &#34;&#34;) if header_printed:
if content_to_print: from rich.console import Console as RichConsole
if live_display: from ..printer import connpy_theme, get_original_stdout
# Re-render the final frame with correct title/colors stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
live_display.update(Panel(Markdown(content_to_print), title=title, border_style=alias, expand=False)) stable_console.print(Rule(style=alias))
else: elif not full_content and final_result.get(&#34;response&#34;):
printer.console.print(Panel(Markdown(content_to_print), title=title, border_style=alias, expand=False)) # If nothing streamed but we have response (e.g. error or direct guide)
printer.console.print(Panel(Markdown(final_result[&#34;response&#34;]), title=title, border_style=alias, expand=False))
break break
except Exception as e: except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch # Check if it was a gRPC error that we should let handle_errors catch
@@ -1024,7 +992,7 @@ def set_reserved_names(self, names):
self.remote_host = remote_host self.remote_host = remote_host
self.config = config self.config = config
def _handle_remote_copilot(self, res, request_queue, response_queue, client_buffer_bytes, cmd_byte_positions, pause_generator, resume_generator, old_tty): def _handle_remote_copilot(self, res, request_queue, response_queue, client_buffer_bytes, pause_generator, resume_generator, old_tty):
import json, asyncio, termios, sys, tty, queue import json, asyncio, termios, sys, tty, queue
from ..core import copilot_terminal_mode from ..core import copilot_terminal_mode
from . import connpy_pb2 from . import connpy_pb2
@@ -1032,6 +1000,10 @@ def set_reserved_names(self, names):
pause_generator() pause_generator()
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty) termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
node_info = json.loads(res.copilot_node_info_json) if res.copilot_node_info_json else {}
blocks = node_info.get(&#34;context_blocks&#34;, [])
interface = CopilotInterface( interface = CopilotInterface(
self.config, self.config,
history=getattr(self, &#39;copilot_history&#39;, None), history=getattr(self, &#39;copilot_history&#39;, None),
@@ -1040,8 +1012,6 @@ def set_reserved_names(self, names):
self.copilot_history = interface.history self.copilot_history = interface.history
self.copilot_state = interface.session_state self.copilot_state = interface.session_state
node_info = json.loads(res.copilot_node_info_json) if res.copilot_node_info_json else {}
async def on_ai_call_remote(active_buffer, question, chunk_callback, merged_node_info): async def on_ai_call_remote(active_buffer, question, chunk_callback, merged_node_info):
# Send request to server # Send request to server
request_queue.put(connpy_pb2.InteractRequest( request_queue.put(connpy_pb2.InteractRequest(
@@ -1066,9 +1036,9 @@ def set_reserved_names(self, names):
while True: while True:
action, commands, custom_cmd = await interface.run_session( action, commands, custom_cmd = await interface.run_session(
raw_bytes=bytes(client_buffer_bytes), raw_bytes=bytes(client_buffer_bytes),
cmd_byte_positions=cmd_byte_positions,
node_info=node_info, node_info=node_info,
on_ai_call=on_ai_call_remote on_ai_call=on_ai_call_remote,
blocks=blocks
) )
if action == &#34;continue&#34;: if action == &#34;continue&#34;:
@@ -1081,6 +1051,7 @@ def set_reserved_names(self, names):
with copilot_terminal_mode(): with copilot_terminal_mode():
action, commands, custom_cmd = asyncio.run(run_remote_copilot()) action, commands, custom_cmd = asyncio.run(run_remote_copilot())
print(&#34;\033[2m Returning to session...\033[0m&#34;, flush=True)
# Prepare final action for server # Prepare final action for server
action_sent = &#34;cancel&#34; action_sent = &#34;cancel&#34;
if action == &#34;send_all&#34; and commands: if action == &#34;send_all&#34; and commands:
@@ -1105,7 +1076,6 @@ def set_reserved_names(self, names):
request_queue = queue.Queue() request_queue = queue.Queue()
client_buffer_bytes = bytearray() client_buffer_bytes = bytearray()
cmd_byte_positions = [(0, None)]
pause_stdin = [False] pause_stdin = [False]
wake_r, wake_w = os.pipe() wake_r, wake_w = os.pipe()
@@ -1152,8 +1122,6 @@ def set_reserved_names(self, names):
data = os.read(sys.stdin.fileno(), 1024) data = os.read(sys.stdin.fileno(), 1024)
if not data: if not data:
break break
if b&#39;\r&#39; in data or b&#39;\n&#39; in data:
cmd_byte_positions.append((len(client_buffer_bytes), None))
yield connpy_pb2.InteractRequest(stdin_data=data) yield connpy_pb2.InteractRequest(stdin_data=data)
except OSError: except OSError:
break break
@@ -1227,14 +1195,11 @@ def set_reserved_names(self, names):
if res.copilot_prompt: if res.copilot_prompt:
self._handle_remote_copilot( self._handle_remote_copilot(
res, request_queue, response_queue, res, request_queue, response_queue,
client_buffer_bytes, cmd_byte_positions, client_buffer_bytes,
pause_generator, resume_generator, old_tty pause_generator, resume_generator, old_tty
) )
continue continue
if res.copilot_injected_command:
cmd_byte_positions.append((len(client_buffer_bytes), res.copilot_injected_command))
if res.stdout_data: if res.stdout_data:
os.write(sys.stdout.fileno(), res.stdout_data) os.write(sys.stdout.fileno(), res.stdout_data)
client_buffer_bytes.extend(res.stdout_data) client_buffer_bytes.extend(res.stdout_data)
@@ -1256,7 +1221,6 @@ def set_reserved_names(self, names):
params_json = json.dumps(connection_params) params_json = json.dumps(connection_params)
request_queue = queue.Queue() request_queue = queue.Queue()
client_buffer_bytes = bytearray() client_buffer_bytes = bytearray()
cmd_byte_positions = [(0, None)]
pause_stdin = [False] pause_stdin = [False]
wake_r, wake_w = os.pipe() wake_r, wake_w = os.pipe()
@@ -1304,8 +1268,6 @@ def set_reserved_names(self, names):
data = os.read(sys.stdin.fileno(), 1024) data = os.read(sys.stdin.fileno(), 1024)
if not data: if not data:
break break
if b&#39;\r&#39; in data or b&#39;\n&#39; in data:
cmd_byte_positions.append((len(client_buffer_bytes), None))
yield connpy_pb2.InteractRequest(stdin_data=data) yield connpy_pb2.InteractRequest(stdin_data=data)
except OSError: except OSError:
break break
@@ -1378,14 +1340,11 @@ def set_reserved_names(self, names):
if res.copilot_prompt: if res.copilot_prompt:
self._handle_remote_copilot( self._handle_remote_copilot(
res, request_queue, response_queue, res, request_queue, response_queue,
client_buffer_bytes, cmd_byte_positions, client_buffer_bytes,
pause_generator, resume_generator, old_tty pause_generator, resume_generator, old_tty
) )
continue continue
if res.copilot_injected_command:
cmd_byte_positions.append((len(client_buffer_bytes), res.copilot_injected_command))
if res.stdout_data: if res.stdout_data:
os.write(sys.stdout.fileno(), res.stdout_data) os.write(sys.stdout.fileno(), res.stdout_data)
client_buffer_bytes.extend(res.stdout_data) client_buffer_bytes.extend(res.stdout_data)
@@ -1553,7 +1512,6 @@ def connect_dynamic(self, connection_params, debug=False):
params_json = json.dumps(connection_params) params_json = json.dumps(connection_params)
request_queue = queue.Queue() request_queue = queue.Queue()
client_buffer_bytes = bytearray() client_buffer_bytes = bytearray()
cmd_byte_positions = [(0, None)]
pause_stdin = [False] pause_stdin = [False]
wake_r, wake_w = os.pipe() wake_r, wake_w = os.pipe()
@@ -1601,8 +1559,6 @@ def connect_dynamic(self, connection_params, debug=False):
data = os.read(sys.stdin.fileno(), 1024) data = os.read(sys.stdin.fileno(), 1024)
if not data: if not data:
break break
if b&#39;\r&#39; in data or b&#39;\n&#39; in data:
cmd_byte_positions.append((len(client_buffer_bytes), None))
yield connpy_pb2.InteractRequest(stdin_data=data) yield connpy_pb2.InteractRequest(stdin_data=data)
except OSError: except OSError:
break break
@@ -1675,14 +1631,11 @@ def connect_dynamic(self, connection_params, debug=False):
if res.copilot_prompt: if res.copilot_prompt:
self._handle_remote_copilot( self._handle_remote_copilot(
res, request_queue, response_queue, res, request_queue, response_queue,
client_buffer_bytes, cmd_byte_positions, client_buffer_bytes,
pause_generator, resume_generator, old_tty pause_generator, resume_generator, old_tty
) )
continue continue
if res.copilot_injected_command:
cmd_byte_positions.append((len(client_buffer_bytes), res.copilot_injected_command))
if res.stdout_data: if res.stdout_data:
os.write(sys.stdout.fileno(), res.stdout_data) os.write(sys.stdout.fileno(), res.stdout_data)
client_buffer_bytes.extend(res.stdout_data) client_buffer_bytes.extend(res.stdout_data)
@@ -1713,7 +1666,6 @@ def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
request_queue = queue.Queue() request_queue = queue.Queue()
client_buffer_bytes = bytearray() client_buffer_bytes = bytearray()
cmd_byte_positions = [(0, None)]
pause_stdin = [False] pause_stdin = [False]
wake_r, wake_w = os.pipe() wake_r, wake_w = os.pipe()
@@ -1760,8 +1712,6 @@ def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
data = os.read(sys.stdin.fileno(), 1024) data = os.read(sys.stdin.fileno(), 1024)
if not data: if not data:
break break
if b&#39;\r&#39; in data or b&#39;\n&#39; in data:
cmd_byte_positions.append((len(client_buffer_bytes), None))
yield connpy_pb2.InteractRequest(stdin_data=data) yield connpy_pb2.InteractRequest(stdin_data=data)
except OSError: except OSError:
break break
@@ -1835,14 +1785,11 @@ def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
if res.copilot_prompt: if res.copilot_prompt:
self._handle_remote_copilot( self._handle_remote_copilot(
res, request_queue, response_queue, res, request_queue, response_queue,
client_buffer_bytes, cmd_byte_positions, client_buffer_bytes,
pause_generator, resume_generator, old_tty pause_generator, resume_generator, old_tty
) )
continue continue
if res.copilot_injected_command:
cmd_byte_positions.append((len(client_buffer_bytes), res.copilot_injected_command))
if res.stdout_data: if res.stdout_data:
os.write(sys.stdout.fileno(), res.stdout_data) os.write(sys.stdout.fileno(), res.stdout_data)
client_buffer_bytes.extend(res.stdout_data) client_buffer_bytes.extend(res.stdout_data)
+153 -71
View File
@@ -84,10 +84,10 @@ cd connpy
docker compose build docker compose build
# Run it like a native app (completely silent) # Run it like a native app (completely silent)
docker compose --log-level ERROR run --rm --remove-orphans connpy-app [command] docker compose run --rm --remove-orphans connpy-app [command]
# Pro Tip: Add this alias for a 100% native experience from any folder # Pro Tip: Add this alias for a 100% native experience from any folder
alias conn='docker compose -f /path/to/connpy/docker-compose.yml --log-level ERROR run --rm --remove-orphans connpy-app' alias conn='docker compose -f /path/to/connpy/docker-compose.yml run --rm --remove-orphans connpy-app'
</code></pre> </code></pre>
<hr> <hr>
<h2 id="privacy-integration">🔒 Privacy &amp; Integration</h2> <h2 id="privacy-integration">🔒 Privacy &amp; Integration</h2>
@@ -737,6 +737,7 @@ class ai:
- COMPLETE MISSIONS: Execute ALL steps of a mission before reporting back. - COMPLETE MISSIONS: Execute ALL steps of a mission before reporting back.
- DIAGRAM: Use ASCII art or Unicode box-drawing characters directly in your responses to visualize topologies or paths when helpful. - DIAGRAM: Use ASCII art or Unicode box-drawing characters directly in your responses to visualize topologies or paths when helpful.
- EVIDENCE: Include &#39;Key Snippets&#39; from tool outputs. Be token-efficient. - EVIDENCE: Include &#39;Key Snippets&#39; from tool outputs. Be token-efficient.
- LANGUAGE: You MUST respond in the same language used by the user in their question or instruction.
- NO WANDERING: Do not speculate. If stuck, report attempts. - NO WANDERING: Do not speculate. If stuck, report attempts.
- SAFETY: When you use &#39;run_commands&#39; with configuration commands, the system automatically prompts the user for confirmation. Just execute - don&#39;t ask permission first. - SAFETY: When you use &#39;run_commands&#39; with configuration commands, the system automatically prompts the user for confirmation. Just execute - don&#39;t ask permission first.
{architect_instructions} {architect_instructions}
@@ -754,6 +755,7 @@ class ai:
- ENGINEER CAPABILITIES: Your Engineer can: - ENGINEER CAPABILITIES: Your Engineer can:
* Filter nodes (list_nodes), Run CLI commands (run_commands), Get metadata (get_node_info). * Filter nodes (list_nodes), Run CLI commands (run_commands), Get metadata (get_node_info).
- ANALYSIS: Review technical findings to identify patterns or design failures. - ANALYSIS: Review technical findings to identify patterns or design failures.
- LANGUAGE: You MUST respond in the same language used by the user in their question or instruction.
- MEMORY: Update long-term facts ONLY when the user explicitly requests it. - MEMORY: Update long-term facts ONLY when the user explicitly requests it.
CRITICAL - EFFICIENT DELEGATION: CRITICAL - EFFICIENT DELEGATION:
@@ -829,7 +831,6 @@ class ai:
- response: reconstructed ModelResponse (same as non-streaming) - response: reconstructed ModelResponse (same as non-streaming)
- streamed: True if text was rendered to console during streaming - streamed: True if text was rendered to console during streaming
&#34;&#34;&#34; &#34;&#34;&#34;
from rich.live import Live
stream_resp = completion(model=model, messages=messages, tools=tools, api_key=api_key, stream=True, **kwargs) stream_resp = completion(model=model, messages=messages, tools=tools, api_key=api_key, stream=True, **kwargs)
@@ -837,7 +838,7 @@ class ai:
full_content = &#34;&#34; full_content = &#34;&#34;
is_streaming_text = False is_streaming_text = False
has_tool_calls = False has_tool_calls = False
live_display = None header_printed = False
# Determine styling based on current brain # Determine styling based on current brain
role_label = &#34;Network Architect&#34; if &#34;architect&#34; in label.lower() else &#34;Network Engineer&#34; role_label = &#34;Network Architect&#34; if &#34;architect&#34; in label.lower() else &#34;Network Engineer&#34;
@@ -866,7 +867,6 @@ class ai:
if not chunk_callback: if not chunk_callback:
if not is_streaming_text: if not is_streaming_text:
# Stop spinner definitively
if status: if status:
try: try:
status.stop() status.stop()
@@ -875,35 +875,28 @@ class ai:
# Create a stable, direct Console to bypass _ConsoleProxy recreation bugs # Create a stable, direct Console to bypass _ConsoleProxy recreation bugs
from rich.console import Console as RichConsole from rich.console import Console as RichConsole
from .printer import connpy_theme, get_original_stdout from rich.rule import Rule
from .printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
live_display = Live( stable_console.print(Rule(f&#34;[bold {border}]{title}[/bold {border}]&#34;, style=border))
Panel(Markdown(full_content), title=title, border_style=border, expand=False), header_printed = True
console=stable_console, md_parser = IncrementalMarkdownParser(console=stable_console)
refresh_per_second=8,
transient=False
)
live_display.start()
is_streaming_text = True is_streaming_text = True
else:
live_display.update( md_parser.feed(delta.content)
Panel(Markdown(full_content), title=title, border_style=border, expand=False)
)
except Exception as e: except Exception as e:
if not chunks: if not chunks:
raise raise
finally: finally:
if live_display: if header_printed:
# Render final state with complete content
try: try:
live_display.update( md_parser.flush()
Panel(Markdown(full_content), title=title, border_style=border, expand=False) from rich.console import Console as RichConsole
) from rich.rule import Rule
except Exception: from .printer import connpy_theme, get_original_stdout
pass stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
try: stable_console.print(Rule(style=border))
live_display.stop()
except Exception: except Exception:
pass pass
@@ -1852,11 +1845,13 @@ class ai:
if persona == &#34;architect&#34;: if persona == &#34;architect&#34;:
system_prompt = f&#34;&#34;&#34;Role: NETWORK ARCHITECT. You act as a senior strategic advisor during a live SSH session. system_prompt = f&#34;&#34;&#34;Role: NETWORK ARCHITECT. You act as a senior strategic advisor during a live SSH session.
Rules: Rules:
1. Answer the user&#39;s question directly based on the Terminal Context. 1. MANDATORY: You MUST respond in the same language used by the user in their question.
2. Focus on the &#34;why&#34; and &#34;how&#34;. Analyze topologies, design patterns, and validate configurations. 2. Answer the user&#39;s question directly and EXCLUSIVELY based on the Terminal Context.
3. Do NOT provide commands to execute unless specifically requested. Instead, explain the consequences and best practices. 3. NO HALLUCINATIONS. The Terminal Context is a live buffer. If it contains only a shell prompt (like &#39;iol#&#39; or &#39;admin@vrouter&gt;&#39;) and no command output, it means YOU DON&#39;T HAVE DATA. In this case, YOU MUST NOT invent any information.
4. Keep your guide concise and authoritative. 4. Focus on the &#34;why&#34; and &#34;how&#34;. Analyze topologies, design patterns, and validate configurations.
5. You MUST output your response in the following strict format: 5. Do NOT provide commands to execute unless specifically requested. Instead, explain the consequences and best practices.
6. Keep your guide concise and authoritative.
7. You MUST output your response in the following strict format:
&lt;guide&gt; &lt;guide&gt;
Your brief tactical guide in markdown. Your brief tactical guide in markdown.
&lt;/guide&gt; &lt;/guide&gt;
@@ -1865,7 +1860,7 @@ Your brief tactical guide in markdown.
&lt;risk&gt; &lt;risk&gt;
low low
&lt;/risk&gt; &lt;/risk&gt;
6. Risk level is usually &#34;low&#34; for read-only/no commands. 8. Risk level is usually &#34;low&#34; for read-only/no commands.
Terminal Context: Terminal Context:
{terminal_buffer} {terminal_buffer}
@@ -1875,11 +1870,13 @@ Node: {node_name}&#34;&#34;&#34;
else: else:
system_prompt = f&#34;&#34;&#34;Role: TERMINAL COPILOT. You assist a network engineer during a live SSH session. system_prompt = f&#34;&#34;&#34;Role: TERMINAL COPILOT. You assist a network engineer during a live SSH session.
Rules: Rules:
1. Answer the user&#39;s question directly based on the Terminal Context. 1. MANDATORY: You MUST respond in the same language used by the user in their question.
2. If the user asks you to analyze, parse, or extract data from the Terminal Context, DO IT directly in the &lt;guide&gt; section (you can use markdown tables or lists). Do NOT just give them a command to do it themselves. 2. EXTREMELY IMPORTANT: Answer EXCLUSIVELY based on the provided Terminal Context.
3. If the user wants to execute an action, provide the required CLI commands inside a &lt;commands&gt; block, one command per line. If no commands are needed, leave it empty or omit the block. 3. NO HALLUCINATIONS. The Terminal Context is a live buffer. If it contains only a shell prompt (like &#39;iol#&#39; or &#39;admin@vrouter&gt;&#39;) and no command output, it means YOU DON&#39;T HAVE DATA. In this case, YOU MUST NOT invent any information. Instead, explicitly state that you don&#39;t see the data and offer the correct CLI commands to retrieve it.
4. ULTRA-CONCISE. Keep your guide to the point. 4. If the user asks you to analyze, parse, or extract data from the Terminal Context, DO IT directly in the &lt;guide&gt; section (you can use markdown tables or lists). Do NOT just give them a command to do it themselves.
5. You MUST output your response in the following strict format: 5. If the user wants to execute an action, provide the required CLI commands inside a &lt;commands&gt; block, one command per line. If no commands are needed, leave it empty or omit the block.
6. ULTRA-CONCISE. Keep your guide to the point.
7. You MUST output your response in the following strict format:
&lt;guide&gt; &lt;guide&gt;
Your brief tactical guide in markdown. 3-4 sentences max. Your brief tactical guide in markdown. 3-4 sentences max.
&lt;/guide&gt; &lt;/guide&gt;
@@ -1890,7 +1887,7 @@ command 2
&lt;risk&gt; &lt;risk&gt;
low, high, or destructive low, high, or destructive
&lt;/risk&gt; &lt;/risk&gt;
6. Risk level: &#34;low&#34; for read-only/no commands, &#34;high&#34; for config changes, &#34;destructive&#34; for potentially dangerous ops. 8. Risk level: &#34;low&#34; for read-only/no commands, &#34;high&#34; for config changes, &#34;destructive&#34; for potentially dangerous ops.
Terminal Context: Terminal Context:
{terminal_buffer} {terminal_buffer}
@@ -1932,6 +1929,7 @@ Node: {node_name}&#34;&#34;&#34;
try: try:
while iteration &lt; max_iterations: while iteration &lt; max_iterations:
iteration += 1 iteration += 1
response = await acompletion( response = await acompletion(
model=current_model, model=current_model,
messages=messages, messages=messages,
@@ -2177,11 +2175,13 @@ def engineer_system_prompt(self):
if persona == &#34;architect&#34;: if persona == &#34;architect&#34;:
system_prompt = f&#34;&#34;&#34;Role: NETWORK ARCHITECT. You act as a senior strategic advisor during a live SSH session. system_prompt = f&#34;&#34;&#34;Role: NETWORK ARCHITECT. You act as a senior strategic advisor during a live SSH session.
Rules: Rules:
1. Answer the user&#39;s question directly based on the Terminal Context. 1. MANDATORY: You MUST respond in the same language used by the user in their question.
2. Focus on the &#34;why&#34; and &#34;how&#34;. Analyze topologies, design patterns, and validate configurations. 2. Answer the user&#39;s question directly and EXCLUSIVELY based on the Terminal Context.
3. Do NOT provide commands to execute unless specifically requested. Instead, explain the consequences and best practices. 3. NO HALLUCINATIONS. The Terminal Context is a live buffer. If it contains only a shell prompt (like &#39;iol#&#39; or &#39;admin@vrouter&gt;&#39;) and no command output, it means YOU DON&#39;T HAVE DATA. In this case, YOU MUST NOT invent any information.
4. Keep your guide concise and authoritative. 4. Focus on the &#34;why&#34; and &#34;how&#34;. Analyze topologies, design patterns, and validate configurations.
5. You MUST output your response in the following strict format: 5. Do NOT provide commands to execute unless specifically requested. Instead, explain the consequences and best practices.
6. Keep your guide concise and authoritative.
7. You MUST output your response in the following strict format:
&lt;guide&gt; &lt;guide&gt;
Your brief tactical guide in markdown. Your brief tactical guide in markdown.
&lt;/guide&gt; &lt;/guide&gt;
@@ -2190,7 +2190,7 @@ Your brief tactical guide in markdown.
&lt;risk&gt; &lt;risk&gt;
low low
&lt;/risk&gt; &lt;/risk&gt;
6. Risk level is usually &#34;low&#34; for read-only/no commands. 8. Risk level is usually &#34;low&#34; for read-only/no commands.
Terminal Context: Terminal Context:
{terminal_buffer} {terminal_buffer}
@@ -2200,11 +2200,13 @@ Node: {node_name}&#34;&#34;&#34;
else: else:
system_prompt = f&#34;&#34;&#34;Role: TERMINAL COPILOT. You assist a network engineer during a live SSH session. system_prompt = f&#34;&#34;&#34;Role: TERMINAL COPILOT. You assist a network engineer during a live SSH session.
Rules: Rules:
1. Answer the user&#39;s question directly based on the Terminal Context. 1. MANDATORY: You MUST respond in the same language used by the user in their question.
2. If the user asks you to analyze, parse, or extract data from the Terminal Context, DO IT directly in the &lt;guide&gt; section (you can use markdown tables or lists). Do NOT just give them a command to do it themselves. 2. EXTREMELY IMPORTANT: Answer EXCLUSIVELY based on the provided Terminal Context.
3. If the user wants to execute an action, provide the required CLI commands inside a &lt;commands&gt; block, one command per line. If no commands are needed, leave it empty or omit the block. 3. NO HALLUCINATIONS. The Terminal Context is a live buffer. If it contains only a shell prompt (like &#39;iol#&#39; or &#39;admin@vrouter&gt;&#39;) and no command output, it means YOU DON&#39;T HAVE DATA. In this case, YOU MUST NOT invent any information. Instead, explicitly state that you don&#39;t see the data and offer the correct CLI commands to retrieve it.
4. ULTRA-CONCISE. Keep your guide to the point. 4. If the user asks you to analyze, parse, or extract data from the Terminal Context, DO IT directly in the &lt;guide&gt; section (you can use markdown tables or lists). Do NOT just give them a command to do it themselves.
5. You MUST output your response in the following strict format: 5. If the user wants to execute an action, provide the required CLI commands inside a &lt;commands&gt; block, one command per line. If no commands are needed, leave it empty or omit the block.
6. ULTRA-CONCISE. Keep your guide to the point.
7. You MUST output your response in the following strict format:
&lt;guide&gt; &lt;guide&gt;
Your brief tactical guide in markdown. 3-4 sentences max. Your brief tactical guide in markdown. 3-4 sentences max.
&lt;/guide&gt; &lt;/guide&gt;
@@ -2215,7 +2217,7 @@ command 2
&lt;risk&gt; &lt;risk&gt;
low, high, or destructive low, high, or destructive
&lt;/risk&gt; &lt;/risk&gt;
6. Risk level: &#34;low&#34; for read-only/no commands, &#34;high&#34; for config changes, &#34;destructive&#34; for potentially dangerous ops. 8. Risk level: &#34;low&#34; for read-only/no commands, &#34;high&#34; for config changes, &#34;destructive&#34; for potentially dangerous ops.
Terminal Context: Terminal Context:
{terminal_buffer} {terminal_buffer}
@@ -2257,6 +2259,7 @@ Node: {node_name}&#34;&#34;&#34;
try: try:
while iteration &lt; max_iterations: while iteration &lt; max_iterations:
iteration += 1 iteration += 1
response = await acompletion( response = await acompletion(
model=current_model, model=current_model,
messages=messages, messages=messages,
@@ -4119,6 +4122,7 @@ class node:
self.output = &#34;&#34; self.output = &#34;&#34;
self.status = 1 self.status = 1
self.result = {} self.result = {}
self.cmd_byte_positions = [(0, None)]
@MethodHook @MethodHook
def _passtx(self, passwords, *, keyfile=None): def _passtx(self, passwords, *, keyfile=None):
@@ -4293,9 +4297,9 @@ class node:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
child_reader_queue = asyncio.Queue() child_reader_queue = asyncio.Queue()
# Track command byte positions for copilot context navigation # Reset and track command byte positions for copilot context navigation
# Each entry is (byte_position, command_text_or_None) # Each entry is (byte_position, command_text_or_None)
cmd_byte_positions = [(0, None)] self.cmd_byte_positions = [(self.mylog.tell() if hasattr(self, &#39;mylog&#39;) else 0, None)]
def _child_read_ready(): def _child_read_ready():
try: try:
@@ -4336,7 +4340,7 @@ class node:
node_info[&#34;prompt&#34;] = to_str(self.tags.get(&#34;prompt&#34;, r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;)) node_info[&#34;prompt&#34;] = to_str(self.tags.get(&#34;prompt&#34;, r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;))
# Invoke copilot (async callback handles UI) # Invoke copilot (async callback handles UI)
await copilot_handler(self.mylog.getvalue(), node_info, local_stream, child_fd, cmd_byte_positions) await copilot_handler(self.mylog.getvalue(), node_info, local_stream, child_fd, self.cmd_byte_positions)
continue continue
# Remove any stray \x00 bytes and forward normally # Remove any stray \x00 bytes and forward normally
@@ -4344,10 +4348,9 @@ class node:
if clean_data: if clean_data:
# Track command boundaries when user hits Enter # Track command boundaries when user hits Enter
if hasattr(self, &#39;mylog&#39;) and (b&#39;\r&#39; in clean_data or b&#39;\n&#39; in clean_data): if hasattr(self, &#39;mylog&#39;) and (b&#39;\r&#39; in clean_data or b&#39;\n&#39; in clean_data):
cmd_byte_positions.append((self.mylog.tell(), None)) self.cmd_byte_positions.append((self.mylog.tell(), None))
try: try: os.write(child_fd, clean_data)
os.write(child_fd, clean_data)
except OSError: except OSError:
break break
self.lastinput = time() self.lastinput = time()
@@ -4469,6 +4472,45 @@ class node:
finally: finally:
local_stream.teardown() local_stream.teardown()
@MethodHook
async def inject_commands(self, commands, child_fd, on_inject=None):
&#34;&#34;&#34;
Inject a list of commands into the node&#39;s PTY.
Handles screen_length_command, history tracking and delays.
&#34;&#34;&#34;
if not commands:
return
# 0. Clear line
os.write(child_fd, b&#39;\x15&#39;)
await asyncio.sleep(0.1)
# 1. Prepare list (prepend screen_length if exists)
slc = self.tags.get(&#34;screen_length_command&#34;) if hasattr(self, &#39;tags&#39;) and isinstance(self.tags, dict) else None
to_send = list(commands)
if slc and slc not in to_send: # avoid duplicates if already there
to_send.insert(0, slc)
# 2. Inject one by one
for cmd in to_send:
# Register in node&#39;s official history (SKIP if it&#39;s the administrative screen length command)
if cmd != slc and hasattr(self, &#39;cmd_byte_positions&#39;) and self.cmd_byte_positions is not None:
log_pos = self.mylog.tell() if hasattr(self, &#39;mylog&#39;) else 0
self.cmd_byte_positions.append((log_pos, cmd))
# Write physically to PTY
os.write(child_fd, (cmd + &#34;\n&#34;).encode())
# Notify (e.g., for gRPC or logs) - SKIP for administrative SLC
if on_inject and cmd != slc:
if asyncio.iscoroutinefunction(on_inject):
await on_inject(cmd)
else:
on_inject(cmd)
# Delay to avoid overwhelming the router
await asyncio.sleep(0.8)
@MethodHook @MethodHook
def interact(self, debug=False, logger=None): def interact(self, debug=False, logger=None):
@@ -4550,7 +4592,7 @@ class node:
while True: while True:
action, commands, custom_cmd = await interface.run_session( action, commands, custom_cmd = await interface.run_session(
raw_bytes=raw_bytes, raw_bytes=raw_bytes,
cmd_byte_positions=cmd_byte_positions, cmd_byte_positions=self.cmd_byte_positions,
node_info=node_info, node_info=node_info,
on_ai_call=on_ai_call on_ai_call=on_ai_call
) )
@@ -4558,6 +4600,7 @@ class node:
continue continue
break break
finally: finally:
print(&#34;\033[2m Returning to session...\033[0m&#34;, flush=True)
# Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet # Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet
if hasattr(stream, &#39;start_reading&#39;): if hasattr(stream, &#39;start_reading&#39;):
stream.start_reading() stream.start_reading()
@@ -4566,20 +4609,7 @@ class node:
if action in (&#34;send_all&#34;, &#34;custom&#34;): if action in (&#34;send_all&#34;, &#34;custom&#34;):
cmds_to_send = commands if action == &#34;send_all&#34; else custom_cmd cmds_to_send = commands if action == &#34;send_all&#34; else custom_cmd
await self.inject_commands(cmds_to_send, child_fd)
if cmds_to_send:
os.write(child_fd, b&#39;\x15&#39;) # Ctrl+U
await asyncio.sleep(0.1)
# Prepend screen length command to avoid pagination
if &#34;screen_length_command&#34; in self.tags:
cmds_to_send.insert(0, self.tags[&#34;screen_length_command&#34;])
for cmd in cmds_to_send:
if cmd_byte_positions is not None:
cmd_byte_positions.append((self.mylog.tell(), cmd))
os.write(child_fd, (cmd + &#34;\n&#34;).encode())
await asyncio.sleep(0.8)
else: else:
os.write(child_fd, b&#39;\x15\r&#39;) os.write(child_fd, b&#39;\x15\r&#39;)
except Exception as e: except Exception as e:
@@ -5073,6 +5103,57 @@ class node:
</code></pre></div> </code></pre></div>
<h3>Methods</h3> <h3>Methods</h3>
<dl> <dl>
<dt id="connpy.node.inject_commands"><code class="name flex">
<span>async def <span class="ident">inject_commands</span></span>(<span>self, commands, child_fd, on_inject=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@MethodHook
async def inject_commands(self, commands, child_fd, on_inject=None):
&#34;&#34;&#34;
Inject a list of commands into the node&#39;s PTY.
Handles screen_length_command, history tracking and delays.
&#34;&#34;&#34;
if not commands:
return
# 0. Clear line
os.write(child_fd, b&#39;\x15&#39;)
await asyncio.sleep(0.1)
# 1. Prepare list (prepend screen_length if exists)
slc = self.tags.get(&#34;screen_length_command&#34;) if hasattr(self, &#39;tags&#39;) and isinstance(self.tags, dict) else None
to_send = list(commands)
if slc and slc not in to_send: # avoid duplicates if already there
to_send.insert(0, slc)
# 2. Inject one by one
for cmd in to_send:
# Register in node&#39;s official history (SKIP if it&#39;s the administrative screen length command)
if cmd != slc and hasattr(self, &#39;cmd_byte_positions&#39;) and self.cmd_byte_positions is not None:
log_pos = self.mylog.tell() if hasattr(self, &#39;mylog&#39;) else 0
self.cmd_byte_positions.append((log_pos, cmd))
# Write physically to PTY
os.write(child_fd, (cmd + &#34;\n&#34;).encode())
# Notify (e.g., for gRPC or logs) - SKIP for administrative SLC
if on_inject and cmd != slc:
if asyncio.iscoroutinefunction(on_inject):
await on_inject(cmd)
else:
on_inject(cmd)
# Delay to avoid overwhelming the router
await asyncio.sleep(0.8)</code></pre>
</details>
<div class="desc"><p>Inject a list of commands into the node's PTY.
Handles screen_length_command, history tracking and delays.</p></div>
</dd>
<dt id="connpy.node.interact"><code class="name flex"> <dt id="connpy.node.interact"><code class="name flex">
<span>def <span class="ident">interact</span></span>(<span>self, debug=False, logger=None)</span> <span>def <span class="ident">interact</span></span>(<span>self, debug=False, logger=None)</span>
</code></dt> </code></dt>
@@ -6189,6 +6270,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
<li> <li>
<h4><code><a title="connpy.node" href="#connpy.node">node</a></code></h4> <h4><code><a title="connpy.node" href="#connpy.node">node</a></code></h4>
<ul class=""> <ul class="">
<li><code><a title="connpy.node.inject_commands" href="#connpy.node.inject_commands">inject_commands</a></code></li>
<li><code><a title="connpy.node.interact" href="#connpy.node.interact">interact</a></code></li> <li><code><a title="connpy.node.interact" href="#connpy.node.interact">interact</a></code></li>
<li><code><a title="connpy.node.run" href="#connpy.node.run">run</a></code></li> <li><code><a title="connpy.node.run" href="#connpy.node.run">run</a></code></li>
<li><code><a title="connpy.node.test" href="#connpy.node.test">test</a></code></li> <li><code><a title="connpy.node.test" href="#connpy.node.test">test</a></code></li>
+77 -9
View File
@@ -58,10 +58,10 @@ el.replaceWith(d);
<pre><code class="python">class AIService(BaseService): <pre><code class="python">class AIService(BaseService):
&#34;&#34;&#34;Business logic for interacting with AI agents and LLM configurations.&#34;&#34;&#34; &#34;&#34;&#34;Business logic for interacting with AI agents and LLM configurations.&#34;&#34;&#34;
def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict) -&gt; list: def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict, last_line: str = &#34;&#34;) -&gt; list:
&#34;&#34;&#34;Identifies command blocks in the terminal history.&#34;&#34;&#34; &#34;&#34;&#34;Identifies command blocks in the terminal history.&#34;&#34;&#34;
blocks = [] blocks = []
if not (cmd_byte_positions and len(cmd_byte_positions) &gt;= 2 and raw_bytes): if not raw_bytes:
return blocks return blocks
default_prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39; default_prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;
@@ -72,6 +72,8 @@ el.replaceWith(d);
except Exception: except Exception:
prompt_re = re.compile(re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, default_prompt)) prompt_re = re.compile(re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, default_prompt))
parsed_positions = []
if cmd_byte_positions and len(cmd_byte_positions) &gt;= 1:
for i in range(1, len(cmd_byte_positions)): for i in range(1, len(cmd_byte_positions)):
pos, known_cmd = cmd_byte_positions[i] pos, known_cmd = cmd_byte_positions[i]
prev_pos = cmd_byte_positions[i-1][0] prev_pos = cmd_byte_positions[i-1][0]
@@ -82,7 +84,7 @@ el.replaceWith(d);
prev_lines = [l for l in prev_cleaned.split(&#39;\n&#39;) if l.strip()] prev_lines = [l for l in prev_cleaned.split(&#39;\n&#39;) if l.strip()]
prompt_text = prev_lines[-1].strip() if prev_lines else &#34;&#34; prompt_text = prev_lines[-1].strip() if prev_lines else &#34;&#34;
preview = f&#34;{prompt_text}{known_cmd}&#34; if prompt_text else known_cmd preview = f&#34;{prompt_text}{known_cmd}&#34; if prompt_text else known_cmd
blocks.append((pos, preview[:80])) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview[:80]})
else: else:
chunk = raw_bytes[prev_pos:pos] chunk = raw_bytes[prev_pos:pos]
cleaned = log_cleaner(chunk.decode(errors=&#39;replace&#39;)) cleaned = log_cleaner(chunk.decode(errors=&#39;replace&#39;))
@@ -94,7 +96,39 @@ el.replaceWith(d);
if match: if match:
cmd_text = preview[match.end():].strip() cmd_text = preview[match.end():].strip()
if cmd_text: if cmd_text:
blocks.append((pos, preview[:80])) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview[:80]})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;EMPTY_PROMPT&#34;, &#34;preview&#34;: &#34;&#34;})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
last_newline = raw_bytes.rfind(b&#39;\n&#39;)
current_prompt_pos = last_newline + 1 if last_newline != -1 else 0
current_end = len(raw_bytes)
for i, item in enumerate(parsed_positions):
if item[&#34;type&#34;] == &#34;VALID_CMD&#34;:
start_pos = item[&#34;pos&#34;]
preview = item[&#34;preview&#34;]
# Find the end position: next VALID_CMD or EMPTY_PROMPT
end_pos = current_prompt_pos
for j in range(i + 1, len(parsed_positions)):
next_item = parsed_positions[j]
if next_item[&#34;type&#34;] in (&#34;VALID_CMD&#34;, &#34;EMPTY_PROMPT&#34;):
end_pos = next_item[&#34;pos&#34;]
break
blocks.append((start_pos, end_pos, preview))
# Always ensure there is a final block representing the current prompt
if not blocks:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else &#34;CURRENT CONTEXT&#34;))
elif blocks[-1][0] &lt; current_prompt_pos:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else &#34;CURRENT CONTEXT&#34;))
return blocks return blocks
def process_copilot_input(self, input_text: str, session_state: dict) -&gt; dict: def process_copilot_input(self, input_text: str, session_state: dict) -&gt; dict:
@@ -317,17 +351,17 @@ el.replaceWith(d);
<div class="desc"><p>Ask the AI copilot for terminal assistance.</p></div> <div class="desc"><p>Ask the AI copilot for terminal assistance.</p></div>
</dd> </dd>
<dt id="connpy.services.ai_service.AIService.build_context_blocks"><code class="name flex"> <dt id="connpy.services.ai_service.AIService.build_context_blocks"><code class="name flex">
<span>def <span class="ident">build_context_blocks</span></span>(<span>self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict) > list</span> <span>def <span class="ident">build_context_blocks</span></span>(<span>self,<br>raw_bytes: bytes,<br>cmd_byte_positions: list,<br>node_info: dict,<br>last_line: str = '') > list</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
<summary> <summary>
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict) -&gt; list: <pre><code class="python">def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict, last_line: str = &#34;&#34;) -&gt; list:
&#34;&#34;&#34;Identifies command blocks in the terminal history.&#34;&#34;&#34; &#34;&#34;&#34;Identifies command blocks in the terminal history.&#34;&#34;&#34;
blocks = [] blocks = []
if not (cmd_byte_positions and len(cmd_byte_positions) &gt;= 2 and raw_bytes): if not raw_bytes:
return blocks return blocks
default_prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39; default_prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;
@@ -338,6 +372,8 @@ el.replaceWith(d);
except Exception: except Exception:
prompt_re = re.compile(re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, default_prompt)) prompt_re = re.compile(re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, default_prompt))
parsed_positions = []
if cmd_byte_positions and len(cmd_byte_positions) &gt;= 1:
for i in range(1, len(cmd_byte_positions)): for i in range(1, len(cmd_byte_positions)):
pos, known_cmd = cmd_byte_positions[i] pos, known_cmd = cmd_byte_positions[i]
prev_pos = cmd_byte_positions[i-1][0] prev_pos = cmd_byte_positions[i-1][0]
@@ -348,7 +384,7 @@ el.replaceWith(d);
prev_lines = [l for l in prev_cleaned.split(&#39;\n&#39;) if l.strip()] prev_lines = [l for l in prev_cleaned.split(&#39;\n&#39;) if l.strip()]
prompt_text = prev_lines[-1].strip() if prev_lines else &#34;&#34; prompt_text = prev_lines[-1].strip() if prev_lines else &#34;&#34;
preview = f&#34;{prompt_text}{known_cmd}&#34; if prompt_text else known_cmd preview = f&#34;{prompt_text}{known_cmd}&#34; if prompt_text else known_cmd
blocks.append((pos, preview[:80])) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview[:80]})
else: else:
chunk = raw_bytes[prev_pos:pos] chunk = raw_bytes[prev_pos:pos]
cleaned = log_cleaner(chunk.decode(errors=&#39;replace&#39;)) cleaned = log_cleaner(chunk.decode(errors=&#39;replace&#39;))
@@ -360,7 +396,39 @@ el.replaceWith(d);
if match: if match:
cmd_text = preview[match.end():].strip() cmd_text = preview[match.end():].strip()
if cmd_text: if cmd_text:
blocks.append((pos, preview[:80])) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview[:80]})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;EMPTY_PROMPT&#34;, &#34;preview&#34;: &#34;&#34;})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
last_newline = raw_bytes.rfind(b&#39;\n&#39;)
current_prompt_pos = last_newline + 1 if last_newline != -1 else 0
current_end = len(raw_bytes)
for i, item in enumerate(parsed_positions):
if item[&#34;type&#34;] == &#34;VALID_CMD&#34;:
start_pos = item[&#34;pos&#34;]
preview = item[&#34;preview&#34;]
# Find the end position: next VALID_CMD or EMPTY_PROMPT
end_pos = current_prompt_pos
for j in range(i + 1, len(parsed_positions)):
next_item = parsed_positions[j]
if next_item[&#34;type&#34;] in (&#34;VALID_CMD&#34;, &#34;EMPTY_PROMPT&#34;):
end_pos = next_item[&#34;pos&#34;]
break
blocks.append((start_pos, end_pos, preview))
# Always ensure there is a final block representing the current prompt
if not blocks:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else &#34;CURRENT CONTEXT&#34;))
elif blocks[-1][0] &lt; current_prompt_pos:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else &#34;CURRENT CONTEXT&#34;))
return blocks</code></pre> return blocks</code></pre>
</details> </details>
<div class="desc"><p>Identifies command blocks in the terminal history.</p></div> <div class="desc"><p>Identifies command blocks in the terminal history.</p></div>
+77 -9
View File
@@ -113,10 +113,10 @@ el.replaceWith(d);
<pre><code class="python">class AIService(BaseService): <pre><code class="python">class AIService(BaseService):
&#34;&#34;&#34;Business logic for interacting with AI agents and LLM configurations.&#34;&#34;&#34; &#34;&#34;&#34;Business logic for interacting with AI agents and LLM configurations.&#34;&#34;&#34;
def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict) -&gt; list: def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict, last_line: str = &#34;&#34;) -&gt; list:
&#34;&#34;&#34;Identifies command blocks in the terminal history.&#34;&#34;&#34; &#34;&#34;&#34;Identifies command blocks in the terminal history.&#34;&#34;&#34;
blocks = [] blocks = []
if not (cmd_byte_positions and len(cmd_byte_positions) &gt;= 2 and raw_bytes): if not raw_bytes:
return blocks return blocks
default_prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39; default_prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;
@@ -127,6 +127,8 @@ el.replaceWith(d);
except Exception: except Exception:
prompt_re = re.compile(re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, default_prompt)) prompt_re = re.compile(re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, default_prompt))
parsed_positions = []
if cmd_byte_positions and len(cmd_byte_positions) &gt;= 1:
for i in range(1, len(cmd_byte_positions)): for i in range(1, len(cmd_byte_positions)):
pos, known_cmd = cmd_byte_positions[i] pos, known_cmd = cmd_byte_positions[i]
prev_pos = cmd_byte_positions[i-1][0] prev_pos = cmd_byte_positions[i-1][0]
@@ -137,7 +139,7 @@ el.replaceWith(d);
prev_lines = [l for l in prev_cleaned.split(&#39;\n&#39;) if l.strip()] prev_lines = [l for l in prev_cleaned.split(&#39;\n&#39;) if l.strip()]
prompt_text = prev_lines[-1].strip() if prev_lines else &#34;&#34; prompt_text = prev_lines[-1].strip() if prev_lines else &#34;&#34;
preview = f&#34;{prompt_text}{known_cmd}&#34; if prompt_text else known_cmd preview = f&#34;{prompt_text}{known_cmd}&#34; if prompt_text else known_cmd
blocks.append((pos, preview[:80])) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview[:80]})
else: else:
chunk = raw_bytes[prev_pos:pos] chunk = raw_bytes[prev_pos:pos]
cleaned = log_cleaner(chunk.decode(errors=&#39;replace&#39;)) cleaned = log_cleaner(chunk.decode(errors=&#39;replace&#39;))
@@ -149,7 +151,39 @@ el.replaceWith(d);
if match: if match:
cmd_text = preview[match.end():].strip() cmd_text = preview[match.end():].strip()
if cmd_text: if cmd_text:
blocks.append((pos, preview[:80])) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview[:80]})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;EMPTY_PROMPT&#34;, &#34;preview&#34;: &#34;&#34;})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
last_newline = raw_bytes.rfind(b&#39;\n&#39;)
current_prompt_pos = last_newline + 1 if last_newline != -1 else 0
current_end = len(raw_bytes)
for i, item in enumerate(parsed_positions):
if item[&#34;type&#34;] == &#34;VALID_CMD&#34;:
start_pos = item[&#34;pos&#34;]
preview = item[&#34;preview&#34;]
# Find the end position: next VALID_CMD or EMPTY_PROMPT
end_pos = current_prompt_pos
for j in range(i + 1, len(parsed_positions)):
next_item = parsed_positions[j]
if next_item[&#34;type&#34;] in (&#34;VALID_CMD&#34;, &#34;EMPTY_PROMPT&#34;):
end_pos = next_item[&#34;pos&#34;]
break
blocks.append((start_pos, end_pos, preview))
# Always ensure there is a final block representing the current prompt
if not blocks:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else &#34;CURRENT CONTEXT&#34;))
elif blocks[-1][0] &lt; current_prompt_pos:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else &#34;CURRENT CONTEXT&#34;))
return blocks return blocks
def process_copilot_input(self, input_text: str, session_state: dict) -&gt; dict: def process_copilot_input(self, input_text: str, session_state: dict) -&gt; dict:
@@ -372,17 +406,17 @@ el.replaceWith(d);
<div class="desc"><p>Ask the AI copilot for terminal assistance.</p></div> <div class="desc"><p>Ask the AI copilot for terminal assistance.</p></div>
</dd> </dd>
<dt id="connpy.services.AIService.build_context_blocks"><code class="name flex"> <dt id="connpy.services.AIService.build_context_blocks"><code class="name flex">
<span>def <span class="ident">build_context_blocks</span></span>(<span>self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict) > list</span> <span>def <span class="ident">build_context_blocks</span></span>(<span>self,<br>raw_bytes: bytes,<br>cmd_byte_positions: list,<br>node_info: dict,<br>last_line: str = '') > list</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
<summary> <summary>
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict) -&gt; list: <pre><code class="python">def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict, last_line: str = &#34;&#34;) -&gt; list:
&#34;&#34;&#34;Identifies command blocks in the terminal history.&#34;&#34;&#34; &#34;&#34;&#34;Identifies command blocks in the terminal history.&#34;&#34;&#34;
blocks = [] blocks = []
if not (cmd_byte_positions and len(cmd_byte_positions) &gt;= 2 and raw_bytes): if not raw_bytes:
return blocks return blocks
default_prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39; default_prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;
@@ -393,6 +427,8 @@ el.replaceWith(d);
except Exception: except Exception:
prompt_re = re.compile(re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, default_prompt)) prompt_re = re.compile(re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, default_prompt))
parsed_positions = []
if cmd_byte_positions and len(cmd_byte_positions) &gt;= 1:
for i in range(1, len(cmd_byte_positions)): for i in range(1, len(cmd_byte_positions)):
pos, known_cmd = cmd_byte_positions[i] pos, known_cmd = cmd_byte_positions[i]
prev_pos = cmd_byte_positions[i-1][0] prev_pos = cmd_byte_positions[i-1][0]
@@ -403,7 +439,7 @@ el.replaceWith(d);
prev_lines = [l for l in prev_cleaned.split(&#39;\n&#39;) if l.strip()] prev_lines = [l for l in prev_cleaned.split(&#39;\n&#39;) if l.strip()]
prompt_text = prev_lines[-1].strip() if prev_lines else &#34;&#34; prompt_text = prev_lines[-1].strip() if prev_lines else &#34;&#34;
preview = f&#34;{prompt_text}{known_cmd}&#34; if prompt_text else known_cmd preview = f&#34;{prompt_text}{known_cmd}&#34; if prompt_text else known_cmd
blocks.append((pos, preview[:80])) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview[:80]})
else: else:
chunk = raw_bytes[prev_pos:pos] chunk = raw_bytes[prev_pos:pos]
cleaned = log_cleaner(chunk.decode(errors=&#39;replace&#39;)) cleaned = log_cleaner(chunk.decode(errors=&#39;replace&#39;))
@@ -415,7 +451,39 @@ el.replaceWith(d);
if match: if match:
cmd_text = preview[match.end():].strip() cmd_text = preview[match.end():].strip()
if cmd_text: if cmd_text:
blocks.append((pos, preview[:80])) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview[:80]})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;EMPTY_PROMPT&#34;, &#34;preview&#34;: &#34;&#34;})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
last_newline = raw_bytes.rfind(b&#39;\n&#39;)
current_prompt_pos = last_newline + 1 if last_newline != -1 else 0
current_end = len(raw_bytes)
for i, item in enumerate(parsed_positions):
if item[&#34;type&#34;] == &#34;VALID_CMD&#34;:
start_pos = item[&#34;pos&#34;]
preview = item[&#34;preview&#34;]
# Find the end position: next VALID_CMD or EMPTY_PROMPT
end_pos = current_prompt_pos
for j in range(i + 1, len(parsed_positions)):
next_item = parsed_positions[j]
if next_item[&#34;type&#34;] in (&#34;VALID_CMD&#34;, &#34;EMPTY_PROMPT&#34;):
end_pos = next_item[&#34;pos&#34;]
break
blocks.append((start_pos, end_pos, preview))
# Always ensure there is a final block representing the current prompt
if not blocks:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else &#34;CURRENT CONTEXT&#34;))
elif blocks[-1][0] &lt; current_prompt_pos:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else &#34;CURRENT CONTEXT&#34;))
return blocks</code></pre> return blocks</code></pre>
</details> </details>
<div class="desc"><p>Identifies command blocks in the terminal history.</p></div> <div class="desc"><p>Identifies command blocks in the terminal history.</p></div>
+27 -7
View File
@@ -75,14 +75,34 @@ el.replaceWith(d);
elif token in (&#39;\b&#39;, &#39;\x7f&#39;): elif token in (&#39;\b&#39;, &#39;\x7f&#39;):
if cursor &gt; 0: if cursor &gt; 0:
cursor -= 1 cursor -= 1
elif token == &#39;\x1B[D&#39;: # Left Arrow elif token.startswith(&#39;\x1B[&#39;) and len(token) &gt;= 3:
if cursor &gt; 0: # Parse CSI: \x1B[ &lt;params&gt; &lt;final_char&gt;
cursor -= 1 final = token[-1]
elif token == &#39;\x1B[C&#39;: # Right Arrow param_str = token[2:-1]
if cursor &lt; len(buffer): n = int(param_str) if param_str.isdigit() else 1
cursor += 1
elif token == &#39;\x1B[K&#39;: # Clear to end of line if final == &#39;D&#39;: # CUB Cursor Back
cursor = max(0, cursor - n)
elif final == &#39;C&#39;: # CUF Cursor Forward
cursor = min(len(buffer), cursor + n)
elif final == &#39;K&#39;: # EL Erase in Line
if n == 0 or param_str == &#39;&#39;: # Clear to end
buffer = buffer[:cursor] buffer = buffer[:cursor]
elif n == 1: # Clear to start
buffer[:cursor] = [&#39; &#39;] * cursor
elif n == 2: # Clear entire line
buffer = []
cursor = 0
elif final == &#39;G&#39;: # CHA Cursor Horizontal Absolute (1-indexed)
cursor = max(0, n - 1)
# Pad buffer if cursor is beyond current length
if cursor &gt; len(buffer):
buffer.extend([&#39; &#39;] * (cursor - len(buffer)))
elif final == &#39;P&#39;: # DCH Delete Characters
del buffer[cursor:cursor + n]
elif final == &#39;@&#39;: # ICH Insert Characters
buffer[cursor:cursor] = [&#39; &#39;] * n
# All other CSI sequences are silently discarded
elif token.startswith(&#39;\x1B&#39;): elif token.startswith(&#39;\x1B&#39;):
continue continue
elif len(token) == 1 and ord(token) &lt; 32: elif len(token) == 1 and ord(token) &lt; 32:
+1 -1
View File
@@ -60,4 +60,4 @@ console_scripts =
connpy = connpy =
core_plugins/* core_plugins/*
proto/* proto/*
grpc/*.proto grpc_layer/*