feat(copilot): implement 5-turn chat history & internal agent notes (<notes>) for CLI & Command Center (v6.2.0)

- Add multi-turn conversation memory (sliding window of last 5 turns / 10 messages) for Terminal Copilot.
- Introduce <notes> XML tag in Copilot response schema for tracking internal agent reasoning, tool usage, and key facts across turns.
- Instruct Copilot via System Prompt about the 5-turn pruning window and mandate summarizing critical facts in <notes>.
- Extend Command Center frontend (types.ts, useAISession.ts, AIPanel.tsx) with expandable "🧠 Internal Agent Notes / Memory" accordion and browser console response debug logging.
- Ensure 100% transparent serialization across local CLI execution, remote gRPC streams, and Web UI.
- Update documentation and bump version to v6.2.0.
This commit is contained in:
2026-08-13 18:49:59 -03:00
parent 32fe18d27a
commit 3637d34f6f
9 changed files with 161 additions and 54 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
</p>
# Connpy (v6.1.1)
# Connpy (v6.2.0)
[![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/)
+1 -1
View File
@@ -5,7 +5,7 @@
</p>
# Connpy (v6.1.1)
# Connpy (v6.2.0)
[![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/)
+1 -1
View File
@@ -1 +1 @@
__version__ = "6.1.1"
__version__ = "6.2.0"
+26 -4
View File
@@ -1394,6 +1394,9 @@ Rules:
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:
<notes>
Short internal notes for future context: tools used, key findings, IP addresses, or hypotheses.
</notes>
<guide>
Your brief tactical guide in markdown.
</guide>
@@ -1402,7 +1405,8 @@ Your brief tactical guide in markdown.
<risk>
low
</risk>
8. Risk level is usually "low" for read-only/no commands.
8. CRITICAL CONVERSATION MEMORY: The chat history window retains ONLY the last 5 interactions. Messages older than 5 turns are automatically pruned. You MUST use your <notes> tag in EVERY response to record all critical facts, IP addresses, discoveries, and hypotheses. Because your <notes> are preserved in the assistant history, summarizing key facts in <notes> ensures you never lose vital context when older turns expire.
9. Risk level is usually "low" for read-only/no commands.
Terminal Context:
{terminal_buffer}
@@ -1419,6 +1423,9 @@ Rules:
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:
<notes>
Short internal notes for future context: tools used, key findings, IP addresses, or hypotheses.
</notes>
<guide>
Your brief tactical guide in markdown. 3-4 sentences max.
</guide>
@@ -1429,7 +1436,8 @@ command 2
<risk>
low, high, or destructive
</risk>
8. Risk level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops.
8. CRITICAL CONVERSATION MEMORY: The chat history window retains ONLY the last 5 interactions. Messages older than 5 turns are automatically pruned. You MUST use your <notes> tag in EVERY response to record all critical facts, IP addresses, discoveries, and hypotheses. Because your <notes> are preserved in the assistant history, summarizing key facts in <notes> ensures you never lose vital context when older turns expire.
9. Risk level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops.
Terminal Context:
{terminal_buffer}
@@ -1457,10 +1465,18 @@ Node: {node_name}"""
system_prompt += "\nUse these tools to validate syntax or find exact commands if needed before providing the final guide."
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_question}
{"role": "system", "content": system_prompt}
]
chat_history = node_info.get("chat_history", []) if node_info else []
if chat_history:
clean_history = chat_history[-10:]
for msg in clean_history:
if isinstance(msg, dict) and "role" in msg and "content" in msg:
messages.append({"role": msg["role"], "content": msg["content"]})
messages.append({"role": "user", "content": user_question})
iteration = 0
max_iterations = 5 # Allow up to 5 iterations for tool usage
@@ -1582,6 +1598,11 @@ Node: {node_name}"""
chunk_callback(new_text)
streamed_guide += new_text
notes = ""
notes_match = re.search(r"<notes>(.*?)</notes>", full_content, re.DOTALL)
if notes_match:
notes = notes_match.group(1).strip()
guide = ""
commands = []
risk_level = "low"
@@ -1606,6 +1627,7 @@ Node: {node_name}"""
return {
"commands": commands,
"guide": guide,
"notes": notes,
"risk_level": risk_level,
"error": None
}
+13 -8
View File
@@ -36,6 +36,7 @@ class CopilotInterface:
self.session_state.setdefault('persona', 'engineer')
self.session_state.setdefault('trust_mode', False)
self.session_state.setdefault('memories', [])
self.session_state.setdefault('copilot_chat_history', [])
self.session_state.setdefault('os', None)
self.session_state.setdefault('prompt', None)
self.session_state.setdefault('context_mode', self.mode_range)
@@ -382,18 +383,11 @@ class CopilotInterface:
merged_node_info['persona'] = self.session_state['persona']
merged_node_info['trust'] = self.session_state['trust_mode']
merged_node_info['memories'] = list(self.session_state['memories'])
merged_node_info['chat_history'] = list(self.session_state.get('copilot_chat_history', []))
for k, v in overrides.items():
merged_node_info[k] = v
# Enrich question
past = self.history.get_strings()
if len(past) > 1:
clean_past = [q for q in past[-6:-1] if not q.startswith('/')]
if clean_past:
history_text = "\n".join(f"- {q}" for q in clean_past)
clean_question = f"Previous questions:\n{history_text}\n\nCurrent Question:\n{clean_question}"
# 3. AI Execution
# Use persona from overrides (one-shot) or from session state
active_persona = merged_node_info.get('persona', self.session_state.get('persona', 'engineer'))
@@ -461,6 +455,17 @@ class CopilotInterface:
from rich.markdown import Markdown
self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color))
# Update copilot_chat_history with clean Q&A turn
if result and not result.get("error"):
guide = result.get("guide", "")
notes = result.get("notes", "")
if guide:
asst_msg = f"Notes: {notes}\nGuide: {guide}" if notes else guide
hist = self.session_state.setdefault("copilot_chat_history", [])
hist.append({"role": "user", "content": clean_question})
hist.append({"role": "assistant", "content": asst_msg})
self.session_state["copilot_chat_history"] = hist[-10:]
commands = result.get("commands", [])
if not commands:
self.console.print("")
+40 -13
View File
@@ -27,22 +27,21 @@ def mock_acompletion():
with patch('litellm.acompletion') as mock:
yield mock
class MockDelta:
def __init__(self, content):
self.content = content
class MockChoice:
def __init__(self, content):
self.delta = MockDelta(content)
class MockChunk:
def __init__(self, content):
self.choices = [MockChoice(content)]
def test_aask_copilot_tool_call(mock_acompletion):
agent = ai(DummyConfig())
# Setup mock response for streaming
class MockDelta:
def __init__(self, content):
self.content = content
class MockChoice:
def __init__(self, content):
self.delta = MockDelta(content)
class MockChunk:
def __init__(self, content):
self.choices = [MockChoice(content)]
# acompletion is awaited and returns an async iterator
async def mock_ac(*args, **kwargs):
return MockAsyncIterator([
@@ -557,6 +556,34 @@ def test_copilot_single_mode_retains_command_block():
assert interface_custom.session_state.get('context_cmd') == 4
def test_aask_copilot_notes_parsing(mock_acompletion):
agent = ai(DummyConfig())
async def mock_ac(*args, **kwargs):
return MockAsyncIterator([
MockChunk("<notes>Tool used: mcp_cisco__search. MTU mismatch suspected.</notes>"),
MockChunk("<guide>Check MTU on eth0.</guide>"),
MockChunk("<risk>low</risk>")
])
mock_acompletion.side_effect = mock_ac
async def run_test():
return await agent.aask_copilot("Router#", "Why is ping failing?", node_info={"chat_history": [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}]})
result = asyncio.run(run_test())
assert result["error"] is None
assert result["notes"] == "Tool used: mcp_cisco__search. MTU mismatch suspected."
assert result["guide"] == "Check MTU on eth0."
# Check that messages passed to acompletion included chat_history
call_args = mock_acompletion.call_args[1]
msgs = call_args["messages"]
assert len(msgs) == 4 # system, user(hello), asst(hi), current_user
assert msgs[1]["content"] == "hello"
assert msgs[2]["content"] == "hi"
assert msgs[3]["content"] == "Why is ping failing?"
+52 -8
View File
@@ -1835,6 +1835,9 @@ Rules:
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;notes&gt;
Short internal notes for future context: tools used, key findings, IP addresses, or hypotheses.
&lt;/notes&gt;
&lt;guide&gt;
Your brief tactical guide in markdown.
&lt;/guide&gt;
@@ -1843,7 +1846,8 @@ Your brief tactical guide in markdown.
&lt;risk&gt;
low
&lt;/risk&gt;
8. Risk level is usually &#34;low&#34; for read-only/no commands.
8. CRITICAL CONVERSATION MEMORY: The chat history window retains ONLY the last 5 interactions. Messages older than 5 turns are automatically pruned. You MUST use your &lt;notes&gt; tag in EVERY response to record all critical facts, IP addresses, discoveries, and hypotheses. Because your &lt;notes&gt; are preserved in the assistant history, summarizing key facts in &lt;notes&gt; ensures you never lose vital context when older turns expire.
9. Risk level is usually &#34;low&#34; for read-only/no commands.
Terminal Context:
{terminal_buffer}
@@ -1860,6 +1864,9 @@ Rules:
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;notes&gt;
Short internal notes for future context: tools used, key findings, IP addresses, or hypotheses.
&lt;/notes&gt;
&lt;guide&gt;
Your brief tactical guide in markdown. 3-4 sentences max.
&lt;/guide&gt;
@@ -1870,7 +1877,8 @@ command 2
&lt;risk&gt;
low, high, or destructive
&lt;/risk&gt;
8. Risk level: &#34;low&#34; for read-only/no commands, &#34;high&#34; for config changes, &#34;destructive&#34; for potentially dangerous ops.
8. CRITICAL CONVERSATION MEMORY: The chat history window retains ONLY the last 5 interactions. Messages older than 5 turns are automatically pruned. You MUST use your &lt;notes&gt; tag in EVERY response to record all critical facts, IP addresses, discoveries, and hypotheses. Because your &lt;notes&gt; are preserved in the assistant history, summarizing key facts in &lt;notes&gt; ensures you never lose vital context when older turns expire.
9. 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_buffer}
@@ -1898,10 +1906,18 @@ Node: {node_name}&#34;&#34;&#34;
system_prompt += &#34;\nUse these tools to validate syntax or find exact commands if needed before providing the final guide.&#34;
messages = [
{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: system_prompt},
{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: user_question}
{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: system_prompt}
]
chat_history = node_info.get(&#34;chat_history&#34;, []) if node_info else []
if chat_history:
clean_history = chat_history[-10:]
for msg in clean_history:
if isinstance(msg, dict) and &#34;role&#34; in msg and &#34;content&#34; in msg:
messages.append({&#34;role&#34;: msg[&#34;role&#34;], &#34;content&#34;: msg[&#34;content&#34;]})
messages.append({&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: user_question})
iteration = 0
max_iterations = 5 # Allow up to 5 iterations for tool usage
@@ -2023,6 +2039,11 @@ Node: {node_name}&#34;&#34;&#34;
chunk_callback(new_text)
streamed_guide += new_text
notes = &#34;&#34;
notes_match = re.search(r&#34;&lt;notes&gt;(.*?)&lt;/notes&gt;&#34;, full_content, re.DOTALL)
if notes_match:
notes = notes_match.group(1).strip()
guide = &#34;&#34;
commands = []
risk_level = &#34;low&#34;
@@ -2047,6 +2068,7 @@ Node: {node_name}&#34;&#34;&#34;
return {
&#34;commands&#34;: commands,
&#34;guide&#34;: guide,
&#34;notes&#34;: notes,
&#34;risk_level&#34;: risk_level,
&#34;error&#34;: None
}
@@ -2173,6 +2195,9 @@ Rules:
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;notes&gt;
Short internal notes for future context: tools used, key findings, IP addresses, or hypotheses.
&lt;/notes&gt;
&lt;guide&gt;
Your brief tactical guide in markdown.
&lt;/guide&gt;
@@ -2181,7 +2206,8 @@ Your brief tactical guide in markdown.
&lt;risk&gt;
low
&lt;/risk&gt;
8. Risk level is usually &#34;low&#34; for read-only/no commands.
8. CRITICAL CONVERSATION MEMORY: The chat history window retains ONLY the last 5 interactions. Messages older than 5 turns are automatically pruned. You MUST use your &lt;notes&gt; tag in EVERY response to record all critical facts, IP addresses, discoveries, and hypotheses. Because your &lt;notes&gt; are preserved in the assistant history, summarizing key facts in &lt;notes&gt; ensures you never lose vital context when older turns expire.
9. Risk level is usually &#34;low&#34; for read-only/no commands.
Terminal Context:
{terminal_buffer}
@@ -2198,6 +2224,9 @@ Rules:
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;notes&gt;
Short internal notes for future context: tools used, key findings, IP addresses, or hypotheses.
&lt;/notes&gt;
&lt;guide&gt;
Your brief tactical guide in markdown. 3-4 sentences max.
&lt;/guide&gt;
@@ -2208,7 +2237,8 @@ command 2
&lt;risk&gt;
low, high, or destructive
&lt;/risk&gt;
8. Risk level: &#34;low&#34; for read-only/no commands, &#34;high&#34; for config changes, &#34;destructive&#34; for potentially dangerous ops.
8. CRITICAL CONVERSATION MEMORY: The chat history window retains ONLY the last 5 interactions. Messages older than 5 turns are automatically pruned. You MUST use your &lt;notes&gt; tag in EVERY response to record all critical facts, IP addresses, discoveries, and hypotheses. Because your &lt;notes&gt; are preserved in the assistant history, summarizing key facts in &lt;notes&gt; ensures you never lose vital context when older turns expire.
9. 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_buffer}
@@ -2236,10 +2266,18 @@ Node: {node_name}&#34;&#34;&#34;
system_prompt += &#34;\nUse these tools to validate syntax or find exact commands if needed before providing the final guide.&#34;
messages = [
{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: system_prompt},
{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: user_question}
{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: system_prompt}
]
chat_history = node_info.get(&#34;chat_history&#34;, []) if node_info else []
if chat_history:
clean_history = chat_history[-10:]
for msg in clean_history:
if isinstance(msg, dict) and &#34;role&#34; in msg and &#34;content&#34; in msg:
messages.append({&#34;role&#34;: msg[&#34;role&#34;], &#34;content&#34;: msg[&#34;content&#34;]})
messages.append({&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: user_question})
iteration = 0
max_iterations = 5 # Allow up to 5 iterations for tool usage
@@ -2361,6 +2399,11 @@ Node: {node_name}&#34;&#34;&#34;
chunk_callback(new_text)
streamed_guide += new_text
notes = &#34;&#34;
notes_match = re.search(r&#34;&lt;notes&gt;(.*?)&lt;/notes&gt;&#34;, full_content, re.DOTALL)
if notes_match:
notes = notes_match.group(1).strip()
guide = &#34;&#34;
commands = []
risk_level = &#34;low&#34;
@@ -2385,6 +2428,7 @@ Node: {node_name}&#34;&#34;&#34;
return {
&#34;commands&#34;: commands,
&#34;guide&#34;: guide,
&#34;notes&#34;: notes,
&#34;risk_level&#34;: risk_level,
&#34;error&#34;: None
}
+25 -16
View File
@@ -70,6 +70,7 @@ el.replaceWith(d);
self.session_state.setdefault(&#39;persona&#39;, &#39;engineer&#39;)
self.session_state.setdefault(&#39;trust_mode&#39;, False)
self.session_state.setdefault(&#39;memories&#39;, [])
self.session_state.setdefault(&#39;copilot_chat_history&#39;, [])
self.session_state.setdefault(&#39;os&#39;, None)
self.session_state.setdefault(&#39;prompt&#39;, None)
self.session_state.setdefault(&#39;context_mode&#39;, self.mode_range)
@@ -416,18 +417,11 @@ el.replaceWith(d);
merged_node_info[&#39;persona&#39;] = self.session_state[&#39;persona&#39;]
merged_node_info[&#39;trust&#39;] = self.session_state[&#39;trust_mode&#39;]
merged_node_info[&#39;memories&#39;] = list(self.session_state[&#39;memories&#39;])
merged_node_info[&#39;chat_history&#39;] = list(self.session_state.get(&#39;copilot_chat_history&#39;, []))
for k, v in overrides.items():
merged_node_info[k] = v
# Enrich question
past = self.history.get_strings()
if len(past) &gt; 1:
clean_past = [q for q in past[-6:-1] if not q.startswith(&#39;/&#39;)]
if clean_past:
history_text = &#34;\n&#34;.join(f&#34;- {q}&#34; for q in clean_past)
clean_question = f&#34;Previous questions:\n{history_text}\n\nCurrent Question:\n{clean_question}&#34;
# 3. AI Execution
# 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;))
@@ -495,6 +489,17 @@ el.replaceWith(d);
from rich.markdown import Markdown
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))
# Update copilot_chat_history with clean Q&amp;A turn
if result and not result.get(&#34;error&#34;):
guide = result.get(&#34;guide&#34;, &#34;&#34;)
notes = result.get(&#34;notes&#34;, &#34;&#34;)
if guide:
asst_msg = f&#34;Notes: {notes}\nGuide: {guide}&#34; if notes else guide
hist = self.session_state.setdefault(&#34;copilot_chat_history&#34;, [])
hist.append({&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: clean_question})
hist.append({&#34;role&#34;: &#34;assistant&#34;, &#34;content&#34;: asst_msg})
self.session_state[&#34;copilot_chat_history&#34;] = hist[-10:]
commands = result.get(&#34;commands&#34;, [])
if not commands:
self.console.print(&#34;&#34;)
@@ -922,18 +927,11 @@ el.replaceWith(d);
merged_node_info[&#39;persona&#39;] = self.session_state[&#39;persona&#39;]
merged_node_info[&#39;trust&#39;] = self.session_state[&#39;trust_mode&#39;]
merged_node_info[&#39;memories&#39;] = list(self.session_state[&#39;memories&#39;])
merged_node_info[&#39;chat_history&#39;] = list(self.session_state.get(&#39;copilot_chat_history&#39;, []))
for k, v in overrides.items():
merged_node_info[k] = v
# Enrich question
past = self.history.get_strings()
if len(past) &gt; 1:
clean_past = [q for q in past[-6:-1] if not q.startswith(&#39;/&#39;)]
if clean_past:
history_text = &#34;\n&#34;.join(f&#34;- {q}&#34; for q in clean_past)
clean_question = f&#34;Previous questions:\n{history_text}\n\nCurrent Question:\n{clean_question}&#34;
# 3. AI Execution
# 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;))
@@ -1001,6 +999,17 @@ el.replaceWith(d);
from rich.markdown import Markdown
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))
# Update copilot_chat_history with clean Q&amp;A turn
if result and not result.get(&#34;error&#34;):
guide = result.get(&#34;guide&#34;, &#34;&#34;)
notes = result.get(&#34;notes&#34;, &#34;&#34;)
if guide:
asst_msg = f&#34;Notes: {notes}\nGuide: {guide}&#34; if notes else guide
hist = self.session_state.setdefault(&#34;copilot_chat_history&#34;, [])
hist.append({&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: clean_question})
hist.append({&#34;role&#34;: &#34;assistant&#34;, &#34;content&#34;: asst_msg})
self.session_state[&#34;copilot_chat_history&#34;] = hist[-10:]
commands = result.get(&#34;commands&#34;, [])
if not commands:
self.console.print(&#34;&#34;)
+2 -2
View File
@@ -41,7 +41,7 @@ el.replaceWith(d);
<p align="center">
<img src="https://nginx.gederico.dynu.net/images/CONNPY-resized.png" alt="App Logo">
</p>
<h1 id="connpy-v611">Connpy (v6.1.1)</h1>
<h1 id="connpy-v620">Connpy (v6.2.0)</h1>
<p><a href="https://pypi.org/pypi/connpy/"><img alt="" src="https://img.shields.io/pypi/v/connpy.svg?style=flat-square"></a>
<a href="https://pypi.org/pypi/connpy/"><img alt="" src="https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square"></a>
<a href="https://pypi.org/pypi/connpy/"><img alt="" src="https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&amp;cacheSeconds=86400"></a>
@@ -4087,7 +4087,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
<nav id="sidebar">
<div class="toc">
<ul>
<li><a href="#connpy-v611">Connpy (v6.1.1)</a><ul>
<li><a href="#connpy-v620">Connpy (v6.2.0)</a><ul>
<li><a href="#1-ai-system">1. 🤖 AI System</a><ul>
<li><a href="#1a-terminal-copilot-ctrlspace">1a. Terminal Copilot (Ctrl+Space)</a></li>
<li><a href="#1b-ai-chat-conn-ai">1b. AI Chat (conn ai)</a></li>