pax_global_header00006660000000000000000000000064152723545240014523gustar00rootroot0000000000000052 comment=4ab5fd9dff6270ab836e90245dda78e41de30acc corindo-cli-v0.0.3-4ab5fd9dff6270ab836e90245dda78e41de30acc/000077500000000000000000000000001527235452400220505ustar00rootroot00000000000000corindo-cli-v0.0.3-4ab5fd9dff6270ab836e90245dda78e41de30acc/README.md000066400000000000000000000010431527235452400233250ustar00rootroot00000000000000# Corindo CLI A simple implementation of an REPL-loop AI agent. ## Getting started Make sure debgpt works first. This AI agent can use the settings from debgpt. ## Purpose I just want a simple, very light-weight AI agent. ## License All the source code are under GPL-3+ See /usr/share/common-licenses/GPL-3 in Debian system. ## Note * Please run this AI agent inside a sandbox or docker. It is too simple and have almost no security check. It will be a bit dangerous to let it access everything on your home directoty or machine. corindo-cli-v0.0.3-4ab5fd9dff6270ab836e90245dda78e41de30acc/corindo-cli.py000077500000000000000000000441531527235452400246360ustar00rootroot00000000000000#!/usr/bin/env python3 # # corindo-cli, a simple REPL AI agent. # Copyright (C) 2026 Ying-Chun Liu (PaulLiu) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import json import os import pathlib import readline import subprocess import sys import tomllib from openai import OpenAI def load_config(): """Loads configuration from ~/.config/corindo-cli/config.toml, with fallback to legacy ~/.debgpt/config.toml. """ config_path = pathlib.Path.home() / ".config" / "corindo-cli" / "config.toml" if not config_path.exists(): # Fallback to legacy path legacy_path = pathlib.Path.home() / ".debgpt" / "config.toml" if legacy_path.exists(): config_path = legacy_path else: print(f"❌ Configuration file not found:") print(f" - Tried: ~/.config/corindo-cli/config.toml") print(f" - Tried: ~/.debgpt/config.toml") print( "Please create one of them and fill in 'openai_api_key'," " 'openai_base_url', and 'openai_model'." ) sys.exit(1) try: with open(config_path, "rb") as f: return tomllib.load(f), config_path except Exception as e: print(f"❌ Failed to parse TOML config file ({config_path}): {e}") sys.exit(1) # Load configuration and initialize the OpenAI client config, loaded_path = load_config() API_KEY = config.get("openai_api_key") BASE_URL = config.get("openai_base_url") MODEL_NAME = config.get("openai_model", "gpt-4o") if not API_KEY: print("❌ Configuration error: Missing required field 'openai_api_key'") sys.exit(1) client_kwargs = {"api_key": API_KEY} if BASE_URL: client_kwargs["base_url"] = BASE_URL client = OpenAI(**client_kwargs) # Helper to robustly call LLM and handle 413 Request Entity Too Large errors def call_llm(messages, tools=None, tool_choice=None): """Calls OpenAI API and automatically handles 413 context overflow by popping the latest request, cleaning older bulky tool outputs while preserving early turns/system prompts, and retrying. """ while True: try: kwargs = {"model": MODEL_NAME, "messages": messages} if tools is not None: kwargs["tools"] = tools if tool_choice is not None: kwargs["tool_choice"] = tool_choice return client.chat.completions.create(**kwargs) except Exception as e: err_str = str(e) if "413" in err_str or "Request Entity Too Large" in err_str: print(f"\n⚠️ [Context Overflow] Server returned 413 (Request Entity Too Large).") if len(messages) > 1: # Pop the latest request (the straw that broke the camel's back) failed_msg = messages.pop() print(f"🧹 Cleaning and organizing conversation history (preserving first 5 turns)...") # Clean/organize older messages (preserve system prompt at index 0 and first 10 messages / ~5 turns) cleaned_messages = [] for idx, m in enumerate(messages): if idx == 0 or idx < 10: cleaned_messages.append(m) elif isinstance(m, dict) and m.get("role") == "tool": content = m.get("content", "") if len(content) > 200: pruned = m.copy() pruned["content"] = f"[Pruned old tool output to resolve 413 error. Original length: {len(content)} chars]" cleaned_messages.append(pruned) else: cleaned_messages.append(m) else: cleaned_messages.append(m) messages.clear() messages.extend(cleaned_messages) # Put the failed message back messages.append(failed_msg) print(f"🔄 Retrying request with optimized context...\n") continue else: raise e else: raise e # Strict user confirmation helper (requires explicit Y or N) def get_user_confirmation(prompt_text: str) -> bool: while True: try: user_input = input(prompt_text).strip().upper() if user_input == "Y": return True elif user_input == "N": return False else: print( "⚠️ Invalid input: Please enter 'Y' or 'N' (case-insensitive)." ) except (KeyboardInterrupt, EOFError): print("\nOperation cancelled by user.") return False # Smart truncation helper to protect the model context window def truncate_output(output: str, max_chars: int = 8000) -> str: """Truncates output by preserving the head and tail if it exceeds max_chars.""" if len(output) <= max_chars: return output half = max_chars // 2 head = output[:half] tail = output[-half:] return ( f"{head}\n\n... [Output truncated: omitted approx" f" {len(output) - max_chars} characters in between] ...\n\n{tail}" ) # Define function calling tools tools = [ { "type": "function", "function": { "name": "run_shell_command", "description": ( "Executes a shell command safely, such as debuild, git status," " cargo build, etc." ), "parameters": { "type": "object", "properties": { "command": { "type": "string", "description": "The bash command to execute", } }, "required": ["command"], }, }, }, { "type": "function", "function": { "name": "read_file", "description": ( "Reads content of a specified file path. Optionally accepts start_line and end_line (1-indexed) to read a specific range." ), "parameters": { "type": "object", "properties": { "filepath": { "type": "string", "description": "File path to read", }, "start_line": { "type": "integer", "description": "Optional 1-indexed starting line number (inclusive)", }, "end_line": { "type": "integer", "description": "Optional 1-indexed ending line number (inclusive)", }, }, "required": ["filepath"], }, }, }, { "type": "function", "function": { "name": "write_file", "description": ( "Writes or overwrites content into a specified file path" ), "parameters": { "type": "object", "properties": { "filepath": { "type": "string", "description": "File path to write", }, "content": { "type": "string", "description": "Content to write", }, }, "required": ["filepath", "content"], }, }, }, { "type": "function", "function": { "name": "fetch_webpage", "description": ( "Fetches and extracts the text content of a public webpage given a URL." ), "parameters": { "type": "object", "properties": { "url": { "type": "string", "description": "The public URL to fetch", } }, "required": ["url"], }, }, }, ] # Tool execution logic def execute_tool(name: str, arguments: dict) -> str: if name == "run_shell_command": cmd = arguments.get("command", "") if any( danger in cmd for danger in ["rm -rf /", "mkfs", "> /dev/sd", ":(){ :|:& };:"] ): return "Error: Command blocked due to critical safety risk." prompt = ( f"\n⚠️ [Security Confirmation] Agent requests to execute shell" f" command:\n > {cmd}\nAllow execution? (Y/N): " ) if not get_user_confirmation(prompt): return "Execution cancelled: User explicitly denied command execution." print(f"\n🚀 Executing: {cmd}\n" + "-" * 40) try: # Popen for real-time streaming, no timeout to support long builds # (e.g. debuild) process = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) output_lines = [] for line in process.stdout: print(line, end="") output_lines.append(line) process.wait() print("-" * 40) full_output = "".join(output_lines) if not full_output.strip(): return "Command executed successfully with no output." # Smart truncation before sending back to LLM context return truncate_output(full_output, max_chars=8000) except KeyboardInterrupt: if process.poll() is None: process.terminate() print("\n⚠️ Command terminated manually by user (Ctrl + C).") return "Execution cancelled by user during runtime." except Exception as e: return f"Execution error: {str(e)}" elif name == "write_file": filepath = arguments.get("filepath", "") content = arguments.get("content", "") prompt = ( f"\n⚠️ [Security Confirmation] Agent requests to write/modify" f" file:\n > {filepath}\nAllow file write? (Y/N): " ) if not get_user_confirmation(prompt): return "Execution cancelled: User explicitly denied file write." try: with open(filepath, "w", encoding="utf-8") as f: f.write(content) return f"Successfully wrote content to {filepath}" except Exception as e: return f"Error writing to file: {str(e)}" elif name == "read_file": filepath = arguments.get("filepath", "") start_line = arguments.get("start_line") end_line = arguments.get("end_line") try: with open(filepath, "r", encoding="utf-8") as f: lines = f.readlines() if start_line is not None or end_line is not None: start_idx = (start_line - 1) if (start_line is not None and start_line > 0) else 0 end_idx = end_line if end_line is not None else len(lines) selected_lines = lines[start_idx:end_idx] actual_start = start_idx + 1 actual_end = start_idx + len(selected_lines) header = f"[Lines {actual_start}-{actual_end} of {filepath} (total {len(lines)} lines)]\n" content = header + "".join(selected_lines) return truncate_output(content, max_chars=8000) else: return truncate_output("".join(lines), max_chars=8000) except Exception as e: return f"Error reading file: {str(e)}" elif name == "fetch_webpage": url = arguments.get("url", "") print(f"\n🌐 Fetching webpage: {url}\n" + "-" * 40) try: import urllib.request from html.parser import HTMLParser req = urllib.request.Request( url, headers={"User-Agent": "Mozilla/5.0 (compatible; CorindoCLI/1.0)"} ) with urllib.request.urlopen(req, timeout=15) as response: html_content = response.read().decode("utf-8", errors="ignore") class HTMLFilter(HTMLParser): def __init__(self): super().__init__() self.text = [] def handle_data(self, data): self.text.append(data) parser = HTMLFilter() parser.feed(html_content) text_content = "".join(parser.text) cleaned_text = "\n".join([line.strip() for line in text_content.splitlines() if line.strip()]) print("-" * 40) return truncate_output(cleaned_text, max_chars=8000) except Exception as e: return f"Error fetching webpage: {str(e)}" return f"Error: Unknown tool '{name}'" # Session management helpers def save_session(messages, filename): """Serializes and saves messages list to a JSON session file.""" try: serializable_messages = [] for m in messages: if hasattr(m, "model_dump"): serializable_messages.append(m.model_dump()) elif hasattr(m, "dict"): serializable_messages.append(m.dict()) elif isinstance(m, dict): serializable_messages.append(m) else: serializable_messages.append(dict(m)) path = pathlib.Path(filename) with open(path, "w", encoding="utf-8") as f: json.dump(serializable_messages, f, ensure_ascii=False, indent=2) print(f"💾 Session successfully saved to {path}") except Exception as e: print(f"❌ Failed to save session: {e}") def resume_session(filename): """Loads messages list from a JSON session file.""" try: path = pathlib.Path(filename) if not path.exists(): alt_path = pathlib.Path.home() / ".config" / "corindo-cli" / filename if alt_path.exists(): path = alt_path else: print(f"❌ Session file not found: {filename}") return None with open(path, "r", encoding="utf-8") as f: data = json.load(f) print(f"📂 Session successfully loaded from {path}") return data except Exception as e: print(f"❌ Failed to resume session: {e}") return None def print_help(): """Prints available slash commands.""" print("\n📖 Corindo CLI Help - Available Commands:") print(" /help Show this help message") print(" /save-session Save current conversation session to file") print(" /resume Resume a conversation session from file") print(" /exit, /quit Exit the CLI agent\n") # REPL Main Loop def main(): # Setup config and history directory under ~/.config/corindo-cli/ config_dir = pathlib.Path.home() / ".config" / "corindo-cli" config_dir.mkdir(parents=True, exist_ok=True) history_file = config_dir / ".history" if history_file.exists(): readline.read_history_file(history_file) print(f"🤖 Corindo CLI Agent started! Using model: {MODEL_NAME}") print(f"📁 Config loaded from: {loaded_path}") print("Type '/exit' or '/quit' to exit.") print("Type '/help' to view available commands.\n") messages = [ { "role": "system", "content": ( "You are a capable terminal and development assistant" " agent. You can read/write files, fetch web pages, and run shell commands (e.g.," " debuild, git, cargo) to help the user complete software" " engineering tasks. Always reason clearly before taking" " actions." ), } ] while True: try: user_input = input("\n👉 You: ") if user_input.lower() in ["/exit", "/quit"]: break # Handle /help command if user_input.lower() == "/help": print_help() continue # Handle /save-session command if user_input.startswith("/save-session"): parts = user_input.split(maxsplit=1) if len(parts) < 2: print("❌ Usage: /save-session ") else: filename = parts[1].strip() save_session(messages, filename) continue # Handle /resume command if user_input.startswith("/resume"): parts = user_input.split(maxsplit=1) if len(parts) < 2: print("❌ Usage: /resume ") else: filename = parts[1].strip() loaded = resume_session(filename) if loaded is not None: messages = loaded print(f"✨ Resumed session with {len(messages)} messages loaded.") continue if not user_input.strip(): continue messages.append({"role": "user", "content": user_input}) # Ensure that the tool_denied state is reset at the start of # each new dialogue round. tool_denied = False response = call_llm(messages=messages, tools=tools, tool_choice="auto") response_message = response.choices[0].message messages.append(response_message) while response_message.tool_calls: tool_denied = False for tool_call in response_message.tool_calls: function_name = tool_call.function.name function_args = json.loads(tool_call.function.arguments) print( f"⚙️ Calling tool: {function_name}(" f"{json.dumps(function_args, ensure_ascii=False)})" ) tool_output = execute_tool(function_name, function_args) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "name": function_name, "content": tool_output, }) if "Execution cancelled" in tool_output: tool_denied = True break if tool_denied: response = call_llm(messages=messages) response_message = response.choices[0].message messages.append(response_message) # Ensure that the AI's response to refusals is printed. if response_message.content: print(f"\n🤖 Agent:\n{response_message.content}") break response = call_llm(messages=messages, tools=tools, tool_choice="auto") response_message = response.choices[0].message messages.append(response_message) if response_message.content and not tool_denied: print(f"\n🤖 Agent:\n{response_message.content}") readline.write_history_file(history_file) except KeyboardInterrupt: print("\nGoodbye!") break except Exception as e: print(f"\n❌ Error occurred: {e}") if __name__ == "__main__": main()