Move console, auth, server to Nextra (v0.4.0)

These modules are now proprietary and live in the Nextra SaaS product.
xml-pipeline remains the OSS core with:
- Message pump and pipeline steps
- Handler contract and responses
- LLM router abstraction
- Native tools
- Config loading
- Memory/context buffer

Removed:
- xml_pipeline/console/ → nextra/console/
- xml_pipeline/auth/ → nextra/auth/
- xml_pipeline/server/ → nextra/server/
- Legacy files: agentserver.py, main.py, xml_listener.py

The simple console example remains in examples/console/.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
dullfig 2026-01-19 22:37:21 -08:00
parent d53bc1dfbe
commit f87d9f80e9
23 changed files with 5 additions and 5364 deletions

View file

@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "xml-pipeline"
version = "0.3.1"
version = "0.4.0"
description = "Schema-driven XML message bus for multi-agent systems"
readme = "README.md"
requires-python = ">=3.11"

View file

@ -2,4 +2,4 @@
xml-pipeline: Tamper-proof nervous system for multi-agent organisms.
"""
__version__ = "0.3.1"
__version__ = "0.4.0"

View file

@ -1,295 +0,0 @@
# agent_server.py
"""
AgentServer The Living Organism Host
December 25, 2025
Preliminary but runnable implementation.
This is the body: one process, one secure WebSocket endpoint,
hosting many concurrent AgentService organs sharing a single
tamper-proof MessageBus from xml-pipeline.
Features in this version:
- Mandatory WSS (TLS)
- First-message TOTP authentication
- Per-user capability control via config/users.yaml
- Personalized <catalog/> on connect
- Ed25519 identity generation helper
- Boot-time agent registration
- Hooks for future signed privileged commands
XML wins.
"""
import asyncio
import os
import ssl
import time
from typing import Optional, Dict, Any
import pyotp
import yaml
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
from websockets.server import serve, WebSocketServerProtocol
from xml_pipeline import MessageBus
from xml_pipeline.service import AgentService
from xml_pipeline.message import repair_and_canonicalize, XmlTamperError
class AgentServer:
"""
The body of the organism.
One instance = one living, multi-personality swarm.
"""
# Default identity location — can be overridden if needed
IDENTITY_DIR = os.path.expanduser("~/.agent_server")
PRIVATE_KEY_PATH = os.path.join(IDENTITY_DIR, "identity.ed25519")
PUBLIC_KEY_PATH = os.path.join(IDENTITY_DIR, "identity.ed25519.pub")
def __init__(
self,
host: str = "0.0.0.0",
port: int = 8765,
ssl_context: Optional[ssl.SSLContext] = None,
users_config_path: str = "config/users.yaml",
identity_pubkey_path: Optional[str] = None,
):
self.host = host
self.port = port
self.ssl_context = ssl_context # None = ws:// (dev only), set for wss://
self.bus = MessageBus()
# Load per-user TOTP secrets + allowed root tags
self.users_config: Dict[str, Dict[str, Any]] = self._load_users_config(users_config_path)
# Load organism public key for future privileged command verification
self.pubkey: Optional[bytes] = None
pubkey_path = identity_pubkey_path or self.PUBLIC_KEY_PATH
if os.path.exists(pubkey_path):
self.pubkey = self._load_pubkey(pubkey_path)
# Built-in platform listeners will be added here in future versions
@staticmethod
def _load_users_config(path: str) -> Dict[str, Dict[str, Any]]:
"""Load users.yaml → {user_id: {totp_secret: ..., allowed_root_tags: [...]}}"""
if not os.path.exists(path):
raise FileNotFoundError(f"Users config not found: {path}")
with open(path) as f:
data = yaml.safe_load(f) or {}
return data.get("users", {})
@staticmethod
def _load_pubkey(path: str) -> bytes:
"""Load raw Ed25519 public key bytes"""
with open(path, "rb") as f:
content = f.read().strip()
# Accept either raw bytes or ssh-ed25519 format
if content.startswith(b"ssh-ed25519 "):
import base64
return base64.b64decode(content.split()[1])
return content
def register_agent(
self,
agent_class: type[AgentService],
*,
system_prompt: str,
max_concurrent: int = 10,
session_timeout: float = 1800.0,
version: str = "1.0",
public: bool = True,
) -> None:
"""Register a permanent agent at boot time."""
# Wrapper to store public flag for catalog building
self.bus.register_agent(
agent_class=agent_class,
system_prompt=system_prompt,
max_concurrent=max_concurrent,
session_timeout=session_timeout,
version=version,
metadata={"public": public},
)
async def _handle_client(self, websocket: WebSocketServerProtocol):
"""Per-connection handler: authenticate → send catalog → pump messages"""
context = {
"authenticated": False,
"user": None,
"allowed_tags": set(),
"bad_message_count": 0,
"last_bad_time": 0.0,
}
try:
# 1. Authentication — first message must be <authenticate totp="..."/>
first_raw = await asyncio.wait_for(websocket.recv(), timeout=15.0)
auth_msg = repair_and_canonicalize(first_raw)
if auth_msg.getroot().tag != "authenticate":
await websocket.close(code=1008, reason="First message must be <authenticate/>")
return
totp_code = auth_msg.getroot().get("totp")
if not totp_code:
await websocket.close(code=1008, reason="Missing TOTP code")
return
user_id = self._authenticate_totp(totp_code)
if not user_id:
await websocket.close(code=1008, reason="Invalid TOTP")
return
user_cfg = self.users_config[user_id]
allowed_tags = set(user_cfg.get("allowed_root_tags", []))
if "*" in allowed_tags:
# Wildcard = all current + future tags
allowed_tags = None # Special sentinel
context.update({
"authenticated": True,
"user": user_id,
"allowed_tags": allowed_tags,
})
# 2. Send personalized catalog
catalog_xml = self.bus.build_catalog_for_user(allowed_tags)
await websocket.send(catalog_xml)
# 3. Message pump
async def inbound():
async for raw in websocket:
try:
yield repair_and_canonicalize(raw)
except XmlTamperError:
await websocket.close(code=1008, reason="Invalid/tampered XML")
raise
async def outbound(message: bytes):
await websocket.send(message)
await self.bus.run(
inbound=inbound(),
outbound=outbound,
context=context, # For ACL checks in listeners
)
except asyncio.TimeoutError:
await websocket.close(code=1008, reason="Authentication timeout")
except Exception as e:
print(f"Client error ({websocket.remote_address}): {e}")
def _authenticate_totp(self, code: str) -> Optional[str]:
"""Validate TOTP and return user identifier if successful"""
for user_id, cfg in self.users_config.items():
totp = pyotp.TOTP(cfg["totp_secret"])
if totp.verify(code, valid_window=1): # 30s tolerance
return user_id
return None
async def start(self):
"""Start the organism — runs forever"""
scheme = "wss" if self.ssl_context else "ws"
print(f"AgentServer starting on {scheme}://{self.host}:{self.port}")
print("Organism awakening...")
async with serve(self._handle_client, self.host, self.port, ssl=self.ssl_context):
await asyncio.Future() # Run forever
@classmethod
def generate_identity(cls, force: bool = False) -> None:
"""
Generate the organism's permanent Ed25519 identity.
Run once on first deployment.
"""
os.makedirs(cls.IDENTITY_DIR, exist_ok=True)
if os.path.exists(cls.PRIVATE_KEY_PATH) and not force:
print("Identity already exists:")
print(f" Private key: {cls.PRIVATE_KEY_PATH}")
print(f" Public key : {cls.PUBLIC_KEY_PATH}")
print("Use --force to regenerate (will overwrite!).")
return
print("Generating organism Ed25519 identity...")
private_key = ed25519.Ed25519PrivateKey.generate()
public_key = private_key.public_key()
# Private key — PEM PKCS8 unencrypted (rely on file permissions)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
# Public key — raw bytes + ssh-ed25519 format for readability
public_raw = public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
public_ssh = f"ssh-ed25519 {public_raw.hex()} organism@{os.uname().nodename}"
# Write with secure permissions
with open(cls.PRIVATE_KEY_PATH, "wb") as f:
os.fchmod(f.fileno(), 0o600)
f.write(private_pem)
with open(cls.PUBLIC_KEY_PATH, "w") as f:
f.write(public_ssh + "\n")
print("Organism identity created!")
print(f"Private key (KEEP SAFE): {cls.PRIVATE_KEY_PATH}")
print(f"Public key : {cls.PUBLIC_KEY_PATH}")
print("\nBackup the private key offline. Lose it → lose structural control forever.")
# ————————————————————————
# Example CLI entrypoint
# ————————————————————————
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="AgentServer — the living organism")
subparsers = parser.add_subparsers(dest="command", required=True)
# Run the server
run_p = subparsers.add_parser("run", help="Start the organism")
run_p.add_argument("--host", default="0.0.0.0")
run_p.add_argument("--port", type=int, default=8765)
run_p.add_argument("--cert", help="Path to TLS fullchain.pem")
run_p.add_argument("--key", help="Path to TLS privkey.pem")
run_p.add_argument("--users-config", default="config/users.yaml")
# Generate identity
gen_p = subparsers.add_parser("generate-identity", help="Create cryptographic identity")
gen_p.add_argument("--force", action="store_true")
args = parser.parse_args()
if args.command == "generate-identity":
AgentServer.generate_identity(force=args.force)
else: # run
ssl_ctx = None
if args.cert and args.key:
ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_ctx.load_cert_chain(args.cert, args.key)
server = AgentServer(
host=args.host,
port=args.port,
ssl_context=ssl_ctx,
users_config_path=args.users_config,
)
# Example boot-time listeners (uncomment and customise)
# from your_agents import CodingAgent, ResearchAgent, GrokAgent
# server.register_agent(CodingAgent, system_prompt="You are an elite Python engineer...", max_concurrent=20)
# server.register_agent(ResearchAgent, system_prompt="You are a thorough researcher...", max_concurrent=10)
# server.register_agent(GrokAgent, system_prompt="You are Grok, built by xAI...", max_concurrent=15)
asyncio.run(server.start())

View file

@ -1,19 +0,0 @@
"""
Authentication and authorization for xml-pipeline.
Provides:
- UserStore: User management with Argon2id password hashing
- SessionManager: Token-based session management
"""
from .users import User, UserStore, get_user_store
from .sessions import Session, SessionManager, get_session_manager
__all__ = [
"User",
"UserStore",
"get_user_store",
"Session",
"SessionManager",
"get_session_manager",
]

View file

@ -1,197 +0,0 @@
"""
Session management with token-based authentication.
Tokens are random hex strings stored in memory with expiry.
"""
from __future__ import annotations
import secrets
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
# Default session lifetime
DEFAULT_SESSION_LIFETIME = timedelta(hours=8)
# Token length in bytes (32 bytes = 64 hex chars)
TOKEN_BYTES = 32
@dataclass
class Session:
"""An authenticated session."""
token: str
username: str
role: str
created_at: datetime
expires_at: datetime
last_activity: datetime
def is_expired(self) -> bool:
"""Check if session has expired."""
return datetime.now(timezone.utc) > self.expires_at
def touch(self) -> None:
"""Update last activity time."""
self.last_activity = datetime.now(timezone.utc)
def to_dict(self) -> dict:
"""Convert to dict for API responses."""
return {
"token": self.token,
"username": self.username,
"role": self.role,
"expires_at": self.expires_at.isoformat(),
}
class SessionManager:
"""
Manages authenticated sessions.
Thread-safe for concurrent access.
Usage:
manager = SessionManager()
# Create session after successful login
session = manager.create("admin", "admin")
# Validate token on subsequent requests
session = manager.validate(token)
if session:
print(f"Welcome back {session.username}")
# Logout
manager.revoke(token)
"""
def __init__(self, lifetime: timedelta = DEFAULT_SESSION_LIFETIME):
self.lifetime = lifetime
self._sessions: dict[str, Session] = {}
self._lock = threading.Lock()
def create(
self,
username: str,
role: str,
lifetime: Optional[timedelta] = None,
) -> Session:
"""
Create a new session.
Args:
username: Authenticated username
role: User's role
lifetime: Optional custom lifetime
Returns:
New Session with token
"""
token = secrets.token_hex(TOKEN_BYTES)
now = datetime.now(timezone.utc)
expires = now + (lifetime or self.lifetime)
session = Session(
token=token,
username=username,
role=role,
created_at=now,
expires_at=expires,
last_activity=now,
)
with self._lock:
self._sessions[token] = session
self._cleanup_expired()
return session
def validate(self, token: str) -> Optional[Session]:
"""
Validate a session token.
Args:
token: Session token from client
Returns:
Session if valid, None if invalid/expired
"""
with self._lock:
session = self._sessions.get(token)
if not session:
return None
if session.is_expired():
del self._sessions[token]
return None
session.touch()
return session
def revoke(self, token: str) -> bool:
"""
Revoke a session (logout).
Returns:
True if session was revoked, False if not found
"""
with self._lock:
if token in self._sessions:
del self._sessions[token]
return True
return False
def revoke_user(self, username: str) -> int:
"""
Revoke all sessions for a user.
Returns:
Number of sessions revoked
"""
with self._lock:
to_revoke = [
token for token, session in self._sessions.items()
if session.username == username
]
for token in to_revoke:
del self._sessions[token]
return len(to_revoke)
def get_user_sessions(self, username: str) -> list[Session]:
"""Get all active sessions for a user."""
with self._lock:
return [
s for s in self._sessions.values()
if s.username == username and not s.is_expired()
]
def _cleanup_expired(self) -> None:
"""Remove expired sessions. Must hold lock."""
expired = [
token for token, session in self._sessions.items()
if session.is_expired()
]
for token in expired:
del self._sessions[token]
def active_count(self) -> int:
"""Count active sessions."""
with self._lock:
self._cleanup_expired()
return len(self._sessions)
# Global instance
_manager: Optional[SessionManager] = None
def get_session_manager() -> SessionManager:
"""Get the global session manager."""
global _manager
if _manager is None:
_manager = SessionManager()
return _manager

View file

@ -1,227 +0,0 @@
"""
User store with Argon2id password hashing.
Users are stored in ~/.xml-pipeline/users.yaml with hashed passwords.
"""
from __future__ import annotations
import os
import stat
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import yaml
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
CONFIG_DIR = Path.home() / ".xml-pipeline"
USERS_FILE = CONFIG_DIR / "users.yaml"
@dataclass
class User:
"""A user account."""
username: str
password_hash: str
role: str = "operator" # admin, operator, viewer
created_at: str = ""
last_login: Optional[str] = None
def to_dict(self) -> dict:
return {
"username": self.username,
"password_hash": self.password_hash,
"role": self.role,
"created_at": self.created_at,
"last_login": self.last_login,
}
@classmethod
def from_dict(cls, data: dict) -> User:
return cls(
username=data["username"],
password_hash=data["password_hash"],
role=data.get("role", "operator"),
created_at=data.get("created_at", ""),
last_login=data.get("last_login"),
)
class UserStore:
"""
Manages user accounts with secure password storage.
Usage:
store = UserStore()
store.create_user("admin", "secretpass", role="admin")
user = store.authenticate("admin", "secretpass")
if user:
print(f"Welcome {user.username}!")
"""
def __init__(self, users_file: Path = USERS_FILE):
self.users_file = users_file
self.hasher = PasswordHasher()
self._users: dict[str, User] = {}
self._load()
def _ensure_dir(self) -> None:
"""Create config directory if needed."""
self.users_file.parent.mkdir(parents=True, exist_ok=True)
def _load(self) -> None:
"""Load users from file."""
if not self.users_file.exists():
return
try:
with open(self.users_file) as f:
data = yaml.safe_load(f) or {}
for username, user_data in data.get("users", {}).items():
user_data["username"] = username
self._users[username] = User.from_dict(user_data)
except Exception:
pass
def _save(self) -> None:
"""Save users to file."""
self._ensure_dir()
data = {
"users": {
username: {
"password_hash": user.password_hash,
"role": user.role,
"created_at": user.created_at,
"last_login": user.last_login,
}
for username, user in self._users.items()
}
}
with open(self.users_file, "w") as f:
yaml.dump(data, f, default_flow_style=False)
# Set file permissions to 600
if sys.platform != "win32":
os.chmod(self.users_file, stat.S_IRUSR | stat.S_IWUSR)
def has_users(self) -> bool:
"""Check if any users exist."""
return len(self._users) > 0
def get_user(self, username: str) -> Optional[User]:
"""Get user by username."""
return self._users.get(username)
def list_users(self) -> list[str]:
"""List all usernames."""
return list(self._users.keys())
def create_user(
self,
username: str,
password: str,
role: str = "operator",
) -> User:
"""
Create a new user.
Args:
username: Unique username
password: Plain text password (will be hashed)
role: User role (admin, operator, viewer)
Returns:
The created User
Raises:
ValueError: If username already exists
"""
if username in self._users:
raise ValueError(f"User already exists: {username}")
if len(password) < 4:
raise ValueError("Password must be at least 4 characters")
user = User(
username=username,
password_hash=self.hasher.hash(password),
role=role,
created_at=datetime.now(timezone.utc).isoformat(),
)
self._users[username] = user
self._save()
return user
def authenticate(self, username: str, password: str) -> Optional[User]:
"""
Authenticate user with password.
Returns:
User if authentication successful, None otherwise
"""
user = self._users.get(username)
if not user:
return None
try:
self.hasher.verify(user.password_hash, password)
# Update last login
user.last_login = datetime.now(timezone.utc).isoformat()
self._save()
return user
except VerifyMismatchError:
return None
def change_password(self, username: str, new_password: str) -> bool:
"""Change user's password."""
user = self._users.get(username)
if not user:
return False
if len(new_password) < 4:
raise ValueError("Password must be at least 4 characters")
user.password_hash = self.hasher.hash(new_password)
self._save()
return True
def delete_user(self, username: str) -> bool:
"""Delete a user."""
if username not in self._users:
return False
del self._users[username]
self._save()
return True
def set_role(self, username: str, role: str) -> bool:
"""Change user's role."""
user = self._users.get(username)
if not user:
return False
user.role = role
self._save()
return True
# Global instance
_store: Optional[UserStore] = None
def get_user_store() -> UserStore:
"""Get the global user store."""
global _store
if _store is None:
_store = UserStore()
return _store

