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

- Bump version to 5.0b6 and regenerate HTML documentation via pdoc3.
- Add persistent AI chat sessions (list, resume, delete) stored locally.
- Fix 'rich' library console rendering and routing 'error()' to stderr.
- Update Architect UI color theme to medium_purple.
- Sanitize caching metadata (cache_control) for compatibility with non-Anthropic models.
- Fix .folder config path redirection mapping and fzf-wrapper compatibility.
- Ensure context plugin correctly filters node lists upon load.
- Inject config instance directly into API components instead of instantiating globally.
- Fix edge-case in plugin loading preventing startup when folder is missing.
- Add comprehensive test coverage for printer module and AI sessions.
This commit is contained in:
2026-04-06 15:52:09 -03:00
parent af85051eb7
commit 85b23526cd
23 changed files with 1092 additions and 263 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "5.0b5" __version__ = "5.0b6"
+174 -45
View File
@@ -13,11 +13,11 @@ litellm.set_verbose = False
from .hooks import ClassHook, MethodHook from .hooks import ClassHook, MethodHook
from . import printer from . import printer
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.text import Text from rich.text import Text
console = Console() console = printer.console
@ClassHook @ClassHook
class ai: class ai:
@@ -62,7 +62,7 @@ class ai:
self.architect_prompt_extensions = [] # Extra text for architect prompt self.architect_prompt_extensions = [] # Extra text for architect prompt
# Long-term memory # Long-term memory
self.memory_path = os.path.expanduser("~/.config/conn/ai_memory.md") self.memory_path = os.path.join(self.config.defaultdir, "ai_memory.md")
self.long_term_memory = "" self.long_term_memory = ""
if os.path.exists(self.memory_path): if os.path.exists(self.memory_path):
try: try:
@@ -75,6 +75,12 @@ class ai:
except Exception as e: except Exception as e:
console.print(f"[yellow]Warning: Failed to load AI memory: {e}[/yellow]") console.print(f"[yellow]Warning: Failed to load AI memory: {e}[/yellow]")
# Session Management
self.sessions_dir = os.path.join(self.config.defaultdir, "ai_sessions")
os.makedirs(self.sessions_dir, exist_ok=True)
self.session_id = None
self.session_path = None
# Prompts base agnósticos # Prompts base agnósticos
self._engineer_base_prompt = dedent(f""" self._engineer_base_prompt = dedent(f"""
Role: TECHNICAL EXECUTION ENGINE. Role: TECHNICAL EXECUTION ENGINE.
@@ -190,7 +196,7 @@ class ai:
# 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"
border = "purple" if "architect" in label.lower() else "blue" border = "medium_purple" if "architect" in label.lower() else "blue"
title = f"[bold {border}]{role_label}[/bold {border}]" title = f"[bold {border}]{role_label}[/bold {border}]"
try: try:
@@ -290,14 +296,34 @@ class ai:
2. No user/system messages appear between tool_calls and tool responses 2. No user/system messages appear between tool_calls and tool responses
3. Orphaned tool_calls at the end are removed 3. Orphaned tool_calls at the end are removed
4. Orphaned tool responses without a preceding tool_call are removed 4. Orphaned tool responses without a preceding tool_call are removed
5. Incompatible metadata like cache_control is stripped for non-Anthropic models
""" """
if not messages: if not messages:
return messages return messages
# Pre-process messages to pull text from list contents (Anthropic cache format)
# and remove explicit cache keys.
pre_sanitized = []
for msg in messages:
m = msg.copy() if isinstance(msg, dict) else msg.model_dump(exclude_none=True)
# Convert content list to plain string if it's a system message with caching metadata
if m.get('role') == 'system' and isinstance(m.get('content'), list):
# Extraer texto de [{"type": "text", "text": "...", "cache_control": ...}]
m['content'] = m['content'][0]['text'] if m['content'] else ""
# Remove any explicit cache_control key anywhere
if 'cache_control' in m: del m['cache_control']
if isinstance(m.get('content'), list):
for item in m['content']:
if isinstance(item, dict) and 'cache_control' in item: del item['cache_control']
pre_sanitized.append(m)
sanitized = [] sanitized = []
i = 0 i = 0
while i < len(messages): while i < len(pre_sanitized):
msg = messages[i] msg = pre_sanitized[i]
role = msg.get('role', '') role = msg.get('role', '')
if role == 'assistant' and msg.get('tool_calls'): if role == 'assistant' and msg.get('tool_calls'):
@@ -311,8 +337,8 @@ class ai:
# Look ahead for matching tool responses # Look ahead for matching tool responses
tool_responses = [] tool_responses = []
j = i + 1 j = i + 1
while j < len(messages): while j < len(pre_sanitized):
next_msg = messages[j] next_msg = pre_sanitized[j]
if next_msg.get('role') == 'tool': if next_msg.get('role') == 'tool':
tool_responses.append(next_msg) tool_responses.append(next_msg)
j += 1 j += 1
@@ -470,23 +496,16 @@ class ai:
def _engineer_loop(self, task, status=None, debug=False, chat_history=None): def _engineer_loop(self, task, status=None, debug=False, chat_history=None):
"""Internal loop where the Engineer executes technical tasks for the Architect.""" """Internal loop where the Engineer executes technical tasks for the Architect."""
# Optimización de caché para el Ingeniero # Optimización de caché para el Ingeniero (Solo para Anthropic directo, Vertex tiene reglas distintas)
if "claude" in self.engineer_model.lower(): if "claude" in self.engineer_model.lower() and "vertex" not in self.engineer_model.lower():
messages = [{"role": "system", "content": [{"type": "text", "text": self.engineer_system_prompt, "cache_control": {"type": "ephemeral"}}]}] messages = [{"role": "system", "content": [{"type": "text", "text": self.engineer_system_prompt, "cache_control": {"type": "ephemeral"}}]}]
else: else:
messages = [{"role": "system", "content": self.engineer_system_prompt}] messages = [{"role": "system", "content": self.engineer_system_prompt}]
if chat_history: if chat_history:
# Clean chat history from caching metadata if engineer is not Claude # Clean chat history from caching metadata if engineer is not a compatible Claude model
if "claude" not in self.engineer_model.lower(): if "claude" not in self.engineer_model.lower() or "vertex" in self.engineer_model.lower():
cleaned_history = [] messages.extend(self._sanitize_messages(chat_history[-5:]))
for msg in chat_history[-5:]:
m = msg if isinstance(msg, dict) else msg.model_dump(exclude_none=True)
# Remove cache_control from system messages
if m.get('role') == 'system' and isinstance(m.get('content'), list):
m['content'] = m['content'][0]['text'] if m['content'] else ""
cleaned_history.append(m)
messages.extend(cleaned_history)
else: else:
messages.extend(chat_history[-5:]) messages.extend(chat_history[-5:])
@@ -582,9 +601,125 @@ class ai:
tools.extend(self.external_architect_tools) tools.extend(self.external_architect_tools)
return tools return tools
def _get_sessions(self):
"""Returns a list of session metadata sorted by date."""
sessions = []
if not os.path.exists(self.sessions_dir):
return []
for f in os.listdir(self.sessions_dir):
if f.endswith(".json"):
path = os.path.join(self.sessions_dir, f)
try:
with open(path, "r") as fs:
data = json.load(fs)
sessions.append({
"id": f[:-5],
"title": data.get("title", "Untitled Session"),
"created_at": data.get("created_at", "Unknown"),
"model": data.get("model", "Unknown"),
"path": path
})
except Exception:
continue
return sorted(sessions, key=lambda x: x["created_at"], reverse=True)
def list_sessions(self):
"""Prints a list of sessions using printer.table."""
sessions = self._get_sessions()
if not sessions:
printer.info("No saved AI sessions found.")
return
columns = ["ID", "Title", "Created At", "Model"]
rows = [[s["id"], s["title"], s["created_at"], s["model"]] for s in sessions]
printer.table("AI Persisted Sessions", columns, rows)
def load_session_data(self, session_id):
"""Loads a session's raw data by ID."""
path = os.path.join(self.sessions_dir, f"{session_id}.json")
if os.path.exists(path):
try:
with open(path, "r") as f:
data = json.load(f)
self.session_id = session_id
self.session_path = path
return data
except Exception as e:
printer.error(f"Failed to load session {session_id}: {e}")
return None
def delete_session(self, session_id):
"""Deletes a session by ID."""
path = os.path.join(self.sessions_dir, f"{session_id}.json")
if os.path.exists(path):
os.remove(path)
printer.success(f"Session {session_id} deleted.")
else:
printer.error(f"Session {session_id} not found.")
def get_last_session_id(self):
"""Returns the ID of the most recent session."""
sessions = self._get_sessions()
return sessions[0]["id"] if sessions else None
def _generate_session_id(self, query):
"""Generates a unique session ID based on timestamp."""
return datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
def save_session(self, history, title=None, model=None):
"""Saves current history to the session file."""
if not self.session_id:
# Generate ID from first user query if available
first_user_msg = next((m["content"] for m in history if m["role"] == "user"), "new-session")
self.session_id = self._generate_session_id(first_user_msg)
self.session_path = os.path.join(self.sessions_dir, f"{self.session_id}.json")
# If it's a new file, we might want to set a better title
if not os.path.exists(self.session_path) and not title:
raw_title = next((m["content"] for m in history if m["role"] == "user"), "New Session")
# Clean title: remove newlines, multiple spaces
clean_title = " ".join(raw_title.split())
if len(clean_title) > 40:
title = clean_title[:37].strip() + "..."
else:
title = clean_title
try:
# Read existing metadata if it exists
metadata = {}
if os.path.exists(self.session_path):
with open(self.session_path, "r") as f:
metadata = json.load(f)
metadata.update({
"id": self.session_id,
"title": title or metadata.get("title", "New Session"),
"created_at": metadata.get("created_at", datetime.datetime.now().isoformat()),
"updated_at": datetime.datetime.now().isoformat(),
"model": model or metadata.get("model", self.engineer_model),
"history": history
})
with open(self.session_path, "w") as f:
json.dump(metadata, f, indent=4)
except Exception as e:
printer.error(f"Failed to save session: {e}")
except Exception as e:
printer.error(f"Failed to save session: {e}")
@MethodHook @MethodHook
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True): def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None):
if chat_history is None: chat_history = [] if chat_history is None: chat_history = []
# Load session if provided and history is empty
if session_id and not chat_history:
session_data = self.load_session_data(session_id)
if session_data:
chat_history = session_data.get("history", [])
# If we loaded history, the caller might need it back
# But typically ask() is called in a loop with an external history object
usage = {"input": 0, "output": 0, "total": 0} usage = {"input": 0, "output": 0, "total": 0}
# 1. Selector de Rol inicial (Sticky Brain) # 1. Selector de Rol inicial (Sticky Brain)
@@ -618,15 +753,20 @@ class ai:
model = self.architect_model if current_brain == "architect" else self.engineer_model model = self.architect_model if current_brain == "architect" else self.engineer_model
key = self.architect_key if current_brain == "architect" else self.engineer_key key = self.architect_key if current_brain == "architect" else self.engineer_key
# Estructura optimizada para Prompt Caching # Estructura optimizada para Prompt Caching (Solo para Anthropic directo, Vertex tiene reglas distintas)
if "claude" in model.lower(): if "claude" in model.lower() and "vertex" not in model.lower():
messages = [{"role": "system", "content": [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}]}] messages = [{"role": "system", "content": [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}]}]
else: else:
messages = [{"role": "system", "content": system_prompt}] messages = [{"role": "system", "content": system_prompt}]
# Interleaving de historial # Interleaving de historial
last_role = "system" last_role = "system"
for msg in chat_history[-self.max_history:]: # Sanitize history if the current target model is not compatible with cache_control
history_to_process = chat_history[-self.max_history:]
if "claude" not in model.lower() or "vertex" in model.lower():
history_to_process = self._sanitize_messages(history_to_process)
for msg in history_to_process:
m = msg if isinstance(msg, dict) else msg.model_dump(exclude_none=True) m = msg if isinstance(msg, dict) else msg.model_dump(exclude_none=True)
role = m.get('role') role = m.get('role')
if role == last_role and role == 'user': if role == last_role and role == 'user':
@@ -654,7 +794,7 @@ class ai:
console.print(f"[yellow] You can press Ctrl+C to interrupt and get a summary of progress.[/yellow]") console.print(f"[yellow] You can press Ctrl+C to interrupt and get a summary of progress.[/yellow]")
soft_limit_warned = True soft_limit_warned = True
label = "[bold purple]Architect" if current_brain == "architect" else "[bold blue]Engineer" label = "[bold medium_purple]Architect" if current_brain == "architect" else "[bold blue]Engineer"
if status: status.update(f"{label} is thinking... (step {iteration})") if status: status.update(f"{label} is thinking... (step {iteration})")
streamed_response = False streamed_response = False
@@ -699,7 +839,7 @@ class ai:
messages.append(msg_dict) messages.append(msg_dict)
if debug and resp_msg.content: if debug and resp_msg.content:
console.print(Panel(Markdown(resp_msg.content), title=f"{label} Reasoning", border_style="purple" if current_brain == "architect" else "blue")) console.print(Panel(Markdown(resp_msg.content), title=f"{label} Reasoning", border_style="medium_purple" if current_brain == "architect" else "blue"))
if not resp_msg.tool_calls: break if not resp_msg.tool_calls: break
@@ -716,8 +856,8 @@ class ai:
continue continue
if status: if status:
if fn == "delegate_to_engineer": status.update(f"[bold purple]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...") if fn == "delegate_to_engineer": status.update(f"[bold medium_purple]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
elif fn == "manage_memory_tool": status.update(f"[bold purple]Architect: [UPDATING MEMORY]") elif fn == "manage_memory_tool": status.update(f"[bold medium_purple]Architect: [UPDATING MEMORY]")
if debug: console.print(Panel(Text(json.dumps(args, indent=2)), title=f"{label} Decision: {fn}", border_style="white")) if debug: console.print(Panel(Text(json.dumps(args, indent=2)), title=f"{label} Decision: {fn}", border_style="white"))
@@ -725,7 +865,7 @@ class ai:
obs, eng_usage = self._engineer_loop(args["task"], status=status, debug=debug, chat_history=messages[:-1]) obs, eng_usage = self._engineer_loop(args["task"], status=status, debug=debug, chat_history=messages[:-1])
usage["input"] += eng_usage["input"]; usage["output"] += eng_usage["output"]; usage["total"] += eng_usage["total"] usage["input"] += eng_usage["input"]; usage["output"] += eng_usage["output"]; usage["total"] += eng_usage["total"]
elif fn == "consult_architect": elif fn == "consult_architect":
if status: status.update("[bold purple]Engineer consulting Architect...") if status: status.update("[bold medium_purple]Engineer consulting Architect...")
try: try:
# Consultation only - Engineer stays in control # Consultation only - Engineer stays in control
claude_resp = completion( claude_resp = completion(
@@ -738,13 +878,13 @@ class ai:
num_retries=3 num_retries=3
) )
obs = claude_resp.choices[0].message.content obs = claude_resp.choices[0].message.content
if debug: console.print(Panel(Markdown(obs), title="[bold purple]Architect Consultation[/bold purple]", border_style="purple")) if debug: console.print(Panel(Markdown(obs), title="[bold medium_purple]Architect Consultation[/bold medium_purple]", border_style="medium_purple"))
except Exception as e: except Exception as e:
if status: status.update("[bold orange3]Architect unavailable! Engineer continuing alone...") if status: status.update("[bold orange3]Architect unavailable! Engineer continuing alone...")
obs = f"Architect unavailable ({str(e)}). Proceeding with your best technical judgment." obs = f"Architect unavailable ({str(e)}). Proceeding with your best technical judgment."
elif fn == "escalate_to_architect": elif fn == "escalate_to_architect":
if status: status.update("[bold purple]Transferring control to Architect...") if status: status.update("[bold medium_purple]Transferring control to Architect...")
# Full escalation - Architect takes over # Full escalation - Architect takes over
current_brain = "architect" current_brain = "architect"
model = self.architect_model model = self.architect_model
@@ -755,7 +895,7 @@ class ai:
handover_msg = f"HANDOVER FROM EXECUTION ENGINE\n\nReason: {args['reason']}\n\nContext: {args['context']}\n\nYou are now in control of this conversation." handover_msg = f"HANDOVER FROM EXECUTION ENGINE\n\nReason: {args['reason']}\n\nContext: {args['context']}\n\nYou are now in control of this conversation."
pending_user_message = handover_msg pending_user_message = handover_msg
obs = "Control transferred to Architect. Handover context will be provided." obs = "Control transferred to Architect. Handover context will be provided."
if debug: console.print(Panel(Text(handover_msg), title="[bold purple]Escalation to Architect[/bold purple]", border_style="purple")) if debug: console.print(Panel(Text(handover_msg), title="[bold medium_purple]Escalation to Architect[/bold medium_purple]", border_style="medium_purple"))
elif fn == "return_to_engineer": elif fn == "return_to_engineer":
if status: status.update("[bold blue]Transferring control back to Engineer...") if status: status.update("[bold blue]Transferring control back to Engineer...")
@@ -813,19 +953,8 @@ class ai:
messages.append(resp_msg.model_dump(exclude_none=True)) messages.append(resp_msg.model_dump(exclude_none=True))
except Exception: pass except Exception: pass
finally: finally:
try: # Auto-save session
log_dir = self.config.defaultdir self.save_session(messages, model=model)
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, "ai_debug.json")
hist = []
if os.path.exists(log_path):
try:
with open(log_path, "r") as f: hist = json.load(f)
except (IOError, json.JSONDecodeError): hist = []
hist.append({"timestamp": datetime.datetime.now().isoformat(), "roles": {"strategic_engine": self.architect_model, "execution_engine": self.engineer_model}, "session": messages})
with open(log_path, "w") as f: json.dump(hist[-10:], f, indent=4)
except Exception as e:
if debug: console.print(f"[dim red]Debug log failed: {e}[/dim red]")
return { return {
"response": messages[-1].get("content"), "response": messages[-1].get("content"),
+7 -7
View File
@@ -8,7 +8,7 @@ import signal
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
conf = configfile() # conf = configfile() # REMOVED: Item #1 in Roadmap -> Don't instantiate globally
PID_FILE1 = "/run/connpy.pid" PID_FILE1 = "/run/connpy.pid"
PID_FILE2 = "/tmp/connpy.pid" PID_FILE2 = "/tmp/connpy.pid"
@@ -156,23 +156,23 @@ def stop_api():
return port return port
@hooks.MethodHook @hooks.MethodHook
def debug_api(port=8048): def debug_api(port=8048, config=None):
app.custom_config = configfile() app.custom_config = config or configfile()
app.run(debug=True, port=port) app.run(debug=True, port=port)
@hooks.MethodHook @hooks.MethodHook
def start_server(port=8048): def start_server(port=8048, config=None):
app.custom_config = configfile() app.custom_config = config or configfile()
serve(app, host='0.0.0.0', port=port) serve(app, host='0.0.0.0', port=port)
@hooks.MethodHook @hooks.MethodHook
def start_api(port=8048): def start_api(port=8048, config=None):
if os.path.exists(PID_FILE1) or os.path.exists(PID_FILE2): if os.path.exists(PID_FILE1) or os.path.exists(PID_FILE2):
printer.warning("Connpy server is already running.") printer.warning("Connpy server is already running.")
return return
pid = os.fork() pid = os.fork()
if pid == 0: if pid == 0:
start_server(port) start_server(port, config=config)
else: else:
try: try:
with open(PID_FILE1, "w") as f: with open(PID_FILE1, "w") as f:
+42 -32
View File
@@ -56,27 +56,31 @@ class configfile:
''' '''
home = os.path.expanduser("~") home = os.path.expanduser("~")
defaultdir = home + '/.config/conn' defaultdir = home + '/.config/conn'
self.defaultdir = defaultdir
Path(defaultdir).mkdir(parents=True, exist_ok=True)
Path(f"{defaultdir}/plugins").mkdir(parents=True, exist_ok=True)
pathfile = defaultdir + '/.folder'
try:
with open(pathfile, "r") as f:
configdir = f.read().strip()
except (FileNotFoundError, IOError):
with open(pathfile, "w") as f:
f.write(str(defaultdir))
configdir = defaultdir
defaultfile = configdir + '/config.yaml'
self.cachefile = configdir + '/.config.cache.json'
self.fzf_cachefile = configdir + '/.fzf_nodes_cache.txt'
self.folders_cachefile = configdir + '/.folders_cache.txt'
self.profiles_cachefile = configdir + '/.profiles_cache.txt'
defaultkey = configdir + '/.osk'
if conf == None:
self.file = defaultfile
# Backwards compatibility: Migrate from JSON to YAML if conf is None:
# Standard path: use ~/.config/conn and respect .folder redirection
self.anchor_path = defaultdir
self.defaultdir = defaultdir
Path(defaultdir).mkdir(parents=True, exist_ok=True)
pathfile = defaultdir + '/.folder'
try:
with open(pathfile, "r") as f:
configdir = f.read().strip()
except (FileNotFoundError, IOError):
with open(pathfile, "w") as f:
f.write(str(defaultdir))
configdir = defaultdir
self.defaultdir = configdir
self.file = configdir + '/config.yaml'
self.key = key or (configdir + '/.osk')
# Ensure redirected directories exist
Path(configdir).mkdir(parents=True, exist_ok=True)
Path(f"{configdir}/plugins").mkdir(parents=True, exist_ok=True)
# Backwards compatibility: Migrate from JSON to YAML only for default path
legacy_json = configdir + '/config.json' legacy_json = configdir + '/config.json'
legacy_noext = configdir + '/config' legacy_noext = configdir + '/config'
legacy_file = None legacy_file = None
@@ -99,38 +103,44 @@ class configfile:
os.remove(self.file) os.remove(self.file)
printer.warning("YAML verification failed after migration, keeping legacy config.") printer.warning("YAML verification failed after migration, keeping legacy config.")
else: else:
with open(self.cachefile, 'w') as f: # Note: cachefile is derived later, we use temp one for migration sync
temp_cache = configdir + '/.config.cache.json'
with open(temp_cache, 'w') as f:
json.dump(old_data, f) json.dump(old_data, f)
shutil.move(legacy_file, legacy_file + ".backup") shutil.move(legacy_file, legacy_file + ".backup")
printer.success(f"Migrated legacy config ({len(old_data.get('connections',{}))} folders/nodes) into YAML and Cache successfully!") printer.success(f"Migrated legacy config ({len(old_data.get('connections',{}))} folders/nodes) into YAML and Cache successfully!")
except Exception as e: except Exception as e:
# Clean up partial YAML if it was created
if os.path.exists(self.file): if os.path.exists(self.file):
try: try: os.remove(self.file)
os.remove(self.file) except OSError: pass
except OSError:
pass
printer.warning(f"Failed to migrate legacy config: {e}") printer.warning(f"Failed to migrate legacy config: {e}")
else: else:
self.file = conf # Custom path (common in tests): isolate everything to the conf parent directory
self.file = os.path.abspath(conf)
configdir = os.path.dirname(self.file)
self.anchor_path = configdir
self.defaultdir = configdir
self.key = os.path.abspath(key) if key else (configdir + '/.osk')
if key == None: # Sidecar files always live next to the config file (or in the redirected configdir)
self.key = defaultkey self.cachefile = configdir + '/.config.cache.json'
else: self.fzf_cachefile = configdir + '/.fzf_nodes_cache.txt'
self.key = key self.folders_cachefile = configdir + '/.folders_cache.txt'
self.profiles_cachefile = configdir + '/.profiles_cache.txt'
if os.path.exists(self.file): if os.path.exists(self.file):
config = self._loadconfig(self.file) config = self._loadconfig(self.file)
else: else:
config = self._createconfig(self.file) config = self._createconfig(self.file)
self.config = config["config"] self.config = config["config"]
self.connections = config["connections"] self.connections = config["connections"]
self.profiles = config["profiles"] self.profiles = config["profiles"]
if not os.path.exists(self.key): if not os.path.exists(self.key):
self._createkey(self.key) self._createkey(self.key)
with open(self.key) as f: with open(self.key) as f:
self.privatekey = RSA.import_key(f.read()) self.privatekey = RSA.import_key(f.read())
f.close()
self.publickey = self.privatekey.publickey() self.publickey = self.privatekey.publickey()
# Self-heal text caches if they are missing # Self-heal text caches if they are missing
+68 -28
View File
@@ -18,14 +18,15 @@ class NoAliasDumper(yaml.SafeDumper):
def ignore_aliases(self, data): def ignore_aliases(self, data):
return True return True
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.console import Console, Group from rich.markdown import Markdown
from rich.panel import Panel from rich.panel import Panel
from rich.text import Text from rich.text import Text
from rich.rule import Rule from rich.rule import Rule
from rich.style import Style from rich.style import Style
from rich.prompt import Prompt from rich.prompt import Prompt
mdprint = Console().print mdprint = printer.console.print
console = Console() console = printer.console
try: try:
from pyfzf.pyfzf import FzfPrompt from pyfzf.pyfzf import FzfPrompt
except ImportError: except ImportError:
@@ -135,6 +136,10 @@ class connapp:
aiparser.add_argument("--architect-model", nargs=1, help="Override architect model") aiparser.add_argument("--architect-model", nargs=1, help="Override architect model")
aiparser.add_argument("--architect-api-key", nargs=1, help="Override architect api key") aiparser.add_argument("--architect-api-key", nargs=1, help="Override architect api key")
aiparser.add_argument("--debug", action="store_true", help="Show AI reasoning and tool calls") aiparser.add_argument("--debug", action="store_true", help="Show AI reasoning and tool calls")
aiparser.add_argument("--list", "--list-sessions", dest="list_sessions", action="store_true", help="List saved AI sessions")
aiparser.add_argument("--session", nargs=1, help="Resume a specific AI session by ID")
aiparser.add_argument("--resume", action="store_true", help="Resume the most recent AI session")
aiparser.add_argument("--delete", "--delete-session", dest="delete_session", nargs=1, help="Delete an AI session by ID")
aiparser.set_defaults(func=self._func_ai) aiparser.set_defaults(func=self._func_ai)
#RUNPARSER #RUNPARSER
runparser = subparsers.add_parser("run", description="Run scripts or commands on nodes", formatter_class=argparse.RawTextHelpFormatter) runparser = subparsers.add_parser("run", description="Run scripts or commands on nodes", formatter_class=argparse.RawTextHelpFormatter)
@@ -188,8 +193,10 @@ class connapp:
for preload in self.plugins.preloads.values(): for preload in self.plugins.preloads.values():
preload.Preload(self) preload.Preload(self)
if not os.path.exists(self.config.fzf_cachefile): # Update internal state and force cache generation after all preloads
self.config._generate_nodes_cache() self.nodes_list = self.config._getallnodes()
self.folders = self.config._getallfolders()
self.config._generate_nodes_cache()
#Generate helps #Generate helps
nodeparser.usage = self._help("usage", subparsers) nodeparser.usage = self._help("usage", subparsers)
@@ -656,7 +663,7 @@ class connapp:
if not os.path.isdir(args.data[0]): if not os.path.isdir(args.data[0]):
raise argparse.ArgumentTypeError(f"readable_dir:{args.data[0]} is not a valid path") raise argparse.ArgumentTypeError(f"readable_dir:{args.data[0]} is not a valid path")
else: else:
pathfile = self.config.defaultdir + "/.folder" pathfile = self.config.anchor_path + "/.folder"
folder = os.path.abspath(args.data[0]).rstrip('/') folder = os.path.abspath(args.data[0]).rstrip('/')
with open(pathfile, "w") as f: with open(pathfile, "w") as f:
f.write(str(folder)) f.write(str(folder))
@@ -803,13 +810,15 @@ class connapp:
plugins = {} plugins = {}
# Iterate over all files in the specified folder # Iterate over all files in the specified folder
for file in os.listdir(self.config.defaultdir + "/plugins"): plugins_dir = self.config.defaultdir + "/plugins"
# Check if the file is a Python file if os.path.exists(plugins_dir):
if file.endswith('.py'): for file in os.listdir(plugins_dir):
enabled_files.append(os.path.splitext(file)[0]) # Check if the file is a Python file
# Check if the file is a Python backup file if file.endswith('.py'):
elif file.endswith('.py.bkp'): enabled_files.append(os.path.splitext(file)[0])
disabled_files.append(os.path.splitext(os.path.splitext(file)[0])[0]) # Check if the file is a Python backup file
elif file.endswith('.py.bkp'):
disabled_files.append(os.path.splitext(os.path.splitext(file)[0])[0])
if enabled_files: if enabled_files:
plugins["Enabled"] = enabled_files plugins["Enabled"] = enabled_files
if disabled_files: if disabled_files:
@@ -899,17 +908,35 @@ class connapp:
self.myai = self.ai(self.config, **arguments) self.myai = self.ai(self.config, **arguments)
# 1. Gestionar comandos de sesión (Listar/Borrar)
if args.list_sessions:
self.myai.list_sessions()
return
if args.delete_session:
self.myai.delete_session(args.delete_session[0])
return
# 2. Determinar session_id para retomar
session_id = None
if args.resume:
session_id = self.myai.get_last_session_id()
if not session_id:
printer.warning("No previous session found to resume.")
elif args.session:
session_id = args.session[0]
if args.ask: if args.ask:
# Single question mode # Single question mode
query = " ".join(args.ask) query = " ".join(args.ask)
with console.status("[bold green]Agent is thinking and analyzing...") as status: with console.status("[bold green]Agent is thinking and analyzing...") as status:
result = self.myai.ask(query, status=status, debug=args.debug) result = self.myai.ask(query, status=status, debug=args.debug, session_id=session_id)
# Determine title and color based on responder # Determine title and color based on responder
responder = result.get("responder", "engineer") responder = result.get("responder", "engineer")
if responder == "architect": if responder == "architect":
title = "[bold purple]Network Architect[/bold purple]" title = "[bold medium_purple]Network Architect[/bold medium_purple]"
border_style = "purple" border_style = "medium_purple"
else: else:
title = "[bold blue]Network Engineer[/bold blue]" title = "[bold blue]Network Engineer[/bold blue]"
border_style = "blue" border_style = "blue"
@@ -927,9 +954,20 @@ class connapp:
else: else:
# Interactive chat mode # Interactive chat mode
history = None history = None
mdprint(Rule(style="bold blue")) if session_id:
mdprint(Markdown("**Networking Expert Agent**: Hi! I'm your assistant. I can help you diagnose issues, run commands, and manage your nodes.\nType 'exit' to quit.\n")) session_data = self.myai.load_session_data(session_id)
mdprint(Rule(style="bold blue")) if session_data:
history = session_data.get("history", [])
mdprint(Rule(title=f"[bold cyan] Resuming Session: {session_data.get('title')} [/bold cyan]", style="cyan"))
else:
printer.error(f"Could not load session {session_id}. Starting clean.")
if not history:
mdprint(Rule(style="bold blue"))
mdprint(Markdown("**Networking Expert Agent**: Hi! I'm your assistant. I can help you diagnose issues, run commands, and manage your nodes.\nType 'exit' to quit.\n"))
mdprint(Rule(style="bold blue"))
else:
mdprint(f"[dim]Analyzing {len(history)} previous messages...[/dim]\n")
while True: while True:
try: try:
@@ -984,18 +1022,18 @@ class connapp:
return True return True
def _func_api(self, args): def _func_api(self, args):
if args.command == "stop" or args.command == "restart": if args.command == "stop" or args.command == "restart" or args.command == "stop":
args.data = self.stop_api() args.data = self.stop_api()
if args.command == "start" or args.command == "restart": if args.command == "start" or args.command == "restart":
if args.data: if args.data:
self.start_api(args.data) self.start_api(args.data, config=self.config)
else: else:
self.start_api() self.start_api(config=self.config)
if args.command == "debug": if args.command == "debug":
if args.data: if args.data:
self.debug_api(args.data) self.debug_api(args.data, config=self.config)
else: else:
self.debug_api() self.debug_api(config=self.config)
return return
def _node_run(self, args): def _node_run(self, args):
@@ -1577,8 +1615,9 @@ compdef _conn connpy
connpy() { connpy() {
if [ $# -eq 0 ]; then if [ $# -eq 0 ]; then
local selected local selected
if [ -f ~/.config/conn/.fzf_nodes_cache.txt ]; then local configdir=$(cat ~/.config/conn/.folder 2>/dev/null || echo ~/.config/conn)
selected=$(cat ~/.config/conn/.fzf_nodes_cache.txt | fzf-tmux -d 25% --reverse) if [ -s "$configdir/.fzf_nodes_cache.txt" ]; then
selected=$(cat "$configdir/.fzf_nodes_cache.txt" | fzf-tmux -i -d 25%)
else else
command connpy command connpy
return return
@@ -1598,8 +1637,9 @@ alias c="connpy"
connpy() { connpy() {
if [ $# -eq 0 ]; then if [ $# -eq 0 ]; then
local selected local selected
if [ -f ~/.config/conn/.fzf_nodes_cache.txt ]; then local configdir=$(cat ~/.config/conn/.folder 2>/dev/null || echo ~/.config/conn)
selected=$(cat ~/.config/conn/.fzf_nodes_cache.txt | fzf-tmux -d 25% --reverse) if [ -s "$configdir/.fzf_nodes_cache.txt" ]; then
selected=$(cat "$configdir/.fzf_nodes_cache.txt" | fzf-tmux -i -d 25%)
else else
command connpy command connpy
return return
+8 -5
View File
@@ -117,16 +117,19 @@ class context_manager:
class Preload: class Preload:
def __init__(self, connapp): def __init__(self, connapp):
#define contexts if doesn't exist
connapp.config.modify(context_manager.add_default_context)
#filter nodes using context
cm = context_manager(connapp) cm = context_manager(connapp)
connapp.nodes_list = [node for node in connapp.nodes_list if cm.match_any_regex(node, cm.regex)] # Register hooks first so that any save triggers a filtered cache generation
connapp.folders = [node for node in connapp.folders if cm.match_any_regex(node, cm.regex)]
connapp.config._getallnodes.register_post_hook(cm.modify_node_list) connapp.config._getallnodes.register_post_hook(cm.modify_node_list)
connapp.config._getallfolders.register_post_hook(cm.modify_node_list) connapp.config._getallfolders.register_post_hook(cm.modify_node_list)
connapp.config._getallnodesfull.register_post_hook(cm.modify_node_dict) connapp.config._getallnodesfull.register_post_hook(cm.modify_node_dict)
# Define contexts if doesn't exist (triggers save/cache generation)
connapp.config.modify(context_manager.add_default_context)
# Filter in-memory nodes using current context
connapp.nodes_list = [node for node in connapp.nodes_list if cm.match_any_regex(node, cm.regex)]
connapp.folders = [node for node in connapp.folders if cm.match_any_regex(node, cm.regex)]
class Parser: class Parser:
def __init__(self): def __init__(self):
self.parser = argparse.ArgumentParser(description="Manage contexts with regex matching", formatter_class=argparse.RawTextHelpFormatter) self.parser = argparse.ArgumentParser(description="Manage contexts with regex matching", formatter_class=argparse.RawTextHelpFormatter)
+2
View File
@@ -115,6 +115,8 @@ class Plugins:
return module return module
def _import_plugins_to_argparse(self, directory, subparsers): def _import_plugins_to_argparse(self, directory, subparsers):
if not os.path.exists(directory):
return
for filename in os.listdir(directory): for filename in os.listdir(directory):
commands = subparsers.choices.keys() commands = subparsers.choices.keys()
if filename.endswith(".py"): if filename.endswith(".py"):
+27 -9
View File
@@ -1,33 +1,51 @@
import sys import sys
from rich.console import Console
from rich.table import Table
from rich.live import Live
console = Console()
err_console = Console(stderr=True)
def _format_multiline(tag, message): def _format_multiline(tag, message):
message = str(message)
lines = message.splitlines() lines = message.splitlines()
if not lines: if not lines:
return f"[{tag}]" return f"\\[{tag}]"
formatted = [f"[{tag}] {lines[0]}"] formatted = [f"\\[{tag}] {lines[0]}"]
indent = " " * (len(tag) + 3) indent = " " * (len(tag) + 3)
for line in lines[1:]: for line in lines[1:]:
formatted.append(f"{indent}{line}") formatted.append(f"{indent}{line}")
return "\n".join(formatted) return "\n".join(formatted)
def info(message): def info(message):
print(_format_multiline("i", message)) console.print(_format_multiline("i", message))
def success(message): def success(message):
print(_format_multiline("", message)) console.print(_format_multiline("", message))
def start(message): def start(message):
print(_format_multiline("+", message)) console.print(_format_multiline("+", message))
def warning(message): def warning(message):
print(_format_multiline("!", message)) console.print(_format_multiline("!", message))
def error(message): def error(message):
print(_format_multiline("", message), file=sys.stderr) # For error, we can create a temporary stderr console or just use the current one
# err_console handles styles better than standard print and outputs to stderr.
err_console.print(_format_multiline("", message), style="red")
def debug(message): def debug(message):
print(_format_multiline("d", message)) console.print(_format_multiline("d", message))
def custom(tag, message): def custom(tag, message):
print(_format_multiline(tag, message)) console.print(_format_multiline(tag, message))
def table(title, columns, rows, header_style="bold cyan", box=None):
t = Table(title=title, header_style=header_style, box=box)
for col in columns:
t.add_column(col)
for row in rows:
t.add_row(*[str(item) for item in row])
console.print(t)
+85 -1
View File
@@ -42,7 +42,7 @@ class TestAIInit:
def test_init_loads_memory(self, ai_config, tmp_path, mock_litellm): def test_init_loads_memory(self, ai_config, tmp_path, mock_litellm):
"""Loads long-term memory from file if it exists.""" """Loads long-term memory from file if it exists."""
memory_path = os.path.expanduser("~/.config/conn/ai_memory.md") memory_path = os.path.join(ai_config.defaultdir, "ai_memory.md")
from connpy.ai import ai from connpy.ai import ai
with patch("os.path.exists", side_effect=lambda p: True if p == memory_path else os.path.exists(p)): with patch("os.path.exists", side_effect=lambda p: True if p == memory_path else os.path.exists(p)):
@@ -210,6 +210,17 @@ class TestSanitizeMessages:
result = myai._sanitize_messages(messages) result = myai._sanitize_messages(messages)
assert len(result) == 4 assert len(result) == 4
def test_sanitize_strips_cache_control(self, myai):
"""_sanitize_messages should convert list-based content (with cache_control) back to strings."""
messages = [
{"role": "system", "content": [{"type": "text", "text": "system prompt", "cache_control": {"type": "ephemeral"}}]},
{"role": "user", "content": "hello"}
]
result = myai._sanitize_messages(messages)
assert result[0]["role"] == "system"
assert isinstance(result[0]["content"], str)
assert result[0]["content"] == "system prompt"
# ========================================================================= # =========================================================================
# _truncate tests # _truncate tests
@@ -395,3 +406,76 @@ class TestToolDefinitions:
tools = myai._get_architect_tools() tools = myai._get_architect_tools()
names = [t["function"]["name"] for t in tools] names = [t["function"]["name"] for t in tools]
assert "arch_tool" in names assert "arch_tool" in names
# =========================================================================
# AI Session Management tests
# =========================================================================
class TestAISessions:
@pytest.fixture
def myai(self, ai_config, mock_litellm, tmp_path):
from connpy.ai import ai
ai_config.defaultdir = str(tmp_path)
return ai(ai_config)
def test_sessions_dir_initialization(self, myai, tmp_path):
assert os.path.exists(os.path.join(tmp_path, "ai_sessions"))
assert myai.sessions_dir == str(tmp_path / "ai_sessions")
def test_generate_session_id(self, myai):
session_id = myai._generate_session_id("Any query")
# Format: YYYYMMDD-HHMMSS
assert len(session_id) == 15
assert "-" in session_id
parts = session_id.split("-")
assert len(parts[0]) == 8 # YYYYMMDD
assert len(parts[1]) == 6 # HHMMSS
def test_save_and_load_session(self, myai):
history = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi"}
]
myai.save_session(history, title="Test Session")
session_id = myai.session_id
# Load it back
loaded = myai.load_session_data(session_id)
assert loaded["title"] == "Test Session"
assert loaded["history"] == history
assert loaded["model"] == myai.engineer_model
def test_list_sessions(self, myai, capsys):
history = [{"role": "user", "content": "Query 1"}]
myai.save_session(history, title="Session 1")
# Use a second instance to list
myai.list_sessions()
captured = capsys.readouterr()
assert "Session 1" in captured.out
assert "AI Persisted Sessions" in captured.out
def test_get_last_session_id(self, myai):
# Save two sessions
myai.session_id = None # Force new
myai.save_session([{"role": "user", "content": "First"}])
first_id = myai.session_id
import time
time.sleep(1.1) # Ensure different timestamp
myai.session_id = None # Force new
myai.save_session([{"role": "user", "content": "Second"}])
second_id = myai.session_id
last_id = myai.get_last_session_id()
assert last_id == second_id
assert last_id != first_id
def test_delete_session(self, myai):
myai.save_session([{"role": "user", "content": "To be deleted"}])
session_id = myai.session_id
assert os.path.exists(myai.session_path)
myai.delete_session(session_id)
assert not os.path.exists(myai.session_path)
+384 -104
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy API documentation</title> <title>connpy API documentation</title>
<meta name="description" content="Connection manager …"> <meta name="description" content="Connection manager …">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -683,6 +683,8 @@ class Preload:
return module return module
def _import_plugins_to_argparse(self, directory, subparsers): def _import_plugins_to_argparse(self, directory, subparsers):
if not os.path.exists(directory):
return
for filename in os.listdir(directory): for filename in os.listdir(directory):
commands = subparsers.choices.keys() commands = subparsers.choices.keys()
if filename.endswith(&#34;.py&#34;): if filename.endswith(&#34;.py&#34;):
@@ -890,7 +892,7 @@ class ai:
self.architect_prompt_extensions = [] # Extra text for architect prompt self.architect_prompt_extensions = [] # Extra text for architect prompt
# Long-term memory # Long-term memory
self.memory_path = os.path.expanduser(&#34;~/.config/conn/ai_memory.md&#34;) self.memory_path = os.path.join(self.config.defaultdir, &#34;ai_memory.md&#34;)
self.long_term_memory = &#34;&#34; self.long_term_memory = &#34;&#34;
if os.path.exists(self.memory_path): if os.path.exists(self.memory_path):
try: try:
@@ -903,6 +905,12 @@ class ai:
except Exception as e: except Exception as e:
console.print(f&#34;[yellow]Warning: Failed to load AI memory: {e}[/yellow]&#34;) console.print(f&#34;[yellow]Warning: Failed to load AI memory: {e}[/yellow]&#34;)
# Session Management
self.sessions_dir = os.path.join(self.config.defaultdir, &#34;ai_sessions&#34;)
os.makedirs(self.sessions_dir, exist_ok=True)
self.session_id = None
self.session_path = None
# Prompts base agnósticos # Prompts base agnósticos
self._engineer_base_prompt = dedent(f&#34;&#34;&#34; self._engineer_base_prompt = dedent(f&#34;&#34;&#34;
Role: TECHNICAL EXECUTION ENGINE. Role: TECHNICAL EXECUTION ENGINE.
@@ -1018,7 +1026,7 @@ class ai:
# 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;
border = &#34;purple&#34; if &#34;architect&#34; in label.lower() else &#34;blue&#34; border = &#34;medium_purple&#34; if &#34;architect&#34; in label.lower() else &#34;blue&#34;
title = f&#34;[bold {border}]{role_label}[/bold {border}]&#34; title = f&#34;[bold {border}]{role_label}[/bold {border}]&#34;
try: try:
@@ -1118,14 +1126,34 @@ class ai:
2. No user/system messages appear between tool_calls and tool responses 2. No user/system messages appear between tool_calls and tool responses
3. Orphaned tool_calls at the end are removed 3. Orphaned tool_calls at the end are removed
4. Orphaned tool responses without a preceding tool_call are removed 4. Orphaned tool responses without a preceding tool_call are removed
5. Incompatible metadata like cache_control is stripped for non-Anthropic models
&#34;&#34;&#34; &#34;&#34;&#34;
if not messages: if not messages:
return messages return messages
# Pre-process messages to pull text from list contents (Anthropic cache format)
# and remove explicit cache keys.
pre_sanitized = []
for msg in messages:
m = msg.copy() if isinstance(msg, dict) else msg.model_dump(exclude_none=True)
# Convert content list to plain string if it&#39;s a system message with caching metadata
if m.get(&#39;role&#39;) == &#39;system&#39; and isinstance(m.get(&#39;content&#39;), list):
# Extraer texto de [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: &#34;...&#34;, &#34;cache_control&#34;: ...}]
m[&#39;content&#39;] = m[&#39;content&#39;][0][&#39;text&#39;] if m[&#39;content&#39;] else &#34;&#34;
# Remove any explicit cache_control key anywhere
if &#39;cache_control&#39; in m: del m[&#39;cache_control&#39;]
if isinstance(m.get(&#39;content&#39;), list):
for item in m[&#39;content&#39;]:
if isinstance(item, dict) and &#39;cache_control&#39; in item: del item[&#39;cache_control&#39;]
pre_sanitized.append(m)
sanitized = [] sanitized = []
i = 0 i = 0
while i &lt; len(messages): while i &lt; len(pre_sanitized):
msg = messages[i] msg = pre_sanitized[i]
role = msg.get(&#39;role&#39;, &#39;&#39;) role = msg.get(&#39;role&#39;, &#39;&#39;)
if role == &#39;assistant&#39; and msg.get(&#39;tool_calls&#39;): if role == &#39;assistant&#39; and msg.get(&#39;tool_calls&#39;):
@@ -1139,8 +1167,8 @@ class ai:
# Look ahead for matching tool responses # Look ahead for matching tool responses
tool_responses = [] tool_responses = []
j = i + 1 j = i + 1
while j &lt; len(messages): while j &lt; len(pre_sanitized):
next_msg = messages[j] next_msg = pre_sanitized[j]
if next_msg.get(&#39;role&#39;) == &#39;tool&#39;: if next_msg.get(&#39;role&#39;) == &#39;tool&#39;:
tool_responses.append(next_msg) tool_responses.append(next_msg)
j += 1 j += 1
@@ -1298,23 +1326,16 @@ class ai:
def _engineer_loop(self, task, status=None, debug=False, chat_history=None): def _engineer_loop(self, task, status=None, debug=False, chat_history=None):
&#34;&#34;&#34;Internal loop where the Engineer executes technical tasks for the Architect.&#34;&#34;&#34; &#34;&#34;&#34;Internal loop where the Engineer executes technical tasks for the Architect.&#34;&#34;&#34;
# Optimización de caché para el Ingeniero # Optimización de caché para el Ingeniero (Solo para Anthropic directo, Vertex tiene reglas distintas)
if &#34;claude&#34; in self.engineer_model.lower(): if &#34;claude&#34; in self.engineer_model.lower() and &#34;vertex&#34; not in self.engineer_model.lower():
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: self.engineer_system_prompt, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]}] messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: self.engineer_system_prompt, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]}]
else: else:
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: self.engineer_system_prompt}] messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: self.engineer_system_prompt}]
if chat_history: if chat_history:
# Clean chat history from caching metadata if engineer is not Claude # Clean chat history from caching metadata if engineer is not a compatible Claude model
if &#34;claude&#34; not in self.engineer_model.lower(): if &#34;claude&#34; not in self.engineer_model.lower() or &#34;vertex&#34; in self.engineer_model.lower():
cleaned_history = [] messages.extend(self._sanitize_messages(chat_history[-5:]))
for msg in chat_history[-5:]:
m = msg if isinstance(msg, dict) else msg.model_dump(exclude_none=True)
# Remove cache_control from system messages
if m.get(&#39;role&#39;) == &#39;system&#39; and isinstance(m.get(&#39;content&#39;), list):
m[&#39;content&#39;] = m[&#39;content&#39;][0][&#39;text&#39;] if m[&#39;content&#39;] else &#34;&#34;
cleaned_history.append(m)
messages.extend(cleaned_history)
else: else:
messages.extend(chat_history[-5:]) messages.extend(chat_history[-5:])
@@ -1410,9 +1431,125 @@ class ai:
tools.extend(self.external_architect_tools) tools.extend(self.external_architect_tools)
return tools return tools
def _get_sessions(self):
&#34;&#34;&#34;Returns a list of session metadata sorted by date.&#34;&#34;&#34;
sessions = []
if not os.path.exists(self.sessions_dir):
return []
for f in os.listdir(self.sessions_dir):
if f.endswith(&#34;.json&#34;):
path = os.path.join(self.sessions_dir, f)
try:
with open(path, &#34;r&#34;) as fs:
data = json.load(fs)
sessions.append({
&#34;id&#34;: f[:-5],
&#34;title&#34;: data.get(&#34;title&#34;, &#34;Untitled Session&#34;),
&#34;created_at&#34;: data.get(&#34;created_at&#34;, &#34;Unknown&#34;),
&#34;model&#34;: data.get(&#34;model&#34;, &#34;Unknown&#34;),
&#34;path&#34;: path
})
except Exception:
continue
return sorted(sessions, key=lambda x: x[&#34;created_at&#34;], reverse=True)
def list_sessions(self):
&#34;&#34;&#34;Prints a list of sessions using printer.table.&#34;&#34;&#34;
sessions = self._get_sessions()
if not sessions:
printer.info(&#34;No saved AI sessions found.&#34;)
return
columns = [&#34;ID&#34;, &#34;Title&#34;, &#34;Created At&#34;, &#34;Model&#34;]
rows = [[s[&#34;id&#34;], s[&#34;title&#34;], s[&#34;created_at&#34;], s[&#34;model&#34;]] for s in sessions]
printer.table(&#34;AI Persisted Sessions&#34;, columns, rows)
def load_session_data(self, session_id):
&#34;&#34;&#34;Loads a session&#39;s raw data by ID.&#34;&#34;&#34;
path = os.path.join(self.sessions_dir, f&#34;{session_id}.json&#34;)
if os.path.exists(path):
try:
with open(path, &#34;r&#34;) as f:
data = json.load(f)
self.session_id = session_id
self.session_path = path
return data
except Exception as e:
printer.error(f&#34;Failed to load session {session_id}: {e}&#34;)
return None
def delete_session(self, session_id):
&#34;&#34;&#34;Deletes a session by ID.&#34;&#34;&#34;
path = os.path.join(self.sessions_dir, f&#34;{session_id}.json&#34;)
if os.path.exists(path):
os.remove(path)
printer.success(f&#34;Session {session_id} deleted.&#34;)
else:
printer.error(f&#34;Session {session_id} not found.&#34;)
def get_last_session_id(self):
&#34;&#34;&#34;Returns the ID of the most recent session.&#34;&#34;&#34;
sessions = self._get_sessions()
return sessions[0][&#34;id&#34;] if sessions else None
def _generate_session_id(self, query):
&#34;&#34;&#34;Generates a unique session ID based on timestamp.&#34;&#34;&#34;
return datetime.datetime.now().strftime(&#34;%Y%m%d-%H%M%S&#34;)
def save_session(self, history, title=None, model=None):
&#34;&#34;&#34;Saves current history to the session file.&#34;&#34;&#34;
if not self.session_id:
# Generate ID from first user query if available
first_user_msg = next((m[&#34;content&#34;] for m in history if m[&#34;role&#34;] == &#34;user&#34;), &#34;new-session&#34;)
self.session_id = self._generate_session_id(first_user_msg)
self.session_path = os.path.join(self.sessions_dir, f&#34;{self.session_id}.json&#34;)
# If it&#39;s a new file, we might want to set a better title
if not os.path.exists(self.session_path) and not title:
raw_title = next((m[&#34;content&#34;] for m in history if m[&#34;role&#34;] == &#34;user&#34;), &#34;New Session&#34;)
# Clean title: remove newlines, multiple spaces
clean_title = &#34; &#34;.join(raw_title.split())
if len(clean_title) &gt; 40:
title = clean_title[:37].strip() + &#34;...&#34;
else:
title = clean_title
try:
# Read existing metadata if it exists
metadata = {}
if os.path.exists(self.session_path):
with open(self.session_path, &#34;r&#34;) as f:
metadata = json.load(f)
metadata.update({
&#34;id&#34;: self.session_id,
&#34;title&#34;: title or metadata.get(&#34;title&#34;, &#34;New Session&#34;),
&#34;created_at&#34;: metadata.get(&#34;created_at&#34;, datetime.datetime.now().isoformat()),
&#34;updated_at&#34;: datetime.datetime.now().isoformat(),
&#34;model&#34;: model or metadata.get(&#34;model&#34;, self.engineer_model),
&#34;history&#34;: history
})
with open(self.session_path, &#34;w&#34;) as f:
json.dump(metadata, f, indent=4)
except Exception as e:
printer.error(f&#34;Failed to save session: {e}&#34;)
except Exception as e:
printer.error(f&#34;Failed to save session: {e}&#34;)
@MethodHook @MethodHook
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True): def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None):
if chat_history is None: chat_history = [] if chat_history is None: chat_history = []
# Load session if provided and history is empty
if session_id and not chat_history:
session_data = self.load_session_data(session_id)
if session_data:
chat_history = session_data.get(&#34;history&#34;, [])
# If we loaded history, the caller might need it back
# But typically ask() is called in a loop with an external history object
usage = {&#34;input&#34;: 0, &#34;output&#34;: 0, &#34;total&#34;: 0} usage = {&#34;input&#34;: 0, &#34;output&#34;: 0, &#34;total&#34;: 0}
# 1. Selector de Rol inicial (Sticky Brain) # 1. Selector de Rol inicial (Sticky Brain)
@@ -1446,15 +1583,20 @@ class ai:
model = self.architect_model if current_brain == &#34;architect&#34; else self.engineer_model model = self.architect_model if current_brain == &#34;architect&#34; else self.engineer_model
key = self.architect_key if current_brain == &#34;architect&#34; else self.engineer_key key = self.architect_key if current_brain == &#34;architect&#34; else self.engineer_key
# Estructura optimizada para Prompt Caching # Estructura optimizada para Prompt Caching (Solo para Anthropic directo, Vertex tiene reglas distintas)
if &#34;claude&#34; in model.lower(): if &#34;claude&#34; in model.lower() and &#34;vertex&#34; not in model.lower():
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: system_prompt, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]}] messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: system_prompt, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]}]
else: else:
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: system_prompt}] messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: system_prompt}]
# Interleaving de historial # Interleaving de historial
last_role = &#34;system&#34; last_role = &#34;system&#34;
for msg in chat_history[-self.max_history:]: # Sanitize history if the current target model is not compatible with cache_control
history_to_process = chat_history[-self.max_history:]
if &#34;claude&#34; not in model.lower() or &#34;vertex&#34; in model.lower():
history_to_process = self._sanitize_messages(history_to_process)
for msg in history_to_process:
m = msg if isinstance(msg, dict) else msg.model_dump(exclude_none=True) m = msg if isinstance(msg, dict) else msg.model_dump(exclude_none=True)
role = m.get(&#39;role&#39;) role = m.get(&#39;role&#39;)
if role == last_role and role == &#39;user&#39;: if role == last_role and role == &#39;user&#39;:
@@ -1482,7 +1624,7 @@ class ai:
console.print(f&#34;[yellow] You can press Ctrl+C to interrupt and get a summary of progress.[/yellow]&#34;) console.print(f&#34;[yellow] You can press Ctrl+C to interrupt and get a summary of progress.[/yellow]&#34;)
soft_limit_warned = True soft_limit_warned = True
label = &#34;[bold purple]Architect&#34; if current_brain == &#34;architect&#34; else &#34;[bold blue]Engineer&#34; label = &#34;[bold medium_purple]Architect&#34; if current_brain == &#34;architect&#34; else &#34;[bold blue]Engineer&#34;
if status: status.update(f&#34;{label} is thinking... (step {iteration})&#34;) if status: status.update(f&#34;{label} is thinking... (step {iteration})&#34;)
streamed_response = False streamed_response = False
@@ -1527,7 +1669,7 @@ class ai:
messages.append(msg_dict) messages.append(msg_dict)
if debug and resp_msg.content: if debug and resp_msg.content:
console.print(Panel(Markdown(resp_msg.content), title=f&#34;{label} Reasoning&#34;, border_style=&#34;purple&#34; if current_brain == &#34;architect&#34; else &#34;blue&#34;)) console.print(Panel(Markdown(resp_msg.content), title=f&#34;{label} Reasoning&#34;, border_style=&#34;medium_purple&#34; if current_brain == &#34;architect&#34; else &#34;blue&#34;))
if not resp_msg.tool_calls: break if not resp_msg.tool_calls: break
@@ -1544,8 +1686,8 @@ class ai:
continue continue
if status: if status:
if fn == &#34;delegate_to_engineer&#34;: status.update(f&#34;[bold purple]Architect: [DELEGATING MISSION] {args.get(&#39;task&#39;,&#39;&#39;)[:40]}...&#34;) if fn == &#34;delegate_to_engineer&#34;: status.update(f&#34;[bold medium_purple]Architect: [DELEGATING MISSION] {args.get(&#39;task&#39;,&#39;&#39;)[:40]}...&#34;)
elif fn == &#34;manage_memory_tool&#34;: status.update(f&#34;[bold purple]Architect: [UPDATING MEMORY]&#34;) elif fn == &#34;manage_memory_tool&#34;: status.update(f&#34;[bold medium_purple]Architect: [UPDATING MEMORY]&#34;)
if debug: console.print(Panel(Text(json.dumps(args, indent=2)), title=f&#34;{label} Decision: {fn}&#34;, border_style=&#34;white&#34;)) if debug: console.print(Panel(Text(json.dumps(args, indent=2)), title=f&#34;{label} Decision: {fn}&#34;, border_style=&#34;white&#34;))
@@ -1553,7 +1695,7 @@ class ai:
obs, eng_usage = self._engineer_loop(args[&#34;task&#34;], status=status, debug=debug, chat_history=messages[:-1]) obs, eng_usage = self._engineer_loop(args[&#34;task&#34;], status=status, debug=debug, chat_history=messages[:-1])
usage[&#34;input&#34;] += eng_usage[&#34;input&#34;]; usage[&#34;output&#34;] += eng_usage[&#34;output&#34;]; usage[&#34;total&#34;] += eng_usage[&#34;total&#34;] usage[&#34;input&#34;] += eng_usage[&#34;input&#34;]; usage[&#34;output&#34;] += eng_usage[&#34;output&#34;]; usage[&#34;total&#34;] += eng_usage[&#34;total&#34;]
elif fn == &#34;consult_architect&#34;: elif fn == &#34;consult_architect&#34;:
if status: status.update(&#34;[bold purple]Engineer consulting Architect...&#34;) if status: status.update(&#34;[bold medium_purple]Engineer consulting Architect...&#34;)
try: try:
# Consultation only - Engineer stays in control # Consultation only - Engineer stays in control
claude_resp = completion( claude_resp = completion(
@@ -1566,13 +1708,13 @@ class ai:
num_retries=3 num_retries=3
) )
obs = claude_resp.choices[0].message.content obs = claude_resp.choices[0].message.content
if debug: console.print(Panel(Markdown(obs), title=&#34;[bold purple]Architect Consultation[/bold purple]&#34;, border_style=&#34;purple&#34;)) if debug: console.print(Panel(Markdown(obs), title=&#34;[bold medium_purple]Architect Consultation[/bold medium_purple]&#34;, border_style=&#34;medium_purple&#34;))
except Exception as e: except Exception as e:
if status: status.update(&#34;[bold orange3]Architect unavailable! Engineer continuing alone...&#34;) if status: status.update(&#34;[bold orange3]Architect unavailable! Engineer continuing alone...&#34;)
obs = f&#34;Architect unavailable ({str(e)}). Proceeding with your best technical judgment.&#34; obs = f&#34;Architect unavailable ({str(e)}). Proceeding with your best technical judgment.&#34;
elif fn == &#34;escalate_to_architect&#34;: elif fn == &#34;escalate_to_architect&#34;:
if status: status.update(&#34;[bold purple]Transferring control to Architect...&#34;) if status: status.update(&#34;[bold medium_purple]Transferring control to Architect...&#34;)
# Full escalation - Architect takes over # Full escalation - Architect takes over
current_brain = &#34;architect&#34; current_brain = &#34;architect&#34;
model = self.architect_model model = self.architect_model
@@ -1583,7 +1725,7 @@ class ai:
handover_msg = f&#34;HANDOVER FROM EXECUTION ENGINE\n\nReason: {args[&#39;reason&#39;]}\n\nContext: {args[&#39;context&#39;]}\n\nYou are now in control of this conversation.&#34; handover_msg = f&#34;HANDOVER FROM EXECUTION ENGINE\n\nReason: {args[&#39;reason&#39;]}\n\nContext: {args[&#39;context&#39;]}\n\nYou are now in control of this conversation.&#34;
pending_user_message = handover_msg pending_user_message = handover_msg
obs = &#34;Control transferred to Architect. Handover context will be provided.&#34; obs = &#34;Control transferred to Architect. Handover context will be provided.&#34;
if debug: console.print(Panel(Text(handover_msg), title=&#34;[bold purple]Escalation to Architect[/bold purple]&#34;, border_style=&#34;purple&#34;)) if debug: console.print(Panel(Text(handover_msg), title=&#34;[bold medium_purple]Escalation to Architect[/bold medium_purple]&#34;, border_style=&#34;medium_purple&#34;))
elif fn == &#34;return_to_engineer&#34;: elif fn == &#34;return_to_engineer&#34;:
if status: status.update(&#34;[bold blue]Transferring control back to Engineer...&#34;) if status: status.update(&#34;[bold blue]Transferring control back to Engineer...&#34;)
@@ -1641,19 +1783,8 @@ class ai:
messages.append(resp_msg.model_dump(exclude_none=True)) messages.append(resp_msg.model_dump(exclude_none=True))
except Exception: pass except Exception: pass
finally: finally:
try: # Auto-save session
log_dir = self.config.defaultdir self.save_session(messages, model=model)
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, &#34;ai_debug.json&#34;)
hist = []
if os.path.exists(log_path):
try:
with open(log_path, &#34;r&#34;) as f: hist = json.load(f)
except (IOError, json.JSONDecodeError): hist = []
hist.append({&#34;timestamp&#34;: datetime.datetime.now().isoformat(), &#34;roles&#34;: {&#34;strategic_engine&#34;: self.architect_model, &#34;execution_engine&#34;: self.engineer_model}, &#34;session&#34;: messages})
with open(log_path, &#34;w&#34;) as f: json.dump(hist[-10:], f, indent=4)
except Exception as e:
if debug: console.print(f&#34;[dim red]Debug log failed: {e}[/dim red]&#34;)
return { return {
&#34;response&#34;: messages[-1].get(&#34;content&#34;), &#34;response&#34;: messages[-1].get(&#34;content&#34;),
@@ -1672,7 +1803,7 @@ class ai:
<dl> <dl>
<dt id="connpy.ai.SAFE_COMMANDS"><code class="name">var <span class="ident">SAFE_COMMANDS</span></code></dt> <dt id="connpy.ai.SAFE_COMMANDS"><code class="name">var <span class="ident">SAFE_COMMANDS</span></code></dt>
<dd> <dd>
<div class="desc"><p>The type of the None singleton.</p></div> <div class="desc"></div>
</dd> </dd>
</dl> </dl>
<h3>Instance variables</h3> <h3>Instance variables</h3>
@@ -1713,7 +1844,7 @@ def engineer_system_prompt(self):
<h3>Methods</h3> <h3>Methods</h3>
<dl> <dl>
<dt id="connpy.ai.ask"><code class="name flex"> <dt id="connpy.ai.ask"><code class="name flex">
<span>def <span class="ident">ask</span></span>(<span>self,<br>user_input,<br>dryrun=False,<br>chat_history=None,<br>status=None,<br>debug=False,<br>stream=True)</span> <span>def <span class="ident">ask</span></span>(<span>self,<br>user_input,<br>dryrun=False,<br>chat_history=None,<br>status=None,<br>debug=False,<br>stream=True,<br>session_id=None)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
@@ -1721,8 +1852,17 @@ def engineer_system_prompt(self):
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">@MethodHook <pre><code class="python">@MethodHook
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True): def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None):
if chat_history is None: chat_history = [] if chat_history is None: chat_history = []
# Load session if provided and history is empty
if session_id and not chat_history:
session_data = self.load_session_data(session_id)
if session_data:
chat_history = session_data.get(&#34;history&#34;, [])
# If we loaded history, the caller might need it back
# But typically ask() is called in a loop with an external history object
usage = {&#34;input&#34;: 0, &#34;output&#34;: 0, &#34;total&#34;: 0} usage = {&#34;input&#34;: 0, &#34;output&#34;: 0, &#34;total&#34;: 0}
# 1. Selector de Rol inicial (Sticky Brain) # 1. Selector de Rol inicial (Sticky Brain)
@@ -1756,15 +1896,20 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
model = self.architect_model if current_brain == &#34;architect&#34; else self.engineer_model model = self.architect_model if current_brain == &#34;architect&#34; else self.engineer_model
key = self.architect_key if current_brain == &#34;architect&#34; else self.engineer_key key = self.architect_key if current_brain == &#34;architect&#34; else self.engineer_key
# Estructura optimizada para Prompt Caching # Estructura optimizada para Prompt Caching (Solo para Anthropic directo, Vertex tiene reglas distintas)
if &#34;claude&#34; in model.lower(): if &#34;claude&#34; in model.lower() and &#34;vertex&#34; not in model.lower():
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: system_prompt, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]}] messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: system_prompt, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]}]
else: else:
messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: system_prompt}] messages = [{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: system_prompt}]
# Interleaving de historial # Interleaving de historial
last_role = &#34;system&#34; last_role = &#34;system&#34;
for msg in chat_history[-self.max_history:]: # Sanitize history if the current target model is not compatible with cache_control
history_to_process = chat_history[-self.max_history:]
if &#34;claude&#34; not in model.lower() or &#34;vertex&#34; in model.lower():
history_to_process = self._sanitize_messages(history_to_process)
for msg in history_to_process:
m = msg if isinstance(msg, dict) else msg.model_dump(exclude_none=True) m = msg if isinstance(msg, dict) else msg.model_dump(exclude_none=True)
role = m.get(&#39;role&#39;) role = m.get(&#39;role&#39;)
if role == last_role and role == &#39;user&#39;: if role == last_role and role == &#39;user&#39;:
@@ -1792,7 +1937,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
console.print(f&#34;[yellow] You can press Ctrl+C to interrupt and get a summary of progress.[/yellow]&#34;) console.print(f&#34;[yellow] You can press Ctrl+C to interrupt and get a summary of progress.[/yellow]&#34;)
soft_limit_warned = True soft_limit_warned = True
label = &#34;[bold purple]Architect&#34; if current_brain == &#34;architect&#34; else &#34;[bold blue]Engineer&#34; label = &#34;[bold medium_purple]Architect&#34; if current_brain == &#34;architect&#34; else &#34;[bold blue]Engineer&#34;
if status: status.update(f&#34;{label} is thinking... (step {iteration})&#34;) if status: status.update(f&#34;{label} is thinking... (step {iteration})&#34;)
streamed_response = False streamed_response = False
@@ -1837,7 +1982,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
messages.append(msg_dict) messages.append(msg_dict)
if debug and resp_msg.content: if debug and resp_msg.content:
console.print(Panel(Markdown(resp_msg.content), title=f&#34;{label} Reasoning&#34;, border_style=&#34;purple&#34; if current_brain == &#34;architect&#34; else &#34;blue&#34;)) console.print(Panel(Markdown(resp_msg.content), title=f&#34;{label} Reasoning&#34;, border_style=&#34;medium_purple&#34; if current_brain == &#34;architect&#34; else &#34;blue&#34;))
if not resp_msg.tool_calls: break if not resp_msg.tool_calls: break
@@ -1854,8 +1999,8 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
continue continue
if status: if status:
if fn == &#34;delegate_to_engineer&#34;: status.update(f&#34;[bold purple]Architect: [DELEGATING MISSION] {args.get(&#39;task&#39;,&#39;&#39;)[:40]}...&#34;) if fn == &#34;delegate_to_engineer&#34;: status.update(f&#34;[bold medium_purple]Architect: [DELEGATING MISSION] {args.get(&#39;task&#39;,&#39;&#39;)[:40]}...&#34;)
elif fn == &#34;manage_memory_tool&#34;: status.update(f&#34;[bold purple]Architect: [UPDATING MEMORY]&#34;) elif fn == &#34;manage_memory_tool&#34;: status.update(f&#34;[bold medium_purple]Architect: [UPDATING MEMORY]&#34;)
if debug: console.print(Panel(Text(json.dumps(args, indent=2)), title=f&#34;{label} Decision: {fn}&#34;, border_style=&#34;white&#34;)) if debug: console.print(Panel(Text(json.dumps(args, indent=2)), title=f&#34;{label} Decision: {fn}&#34;, border_style=&#34;white&#34;))
@@ -1863,7 +2008,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
obs, eng_usage = self._engineer_loop(args[&#34;task&#34;], status=status, debug=debug, chat_history=messages[:-1]) obs, eng_usage = self._engineer_loop(args[&#34;task&#34;], status=status, debug=debug, chat_history=messages[:-1])
usage[&#34;input&#34;] += eng_usage[&#34;input&#34;]; usage[&#34;output&#34;] += eng_usage[&#34;output&#34;]; usage[&#34;total&#34;] += eng_usage[&#34;total&#34;] usage[&#34;input&#34;] += eng_usage[&#34;input&#34;]; usage[&#34;output&#34;] += eng_usage[&#34;output&#34;]; usage[&#34;total&#34;] += eng_usage[&#34;total&#34;]
elif fn == &#34;consult_architect&#34;: elif fn == &#34;consult_architect&#34;:
if status: status.update(&#34;[bold purple]Engineer consulting Architect...&#34;) if status: status.update(&#34;[bold medium_purple]Engineer consulting Architect...&#34;)
try: try:
# Consultation only - Engineer stays in control # Consultation only - Engineer stays in control
claude_resp = completion( claude_resp = completion(
@@ -1876,13 +2021,13 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
num_retries=3 num_retries=3
) )
obs = claude_resp.choices[0].message.content obs = claude_resp.choices[0].message.content
if debug: console.print(Panel(Markdown(obs), title=&#34;[bold purple]Architect Consultation[/bold purple]&#34;, border_style=&#34;purple&#34;)) if debug: console.print(Panel(Markdown(obs), title=&#34;[bold medium_purple]Architect Consultation[/bold medium_purple]&#34;, border_style=&#34;medium_purple&#34;))
except Exception as e: except Exception as e:
if status: status.update(&#34;[bold orange3]Architect unavailable! Engineer continuing alone...&#34;) if status: status.update(&#34;[bold orange3]Architect unavailable! Engineer continuing alone...&#34;)
obs = f&#34;Architect unavailable ({str(e)}). Proceeding with your best technical judgment.&#34; obs = f&#34;Architect unavailable ({str(e)}). Proceeding with your best technical judgment.&#34;
elif fn == &#34;escalate_to_architect&#34;: elif fn == &#34;escalate_to_architect&#34;:
if status: status.update(&#34;[bold purple]Transferring control to Architect...&#34;) if status: status.update(&#34;[bold medium_purple]Transferring control to Architect...&#34;)
# Full escalation - Architect takes over # Full escalation - Architect takes over
current_brain = &#34;architect&#34; current_brain = &#34;architect&#34;
model = self.architect_model model = self.architect_model
@@ -1893,7 +2038,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
handover_msg = f&#34;HANDOVER FROM EXECUTION ENGINE\n\nReason: {args[&#39;reason&#39;]}\n\nContext: {args[&#39;context&#39;]}\n\nYou are now in control of this conversation.&#34; handover_msg = f&#34;HANDOVER FROM EXECUTION ENGINE\n\nReason: {args[&#39;reason&#39;]}\n\nContext: {args[&#39;context&#39;]}\n\nYou are now in control of this conversation.&#34;
pending_user_message = handover_msg pending_user_message = handover_msg
obs = &#34;Control transferred to Architect. Handover context will be provided.&#34; obs = &#34;Control transferred to Architect. Handover context will be provided.&#34;
if debug: console.print(Panel(Text(handover_msg), title=&#34;[bold purple]Escalation to Architect[/bold purple]&#34;, border_style=&#34;purple&#34;)) if debug: console.print(Panel(Text(handover_msg), title=&#34;[bold medium_purple]Escalation to Architect[/bold medium_purple]&#34;, border_style=&#34;medium_purple&#34;))
elif fn == &#34;return_to_engineer&#34;: elif fn == &#34;return_to_engineer&#34;:
if status: status.update(&#34;[bold blue]Transferring control back to Engineer...&#34;) if status: status.update(&#34;[bold blue]Transferring control back to Engineer...&#34;)
@@ -1951,19 +2096,8 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
messages.append(resp_msg.model_dump(exclude_none=True)) messages.append(resp_msg.model_dump(exclude_none=True))
except Exception: pass except Exception: pass
finally: finally:
try: # Auto-save session
log_dir = self.config.defaultdir self.save_session(messages, model=model)
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, &#34;ai_debug.json&#34;)
hist = []
if os.path.exists(log_path):
try:
with open(log_path, &#34;r&#34;) as f: hist = json.load(f)
except (IOError, json.JSONDecodeError): hist = []
hist.append({&#34;timestamp&#34;: datetime.datetime.now().isoformat(), &#34;roles&#34;: {&#34;strategic_engine&#34;: self.architect_model, &#34;execution_engine&#34;: self.engineer_model}, &#34;session&#34;: messages})
with open(log_path, &#34;w&#34;) as f: json.dump(hist[-10:], f, indent=4)
except Exception as e:
if debug: console.print(f&#34;[dim red]Debug log failed: {e}[/dim red]&#34;)
return { return {
&#34;response&#34;: messages[-1].get(&#34;content&#34;), &#34;response&#34;: messages[-1].get(&#34;content&#34;),
@@ -1989,6 +2123,40 @@ def confirm(self, user_input): return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.ai.delete_session"><code class="name flex">
<span>def <span class="ident">delete_session</span></span>(<span>self, session_id)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def delete_session(self, session_id):
&#34;&#34;&#34;Deletes a session by ID.&#34;&#34;&#34;
path = os.path.join(self.sessions_dir, f&#34;{session_id}.json&#34;)
if os.path.exists(path):
os.remove(path)
printer.success(f&#34;Session {session_id} deleted.&#34;)
else:
printer.error(f&#34;Session {session_id} not found.&#34;)</code></pre>
</details>
<div class="desc"><p>Deletes a session by ID.</p></div>
</dd>
<dt id="connpy.ai.get_last_session_id"><code class="name flex">
<span>def <span class="ident">get_last_session_id</span></span>(<span>self)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def get_last_session_id(self):
&#34;&#34;&#34;Returns the ID of the most recent session.&#34;&#34;&#34;
sessions = self._get_sessions()
return sessions[0][&#34;id&#34;] if sessions else None</code></pre>
</details>
<div class="desc"><p>Returns the ID of the most recent session.</p></div>
</dd>
<dt id="connpy.ai.get_node_info_tool"><code class="name flex"> <dt id="connpy.ai.get_node_info_tool"><code class="name flex">
<span>def <span class="ident">get_node_info_tool</span></span>(<span>self, node_name)</span> <span>def <span class="ident">get_node_info_tool</span></span>(<span>self, node_name)</span>
</code></dt> </code></dt>
@@ -2037,6 +2205,51 @@ def confirm(self, user_input): return True</code></pre>
</details> </details>
<div class="desc"><p>List nodes matching the filter pattern. Returns metadata for &lt;=5 nodes, names only for more.</p></div> <div class="desc"><p>List nodes matching the filter pattern. Returns metadata for &lt;=5 nodes, names only for more.</p></div>
</dd> </dd>
<dt id="connpy.ai.list_sessions"><code class="name flex">
<span>def <span class="ident">list_sessions</span></span>(<span>self)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def list_sessions(self):
&#34;&#34;&#34;Prints a list of sessions using printer.table.&#34;&#34;&#34;
sessions = self._get_sessions()
if not sessions:
printer.info(&#34;No saved AI sessions found.&#34;)
return
columns = [&#34;ID&#34;, &#34;Title&#34;, &#34;Created At&#34;, &#34;Model&#34;]
rows = [[s[&#34;id&#34;], s[&#34;title&#34;], s[&#34;created_at&#34;], s[&#34;model&#34;]] for s in sessions]
printer.table(&#34;AI Persisted Sessions&#34;, columns, rows)</code></pre>
</details>
<div class="desc"><p>Prints a list of sessions using printer.table.</p></div>
</dd>
<dt id="connpy.ai.load_session_data"><code class="name flex">
<span>def <span class="ident">load_session_data</span></span>(<span>self, session_id)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def load_session_data(self, session_id):
&#34;&#34;&#34;Loads a session&#39;s raw data by ID.&#34;&#34;&#34;
path = os.path.join(self.sessions_dir, f&#34;{session_id}.json&#34;)
if os.path.exists(path):
try:
with open(path, &#34;r&#34;) as f:
data = json.load(f)
self.session_id = session_id
self.session_path = path
return data
except Exception as e:
printer.error(f&#34;Failed to load session {session_id}: {e}&#34;)
return None</code></pre>
</details>
<div class="desc"><p>Loads a session's raw data by ID.</p></div>
</dd>
<dt id="connpy.ai.manage_memory_tool"><code class="name flex"> <dt id="connpy.ai.manage_memory_tool"><code class="name flex">
<span>def <span class="ident">manage_memory_tool</span></span>(<span>self, content, action='append')</span> <span>def <span class="ident">manage_memory_tool</span></span>(<span>self, content, action='append')</span>
</code></dt> </code></dt>
@@ -2197,6 +2410,58 @@ def confirm(self, user_input): return True</code></pre>
</details> </details>
<div class="desc"><p>Execute commands on nodes matching the filter. Native interactive confirmation for unsafe commands.</p></div> <div class="desc"><p>Execute commands on nodes matching the filter. Native interactive confirmation for unsafe commands.</p></div>
</dd> </dd>
<dt id="connpy.ai.save_session"><code class="name flex">
<span>def <span class="ident">save_session</span></span>(<span>self, history, title=None, model=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def save_session(self, history, title=None, model=None):
&#34;&#34;&#34;Saves current history to the session file.&#34;&#34;&#34;
if not self.session_id:
# Generate ID from first user query if available
first_user_msg = next((m[&#34;content&#34;] for m in history if m[&#34;role&#34;] == &#34;user&#34;), &#34;new-session&#34;)
self.session_id = self._generate_session_id(first_user_msg)
self.session_path = os.path.join(self.sessions_dir, f&#34;{self.session_id}.json&#34;)
# If it&#39;s a new file, we might want to set a better title
if not os.path.exists(self.session_path) and not title:
raw_title = next((m[&#34;content&#34;] for m in history if m[&#34;role&#34;] == &#34;user&#34;), &#34;New Session&#34;)
# Clean title: remove newlines, multiple spaces
clean_title = &#34; &#34;.join(raw_title.split())
if len(clean_title) &gt; 40:
title = clean_title[:37].strip() + &#34;...&#34;
else:
title = clean_title
try:
# Read existing metadata if it exists
metadata = {}
if os.path.exists(self.session_path):
with open(self.session_path, &#34;r&#34;) as f:
metadata = json.load(f)
metadata.update({
&#34;id&#34;: self.session_id,
&#34;title&#34;: title or metadata.get(&#34;title&#34;, &#34;New Session&#34;),
&#34;created_at&#34;: metadata.get(&#34;created_at&#34;, datetime.datetime.now().isoformat()),
&#34;updated_at&#34;: datetime.datetime.now().isoformat(),
&#34;model&#34;: model or metadata.get(&#34;model&#34;, self.engineer_model),
&#34;history&#34;: history
})
with open(self.session_path, &#34;w&#34;) as f:
json.dump(metadata, f, indent=4)
except Exception as e:
printer.error(f&#34;Failed to save session: {e}&#34;)
except Exception as e:
printer.error(f&#34;Failed to save session: {e}&#34;)</code></pre>
</details>
<div class="desc"><p>Saves current history to the session file.</p></div>
</dd>
</dl> </dl>
</dd> </dd>
<dt id="connpy.configfile"><code class="flex name class"> <dt id="connpy.configfile"><code class="flex name class">
@@ -2248,27 +2513,31 @@ class configfile:
&#39;&#39;&#39; &#39;&#39;&#39;
home = os.path.expanduser(&#34;~&#34;) home = os.path.expanduser(&#34;~&#34;)
defaultdir = home + &#39;/.config/conn&#39; defaultdir = home + &#39;/.config/conn&#39;
self.defaultdir = defaultdir
Path(defaultdir).mkdir(parents=True, exist_ok=True)
Path(f&#34;{defaultdir}/plugins&#34;).mkdir(parents=True, exist_ok=True)
pathfile = defaultdir + &#39;/.folder&#39;
try:
with open(pathfile, &#34;r&#34;) as f:
configdir = f.read().strip()
except (FileNotFoundError, IOError):
with open(pathfile, &#34;w&#34;) as f:
f.write(str(defaultdir))
configdir = defaultdir
defaultfile = configdir + &#39;/config.yaml&#39;
self.cachefile = configdir + &#39;/.config.cache.json&#39;
self.fzf_cachefile = configdir + &#39;/.fzf_nodes_cache.txt&#39;
self.folders_cachefile = configdir + &#39;/.folders_cache.txt&#39;
self.profiles_cachefile = configdir + &#39;/.profiles_cache.txt&#39;
defaultkey = configdir + &#39;/.osk&#39;
if conf == None:
self.file = defaultfile
# Backwards compatibility: Migrate from JSON to YAML if conf is None:
# Standard path: use ~/.config/conn and respect .folder redirection
self.anchor_path = defaultdir
self.defaultdir = defaultdir
Path(defaultdir).mkdir(parents=True, exist_ok=True)
pathfile = defaultdir + &#39;/.folder&#39;
try:
with open(pathfile, &#34;r&#34;) as f:
configdir = f.read().strip()
except (FileNotFoundError, IOError):
with open(pathfile, &#34;w&#34;) as f:
f.write(str(defaultdir))
configdir = defaultdir
self.defaultdir = configdir
self.file = configdir + &#39;/config.yaml&#39;
self.key = key or (configdir + &#39;/.osk&#39;)
# Ensure redirected directories exist
Path(configdir).mkdir(parents=True, exist_ok=True)
Path(f&#34;{configdir}/plugins&#34;).mkdir(parents=True, exist_ok=True)
# Backwards compatibility: Migrate from JSON to YAML only for default path
legacy_json = configdir + &#39;/config.json&#39; legacy_json = configdir + &#39;/config.json&#39;
legacy_noext = configdir + &#39;/config&#39; legacy_noext = configdir + &#39;/config&#39;
legacy_file = None legacy_file = None
@@ -2291,38 +2560,44 @@ class configfile:
os.remove(self.file) os.remove(self.file)
printer.warning(&#34;YAML verification failed after migration, keeping legacy config.&#34;) printer.warning(&#34;YAML verification failed after migration, keeping legacy config.&#34;)
else: else:
with open(self.cachefile, &#39;w&#39;) as f: # Note: cachefile is derived later, we use temp one for migration sync
temp_cache = configdir + &#39;/.config.cache.json&#39;
with open(temp_cache, &#39;w&#39;) as f:
json.dump(old_data, f) json.dump(old_data, f)
shutil.move(legacy_file, legacy_file + &#34;.backup&#34;) shutil.move(legacy_file, legacy_file + &#34;.backup&#34;)
printer.success(f&#34;Migrated legacy config ({len(old_data.get(&#39;connections&#39;,{}))} folders/nodes) into YAML and Cache successfully!&#34;) printer.success(f&#34;Migrated legacy config ({len(old_data.get(&#39;connections&#39;,{}))} folders/nodes) into YAML and Cache successfully!&#34;)
except Exception as e: except Exception as e:
# Clean up partial YAML if it was created
if os.path.exists(self.file): if os.path.exists(self.file):
try: try: os.remove(self.file)
os.remove(self.file) except OSError: pass
except OSError:
pass
printer.warning(f&#34;Failed to migrate legacy config: {e}&#34;) printer.warning(f&#34;Failed to migrate legacy config: {e}&#34;)
else: else:
self.file = conf # Custom path (common in tests): isolate everything to the conf parent directory
self.file = os.path.abspath(conf)
configdir = os.path.dirname(self.file)
self.anchor_path = configdir
self.defaultdir = configdir
self.key = os.path.abspath(key) if key else (configdir + &#39;/.osk&#39;)
if key == None: # Sidecar files always live next to the config file (or in the redirected configdir)
self.key = defaultkey self.cachefile = configdir + &#39;/.config.cache.json&#39;
else: self.fzf_cachefile = configdir + &#39;/.fzf_nodes_cache.txt&#39;
self.key = key self.folders_cachefile = configdir + &#39;/.folders_cache.txt&#39;
self.profiles_cachefile = configdir + &#39;/.profiles_cache.txt&#39;
if os.path.exists(self.file): if os.path.exists(self.file):
config = self._loadconfig(self.file) config = self._loadconfig(self.file)
else: else:
config = self._createconfig(self.file) config = self._createconfig(self.file)
self.config = config[&#34;config&#34;] self.config = config[&#34;config&#34;]
self.connections = config[&#34;connections&#34;] self.connections = config[&#34;connections&#34;]
self.profiles = config[&#34;profiles&#34;] self.profiles = config[&#34;profiles&#34;]
if not os.path.exists(self.key): if not os.path.exists(self.key):
self._createkey(self.key) self._createkey(self.key)
with open(self.key) as f: with open(self.key) as f:
self.privatekey = RSA.import_key(f.read()) self.privatekey = RSA.import_key(f.read())
f.close()
self.publickey = self.privatekey.publickey() self.publickey = self.privatekey.publickey()
# Self-heal text caches if they are missing # Self-heal text caches if they are missing
@@ -4724,12 +4999,17 @@ def test(self, commands, expected, vars = None,*, prompt = None, parallel = 10,
<li><code><a title="connpy.ai.architect_system_prompt" href="#connpy.ai.architect_system_prompt">architect_system_prompt</a></code></li> <li><code><a title="connpy.ai.architect_system_prompt" href="#connpy.ai.architect_system_prompt">architect_system_prompt</a></code></li>
<li><code><a title="connpy.ai.ask" href="#connpy.ai.ask">ask</a></code></li> <li><code><a title="connpy.ai.ask" href="#connpy.ai.ask">ask</a></code></li>
<li><code><a title="connpy.ai.confirm" href="#connpy.ai.confirm">confirm</a></code></li> <li><code><a title="connpy.ai.confirm" href="#connpy.ai.confirm">confirm</a></code></li>
<li><code><a title="connpy.ai.delete_session" href="#connpy.ai.delete_session">delete_session</a></code></li>
<li><code><a title="connpy.ai.engineer_system_prompt" href="#connpy.ai.engineer_system_prompt">engineer_system_prompt</a></code></li> <li><code><a title="connpy.ai.engineer_system_prompt" href="#connpy.ai.engineer_system_prompt">engineer_system_prompt</a></code></li>
<li><code><a title="connpy.ai.get_last_session_id" href="#connpy.ai.get_last_session_id">get_last_session_id</a></code></li>
<li><code><a title="connpy.ai.get_node_info_tool" href="#connpy.ai.get_node_info_tool">get_node_info_tool</a></code></li> <li><code><a title="connpy.ai.get_node_info_tool" href="#connpy.ai.get_node_info_tool">get_node_info_tool</a></code></li>
<li><code><a title="connpy.ai.list_nodes_tool" href="#connpy.ai.list_nodes_tool">list_nodes_tool</a></code></li> <li><code><a title="connpy.ai.list_nodes_tool" href="#connpy.ai.list_nodes_tool">list_nodes_tool</a></code></li>
<li><code><a title="connpy.ai.list_sessions" href="#connpy.ai.list_sessions">list_sessions</a></code></li>
<li><code><a title="connpy.ai.load_session_data" href="#connpy.ai.load_session_data">load_session_data</a></code></li>
<li><code><a title="connpy.ai.manage_memory_tool" href="#connpy.ai.manage_memory_tool">manage_memory_tool</a></code></li> <li><code><a title="connpy.ai.manage_memory_tool" href="#connpy.ai.manage_memory_tool">manage_memory_tool</a></code></li>
<li><code><a title="connpy.ai.register_ai_tool" href="#connpy.ai.register_ai_tool">register_ai_tool</a></code></li> <li><code><a title="connpy.ai.register_ai_tool" href="#connpy.ai.register_ai_tool">register_ai_tool</a></code></li>
<li><code><a title="connpy.ai.run_commands_tool" href="#connpy.ai.run_commands_tool">run_commands_tool</a></code></li> <li><code><a title="connpy.ai.run_commands_tool" href="#connpy.ai.run_commands_tool">run_commands_tool</a></code></li>
<li><code><a title="connpy.ai.save_session" href="#connpy.ai.save_session">save_session</a></code></li>
</ul> </ul>
</li> </li>
<li> <li>
@@ -4761,7 +5041,7 @@ def test(self, commands, expected, vars = None,*, prompt = None, parallel = 10,
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests.conftest API documentation</title> <title>connpy.tests.conftest API documentation</title>
<meta name="description" content="Shared fixtures for connpy tests …"> <meta name="description" content="Shared fixtures for connpy tests …">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -258,7 +258,7 @@ def tmp_config_dir(tmp_path):
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests API documentation</title> <title>connpy.tests API documentation</title>
<meta name="description" content=""> <meta name="description" content="">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -127,7 +127,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+268 -5
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests.test_ai API documentation</title> <title>connpy.tests.test_ai API documentation</title>
<meta name="description" content="Tests for connpy.ai module."> <meta name="description" content="Tests for connpy.ai module.">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -88,7 +88,7 @@ el.replaceWith(d);
def test_init_loads_memory(self, ai_config, tmp_path, mock_litellm): def test_init_loads_memory(self, ai_config, tmp_path, mock_litellm):
&#34;&#34;&#34;Loads long-term memory from file if it exists.&#34;&#34;&#34; &#34;&#34;&#34;Loads long-term memory from file if it exists.&#34;&#34;&#34;
memory_path = os.path.expanduser(&#34;~/.config/conn/ai_memory.md&#34;) memory_path = os.path.join(ai_config.defaultdir, &#34;ai_memory.md&#34;)
from connpy.ai import ai from connpy.ai import ai
with patch(&#34;os.path.exists&#34;, side_effect=lambda p: True if p == memory_path else os.path.exists(p)): with patch(&#34;os.path.exists&#34;, side_effect=lambda p: True if p == memory_path else os.path.exists(p)):
@@ -132,7 +132,7 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def test_init_loads_memory(self, ai_config, tmp_path, mock_litellm): <pre><code class="python">def test_init_loads_memory(self, ai_config, tmp_path, mock_litellm):
&#34;&#34;&#34;Loads long-term memory from file if it exists.&#34;&#34;&#34; &#34;&#34;&#34;Loads long-term memory from file if it exists.&#34;&#34;&#34;
memory_path = os.path.expanduser(&#34;~/.config/conn/ai_memory.md&#34;) memory_path = os.path.join(ai_config.defaultdir, &#34;ai_memory.md&#34;)
from connpy.ai import ai from connpy.ai import ai
with patch(&#34;os.path.exists&#34;, side_effect=lambda p: True if p == memory_path else os.path.exists(p)): with patch(&#34;os.path.exists&#34;, side_effect=lambda p: True if p == memory_path else os.path.exists(p)):
@@ -201,6 +201,224 @@ el.replaceWith(d);
</dd> </dd>
</dl> </dl>
</dd> </dd>
<dt id="connpy.tests.test_ai.TestAISessions"><code class="flex name class">
<span>class <span class="ident">TestAISessions</span></span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class TestAISessions:
@pytest.fixture
def myai(self, ai_config, mock_litellm, tmp_path):
from connpy.ai import ai
ai_config.defaultdir = str(tmp_path)
return ai(ai_config)
def test_sessions_dir_initialization(self, myai, tmp_path):
assert os.path.exists(os.path.join(tmp_path, &#34;ai_sessions&#34;))
assert myai.sessions_dir == str(tmp_path / &#34;ai_sessions&#34;)
def test_generate_session_id(self, myai):
session_id = myai._generate_session_id(&#34;Any query&#34;)
# Format: YYYYMMDD-HHMMSS
assert len(session_id) == 15
assert &#34;-&#34; in session_id
parts = session_id.split(&#34;-&#34;)
assert len(parts[0]) == 8 # YYYYMMDD
assert len(parts[1]) == 6 # HHMMSS
def test_save_and_load_session(self, myai):
history = [
{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;Hello&#34;},
{&#34;role&#34;: &#34;assistant&#34;, &#34;content&#34;: &#34;Hi&#34;}
]
myai.save_session(history, title=&#34;Test Session&#34;)
session_id = myai.session_id
# Load it back
loaded = myai.load_session_data(session_id)
assert loaded[&#34;title&#34;] == &#34;Test Session&#34;
assert loaded[&#34;history&#34;] == history
assert loaded[&#34;model&#34;] == myai.engineer_model
def test_list_sessions(self, myai, capsys):
history = [{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;Query 1&#34;}]
myai.save_session(history, title=&#34;Session 1&#34;)
# Use a second instance to list
myai.list_sessions()
captured = capsys.readouterr()
assert &#34;Session 1&#34; in captured.out
assert &#34;AI Persisted Sessions&#34; in captured.out
def test_get_last_session_id(self, myai):
# Save two sessions
myai.session_id = None # Force new
myai.save_session([{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;First&#34;}])
first_id = myai.session_id
import time
time.sleep(1.1) # Ensure different timestamp
myai.session_id = None # Force new
myai.save_session([{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;Second&#34;}])
second_id = myai.session_id
last_id = myai.get_last_session_id()
assert last_id == second_id
assert last_id != first_id
def test_delete_session(self, myai):
myai.save_session([{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;To be deleted&#34;}])
session_id = myai.session_id
assert os.path.exists(myai.session_path)
myai.delete_session(session_id)
assert not os.path.exists(myai.session_path)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.tests.test_ai.TestAISessions.myai"><code class="name flex">
<span>def <span class="ident">myai</span></span>(<span>self, ai_config, mock_litellm, tmp_path)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@pytest.fixture
def myai(self, ai_config, mock_litellm, tmp_path):
from connpy.ai import ai
ai_config.defaultdir = str(tmp_path)
return ai(ai_config)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.tests.test_ai.TestAISessions.test_delete_session"><code class="name flex">
<span>def <span class="ident">test_delete_session</span></span>(<span>self, myai)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def test_delete_session(self, myai):
myai.save_session([{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;To be deleted&#34;}])
session_id = myai.session_id
assert os.path.exists(myai.session_path)
myai.delete_session(session_id)
assert not os.path.exists(myai.session_path)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.tests.test_ai.TestAISessions.test_generate_session_id"><code class="name flex">
<span>def <span class="ident">test_generate_session_id</span></span>(<span>self, myai)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def test_generate_session_id(self, myai):
session_id = myai._generate_session_id(&#34;Any query&#34;)
# Format: YYYYMMDD-HHMMSS
assert len(session_id) == 15
assert &#34;-&#34; in session_id
parts = session_id.split(&#34;-&#34;)
assert len(parts[0]) == 8 # YYYYMMDD
assert len(parts[1]) == 6 # HHMMSS</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.tests.test_ai.TestAISessions.test_get_last_session_id"><code class="name flex">
<span>def <span class="ident">test_get_last_session_id</span></span>(<span>self, myai)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def test_get_last_session_id(self, myai):
# Save two sessions
myai.session_id = None # Force new
myai.save_session([{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;First&#34;}])
first_id = myai.session_id
import time
time.sleep(1.1) # Ensure different timestamp
myai.session_id = None # Force new
myai.save_session([{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;Second&#34;}])
second_id = myai.session_id
last_id = myai.get_last_session_id()
assert last_id == second_id
assert last_id != first_id</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.tests.test_ai.TestAISessions.test_list_sessions"><code class="name flex">
<span>def <span class="ident">test_list_sessions</span></span>(<span>self, myai, capsys)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def test_list_sessions(self, myai, capsys):
history = [{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;Query 1&#34;}]
myai.save_session(history, title=&#34;Session 1&#34;)
# Use a second instance to list
myai.list_sessions()
captured = capsys.readouterr()
assert &#34;Session 1&#34; in captured.out
assert &#34;AI Persisted Sessions&#34; in captured.out</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.tests.test_ai.TestAISessions.test_save_and_load_session"><code class="name flex">
<span>def <span class="ident">test_save_and_load_session</span></span>(<span>self, myai)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def test_save_and_load_session(self, myai):
history = [
{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;Hello&#34;},
{&#34;role&#34;: &#34;assistant&#34;, &#34;content&#34;: &#34;Hi&#34;}
]
myai.save_session(history, title=&#34;Test Session&#34;)
session_id = myai.session_id
# Load it back
loaded = myai.load_session_data(session_id)
assert loaded[&#34;title&#34;] == &#34;Test Session&#34;
assert loaded[&#34;history&#34;] == history
assert loaded[&#34;model&#34;] == myai.engineer_model</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.tests.test_ai.TestAISessions.test_sessions_dir_initialization"><code class="name flex">
<span>def <span class="ident">test_sessions_dir_initialization</span></span>(<span>self, myai, tmp_path)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def test_sessions_dir_initialization(self, myai, tmp_path):
assert os.path.exists(os.path.join(tmp_path, &#34;ai_sessions&#34;))
assert myai.sessions_dir == str(tmp_path / &#34;ai_sessions&#34;)</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.tests.test_ai.TestAsk"><code class="flex name class"> <dt id="connpy.tests.test_ai.TestAsk"><code class="flex name class">
<span>class <span class="ident">TestAsk</span></span> <span>class <span class="ident">TestAsk</span></span>
</code></dt> </code></dt>
@@ -807,7 +1025,18 @@ def myai(self, ai_config, mock_litellm):
{&#34;role&#34;: &#34;assistant&#34;, &#34;content&#34;: &#34;Found r1&#34;} {&#34;role&#34;: &#34;assistant&#34;, &#34;content&#34;: &#34;Found r1&#34;}
] ]
result = myai._sanitize_messages(messages) result = myai._sanitize_messages(messages)
assert len(result) == 4</code></pre> assert len(result) == 4
def test_sanitize_strips_cache_control(self, myai):
&#34;&#34;&#34;_sanitize_messages should convert list-based content (with cache_control) back to strings.&#34;&#34;&#34;
messages = [
{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: &#34;system prompt&#34;, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]},
{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;hello&#34;}
]
result = myai._sanitize_messages(messages)
assert result[0][&#34;role&#34;] == &#34;system&#34;
assert isinstance(result[0][&#34;content&#34;], str)
assert result[0][&#34;content&#34;] == &#34;system prompt&#34;</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
<h3>Methods</h3> <h3>Methods</h3>
@@ -925,6 +1154,27 @@ def myai(self, ai_config, mock_litellm):
</details> </details>
<div class="desc"><p>Tool responses without preceding tool_calls are removed.</p></div> <div class="desc"><p>Tool responses without preceding tool_calls are removed.</p></div>
</dd> </dd>
<dt id="connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_strips_cache_control"><code class="name flex">
<span>def <span class="ident">test_sanitize_strips_cache_control</span></span>(<span>self, myai)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def test_sanitize_strips_cache_control(self, myai):
&#34;&#34;&#34;_sanitize_messages should convert list-based content (with cache_control) back to strings.&#34;&#34;&#34;
messages = [
{&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: [{&#34;type&#34;: &#34;text&#34;, &#34;text&#34;: &#34;system prompt&#34;, &#34;cache_control&#34;: {&#34;type&#34;: &#34;ephemeral&#34;}}]},
{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;hello&#34;}
]
result = myai._sanitize_messages(messages)
assert result[0][&#34;role&#34;] == &#34;system&#34;
assert isinstance(result[0][&#34;content&#34;], str)
assert result[0][&#34;content&#34;] == &#34;system prompt&#34;</code></pre>
</details>
<div class="desc"><p>_sanitize_messages should convert list-based content (with cache_control) back to strings.</p></div>
</dd>
</dl> </dl>
</dd> </dd>
<dt id="connpy.tests.test_ai.TestToolDefinitions"><code class="flex name class"> <dt id="connpy.tests.test_ai.TestToolDefinitions"><code class="flex name class">
@@ -1373,6 +1623,18 @@ def myai(self, ai_config, mock_litellm):
</ul> </ul>
</li> </li>
<li> <li>
<h4><code><a title="connpy.tests.test_ai.TestAISessions" href="#connpy.tests.test_ai.TestAISessions">TestAISessions</a></code></h4>
<ul class="">
<li><code><a title="connpy.tests.test_ai.TestAISessions.myai" href="#connpy.tests.test_ai.TestAISessions.myai">myai</a></code></li>
<li><code><a title="connpy.tests.test_ai.TestAISessions.test_delete_session" href="#connpy.tests.test_ai.TestAISessions.test_delete_session">test_delete_session</a></code></li>
<li><code><a title="connpy.tests.test_ai.TestAISessions.test_generate_session_id" href="#connpy.tests.test_ai.TestAISessions.test_generate_session_id">test_generate_session_id</a></code></li>
<li><code><a title="connpy.tests.test_ai.TestAISessions.test_get_last_session_id" href="#connpy.tests.test_ai.TestAISessions.test_get_last_session_id">test_get_last_session_id</a></code></li>
<li><code><a title="connpy.tests.test_ai.TestAISessions.test_list_sessions" href="#connpy.tests.test_ai.TestAISessions.test_list_sessions">test_list_sessions</a></code></li>
<li><code><a title="connpy.tests.test_ai.TestAISessions.test_save_and_load_session" href="#connpy.tests.test_ai.TestAISessions.test_save_and_load_session">test_save_and_load_session</a></code></li>
<li><code><a title="connpy.tests.test_ai.TestAISessions.test_sessions_dir_initialization" href="#connpy.tests.test_ai.TestAISessions.test_sessions_dir_initialization">test_sessions_dir_initialization</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.tests.test_ai.TestAsk" href="#connpy.tests.test_ai.TestAsk">TestAsk</a></code></h4> <h4><code><a title="connpy.tests.test_ai.TestAsk" href="#connpy.tests.test_ai.TestAsk">TestAsk</a></code></h4>
<ul class=""> <ul class="">
<li><code><a title="connpy.tests.test_ai.TestAsk.myai" href="#connpy.tests.test_ai.TestAsk.myai">myai</a></code></li> <li><code><a title="connpy.tests.test_ai.TestAsk.myai" href="#connpy.tests.test_ai.TestAsk.myai">myai</a></code></li>
@@ -1423,6 +1685,7 @@ def myai(self, ai_config, mock_litellm):
<li><code><a title="connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_preserves_valid_tool_pairs" href="#connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_preserves_valid_tool_pairs">test_sanitize_preserves_valid_tool_pairs</a></code></li> <li><code><a title="connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_preserves_valid_tool_pairs" href="#connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_preserves_valid_tool_pairs">test_sanitize_preserves_valid_tool_pairs</a></code></li>
<li><code><a title="connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_removes_orphan_tool_calls" href="#connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_removes_orphan_tool_calls">test_sanitize_removes_orphan_tool_calls</a></code></li> <li><code><a title="connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_removes_orphan_tool_calls" href="#connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_removes_orphan_tool_calls">test_sanitize_removes_orphan_tool_calls</a></code></li>
<li><code><a title="connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_removes_orphan_tool_responses" href="#connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_removes_orphan_tool_responses">test_sanitize_removes_orphan_tool_responses</a></code></li> <li><code><a title="connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_removes_orphan_tool_responses" href="#connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_removes_orphan_tool_responses">test_sanitize_removes_orphan_tool_responses</a></code></li>
<li><code><a title="connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_strips_cache_control" href="#connpy.tests.test_ai.TestSanitizeMessages.test_sanitize_strips_cache_control">test_sanitize_strips_cache_control</a></code></li>
</ul> </ul>
</li> </li>
<li> <li>
@@ -1464,7 +1727,7 @@ def myai(self, ai_config, mock_litellm):
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests.test_api API documentation</title> <title>connpy.tests.test_api API documentation</title>
<meta name="description" content="Tests for connpy.api module — Flask routes."> <meta name="description" content="Tests for connpy.api module — Flask routes.">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -876,7 +876,7 @@ def test_test_action(self, mock_nodes_cls, api_client):
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests.test_capture API documentation</title> <title>connpy.tests.test_capture API documentation</title>
<meta name="description" content="Tests for connpy.core_plugins.capture"> <meta name="description" content="Tests for connpy.core_plugins.capture">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -229,7 +229,7 @@ def test_is_port_in_use(self, mock_socket, mock_connapp):
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests.test_completion API documentation</title> <title>connpy.tests.test_completion API documentation</title>
<meta name="description" content="Tests for connpy.completion module."> <meta name="description" content="Tests for connpy.completion module.">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -433,7 +433,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests.test_configfile API documentation</title> <title>connpy.tests.test_configfile API documentation</title>
<meta name="description" content="Tests for connpy.configfile module."> <meta name="description" content="Tests for connpy.configfile module.">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -2003,7 +2003,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests.test_context API documentation</title> <title>connpy.tests.test_context API documentation</title>
<meta name="description" content="Tests for connpy.core_plugins.context"> <meta name="description" content="Tests for connpy.core_plugins.context">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -469,7 +469,7 @@ def mock_connapp():
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests.test_core API documentation</title> <title>connpy.tests.test_core API documentation</title>
<meta name="description" content="Tests for connpy.core module — node and nodes classes."> <meta name="description" content="Tests for connpy.core module — node and nodes classes.">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -1300,7 +1300,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests.test_hooks API documentation</title> <title>connpy.tests.test_hooks API documentation</title>
<meta name="description" content="Tests for connpy.hooks module — MethodHook and ClassHook."> <meta name="description" content="Tests for connpy.hooks module — MethodHook and ClassHook.">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -673,7 +673,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests.test_plugins API documentation</title> <title>connpy.tests.test_plugins API documentation</title>
<meta name="description" content="Tests for connpy.plugins module."> <meta name="description" content="Tests for connpy.plugins module.">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -917,7 +917,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests.test_printer API documentation</title> <title>connpy.tests.test_printer API documentation</title>
<meta name="description" content="Tests for connpy.printer module."> <meta name="description" content="Tests for connpy.printer module.">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -263,7 +263,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tests.test_sync API documentation</title> <title>connpy.tests.test_sync API documentation</title>
<meta name="description" content="Tests for connpy.core_plugins.sync"> <meta name="description" content="Tests for connpy.core_plugins.sync">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -390,7 +390,7 @@ def test_get_credentials_success(self, MockCreds, mock_exists, mock_connapp):
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer> </footer>
</body> </body>
</html> </html>