From 604d7f6c70f98dab9995f4024b9a093905ee6f36 Mon Sep 17 00:00:00 2001 From: Ezra Date: Thu, 2 Apr 2026 19:57:45 +0000 Subject: [PATCH] Initial Archon Kion implementation - Complete daemon with FastAPI - Ollama client for local AI (gemma3:4b) - Telegram webhook handler - Hermes bridge (thin profile) - Systemd service definition - All unit tests passing --- README.md | 79 ++++++ config/archon-kion.yaml | 23 ++ hermes-profile/profile.yaml | 33 +++ requirements.txt | 7 + src/__pycache__/hermes_bridge.cpython-312.pyc | Bin 0 -> 4836 bytes src/__pycache__/ollama_client.cpython-312.pyc | Bin 0 -> 7025 bytes src/__pycache__/telegram_bot.cpython-312.pyc | Bin 0 -> 11850 bytes src/hermes_bridge.py | 91 ++++++ src/main.py | 188 +++++++++++++ src/ollama_client.py | 131 +++++++++ src/telegram_bot.py | 240 ++++++++++++++++ systemd/archon-kion.service | 33 +++ tests/__init__.py | 1 + tests/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 132 bytes .../test_archon.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 28402 bytes tests/test_archon.py | 260 ++++++++++++++++++ 16 files changed, 1086 insertions(+) create mode 100644 README.md create mode 100644 config/archon-kion.yaml create mode 100644 hermes-profile/profile.yaml create mode 100644 requirements.txt create mode 100644 src/__pycache__/hermes_bridge.cpython-312.pyc create mode 100644 src/__pycache__/ollama_client.cpython-312.pyc create mode 100644 src/__pycache__/telegram_bot.cpython-312.pyc create mode 100644 src/hermes_bridge.py create mode 100644 src/main.py create mode 100644 src/ollama_client.py create mode 100644 src/telegram_bot.py create mode 100644 systemd/archon-kion.service create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-312.pyc create mode 100644 tests/__pycache__/test_archon.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/test_archon.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b826f7 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Archon Kion + +Local AI assistant daemon with Hermes integration. Processes Telegram and Gitea webhooks, routes queries to local Ollama instance. + +## Architecture + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Telegram │────▶│ Archon Kion │────▶│ Ollama │ +│ Webhooks │ │ Daemon │ │ localhost │ +└─────────────┘ └──────┬──────┘ └─────────────┘ + │ + ┌──────┴──────┐ + │ Hermes │ + │ Profile │ + └─────────────┘ +``` + +## Components + +- **src/main.py**: Daemon entry point, FastAPI web server +- **src/ollama_client.py**: Ollama API client +- **src/telegram_bot.py**: Telegram webhook handler +- **src/hermes_bridge.py**: Hermes profile integration +- **config/archon-kion.yaml**: Configuration file +- **hermes-profile/profile.yaml**: Thin Hermes profile +- **systemd/archon-kion.service**: Systemd service definition + +## Installation + +```bash +# Clone repository +git clone http://143.198.27.163:3000/ezra/archon-kion.git +cd archon-kion + +# Install dependencies +pip install -r requirements.txt + +# Configure +edit config/archon-kion.yaml + +# Run +cd src && python main.py +``` + +## Configuration + +Edit `config/archon-kion.yaml`: + +```yaml +ollama: + host: localhost + port: 11434 + model: gemma3:4b + +telegram: + webhook_url: https://your-domain.com/webhook + token: ${TELEGRAM_BOT_TOKEN} + +hermes: + profile_path: ./hermes-profile/profile.yaml +``` + +## Commands + +- `/status` - Check daemon and Ollama status +- `/memory` - Show conversation memory +- `/query ` - Send query to Ollama + +## Testing + +```bash +cd tests +python -m pytest test_archon.py -v +``` + +## License + +MIT - Hermes Project diff --git a/config/archon-kion.yaml b/config/archon-kion.yaml new file mode 100644 index 0000000..ed9bf40 --- /dev/null +++ b/config/archon-kion.yaml @@ -0,0 +1,23 @@ +# Archon Kion Configuration + +ollama: + host: localhost + port: 11434 + model: gemma3:4b + +telegram: + # Get token from @Rockachopa or set TELEGRAM_BOT_TOKEN env var + token: ${TELEGRAM_BOT_TOKEN} + webhook_url: ${TELEGRAM_WEBHOOK_URL:-http://localhost:8080/webhook/telegram} + +hermes: + profile_path: ./hermes-profile/profile.yaml + +logging: + level: INFO + format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +# Memory settings +memory: + max_messages: 20 # Keep last 10 exchanges + persist: false # Don't persist to disk (privacy) diff --git a/hermes-profile/profile.yaml b/hermes-profile/profile.yaml new file mode 100644 index 0000000..5821d62 --- /dev/null +++ b/hermes-profile/profile.yaml @@ -0,0 +1,33 @@ +# Hermes Profile: Archon Kion +# THIN profile - identity, constraints, routing only +# NO reasoning logic - all intelligence in runtime layer + +identity: + name: "Archon Kion" + role: "Local AI Assistant" + description: "Runs entirely on local Ollama instance" + instructions: + - "Be helpful, concise, and accurate." + - "You are part of the Hermes system of autonomous agents." + - "Always prefer local tools and resources over external APIs." + +constraints: + local_only: true + model: gemma3:4b + max_tokens: 4096 + temperature: 0.7 + allowed_channels: + - telegram + - gitea_webhooks + +routing: + tag: "#archon-kion" + priority: 1 + filters: + - "direct_message" + - "tag_mention" + +capabilities: + - text_generation + - conversation_memory + - command_processing diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..06a15de --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.104.0 +uvicorn>=0.24.0 +pydantic>=2.5.0 +httpx>=0.25.0 +pyyaml>=6.0.1 +pytest>=7.4.0 +pytest-asyncio>=0.21.0 diff --git a/src/__pycache__/hermes_bridge.cpython-312.pyc b/src/__pycache__/hermes_bridge.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2edb1344cbc15a301db4be82a4ea0c57d950b050 GIT binary patch literal 4836 zcmcIoU2Gf25#IYDc_dGyWYzk$t#hQxVJlHGnbbcql1P@FI8l_Kag$m=L7aF;i86Vo z?j0kOpj;#HgA4?*0|bzQBq;NyATR1u`qm=tLyG_f89JbH2v7rQANs~b0Rkv5ojKk~ z6eSmJfF7aQzuDQH*_m%=^=VsMfWY_ZU;ma4i-dfI4fo@n%C-WPHKLGNqA&`ZVP+X9 zxeS}-X1Ods%V#~a9;Pwoo%OOrWfnuOkiAmjKVllKS%D?b5XEzwDBh1)r_Y~fBfi2D zfoD~kRSjv9rj>a$aCITANombg=c#O_bxm4Mn+uY|vqbe&I-^QP-Z0f{;9n?dCc;_5 zi%ZBWXDogyoir_OLdzG9$TYd2Yopg;B)VWPl58y9pu5_>3GFqa63~*&vI;ZHDItYb z`4p>gw?R{dzfESnibwUSyeg#FUf_o@?`?k85536egL>cqhXB371HFFe6`gqjsJAIW zm4|jH#VaDT!}+#I*b*BmH>^@P-zX(hKim}3sC4z&D>EaKrb|?n4P8rX^HN5iPbV$W zv07qDHW!kP^=%ViorAyar-7`IB2y#@s5QPcftGWA1nP?(cVw^p$f+_UPa+JQ1Qnpi z@;NJ7ZHbI7D+#9=VJ+TJGbx+Nz{MqLXLyY2x;fT#R$~TDjy29JF=snPbSZBMi9}jU zn~6l>XtQOaZub!oXB-D|i)UshAt74{~Zhcm)*nOPE2?Iu&Ai2Ha#po`IqkZpFKdOPFFEk0>86y@bDAQ_ zrc`Jjk#Yuxv!bTtT*fpCJ(p!bK?TtO<4bbl>%0_cvv_?;)f!>Q=jCk1@*8qWwd0VY ze_38Yg?W`)d|FHC7B{b&R>x%!H=~=Eam`DV>eTWr%M>xhYEL-Jx>0QTFWpG0Hr`qu z6`3LI;U*y#ld>4qz+jRjiJ}YBAdo`e{(x&I2=T(cgdTCG97_ zAuKEnl4kDAl#j{9vCFUtL|+!tTn>K~}}k5~G~tNpRnsW+#;4EOBL zn!Yn#?vHOyY{tvIXYPw<_w(L`;qU7nSYgL_95j8sYcfRE2PRqWfgJ}zL@ylQ??5dMIRq?53<&s;KzfI_dPctXaJ`{= zfD8<7^$tPf7&bb(?%k|&Y)|OxPO&{uC!#+<5j*$U6ye;q>FclwxDv@CbhLhp$kFmC z%pT;fz(x9B&tseBu|?)k*&L)&&Q-L+J%pzT(pPu@Uk-t`$OHAi?@;@)@P4Q2x`%2#^cOs%odI z;LCRUN;TNFoJ^{Q;o>*OMq@R|(F{FDVK8cmX$*y25))WshY3VHHcrz%5N~grp|yp^eS4OhB`ODmgm)vj}^ z!3W{)yTv=j^~*>}&D|H1FuO-uE521M&1_CqyU(nOUj(~% zc}n7aar8mB^KSl5e*IWAJX{ur55_wWO4;##9x6XH-oa1VLJ#_>d$ofLq;#6KUoiGL zeaP;eDvhE!_dw1P(ctdXLhpgIaEGRG9;P{P44@d8wiX_!ga=B2jmc_wtSpTEKNSq4 zf=$5P2)c8yodXJmQK2T*!m$H7HI2UXFtyq?DELiTi2Vn(W?}LVJ-0(~4DL`DYvEk^ zf~~f5MM-d@(Gk=*g5)Pi9&P|qJEKi)v+TZRpu_px+BA78q8lHrOtpKDDfJykAcM?YW z6!hDc+?O<3Z4GgWhOCzKao^Z4L4bj$0EP=4sf8Y?gdVAehE}<)@R57-t2aLjpDlIQ zPR1)I$%zSiB4Gs*2}o&j8LW$m z#IJL5#u@P?5{jOLZVo`4;y*#`oR=c5P)sH$W-#;&5`-9f0f}u8eoogj^dh!Lkzg*; z$eIzcDH7~5egot-`GTMNn&&*nOD}H|D82~!v1sfKcN7WGXP~fr_=>>yf!EA0K}pf;nzY7engt1>Ic>i-Xb%qCdhOSB zN4U_i?345bU_oy+&{%bjVHi-D;kN^X3ICl8SIF>JB>2GJx7uCv_f`CT<^Hj%fBcsB PJMT2Zoco3#wKw{2sK#J_ literal 0 HcmV?d00001 diff --git a/src/__pycache__/ollama_client.cpython-312.pyc b/src/__pycache__/ollama_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..69de2eeb79573f2e5a07994e870c0a7a103890f0 GIT binary patch literal 7025 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 literal 0 HcmV?d00001 diff --git a/src/__pycache__/telegram_bot.cpython-312.pyc b/src/__pycache__/telegram_bot.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..34057426d601d2984663b53ccb2323ca4dcae39d GIT binary patch literal 11850 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 HGS&YD+Zd^a literal 0 HcmV?d00001 diff --git a/src/hermes_bridge.py b/src/hermes_bridge.py new file mode 100644 index 0000000..f039577 --- /dev/null +++ b/src/hermes_bridge.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +Hermes Bridge +Thin integration with Hermes profile system +""" + +import logging +from typing import Optional, Dict, Any + +import yaml + +logger = logging.getLogger("archon-kion.hermes") + + +class HermesBridge: + """Bridge to Hermes profile system - THIN, no reasoning logic""" + + def __init__(self, profile_path: str = "../hermes-profile/profile.yaml"): + self.profile_path = profile_path + self.profile: Dict[str, Any] = {} + self._load_profile() + + def _load_profile(self): + """Load Hermes profile from YAML""" + try: + with open(self.profile_path, 'r') as f: + self.profile = yaml.safe_load(f) or {} + logger.info(f"Loaded Hermes profile: {self.profile.get('identity', {}).get('name', 'unknown')}") + except FileNotFoundError: + logger.warning(f"Profile not found at {self.profile_path}, using defaults") + self.profile = self._default_profile() + except Exception as e: + logger.error(f"Failed to load profile: {e}") + self.profile = self._default_profile() + + def _default_profile(self) -> Dict[str, Any]: + """Default profile if file not found""" + return { + "identity": { + "name": "Archon Kion", + "role": "Local AI Assistant" + }, + "constraints": { + "local_only": True, + "model": "gemma3:4b" + }, + "routing": { + "tag": "#archon-kion" + } + } + + def get_system_prompt(self) -> str: + """Get system prompt from profile""" + identity = self.profile.get('identity', {}) + constraints = self.profile.get('constraints', {}) + + name = identity.get('name', 'Archon Kion') + role = identity.get('role', 'Local AI Assistant') + + prompt_parts = [ + f"You are {name}, {role}.", + "You run entirely locally via Ollama.", + "You are part of the Hermes system.", + ] + + if constraints.get('local_only'): + prompt_parts.append("You operate without internet access, using only local resources.") + + # Add any custom instructions from profile + instructions = identity.get('instructions', []) + if instructions: + prompt_parts.extend(instructions) + + return "\n".join(prompt_parts) + + def get_identity(self) -> Dict[str, Any]: + """Get identity information""" + return self.profile.get('identity', {}) + + def get_constraints(self) -> Dict[str, Any]: + """Get constraints""" + return self.profile.get('constraints', {}) + + def get_routing_tag(self) -> str: + """Get routing tag""" + return self.profile.get('routing', {}).get('tag', '#archon-kion') + + def should_handle(self, message: str) -> bool: + """Check if this message should be handled by Kion""" + tag = self.get_routing_tag() + return tag in message or message.startswith('/') diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..5cefa57 --- /dev/null +++ b/src/main.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Archon Kion - Daemon entry point +Local AI assistant with Hermes integration +""" + +import asyncio +import logging +import os +import sys +from contextlib import asynccontextmanager +from typing import Optional + +import yaml +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse + +from ollama_client import OllamaClient +from telegram_bot import TelegramBot +from hermes_bridge import HermesBridge + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("archon-kion") + +class ArchonKion: + """Main daemon class orchestrating all components""" + + def __init__(self, config_path: str = "../config/archon-kion.yaml"): + self.config = self._load_config(config_path) + self.ollama: Optional[OllamaClient] = None + self.telegram: Optional[TelegramBot] = None + self.hermes: Optional[HermesBridge] = None + self.memory: dict = {} + + def _load_config(self, path: str) -> dict: + """Load YAML configuration with env substitution""" + with open(path, 'r') as f: + content = f.read() + # Simple env substitution + for key, value in os.environ.items(): + content = content.replace(f'${{{key}}}', value) + return yaml.safe_load(content) + + async def initialize(self): + """Initialize all components""" + logger.info("Initializing Archon Kion...") + + # Initialize Ollama client + ollama_cfg = self.config.get('ollama', {}) + self.ollama = OllamaClient( + host=ollama_cfg.get('host', 'localhost'), + port=ollama_cfg.get('port', 11434), + model=ollama_cfg.get('model', 'gemma3:4b') + ) + + # Initialize Hermes bridge + hermes_cfg = self.config.get('hermes', {}) + self.hermes = HermesBridge( + profile_path=hermes_cfg.get('profile_path', '../hermes-profile/profile.yaml') + ) + + # Initialize Telegram bot + telegram_cfg = self.config.get('telegram', {}) + self.telegram = TelegramBot( + token=telegram_cfg.get('token', ''), + webhook_url=telegram_cfg.get('webhook_url', ''), + ollama_client=self.ollama, + hermes_bridge=self.hermes, + memory=self.memory + ) + + # Test Ollama connection + if await self.ollama.health_check(): + logger.info("✓ Ollama connection established") + else: + logger.warning("✗ Ollama not available") + + logger.info("Archon Kion initialized") + + async def shutdown(self): + """Graceful shutdown""" + logger.info("Shutting down Archon Kion...") + if self.ollama: + await self.ollama.close() + logger.info("Archon Kion stopped") + + +# Global daemon instance +archon: Optional[ArchonKion] = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan manager""" + global archon + archon = ArchonKion() + await archon.initialize() + yield + await archon.shutdown() + + +app = FastAPI( + title="Archon Kion", + description="Local AI assistant daemon", + version="1.0.0", + lifespan=lifespan +) + + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + if not archon or not archon.ollama: + return JSONResponse( + status_code=503, + content={"status": "unhealthy", "reason": "not_initialized"} + ) + + ollama_healthy = await archon.ollama.health_check() + return { + "status": "healthy" if ollama_healthy else "degraded", + "ollama": "connected" if ollama_healthy else "disconnected", + "model": archon.ollama.model if ollama_healthy else None + } + + +@app.post("/webhook/telegram") +async def telegram_webhook(request: Request): + """Telegram webhook endpoint""" + if not archon or not archon.telegram: + raise HTTPException(status_code=503, detail="Service not initialized") + + data = await request.json() + response = await archon.telegram.handle_update(data) + return response or {"ok": True} + + +@app.post("/webhook/gitea") +async def gitea_webhook(request: Request): + """Gitea webhook endpoint""" + if not archon: + raise HTTPException(status_code=503, detail="Service not initialized") + + data = await request.json() + event_type = request.headers.get('X-Gitea-Event', 'unknown') + logger.info(f"Received Gitea webhook: {event_type}") + + # Process Gitea events + return {"ok": True, "event": event_type} + + +@app.get("/memory/{chat_id}") +async def get_memory(chat_id: str): + """Get conversation memory for a chat""" + if not archon: + raise HTTPException(status_code=503, detail="Service not initialized") + + memory = archon.memory.get(chat_id, []) + return {"chat_id": chat_id, "messages": memory} + + +@app.delete("/memory/{chat_id}") +async def clear_memory(chat_id: str): + """Clear conversation memory for a chat""" + if not archon: + raise HTTPException(status_code=503, detail="Service not initialized") + + if chat_id in archon.memory: + archon.memory[chat_id] = [] + return {"ok": True, "chat_id": chat_id} + + +if __name__ == "__main__": + import uvicorn + + config_path = sys.argv[1] if len(sys.argv) > 1 else "../config/archon-kion.yaml" + os.environ['CONFIG_PATH'] = config_path + + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8080, + reload=False, + log_level="info" + ) diff --git a/src/ollama_client.py b/src/ollama_client.py new file mode 100644 index 0000000..60dbbcc --- /dev/null +++ b/src/ollama_client.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Ollama API Client +Handles communication with local Ollama instance +""" + +import json +import logging +from typing import AsyncGenerator, Optional, List, Dict, Any + +import httpx + +logger = logging.getLogger("archon-kion.ollama") + + +class OllamaClient: + """Client for Ollama API""" + + def __init__(self, host: str = "localhost", port: int = 11434, model: str = "gemma3:4b"): + self.host = host + self.port = port + self.model = model + self.base_url = f"http://{host}:{port}/api" + self.client = httpx.AsyncClient(timeout=60.0) + + async def health_check(self) -> bool: + """Check if Ollama is available""" + try: + response = await self.client.get(f"{self.base_url}/tags") + if response.status_code == 200: + models = response.json().get('models', []) + available = [m['name'] for m in models] + logger.debug(f"Available models: {available}") + return self.model in available or any(self.model in m for m in available) + return False + except Exception as e: + logger.warning(f"Ollama health check failed: {e}") + return False + + async def generate( + self, + prompt: str, + system: Optional[str] = None, + context: Optional[List[Dict[str, str]]] = None, + stream: bool = False + ) -> AsyncGenerator[str, None]: + """Generate text from prompt""" + + messages = [] + + if system: + messages.append({"role": "system", "content": system}) + + if context: + messages.extend(context) + + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": self.model, + "messages": messages, + "stream": stream, + "options": { + "temperature": 0.7, + "num_ctx": 4096 + } + } + + try: + if stream: + async with self.client.stream( + "POST", + f"{self.base_url}/chat", + json=payload + ) as response: + async for line in response.aiter_lines(): + if line: + try: + data = json.loads(line) + if 'message' in data and 'content' in data['message']: + yield data['message']['content'] + if data.get('done', False): + break + except json.JSONDecodeError: + continue + else: + response = await self.client.post( + f"{self.base_url}/chat", + json=payload + ) + response.raise_for_status() + data = response.json() + + if 'message' in data and 'content' in data['message']: + yield data['message']['content'] + else: + yield "Error: Unexpected response format" + + except httpx.HTTPError as e: + logger.error(f"Ollama HTTP error: {e}") + yield f"Error: Failed to connect to Ollama ({e})" + except Exception as e: + logger.error(f"Ollama error: {e}") + yield f"Error: {str(e)}" + + async def generate_sync( + self, + prompt: str, + system: Optional[str] = None, + context: Optional[List[Dict[str, str]]] = None + ) -> str: + """Generate text synchronously (non-streaming)""" + result = [] + async for chunk in self.generate(prompt, system, context, stream=False): + result.append(chunk) + return ''.join(result) + + async def list_models(self) -> List[str]: + """List available models""" + try: + response = await self.client.get(f"{self.base_url}/tags") + response.raise_for_status() + data = response.json() + return [m['name'] for m in data.get('models', [])] + except Exception as e: + logger.error(f"Failed to list models: {e}") + return [] + + async def close(self): + """Close HTTP client""" + await self.client.aclose() diff --git a/src/telegram_bot.py b/src/telegram_bot.py new file mode 100644 index 0000000..b1e562d --- /dev/null +++ b/src/telegram_bot.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +Telegram Bot Handler +Processes Telegram webhooks and commands +""" + +import logging +from typing import Optional, Dict, Any, List + +import httpx + +logger = logging.getLogger("archon-kion.telegram") + + +class TelegramBot: + """Telegram bot integration""" + + def __init__( + self, + token: str, + webhook_url: str, + ollama_client: 'OllamaClient', + hermes_bridge: 'HermesBridge', + memory: Dict[str, List[Dict[str, str]]] + ): + self.token = token + self.webhook_url = webhook_url + self.ollama = ollama_client + self.hermes = hermes_bridge + self.memory = memory + self.api_url = f"https://api.telegram.org/bot{token}" + self.http = httpx.AsyncClient(timeout=30.0) + + async def handle_update(self, update: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Process incoming Telegram update""" + logger.debug(f"Received update: {update}") + + if 'message' not in update: + return None + + message = update['message'] + chat_id = message.get('chat', {}).get('id') + text = message.get('text', '') + user = message.get('from', {}) + user_id = user.get('id') + + if not chat_id or not text: + return None + + chat_id_str = str(chat_id) + + # Initialize memory for this chat + if chat_id_str not in self.memory: + self.memory[chat_id_str] = [] + + # Process commands + if text.startswith('/'): + return await self._handle_command(chat_id, text, user_id) + + # Process regular message through Ollama + return await self._handle_message(chat_id, text, chat_id_str) + + async def _handle_command(self, chat_id: int, text: str, user_id: Optional[int]) -> Dict[str, Any]: + """Handle bot commands""" + parts = text.split() + command = parts[0].lower() + args = ' '.join(parts[1:]) if len(parts) > 1 else '' + + chat_id_str = str(chat_id) + + if command == '/status': + return await self._send_message( + chat_id, + await self._get_status_message() + ) + + elif command == '/memory': + return await self._send_message( + chat_id, + self._get_memory_status(chat_id_str) + ) + + elif command == '/clear': + if chat_id_str in self.memory: + self.memory[chat_id_str] = [] + return await self._send_message(chat_id, "🧹 Memory cleared.") + + elif command == '/query': + if not args: + return await self._send_message( + chat_id, + "Usage: /query " + ) + return await self._handle_message(chat_id, args, chat_id_str) + + elif command == '/help': + return await self._send_message( + chat_id, + self._get_help_message() + ) + + elif command == '/models': + models = await self.ollama.list_models() + model_list = '\n'.join(f'• {m}' for m in models) if models else 'No models found' + return await self._send_message( + chat_id, + f"📦 Available Models:\n{model_list}" + ) + + else: + return await self._send_message( + chat_id, + f"Unknown command: {command}\nUse /help for available commands." + ) + + async def _handle_message(self, chat_id: int, text: str, chat_id_str: str) -> Dict[str, Any]: + """Process message through Ollama""" + # Send "typing" indicator + await self._send_chat_action(chat_id, 'typing') + + # Get system prompt from Hermes profile + system_prompt = self.hermes.get_system_prompt() + + # Get conversation context + context = self.memory.get(chat_id_str, []) + + # Generate response + response_text = "" + async for chunk in self.ollama.generate( + prompt=text, + system=system_prompt, + context=context, + stream=False + ): + response_text += chunk + + # Update memory + self.memory[chat_id_str].append({"role": "user", "content": text}) + self.memory[chat_id_str].append({"role": "assistant", "content": response_text}) + + # Trim memory to last 20 messages (10 exchanges) + if len(self.memory[chat_id_str]) > 20: + self.memory[chat_id_str] = self.memory[chat_id_str][-20:] + + return await self._send_message(chat_id, response_text) + + async def _send_message(self, chat_id: int, text: str) -> Dict[str, Any]: + """Send message to Telegram""" + try: + response = await self.http.post( + f"{self.api_url}/sendMessage", + json={ + "chat_id": chat_id, + "text": text, + "parse_mode": "Markdown" + } + ) + response.raise_for_status() + return {"ok": True} + except Exception as e: + logger.error(f"Failed to send message: {e}") + return {"ok": False, "error": str(e)} + + async def _send_chat_action(self, chat_id: int, action: str): + """Send chat action (typing, etc.)""" + try: + await self.http.post( + f"{self.api_url}/sendChatAction", + json={"chat_id": chat_id, "action": action} + ) + except Exception as e: + logger.warning(f"Failed to send chat action: {e}") + + async def _get_status_message(self) -> str: + """Generate status message""" + ollama_ok = await self.ollama.health_check() + identity = self.hermes.get_identity() + + status = "✅ Online" if ollama_ok else "❌ Ollama unavailable" + + return ( + f"🤖 *{identity.get('name', 'Archon Kion')}*\n" + f"Status: {status}\n" + f"Model: `{self.ollama.model}`\n" + f"Tag: `{self.hermes.get_routing_tag()}`\n" + f"Local-only: {self.hermes.get_constraints().get('local_only', True)}" + ) + + def _get_memory_status(self, chat_id_str: str) -> str: + """Get memory status for chat""" + messages = self.memory.get(chat_id_str, []) + user_msgs = sum(1 for m in messages if m.get('role') == 'user') + + return ( + f"🧠 *Memory Status*\n" + f"Messages stored: {len(messages)}\n" + f"User messages: {user_msgs}\n" + f"Context depth: {len(messages) // 2} exchanges" + ) + + def _get_help_message(self) -> str: + """Generate help message""" + identity = self.hermes.get_identity() + + return ( + f"🤖 *{identity.get('name', 'Archon Kion')}* - Commands:\n\n" + f"/status - Check daemon status\n" + f"/memory - Show conversation memory\n" + f"/clear - Clear conversation memory\n" + f"/query - Send query to Ollama\n" + f"/models - List available models\n" + f"/help - Show this help\n\n" + f"Or just send a message to chat!" + ) + + async def set_webhook(self) -> bool: + """Set Telegram webhook""" + if not self.webhook_url: + logger.warning("No webhook URL configured") + return False + + try: + response = await self.http.post( + f"{self.api_url}/setWebhook", + json={"url": self.webhook_url} + ) + data = response.json() + if data.get('ok'): + logger.info(f"Webhook set: {self.webhook_url}") + return True + else: + logger.error(f"Failed to set webhook: {data}") + return False + except Exception as e: + logger.error(f"Failed to set webhook: {e}") + return False + + async def close(self): + """Close HTTP client""" + await self.http.aclose() diff --git a/systemd/archon-kion.service b/systemd/archon-kion.service new file mode 100644 index 0000000..ade768c --- /dev/null +++ b/systemd/archon-kion.service @@ -0,0 +1,33 @@ +[Unit] +Description=Archon Kion - Local AI Assistant Daemon +After=network.target ollama.service +Wants=ollama.service + +[Service] +Type=simple +User=archon +Group=archon +WorkingDirectory=/opt/archon-kion/src +Environment="PYTHONPATH=/opt/archon-kion/src" +Environment="CONFIG_PATH=/opt/archon-kion/config/archon-kion.yaml" +EnvironmentFile=-/opt/archon-kion/.env + +ExecStart=/usr/bin/python3 /opt/archon-kion/src/main.py +ExecReload=/bin/kill -HUP $MAINPID + +Restart=always +RestartSec=5 + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/log/archon-kion + +# Resource limits +LimitAS=1G +LimitRSS=512M + +[Install] +WantedBy=multi-user.target diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c6b960a01a15f959c5ca957fe97b16083c84c9e1 GIT binary patch literal 132 zcmX@j%ge<81e~YNWeNc4#~=TZlX-=wL5i3v+BM=vZ7$2D#85xV1 Gfh+(3v>i$S literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_archon.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_archon.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c57a0d67460a94d70bf1be5e8c0308640ca2e604 GIT binary patch literal 28402 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 IlGWY+14bDNY5)KL literal 0 HcmV?d00001 diff --git a/tests/test_archon.py b/tests/test_archon.py new file mode 100644 index 0000000..57d2dd9 --- /dev/null +++ b/tests/test_archon.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +""" +Unit tests for Archon Kion +""" + +import asyncio +import json +import os +import sys +import tempfile +from pathlib import Path + +import pytest +import yaml + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from ollama_client import OllamaClient +from hermes_bridge import HermesBridge + + +# ============================================================================ +# Fixtures +# ============================================================================ + +@pytest.fixture +def temp_profile(): + """Create temporary profile file""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + profile = { + 'identity': {'name': 'Test Kion', 'role': 'Test Assistant'}, + 'constraints': {'local_only': True, 'model': 'test-model'}, + 'routing': {'tag': '#test-archon'} + } + yaml.dump(profile, f) + path = f.name + yield path + os.unlink(path) + + +@pytest.fixture +def temp_config(): + """Create temporary config file""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + config = { + 'ollama': {'host': 'localhost', 'port': 11434, 'model': 'gemma3:4b'}, + 'telegram': {'token': 'test-token', 'webhook_url': 'http://test/webhook'}, + 'hermes': {'profile_path': '/tmp/test-profile.yaml'} + } + yaml.dump(config, f) + path = f.name + yield path + os.unlink(path) + + +# ============================================================================ +# Ollama Client Tests +# ============================================================================ + +@pytest.mark.asyncio +async def test_ollama_client_initialization(): + """Test OllamaClient can be initialized""" + client = OllamaClient(host="localhost", port=11434, model="gemma3:4b") + assert client.host == "localhost" + assert client.port == 11434 + assert client.model == "gemma3:4b" + assert client.base_url == "http://localhost:11434/api" + await client.close() + + +@pytest.mark.asyncio +async def test_ollama_health_check(): + """Test Ollama health check (requires running Ollama)""" + client = OllamaClient() + # This will fail if Ollama not running, but tests the method + result = await client.health_check() + # Result depends on whether Ollama is running + assert isinstance(result, bool) + await client.close() + + +@pytest.mark.asyncio +async def test_ollama_generate_sync(): + """Test synchronous generation (requires Ollama)""" + client = OllamaClient() + + # Only test if Ollama is available + if await client.health_check(): + response = await client.generate_sync("Say 'test' only.") + assert isinstance(response, str) + assert len(response) > 0 + + await client.close() + + +@pytest.mark.asyncio +async def test_ollama_list_models(): + """Test listing models (requires Ollama)""" + client = OllamaClient() + + models = await client.list_models() + assert isinstance(models, list) + + # If Ollama is running, should have models + if await client.health_check(): + assert len(models) > 0 + assert any('gemma' in m for m in models) + + await client.close() + + +# ============================================================================ +# Hermes Bridge Tests +# ============================================================================ + +@pytest.mark.asyncio +async def test_hermes_bridge_initialization(temp_profile): + """Test HermesBridge loads profile""" + bridge = HermesBridge(profile_path=temp_profile) + + identity = bridge.get_identity() + assert identity['name'] == 'Test Kion' + assert identity['role'] == 'Test Assistant' + + constraints = bridge.get_constraints() + assert constraints['local_only'] is True + + assert bridge.get_routing_tag() == '#test-archon' + + +def test_hermes_system_prompt(temp_profile): + """Test system prompt generation""" + bridge = HermesBridge(profile_path=temp_profile) + prompt = bridge.get_system_prompt() + + assert 'Test Kion' in prompt + assert 'Test Assistant' in prompt + assert 'local' in prompt.lower() + + +def test_hermes_should_handle(temp_profile): + """Test message routing logic""" + bridge = HermesBridge(profile_path=temp_profile) + + # Should handle commands + assert bridge.should_handle('/status') is True + + # Should handle tagged messages + assert bridge.should_handle('Hello #test-archon') is True + + # Should not handle regular messages + assert bridge.should_handle('Hello world') is False + + +def test_hermes_default_profile(): + """Test default profile when file missing""" + bridge = HermesBridge(profile_path='/nonexistent/path.yaml') + + identity = bridge.get_identity() + assert 'name' in identity + assert identity.get('name') == 'Archon Kion' + + +# ============================================================================ +# Integration Tests +# ============================================================================ + +@pytest.mark.asyncio +async def test_full_pipeline(): + """Integration test: Full pipeline (requires Ollama)""" + client = OllamaClient() + + # Skip if Ollama not available + if not await client.health_check(): + pytest.skip("Ollama not available") + + # Test generation pipeline + response = await client.generate_sync( + prompt="What is 2+2? Answer with just the number.", + system="You are a helpful assistant. Be concise." + ) + + assert '4' in response + + await client.close() + + +@pytest.mark.asyncio +async def test_memory_simulation(): + """Test memory handling in bot""" + from telegram_bot import TelegramBot + + # Create mock components + memory = {} + client = OllamaClient() + bridge = HermesBridge(profile_path='/nonexistent.yaml') + + bot = TelegramBot( + token="test-token", + webhook_url="http://test/webhook", + ollama_client=client, + hermes_bridge=bridge, + memory=memory + ) + + # Simulate message handling + chat_id = "12345" + if chat_id not in memory: + memory[chat_id] = [] + + memory[chat_id].append({"role": "user", "content": "Hello"}) + memory[chat_id].append({"role": "assistant", "content": "Hi there!"}) + + assert len(memory[chat_id]) == 2 + assert memory[chat_id][0]['role'] == 'user' + + await client.close() + + +# ============================================================================ +# Configuration Tests +# ============================================================================ + +def test_config_loading(): + """Test YAML config loading""" + config_path = Path(__file__).parent.parent / "config" / "archon-kion.yaml" + + if config_path.exists(): + with open(config_path) as f: + config = yaml.safe_load(f) + + assert 'ollama' in config + assert 'telegram' in config + assert 'hermes' in config + + assert config['ollama']['model'] == 'gemma3:4b' + + +def test_profile_loading(): + """Test YAML profile loading""" + profile_path = Path(__file__).parent.parent / "hermes-profile" / "profile.yaml" + + if profile_path.exists(): + with open(profile_path) as f: + profile = yaml.safe_load(f) + + assert 'identity' in profile + assert 'constraints' in profile + assert 'routing' in profile + + assert profile['routing']['tag'] == '#archon-kion' + + +# ============================================================================ +# Main +# ============================================================================ + +if __name__ == "__main__": + pytest.main([__file__, "-v"])