View file

@ -16,14 +16,13 @@ def _check_import(module: str) -> bool:
# Feature registry: feature_name -> (check_function, description)
# Note: auth, server, lsp moved to Nextra (proprietary)
FEATURES: dict[str, tuple[Callable[[], bool], str]] = {
"anthropic": (lambda: _check_import("anthropic"), "Anthropic Claude SDK"),
"openai": (lambda: _check_import("openai"), "OpenAI SDK"),
"redis": (lambda: _check_import("redis"), "Redis for distributed keyvalue"),
"search": (lambda: _check_import("duckduckgo_search"), "DuckDuckGo search"),
"auth": (lambda: _check_import("pyotp") and _check_import("argon2"), "TOTP auth"),
"server": (lambda: _check_import("websockets"), "WebSocket server"),
"lsp": (lambda: _check_import("lsp_client"), "LSP client for config editor"),
"console": (lambda: _check_import("prompt_toolkit"), "Interactive console example"),
}
@ -80,15 +79,7 @@ def check_features(config) -> FeatureCheck:
# This would need more sophisticated detection based on tool config
pass
# Check if auth is needed (multi-tenant mode)
if getattr(config, "auth", None):
if not result.available.get("auth"):
result.missing["auth"] = "Config has auth enabled"
# Check if websocket server is needed
if getattr(config, "server", None):
if not result.available.get("server"):
result.missing["server"] = "Config has server enabled"
# Note: auth/server config sections are read but implemented in Nextra
return result

View file

@ -1,12 +0,0 @@
"""
console Console interfaces for xml-pipeline.
Provides:
- SecureConsole: Local keyboard-only console (no network)
- ConsoleClient: Network client connecting to server with auth
"""
from xml_pipeline.console.secure_console import SecureConsole, PasswordManager
from xml_pipeline.console.client import ConsoleClient
__all__ = ["SecureConsole", "PasswordManager", "ConsoleClient"]

View file

@ -1,367 +0,0 @@
"""
Console client that connects to the agent server.
Provides SSH-style login with username/password authentication.
"""
from __future__ import annotations
import asyncio
import getpass
import json
import sys
from pathlib import Path
from typing import Optional
try:
import aiohttp
AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import InMemoryHistory
from prompt_toolkit.styles import Style
PROMPT_TOOLKIT_AVAILABLE = True
except ImportError:
PROMPT_TOOLKIT_AVAILABLE = False
from ..config import get_agent_config_store, CONFIG_DIR
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8765
MAX_LOGIN_ATTEMPTS = 3
# Default organism config path
DEFAULT_ORGANISM_CONFIG = Path("config/organism.yaml")
class ConsoleClient:
"""
Text-based console client for the agent server.
Usage:
client = ConsoleClient()
asyncio.run(client.run())
"""
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
self.host = host
self.port = port
self.base_url = f"http://{host}:{port}"
self.ws_url = f"ws://{host}:{port}/ws"
self.token: Optional[str] = None
self.username: Optional[str] = None
self.session: Optional[aiohttp.ClientSession] = None
self.ws: Optional[aiohttp.ClientWebSocketResponse] = None
self.running = False
async def login(self) -> bool:
"""
Perform SSH-style login.
Returns:
True if login successful, False otherwise
"""
print(f"Connecting to {self.host}:{self.port}...")
for attempt in range(1, MAX_LOGIN_ATTEMPTS + 1):
try:
username = input("Username: ")
password = getpass.getpass("Password: ")
except (EOFError, KeyboardInterrupt):
print("\nLogin cancelled.")
return False
if not username or not password:
print("Username and password required.")
continue
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/auth/login",
json={"username": username, "password": password},
) as resp:
data = await resp.json()
if resp.status == 200:
self.token = data["token"]
self.username = username
print(f"Welcome, {username}!")
return True
else:
error = data.get("error", "Authentication failed")
remaining = MAX_LOGIN_ATTEMPTS - attempt
if remaining > 0:
print(f"{error}. {remaining} attempt(s) remaining.")
else:
print(f"{error}. No attempts remaining.")
except aiohttp.ClientError as e:
print(f"Connection error: {e}")
return False
return False
async def connect_ws(self) -> bool:
"""Connect to WebSocket after authentication."""
if not self.token:
return False
try:
self.session = aiohttp.ClientSession(
headers={"Authorization": f"Bearer {self.token}"}
)
self.ws = await self.session.ws_connect(self.ws_url)
# Wait for connected message
msg = await self.ws.receive_json()
if msg.get("type") == "connected":
return True
return False
except Exception as e:
print(f"WebSocket connection failed: {e}")
return False
async def send_command(self, cmd: str) -> Optional[dict]:
"""Send a command via WebSocket and get response."""
if not self.ws:
return None
await self.ws.send_json(cmd)
return await self.ws.receive_json()
def print_help(self):
"""Print available commands."""
print("""
Available commands:
/help - Show this help
/status - Show server status
/listeners - List available targets
/targets - Alias for /listeners
/configure - Edit organism.yaml (swarm wiring)
/configure @agent - Edit agent config (prompt, model, etc.)
/quit - Disconnect and exit
Send messages:
@target message - Send message to a target listener
Example: @greeter Hello there!
""")
async def handle_command(self, line: str) -> bool:
"""
Handle a command line.
Returns:
False if should quit, True otherwise
"""
line = line.strip()
if not line:
return True
if line == "/help":
self.print_help()
elif line == "/quit" or line == "/exit":
return False
elif line == "/status":
resp = await self.send_command({"type": "status"})
if resp:
threads = resp.get("threads", 0)
print(f"Active threads: {threads}")
elif line == "/listeners" or line == "/targets":
resp = await self.send_command({"type": "listeners"})
if resp:
listeners = resp.get("listeners", [])
if listeners:
print("Available targets:")
for name in listeners:
print(f" - {name}")
else:
print("No targets available (pipeline not running)")
elif line == "/configure":
# Edit organism.yaml
await self._configure_organism()
elif line.startswith("/configure @"):
# Edit agent config: /configure @agent
agent_name = line[12:].strip()
if agent_name:
await self._configure_agent(agent_name)
else:
print("Usage: /configure @agent_name")
elif line.startswith("/configure "):
# Also support /configure agent without @
agent_name = line[11:].strip()
if agent_name:
await self._configure_agent(agent_name)
else:
print("Usage: /configure @agent_name")
elif line.startswith("/"):
print(f"Unknown command: {line}")
elif line.startswith("@"):
# Send message to target: @target message
resp = await self.send_command({"type": "send", "raw": line})
if resp:
if resp.get("type") == "sent":
thread_id = resp.get("thread_id", "")[:8]
target = resp.get("target", "unknown")
print(f"Sent to {target} (thread: {thread_id}...)")
elif resp.get("type") == "error":
print(f"Error: {resp.get('error')}")
else:
print("Use @target message to send. Example: @greeter Hello!")
print("Type /listeners to see available targets.")
return True
async def _configure_organism(self):
"""Open organism.yaml in editor."""
from .editor import edit_text, PROMPT_TOOLKIT_AVAILABLE as EDITOR_AVAILABLE
# Find organism.yaml
organism_path = DEFAULT_ORGANISM_CONFIG
if not organism_path.exists():
# Try absolute path in config dir
organism_path = CONFIG_DIR / "organism.yaml"
if not organism_path.exists():
print(f"organism.yaml not found at {organism_path}")
print("Create one or specify path in configuration.")
return
# Load content
content = organism_path.read_text()
if EDITOR_AVAILABLE:
# Use built-in editor
edited, saved = await asyncio.get_event_loop().run_in_executor(
None,
lambda: edit_text(content, title=f"organism.yaml")
)
if saved and edited is not None:
organism_path.write_text(edited)
print(f"Saved {organism_path}")
print("Note: Restart server to apply changes.")
else:
print("Cancelled.")
else:
print(f"Edit manually: {organism_path}")
async def _configure_agent(self, agent_name: str):
"""Open agent config in editor."""
from .editor import edit_text, PROMPT_TOOLKIT_AVAILABLE as EDITOR_AVAILABLE
store = get_agent_config_store()
# Load or create config content
content = store.load_yaml(agent_name)
config_path = store.path_for(agent_name)
if EDITOR_AVAILABLE:
# Use built-in editor
edited, saved = await asyncio.get_event_loop().run_in_executor(
None,
lambda: edit_text(content, title=f"Agent: {agent_name}")
)
if saved and edited is not None:
try:
store.save_yaml(agent_name, edited)
print(f"Saved {config_path}")
except Exception as e:
print(f"Error saving: {e}")
else:
print("Cancelled.")
else:
# Fallback: show path
if not config_path.exists():
# Create default
store.save_yaml(agent_name, content)
print(f"Edit manually: {config_path}")
async def run(self):
"""Main client loop."""
if not AIOHTTP_AVAILABLE:
print("Error: aiohttp not installed")
sys.exit(1)
# Login
if not await self.login():
print("Authentication failed.")
sys.exit(1)
# Connect WebSocket
if not await self.connect_ws():
print("Failed to connect to server.")
sys.exit(1)
print("Connected. Type /help for commands, /quit to exit.")
self.running = True
try:
if PROMPT_TOOLKIT_AVAILABLE:
await self._run_prompt_toolkit()
else:
await self._run_simple()
finally:
await self.cleanup()
async def _run_prompt_toolkit(self):
"""Run with prompt_toolkit for better UX."""
style = Style.from_dict({
"prompt": "ansicyan bold",
})
session = PromptSession(
history=InMemoryHistory(),
style=style,
)
while self.running:
try:
line = await asyncio.get_event_loop().run_in_executor(
None,
lambda: session.prompt(f"{self.username}> ")
)
if not await self.handle_command(line):
break
except (EOFError, KeyboardInterrupt):
break
async def _run_simple(self):
"""Run with simple input (fallback)."""
while self.running:
try:
line = input(f"{self.username}> ")
if not await self.handle_command(line):
break
except (EOFError, KeyboardInterrupt):
break
async def cleanup(self):
"""Clean up connections."""
if self.ws:
await self.ws.close()
if self.session:
await self.session.close()
print("Disconnected.")
def main():
"""Entry point."""
import argparse
parser = argparse.ArgumentParser(description="XML Pipeline Console")
parser.add_argument("--host", default=DEFAULT_HOST, help="Server host")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Server port")
args = parser.parse_args()
client = ConsoleClient(host=args.host, port=args.port)
asyncio.run(client.run())
if __name__ == "__main__":
main()

View file

@ -1,19 +0,0 @@
"""
console_registry.py Global console reference for handlers.
This module provides a central place to register and retrieve
the active console instance, avoiding Python module import issues.
"""
_console = None
def set_console(console):
"""Set the active console instance."""
global _console
_console = console
def get_console():
"""Get the active console instance (or None)."""
return _console

View file

