From 6411a0cfd46fd60870680f0b316919f4359a7adf Mon Sep 17 00:00:00 2001 From: Hermes Date: Mon, 11 May 2026 06:42:04 +0000 Subject: [PATCH] Salior v0.1.0: core skeleton + 3 agents + skills + MCP --- .gitignore | 1 + README.md | 40 +++ pyproject.toml | 32 ++ salior/__init__.py | 3 + salior/agents/data/__init__.py | 4 + salior/agents/data/agent.py | 172 +++++++++++ salior/agents/exec/__init__.py | 4 + salior/agents/exec/agent.py | 203 +++++++++++++ salior/agents/signal/__init__.py | 4 + salior/agents/signal/agent.py | 185 ++++++++++++ salior/cli.py | 96 ++++++ salior/core/__init__.py | 7 + salior/core/agent.py | 101 +++++++ salior/core/config.py | 70 +++++ salior/core/logging.py | 35 +++ salior/core/memory.py | 76 +++++ salior/db/__init__.py | 5 + .../db/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 397 bytes .../supabase_client.cpython-311.pyc | Bin 0 -> 9284 bytes .../timescale_client.cpython-311.pyc | Bin 0 -> 12201 bytes salior/db/schema.sql | 175 +++++++++++ salior/db/supabase_client.py | 195 ++++++++++++ salior/db/timescale_client.py | 281 ++++++++++++++++++ salior/llm/__init__.py | 5 + .../llm/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 328 bytes salior/llm/__pycache__/client.cpython-311.pyc | Bin 0 -> 5874 bytes salior/llm/client.py | 130 ++++++++ salior/mcp/__init__.py | 4 + .../mcp/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 298 bytes salior/mcp/__pycache__/server.cpython-311.pyc | Bin 0 -> 10356 bytes salior/mcp/server.py | 169 +++++++++++ salior/plugins/__init__.py | 113 +++++++ .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 6462 bytes salior/skills/__init__.py | 4 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 307 bytes .../__pycache__/registry.cpython-311.pyc | Bin 0 -> 4743 bytes salior/skills/build.md | 30 ++ salior/skills/registry.py | 76 +++++ salior/skills/research.md | 33 ++ salior/skills/review.md | 48 +++ salior/skills/spec.md | 45 +++ salior/wallet/__init__.py | 4 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 340 bytes .../__pycache__/connect.cpython-311.pyc | Bin 0 -> 5866 bytes salior/wallet/connect.py | 97 ++++++ 45 files changed, 2447 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 salior/__init__.py create mode 100644 salior/agents/data/__init__.py create mode 100644 salior/agents/data/agent.py create mode 100644 salior/agents/exec/__init__.py create mode 100644 salior/agents/exec/agent.py create mode 100644 salior/agents/signal/__init__.py create mode 100644 salior/agents/signal/agent.py create mode 100644 salior/cli.py create mode 100644 salior/core/__init__.py create mode 100644 salior/core/agent.py create mode 100644 salior/core/config.py create mode 100644 salior/core/logging.py create mode 100644 salior/core/memory.py create mode 100644 salior/db/__init__.py create mode 100644 salior/db/__pycache__/__init__.cpython-311.pyc create mode 100644 salior/db/__pycache__/supabase_client.cpython-311.pyc create mode 100644 salior/db/__pycache__/timescale_client.cpython-311.pyc create mode 100644 salior/db/schema.sql create mode 100644 salior/db/supabase_client.py create mode 100644 salior/db/timescale_client.py create mode 100644 salior/llm/__init__.py create mode 100644 salior/llm/__pycache__/__init__.cpython-311.pyc create mode 100644 salior/llm/__pycache__/client.cpython-311.pyc create mode 100644 salior/llm/client.py create mode 100644 salior/mcp/__init__.py create mode 100644 salior/mcp/__pycache__/__init__.cpython-311.pyc create mode 100644 salior/mcp/__pycache__/server.cpython-311.pyc create mode 100644 salior/mcp/server.py create mode 100644 salior/plugins/__init__.py create mode 100644 salior/plugins/__pycache__/__init__.cpython-311.pyc create mode 100644 salior/skills/__init__.py create mode 100644 salior/skills/__pycache__/__init__.cpython-311.pyc create mode 100644 salior/skills/__pycache__/registry.cpython-311.pyc create mode 100644 salior/skills/build.md create mode 100644 salior/skills/registry.py create mode 100644 salior/skills/research.md create mode 100644 salior/skills/review.md create mode 100644 salior/skills/spec.md create mode 100644 salior/wallet/__init__.py create mode 100644 salior/wallet/__pycache__/__init__.cpython-311.pyc create mode 100644 salior/wallet/__pycache__/connect.cpython-311.pyc create mode 100644 salior/wallet/connect.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..15804e5 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Salior — Autonomous Trading System + +See `plans/salior-v1-plan.md` for full architecture. + +## Structure + +``` +salior/ # Project root +├── pyproject.toml +├── README.md +└── salior/ # Python package + ├── __init__.py + ├── cli.py # salior CLI + ├── core/ # Config, logging, memory, base agent + ├── agents/ + │ ├── data/ # HL WebSocket → TimescaleDB + │ ├── signal/ # Regime + conviction → Supabase signals + │ └── exec/ # HL CLOB API → orders + ├── db/ # Schema + clients + ├── skills/ # Agent skill definitions + ├── mcp/ # MCP server + ├── plugins/ # Plugin system + └── wallet/ # Wallet connect +``` + +## Quick Start + +```bash +# Install +pip install -e . + +# Initialize database +salior db init + +# Start agents +salior agent start + +# Check MCP tools +curl http://localhost:8080/mcp/tools +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1ab3cb9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "salior" +version = "0.1.0" +description = "All-in-one autonomous trading system" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "aiohttp>=3.9.0", + "asyncpg>=0.29.0", + "structlog>=24.0.0", + "psycopg>=3.1.0", + "httpx>=0.27.0", + "click>=8.1.0", + "pyyaml>=6.0.0", + "websockets>=12.0", +] + +[project.optional-dependencies] +dev = ["pytest", "pytest-asyncio", "ruff"] + +[project.scripts] +salior = "salior.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["salior"] + +[tool.ruff] +line-length = 100 \ No newline at end of file diff --git a/salior/__init__.py b/salior/__init__.py new file mode 100644 index 0000000..56533a4 --- /dev/null +++ b/salior/__init__.py @@ -0,0 +1,3 @@ +"""Salior core package.""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/salior/agents/data/__init__.py b/salior/agents/data/__init__.py new file mode 100644 index 0000000..379c679 --- /dev/null +++ b/salior/agents/data/__init__.py @@ -0,0 +1,4 @@ +"""Data agent module.""" +from salior.agents.data.agent import DataAgent + +__all__ = ["DataAgent"] \ No newline at end of file diff --git a/salior/agents/data/agent.py b/salior/agents/data/agent.py new file mode 100644 index 0000000..7da3674 --- /dev/null +++ b/salior/agents/data/agent.py @@ -0,0 +1,172 @@ +"""Hyperliquid WebSocket data collector. +Streams candles + trades + orderbook → TimescaleDB. +""" +from __future__ import annotations + +import asyncio +import json +import zlib +from datetime import datetime, timezone +from typing import Any, Optional + +import websockets + +from salior.core.agent import Agent +from salior.core.config import config +from salior.core.logging import setup_logging +from salior.db.timescale_client import TimescaleDB + +log = setup_logging() + + +class DataAgent(Agent): + """Collects HL market data via WebSocket, writes to TimescaleDB.""" + + name = "data_agent" + coins: list[str] # set by __init__ + + def __init__(self, coins: Optional[list[str]] = None) -> None: + super().__init__() + self.coins = coins or config.coins + self._db = TimescaleDB() + self._ws: Optional[websockets.WebSocketApp] = None + self._last_candle_time: dict[str, datetime] = {} + + def loop_interval(self) -> float: + return 0 # WebSocket-based, no polling + + async def run(self) -> None: + """Connect to HL WebSocket, handle messages forever.""" + await self._db.connect() + await self._subscribe() + self._running = True + + while self._running: + try: + async with websockets.connect( + config.hl_ws_url, + compression=None, + ) as ws: + self._ws = ws + log.info("hl_ws_connected", coins=self.coins) + + # Subscribe to channels + await self._send_subscribe() + + # Handle messages + async for msg in ws: + await self._handle_message(msg) + + except websockets.exceptions.ConnectionClosed as e: + log.warning("hl_ws_disconnected", reason=str(e)) + await asyncio.sleep(3) + except Exception as e: + log.error("hl_ws_error", error=str(e)) + await asyncio.sleep(5) + + async def _subscribe(self) -> None: + """Send subscription messages for candles + trades + orderbook.""" + pass # subscriptions sent after connect + + async def _send_subscribe(self) -> None: + """Send HL WebSocket subscription payload.""" + if not self._ws: + return + + # Candle candles + candle_sub = { + "method": "subscribe", + "params": { + "channels": [ + {"type": "candle_1m", "coin": coin} + for coin in self.coins + ] + [ + {"type": "candle_5m", "coin": coin} + for coin in self.coins + ] + [ + {"type": "trades", "coin": coin} + for coin in self.coins + ] + }, + "id": 1, + } + await self._ws.send(json.dumps(candle_sub)) + + log.info("hl_subscriptions_sent", coins=self.coins) + + async def _handle_message(self, raw: str | bytes) -> None: + """Parse and dispatch a HL WebSocket message.""" + try: + # Decompress if compressed + if isinstance(raw, bytes): + raw = zlib.decompress(raw).decode() + + msg = json.loads(raw) + + # Handle channel data + if "channel" in msg and "data" in msg: + channel = msg["channel"] + data = msg["data"] + + if channel == "candle_1m" or channel == "candle_5m": + await self._handle_candle(data) + elif channel == "trades": + await self._handle_trade(data) + elif channel == "batched": + for item in data: + if "candle" in item: + await self._handle_candle(item["candle"]) + elif "trade" in item: + await self._handle_trade(item["trade"]) + + # Handle subscription confirmations + if msg.get("id") == 1 and msg.get("code") == 0: + log.info("hl_subscription_confirmed") + + except Exception as e: + log.error("hl_parse_error", error=str(e), raw=str(raw)[:100]) + + async def _handle_candle(self, data: dict) -> None: + """Handle a candle update.""" + coin = data.get("coin", "") + t_str = data.get("t", "") # Unix timestamp in ms + o = float(data.get("o", 0) or 0) + h = float(data.get("h", 0) or 0) + l = float(data.get("l", 0) or 0) + c = float(data.get("c", 0) or 0) + v = float(data.get("v", 0) or 0) + + if not t_str: + return + + t = datetime.fromtimestamp(int(t_str) / 1000, tz=timezone.utc) + + # Determine table from channel + interval = data.get("interval", "1m") + table = f"candles_1m" if interval == "1m" else f"candles_5m" + + await self._db.upsert_candle(table, coin, t, o, h, l, c, v) + log.debug("candle_written", coin=coin, table=table, t=t.isoformat()) + + async def _handle_trade(self, data: dict) -> None: + """Handle a trade message.""" + coin = data.get("coin", "") + px = float(data.get("px", 0) or 0) + sz = float(data.get("sz", 0) or 0) + side = data.get("side", "") # B = bid, A = ask + hash_ = data.get("hash", "") + + if not hash_: + return + + t_str = data.get("time", "") + t = datetime.fromtimestamp(int(t_str) / 1000, tz=timezone.utc) if t_str else datetime.now(timezone.utc) + + # Deduplicate by hash (handled by schema) + await self._db.insert_trade( + coin, t, px, sz, side.lower(), hash_ + ) + + # Log heartbeat periodically + if len(self._last_candle_time) % 100 == 0: + await self._db.log_health(self.name, "running", iteration=len(self._last_candle_time)) \ No newline at end of file diff --git a/salior/agents/exec/__init__.py b/salior/agents/exec/__init__.py new file mode 100644 index 0000000..3a5997f --- /dev/null +++ b/salior/agents/exec/__init__.py @@ -0,0 +1,4 @@ +"""Exec agent module.""" +from salior.agents.exec.agent import ExecAgent + +__all__ = ["ExecAgent"] \ No newline at end of file diff --git a/salior/agents/exec/agent.py b/salior/agents/exec/agent.py new file mode 100644 index 0000000..0896b87 --- /dev/null +++ b/salior/agents/exec/agent.py @@ -0,0 +1,203 @@ +"""Hyperliquid execution agent. +Reads signals from Supabase → places HL CLOB orders. +""" +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import time +from datetime import datetime, timezone +from typing import Any, Optional + +import httpx +import websockets + +from salior.core.agent import Agent +from salior.core.config import config +from salior.core.logging import setup_logging +from salior.db.timescale_client import TimescaleDB +from salior.db.supabase_client import SupabaseClient + +log = setup_logging() + +# HL API helpers +HL_API = "https://api.hyperliquid.xyz" + + +class ExecAgent(Agent): + """Executes trades based on conviction signals from Supabase.""" + + name = "exec_agent" + mode: str # paper | live + + def __init__( + self, + coins: list[str] | None = None, + min_conviction: float = 0.7, + mode: str | None = None, + ) -> None: + super().__init__() + self.coins = coins or config.coins + self.min_conviction = min_conviction + self.mode = mode or config.execution_mode + self._db = TimescaleDB() + self._supabase = SupabaseClient() + self._portfolio: dict[str, dict] = {} # coin -> position + + def loop_interval(self) -> float: + return 300.0 # Check signals every 5 minutes + + async def run(self) -> None: + """Poll Supabase for high-conviction signals and execute.""" + if self.mode == "paper": + log.info("exec_agent_paper_mode", min_conviction=self.min_conviction) + return # Paper mode: log only, don't execute + + await self._db.connect() + + # Get recent signals with high conviction + signals = await self._supabase.get_recent_signals(limit=10) + + for sig in signals: + conviction = abs(sig.get("conviction", 0)) + if conviction < self.min_conviction: + continue + + try: + await self._execute_signal(sig) + except Exception as e: + log.error("exec_error", signal_id=sig.get("id"), error=str(e)) + + await self._db.log_health(self.name, "running", mode=self.mode) + + async def _execute_signal(self, sig: dict) -> None: + """Place an order based on a signal.""" + coin = sig.get("coin", "") + regime = sig.get("regime", "") + conviction = sig.get("conviction", 0) + reasoning = sig.get("reasoning", "") + + # Determine side from conviction + side = "buy" if conviction > 0 else "sell" + size = self._calculate_size(coin, conviction) + price = await self._get_market_price(coin, side) + + if not price or size <= 0: + log.warning("exec_skip_no_price", coin=coin) + return + + # Place order via HL API + order_result = await self._place_order( + coin=coin, + side=side, + size=size, + price=price, + sig_id=sig.get("id"), + ) + + log.info( + "order_placed", + coin=coin, + side=side, + size=size, + price=price, + conviction=conviction, + result=order_result.get("status"), + ) + + def _calculate_size(self, coin: str, conviction: float) -> float: + """Calculate position size based on conviction and portfolio.""" + # Base size: 1% of portfolio per trade + base_pct = 0.01 + # Scale with conviction (0.7 → 1x, 1.0 → 3x) + conviction_multiplier = 1 + (conviction - 0.7) * 6 + size_pct = base_pct * conviction_multiplier + + # Get current portfolio value (from TimescaleDB) + portfolio_value = 10_000 # Default paper amount + if self._portfolio: + pos = self._portfolio.get(coin, {}) + pos_value = pos.get("size", 0) * pos.get("avg_px", 0) + portfolio_value = max(portfolio_value, pos_value) + + return portfolio_value * size_pct + + async def _get_market_price(self, coin: str, side: str) -> Optional[float]: + """Get current market price for a coin.""" + # Get latest candle from TimescaleDB + candle = await self._db.get_latest_candle("candles_1m", coin) + if candle: + return candle["c"] + return None + + async def _place_order( + self, + coin: str, + side: str, + size: float, + price: float, + sig_id: str | None, + ) -> dict: + """Place an order via Hyperliquid CLOB API.""" + if not config.hl_private_key: + log.warning("exec_no_private_key") + return {"status": "error", "reason": "no private key configured"} + + # Build order payload + payload = { + "type": "Limit", + "symbol": coin, + "side": side, + "size": str(size), + "price": str(price), + "reduceOnly": False, + } + + # Log execution (paper mode) + if self.mode == "paper": + log.info( + "paper_order", + coin=coin, + side=side, + size=size, + price=price, + sig_id=sig_id, + ) + # Record in DB + exec_id = await self._db.insert_execution( + sig_id, coin, side, size, price, self.mode + ) + await self._db.update_execution(exec_id, "filled", price) + return {"status": "paper", "exec_id": exec_id} + + # Live execution via HL API + try: + headers = {"Content-Type": "application/json"} + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{HL_API}/order", + json=payload, + headers=headers, + timeout=10.0, + ) + + if resp.status_code == 200: + result = resp.json() + status = result.get("status", "unknown") + exec_id = await self._db.insert_execution( + sig_id, coin, side, size, price, self.mode + ) + await self._db.update_execution( + exec_id, status, result.get("filled_px") + ) + return result + else: + error = resp.text + log.error("hl_order_failed", status=resp.status_code, error=error) + return {"status": "error", "reason": error} + + except Exception as e: + log.error("hl_order_error", error=str(e)) + return {"status": "error", "reason": str(e)} \ No newline at end of file diff --git a/salior/agents/signal/__init__.py b/salior/agents/signal/__init__.py new file mode 100644 index 0000000..99f10f1 --- /dev/null +++ b/salior/agents/signal/__init__.py @@ -0,0 +1,4 @@ +"""Signal agent module.""" +from salior.agents.signal.agent import SignalAgent + +__all__ = ["SignalAgent"] \ No newline at end of file diff --git a/salior/agents/signal/agent.py b/salior/agents/signal/agent.py new file mode 100644 index 0000000..e1ad75b --- /dev/null +++ b/salior/agents/signal/agent.py @@ -0,0 +1,185 @@ +"""Signal generation agent. +Reads candles from TimescaleDB → regime + conviction → Supabase signals. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from salior.core.agent import Agent +from salior.core.config import config +from salior.core.logging import setup_logging +from salior.db.timescale_client import TimescaleDB +from salior.db.supabase_client import SupabaseClient + +log = setup_logging() + +REGIME_PROMPT = """You are a market regime detector. Analyze BTC/USD price data and classify the current regime. + +Data: +- Current price: ${price} +- 24h high: ${high}, 24h low: ${low} +- Price range (high-low)/low: {range_pct:.2%} +- 1h trend: {trend_1h:.2%} +- Volume (last 1h): {volume_1h} +- Volume (prev 1h): {volume_prev} +- Volume ratio: {vol_ratio:.2f}x + +Classify regime (choose one): +- trending_up: Strong directional move, clear trend +- trending_down: Strong downward move +- ranging: No clear direction, oscillating +- volatile: High range but unclear direction + +Return JSON only: +{{"regime": "trending_up|trending_down|ranging|volatile", "reasoning": "brief explanation"}} +""" + + +CONVICTION_PROMPT = """You are a trading conviction scorer. Score how confident you are in the current direction. + +Current regime: {regime} +Price: ${price} +1h candles: +{candles_text} + +Score conviction from -1.0 (strong sell) to +1.0 (strong buy). +Return JSON only: +{{"conviction": -0.85, "reasoning": "why", "momentum": "strong_bull|weak_bull|neutral|weak_bear|strong_bear"}} +""" + + +class SignalAgent(Agent): + """Analyzes candles → writes conviction signals to Supabase.""" + + name = "signal_agent" + + def __init__( + self, + coins: list[str] | None = None, + min_conviction: float | None = None, + ) -> None: + super().__init__() + self.coins = coins or config.coins + self.min_conviction = min_conviction or config.min_conviction + self._db = TimescaleDB() + self._supabase = SupabaseClient() + + def loop_interval(self) -> float: + return 60.0 # Run every 60 seconds + + async def run(self) -> None: + """Analyze each coin, emit signals if conviction is high enough.""" + await self._db.connect() + + for coin in self.coins: + try: + signal = await self._analyze_coin(coin) + if signal and abs(signal["conviction"]) >= self.min_conviction: + await self._emit_signal(signal) + log.info( + "signal_emitted", + coin=coin, + regime=signal["regime"], + conviction=signal["conviction"], + ) + except Exception as e: + log.error("signal_error", coin=coin, error=str(e)) + + await self._db.log_health(self.name, "running", iteration=self._iteration) + + async def _analyze_coin(self, coin: str) -> dict | None: + """Analyze a coin's candles and return a signal dict.""" + # Fetch recent candles + candles_1m = await self._db.get_candles("candles_1m", coin, hours=24) + candles_5m = await self._db.get_candles("candles_5m", coin, hours=24) + + if len(candles_1m) < 5: + log.debug("not_enough_candles", coin=coin, count=len(candles_1m)) + return None + + # Get latest values + latest = candles_1m[-1] + price = latest["c"] + high = max(c["h"] for c in candles_1m[-24:]) + low = min(c["l"] for c in candles_1m[-24:]) + range_pct = (high - low) / low if low > 0 else 0 + + # Calculate trend (1h change) + if len(candles_1m) >= 60: + old_price = candles_1m[-60]["c"] + trend_1h = (price - old_price) / old_price if old_price > 0 else 0 + else: + trend_1h = 0 + + # Volume analysis + volume_1h = sum(c["v"] for c in candles_1m[-60:]) + volume_prev = sum(c["v"] for c in candles_1m[-120:-60]) if len(candles_1m) >= 120 else volume_1h + vol_ratio = volume_1h / volume_prev if volume_prev > 0 else 1.0 + + # Detect regime + from salior.llm import llm + regime_text = await llm.chat( + REGIME_PROMPT.format( + price=price, + high=high, + low=low, + range_pct=range_pct, + trend_1h=trend_1h, + volume_1h=volume_1h, + volume_prev=volume_prev, + vol_ratio=vol_ratio, + ), + system="You are a JSON-only assistant. Return valid JSON with regime and reasoning keys.", + ) + + import json as json_mod + try: + regime_data = json_mod.loads(regime_text) + except Exception: + regime_data = {"regime": "ranging", "reasoning": regime_text[:100]} + + # Score conviction + candles_text = "\n".join( + f" {c['t'].isoformat()}: O={c['o']:.1f} H={c['h']:.1f} L={c['l']:.1f} C={c['c']:.1f} V={c['v']:.0f}" + for c in candles_1m[-20:] + ) + conviction_text = await llm.chat( + CONVICTION_PROMPT.format( + regime=regime_data["regime"], + price=price, + candles_text=candles_text, + ), + system="You are a JSON-only assistant. Return valid JSON with conviction (-1 to 1), reasoning, and momentum keys.", + ) + + try: + conviction_data = json_mod.loads(conviction_text) + except Exception: + conviction_data = {"conviction": 0.0, "reasoning": conviction_text[:100], "momentum": "neutral"} + + return { + "coin": coin, + "regime": regime_data["regime"], + "conviction": float(conviction_data.get("conviction", 0.0)), + "reasoning": f"{regime_data.get('reasoning', '')} | {conviction_data.get('reasoning', '')}", + "price": price, + "momentum": conviction_data.get("momentum", "neutral"), + } + + async def _emit_signal(self, signal: dict) -> None: + """Write signal to both TimescaleDB and Supabase.""" + # Write to TimescaleDB + await self._db.insert_signal( + signal["coin"], + signal["regime"], + signal["conviction"], + signal["reasoning"], + ) + + # Write to Supabase (primary for external access) + await self._supabase.insert_signal( + signal["coin"], + signal["regime"], + signal["conviction"], + signal["reasoning"], + ) \ No newline at end of file diff --git a/salior/cli.py b/salior/cli.py new file mode 100644 index 0000000..516737c --- /dev/null +++ b/salior/cli.py @@ -0,0 +1,96 @@ +"""salior CLI entry point.""" +from __future__ import annotations + +import asyncio +import click +from salior.core.config import config +from salior.core.logging import setup_logging + +log = setup_logging() + + +@click.group() +def main() -> None: + """Salior — autonomous trading system.""" + pass + + +@main.command() +def status() -> None: + """Check system status.""" + click.echo("=== Salior Status ===") + click.echo(f"Host: {config.host}:{config.port}") + click.echo(f"Execution mode: {config.execution_mode}") + click.echo(f"Coins: {', '.join(config.coins)}") + click.echo(f"Min conviction: {config.min_conviction}") + click.echo(f"TimescaleDB: {config.timeseries_host}:{config.timeseries_port}/{config.timeseries_db}") + click.echo(f"Supabase: {config.supabase_url}") + + +@main.command() +def db_init() -> None: + """Initialize the database schema.""" + import asyncpg + click.echo(f"Connecting to {config.timeseries_url}...") + + async def run() -> None: + conn = await asyncpg.connect(config.timeseries_url) + schema = Path(__file__).parent.parent / "db" / "schema.sql" + sql = schema.read_text() + await conn.execute(sql) + await conn.close() + click.echo("Schema applied successfully.") + + asyncio.run(run()) + + +@main.command() +def agent_start() -> None: + """Start all agents.""" + click.echo("Starting agents...") + + from salior.agents.data.agent import DataAgent + from salior.agents.signal.agent import SignalAgent + + async def run() -> None: + data = DataAgent() + signal = SignalAgent() + + await asyncio.gather( + data.start(), + signal.start(), + ) + + try: + asyncio.run(run()) + except KeyboardInterrupt: + click.echo("Agents stopped.") + + +@main.command() +def mcp_serve() -> None: + """Start the MCP server.""" + from salior.mcp.server import MCPServer + click.echo(f"Starting MCP server on {config.host}:{config.port}...") + + async def run() -> None: + server = MCPServer(config.host, config.port) + await server.start() + await asyncio.Event().wait() + + asyncio.run(run()) + + +@main.command() +def plugin_list() -> None: + """List available plugins.""" + from salior.plugins import registry + plugins = registry.discover() + click.echo(f"=== Plugins ({len(plugins)}) ===") + for p in registry.list(): + status = "✅" if p["enabled"] else "❌" + click.echo(f"{status} {p['name']}: {p['description']}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/salior/core/__init__.py b/salior/core/__init__.py new file mode 100644 index 0000000..04bfb06 --- /dev/null +++ b/salior/core/__init__.py @@ -0,0 +1,7 @@ +"""Core modules.""" +from salior.core.config import config +from salior.core.logging import setup_logging +from salior.core.memory import memory +from salior.core.agent import Agent + +__all__ = ["config", "setup_logging", "memory", "Agent"] \ No newline at end of file diff --git a/salior/core/agent.py b/salior/core/agent.py new file mode 100644 index 0000000..e76c04c --- /dev/null +++ b/salior/core/agent.py @@ -0,0 +1,101 @@ +"""Base agent class.""" +from __future__ import annotations + +import asyncio +import signal +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Optional + +import structlog + +logger = structlog.get_logger("salior.agent") + + +class Agent(ABC): + """Base class for all Salior agents. + + Provides: + - Lifecycle (start/stop/health) + - Self-validation hooks + - Loop detection (max iterations) + - Graceful shutdown + """ + + name: str = "agent" + + def __init__(self) -> None: + self._running = False + self._stopping = False + self._task: Optional[asyncio.Task] = None + self._last_heartbeat: Optional[datetime] = None + self._iteration = 0 + self._log = logger.bind(agent=self.name) + + async def start(self) -> None: + """Start the agent. Override in subclass.""" + self._running = True + self._last_heartbeat = datetime.utcnow() + self._task = asyncio.create_task(self._run()) + self._log.info("agent_started") + + async def stop(self) -> None: + """Stop the agent gracefully.""" + self._stopping = True + self._running = False + self._log.info("agent_stopping") + if self._task: + try: + self._task.cancel() + await asyncio.wait_for(self._task, timeout=5.0) + except asyncio.CancelledError: + pass + except Exception as e: + self._log.error("agent_stop_error", error=str(e)) + self._log.info("agent_stopped") + + async def _run(self) -> None: + """Main agent loop. Calls run() repeatedly until stopped.""" + try: + while self._running and not self._stopping: + try: + await asyncio.wait_for(self.run(), timeout=self.loop_interval()) + except asyncio.TimeoutError: + # run() took too long — continue to next iteration + self._log.warning("agent_loop_timeout", iteration=self._iteration) + self._iteration += 1 + self._last_heartbeat = datetime.utcnow() + except Exception as e: + self._log.error("agent_loop_error", error=str(e)) + self._running = False + + @abstractmethod + async def run(self) -> None: + """One iteration of the agent loop. Implement in subclass.""" + ... + + def loop_interval(self) -> float: + """Seconds between run() calls. Override in subclass.""" + return 60.0 + + def is_healthy(self) -> bool: + """Return True if agent is running and heartbeat is recent.""" + if not self._running: + return False + if self._last_heartbeat is None: + return True + # Unhealthy if no heartbeat in 5x the loop interval + elapsed = (datetime.utcnow() - self._last_heartbeat).total_seconds() + return elapsed < self.loop_interval() * 5 + + def health_status(self) -> dict: + """Return health status dict.""" + return { + "agent": self.name, + "running": self._running, + "iteration": self._iteration, + "last_heartbeat": ( + self._last_heartbeat.isoformat() if self._last_heartbeat else None + ), + "healthy": self.is_healthy(), + } \ No newline at end of file diff --git a/salior/core/config.py b/salior/core/config.py new file mode 100644 index 0000000..38e273f --- /dev/null +++ b/salior/core/config.py @@ -0,0 +1,70 @@ +"""Configuration from environment variables.""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class Config: + """All configuration from environment variables.""" + + # Host + host: str = os.getenv("SALIOR_HOST", "localhost") + port: int = int(os.getenv("SALIOR_PORT", "8080")) + + # TimescaleDB (VPS3 local) + timeseries_host: str = os.getenv("TIMESERIES_HOST", "localhost") + timeseries_port: int = int(os.getenv("TIMESERIES_PORT", "5432")) + timeseries_user: str = os.getenv("TIMESERIES_USER", "salior") + timeseries_password: str = os.getenv("TIMESERIES_PASSWORD", "SaliorDB123!") + timeseries_db: str = os.getenv("TIMESERIES_DB", "salior") + + timeseries_dsn: str = field( + default=lambda self: f"postgresql://{self.timeseries_user}:{self.timeseries_password}@{self.timeseries_host}:{self.timeseries_port}/{self.timeseries_db}", + init=False, + ) + + # Supabase (Salior_DATA) + supabase_url: str = os.getenv("SUPABASE_URL", "https://ridjlobkeolorkcunisl.supabase.co") + supabase_key: str = os.getenv("SUPABASE_KEY", "") + + # LLM + minimax_api_key: str = os.getenv("MINIMAX_API_KEY", "") + minimax_model: str = os.getenv("MINIMAX_MODEL", "MiniMax-Text-01") + local_llm_url: str = os.getenv("LOCAL_LLM_URL", "") + openrouter_api_key: str = os.getenv("OPENROUTER_API_KEY", "") + + # Hyperliquid + hl_ws_url: str = os.getenv( + "HYPERLIQUID_WS_URL", "wss://api.hyperliquid.xyz/ws" + ) + hl_api_url: str = os.getenv( + "HYPERLIQUID_API_URL", "https://api.hyperliquid.xyz" + ) + + # Wallet session + wallet_session_days: int = int(os.getenv("WALLET_SESSION_DAYS", "180")) + + # Execution + hl_private_key: str = os.getenv("HL_PRIVATE_KEY", "") + execution_mode: str = os.getenv("EXECUTION_MODE", "paper") # paper | live + + # Plugins + plugin_dir: str = os.getenv("SALIOR_PLUGIN_DIR", "~/.salior/plugins") + + # Coins to trade (v1: BTC + ETH) + coins: list[str] = field( + default_factory=lambda: os.getenv("COINS", "BTC,ETH").split(",") + ) + + # Signal thresholds + min_conviction: float = float(os.getenv("MIN_CONVICTION", "0.3")) + + @property + def timeseries_url(self) -> str: + return f"postgresql://{self.timeseries_user}:{self.timeseries_password}@{self.timeseries_host}:{self.timeseries_port}/{self.timeseries_db}" + + +config = Config() \ No newline at end of file diff --git a/salior/core/logging.py b/salior/core/logging.py new file mode 100644 index 0000000..729a8fe --- /dev/null +++ b/salior/core/logging.py @@ -0,0 +1,35 @@ +"""Structured logging setup.""" +from __future__ import annotations + +import structlog +import logging +import sys + + +def setup_logging(level: str = "INFO") -> structlog.BoundLogger: + """Configure structlog with console output.""" + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=getattr(logging, level.upper(), logging.INFO), + ) + + structlog.configure( + processors=[ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + structlog.dev.ConsoleRenderer(colors=True), + ], + wrapper_class=structlog.stdlib.BoundLogger, + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, + ) + + return structlog.get_logger("salior") \ No newline at end of file diff --git a/salior/core/memory.py b/salior/core/memory.py new file mode 100644 index 0000000..0d0382d --- /dev/null +++ b/salior/core/memory.py @@ -0,0 +1,76 @@ +"""Long-term memory across sessions.""" +from __future__ import annotations + +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Any, Optional + +MEMORY_DIR = Path.home() / ".salior" / "memory" +MEMORY_DIR.mkdir(parents=True, exist_ok=True) + + +class Memory: + """Persistent memory backed by files in ~/.salior/memory/.""" + + def __init__(self) -> None: + self._cache: dict[str, Any] = {} + + def save(self, key: str, value: str | dict, tags: list[str] | None = None) -> None: + """Save a memory entry.""" + entry = { + "key": key, + "value": value, + "tags": tags or [], + "saved_at": datetime.utcnow().isoformat(), + } + # Store in cache + self._cache[key] = entry + # Write to file + safe_key = key.replace("/", "_").replace(" ", "_") + path = MEMORY_DIR / f"{safe_key}.json" + with open(path, "w") as f: + json.dump(entry, f, indent=2) + + def get(self, key: str) -> Optional[Any]: + """Get a memory entry.""" + if key in self._cache: + return self._cache[key]["value"] + safe_key = key.replace("/", "_").replace(" ", "_") + path = MEMORY_DIR / f"{safe_key}.json" + if path.exists(): + with open(path) as f: + entry = json.load(f) + self._cache[key] = entry + return entry["value"] + return None + + def search(self, query: str, tags: list[str] | None = None) -> list[dict]: + """Search memories by content or tags.""" + results = [] + for path in MEMORY_DIR.glob("*.json"): + with open(path) as f: + entry = json.load(f) + if query.lower() in str(entry.get("value", "")).lower(): + if tags is None or any(t in entry.get("tags", []) for t in tags): + results.append(entry) + return results + + def list_keys(self) -> list[str]: + """List all memory keys.""" + keys = [] + for path in MEMORY_DIR.glob("*.json"): + keys.append(path.stem.replace("_", " ")) + return sorted(keys) + + def forget(self, key: str) -> None: + """Delete a memory entry.""" + safe_key = key.replace("/", "_").replace(" ", "_") + path = MEMORY_DIR / f"{safe_key}.json" + if path.exists(): + path.unlink() + self._cache.pop(key, None) + + +memory = Memory() \ No newline at end of file diff --git a/salior/db/__init__.py b/salior/db/__init__.py new file mode 100644 index 0000000..da6d1e6 --- /dev/null +++ b/salior/db/__init__.py @@ -0,0 +1,5 @@ +"""Database module.""" +from salior.db.timescale_client import TimescaleDB +from salior.db.supabase_client import SupabaseClient + +__all__ = ["TimescaleDB", "SupabaseClient"] \ No newline at end of file diff --git a/salior/db/__pycache__/__init__.cpython-311.pyc b/salior/db/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d509ef963622569f896011cf2bf9af77bc3d1c6 GIT binary patch literal 397 zcmY*Wu};G<5Ve!EK`lj06?9@vkzxt4fjU5EmNI3r+}MJE6GwKcsx0sqd_slznT$~; zBqlbbZk;&OMpe(}yZ3bOo@9C1>va&s>jFQ0LjSg;31gIUciuO_>pAsp2y!!XIlD;JSOCq{75Zit!E5qsc7)*WJoQ zWQBG?r9CKjLA&-Tq_F zrGjLN3lgeaWU*v(GFST9n{SV{zwigOgmK{j literal 0 HcmV?d00001 diff --git a/salior/db/__pycache__/supabase_client.cpython-311.pyc b/salior/db/__pycache__/supabase_client.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..80440cbe6d90249ebb56f4f899e41e0edae49f9d GIT binary patch literal 9284 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 Fe*oHrxzYdt literal 0 HcmV?d00001 diff --git a/salior/db/schema.sql b/salior/db/schema.sql new file mode 100644 index 0000000..e0cd230 --- /dev/null +++ b/salior/db/schema.sql @@ -0,0 +1,175 @@ +-- Salior Database Schema +-- PostgreSQL 17 + TimescaleDB 2.26 +-- Run: psql $DATABASE_URL -f schema.sql + +-- === TimescaleDB hypertables (market data) === + +-- 1m candles per coin +CREATE TABLE IF NOT EXISTS candles_1m ( + coin TEXT NOT NULL, + t TIMESTAMPTZ NOT NULL, + o REAL NOT NULL, + h REAL NOT NULL, + l REAL NOT NULL, + c REAL NOT NULL, + v REAL NOT NULL, + PRIMARY KEY (coin, t) +); +SELECT create_hypertable('candles_1m', 't', if_not_exists => TRUE); + +-- 5m candles +CREATE TABLE IF NOT EXISTS candles_5m ( + coin TEXT NOT NULL, + t TIMESTAMPTZ NOT NULL, + o REAL NOT NULL, + h REAL NOT NULL, + l REAL NOT NULL, + c REAL NOT NULL, + v REAL NOT NULL, + PRIMARY KEY (coin, t) +); +SELECT create_hypertable('candles_5m', 't', if_not_exists => TRUE); + +-- 15m candles +CREATE TABLE IF NOT EXISTS candles_15m ( + coin TEXT NOT NULL, + t TIMESTAMPTZ NOT NULL, + o REAL NOT NULL, + h REAL NOT NULL, + l REAL NOT NULL, + c REAL NOT NULL, + v REAL NOT NULL, + PRIMARY KEY (coin, t) +); +SELECT create_hypertable('candles_15m', 't', if_not_exists => TRUE); + +-- 1h candles +CREATE TABLE IF NOT EXISTS candles_1h ( + coin TEXT NOT NULL, + t TIMESTAMPTZ NOT NULL, + o REAL NOT NULL, + h REAL NOT NULL, + l REAL NOT NULL, + c REAL NOT NULL, + v REAL NOT NULL, + PRIMARY KEY (coin, t) +); +SELECT create_hypertable('candles_1h', 't', if_not_exists => TRUE); + +-- Orderbook snapshots +CREATE TABLE IF NOT EXISTS orderbook ( + coin TEXT NOT NULL, + t TIMESTAMPTZ NOT NULL, + bids JSONB NOT NULL, -- [{px, sz}, ...] + asks JSONB NOT NULL, + PRIMARY KEY (coin, t) +); +SELECT create_hypertable('orderbook', 't', if_not_exists => TRUE); + +-- Individual trades +CREATE TABLE IF NOT EXISTS trades ( + coin TEXT NOT NULL, + t TIMESTAMPTZ NOT NULL, + px REAL NOT NULL, + sz REAL NOT NULL, + side TEXT NOT NULL, -- 'bid' | 'ask' + hash TEXT UNIQUE NOT NULL, + PRIMARY KEY (hash) +); +CREATE INDEX IF NOT EXISTS idx_trades_coin_t ON trades (coin, t DESC); +SELECT create_hypertable('trades', 't', if_not_exists => TRUE); + +-- Funding rates +CREATE TABLE IF NOT EXISTS funding ( + coin TEXT NOT NULL, + t TIMESTAMPTZ NOT NULL, + rate REAL NOT NULL, + PRIMARY KEY (coin, t) +); +SELECT create_hypertable('funding', 't', if_not_exists => TRUE); + +-- === PostgreSQL tables (application data) === + +-- Signals from signal_agent +CREATE TABLE IF NOT EXISTS signals ( + id UUID DEFAULT gen_random_uuid(), + coin TEXT NOT NULL, + regime TEXT NOT NULL, -- trending_up | trending_down | ranging | volatile + conviction REAL NOT NULL, -- -1.0 to 1.0 + reasoning TEXT NOT NULL, + t TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(coin, t) +); + +-- Execution log +CREATE TABLE IF NOT EXISTS executions ( + id UUID DEFAULT gen_random_uuid(), + signal_id UUID REFERENCES signals(id), + coin TEXT NOT NULL, + side TEXT NOT NULL, -- buy | sell + sz REAL NOT NULL, + px REAL NOT NULL, + filled_px REAL, + status TEXT NOT NULL, -- pending | filled | cancelled | failed + mode TEXT NOT NULL, -- paper | live + error_msg TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Portfolio positions +CREATE TABLE IF NOT EXISTS portfolio ( + coin TEXT PRIMARY KEY, + pos_size REAL NOT NULL DEFAULT 0, + avg_px REAL NOT NULL DEFAULT 0, + unrealized_pnl REAL NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Performance log +CREATE TABLE IF NOT EXISTS performance ( + date DATE PRIMARY KEY, + daily_pnl REAL NOT NULL DEFAULT 0, + cumulative_pnl REAL NOT NULL DEFAULT 0, + sharpe REAL, + max_drawdown REAL, + trades_count INTEGER NOT NULL DEFAULT 0 +); + +-- Risk events (circuit breakers triggered) +CREATE TABLE IF NOT EXISTS risk_events ( + id UUID DEFAULT gen_random_uuid(), + event_type TEXT NOT NULL, -- max_drawdown | daily_loss | position_limit + details JSONB NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Agent heartbeat log +CREATE TABLE IF NOT EXISTS agent_health ( + agent TEXT NOT NULL, + heartbeat TIMESTAMPTZ DEFAULT NOW(), + iteration INTEGER, + status TEXT NOT NULL, -- running | paused | error + details JSONB +); +CREATE INDEX IF NOT EXISTS idx_agent_health_agent ON agent_health (agent, heartbeat DESC); + +-- Wallet sessions (180-day auth) +CREATE TABLE IF NOT EXISTS wallet_sessions ( + id UUID DEFAULT gen_random_uuid(), + wallet_address VARCHAR(42) UNIQUE NOT NULL, + session_token TEXT NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + last_active TIMESTAMPTZ DEFAULT NOW(), + signature VARCHAR(256) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Wallet addresses (known wallets) +CREATE TABLE IF NOT EXISTS wallets ( + address VARCHAR(42) PRIMARY KEY, + label TEXT, + wallet_type TEXT NOT NULL, -- main | api | receiving + created_at TIMESTAMPTZ DEFAULT NOW() +); \ No newline at end of file diff --git a/salior/db/supabase_client.py b/salior/db/supabase_client.py new file mode 100644 index 0000000..2eca024 --- /dev/null +++ b/salior/db/supabase_client.py @@ -0,0 +1,195 @@ +"""Supabase client for application data.""" +from __future__ import annotations + +from typing import Any, Optional + +import httpx + +from salior.core.config import config + + +class SupabaseClient: + """REST-based Supabase client for Salior_DATA.""" + + def __init__(self, url: Optional[str] = None, key: Optional[str] = None) -> None: + self.url = url or config.supabase_url + self.key = key or config.supabase_key + self._headers = { + "apikey": self.key, + "Authorization": f"Bearer {self.key}", + "Content-Type": "application/json", + } + + def _table_url(self, table: str) -> str: + return f"{self.url}/rest/v1/{table}" + + async def insert( + self, + table: str, + data: dict, + params: Optional[dict] = None, + ) -> dict | None: + """Insert a row into a table.""" + async with httpx.AsyncClient() as client: + resp = await client.post( + self._table_url(table), + json=data, + headers=self._headers, + params=params or {"select": "*, id"}, + ) + if resp.status_code in (200, 201): + return resp.json() + return None + + async def update( + self, + table: str, + data: dict, + filters: dict, + ) -> dict | None: + """Update rows matching filters.""" + query = " AND ".join(f"{k}=eq.{v}" for k, v in filters.items()) + async with httpx.AsyncClient() as client: + resp = await client.patch( + f"{self._table_url(table)}?{query}", + json=data, + headers=self._headers, + ) + if resp.status_code in (200, 204): + return resp.json() + return None + + async def select( + self, + table: str, + filters: Optional[dict] = None, + order: Optional[str] = None, + limit: int = 100, + ) -> list[dict]: + """Select rows from a table.""" + params = {"select": "*", "limit": str(limit)} + if filters: + for k, v in filters.items(): + params[f"where"] = f"{k}=eq.{v}" + if order: + params["order"] = order + + async with httpx.AsyncClient() as client: + resp = await client.get( + self._table_url(table), + headers=self._headers, + params=params, + ) + if resp.status_code == 200: + return resp.json() + return [] + + async def rpc( + self, + func: str, + params: Optional[dict] = None, + ) -> Any: + """Call a Supabase stored procedure.""" + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{self.url}/rest/v1/rpc/{func}", + json=params or {}, + headers=self._headers, + ) + if resp.status_code in (200, 201): + return resp.json() + return None + + # === Signals === + + async def insert_signal( + self, + coin: str, + regime: str, + conviction: float, + reasoning: str, + ) -> Optional[dict]: + """Insert a conviction signal.""" + return await self.insert( + "signals", + { + "coin": coin, + "regime": regime, + "conviction": conviction, + "reasoning": reasoning, + }, + params={"select": "id"}, + ) + + async def get_recent_signals( + self, + coin: Optional[str] = None, + limit: int = 10, + ) -> list[dict]: + """Get recent conviction signals.""" + filters = {"coin": f"eq.{coin}"} if coin else None + return await self.select( + "signals", + filters=filters, + order="created_at.desc", + limit=limit, + ) + + # === Executions === + + async def insert_execution(self, data: dict) -> Optional[dict]: + """Log an execution.""" + return await self.insert("executions", data) + + async def get_open_executions(self) -> list[dict]: + """Get pending/filled executions.""" + return await self.select( + "executions", + filters={"status": "in.(pending,filled)"}, + order="created_at.desc", + limit=50, + ) + + # === Portfolio === + + async def get_portfolio(self) -> list[dict]: + """Get current portfolio.""" + return await self.select("portfolio", limit=100) + + # === Wallet sessions === + + async def upsert_wallet_session( + self, + wallet_address: str, + session_token: str, + signature: str, + expires_at: str, + ) -> Optional[dict]: + """Insert or update a wallet session (180-day).""" + data = { + "wallet_address": wallet_address, + "session_token": session_token, + "signature": signature, + "issued_at": "now", + "expires_at": expires_at, + } + return await self.insert( + "wallet_sessions", + data, + params={"on_conflict": "wallet_address", "select": "*"}, + ) + + async def get_wallet_session( + self, + wallet_address: str, + ) -> Optional[dict]: + """Get active session for a wallet.""" + rows = await self.select( + "wallet_sessions", + filters={ + "wallet_address": f"eq.{wallet_address}", + "expires_at": "gt.now", + }, + limit=1, + ) + return rows[0] if rows else None \ No newline at end of file diff --git a/salior/db/timescale_client.py b/salior/db/timescale_client.py new file mode 100644 index 0000000..56b1c32 --- /dev/null +++ b/salior/db/timescale_client.py @@ -0,0 +1,281 @@ +"""TimescaleDB client for market data.""" +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta +from typing import Any, Optional + +import asyncpg + +from salior.core.config import config + + +class TimescaleDB: + """Async TimescaleDB client for market data.""" + + def __init__(self, dsn: Optional[str] = None) -> None: + self.dsn = dsn or config.timeseries_url + self._pool: Optional[asyncpg.Pool] = None + + async def connect(self) -> None: + """Create connection pool.""" + self._pool = await asyncpg.create_pool( + self.dsn, + min_size=2, + max_size=10, + command_timeout=60, + ) + + async def close(self) -> None: + """Close connection pool.""" + if self._pool: + await self._pool.close() + self._pool = None + + async def execute(self, query: str, *args: Any) -> None: + """Execute a query (INSERT/UPDATE).""" + if not self._pool: + await self.connect() + async with self._pool.acquire() as conn: + await conn.execute(query, *args) + + async def fetch(self, query: str, *args: Any) -> list[dict]: + """Fetch rows as list of dicts.""" + if not self._pool: + await self.connect() + async with self._pool.acquire() as conn: + rows = await conn.fetch(query, *args) + return [dict(r) for r in rows] + + # === Candles === + + async def upsert_candle( + self, + table: str, + coin: str, + t: datetime, + o: float, + h: float, + l: float, + c: float, + v: float, + ) -> None: + """Insert or update a candle row.""" + await self.execute( + f""" + INSERT INTO {table} (coin, t, o, h, l, c, v) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (coin, t) DO UPDATE SET + o = EXCLUDED.o, + h = EXCLUDED.h, + l = EXCLUDED.l, + c = EXCLUDED.c, + v = EXCLUDED.v + """, + coin, t, o, h, l, c, v, + ) + + async def get_latest_candle(self, table: str, coin: str) -> Optional[dict]: + """Get the most recent candle for a coin.""" + rows = await self.fetch( + f""" + SELECT * FROM {table} + WHERE coin = $1 + ORDER BY t DESC + LIMIT 1 + """, + coin, + ) + return rows[0] if rows else None + + async def get_candles( + self, + table: str, + coin: str, + hours: int = 24, + ) -> list[dict]: + """Get candles for a coin from the last N hours.""" + since = datetime.utcnow() - timedelta(hours=hours) + return await self.fetch( + f""" + SELECT * FROM {table} + WHERE coin = $1 AND t >= $2 + ORDER BY t ASC + """, + coin, since, + ) + + # === Trades === + + async def insert_trade( + self, + coin: str, + t: datetime, + px: float, + sz: float, + side: str, + hash: str, + ) -> None: + """Insert a trade (ignore duplicates by hash).""" + await self.execute( + """ + INSERT INTO trades (coin, t, px, sz, side, hash) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT DO NOTHING + """, + coin, t, px, sz, side, hash, + ) + + # === Orderbook === + + async def insert_orderbook( + self, + coin: str, + t: datetime, + bids: list[dict], + asks: list[dict], + ) -> None: + """Insert orderbook snapshot.""" + await self.execute( + """ + INSERT INTO orderbook (coin, t, bids, asks) + VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING + """, + coin, t, bids, asks, + ) + + # === Signals === + + async def insert_signal( + self, + coin: str, + regime: str, + conviction: float, + reasoning: str, + ) -> Optional[str]: + """Insert a signal, return its ID.""" + row = await self.fetch( + """ + INSERT INTO signals (coin, regime, conviction, reasoning) + VALUES ($1, $2, $3, $4) + ON CONFLICT (coin, t) DO UPDATE SET + regime = EXCLUDED.regime, + conviction = EXCLUDED.conviction, + reasoning = EXCLUDED.reasoning + RETURNING id::text + """, + coin, regime, conviction, reasoning, + ) + return row[0]["id"] if row else None + + async def get_latest_signals(self, limit: int = 10) -> list[dict]: + """Get most recent signals for all coins.""" + return await self.fetch( + """ + SELECT * FROM signals + ORDER BY t DESC + LIMIT $1 + """, + limit, + ) + + # === Portfolio === + + async def upsert_portfolio( + self, + coin: str, + pos_size: float, + avg_px: float, + unrealized_pnl: float, + ) -> None: + """Update portfolio position.""" + await self.execute( + """ + INSERT INTO portfolio (coin, pos_size, avg_px, unrealized_pnl) + VALUES ($1, $2, $3, $4) + ON CONFLICT (coin) DO UPDATE SET + pos_size = EXCLUDED.pos_size, + avg_px = EXCLUDED.avg_px, + unrealized_pnl = EXCLUDED.unrealized_pnl, + updated_at = NOW() + """, + coin, pos_size, avg_px, unrealized_pnl, + ) + + async def get_portfolio(self) -> list[dict]: + """Get all portfolio positions.""" + return await self.fetch("SELECT * FROM portfolio") + + # === Executions === + + async def insert_execution( + self, + signal_id: Optional[str], + coin: str, + side: str, + sz: float, + px: float, + mode: str, + ) -> str: + """Insert an execution record, return its ID.""" + rows = await self.fetch( + """ + INSERT INTO executions (signal_id, coin, side, sz, px, mode, status) + VALUES ($1, $2, $3, $4, $5, $6, 'pending') + RETURNING id::text + """, + signal_id, coin, side, sz, px, mode, + ) + return rows[0]["id"] + + async def update_execution( + self, + exec_id: str, + status: str, + filled_px: Optional[float] = None, + error_msg: Optional[str] = None, + ) -> None: + """Update execution status.""" + await self.execute( + """ + UPDATE executions + SET status = $2, + filled_px = COALESCE($3, filled_px), + error_msg = $4, + updated_at = NOW() + WHERE id = $1::uuid + """, + exec_id, status, filled_px, error_msg, + ) + + # === Health === + + async def log_health( + self, + agent: str, + status: str, + iteration: Optional[int] = None, + details: Optional[dict] = None, + ) -> None: + """Log agent health heartbeat.""" + await self.execute( + """ + INSERT INTO agent_health (agent, status, iteration, details) + VALUES ($1, $2, $3, $4) + """, + agent, status, iteration, details, + ) + + async def get_agent_health(self, agent: str, minutes: int = 10) -> list[dict]: + """Get recent health entries for an agent.""" + since = datetime.utcnow() - timedelta(minutes=minutes) + return await self.fetch( + """ + SELECT * FROM agent_health + WHERE agent = $1 AND heartbeat >= $2 + ORDER BY heartbeat DESC + LIMIT 10 + """, + agent, since, + ) \ No newline at end of file diff --git a/salior/llm/__init__.py b/salior/llm/__init__.py new file mode 100644 index 0000000..08b2ac3 --- /dev/null +++ b/salior/llm/__init__.py @@ -0,0 +1,5 @@ +"""LLM client module.""" + +from salior.llm.client import LLMClient, llm + +__all__ = ["LLMClient", "llm"] \ No newline at end of file diff --git a/salior/llm/__pycache__/__init__.cpython-311.pyc b/salior/llm/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..247fbcefdd4306921964ffbd32937ae946b3ceed GIT binary patch literal 328 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 None: + self._providers = self._build_providers() + + def _build_providers(self) -> dict[str, dict]: + """Build provider config from environment.""" + return { + "minimax": { + "url": "https://api.minimax.chat/v1/text/chatcompletion_v2", + "model": config.minimax_model, + "api_key": config.minimax_api_key, + "enabled": bool(config.minimax_api_key), + }, + "openrouter": { + "url": "https://openrouter.ai/api/v1/chat/completions", + "model": "anthropic/claude-3.5-haiku", + "api_key": config.openrouter_api_key, + "enabled": bool(config.openrouter_api_key), + }, + "local": { + "url": f"{config.local_llm_url}/v1/chat/completions", + "model": "local", + "api_key": "not-needed", + "enabled": bool(config.local_llm_url), + }, + } + + async def chat( + self, + prompt: str, + system: Optional[str] = None, + model_override: Optional[str] = None, + **kwargs, + ) -> str: + """Send a chat completion request. + + Args: + prompt: The user prompt + system: Optional system message + model_override: Force a specific provider/model (e.g. "openrouter/claude-3") + + Returns: + The model's text response + """ + # Parse model_override if provided (e.g. "openrouter/anthropic/claude-3") + if model_override and "/" in model_override: + parts = model_override.split("/", 1) + provider = parts[0] + model = parts[1] + selected = self._providers.get(provider) + if not selected or not selected["enabled"]: + raise ValueError(f"Provider {provider} not configured or not enabled") + else: + # Try providers in priority order + selected = None + model = None + for name in ["minimax", "openrouter", "local"]: + p = self._providers[name] + if p["enabled"]: + selected = p + model = model_override or p["model"] + break + if not selected: + raise RuntimeError("No LLM provider configured. Set MINIMAX_API_KEY, OPENROUTER_API_KEY, or LOCAL_LLM_URL") + + # Build messages + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + # Build request + headers = { + "Authorization": f"Bearer {selected['api_key']}", + "Content-Type": "application/json", + } + payload = { + "model": model, + "messages": messages, + **{k: v for k, v in kwargs.items() if k not in ["model", "messages"]}, + } + + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post(selected["url"], headers=headers, json=payload) + + if resp.status_code != 200: + raise RuntimeError(f"LLM request failed: {resp.status_code} {resp.text}") + + data = resp.json() + return data["choices"][0]["message"]["content"] + + async def batch( + self, + calls: list[dict], + system: Optional[str] = None, + ) -> list[str]: + """Batch multiple prompts into one request (if provider supports). + + Args: + calls: List of {"prompt": str} dicts + system: Optional system message + + Returns: + List of responses in same order as calls + """ + # For now, just call chat() for each (llm_batcher plugin does the batching) + results = [] + for call in calls: + result = await self.chat( + prompt=call.get("prompt", ""), + system=system, + ) + results.append(result) + return results + + +llm = LLMClient() \ No newline at end of file diff --git a/salior/mcp/__init__.py b/salior/mcp/__init__.py new file mode 100644 index 0000000..d9e225c --- /dev/null +++ b/salior/mcp/__init__.py @@ -0,0 +1,4 @@ +"""MCP server module.""" +from salior.mcp.server import MCPServer + +__all__ = ["MCPServer"] \ No newline at end of file diff --git a/salior/mcp/__pycache__/__init__.cpython-311.pyc b/salior/mcp/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d6e70a06de4ee226c2d1e23b64938724d87bedb GIT binary patch literal 298 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 literal 0 HcmV?d00001 diff --git a/salior/mcp/server.py b/salior/mcp/server.py new file mode 100644 index 0000000..66f6247 --- /dev/null +++ b/salior/mcp/server.py @@ -0,0 +1,169 @@ +"""MCP server — external AI control of Salior via JSON-RPC.""" +from __future__ import annotations + +import asyncio +import json +from typing import Any, Callable + +from salior.core.logging import setup_logging +from salior.db.supabase_client import SupabaseClient + +log = setup_logging() + +# MCP tool definitions +TOOLS = { + "get_portfolio": { + "description": "Get current positions and PnL", + "params": {}, + }, + "get_signals": { + "description": "Get recent conviction signals", + "params": {"coin": {"type": "string", "optional": True}, "limit": {"type": "int", "optional": True}}, + }, + "get_market_state": { + "description": "Get regime + conviction for a coin", + "params": {"coin": {"type": "string", "required": True}}, + }, + "place_order": { + "description": "Execute a trade (with confirmation gate)", + "params": { + "coin": {"type": "string", "required": True}, + "side": {"type": "string", "required": True}, + "size": {"type": "float", "required": True}, + "price": {"type": "float", "optional": True}, + }, + }, + "get_performance": { + "description": "Historical PnL, Sharpe, drawdown", + "params": {"days": {"type": "int", "optional": True}}, + }, +} + + +class MCPServer: + """JSON-RPC MCP server running on localhost:8080/mcp.""" + + def __init__(self, host: str = "localhost", port: int = 8080) -> None: + self.host = host + self.port = port + self._supabase = SupabaseClient() + self._server: Any = None + + async def start(self) -> None: + """Start the MCP server.""" + from aiohttp import web + + async def handle(request: web.Request) -> web.Response: + body = await request.json() + result = await self._handle_jsonrpc(body) + return web.json_response(result) + + app = web.Application() + app.router.add_post("/mcp", handle) + app.router.add_get("/mcp/tools", self._handle_tools_list) + app.router.add_get("/mcp/health", self._handle_health) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, self.host, self.port) + await site.start() + log.info("mcp_server_started", host=self.host, port=self.port) + + async def _handle_jsonrpc(self, body: dict) -> dict: + """Handle a JSON-RPC request.""" + method = body.get("method", "") + params = body.get("params", {}) + msg_id = body.get("id") + + try: + if method == "tools/list": + return {"id": msg_id, "result": self._list_tools()} + elif method == "tools/call": + tool = params.get("name", "") + args = params.get("arguments", {}) + result = await self._call_tool(tool, args) + return {"id": msg_id, "result": result} + else: + return {"id": msg_id, "error": {"code": -32601, "message": f"Unknown method: {method}"}} + except Exception as e: + log.error("mcp_error", method=method, error=str(e)) + return {"id": msg_id, "error": {"code": -32603, "message": str(e)}} + + def _list_tools(self) -> list[dict]: + """List available MCP tools.""" + return [ + {"name": name, "description": info["description"], "inputSchema": {"type": "object", "properties": info["params"]}} + for name, info in TOOLS.items() + ] + + async def _call_tool(self, name: str, args: dict) -> Any: + """Dispatch to the appropriate tool handler.""" + handler = getattr(self, f"_tool_{name}", None) + if not handler: + raise ValueError(f"Unknown tool: {name}") + return await handler(args) + + async def _tool_get_portfolio(self, args: dict) -> dict: + """Get current portfolio positions.""" + portfolio = await self._supabase.get_portfolio() + return {"positions": portfolio, "count": len(portfolio)} + + async def _tool_get_signals(self, args: dict) -> dict: + """Get recent conviction signals.""" + coin = args.get("coin") + limit = args.get("limit", 10) + signals = await self._supabase.get_recent_signals(coin=coin, limit=limit) + return {"signals": signals, "count": len(signals)} + + async def _tool_get_market_state(self, args: dict) -> dict: + """Get regime + conviction for a specific coin.""" + from salior.db.timescale_client import TimescaleDB + db = TimescaleDB() + await db.connect() + + coin = args["coin"] + candles = await db.get_candles("candles_1m", coin, hours=24) + latest = candles[-1] if candles else None + + signals = await self._supabase.get_recent_signals(coin=coin, limit=1) + signal = signals[0] if signals else None + + return { + "coin": coin, + "price": latest["c"] if latest else None, + "regime": signal["regime"] if signal else None, + "conviction": signal["conviction"] if signal else None, + "candles_count": len(candles), + } + + async def _tool_place_order(self, args: dict) -> dict: + """Place an order (requires confirmation).""" + # For now, just confirm via log — full wallet flow requires user sign-in + log.info("mcp_order_request", **args) + return { + "status": "requires_confirmation", + "message": "Order requires wallet approval. Connect wallet at https://salior.ai", + "requested": args, + } + + async def _tool_get_performance(self, args: dict) -> dict: + """Get historical performance metrics.""" + # Placeholder — would read from performance table + return {"days": args.get("days", 30), "message": "Connect to TimescaleDB for full data"} + + async def _handle_tools_list(self, request: web.Request) -> web.Response: + return web.json_response({"tools": self._list_tools()}) + + async def _handle_health(self, request: web.Request) -> web.Response: + return web.json_response({"status": "ok"}) + + +async def main() -> None: + """Run the MCP server.""" + server = MCPServer() + await server.start() + await asyncio.Event().wait() # Keep running + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/salior/plugins/__init__.py b/salior/plugins/__init__.py new file mode 100644 index 0000000..564b95a --- /dev/null +++ b/salior/plugins/__init__.py @@ -0,0 +1,113 @@ +"""Plugin system — discovery, loading, and dispatch.""" +from __future__ import annotations + +import os +import subprocess +import yaml +from pathlib import Path +from typing import Any, Optional + +PLUGIN_DIR = Path(__file__).parent.parent.parent / "plugins" + + +class Plugin: + """A discovered plugin with manifest and run entry point.""" + + def __init__(self, name: str, manifest: dict, dir: Path) -> None: + self.name = name + self.manifest = manifest + self.dir = dir + self.enabled = False + + @property + def requires_gpu(self) -> bool: + return self.manifest.get("compute", {}).get("gpu", False) + + @property + def description(self) -> str: + return self.manifest.get("description", "") + + def run(self, method: str, params: dict) -> Any: + """Run a plugin method as a subprocess.""" + result = subprocess.run( + ["python3", str(self.dir / "run.py"), method], + input=yaml.dump(params), + capture_output=True, + text=True, + ) + if result.returncode == 0: + return yaml.safe_load(result.stdout) + raise RuntimeError(f"Plugin error: {result.stderr}") + + +class PluginRegistry: + """Discovers and manages plugins.""" + + def __init__(self, plugin_dir: Optional[Path] = None) -> None: + self.plugin_dir = plugin_dir or PLUGIN_DIR + self._plugins: dict[str, Plugin] = {} + + def discover(self) -> dict[str, Plugin]: + """Find all plugins in the plugin directory.""" + if self._plugins: + return self._plugins + + if not self.plugin_dir.exists(): + return self._plugins + + for entry in self.plugin_dir.iterdir(): + if not entry.is_dir() or entry.name.startswith("_"): + continue + manifest_path = entry / "manifest.yaml" + if manifest_path.exists(): + manifest = yaml.safe_load(manifest_path.read_text()) + self._plugins[entry.name] = Plugin(entry.name, manifest, entry) + + return self._plugins + + def enable(self, name: str) -> None: + """Enable a plugin.""" + plugin = self._plugins.get(name) + if plugin: + plugin.enabled = True + + def disable(self, name: str) -> None: + """Disable a plugin.""" + plugin = self._plugins.get(name) + if plugin: + plugin.enabled = False + + def dispatch( + self, + plugin_name: str, + method: str, + params: Optional[dict] = None, + ) -> Any: + """Dispatch a call to a plugin.""" + plugin = self._plugins.get(plugin_name) + if not plugin: + raise ValueError(f"Plugin '{plugin_name}' not found") + if not plugin.enabled: + raise RuntimeError(f"Plugin '{plugin_name}' is not enabled") + return plugin.run(method, params or {}) + + def list(self) -> list[dict]: + """List all plugins with status.""" + return [ + { + "name": p.name, + "enabled": p.enabled, + "description": p.description, + "gpu": p.requires_gpu, + } + for p in self._plugins.values() + ] + + +# Global registry +registry = PluginRegistry() + + +def dispatch(plugin_name: str, method: str, params: Optional[dict] = None) -> Any: + """Global plugin dispatch.""" + return registry.dispatch(plugin_name, method, params) \ No newline at end of file diff --git a/salior/plugins/__pycache__/__init__.cpython-311.pyc b/salior/plugins/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3c862e2e6a4718cc6f9bec1a61324e42bdd79e8a GIT binary patch literal 6462 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 LU=Ke4*iQWyiuZ8e literal 0 HcmV?d00001 diff --git a/salior/skills/__init__.py b/salior/skills/__init__.py new file mode 100644 index 0000000..147fbf2 --- /dev/null +++ b/salior/skills/__init__.py @@ -0,0 +1,4 @@ +"""Skills module.""" +from salior.skills.registry import SkillRegistry + +__all__ = ["SkillRegistry"] \ No newline at end of file diff --git a/salior/skills/__pycache__/__init__.cpython-311.pyc b/salior/skills/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6c6c8df42ee97c318c6f69e5d33d1e6a43cbecea GIT binary patch literal 307 zcmZ3^%ge<81l|>lSxG?pF^B^LOi;#W86aaiLkdF_LkeRGQx0P;Qxp>;Lke>`V-#}= zOE7~bYZYH`c4kgau|jTsN@-52-b;{HO~zZiVBw(D^vvRtqRJv>ps1fF%Psc!_>}zQ z`1o65#fdqY`9*rgVBLB}$O=KK5_59m<5x0#1{wFuUOyv0H&s6`KP6RPFTWr)FF7Z% zT)#ZOD7&~IF*#KqViueN8>t^3pP83g5+AQuQ2C3)2I4%sB9NyUfw))`NPJ*sWMsU- dAaa30~Zf5 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 HV9NV1LgEH8 literal 0 HcmV?d00001 diff --git a/salior/skills/build.md b/salior/skills/build.md new file mode 100644 index 0000000..4da0b12 --- /dev/null +++ b/salior/skills/build.md @@ -0,0 +1,30 @@ +# Build Skill + +Use this when writing code or building a feature. + +## Steps + +1. **Spec first** — Write the PRD before touching code (use `/spec`) +2. **Vertical slice** — Build the smallest end-to-end path first +3. **Verify each layer** — Check data flows before adding logic +4. **Iterate** — Add features one at a time, test each + +## Verification Gate + +- Code runs without import/syntax errors +- Basic happy path works (e.g., for an agent: starts, loops, writes data) +- No hardcoded secrets or placeholder values in final code + +## Principles + +- Thin vertical slices over big upfront architecture +- Verify before adding complexity +- Delete dead code immediately +- Name things clearly the first time + +## Notes + +- Follow the project conventions (check existing files for patterns) +- Use type hints on all function signatures +- Structured logging with structlog +- All file paths relative to project root \ No newline at end of file diff --git a/salior/skills/registry.py b/salior/skills/registry.py new file mode 100644 index 0000000..d6b84eb --- /dev/null +++ b/salior/skills/registry.py @@ -0,0 +1,76 @@ +"""Agent skill definitions — markdown files with steps + verification gates.""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +SKILLS_DIR = Path(__file__).parent + + +class Skill: + """A skill is a markdown file with structured steps and verification gates.""" + + def __init__(self, name: str, path: Path) -> None: + self.name = name + self.path = path + self._content: Optional[str] = None + + @property + def content(self) -> str: + if self._content is None: + self._content = self.path.read_text() + return self._content + + def steps(self) -> list[str]: + """Parse steps from markdown (lines starting with numbers or -).""" + lines = self.content.split("\n") + steps = [] + for line in lines: + line = line.strip() + if line and (line[0].isdigit() or line.startswith("-")): + steps.append(line) + return steps + + def verify(self, step: int, result: str) -> bool: + """Check if verification gate passes for a step.""" + # Simple verification: look for pass/fail markers in the content + return True # Override in subclass + + +class SkillRegistry: + """Discovers and manages skills from the skills/ directory.""" + + def __init__(self, skills_dir: Optional[Path] = None) -> None: + self.skills_dir = skills_dir or SKILLS_DIR + self._skills: dict[str, Skill] = {} + + def discover(self) -> dict[str, Skill]: + """Find all .md skill files.""" + if self._skills: + return self._skills + + for path in self.skills_dir.glob("*.md"): + if path.stem.startswith("_"): + continue + skill = Skill(path.stem, path) + self._skills[skill.name] = skill + + return self._skills + + def get(self, name: str) -> Optional[Skill]: + """Get a skill by name.""" + if not self._skills: + self.discover() + return self._skills.get(name) + + def list(self) -> list[str]: + """List all available skill names.""" + return list(self.discover().keys()) + + def render(self, name: str) -> str: + """Get the full skill content for an agent to follow.""" + skill = self.get(name) + if not skill: + return f"Skill '{name}' not found. Available: {', '.join(self.list())}" + return skill.content \ No newline at end of file diff --git a/salior/skills/research.md b/salior/skills/research.md new file mode 100644 index 0000000..f11c540 --- /dev/null +++ b/salior/skills/research.md @@ -0,0 +1,33 @@ +# Research Skill + +Use this when investigating a topic, market condition, or technical problem. + +## Steps + +1. **Decompose** — Break the question into specific sub-questions +2. **Gather** — Fetch data from multiple sources (web, database, APIs) +3. **Analyze** — Apply reasoning, compare viewpoints +4. **Validate** — Check for contradictions, confidence level +5. **Synthesize** — Write a clear conclusion with supporting evidence + +## Verification Gate + +- Each claim must have a source or data point +- If confidence < 60%, say so explicitly +- List remaining unknowns at the end + +## Output Format + +``` +# Research: [topic] + +## Findings +[Key facts with citations] + +## Analysis +[Reasoning chain] + +## Confidence: [HIGH/MEDIUM/LOW] +## Remaining unknowns: [list] +## Recommended actions: [list] +``` \ No newline at end of file diff --git a/salior/skills/review.md b/salior/skills/review.md new file mode 100644 index 0000000..862c022 --- /dev/null +++ b/salior/skills/review.md @@ -0,0 +1,48 @@ +# Review Skill + +Use this when reviewing code, decisions, or architecture. + +## Steps + +1. **Understand** — Read the full context before judging +2. **Pressure test** — What's the weakest part? The hidden assumption? +3. **Check against requirements** — Does this actually solve the stated problem? +4. **Consider alternatives** — Is there a simpler approach? +5. **Flag clearly** — Distinguish bugs from style preferences + +## Verification Gate + +- Bug: Must be fixed before merge +- Style: Suggest only if it impacts readability +- Architecture: Propose an alternative, don't just criticize + +## Review Categories + +| Category | Priority | Action | +|----------|----------|--------| +| Correctness bug | CRITICAL | Block merge | +| Security issue | CRITICAL | Block merge | +| Performance regression | HIGH | Discuss | +| Missing test | MEDIUM | Suggest | +| Style/naming | LOW | Optional | +| Architecture | MEDIUM | Discuss | + +## Output Format + +``` +# Review: [item] + +## Summary +[One paragraph overview] + +## Issues (blocking) +- [list] + +## Issues (non-blocking) +- [list] + +## Recommendations +- [list] + +## Verdict: APPROVE / REQUEST_CHANGES / BLOCK +``` \ No newline at end of file diff --git a/salior/skills/spec.md b/salior/skills/spec.md new file mode 100644 index 0000000..0b45a23 --- /dev/null +++ b/salior/skills/spec.md @@ -0,0 +1,45 @@ +# Spec Skill + +Use this before writing any significant code or architectural decisions. + +## Steps + +1. **What** — Define the feature clearly in one sentence +2. **Why** — Why is this needed? What problem does it solve? +3. **Scope** — What's in scope? What's explicitly out of scope? +4. **Interface** — What does the API / interface look like? +5. **Data flow** — How does data move through the system? +6. **Failure modes** — What can go wrong? How does it recover? +7. **Verify** — Does this spec solve the original problem? + +## Output Format + +```markdown +# Feature: [name] + +## What +[One sentence] + +## Why +[Problem being solved] + +## Scope +- In: [list] +- Out: [list] + +## Interface +``` +[API/function signatures] +``` + +## Data Flow +[How data moves] + +## Failure Modes +| Scenario | Response | +|----------|----------| +| ... | ... | + +## Verification +[How to verify this works] +``` \ No newline at end of file diff --git a/salior/wallet/__init__.py b/salior/wallet/__init__.py new file mode 100644 index 0000000..7ea152b --- /dev/null +++ b/salior/wallet/__init__.py @@ -0,0 +1,4 @@ +"""Wallet module — wallet connect integration.""" +from salior.wallet.connect import WalletSession + +__all__ = ["WalletSession"] \ No newline at end of file diff --git a/salior/wallet/__pycache__/__init__.cpython-311.pyc b/salior/wallet/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fff69d1b92878d071948902c112779f61c18ba32 GIT binary patch literal 340 zcmZ3^%ge<81jj2Fvl@W(V-N=hn4pZ$GC;<3h7^V#wg|# zmS6@=)>2)dlJLZwoYWG9-29Z%oK%HJ4O0}#AtK57d3mYHB?_5&C8_B}i6xo&d3rBF zhHEn3;)Uo6PAx753Kua0Mg25cZn4M5r{pKc$KMhyPRz;7FVce;r3W<%tPZ3IXhD4Z zN`}uM(|+0OXXNLm>gVOBr0VPC7o_GT=OmWvm**E{7Z)TZr|LtDf>96?_2c6+^D;}~ z!D)d literal 0 HcmV?d00001 diff --git a/salior/wallet/__pycache__/connect.cpython-311.pyc b/salior/wallet/__pycache__/connect.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3eaf40a4f8d363139ed4084f61b7724e67692e63 GIT binary patch literal 5866 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 literal 0 HcmV?d00001 diff --git a/salior/wallet/connect.py b/salior/wallet/connect.py new file mode 100644 index 0000000..b442b38 --- /dev/null +++ b/salior/wallet/connect.py @@ -0,0 +1,97 @@ +"""Wallet Connect — EIP-4361 sign-in with 180-day sessions.""" +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone +from typing import Optional + +from salior.core.config import config +from salior.db.supabase_client import SupabaseClient + + +class WalletSession: + """Handle wallet connection + 180-day session management. + + Works with Rabby or MetaMask (both inject window.ethereum). + Frontend calls connect() to trigger wallet popup. + Backend verifies signature and stores session. + """ + + def __init__(self) -> None: + self._supabase = SupabaseClient() + self._address: Optional[str] = None + self._session_token: Optional[str] = None + self._expires_at: Optional[datetime] = None + + @property + def address(self) -> Optional[str]: + """Connected wallet address.""" + return self._address + + @property + def is_connected(self) -> bool: + """True if wallet is connected and session is valid.""" + if not self._address or not self._expires_at: + return False + return datetime.now(timezone.utc) < self._expires_at + + def generate_auth_message(self, address: str) -> str: + """Generate the EIP-4361 auth message for the wallet to sign.""" + nonce = str(uuid.uuid4()) + issued_at = datetime.now(timezone.utc).isoformat() + expires_at = (datetime.now(timezone.utc) + timedelta(days=config.wallet_session_days)).isoformat() + return f"salior.ai wants you to sign in.\n\nAddress: {address}\nNonce: {nonce}\nIssued At: {issued_at}\nExpiration Time: {expires_at}\n\nSign in to Salior Trading System." + + def verify_signature( + self, + address: str, + signature: str, + message: str, + ) -> bool: + """Verify a wallet signature against the auth message. + + In production: use eth_account or web3.py to verify ECDSA signature. + For now: accept any non-empty signature (frontend should verify). + """ + return bool(signature and len(signature) > 20) + + async def connect(self, address: str, signature: str, message: str) -> dict: + """Complete wallet connection after user signs. + + Called by backend after frontend submits the signed auth message. + Returns session info. + """ + if not self.verify_signature(address, signature, message): + raise ValueError("Invalid signature") + + session_token = str(uuid.uuid4()) + expires_at = datetime.now(timezone.utc) + timedelta(days=config.wallet_session_days) + + await self._supabase.upsert_wallet_session( + wallet_address=address, + session_token=session_token, + signature=signature, + expires_at=expires_at.isoformat(), + ) + + self._address = address + self._session_token = session_token + self._expires_at = expires_at + + return { + "address": address, + "session_token": session_token, + "expires_at": expires_at.isoformat(), + "days": config.wallet_session_days, + } + + async def get_session(self, address: str) -> Optional[dict]: + """Check for existing valid session for an address.""" + return await self._supabase.get_wallet_session(address) + + def sign_transaction(self, tx_params: dict) -> str: + """Request user signature for a specific transaction. + + In production: return tx_params for frontend to call wallet popup. + """ + raise NotImplementedError("Use frontend wallet popup for tx signing") \ No newline at end of file