@ -1,752 +0,0 @@
"""
Full-screen text editor using prompt_toolkit.
Provides a vim-like editing experience for configuration files.
Supports optional LSP integration for YAML files.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from xml_pipeline.console.lsp import YAMLLSPClient, ASLSClient
from typing import Union
LSPClientType = Union[YAMLLSPClient, ASLSClient]
logger = logging.getLogger(__name__)
try:
from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.layout import Layout, HSplit, VSplit
from prompt_toolkit.layout.containers import Window, ConditionalContainer, Float, FloatContainer
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
from prompt_toolkit.layout.menus import CompletionsMenu
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.filters import Condition
from prompt_toolkit.styles import Style
from prompt_toolkit.lexers import PygmentsLexer
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.document import Document
PROMPT_TOOLKIT_AVAILABLE = True
except ImportError:
PROMPT_TOOLKIT_AVAILABLE = False
try:
from pygments.lexers.data import YamlLexer
from pygments.lexers.javascript import TypeScriptLexer
PYGMENTS_AVAILABLE = True
except ImportError:
PYGMENTS_AVAILABLE = False
# Supported syntax types and their lexers
SYNTAX_LEXERS = {
"yaml": "YamlLexer",
"typescript": "TypeScriptLexer",
"assemblyscript": "TypeScriptLexer", # AS uses TS syntax
"ts": "TypeScriptLexer",
"as": "TypeScriptLexer",
}
def get_lexer_for_syntax(syntax: str) -> Optional[object]:
"""
Get a Pygments lexer for the given syntax type.
Args:
syntax: Syntax name ("yaml", "typescript", "ts", "as", "assemblyscript")
Returns:
PygmentsLexer instance or None
"""
if not PYGMENTS_AVAILABLE:
return None
syntax_lower = syntax.lower()
if syntax_lower in ("yaml", "yml"):
return PygmentsLexer(YamlLexer)
elif syntax_lower in ("typescript", "ts", "assemblyscript", "as"):
return PygmentsLexer(TypeScriptLexer)
else:
return None
def detect_syntax_from_path(path: str | Path) -> str:
"""
Detect syntax type from file extension.
Returns:
Syntax name for use with get_lexer_for_syntax()
"""
ext = Path(path).suffix.lower()
extension_map = {
".yaml": "yaml",
".yml": "yaml",
".ts": "typescript",
".as": "assemblyscript",
}
return extension_map.get(ext, "text")
def edit_text(
initial_text: str,
title: str = "Editor",
syntax: str = "yaml",
) -> Tuple[Optional[str], bool]:
"""
Open full-screen editor for text.
Args:
initial_text: Text to edit
title: Title shown in header
syntax: Syntax highlighting ("yaml", "typescript", "ts", "as", "text")
Returns:
(edited_text, saved) - edited_text is None if cancelled
"""
if not PROMPT_TOOLKIT_AVAILABLE:
print("Error: prompt_toolkit not installed")
return None, False
# State
result = {"text": None, "saved": False}
# Create buffer with initial text
buffer = Buffer(
multiline=True,
name="editor",
)
buffer.text = initial_text
# Key bindings
kb = KeyBindings()
@kb.add("c-s") # Ctrl+S to save
def save(event):
result["text"] = buffer.text
result["saved"] = True
event.app.exit()
@kb.add("c-q") # Ctrl+Q to quit without saving
def quit_nosave(event):
result["text"] = None
result["saved"] = False
event.app.exit()
@kb.add("escape") # Escape to quit
def escape(event):
result["text"] = None
result["saved"] = False
event.app.exit()
# Syntax highlighting
lexer = get_lexer_for_syntax(syntax)
# Layout
header = Window(
height=1,
content=FormattedTextControl(
lambda: [
("class:header", f" {title} "),
("class:header.key", " Ctrl+S"),
("class:header", "=Save "),
("class:header.key", " Ctrl+Q"),
("class:header", "=Quit "),
]
),
style="class:header",
)
editor_window = Window(
content=BufferControl(
buffer=buffer,
lexer=lexer,
),
)
# Status bar showing cursor position
def get_status():
row = buffer.document.cursor_position_row + 1
col = buffer.document.cursor_position_col + 1
lines = len(buffer.text.split("\n"))
return [
("class:status", f" Line {row}/{lines}, Col {col} "),
]
status_bar = Window(
height=1,
content=FormattedTextControl(get_status),
style="class:status",
)
layout = Layout(
HSplit([
header,
editor_window,
status_bar,
])
)
# Styles
style = Style.from_dict({
"header": "bg:#005f87 #ffffff",
"header.key": "bg:#005f87 #ffff00 bold",
"status": "bg:#444444 #ffffff",
})
# Create and run application
app = Application(
layout=layout,
key_bindings=kb,
style=style,
full_screen=True,
mouse_support=True,
)
app.run()
return result["text"], result["saved"]
def edit_file(filepath: str, title: Optional[str] = None) -> bool:
"""
Edit a file in the full-screen editor.
Args:
filepath: Path to file
title: Optional title (defaults to filename)
Returns:
True if saved, False if cancelled
"""
from pathlib import Path
path = Path(filepath)
title = title or path.name
# Load existing content or empty
if path.exists():
initial_text = path.read_text()
else:
initial_text = ""
# Edit
edited_text, saved = edit_text(initial_text, title=title, syntax="yaml")
# Save if requested
if saved and edited_text is not None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(edited_text)
return True
return False
# Fallback: use system editor via subprocess
def edit_with_system_editor(filepath: str) -> bool:
"""
Edit file using system's default editor ($EDITOR or fallback).
Returns True if file was modified.
"""
import os
import subprocess
from pathlib import Path
path = Path(filepath)
# Get editor from environment
editor = os.environ.get("EDITOR", os.environ.get("VISUAL", ""))
if not editor:
# Fallback based on platform
import platform
if platform.system() == "Windows":
editor = "notepad"
else:
editor = "nano" # Most likely available
# Get modification time before edit
mtime_before = path.stat().st_mtime if path.exists() else None
# Open editor
try:
subprocess.run([editor, str(path)], check=True)
except subprocess.CalledProcessError:
return False
except FileNotFoundError:
print(f"Editor not found: {editor}")
return False
# Check if modified
if path.exists():
mtime_after = path.stat().st_mtime
return mtime_before is None or mtime_after > mtime_before
return False
# =============================================================================
# LSP-Enhanced Editor
# =============================================================================
class LSPEditor:
"""
Full-screen editor with optional LSP integration.
Provides:
- Syntax highlighting (YAML, TypeScript/AssemblyScript via Pygments)
- Autocompletion (LSP when available)
- Inline diagnostics (LSP when available)
- Hover documentation on F1 (LSP when available)
Usage:
# YAML config editing
editor = LSPEditor(schema_type="listener")
edited_text, saved = await editor.edit(initial_text, title="greeter.yaml")
# AssemblyScript listener editing
editor = LSPEditor(syntax="assemblyscript")
edited_text, saved = await editor.edit(source_code, title="handler.ts")
"""
def __init__(
self,
schema_type: Optional[str] = None,
schema_uri: Optional[str] = None,
syntax: str = "yaml",
):
"""
Initialize the LSP editor.
Args:
schema_type: Schema type ("organism" or "listener") for YAML modeline
schema_uri: Explicit schema URI to use
syntax: Syntax highlighting ("yaml", "typescript", "assemblyscript", "ts", "as")
"""
self.schema_type = schema_type
self.schema_uri = schema_uri
self.syntax = syntax
self._lsp_client: Optional["LSPClientType"] = None
self._lsp_type: Optional[str] = None # "yaml" or "assemblyscript"
self._diagnostics_text = ""
self._hover_text = ""
self._show_hover = False
self._document_version = 0
async def edit(
self,
initial_text: str,
title: str = "Editor",
document_uri: Optional[str] = None,
) -> Tuple[Optional[str], bool]:
"""
Open the editor with LSP support.
Args:
initial_text: Initial content to edit
title: Title shown in header
document_uri: URI for LSP (auto-generated if not provided)
Returns:
(edited_text, saved) - edited_text is None if cancelled
"""
if not PROMPT_TOOLKIT_AVAILABLE:
print("Error: prompt_toolkit not installed")
return None, False
# Determine LSP type based on syntax
syntax_lower = self.syntax.lower()
if syntax_lower in ("yaml", "yml"):
self._lsp_type = "yaml"
elif syntax_lower in ("typescript", "ts", "assemblyscript", "as"):
self._lsp_type = "assemblyscript"
else:
self._lsp_type = None
# Try to get appropriate LSP client
try:
from xml_pipeline.console.lsp import get_lsp_manager, LSPServerType
manager = get_lsp_manager()
if self._lsp_type == "yaml":
self._lsp_client = await manager.get_yaml_client()
elif self._lsp_type == "assemblyscript":
self._lsp_client = await manager.get_asls_client()
else:
self._lsp_client = None
except Exception as e:
logger.debug(f"LSP not available: {e}")
self._lsp_client = None
# Generate document URI with appropriate extension
if document_uri is None:
ext = ".yaml" if self._lsp_type == "yaml" else ".ts"
document_uri = f"file:///temp/{title.replace(' ', '_')}{ext}"
# Ensure schema modeline is present (YAML only)
if self._lsp_type == "yaml":
initial_text = self._ensure_modeline(initial_text)
# Open document in LSP
if self._lsp_client:
await self._lsp_client.did_open(document_uri, initial_text)
try:
result = await self._run_editor(initial_text, title, document_uri)
finally:
# Close document in LSP
if self._lsp_client:
await self._lsp_client.did_close(document_uri)
try:
from xml_pipeline.console.lsp import get_lsp_manager, LSPServerType
manager = get_lsp_manager()
if self._lsp_type == "yaml":
await manager.release_client(LSPServerType.YAML)
elif self._lsp_type == "assemblyscript":
await manager.release_client(LSPServerType.ASSEMBLYSCRIPT)
except Exception:
pass
return result
def _ensure_modeline(self, text: str) -> str:
"""Ensure YAML has schema modeline if schema_type is set."""
if self.schema_type is None:
return text
modeline = f"# yaml-language-server: $schema=~/.xml-pipeline/schemas/{self.schema_type}.schema.json"
# Check if modeline already exists
lines = text.split("\n")
for line in lines[:3]: # Check first 3 lines
if "yaml-language-server" in line and "$schema" in line:
return text
# Add modeline at the top
return modeline + "\n" + text
async def _run_editor(
self,
initial_text: str,
title: str,
uri: str,
) -> Tuple[Optional[str], bool]:
"""Run the editor application."""
result = {"text": None, "saved": False}
# Create buffer
buffer = Buffer(multiline=True, name="editor")
buffer.text = initial_text
# Key bindings
kb = KeyBindings()
@kb.add("c-s") # Ctrl+S to save
def save(event):
result["text"] = buffer.text
result["saved"] = True
event.app.exit()
@kb.add("c-q") # Ctrl+Q to quit without saving
def quit_nosave(event):
result["text"] = None
result["saved"] = False
event.app.exit()
@kb.add("escape") # Escape to quit
def escape(event):
result["text"] = None
result["saved"] = False
event.app.exit()
@kb.add("c-space") # Ctrl+Space for completion
async def trigger_completion(event):
if self._lsp_client:
doc = buffer.document
line = doc.cursor_position_row
col = doc.cursor_position_col
completions = await self._lsp_client.completion(uri, line, col)
if completions:
# Show first completion as hint
self._diagnostics_text = f"Completions: {', '.join(c.label for c in completions[:5])}"
event.app.invalidate()
@kb.add("f1") # F1 for hover
async def show_hover(event):
if self._lsp_client:
doc = buffer.document
line = doc.cursor_position_row
col = doc.cursor_position_col
hover = await self._lsp_client.hover(uri, line, col)
if hover:
self._hover_text = hover.contents[:200] # Truncate
self._show_hover = True
else:
self._hover_text = ""
self._show_hover = False
event.app.invalidate()
@kb.add("escape", filter=Condition(lambda: self._show_hover))
def hide_hover(event):
self._show_hover = False
event.app.invalidate()
@kb.add("c-p") # Ctrl+P for signature help (ASLS only)
async def show_signature_help(event):
# Only available for ASLS
if self._lsp_client and self._lsp_type == "assemblyscript":
doc = buffer.document
line = doc.cursor_position_row
col = doc.cursor_position_col
try:
sig_help = await self._lsp_client.signature_help(uri, line, col)
if sig_help and sig_help.get("signatures"):
sig = sig_help["signatures"][0]
label = sig.get("label", "")
self._hover_text = f"Signature: {label}"
self._show_hover = True
else:
self._hover_text = ""
self._show_hover = False
except Exception:
pass
event.app.invalidate()
# Syntax highlighting
lexer = get_lexer_for_syntax(self.syntax)
# Header
def get_header():
if self._lsp_client:
if self._lsp_type == "yaml":
lsp_status = " [YAML LSP]"
elif self._lsp_type == "assemblyscript":
lsp_status = " [ASLS]"
else:
lsp_status = " [LSP]"
else:
lsp_status = ""
parts = [
("class:header", f" {title}{lsp_status} "),
("class:header.key", " Ctrl+S"),
("class:header", "=Save "),
("class:header.key", " Ctrl+Q"),
("class:header", "=Quit "),
("class:header.key", " F1"),
("class:header", "=Hover "),
]
# Add Ctrl+P hint for AssemblyScript
if self._lsp_type == "assemblyscript" and self._lsp_client:
parts.extend([
("class:header.key", " Ctrl+P"),
("class:header", "=Sig "),
])
return parts
header = Window(
height=1,
content=FormattedTextControl(get_header),
style="class:header",
)
# Editor window
editor_window = Window(
content=BufferControl(
buffer=buffer,
lexer=lexer,
),
)
# Status bar
def get_status():
row = buffer.document.cursor_position_row + 1
col = buffer.document.cursor_position_col + 1
lines = len(buffer.text.split("\n"))
parts = [("class:status", f" Line {row}/{lines}, Col {col} ")]
if self._diagnostics_text:
parts.append(("class:status.diag", f" | {self._diagnostics_text}"))
return parts
status_bar = Window(
height=1,
content=FormattedTextControl(get_status),
style="class:status",
)
# Hover popup (shown conditionally)
def get_hover_text():
if self._show_hover and self._hover_text:
return [("class:hover", self._hover_text)]
return []
hover_window = ConditionalContainer(
Window(
height=3,
content=FormattedTextControl(get_hover_text),
style="class:hover",
),
filter=Condition(lambda: self._show_hover and bool(self._hover_text)),
)
# Layout
layout = Layout(
HSplit([
header,
editor_window,
hover_window,
status_bar,
])
)
# Styles
style = Style.from_dict({
"header": "bg:#005f87 #ffffff",
"header.key": "bg:#005f87 #ffff00 bold",
"status": "bg:#444444 #ffffff",
"status.diag": "bg:#444444 #ff8800",
"hover": "bg:#333333 #ffffff italic",
"diagnostic.error": "bg:#5f0000 #ffffff",
"diagnostic.warning": "bg:#5f5f00 #ffffff",
})
# Set up diagnostics callback
async def on_text_changed(buff):
if self._lsp_client:
self._document_version += 1
diagnostics = await self._lsp_client.did_change(
uri, buff.text, self._document_version
)
if diagnostics:
errors = sum(1 for d in diagnostics if d.severity == "error")
warnings = sum(1 for d in diagnostics if d.severity == "warning")
parts = []
if errors:
parts.append(f"{errors} error{'s' if errors > 1 else ''}")
if warnings:
parts.append(f"{warnings} warning{'s' if warnings > 1 else ''}")
self._diagnostics_text = " | ".join(parts) if parts else ""
else:
self._diagnostics_text = ""
buffer.on_text_changed += lambda buff: asyncio.create_task(on_text_changed(buff))
# Create and run application
app: Application = Application(
layout=layout,
key_bindings=kb,
style=style,
full_screen=True,
mouse_support=True,
)
await app.run_async()
return result["text"], result["saved"]
async def edit_text_async(
initial_text: str,
title: str = "Editor",
schema_type: Optional[str] = None,
syntax: str = "yaml",
) -> Tuple[Optional[str], bool]:
"""
Async wrapper for LSP-enabled text editing.
Args:
initial_text: Text to edit
title: Title shown in header
schema_type: "organism" or "listener" for YAML schema modeline
syntax: Syntax highlighting ("yaml", "typescript", "assemblyscript", "ts", "as")
Returns:
(edited_text, saved) - edited_text is None if cancelled
"""
editor = LSPEditor(schema_type=schema_type, syntax=syntax)
return await editor.edit(initial_text, title=title)
async def edit_file_async(
filepath: str,
title: Optional[str] = None,
schema_type: Optional[str] = None,
syntax: Optional[str] = None,
) -> bool:
"""
Edit a file with LSP support.
Args:
filepath: Path to file
title: Optional title (defaults to filename)
schema_type: "organism" or "listener" for YAML schema modeline
syntax: Syntax highlighting (auto-detected from extension if not specified)
Returns:
True if saved, False if cancelled
"""
path = Path(filepath)
title = title or path.name
# Auto-detect syntax from extension if not specified
if syntax is None:
syntax = detect_syntax_from_path(path)
# Load existing content or empty
if path.exists():
initial_text = path.read_text()
else:
initial_text = ""
# Edit
edited_text, saved = await edit_text_async(
initial_text,
title=title,
schema_type=schema_type,
syntax=syntax,
)
# Save if requested
if saved and edited_text is not None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(edited_text)
return True
return False
async def edit_assemblyscript_source(
filepath: str,
title: Optional[str] = None,
) -> bool:
"""
Edit an AssemblyScript listener source file with ASLS support.
Args:
filepath: Path to .ts or .as file
title: Optional title (defaults to filename)
Returns:
True if saved, False if cancelled
"""
return await edit_file_async(
filepath,
title=title,
syntax="assemblyscript",
)

View file

@ -1,63 +0,0 @@
"""
LSP (Language Server Protocol) integration for the editor.
Provides:
- YAMLLSPClient: Wrapper for yaml-language-server communication
- ASLSClient: Wrapper for AssemblyScript language server communication
- LSPServerManager: Server lifecycle management
- LSPBridge: Integration with prompt_toolkit editor
Supported Language Servers:
- yaml-language-server: npm install -g yaml-language-server
- asls (AssemblyScript): npm install -g assemblyscript-lsp
"""
from __future__ import annotations
from .client import (
YAMLLSPClient,
LSPCompletion,
LSPDiagnostic,
LSPHover,
is_lsp_available,
)
from .asls_client import (
ASLSClient,
ASLSConfig,
is_asls_available,
is_assemblyscript_file,
ASSEMBLYSCRIPT_EXTENSIONS,
)
from .manager import (
LSPServerManager,
LSPServerType,
get_lsp_manager,
ensure_lsp_stopped,
)
from .bridge import (
LSPCompleter,
DiagnosticsProcessor,
)
__all__ = [
# YAML Client
"YAMLLSPClient",
"LSPCompletion",
"LSPDiagnostic",
"LSPHover",
"is_lsp_available",
# AssemblyScript Client
"ASLSClient",
"ASLSConfig",
"is_asls_available",
"is_assemblyscript_file",
"ASSEMBLYSCRIPT_EXTENSIONS",
# Manager
"LSPServerManager",
"LSPServerType",
"get_lsp_manager",
"ensure_lsp_stopped",
# Bridge
"LSPCompleter",
"DiagnosticsProcessor",
]

View file

@ -1,527 +0,0 @@
"""
AssemblyScript Language Server Protocol client.
Wraps communication with asls (AssemblyScript Language Server) for:
- Autocompletion for AgentServer SDK types
- Type checking and diagnostics
- Hover documentation
Install: npm install -g assemblyscript-lsp
Used for editing WASM listener source files written in AssemblyScript.
"""
from __future__ import annotations
import asyncio
import shutil
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Any
from .client import (
LSPCompletion,
LSPDiagnostic,
LSPHover,
)
logger = logging.getLogger(__name__)
def _check_asls() -> bool:
"""Check if asls (AssemblyScript Language Server) is installed."""
return shutil.which("asls") is not None
def is_asls_available() -> tuple[bool, str]:
"""
Check if AssemblyScript LSP support is available.
Returns (available, reason) tuple.
"""
if not _check_asls():
return False, "asls not found (npm install -g assemblyscript-lsp)"
return True, "AssemblyScript LSP available"
# File extensions handled by ASLS
ASSEMBLYSCRIPT_EXTENSIONS = {".ts", ".as"}
def is_assemblyscript_file(path: str | Path) -> bool:
"""Check if a file should use the AssemblyScript LSP."""
return Path(path).suffix.lower() in ASSEMBLYSCRIPT_EXTENSIONS
@dataclass
class ASLSConfig:
"""
Configuration for the AssemblyScript Language Server.
These settings are passed during initialization.
"""
# Path to asconfig.json (AssemblyScript project config)
asconfig_path: Optional[str] = None
# Path to AgentServer SDK type definitions
sdk_types_path: Optional[str] = None
# Enable strict null checks
strict_null_checks: bool = True
# Enable additional diagnostics
verbose_diagnostics: bool = False
class ASLSClient:
"""
Client for communicating with the AssemblyScript Language Server.
Uses stdio for communication with the language server process.
Usage:
client = ASLSClient()
if await client.start():
await client.did_open(uri, content)
completions = await client.completion(uri, line, col)
await client.stop()
"""
def __init__(self, config: Optional[ASLSConfig] = None):
"""
Initialize the ASLS client.
Args:
config: Optional ASLS configuration
"""
self.config = config or ASLSConfig()
self._process: Optional[asyncio.subprocess.Process] = None
self._reader_task: Optional[asyncio.Task] = None
self._request_id = 0
self._pending_requests: dict[int, asyncio.Future] = {}
self._diagnostics: dict[str, list[LSPDiagnostic]] = {}
self._initialized = False
self._lock = asyncio.Lock()
async def start(self) -> bool:
"""
Start the AssemblyScript language server.
Returns True if started successfully.
"""
available, reason = is_asls_available()
if not available:
logger.warning(f"ASLS not available: {reason}")
return False
try:
self._process = await asyncio.create_subprocess_exec(
"asls", "--stdio",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# Start reader task
self._reader_task = asyncio.create_task(self._read_messages())
# Initialize LSP
await self._initialize()
self._initialized = True
logger.info("AssemblyScript language server started")
return True
except Exception as e:
logger.error(f"Failed to start asls: {e}")
await self.stop()
return False
async def stop(self) -> None:
"""Stop the language server."""
self._initialized = False
if self._reader_task:
self._reader_task.cancel()
try:
await self._reader_task
except asyncio.CancelledError:
pass
self._reader_task = None
if self._process:
self._process.terminate()
try:
await asyncio.wait_for(self._process.wait(), timeout=2)
except asyncio.TimeoutError:
self._process.kill()
self._process = None
# Cancel pending requests
for future in self._pending_requests.values():
if not future.done():
future.cancel()
self._pending_requests.clear()
async def _initialize(self) -> None:
"""Send LSP initialize request."""
init_options: dict[str, Any] = {}
if self.config.asconfig_path:
init_options["asconfigPath"] = self.config.asconfig_path
if self.config.sdk_types_path:
init_options["sdkTypesPath"] = self.config.sdk_types_path
result = await self._request(
"initialize",
{
"processId": None,
"rootUri": None,
"capabilities": {
"textDocument": {
"completion": {
"completionItem": {
"snippetSupport": True,
"documentationFormat": ["markdown", "plaintext"],
}
},
"hover": {
"contentFormat": ["markdown", "plaintext"],
},
"publishDiagnostics": {
"relatedInformation": True,
},
"signatureHelp": {
"signatureInformation": {
"documentationFormat": ["markdown", "plaintext"],
}
},
}
},
"initializationOptions": init_options,
},
)
logger.debug(f"ASLS initialized: {result}")
# Send initialized notification
await self._notify("initialized", {})
async def did_open(self, uri: str, content: str) -> None:
"""Notify server that a document was opened."""
if not self._initialized:
return
# Determine language ID based on extension
language_id = "assemblyscript"
if uri.endswith(".ts"):
language_id = "typescript" # ASLS may prefer this
await self._notify(
"textDocument/didOpen",
{
"textDocument": {
"uri": uri,
"languageId": language_id,
"version": 1,
"text": content,
}
},
)
async def did_change(
self, uri: str, content: str, version: int = 1
) -> list[LSPDiagnostic]:
"""
Notify server of document change.
Returns current diagnostics for the document.
"""
if not self._initialized:
return []
await self._notify(
"textDocument/didChange",
{
"textDocument": {"uri": uri, "version": version},
"contentChanges": [{"text": content}],
},
)
# Wait briefly for diagnostics
await asyncio.sleep(0.2) # ASLS may need more time than YAML
return self._diagnostics.get(uri, [])
async def did_close(self, uri: str) -> None:
"""Notify server that a document was closed."""
if not self._initialized:
return
await self._notify(
"textDocument/didClose",
{"textDocument": {"uri": uri}},
)
# Clear diagnostics
self._diagnostics.pop(uri, None)
async def completion(
self, uri: str, line: int, column: int
) -> list[LSPCompletion]:
"""
Request completions at a position.
Args:
uri: Document URI
line: 0-indexed line number
column: 0-indexed column number
Returns list of completion items.
"""
if not self._initialized:
return []
try:
result = await self._request(
"textDocument/completion",
{
"textDocument": {"uri": uri},
"position": {"line": line, "character": column},
},
)
if result is None:
return []
items = result.get("items", []) if isinstance(result, dict) else result
return [LSPCompletion.from_lsp(item) for item in items]
except Exception as e:
logger.debug(f"ASLS completion request failed: {e}")
return []
async def hover(self, uri: str, line: int, column: int) -> Optional[LSPHover]:
"""
Request hover information at a position.
Args:
uri: Document URI
line: 0-indexed line number
column: 0-indexed column number
"""
if not self._initialized:
return None
try:
result = await self._request(
"textDocument/hover",
{
"textDocument": {"uri": uri},
"position": {"line": line, "character": column},
},
)
return LSPHover.from_lsp(result) if result else None
except Exception as e:
logger.debug(f"ASLS hover request failed: {e}")
return None
async def signature_help(
self, uri: str, line: int, column: int
) -> Optional[dict[str, Any]]:
"""
Request signature help at a position.
Useful when typing function arguments.
"""
if not self._initialized:
return None
try:
result = await self._request(
"textDocument/signatureHelp",
{
"textDocument": {"uri": uri},
"position": {"line": line, "character": column},
},
)
return result
except Exception as e:
logger.debug(f"ASLS signature help request failed: {e}")
return None
async def go_to_definition(
self, uri: str, line: int, column: int
) -> Optional[list[dict[str, Any]]]:
"""
Request go-to-definition at a position.
Returns list of location objects.
"""
if not self._initialized:
return None
try:
result = await self._request(
"textDocument/definition",
{
"textDocument": {"uri": uri},
"position": {"line": line, "character": column},
},
)
if result is None:
return None
# Normalize to list
if isinstance(result, dict):
return [result]
return result
except Exception as e:
logger.debug(f"ASLS go-to-definition failed: {e}")
return None
def get_diagnostics(self, uri: str) -> list[LSPDiagnostic]:
"""Get current diagnostics for a document."""
return self._diagnostics.get(uri, [])
# -------------------------------------------------------------------------
# LSP Protocol Implementation (shared pattern with YAMLLSPClient)
# -------------------------------------------------------------------------
async def _request(self, method: str, params: dict[str, Any]) -> Any:
"""Send a request and wait for response."""
async with self._lock:
self._request_id += 1
req_id = self._request_id
message = {
"jsonrpc": "2.0",
"id": req_id,
"method": method,
"params": params,
}
future: asyncio.Future = asyncio.Future()
self._pending_requests[req_id] = future
try:
await self._send_message(message)
return await asyncio.wait_for(future, timeout=10.0) # Longer timeout for ASLS
except asyncio.TimeoutError:
logger.warning(f"ASLS request timed out: {method}")
return None
finally:
self._pending_requests.pop(req_id, None)
async def _notify(self, method: str, params: dict[str, Any]) -> None:
"""Send a notification (no response expected)."""
message = {
"jsonrpc": "2.0",
"method": method,
"params": params,
}
await self._send_message(message)
async def _send_message(self, message: dict[str, Any]) -> None:
"""Send a JSON-RPC message to the server."""
if not self._process or not self._process.stdin:
return
import json
content = json.dumps(message)
header = f"Content-Length: {len(content)}\r\n\r\n"
try:
self._process.stdin.write(header.encode())
self._process.stdin.write(content.encode())
await self._process.stdin.drain()
except (BrokenPipeError, OSError, ConnectionResetError) as e:
logger.error(f"Failed to send ASLS message: {e}")
async def _read_messages(self) -> None:
"""Read messages from the server."""
if not self._process or not self._process.stdout:
return
import json
try:
while True:
# Read header
header = b""
while b"\r\n\r\n" not in header:
chunk = await self._process.stdout.read(1)
if not chunk:
return # EOF
header += chunk
# Parse content length
content_length = 0
for line in header.decode().split("\r\n"):
if line.startswith("Content-Length:"):
content_length = int(line.split(":")[1].strip())
break
if content_length == 0:
continue
# Read content
content = await self._process.stdout.read(content_length)
if not content:
return
# Parse and handle message
try:
message = json.loads(content.decode())
await self._handle_message(message)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse ASLS message: {e}")
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"ASLS reader error: {e}")
async def _handle_message(self, message: dict[str, Any]) -> None:
"""Handle an incoming LSP message."""
if "id" in message and "result" in message:
# Response to a request
req_id = message["id"]
if req_id in self._pending_requests:
future = self._pending_requests[req_id]
if not future.done():
future.set_result(message.get("result"))
elif "id" in message and "error" in message:
# Error response
req_id = message["id"]
if req_id in self._pending_requests:
future = self._pending_requests[req_id]
if not future.done():
error = message["error"]
future.set_exception(
Exception(f"ASLS error: {error.get('message', error)}")
)
elif message.get("method") == "textDocument/publishDiagnostics":
# Diagnostics notification
params = message.get("params", {})
uri = params.get("uri", "")
diagnostics = [
LSPDiagnostic.from_lsp(d)
for d in params.get("diagnostics", [])
]
self._diagnostics[uri] = diagnostics
logger.debug(f"ASLS: {len(diagnostics)} diagnostics for {uri}")
elif "method" in message:
# Other notification
logger.debug(f"ASLS notification: {message.get('method')}")

View file

@ -1,314 +0,0 @@
"""
Bridge between LSP client and prompt_toolkit.
Provides:
- LSPCompleter: Async completer for prompt_toolkit using LSP
- DiagnosticsProcessor: Processes diagnostics for inline display
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Optional, Iterable, TYPE_CHECKING
if TYPE_CHECKING:
from .client import YAMLLSPClient, LSPDiagnostic, LSPCompletion
try:
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.document import Document
PROMPT_TOOLKIT_AVAILABLE = True
except ImportError:
PROMPT_TOOLKIT_AVAILABLE = False
# Stub classes for type checking
class Completer: # type: ignore
pass
class Completion: # type: ignore
pass
class Document: # type: ignore
pass
@dataclass
class DiagnosticMark:
"""A diagnostic marker for display in the editor."""
line: int
column: int
end_column: int
message: str
severity: str # error, warning, info, hint
@property
def is_error(self) -> bool:
return self.severity == "error"
@property
def is_warning(self) -> bool:
return self.severity == "warning"
@property
def style(self) -> str:
"""Get prompt_toolkit style for this diagnostic."""
if self.severity == "error":
return "class:diagnostic.error"
elif self.severity == "warning":
return "class:diagnostic.warning"
elif self.severity == "info":
return "class:diagnostic.info"
else:
return "class:diagnostic.hint"
class LSPCompleter(Completer):
"""
prompt_toolkit completer that uses LSP for suggestions.
Usage:
completer = LSPCompleter(lsp_client, document_uri)
buffer = Buffer(completer=completer)
"""
def __init__(
self,
client: Optional["YAMLLSPClient"],
uri: str,
fallback_completer: Optional[Completer] = None,
):
"""
Initialize the LSP completer.
Args:
client: LSP client (can be None for fallback-only mode)
uri: Document URI for LSP requests
fallback_completer: Fallback when LSP unavailable
"""
self.client = client
self.uri = uri
self.fallback_completer = fallback_completer
self._cache: dict[tuple[int, int], list["LSPCompletion"]] = {}
self._cache_version = 0
def invalidate_cache(self) -> None:
"""Invalidate the completion cache."""
self._cache.clear()
self._cache_version += 1
def get_completions(
self,
document: Document,
complete_event,
) -> Iterable[Completion]:
"""
Get completions for the current document position.
This is called synchronously by prompt_toolkit.
We use a cached result if available, otherwise
return nothing (async completions handled separately).
"""
if not PROMPT_TOOLKIT_AVAILABLE:
return
# Get current position
line = document.cursor_position_row
col = document.cursor_position_col
# Check cache
cache_key = (line, col)
if cache_key in self._cache:
completions = self._cache[cache_key]
for item in completions:
yield Completion(
text=item.insert_text or item.label,
start_position=-len(self._get_word_before_cursor(document)),
display=item.label,
display_meta=item.detail or item.kind,
)
return
# Fallback to basic completer
if self.fallback_completer:
yield from self.fallback_completer.get_completions(
document, complete_event
)
async def get_completions_async(
self,
document: Document,
) -> list["LSPCompletion"]:
"""
Get completions asynchronously from LSP.
Call this when Ctrl+Space is pressed.
"""
if self.client is None:
return []
line = document.cursor_position_row
col = document.cursor_position_col
# Request from LSP
completions = await self.client.completion(self.uri, line, col)
# Cache result
self._cache[(line, col)] = completions
return completions
def _get_word_before_cursor(self, document: Document) -> str:
"""Get the word being typed before cursor."""
text = document.text_before_cursor
if not text:
return ""
# Find word boundary
i = len(text) - 1
while i >= 0 and (text[i].isalnum() or text[i] in "_-"):
i -= 1
return text[i + 1:]
class DiagnosticsProcessor:
"""
Processes LSP diagnostics for display in the editor.
Converts LSP diagnostics into markers that can be
displayed inline in the prompt_toolkit editor.
"""
def __init__(self, client: Optional["YAMLLSPClient"], uri: str):
self.client = client
self.uri = uri
self._marks: list[DiagnosticMark] = []
def get_marks(self) -> list[DiagnosticMark]:
"""Get current diagnostic marks."""
return self._marks
def get_marks_for_line(self, line: int) -> list[DiagnosticMark]:
"""Get diagnostic marks for a specific line."""
return [m for m in self._marks if m.line == line]
def has_errors(self) -> bool:
"""Check if there are any error-level diagnostics."""
return any(m.is_error for m in self._marks)
def has_warnings(self) -> bool:
"""Check if there are any warning-level diagnostics."""
return any(m.is_warning for m in self._marks)
def get_error_count(self) -> int:
"""Get number of errors."""
return sum(1 for m in self._marks if m.is_error)
def get_warning_count(self) -> int:
"""Get number of warnings."""
return sum(1 for m in self._marks if m.is_warning)
async def update(self, content: str, version: int = 1) -> list[DiagnosticMark]:
"""
Update diagnostics by sending content to LSP.
Returns the new list of diagnostic marks.
"""
if self.client is None:
self._marks = []
return []
diagnostics = await self.client.did_change(self.uri, content, version)
self._marks = [
DiagnosticMark(
line=d.line,
column=d.column,
end_column=d.end_column,
message=d.message,
severity=d.severity,
)
for d in diagnostics
]
return self._marks
def format_status(self) -> str:
"""Format diagnostics as status bar text."""
errors = self.get_error_count()
warnings = self.get_warning_count()
if errors == 0 and warnings == 0:
return ""
parts = []
if errors > 0:
parts.append(f"{errors} error{'s' if errors > 1 else ''}")
if warnings > 0:
parts.append(f"{warnings} warning{'s' if warnings > 1 else ''}")
return " | ".join(parts)
def format_messages(self, max_lines: int = 3) -> list[str]:
"""Format diagnostic messages for display."""
messages = []
for mark in self._marks[:max_lines]:
prefix = "E" if mark.is_error else "W"
messages.append(f"[{prefix}] Line {mark.line + 1}: {mark.message}")
remaining = len(self._marks) - max_lines
if remaining > 0:
messages.append(f"... and {remaining} more")
return messages
class HoverPopup:
"""
Manages hover information display.
Shows documentation when hovering over a field
or pressing F1 on a position.
"""
def __init__(self, client: Optional["YAMLLSPClient"], uri: str):
self.client = client
self.uri = uri
self._current_hover: Optional[str] = None
self._hover_position: Optional[tuple[int, int]] = None
async def get_hover(self, line: int, col: int) -> Optional[str]:
"""
Get hover information for a position.
Returns formatted hover text or None.
"""
if self.client is None:
return None
hover = await self.client.hover(self.uri, line, col)
if hover is None:
self._current_hover = None
self._hover_position = None
return None
self._current_hover = hover.contents
self._hover_position = (line, col)
return hover.contents
def clear(self) -> None:
"""Clear current hover."""
self._current_hover = None
self._hover_position = None
@property
def has_hover(self) -> bool:
"""Check if there's an active hover."""
return self._current_hover is not None
@property
def text(self) -> str:
"""Get current hover text."""
return self._current_hover or ""

View file

@ -1,538 +0,0 @@
"""
YAML Language Server Protocol client.
Wraps communication with yaml-language-server for:
- Autocompletion
- Diagnostics (validation errors)
- Hover information
"""
from __future__ import annotations
import asyncio
import json
import subprocess
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Any
import logging
logger = logging.getLogger(__name__)
# Check for lsp-client availability
def _check_lsp_client() -> bool:
"""Check if lsp-client package is available."""
try:
import lsp_client # noqa: F401
return True
except ImportError:
return False
def _check_yaml_language_server() -> bool:
"""Check if yaml-language-server is installed."""
return shutil.which("yaml-language-server") is not None
def is_lsp_available() -> tuple[bool, str]:
"""
Check if LSP support is available.
Returns (available, reason) tuple.
"""
if not _check_lsp_client():
return False, "lsp-client package not installed (pip install lsp-client)"
if not _check_yaml_language_server():
return False, "yaml-language-server not found (npm install -g yaml-language-server)"
return True, "LSP available"
@dataclass
class LSPCompletion:
"""Normalized completion item from LSP."""
label: str
kind: str = "text" # text, keyword, property, value, snippet
detail: str = ""
documentation: str = ""
insert_text: str = ""
sort_text: str = ""
@classmethod
def from_lsp(cls, item: dict[str, Any]) -> "LSPCompletion":
"""Create from LSP CompletionItem."""
kind_map = {
1: "text",
2: "method",
3: "function",
5: "field",
6: "variable",
9: "module",
10: "property",
12: "value",
14: "keyword",
15: "snippet",
}
return cls(
label=item.get("label", ""),
kind=kind_map.get(item.get("kind", 1), "text"),
detail=item.get("detail", ""),
documentation=_extract_documentation(item.get("documentation")),
insert_text=item.get("insertText", item.get("label", "")),
sort_text=item.get("sortText", item.get("label", "")),
)
@dataclass
class LSPDiagnostic:
"""Normalized diagnostic from LSP."""
line: int
column: int
end_line: int
end_column: int
message: str
severity: str = "error" # error, warning, info, hint
source: str = "yaml-language-server"
@classmethod
def from_lsp(cls, diag: dict[str, Any]) -> "LSPDiagnostic":
"""Create from LSP Diagnostic."""
severity_map = {1: "error", 2: "warning", 3: "info", 4: "hint"}
range_data = diag.get("range", {})
start = range_data.get("start", {})
end = range_data.get("end", {})
return cls(
line=start.get("line", 0),
column=start.get("character", 0),
end_line=end.get("line", 0),
end_column=end.get("character", 0),
message=diag.get("message", ""),
severity=severity_map.get(diag.get("severity", 1), "error"),
source=diag.get("source", "yaml-language-server"),
)
@dataclass
class LSPHover:
"""Normalized hover information from LSP."""
contents: str
range_start_line: Optional[int] = None
range_start_col: Optional[int] = None
@classmethod
def from_lsp(cls, hover: dict[str, Any]) -> Optional["LSPHover"]:
"""Create from LSP Hover response."""
if not hover:
return None
contents = hover.get("contents")
if isinstance(contents, str):
text = contents
elif isinstance(contents, dict):
text = contents.get("value", str(contents))
elif isinstance(contents, list):
text = "\n".join(
c.get("value", str(c)) if isinstance(c, dict) else str(c)
for c in contents
)
else:
return None
range_data = hover.get("range", {})
start = range_data.get("start", {})
return cls(
contents=text,
range_start_line=start.get("line"),
range_start_col=start.get("character"),
)
def _extract_documentation(doc: Any) -> str:
"""Extract documentation string from LSP documentation field."""
if doc is None:
return ""
if isinstance(doc, str):
return doc
if isinstance(doc, dict):
return doc.get("value", "")
return str(doc)
class YAMLLSPClient:
"""
Client for communicating with yaml-language-server.
Uses stdio for communication with the language server process.
"""
def __init__(self, schema_uri: Optional[str] = None):
"""
Initialize the LSP client.
Args:
schema_uri: Default schema URI for YAML files
"""
self.schema_uri = schema_uri
self._process: Optional[subprocess.Popen] = None
self._reader_task: Optional[asyncio.Task] = None
self._request_id = 0
self._pending_requests: dict[int, asyncio.Future] = {}
self._diagnostics: dict[str, list[LSPDiagnostic]] = {}
self._initialized = False
self._lock = asyncio.Lock()
async def start(self) -> bool:
"""
Start the language server.
Returns True if started successfully.
"""
available, reason = is_lsp_available()
if not available:
logger.warning(f"LSP not available: {reason}")
return False
try:
self._process = subprocess.Popen(
["yaml-language-server", "--stdio"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Start reader task
self._reader_task = asyncio.create_task(self._read_messages())
# Initialize LSP
await self._initialize()
self._initialized = True
return True
except Exception as e:
logger.error(f"Failed to start yaml-language-server: {e}")
await self.stop()
return False
async def stop(self) -> None:
"""Stop the language server."""
self._initialized = False
if self._reader_task:
self._reader_task.cancel()
try:
await self._reader_task
except asyncio.CancelledError:
pass
self._reader_task = None
if self._process:
self._process.terminate()
try:
self._process.wait(timeout=2)
except subprocess.TimeoutExpired:
self._process.kill()
self._process = None
# Cancel pending requests
for future in self._pending_requests.values():
if not future.done():
future.cancel()
self._pending_requests.clear()
async def _initialize(self) -> None:
"""Send LSP initialize request."""
result = await self._request(
"initialize",
{
"processId": None,
"rootUri": None,
"capabilities": {
"textDocument": {
"completion": {
"completionItem": {
"snippetSupport": True,
"documentationFormat": ["markdown", "plaintext"],
}
},
"hover": {
"contentFormat": ["markdown", "plaintext"],
},
"publishDiagnostics": {},
}
},
"initializationOptions": {
"yaml": {
"validate": True,
"hover": True,
"completion": True,
"schemas": {},
}
},
},
)
logger.debug(f"LSP initialized: {result}")
# Send initialized notification
await self._notify("initialized", {})
async def did_open(self, uri: str, content: str) -> None:
"""Notify server that a document was opened."""
if not self._initialized:
return
await self._notify(
"textDocument/didOpen",
{
"textDocument": {
"uri": uri,
"languageId": "yaml",
"version": 1,
"text": content,
}
},
)
async def did_change(self, uri: str, content: str, version: int = 1) -> list[LSPDiagnostic]:
"""
Notify server of document change.
Returns current diagnostics for the document.
"""
if not self._initialized:
return []
await self._notify(
"textDocument/didChange",
{
"textDocument": {"uri": uri, "version": version},
"contentChanges": [{"text": content}],
},
)
# Wait briefly for diagnostics
await asyncio.sleep(0.1)
return self._diagnostics.get(uri, [])
async def did_close(self, uri: str) -> None:
"""Notify server that a document was closed."""
if not self._initialized:
return
await self._notify(
"textDocument/didClose",
{"textDocument": {"uri": uri}},
)
# Clear diagnostics
self._diagnostics.pop(uri, None)
async def completion(
self, uri: str, line: int, column: int
) -> list[LSPCompletion]:
"""
Request completions at a position.
Args:
uri: Document URI
line: 0-indexed line number
column: 0-indexed column number
Returns list of completion items.
"""
if not self._initialized:
return []
try:
result = await self._request(
"textDocument/completion",
{
"textDocument": {"uri": uri},
"position": {"line": line, "character": column},
},
)
if result is None:
return []
items = result.get("items", []) if isinstance(result, dict) else result
return [LSPCompletion.from_lsp(item) for item in items]
except Exception as e:
logger.debug(f"Completion request failed: {e}")
return []
async def hover(self, uri: str, line: int, column: int) -> Optional[LSPHover]:
"""
Request hover information at a position.
Args:
uri: Document URI
line: 0-indexed line number
column: 0-indexed column number
"""
if not self._initialized:
return None
try:
result = await self._request(
"textDocument/hover",
{
"textDocument": {"uri": uri},
"position": {"line": line, "character": column},
},
)
return LSPHover.from_lsp(result) if result else None
except Exception as e:
logger.debug(f"Hover request failed: {e}")
return None
def get_diagnostics(self, uri: str) -> list[LSPDiagnostic]:
"""Get current diagnostics for a document."""
return self._diagnostics.get(uri, [])
async def _request(self, method: str, params: dict[str, Any]) -> Any:
"""Send a request and wait for response."""
async with self._lock:
self._request_id += 1
req_id = self._request_id
message = {
"jsonrpc": "2.0",
"id": req_id,
"method": method,
"params": params,
}
future: asyncio.Future = asyncio.Future()
self._pending_requests[req_id] = future
try:
await self._send_message(message)
return await asyncio.wait_for(future, timeout=5.0)
except asyncio.TimeoutError:
logger.warning(f"LSP request timed out: {method}")
return None
finally:
self._pending_requests.pop(req_id, None)
async def _notify(self, method: str, params: dict[str, Any]) -> None:
"""Send a notification (no response expected)."""
message = {
"jsonrpc": "2.0",
"method": method,
"params": params,
}
await self._send_message(message)
async def _send_message(self, message: dict[str, Any]) -> None:
"""Send a JSON-RPC message to the server."""
if not self._process or not self._process.stdin:
return
content = json.dumps(message)
header = f"Content-Length: {len(content)}\r\n\r\n"
try:
self._process.stdin.write(header.encode())
self._process.stdin.write(content.encode())
self._process.stdin.flush()
except (BrokenPipeError, OSError) as e:
logger.error(f"Failed to send LSP message: {e}")
async def _read_messages(self) -> None:
"""Read messages from the server."""
if not self._process or not self._process.stdout:
return
loop = asyncio.get_event_loop()
try:
while True:
# Read header
header = b""
while b"\r\n\r\n" not in header:
chunk = await loop.run_in_executor(
None, self._process.stdout.read, 1
)
if not chunk:
return # EOF
header += chunk
# Parse content length
content_length = 0
for line in header.decode().split("\r\n"):
if line.startswith("Content-Length:"):
content_length = int(line.split(":")[1].strip())
break
if content_length == 0:
continue
# Read content
content = await loop.run_in_executor(
None, self._process.stdout.read, content_length
)
if not content:
return
# Parse and handle message
try:
message = json.loads(content.decode())
await self._handle_message(message)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse LSP message: {e}")
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"LSP reader error: {e}")
async def _handle_message(self, message: dict[str, Any]) -> None:
"""Handle an incoming LSP message."""
if "id" in message and "result" in message:
# Response to a request
req_id = message["id"]
if req_id in self._pending_requests:
future = self._pending_requests[req_id]
if not future.done():
future.set_result(message.get("result"))
elif "id" in message and "error" in message:
# Error response
req_id = message["id"]
if req_id in self._pending_requests:
future = self._pending_requests[req_id]
if not future.done():
error = message["error"]
future.set_exception(
Exception(f"LSP error: {error.get('message', error)}")
)
elif message.get("method") == "textDocument/publishDiagnostics":
# Diagnostics notification
params = message.get("params", {})
uri = params.get("uri", "")
diagnostics = [
LSPDiagnostic.from_lsp(d)
for d in params.get("diagnostics", [])
]
self._diagnostics[uri] = diagnostics
logger.debug(f"Received {len(diagnostics)} diagnostics for {uri}")
elif "method" in message:
# Other notification
logger.debug(f"LSP notification: {message.get('method')}")

View file

@ -1,211 +0,0 @@
"""
LSP Server lifecycle manager.
Manages language server instances that can be shared across
multiple editor sessions. Supports multiple language servers:
- yaml-language-server (for config files)
- asls (for AssemblyScript listener source)
"""
from __future__ import annotations
import asyncio
import logging
from enum import Enum
from typing import Optional, Union
from .client import YAMLLSPClient, is_lsp_available
from .asls_client import ASLSClient, ASLSConfig, is_asls_available
logger = logging.getLogger(__name__)
class LSPServerType(Enum):
"""Supported language server types."""
YAML = "yaml"
ASSEMBLYSCRIPT = "assemblyscript"
# Type alias for any LSP client
LSPClient = Union[YAMLLSPClient, ASLSClient]
class LSPServerManager:
"""
Manages the lifecycle of LSP servers.
Provides singleton client instances that start on first use
and stop when explicitly requested or when the process exits.
Supports multiple language servers running concurrently.
"""
def __init__(self):
self._clients: dict[LSPServerType, LSPClient] = {}
self._ref_counts: dict[LSPServerType, int] = {}
self._lock = asyncio.Lock()
def is_running(self, server_type: LSPServerType = LSPServerType.YAML) -> bool:
"""Check if a specific LSP server is running."""
client = self._clients.get(server_type)
return client is not None and client._initialized
async def get_client(
self,
server_type: LSPServerType = LSPServerType.YAML,
asls_config: Optional[ASLSConfig] = None,
) -> Optional[LSPClient]:
"""
Get an LSP client, starting the server if needed.
Args:
server_type: Which language server to get
asls_config: Configuration for ASLS (only used if server_type is ASSEMBLYSCRIPT)
Returns None if the requested LSP is not available.
"""
async with self._lock:
# Check if already running
if server_type in self._clients:
client = self._clients[server_type]
if client._initialized:
self._ref_counts[server_type] = self._ref_counts.get(server_type, 0) + 1
return client
# Start the appropriate server
if server_type == LSPServerType.YAML:
return await self._start_yaml_server()
elif server_type == LSPServerType.ASSEMBLYSCRIPT:
return await self._start_asls_server(asls_config)
else:
logger.error(f"Unknown LSP server type: {server_type}")
return None
async def _start_yaml_server(self) -> Optional[YAMLLSPClient]:
"""Start the YAML language server."""
available, reason = is_lsp_available()
if not available:
logger.info(f"YAML LSP not available: {reason}")
return None
client = YAMLLSPClient()
success = await client.start()
if success:
self._clients[LSPServerType.YAML] = client
self._ref_counts[LSPServerType.YAML] = 1
logger.info("yaml-language-server started")
return client
else:
return None
async def _start_asls_server(
self, config: Optional[ASLSConfig] = None
) -> Optional[ASLSClient]:
"""Start the AssemblyScript language server."""
available, reason = is_asls_available()
if not available:
logger.info(f"ASLS not available: {reason}")
return None
client = ASLSClient(config=config)
success = await client.start()
if success:
self._clients[LSPServerType.ASSEMBLYSCRIPT] = client
self._ref_counts[LSPServerType.ASSEMBLYSCRIPT] = 1
logger.info("AssemblyScript language server started")
return client
else:
return None
async def release_client(
self, server_type: LSPServerType = LSPServerType.YAML
) -> None:
"""
Release a reference to a client.
Stops the server when the last reference is released.
"""
async with self._lock:
if server_type not in self._ref_counts:
return
self._ref_counts[server_type] -= 1
if self._ref_counts[server_type] <= 0:
client = self._clients.pop(server_type, None)
self._ref_counts.pop(server_type, None)
if client is not None:
await client.stop()
logger.info(f"{server_type.value} language server stopped")
async def stop(self, server_type: Optional[LSPServerType] = None) -> None:
"""
Force stop LSP server(s).
Args:
server_type: Specific server to stop, or None to stop all
"""
async with self._lock:
if server_type is not None:
# Stop specific server
client = self._clients.pop(server_type, None)
self._ref_counts.pop(server_type, None)
if client is not None:
await client.stop()
logger.info(f"{server_type.value} language server stopped (forced)")
else:
# Stop all servers
for st, client in list(self._clients.items()):
await client.stop()
logger.info(f"{st.value} language server stopped (forced)")
self._clients.clear()
self._ref_counts.clear()
async def stop_all(self) -> None:
"""Force stop all LSP servers."""
await self.stop(None)
# Convenience methods for YAML (backwards compatible)
async def get_yaml_client(self) -> Optional[YAMLLSPClient]:
"""Get YAML LSP client (convenience method)."""
client = await self.get_client(LSPServerType.YAML)
return client if isinstance(client, YAMLLSPClient) else None
async def get_asls_client(
self, config: Optional[ASLSConfig] = None
) -> Optional[ASLSClient]:
"""Get AssemblyScript LSP client (convenience method)."""
client = await self.get_client(LSPServerType.ASSEMBLYSCRIPT, asls_config=config)
return client if isinstance(client, ASLSClient) else None
# Context manager for YAML (backwards compatible)
async def __aenter__(self) -> Optional[YAMLLSPClient]:
"""Context manager entry - get YAML client."""
return await self.get_yaml_client()
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
"""Context manager exit - release YAML client."""
await self.release_client(LSPServerType.YAML)
# Global singleton
_manager: Optional[LSPServerManager] = None
def get_lsp_manager() -> LSPServerManager:
"""Get the global LSP server manager."""
global _manager
if _manager is None:
_manager = LSPServerManager()
return _manager
async def ensure_lsp_stopped() -> None:
"""Ensure all LSP servers are stopped. Call on application shutdown."""
if _manager is not None:
await _manager.stop_all()

View file

@ -1,980 +0,0 @@
"""
secure_console.py Password-protected console for privileged operations.
The console is the sole privileged interface to the organism. Privileged
operations are only accessible via local keyboard input, never over the network.
Features:
- Password protection with Argon2id hashing
- Protected commands require password re-entry
- Attach/detach model with idle timeout
- Integration with context buffer for /monitor
Security model:
- Keyboard = Local = Trusted
- No network port for privileged operations
- Password hash stored in ~/.xml-pipeline/console.key (mode 600)
"""
from __future__ import annotations
import asyncio
import getpass
import os
import stat
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Callable, Awaitable
import yaml
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# prompt_toolkit may not work in all terminals (e.g., Git Bash on Windows)
# We provide a fallback to simple input()
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
PROMPT_TOOLKIT_AVAILABLE = True
except ImportError:
PROMPT_TOOLKIT_AVAILABLE = False
if TYPE_CHECKING:
from xml_pipeline.message_bus.stream_pump import StreamPump
# ============================================================================
# Constants
# ============================================================================
CONFIG_DIR = Path.home() / ".xml-pipeline"
KEY_FILE = CONFIG_DIR / "console.key"
HISTORY_FILE = CONFIG_DIR / "history"
# Commands that require password re-entry
PROTECTED_COMMANDS = {"restart", "kill", "pause", "resume"}
# Idle timeout before auto-detach (seconds, 0 = disabled)
DEFAULT_IDLE_TIMEOUT = 30 * 60 # 30 minutes
# ============================================================================
# ANSI Colors
# ============================================================================
class Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
def cprint(text: str, color: str = Colors.RESET):
"""Print with ANSI color."""
try:
print(f"{color}{text}{Colors.RESET}")
except UnicodeEncodeError:
print(text)
# ============================================================================
# Password Management
# ============================================================================
class PasswordManager:
"""Manages password hashing and verification."""
def __init__(self, key_path: Path = KEY_FILE):
self.key_path = key_path
self.hasher = PasswordHasher()
self._hash: Optional[str] = None
def ensure_config_dir(self):
"""Create config directory if needed."""
self.key_path.parent.mkdir(parents=True, exist_ok=True)
def has_password(self) -> bool:
"""Check if password has been set."""
return self.key_path.exists()
def load_hash(self) -> Optional[str]:
"""Load password hash from file."""
if not self.key_path.exists():
return None
try:
with open(self.key_path) as f:
data = yaml.safe_load(f)
self._hash = data.get("hash")
return self._hash
except Exception:
return None
def save_hash(self, password: str) -> None:
"""Hash password and save to file."""
self.ensure_config_dir()
hash_value = self.hasher.hash(password)
data = {
"algorithm": "argon2id",
"hash": hash_value,
"created": datetime.now(timezone.utc).isoformat(),
}
with open(self.key_path, "w") as f:
yaml.dump(data, f)
# Set file permissions to 600 (owner read/write only)
if sys.platform != "win32":
os.chmod(self.key_path, stat.S_IRUSR | stat.S_IWUSR)
self._hash = hash_value
def verify(self, password: str) -> bool:
"""Verify password against stored hash."""
if self._hash is None:
self.load_hash()
if self._hash is None:
return False
try:
self.hasher.verify(self._hash, password)
return True
except VerifyMismatchError:
return False
# ============================================================================
# Secure Console
# ============================================================================
class SecureConsole:
"""
Password-protected console with privileged command support.
The console can be in one of two states:
- Attached: Full access, can send messages and run commands
- Detached: Limited access, only /commands work, @messages rejected
"""
def __init__(
self,
pump: StreamPump,
idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
):
self.pump = pump
self.idle_timeout = idle_timeout
self.password_mgr = PasswordManager()
# State
self.authenticated = False
self.attached = True # Start attached
self.running = False
# prompt_toolkit session (may be None if fallback mode)
self.session: Optional[PromptSession] = None
self.use_simple_input = False # Fallback mode flag
# ------------------------------------------------------------------
# Startup
# ------------------------------------------------------------------
def _init_prompt_session(self) -> None:
"""Initialize prompt session (with fallback)."""
if self.session is not None:
return # Already initialized
self.password_mgr.ensure_config_dir()
if PROMPT_TOOLKIT_AVAILABLE:
try:
self.session = PromptSession(
history=FileHistory(str(HISTORY_FILE))
)
except Exception as e:
cprint(f"Note: Using simple input mode ({type(e).__name__})", Colors.DIM)
self.use_simple_input = True
else:
self.use_simple_input = True
async def authenticate(self) -> bool:
"""
Authenticate user (call before starting pump).
Returns True if authenticated, False otherwise.
"""
self._init_prompt_session()
# Ensure password is set up
if not await self._ensure_password():
return False
# Authenticate
if not await self._authenticate():
cprint("Authentication failed.", Colors.RED)
return False
self.authenticated = True
return True
async def run_command_loop(self) -> None:
"""
Run the command loop (call after authentication).
This shows the banner and enters the main input loop.
"""
if not self.authenticated:
cprint("Not authenticated. Call authenticate() first.", Colors.RED)
return
self.running = True
self._print_banner()
await self._main_loop()
async def run(self) -> None:
"""Main console loop (combines authenticate + run_command_loop)."""
if await self.authenticate():
await self.run_command_loop()
async def _ensure_password(self) -> bool:
"""Ensure password is set up (first run setup)."""
if self.password_mgr.has_password():
return True
cprint("\n" + "=" * 50, Colors.CYAN)
cprint(" First-time setup: Create console password", Colors.CYAN)
cprint("=" * 50 + "\n", Colors.CYAN)
cprint("This password protects privileged operations.", Colors.DIM)
cprint("It will be required at startup and for protected commands.\n", Colors.DIM)
# Get password with confirmation
while True:
password = await self._prompt_password("New password: ")
if not password:
cprint("Password cannot be empty.", Colors.RED)
continue
if len(password) < 4:
cprint("Password must be at least 4 characters.", Colors.RED)
continue
confirm = await self._prompt_password("Confirm password: ")
if password != confirm:
cprint("Passwords do not match.", Colors.RED)
continue
break
self.password_mgr.save_hash(password)
cprint("\nPassword set successfully.\n", Colors.GREEN)
return True
async def _authenticate(self) -> bool:
"""Authenticate user at startup."""
self.password_mgr.load_hash()
for attempt in range(3):
password = await self._prompt_password("Password: ")
if self.password_mgr.verify(password):
self.authenticated = True
return True
cprint("Incorrect password.", Colors.RED)
return False
async def _prompt_password(self, prompt: str) -> str:
"""Prompt for password (hidden input when possible)."""
if self.use_simple_input:
# Simple input mode: use visible input (getpass unreliable in some terminals)
cprint("(password will be visible)", Colors.DIM)
print(prompt, end="", flush=True)
loop = asyncio.get_event_loop()
try:
line = await loop.run_in_executor(None, sys.stdin.readline)
return line.strip() if line else ""
except (EOFError, KeyboardInterrupt):
return ""
else:
# Use prompt_toolkit for password input (hidden)
try:
session = PromptSession()
return await session.prompt_async(prompt, is_password=True)
except (EOFError, KeyboardInterrupt):
return ""
except Exception:
# Fallback if prompt_toolkit fails mid-session
self.use_simple_input = True
return await self._prompt_password(prompt)
# ------------------------------------------------------------------
# Main Loop
# ------------------------------------------------------------------
async def _main_loop(self) -> None:
"""Main input loop."""
while self.running:
try:
# Determine prompt based on attach state
prompt_str = "> " if self.attached else "# "
# Read input
line = await self._read_input(prompt_str)
await self._handle_input(line.strip())
except EOFError:
cprint("\nEOF received. Shutting down.", Colors.YELLOW)
break
except KeyboardInterrupt:
continue
async def _read_input(self, prompt: str) -> str:
"""Read a line of input (with fallback for non-TTY terminals)."""
if self.use_simple_input:
# Fallback: simple blocking input
loop = asyncio.get_event_loop()
print(prompt, end="", flush=True)
try:
line = await loop.run_in_executor(None, sys.stdin.readline)
if not line:
raise EOFError()
return line.strip()
except (EOFError, KeyboardInterrupt):
raise
else:
# Use prompt_toolkit with optional timeout
try:
with patch_stdout():
if self.idle_timeout > 0:
try:
return await asyncio.wait_for(
self.session.prompt_async(prompt),
timeout=self.idle_timeout
)
except asyncio.TimeoutError:
cprint("\nIdle timeout. Detaching console.", Colors.YELLOW)
self.attached = False
return ""
else:
return await self.session.prompt_async(prompt)
except Exception:
# Fall back to simple input if prompt_toolkit fails
self.use_simple_input = True
return await self._read_input(prompt)
async def _handle_input(self, line: str) -> None:
"""Route input to appropriate handler."""
if not line:
return
if line.startswith("/"):
await self._handle_command(line)
elif line.startswith("@"):
await self._handle_message(line)
else:
cprint("Use @listener message or /command", Colors.DIM)
# ------------------------------------------------------------------
# Command Handling
# ------------------------------------------------------------------
async def _handle_command(self, line: str) -> None:
"""Handle /command."""
parts = line[1:].split(None, 1)
cmd = parts[0].lower() if parts else ""
args = parts[1] if len(parts) > 1 else ""
# Check if protected command
if cmd in PROTECTED_COMMANDS:
if not await self._verify_password():
cprint("Password required for this command.", Colors.RED)
return
# Dispatch to handler
handler = getattr(self, f"_cmd_{cmd}", None)
if handler:
await handler(args)
else:
cprint(f"Unknown command: /{cmd}", Colors.RED)
cprint("Type /help for available commands.", Colors.DIM)
async def _verify_password(self) -> bool:
"""Verify password for protected commands."""
password = await self._prompt_password("Password: ")
return self.password_mgr.verify(password)
# ------------------------------------------------------------------
# Message Handling
# ------------------------------------------------------------------
async def _handle_message(self, line: str) -> None:
"""Handle @listener message."""
if not self.attached:
cprint("Console detached. Use /attach first.", Colors.RED)
return
parts = line[1:].split(None, 1)
if not parts:
cprint("Usage: @listener message", Colors.DIM)
return
target = parts[0].lower()
message = parts[1] if len(parts) > 1 else ""
# Check if listener exists
if target not in self.pump.listeners:
cprint(f"Unknown listener: {target}", Colors.RED)
cprint("Use /listeners to see available listeners.", Colors.DIM)
return
cprint(f"[sending to {target}]", Colors.DIM)
# Create payload based on target listener
listener = self.pump.listeners[target]
payload = self._create_payload(listener, message)
if payload is None:
cprint(f"Cannot create payload for {target}", Colors.RED)
return
# Create thread and inject message
import uuid
thread_id = str(uuid.uuid4())
envelope = self.pump._wrap_in_envelope(
payload=payload,
from_id="console",
to_id=target,
thread_id=thread_id,
)
await self.pump.inject(envelope, thread_id=thread_id, from_id="console")
def _create_payload(self, listener, message: str):
"""Create payload instance for a listener from message text."""
payload_class = listener.payload_class
# Try to create payload with common field patterns
# Most payloads have a single text field like 'name', 'message', 'text', etc.
if hasattr(payload_class, '__dataclass_fields__'):
fields = payload_class.__dataclass_fields__
field_names = list(fields.keys())
if len(field_names) == 1:
# Single field - use the message as its value
return payload_class(**{field_names[0]: message})
elif 'name' in field_names:
return payload_class(name=message)
elif 'message' in field_names:
return payload_class(message=message)
elif 'text' in field_names:
return payload_class(text=message)
# Fallback: try with no args
try:
return payload_class()
except Exception:
return None
# ------------------------------------------------------------------
# Commands: Informational
# ------------------------------------------------------------------
async def _cmd_help(self, args: str) -> None:
"""Show available commands."""
cprint("\nCommands:", Colors.CYAN)
cprint(" /help Show this help", Colors.DIM)
cprint(" /status Show organism status", Colors.DIM)
cprint(" /listeners List registered listeners", Colors.DIM)
cprint(" /threads List active threads", Colors.DIM)
cprint(" /buffer <thread> Inspect thread's context buffer", Colors.DIM)
cprint(" /monitor <thread> Show recent messages from thread", Colors.DIM)
cprint(" /monitor * Show recent messages from all threads", Colors.DIM)
cprint("")
cprint("Configuration:", Colors.CYAN)
cprint(" /config Show current config", Colors.DIM)
cprint(" /config -e Edit organism.yaml", Colors.DIM)
cprint(" /config @name Edit listener config", Colors.DIM)
cprint(" /config --list List listener configs", Colors.DIM)
cprint("")
cprint("Protected (require password):", Colors.YELLOW)
cprint(" /restart Restart the pipeline", Colors.DIM)
cprint(" /kill <thread> Terminate a thread", Colors.DIM)
cprint(" /pause Pause message processing", Colors.DIM)
cprint(" /resume Resume message processing", Colors.DIM)
cprint("")
cprint("Session:", Colors.CYAN)
cprint(" /attach Attach console (enable @messages)", Colors.DIM)
cprint(" /detach Detach console (organism keeps running)", Colors.DIM)
cprint(" /passwd Change console password", Colors.DIM)
cprint(" /quit Graceful shutdown", Colors.DIM)
cprint("")
async def _cmd_status(self, args: str) -> None:
"""Show organism status."""
from xml_pipeline.memory import get_context_buffer
from xml_pipeline.message_bus.thread_registry import get_registry
buffer = get_context_buffer()
registry = get_registry()
stats = buffer.get_stats()
cprint(f"\nOrganism: {self.pump.config.name}", Colors.CYAN)
cprint(f"Status: {'attached' if self.attached else 'detached'}",
Colors.GREEN if self.attached else Colors.YELLOW)
cprint(f"Listeners: {len(self.pump.listeners)}", Colors.DIM)
cprint(f"Threads: {stats['thread_count']} active", Colors.DIM)
cprint(f"Buffer: {stats['total_slots']} slots across threads", Colors.DIM)
cprint("")
async def _cmd_listeners(self, args: str) -> None:
"""List registered listeners."""
cprint("\nRegistered listeners:", Colors.CYAN)
for name, listener in self.pump.listeners.items():
agent_tag = "[agent] " if listener.is_agent else ""
cprint(f" {name:20} {agent_tag}{listener.description}", Colors.DIM)
cprint("")
async def _cmd_threads(self, args: str) -> None:
"""List active threads."""
from xml_pipeline.memory import get_context_buffer
buffer = get_context_buffer()
stats = buffer.get_stats()
if stats["thread_count"] == 0:
cprint("\nNo active threads.", Colors.DIM)
return
cprint(f"\nActive threads ({stats['thread_count']}):", Colors.CYAN)
# Access internal threads dict (not ideal but works for now)
for thread_id, ctx in buffer._threads.items():
slot_count = len(ctx)
age = datetime.now(timezone.utc) - ctx._created_at
age_str = str(age).split(".")[0] # Remove microseconds
# Get last sender/receiver
if slot_count > 0:
last = ctx[-1]
flow = f"{last.from_id} -> {last.to_id}"
else:
flow = "(empty)"
cprint(f" {thread_id[:12]}... slots={slot_count:3} age={age_str} {flow}", Colors.DIM)
cprint("")
async def _cmd_buffer(self, args: str) -> None:
"""Inspect a thread's context buffer."""
if not args:
cprint("Usage: /buffer <thread-id>", Colors.DIM)
return
from xml_pipeline.memory import get_context_buffer
buffer = get_context_buffer()
# Find thread by prefix
thread_id = None
for tid in buffer._threads.keys():
if tid.startswith(args):
thread_id = tid
break
if not thread_id:
cprint(f"Thread not found: {args}", Colors.RED)
return
ctx = buffer.get_thread(thread_id)
cprint(f"\nThread: {thread_id}", Colors.CYAN)
cprint(f"Slots: {len(ctx)}", Colors.DIM)
cprint("-" * 60, Colors.DIM)
for slot in ctx:
payload_type = type(slot.payload).__name__
cprint(f"[{slot.index}] {slot.from_id} -> {slot.to_id}: {payload_type}", Colors.DIM)
# Show first 100 chars of payload repr
payload_repr = repr(slot.payload)[:100]
cprint(f" {payload_repr}", Colors.DIM)
cprint("")
async def _cmd_monitor(self, args: str) -> None:
"""Show recent messages from a thread's context buffer."""
if not args:
cprint("Usage: /monitor <thread-id>", Colors.DIM)
cprint(" /monitor * (show all threads)", Colors.DIM)
return
from xml_pipeline.memory import get_context_buffer
buffer = get_context_buffer()
# Find thread by prefix (or * for all)
monitor_all = args.strip() == "*"
thread_id = None
if not monitor_all:
for tid in buffer._threads.keys():
if tid.startswith(args):
thread_id = tid
break
if not thread_id:
cprint(f"Thread not found: {args}", Colors.RED)
return
# Show header
if monitor_all:
cprint("\nAll threads:", Colors.CYAN)
else:
cprint(f"\nThread {thread_id[:12]}...:", Colors.CYAN)
cprint("-" * 60, Colors.DIM)
# Show messages
if monitor_all:
for tid, ctx in buffer._threads.items():
if len(ctx) > 0:
cprint(f"\n[{tid[:12]}...] ({len(ctx)} messages)", Colors.YELLOW)
# Show last 5 messages per thread
for slot in ctx.get_slice(-5):
self._print_monitor_slot(tid, slot)
else:
ctx = buffer.get_thread(thread_id)
# Show all messages (up to 20)
slots = ctx.get_slice(-20)
for slot in slots:
self._print_monitor_slot(thread_id, slot)
if len(ctx) > 20:
cprint(f" ... ({len(ctx) - 20} earlier messages)", Colors.DIM)
cprint("")
def _print_monitor_slot(self, thread_id: str, slot) -> None:
"""Print a single slot in monitor format."""
payload_type = type(slot.payload).__name__
tid_short = thread_id[:8]
timestamp = slot.metadata.timestamp.split("T")[1][:8] if "T" in slot.metadata.timestamp else ""
# Color based on direction
if slot.from_id == "console":
color = Colors.GREEN
elif "response" in slot.to_id.lower() or "console" in slot.to_id.lower():
color = Colors.CYAN
else:
color = Colors.DIM
# Format: [time] thread: from -> to: Type
cprint(f"[{timestamp}] {tid_short}: {slot.from_id} -> {slot.to_id}: {payload_type}", color)
# Show payload content (abbreviated)
payload_str = repr(slot.payload)
if len(payload_str) > 80:
payload_str = payload_str[:77] + "..."
cprint(f" {payload_str}", Colors.DIM)
async def _cmd_config(self, args: str) -> None:
"""
Edit configuration files.
/config - Edit organism.yaml
/config @name - Edit listener config (e.g., /config @greeter)
/config --list - List available listener configs
/config --show - Show current config (read-only)
"""
args = args.strip() if args else ""
if args == "--list":
await self._config_list()
elif args == "--show" or args == "":
await self._config_show()
elif args.startswith("@"):
listener_name = args[1:].strip()
if listener_name:
await self._config_edit_listener(listener_name)
else:
cprint("Usage: /config @listener_name", Colors.RED)
elif args == "--edit" or args == "-e":
await self._config_edit_organism()
else:
cprint(f"Unknown option: {args}", Colors.RED)
cprint("Usage:", Colors.DIM)
cprint(" /config Show current config", Colors.DIM)
cprint(" /config -e Edit organism.yaml", Colors.DIM)
cprint(" /config @name Edit listener config", Colors.DIM)
cprint(" /config --list List listener configs", Colors.DIM)
async def _config_show(self) -> None:
"""Show current configuration (read-only)."""
cprint(f"\nOrganism: {self.pump.config.name}", Colors.CYAN)
cprint(f"Port: {self.pump.config.port}", Colors.DIM)
cprint(f"Thread scheduling: {self.pump.config.thread_scheduling}", Colors.DIM)
cprint(f"Max concurrent pipelines: {self.pump.config.max_concurrent_pipelines}", Colors.DIM)
cprint(f"Max concurrent handlers: {self.pump.config.max_concurrent_handlers}", Colors.DIM)
cprint(f"Max concurrent per agent: {self.pump.config.max_concurrent_per_agent}", Colors.DIM)
cprint("\nUse /config -e to edit organism.yaml", Colors.DIM)
cprint("Use /config @listener to edit a listener config", Colors.DIM)
cprint("")
async def _config_list(self) -> None:
"""List available listener configs."""
from xml_pipeline.config import get_listener_config_store
store = get_listener_config_store()
listeners = store.list_listeners()
cprint("\nListener configurations:", Colors.CYAN)
cprint(f"Directory: {store.listeners_dir}", Colors.DIM)
cprint("")
if not listeners:
cprint(" No listener configs found.", Colors.DIM)
cprint(" Use /config @name to create one.", Colors.DIM)
else:
for name in sorted(listeners):
config = store.get(name)
agent_tag = "[agent]" if config.agent else "[tool]" if config.tool else ""
cprint(f" @{name:20} {agent_tag} {config.description or ''}", Colors.DIM)
# Also show registered listeners without config files
unconfigured = [
name for name in self.pump.listeners.keys()
if name not in listeners
]
if unconfigured:
cprint("\nRegistered listeners without config files:", Colors.YELLOW)
for name in sorted(unconfigured):
listener = self.pump.listeners[name]
agent_tag = "[agent]" if listener.is_agent else ""
cprint(f" @{name:20} {agent_tag} {listener.description}", Colors.DIM)
cprint("")
async def _config_edit_organism(self) -> None:
"""Edit organism.yaml in the full-screen editor."""
from xml_pipeline.console.editor import edit_text_async
from xml_pipeline.config.schema import ensure_schemas
from xml_pipeline.config.split_loader import (
get_organism_yaml_path,
load_organism_yaml_content,
save_organism_yaml_content,
)
# Ensure schemas are written for LSP
try:
ensure_schemas()
except Exception as e:
cprint(f"Warning: Could not write schemas: {e}", Colors.YELLOW)
# Find organism.yaml
config_path = get_organism_yaml_path()
if config_path is None:
cprint("No organism.yaml found.", Colors.RED)
cprint("Searched in:", Colors.DIM)
cprint(" ~/.xml-pipeline/organism.yaml", Colors.DIM)
cprint(" ./organism.yaml", Colors.DIM)
cprint(" ./config/organism.yaml", Colors.DIM)
return
# Load content
try:
content = load_organism_yaml_content(config_path)
except Exception as e:
cprint(f"Failed to load config: {e}", Colors.RED)
return
# Edit
cprint(f"Editing: {config_path}", Colors.CYAN)
cprint("Press Ctrl+S to save, Ctrl+Q to cancel", Colors.DIM)
cprint("")
edited_text, saved = await edit_text_async(
content,
title=f"organism.yaml ({config_path.name})",
schema_type="organism",
)
if saved and edited_text is not None:
try:
save_organism_yaml_content(config_path, edited_text)
cprint("Configuration saved.", Colors.GREEN)
cprint("Note: Restart required for changes to take effect.", Colors.YELLOW)
except yaml.YAMLError as e:
cprint(f"Invalid YAML: {e}", Colors.RED)
except Exception as e:
cprint(f"Failed to save: {e}", Colors.RED)
else:
cprint("Edit cancelled.", Colors.DIM)
async def _config_edit_listener(self, name: str) -> None:
"""Edit a listener config in the full-screen editor."""
from xml_pipeline.config import get_listener_config_store
from xml_pipeline.console.editor import edit_text_async
from xml_pipeline.config.schema import ensure_schemas
# Ensure schemas are written for LSP
try:
ensure_schemas()
except Exception as e:
cprint(f"Warning: Could not write schemas: {e}", Colors.YELLOW)
store = get_listener_config_store()
# Load or create content
if store.exists(name):
content = store.load_yaml(name)
cprint(f"Editing: {store.path_for(name)}", Colors.CYAN)
else:
# Check if it's a registered listener
if name in self.pump.listeners:
cprint(f"Creating new config for registered listener: {name}", Colors.CYAN)
else:
cprint(f"Creating new config for: {name}", Colors.CYAN)
content = store._default_template(name)
cprint("Press Ctrl+S to save, Ctrl+Q to cancel", Colors.DIM)
cprint("")
# Edit
edited_text, saved = await edit_text_async(
content,
title=f"{name}.yaml",
schema_type="listener",
)
if saved and edited_text is not None:
try:
path = store.save_yaml(name, edited_text)
cprint(f"Saved: {path}", Colors.GREEN)
cprint("Note: Restart required for changes to take effect.", Colors.YELLOW)
except yaml.YAMLError as e:
cprint(f"Invalid YAML: {e}", Colors.RED)
except Exception as e:
cprint(f"Failed to save: {e}", Colors.RED)
else:
cprint("Edit cancelled.", Colors.DIM)
# ------------------------------------------------------------------
# Commands: Protected
# ------------------------------------------------------------------
async def _cmd_restart(self, args: str) -> None:
"""Restart the pipeline."""
cprint("Restarting pipeline...", Colors.YELLOW)
await self.pump.shutdown()
# Re-bootstrap
from xml_pipeline.message_bus.stream_pump import bootstrap
self.pump = await bootstrap()
# Start pump in background
asyncio.create_task(self.pump.run())
cprint("Pipeline restarted.", Colors.GREEN)
async def _cmd_kill(self, args: str) -> None:
"""Terminate a thread."""
if not args:
cprint("Usage: /kill <thread-id>", Colors.DIM)
return
from xml_pipeline.memory import get_context_buffer
buffer = get_context_buffer()
# Find thread by prefix
thread_id = None
for tid in buffer._threads.keys():
if tid.startswith(args):
thread_id = tid
break
if not thread_id:
cprint(f"Thread not found: {args}", Colors.RED)
return
buffer.delete_thread(thread_id)
cprint(f"Thread {thread_id[:12]}... terminated.", Colors.YELLOW)
async def _cmd_pause(self, args: str) -> None:
"""Pause message processing."""
cprint("Pause not yet implemented.", Colors.YELLOW)
# TODO: Implement pump pause
async def _cmd_resume(self, args: str) -> None:
"""Resume message processing."""
cprint("Resume not yet implemented.", Colors.YELLOW)
# TODO: Implement pump resume
# ------------------------------------------------------------------
# Commands: Session
# ------------------------------------------------------------------
async def _cmd_attach(self, args: str) -> None:
"""Attach console."""
if self.attached:
cprint("Already attached.", Colors.DIM)
return
if not await self._verify_password():
cprint("Password required to attach.", Colors.RED)
return
self.attached = True
cprint("Console attached.", Colors.GREEN)
async def _cmd_detach(self, args: str) -> None:
"""Detach console."""
if not self.attached:
cprint("Already detached.", Colors.DIM)
return
self.attached = False
cprint("Console detached. Organism continues running.", Colors.YELLOW)
cprint("Use /attach to re-attach.", Colors.DIM)
async def _cmd_passwd(self, args: str) -> None:
"""Change console password."""
# Verify current password
current = await self._prompt_password("Current password: ")
if not self.password_mgr.verify(current):
cprint("Incorrect password.", Colors.RED)
return
# Get new password
while True:
new_pass = await self._prompt_password("New password: ")
if not new_pass or len(new_pass) < 4:
cprint("Password must be at least 4 characters.", Colors.RED)
continue
confirm = await self._prompt_password("Confirm new password: ")
if new_pass != confirm:
cprint("Passwords do not match.", Colors.RED)
continue
break
self.password_mgr.save_hash(new_pass)
cprint("Password changed successfully.", Colors.GREEN)
async def _cmd_quit(self, args: str) -> None:
"""Graceful shutdown."""
cprint("Shutting down...", Colors.YELLOW)
self.running = False
await self.pump.shutdown()
# ------------------------------------------------------------------
# UI Helpers
# ------------------------------------------------------------------
def _print_banner(self) -> None:
"""Print startup banner."""
print()
cprint("+" + "=" * 44 + "+", Colors.CYAN)
cprint("|" + " " * 8 + "xml-pipeline console v3.0" + " " * 9 + "|", Colors.CYAN)
cprint("+" + "=" * 44 + "+", Colors.CYAN)
print()
cprint(f"Organism '{self.pump.config.name}' ready.", Colors.GREEN)
cprint(f"{len(self.pump.listeners)} listeners registered.", Colors.DIM)
cprint("Type /help for commands.", Colors.DIM)
print()

View file

@ -1,469 +0,0 @@
"""
tui_console.py Split-screen TUI console using prompt_toolkit.
Features:
- Fixed Command History (Up/Down arrows)
- Robust Scrolling with snap-to-bottom and blank line spacer
- Fully implemented /monitor, /status, /listeners commands
"""
from __future__ import annotations
import asyncio
import os
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, List, Optional
try:
from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import FormattedText, HTML
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout import (
Layout,
HSplit,
Window,
FormattedTextControl,
BufferControl,
)
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.layout.margins import ScrollbarMargin
from prompt_toolkit.styles import Style
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.output.win32 import NoConsoleScreenBufferError
PROMPT_TOOLKIT_AVAILABLE = True
except ImportError:
PROMPT_TOOLKIT_AVAILABLE = False
NoConsoleScreenBufferError = Exception
if TYPE_CHECKING:
from xml_pipeline.message_bus.stream_pump import StreamPump
# ============================================================================
# Constants
# ============================================================================
CONFIG_DIR = Path.home() / ".xml-pipeline"
HISTORY_FILE = CONFIG_DIR / "history"
STYLE = Style.from_dict({
"output": "#ffffff",
"output.system": "#888888 italic",
"output.greeter": "#00ff00",
"output.shouter": "#ffff00",
"output.response": "#00ffff",
"output.error": "#ff0000",
"output.dim": "#666666",
"separator": "#444444",
"separator.text": "#888888",
"input": "#ffffff",
"prompt": "#00ff00 bold",
})
# ============================================================================
# Output Buffer
# ============================================================================
class OutputBuffer:
"""Manages scrolling output history using a text Buffer."""
def __init__(self, max_lines: int = 1000):
self.max_lines = max_lines
self._lines: List[str] = []
self.buffer = Buffer(read_only=True, name="output")
self._user_scrolled = False # Track if user manually scrolled
def append(self, text: str, style: str = "output"):
timestamp = datetime.now().strftime("%H:%M:%S")
self._lines.append(f"[{timestamp}] {text}")
self._update_buffer()
def append_raw(self, text: str, style: str = "output"):
self._lines.append(text)
self._update_buffer()
def _update_buffer(self):
"""Update buffer content. Auto-scroll only if user hasn't scrolled up."""
if len(self._lines) > self.max_lines:
self._lines = self._lines[-self.max_lines:]
text = "\n".join(self._lines)
# If user scrolled up, preserve their position; otherwise snap to bottom
if self._user_scrolled:
old_pos = self.buffer.cursor_position
self.buffer.set_document(
Document(text=text, cursor_position=min(old_pos, len(text))),
bypass_readonly=True
)
else:
# Auto-scroll to bottom for new content
self.buffer.set_document(
Document(text=text, cursor_position=len(text)),
bypass_readonly=True
)
def is_at_bottom(self) -> bool:
"""Check if we should show the spacer (user hasn't scrolled away)."""
return not self._user_scrolled
def scroll_to_bottom(self):
"""Force cursor to the end and mark as 'at bottom'."""
self.buffer.cursor_position = len(self.buffer.text)
self._user_scrolled = False # Reset flag when explicitly scrolling to bottom
def mark_scrolled(self):
"""Called when user manually scrolls up."""
self._user_scrolled = True
def mark_unscrolled(self):
"""Called when user scrolls to bottom."""
self._user_scrolled = False
def clear(self):
self._lines.clear()
self.buffer.set_document(Document(text=""), bypass_readonly=True)
self._user_scrolled = False
# ============================================================================
# TUI Console
# ============================================================================
class TUIConsole:
def __init__(self, pump: StreamPump):
self.pump = pump
self.output = OutputBuffer()
self.running = False
self.attached = True
self.use_simple_mode = False
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
try:
if not PROMPT_TOOLKIT_AVAILABLE:
raise ImportError("prompt_toolkit not available")
# Command history setup
if HISTORY_FILE.exists() and not os.access(HISTORY_FILE, os.W_OK):
os.chmod(HISTORY_FILE, 0o666)
self.input_buffer = Buffer(
history=FileHistory(str(HISTORY_FILE)),
multiline=False,
accept_handler=self._accept_handler
)
self._build_ui()
except (NoConsoleScreenBufferError, ImportError, Exception) as e:
self.use_simple_mode = True
self.app = None
print(f"\033[2mNote: Using simple mode ({type(e).__name__})\033[0m")
def _accept_handler(self, buffer: Buffer) -> bool:
text = buffer.text.strip()
if text:
asyncio.create_task(self._process_input(text))
return False
def _build_ui(self):
kb = KeyBindings()
@kb.add("c-c")
@kb.add("c-d")
def _(event):
self.running = False
event.app.exit()
@kb.add("c-l")
def _(event):
self.output.clear()
@kb.add("up")
def _(event):
self.input_buffer.history_backward()
@kb.add("down")
def _(event):
self.input_buffer.history_forward()
@kb.add("pageup")
def _(event):
buf = self.output.buffer
doc = buf.document
new_row = max(0, doc.cursor_position_row - 20)
buf.cursor_position = doc.translate_row_col_to_index(new_row, 0)
self._invalidate()
@kb.add("pagedown")
def _(event):
buf = self.output.buffer
doc = buf.document
lines = doc.line_count
new_row = doc.cursor_position_row + 20
if new_row >= lines - 1:
self.output.scroll_to_bottom()
else:
buf.cursor_position = doc.translate_row_col_to_index(new_row, 0)
self._invalidate()
@kb.add("c-home")
def _(event):
self.output.buffer.cursor_position = 0
self._invalidate()
@kb.add("c-end")
def _(event):
self.output.scroll_to_bottom()
self._invalidate()
output_control = BufferControl(
buffer=self.output.buffer,
focusable=False,
include_default_input_processors=False,
)
self.output_window = Window(
content=output_control,
wrap_lines=True,
right_margins=[ScrollbarMargin(display_arrows=True)],
)
def get_spacer_height():
return 1 if self.output.is_at_bottom() else 0
spacer = Window(height=lambda: Dimension.exact(get_spacer_height()))
def get_separator():
name = self.pump.config.name
width = 60
padding = "" * ((width - len(name) - 4) // 2)
return FormattedText([
("class:separator", padding),
("class:separator.text", f" {name} "),
("class:separator", padding),
])
separator = Window(
content=FormattedTextControl(text=get_separator),
height=1,
)
input_window = Window(
content=BufferControl(buffer=self.input_buffer),
height=1,
)
from prompt_toolkit.layout import VSplit
input_row = VSplit([
Window(
content=FormattedTextControl(text=lambda: FormattedText([("class:prompt", "> ")])),
width=2,
),
input_window,
])
root = HSplit([
self.output_window,
spacer,
separator,
input_row,
])
self.layout = Layout(root, focused_element=input_window)
self.app = Application(
layout=self.layout,
key_bindings=kb,
style=STYLE,
full_screen=True,
mouse_support=True,
)
def print(self, text: str, style: str = "output"):
if self.use_simple_mode:
self._print_simple(text, style)
else:
self.output.append(text, style)
self._invalidate()
def print_raw(self, text: str, style: str = "output"):
if self.use_simple_mode:
self._print_simple(text, style)
else:
self.output.append_raw(text, style)
self._invalidate()
def print_system(self, text: str):
self.print(text, "output.system")
def print_error(self, text: str):
self.print(text, "output.error")
def _invalidate(self):
if self.app:
try:
self.app.invalidate()
except Exception:
pass
def _print_simple(self, text: str, style: str = "output"):
colors = {
"output.system": "\033[2m",
"output.error": "\033[31m",
"output.dim": "\033[2m",
"output.greeter": "\033[32m",
"output.shouter": "\033[33m",
"output.response": "\033[36m",
}
color = colors.get(style, "")
print(f"{color}{text}\033[0m")
async def run(self):
self.running = True
if self.use_simple_mode:
await self._run_simple()
return
self.print_raw(f"xml-pipeline console v3.0", "output.system")
self.print_raw(f"Organism: {self.pump.config.name}", "output.system")
self.print_raw(f"Type /help for commands, @listener message to chat", "output.dim")
self.print_raw("", "output")
try:
async def refresh_loop():
while self.running:
await asyncio.sleep(0.1)
if self.app and self.app.is_running:
self.app.invalidate()
refresh_task = asyncio.create_task(refresh_loop())
try:
await self.app.run_async()
finally:
refresh_task.cancel()
except Exception as e:
print(f"Console error: {e}")
finally:
self.running = False
async def _run_simple(self):
print(f"\033[36mxml-pipeline console v3.0 (simple mode)\033[0m")
while self.running:
try:
line = await asyncio.get_event_loop().run_in_executor(None, lambda: input("> "))
if line: await self._process_input(line.strip())
except (EOFError, KeyboardInterrupt): break
self.running = False
async def _process_input(self, line: str):
if not self.use_simple_mode:
self.print_raw(f"> {line}", "output.dim")
if line.startswith("/"):
await self._handle_command(line)
elif line.startswith("@"):
await self._handle_message(line)
else:
self.print("Use @listener message or /command", "output.dim")
async def _handle_command(self, line: str):
parts = line[1:].split(None, 1)
cmd = parts[0].lower() if parts else ""
args = parts[1] if len(parts) > 1 else ""
handler = getattr(self, f"_cmd_{cmd}", None)
if handler:
await handler(args)
else:
self.print_error(f"Unknown command: /{cmd}")
async def _cmd_help(self, args: str):
self.print_raw("Commands:", "output.system")
self.print_raw(" /status, /listeners, /threads, /monitor, /clear, /quit", "output.dim")
async def _cmd_status(self, args: str):
from xml_pipeline.memory import get_context_buffer
buffer = get_context_buffer()
stats = buffer.get_stats()
self.print_raw(f"Organism: {self.pump.config.name}", "output.system")
self.print_raw(f"Threads: {stats['thread_count']} active, {stats['total_slots']} slots total", "output.dim")
async def _cmd_listeners(self, args: str):
self.print_raw("Listeners:", "output.system")
for name, l in self.pump.listeners.items():
tag = "[agent]" if l.is_agent else "[handler]"
self.print_raw(f" {name:15} {tag} {l.description}", "output.dim")
async def _cmd_threads(self, args: str):
from xml_pipeline.memory import get_context_buffer
buffer = get_context_buffer()
for tid, ctx in buffer._threads.items():
self.print_raw(f" {tid[:8]}... slots: {len(ctx)}", "output.dim")
async def _cmd_monitor(self, args: str):
from xml_pipeline.memory import get_context_buffer
buffer = get_context_buffer()
if args == "*":
for tid, ctx in buffer._threads.items():
self.print_raw(f"--- Thread {tid[:8]} ---", "output.system")
for slot in list(ctx)[-3:]:
self.print_raw(f" {slot.from_id} -> {slot.to_id}: {type(slot.payload).__name__}", "output.dim")
elif args:
matches = [t for t in buffer._threads if t.startswith(args)]
if not matches:
self.print_error(f"No thread matching {args}")
return
ctx = buffer.get_thread(matches[0])
for slot in ctx:
self.print_raw(f" [{slot.from_id} -> {slot.to_id}] {type(slot.payload).__name__}", "output.dim")
else:
self.print("Usage: /monitor <tid> or /monitor *", "output.dim")
async def _cmd_clear(self, args: str):
self.output.clear()
async def _cmd_quit(self, args: str):
self.running = False
if self.app: self.app.exit()
async def _handle_message(self, line: str):
parts = line[1:].split(None, 1)
if not parts: return
target, message = parts[0].lower(), (parts[1] if len(parts) > 1 else "")
if target not in self.pump.listeners:
self.print_error(f"Unknown listener: {target}")
return
listener = self.pump.listeners[target]
payload = self._create_payload(listener, message)
if payload is None:
self.print_error(f"Cannot create payload for {target}")
return
import uuid
thread_id = str(uuid.uuid4())
envelope = self.pump._wrap_in_envelope(payload, "console", target, thread_id)
await self.pump.inject(envelope, thread_id, "console")
def _create_payload(self, listener, message: str):
payload_class = listener.payload_class
if hasattr(payload_class, '__dataclass_fields__'):
fields = list(payload_class.__dataclass_fields__.keys())
if len(fields) == 1: return payload_class(**{fields[0]: message})
if 'message' in fields: return payload_class(message=message)
if 'text' in fields: return payload_class(text=message)
return None
def on_response(self, from_id: str, payload):
style = "output.response" if from_id == "response-handler" else "output"
text = f"[{from_id}] {getattr(payload, 'message', payload)}"
self.print_raw(text, style)
def create_tui_console(pump: StreamPump) -> TUIConsole:
return TUIConsole(pump)

View file

View file

@ -1,11 +0,0 @@
"""
HTTP/WebSocket server for xml-pipeline.
Provides:
- REST API for auth and management
- WebSocket for console and GUI clients
"""
from .app import create_app, run_server
__all__ = ["create_app", "run_server"]

View file

@ -1,266 +0,0 @@
"""
aiohttp-based HTTP/WebSocket server.
Provides:
- REST API for authentication
- WebSocket for console/GUI message sending
- Integration with SystemPipeline for message injection
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import TYPE_CHECKING, Optional, Callable
try:
from aiohttp import web, WSMsgType
AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
web = None
WSMsgType = None
from ..auth.users import get_user_store, UserStore
from ..auth.sessions import get_session_manager, SessionManager, Session
if TYPE_CHECKING:
from ..message_bus.stream_pump import StreamPump
from ..message_bus.system_pipeline import SystemPipeline
logger = logging.getLogger(__name__)
def auth_middleware():
@web.middleware
async def middleware(request, handler):
if request.path in ("/auth/login", "/health"):
return await handler(request)
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return web.json_response({"error": "Missing Authorization"}, status=401)
token = auth_header[7:]
session = request.app["session_manager"].validate(token)
if not session:
return web.json_response({"error": "Invalid token"}, status=401)
request["session"] = session
return await handler(request)
return middleware
async def handle_login(request):
try:
data = await request.json()
except:
return web.json_response({"error": "Invalid JSON"}, status=400)
username = data.get("username", "")
password = data.get("password", "")
if not username or not password:
return web.json_response({"error": "Credentials required"}, status=400)
user = request.app["user_store"].authenticate(username, password)
if not user:
return web.json_response({"error": "Invalid credentials"}, status=401)
session = request.app["session_manager"].create(user.username, user.role)
return web.json_response(session.to_dict())
async def handle_logout(request):
session = request["session"]
request.app["session_manager"].revoke(session.token)
return web.json_response({"message": "Logged out"})
async def handle_me(request):
session = request["session"]
return web.json_response({
"username": session.username,
"role": session.role,
"expires_at": session.expires_at.isoformat(),
})
async def handle_health(request):
return web.json_response({"status": "ok"})
async def handle_websocket(request):
session = request["session"]
pump = request.app.get("pump")
system_pipeline = request.app.get("system_pipeline")
ws = web.WebSocketResponse()
await ws.prepare(request)
# Track this WebSocket for response delivery
ws_id = id(ws)
request.app["websockets"][ws_id] = {
"ws": ws,
"user": session.username,
"threads": set(), # Thread IDs this client is subscribed to
}
await ws.send_json({"type": "connected", "username": session.username})
try:
async for msg in ws:
if msg.type == WSMsgType.TEXT:
try:
data = json.loads(msg.data)
resp = await handle_ws_msg(
data, session, pump, system_pipeline,
request.app["websockets"][ws_id]
)
await ws.send_json(resp)
except Exception as e:
logger.exception(f"WebSocket error: {e}")
await ws.send_json({"type": "error", "error": str(e)})
finally:
# Cleanup on disconnect
del request.app["websockets"][ws_id]
return ws
async def handle_ws_msg(data, session, pump, system_pipeline, ws_state):
"""
Handle WebSocket message.
Message types:
ping - Keepalive
status - Get server status
listeners - List available listeners
targets - Alias for listeners
send - Send message to pipeline (@target or explicit)
"""
t = data.get("type", "")
if t == "ping":
return {"type": "pong"}
elif t == "status":
from ..memory import get_context_buffer
stats = get_context_buffer().get_stats()
return {"type": "status", "threads": stats["thread_count"]}
elif t == "listeners" or t == "targets":
if not pump:
return {"type": "listeners", "listeners": []}
return {"type": "listeners", "listeners": list(pump.listeners.keys())}
elif t == "send":
# Send message to pipeline
if not system_pipeline:
return {"type": "error", "error": "Pipeline not available"}
# Support two formats:
# 1. {"type": "send", "raw": "@greeter Dan"}
# 2. {"type": "send", "target": "greeter", "content": "Dan"}
raw = data.get("raw")
if raw:
# Parse @target message format
try:
thread_id = await system_pipeline.inject_console(
raw=raw,
user=session.username,
)
except ValueError as e:
return {"type": "error", "error": str(e)}
else:
target = data.get("target")
content = data.get("content", data.get("text", data.get("message", "")))
if not target:
return {"type": "error", "error": "Missing target"}
if not content:
return {"type": "error", "error": "Missing content"}
try:
thread_id = await system_pipeline.inject_raw(
target=target,
content=content,
source="websocket",
user=session.username,
)
except ValueError as e:
return {"type": "error", "error": str(e)}
# Track thread for response delivery
ws_state["threads"].add(thread_id)
return {
"type": "sent",
"thread_id": thread_id,
"target": data.get("target") or raw.split()[0].lstrip("@") if raw else None,
}
return {"type": "error", "error": f"Unknown message type: {t}"}
def create_app(pump=None, system_pipeline=None):
"""
Create the aiohttp application.
Args:
pump: StreamPump instance (optional)
system_pipeline: SystemPipeline instance (optional, created from pump if not provided)
"""
if not AIOHTTP_AVAILABLE:
raise RuntimeError("aiohttp not installed")
app = web.Application(middlewares=[auth_middleware()])
app["user_store"] = get_user_store()
app["session_manager"] = get_session_manager()
app["pump"] = pump
app["websockets"] = {} # Track connected WebSocket clients
# Create SystemPipeline if pump provided but system_pipeline not
if pump and not system_pipeline:
from ..message_bus.system_pipeline import SystemPipeline
system_pipeline = SystemPipeline(pump)
app["system_pipeline"] = system_pipeline
app.router.add_post("/auth/login", handle_login)
app.router.add_post("/auth/logout", handle_logout)
app.router.add_get("/auth/me", handle_me)
app.router.add_get("/health", handle_health)
app.router.add_get("/ws", handle_websocket)
return app
async def run_server(pump=None, host="127.0.0.1", port=8765):
"""
Run the server.
Args:
pump: StreamPump instance for message handling
host: Bind address
port: Port number
"""
app = create_app(pump)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
print(f"Server on http://{host}:{port}")
try:
while True:
await asyncio.sleep(3600)
except asyncio.CancelledError:
pass
finally:
await runner.cleanup()

View file

@ -1,83 +0,0 @@
"""
xml_listener.py The Sovereign Contract for All Capabilities (v1.3)
"""
from __future__ import annotations
from typing import Optional, Type, Callable
from pydantic import BaseModel
class XMLListener:
"""
Base class for all reactive capabilities.
Now supports Autonomous Registration via Pydantic payload classes.
"""
def __init__(
self,
name: str,
payload_class: Type[BaseModel],
handler: Callable[[dict], bytes],
description: Optional[str] = None
):
self.agent_name = name
self.payload_class = payload_class
self.handler = handler
self.description = description or payload_class.__doc__ or "No description provided."
# In v1.3, the root tag is derived from the payload class name
self.root_tag = payload_class.__name__
self.listens_to = [self.root_tag]
async def handle(
self,
payload_dict: dict,
thread_id: str,
sender_name: str,
) -> Optional[bytes]:
"""
React to a pre-validated dictionary payload.
Returns raw response XML bytes.
"""
# 1. Execute the handler logic
# Note: In v1.3, the Bus/Lark handles the XML -> Dict conversion
return await self.handler(payload_dict)
def generate_xsd(self) -> str:
"""
Autonomous XSD Synthesis.
Inspects the payload_class and generates an XSD string.
"""
# Logic to iterate over self.payload_class.model_fields
# and build the <xs:element> definitions.
pass
def generate_prompt_fragment(self) -> str:
"""
Prompt Synthesis (The 'Mente').
Generates the tool usage instructions for other agents.
"""
fragment = [
f"Capability: {self.agent_name}",
f"Root Tag: <{self.root_tag}>",
f"Description: {self.description}",
"\nParameters:"
]
for name, field in self.payload_class.model_fields.items():
field_type = field.annotation.__name__
field_desc = field.description or "No description"
fragment.append(f" - {name} ({field_type}): {field_desc}")
return "\n".join(fragment)
def make_response_envelope(
self,
payload_bytes: bytes,
thread_id: str,
to: Optional[str] = None
) -> bytes:
"""
Wraps response bytes in a standard envelope.
"""
# Logic to build the <message> meta block and append the payload_bytes
pass