diff --git a/.idea/ai.iml b/.idea/ai.iml index 69bfc2fc..bb280a92 100644 --- a/.idea/ai.iml +++ b/.idea/ai.iml @@ -16,6 +16,7 @@ + diff --git a/bin/dev b/bin/dev index 78ed5b81..b545e84c 100755 --- a/bin/dev +++ b/bin/dev @@ -151,14 +151,12 @@ rebuild_images() { if [ "$service" == "all" ] || [ "$service" == "gateway" ]; then echo -e "${GREEN}→${NC} Building gateway..." GATEWAY_TAG=$(build_and_get_tag gateway) || exit 1 - docker tag "dexorder/ai-gateway:$GATEWAY_TAG" "dexorder/gateway:$GATEWAY_TAG" fi # Build lifecycle-sidecar (Go binary) if [ "$service" == "all" ] || [ "$service" == "lifecycle-sidecar" ] || [ "$service" == "sidecar" ]; then echo -e "${GREEN}→${NC} Building lifecycle-sidecar..." SIDECAR_TAG=$(build_and_get_tag lifecycle-sidecar) || exit 1 - docker tag "dexorder/ai-lifecycle-sidecar:$SIDECAR_TAG" "lifecycle-sidecar:$SIDECAR_TAG" fi # Build web (Vue.js application) @@ -399,7 +397,7 @@ create_dev_user() { 'http://localhost:8080/mcp', '{\"maxIndicators\":50,\"maxStrategies\":20,\"maxBacktestDays\":365,\"realtimeData\":true,\"customExecutors\":true,\"apiAccess\":true}', '{\"maxConcurrentSessions\":5,\"maxMessagesPerDay\":1000,\"maxTokensPerMessage\":8192,\"rateLimitPerMinute\":60}', - '{\"provider\":\"anthropic\",\"model\":\"claude-3-5-sonnet-20241022\",\"temperature\":0.7}' + '{\"provider\":\"anthropic\",\"model\":\"claude-sonnet-4-6\",\"temperature\":0.7}' ) ON CONFLICT (user_id) DO UPDATE SET license_type = EXCLUDED.license_type, @@ -651,6 +649,9 @@ case "$COMMAND" in kubectl wait --for=delete pod -l app=qdrant --timeout=60s 2>/dev/null || true # Now delete PVCs delete_pvcs all + # Delete dexorder-agents namespace + echo -e "${GREEN}→${NC} Deleting dexorder-agents namespace..." + kubectl delete namespace dexorder-agents 2>/dev/null || true minikube stop echo -e "${GREEN}✓ Minikube stopped and PVCs deleted${NC}" echo -e "${YELLOW}Tip: Use 'bin/dev stop --keep-data' to preserve PVCs${NC}" diff --git a/client-py/Dockerfile b/client-py/Dockerfile index 2eb18c24..37714c61 100644 --- a/client-py/Dockerfile +++ b/client-py/Dockerfile @@ -68,6 +68,9 @@ ENV PYTHONUNBUFFERED=1 \ ZMQ_XPUB_PORT=5570 \ ZMQ_GATEWAY_ENDPOINT=tcp://gateway:5571 \ MCP_SERVER_NAME=dexorder-user \ + MCP_TRANSPORT=sse \ + MCP_HTTP_PORT=3000 \ + MCP_HTTP_HOST=0.0.0.0 \ IDLE_TIMEOUT_MINUTES=15 \ ENABLE_IDLE_SHUTDOWN=true diff --git a/client-py/dexorder/__init__.py b/client-py/dexorder/__init__.py index cdf13a8d..1a04c556 100644 --- a/client-py/dexorder/__init__.py +++ b/client-py/dexorder/__init__.py @@ -11,6 +11,10 @@ Provides high-level APIs for: __version__ = "0.1.0" +# Suppress the protobuf warning - it's handled at import time +import warnings +warnings.filterwarnings('ignore', message='Warning: Protobuf files not found') + from .ohlc_client import OHLCClient from .iceberg_client import IcebergClient from .history_client import HistoryClient diff --git a/client-py/dexorder/api/category_tools.py b/client-py/dexorder/api/category_tools.py new file mode 100644 index 00000000..796fefa5 --- /dev/null +++ b/client-py/dexorder/api/category_tools.py @@ -0,0 +1,559 @@ +""" +Category-based File Management Tools for MCP Server + +Provides write/edit/read/list tools for categorized Python scripts with automatic +directory structure, metadata management, and validation harnesses. + +Categories: +- strategy: Trading strategies with specific data feed requirements +- indicator: Technical indicators with configurable parameters +- research: Research scripts with pyplot visualization support + +File Structure: +{DATA_DIR}/{category}/{sanitized_name}/ + - implementation.py # Python implementation + - metadata.json # Category-specific metadata (includes name and description) + +After write/edit operations, a category-specific test harness runs to validate +the code and capture errors/output for agent feedback. +""" + +import json +import logging +import re +import subprocess +import sys +from dataclasses import dataclass, asdict +from enum import Enum +from pathlib import Path +from typing import Any, Optional + +log = logging.getLogger(__name__) + + +# ============================================================================= +# Categories and Metadata +# ============================================================================= + +class Category(str, Enum): + """Supported file categories.""" + STRATEGY = "strategy" + INDICATOR = "indicator" + RESEARCH = "research" + + +@dataclass +class BaseMetadata: + """Base metadata for all categories.""" + name: str # Display name (can have special chars) + description: str # LLM-generated description + + +@dataclass +class StrategyMetadata(BaseMetadata): + """Metadata for trading strategies.""" + data_feeds: list[str] = None # Required data feeds (e.g., ["BTC/USD", "ETH/USD"]) + + def __post_init__(self): + if self.data_feeds is None: + self.data_feeds = [] + + +@dataclass +class IndicatorMetadata(BaseMetadata): + """Metadata for technical indicators.""" + default_length: int = 14 # Default period/length parameter + + +@dataclass +class ResearchMetadata(BaseMetadata): + """Metadata for research scripts.""" + # Future: data_sources, dependencies, etc. + pass + + +# Metadata class registry +METADATA_CLASSES = { + Category.STRATEGY: StrategyMetadata, + Category.INDICATOR: IndicatorMetadata, + Category.RESEARCH: ResearchMetadata, +} + + +# ============================================================================= +# Utilities +# ============================================================================= + +def sanitize_name(name: str) -> str: + """ + Sanitize a name for use as a directory name. + + Converts special characters to underscores and preserves alphanumerics. + Examples: + "Tim's 5/13 EMA+" -> "Tims_5_13_EMA" + "My Strategy (v2)" -> "My_Strategy_v2" + """ + # Replace non-alphanumeric chars (except spaces/hyphens) with underscores + sanitized = re.sub(r'[^\w\s\-]', '_', name) + # Replace spaces and hyphens with underscores + sanitized = re.sub(r'[\s\-]+', '_', sanitized) + # Remove duplicate underscores + sanitized = re.sub(r'_+', '_', sanitized) + # Strip leading/trailing underscores + sanitized = sanitized.strip('_') + return sanitized + + +def get_category_path(data_dir: Path, category: Category, name: str) -> Path: + """ + Get the directory path for a category item. + + Args: + data_dir: Base data directory + category: Category enum + name: Display name (will be sanitized) + + Returns: + Path to the category item directory + """ + safe_name = sanitize_name(name) + return data_dir / category.value / safe_name + + +# ============================================================================= +# Category File Manager +# ============================================================================= + +class CategoryFileManager: + """ + Manages category-based file operations with validation. + """ + + def __init__(self, data_dir: Path): + self.data_dir = data_dir + + # Ensure category directories exist + for category in Category: + (data_dir / category.value).mkdir(parents=True, exist_ok=True) + + def write( + self, + category: str, + name: str, + description: str, + code: str, + metadata: Optional[dict] = None + ) -> dict[str, Any]: + """ + Write a new category file with metadata. + + Args: + category: Category name (strategy, indicator, research) + name: Display name for the item + description: LLM-generated description (required) + code: Python implementation code + metadata: Additional category-specific metadata fields + + Returns: + dict with: + - success: bool + - path: str - path to the implementation file + - validation: dict - results from test harness + - error: str (if any) + """ + try: + cat = Category(category) + except ValueError: + return { + "success": False, + "error": f"Invalid category '{category}'. Must be one of: {', '.join(c.value for c in Category)}" + } + + # Get item directory + item_dir = get_category_path(self.data_dir, cat, name) + item_dir.mkdir(parents=True, exist_ok=True) + + # Write implementation + impl_path = item_dir / "implementation.py" + try: + impl_path.write_text(code) + log.info(f"Wrote {cat.value} implementation: {impl_path}") + except Exception as e: + return {"success": False, "error": f"Failed to write implementation: {e}"} + + # Build metadata + meta_dict = metadata or {} + meta_dict["name"] = name + meta_dict["description"] = description + + # Validate and write metadata + try: + metadata_class = METADATA_CLASSES[cat] + meta_obj = metadata_class(**meta_dict) + validated_meta = asdict(meta_obj) + + meta_path = item_dir / "metadata.json" + meta_path.write_text(json.dumps(validated_meta, indent=2)) + log.info(f"Wrote metadata: {meta_path}") + except Exception as e: + return {"success": False, "error": f"Failed to write metadata: {e}"} + + # Run validation harness + validation = self._validate(cat, item_dir) + + return { + "success": validation["success"], + "path": str(impl_path), + "validation": validation, + } + + def edit( + self, + category: str, + name: str, + code: Optional[str] = None, + description: Optional[str] = None, + metadata: Optional[dict] = None + ) -> dict[str, Any]: + """ + Edit an existing category file. + + Args: + category: Category name + name: Display name for the item + code: Python implementation code (optional, omit to keep existing) + description: Updated description (optional, omit to keep existing) + metadata: Additional metadata updates (optional) + + Returns: + dict with: + - success: bool + - path: str - path to the implementation file + - validation: dict - results from test harness (if code updated) + - error: str (if any) + """ + try: + cat = Category(category) + except ValueError: + return {"success": False, "error": f"Invalid category '{category}'"} + + item_dir = get_category_path(self.data_dir, cat, name) + + if not item_dir.exists(): + return {"success": False, "error": f"Item '{name}' does not exist in category '{category}'"} + + impl_path = item_dir / "implementation.py" + meta_path = item_dir / "metadata.json" + + # Load existing metadata + try: + existing_meta = {} + if meta_path.exists(): + existing_meta = json.loads(meta_path.read_text()) + except Exception as e: + return {"success": False, "error": f"Failed to read existing metadata: {e}"} + + # Update code if provided + if code is not None: + try: + impl_path.write_text(code) + log.info(f"Updated {cat.value} implementation: {impl_path}") + except Exception as e: + return {"success": False, "error": f"Failed to write implementation: {e}"} + + # Update metadata + updated_meta = existing_meta.copy() + if description is not None: + updated_meta["description"] = description + if metadata: + updated_meta.update(metadata) + + # Validate and write metadata + try: + metadata_class = METADATA_CLASSES[cat] + meta_obj = metadata_class(**updated_meta) + validated_meta = asdict(meta_obj) + + meta_path.write_text(json.dumps(validated_meta, indent=2)) + log.info(f"Updated metadata: {meta_path}") + except Exception as e: + return {"success": False, "error": f"Failed to write metadata: {e}"} + + # Run validation harness if code was updated + validation = None + if code is not None: + validation = self._validate(cat, item_dir) + + result = { + "success": True, + "path": str(impl_path), + } + if validation: + result["validation"] = validation + result["success"] = validation["success"] + + return result + + def read( + self, + category: str, + name: str + ) -> dict[str, Any]: + """ + Read a category file and its metadata. + + Returns: + dict with: + - exists: bool + - code: str - implementation code + - metadata: dict - metadata + - error: str (if any) + """ + try: + cat = Category(category) + except ValueError: + return {"exists": False, "error": f"Invalid category '{category}'"} + + item_dir = get_category_path(self.data_dir, cat, name) + + if not item_dir.exists(): + return {"exists": False} + + impl_path = item_dir / "implementation.py" + meta_path = item_dir / "metadata.json" + + try: + code = impl_path.read_text() if impl_path.exists() else "" + metadata = {} + if meta_path.exists(): + metadata = json.loads(meta_path.read_text()) + + return { + "exists": True, + "code": code, + "metadata": metadata, + } + except Exception as e: + return {"exists": False, "error": str(e)} + + def list_items(self, category: str) -> dict[str, Any]: + """ + List all items in a category with names and descriptions. + + Returns: + dict with: + - items: list of dicts with name, description, and full metadata + - error: str (if any) + """ + try: + cat = Category(category) + except ValueError: + return {"error": f"Invalid category '{category}'"} + + cat_dir = self.data_dir / cat.value + items = [] + + for item_dir in cat_dir.iterdir(): + if not item_dir.is_dir(): + continue + + meta_path = item_dir / "metadata.json" + if meta_path.exists(): + try: + metadata = json.loads(meta_path.read_text()) + items.append({ + "name": metadata.get("name", item_dir.name), + "description": metadata.get("description", ""), + "metadata": metadata, + }) + except Exception as e: + log.error(f"Failed to read metadata for {item_dir}: {e}") + + return {"items": items} + + def _validate(self, category: Category, item_dir: Path) -> dict[str, Any]: + """ + Run category-specific validation harness. + + Returns: + dict with: + - success: bool + - output: str - stdout/stderr from validation + - images: list[dict] - base64-encoded images (for research) + - error: str (if any) + """ + impl_path = item_dir / "implementation.py" + + if category == Category.STRATEGY: + return self._validate_strategy(impl_path) + elif category == Category.INDICATOR: + return self._validate_indicator(impl_path) + elif category == Category.RESEARCH: + return self._validate_research(impl_path, item_dir) + else: + return {"success": False, "error": f"No validator for category {category}"} + + def _validate_strategy(self, impl_path: Path) -> dict[str, Any]: + """ + Validate a strategy implementation. + + Runs basic syntax check and imports. + """ + try: + result = subprocess.run( + [sys.executable, "-m", "py_compile", str(impl_path)], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + return { + "success": True, + "output": "Strategy syntax valid", + } + else: + return { + "success": False, + "output": result.stderr, + "error": "Syntax error in strategy", + } + except subprocess.TimeoutExpired: + return {"success": False, "error": "Validation timeout"} + except Exception as e: + return {"success": False, "error": f"Validation failed: {e}"} + + def _validate_indicator(self, impl_path: Path) -> dict[str, Any]: + """ + Validate an indicator implementation. + + Runs basic syntax check and imports. + """ + try: + result = subprocess.run( + [sys.executable, "-m", "py_compile", str(impl_path)], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + return { + "success": True, + "output": "Indicator syntax valid", + } + else: + return { + "success": False, + "output": result.stderr, + "error": "Syntax error in indicator", + } + except subprocess.TimeoutExpired: + return {"success": False, "error": "Validation timeout"} + except Exception as e: + return {"success": False, "error": f"Validation failed: {e}"} + + def _validate_research(self, impl_path: Path, item_dir: Path) -> dict[str, Any]: + """ + Validate a research script. + + Runs the script and captures output + pyplot images. + """ + # Create a wrapper script that captures pyplot figures + wrapper_code = f""" +import sys +import io +import base64 +import json +from pathlib import Path +import matplotlib +matplotlib.use('Agg') # Non-interactive backend +import matplotlib.pyplot as plt + +# Capture stdout +old_stdout = sys.stdout +sys.stdout = io.StringIO() + +# Run user code +user_code_path = Path(r"{impl_path}") +try: + exec(compile(user_code_path.read_text(), user_code_path, 'exec'), {{}}) +except Exception as e: + print(f"ERROR: {{e}}", file=sys.stderr) + sys.exit(1) + +# Get stdout +output = sys.stdout.getvalue() +sys.stdout = old_stdout + +# Capture all pyplot figures as base64 PNGs +images = [] +for fig_num in plt.get_fignums(): + fig = plt.figure(fig_num) + buf = io.BytesIO() + fig.savefig(buf, format='png', dpi=100, bbox_inches='tight') + buf.seek(0) + img_b64 = base64.b64encode(buf.read()).decode('utf-8') + images.append({{"format": "png", "data": img_b64}}) + buf.close() + +plt.close('all') + +# Output results as JSON +result = {{ + "output": output, + "images": images, +}} +print(json.dumps(result)) +""" + + try: + result = subprocess.run( + [sys.executable, "-c", wrapper_code], + capture_output=True, + text=True, + timeout=30, + cwd=str(item_dir), + ) + + if result.returncode == 0: + try: + data = json.loads(result.stdout) + return { + "success": True, + "output": data["output"], + "images": data["images"], + } + except json.JSONDecodeError: + return { + "success": True, + "output": result.stdout, + "images": [], + } + else: + return { + "success": False, + "output": result.stderr, + "error": "Research script execution failed", + } + except subprocess.TimeoutExpired: + return {"success": False, "error": "Research script timeout"} + except Exception as e: + return {"success": False, "error": f"Validation failed: {e}"} + + +# ============================================================================= +# Global Manager Instance +# ============================================================================= + +_category_manager: Optional[CategoryFileManager] = None + + +def get_category_manager(data_dir: Optional[Path] = None) -> CategoryFileManager: + """Get or create the global category file manager.""" + global _category_manager + if _category_manager is None: + if data_dir is None: + raise ValueError("data_dir required for first initialization") + _category_manager = CategoryFileManager(data_dir) + return _category_manager diff --git a/client-py/dexorder/history_client.py b/client-py/dexorder/history_client.py index 5c30ba69..d0bb5047 100644 --- a/client-py/dexorder/history_client.py +++ b/client-py/dexorder/history_client.py @@ -16,12 +16,11 @@ import struct import sys import os -# Import protobuf messages (assuming they're generated in ../protobuf) -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../protobuf')) +# Import protobuf messages from generated package try: - from ingestor_pb2 import SubmitHistoricalRequest, SubmitResponse, HistoryReadyNotification + from dexorder.generated.ingestor_pb2 import SubmitHistoricalRequest, SubmitResponse, HistoryReadyNotification except ImportError: - print("Warning: Protobuf files not found. Run: protoc -I ../protobuf --python_out=../protobuf ../protobuf/*.proto") + print("Warning: Protobuf files not found. Run: protoc --python_out=dexorder/generated --proto_path=protobuf protobuf/*.proto") raise diff --git a/client-py/dexorder/lifecycle_manager.py b/client-py/dexorder/lifecycle_manager.py index 882d865a..a1ec96c3 100644 --- a/client-py/dexorder/lifecycle_manager.py +++ b/client-py/dexorder/lifecycle_manager.py @@ -205,26 +205,36 @@ class LifecycleManager: _lifecycle_manager: Optional[LifecycleManager] = None -def get_lifecycle_manager() -> LifecycleManager: +def get_lifecycle_manager( + idle_timeout_minutes: Optional[int] = None, + enable_shutdown: Optional[bool] = None, +) -> LifecycleManager: """Get or create the global lifecycle manager instance.""" global _lifecycle_manager if _lifecycle_manager is None: - # Load configuration from environment - idle_timeout = int(os.environ.get("IDLE_TIMEOUT_MINUTES", "15")) + # Load configuration from environment or use provided values + idle_timeout = idle_timeout_minutes if idle_timeout_minutes is not None else int(os.environ.get("IDLE_TIMEOUT_MINUTES", "15")) check_interval = int(os.environ.get("IDLE_CHECK_INTERVAL_SECONDS", "60")) - enable_shutdown = os.environ.get("ENABLE_IDLE_SHUTDOWN", "true").lower() == "true" + enable = enable_shutdown if enable_shutdown is not None else os.environ.get("ENABLE_IDLE_SHUTDOWN", "true").lower() == "true" _lifecycle_manager = LifecycleManager( idle_timeout_minutes=idle_timeout, check_interval_seconds=check_interval, - enable_shutdown=enable_shutdown, + enable_shutdown=enable, ) return _lifecycle_manager -async def start_lifecycle_manager() -> LifecycleManager: +async def start_lifecycle_manager( + user_id: Optional[str] = None, + idle_timeout_minutes: Optional[int] = None, + enable_idle_shutdown: Optional[bool] = None, +) -> LifecycleManager: """Initialize and start the lifecycle manager.""" - manager = get_lifecycle_manager() + manager = get_lifecycle_manager( + idle_timeout_minutes=idle_timeout_minutes, + enable_shutdown=enable_idle_shutdown, + ) manager.setup_signal_handlers() await manager.start() return manager diff --git a/client-py/main.py b/client-py/main.py index 73b977d3..1df1202c 100644 --- a/client-py/main.py +++ b/client-py/main.py @@ -18,12 +18,20 @@ from pathlib import Path from typing import Optional import yaml +import uvicorn from mcp.server import Server from mcp.server.stdio import stdio_server +from mcp.server.sse import SseServerTransport +from starlette.applications import Starlette +from starlette.routing import Route, Mount +from starlette.requests import Request +from starlette.responses import Response +from sse_starlette import EventSourceResponse from dexorder import EventPublisher, start_lifecycle_manager, get_lifecycle_manager from dexorder.events import EventType, UserEvent, DeliverySpec from dexorder.api.workspace_tools import get_workspace_store +from dexorder.api.category_tools import get_category_manager # ============================================================================= @@ -68,6 +76,9 @@ class Config: # MCP server settings self.mcp_server_name: str = os.getenv("MCP_SERVER_NAME", "dexorder-user") + self.mcp_transport: str = os.getenv("MCP_TRANSPORT", "sse") # "stdio" or "sse" + self.mcp_http_port: int = int(os.getenv("MCP_HTTP_PORT", "3000")) + self.mcp_http_host: str = os.getenv("MCP_HTTP_HOST", "0.0.0.0") # Lifecycle settings self.idle_timeout_minutes: int = int(os.getenv("IDLE_TIMEOUT_MINUTES", "15")) @@ -134,6 +145,10 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server workspace_store = get_workspace_store(config.workspace_dir) logging.info(f"Workspace store initialized at {config.workspace_dir}") + # Initialize category file manager + category_manager = get_category_manager(config.data_dir) + logging.info(f"Category manager initialized at {config.data_dir}") + @server.list_resources() async def list_resources(): """List available resources""" @@ -143,6 +158,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server "name": "Hello World", "description": "A simple hello world resource", "mimeType": "text/plain", + "annotations": { + "agent_accessible": True, # Available to agent for ad-hoc queries + } } ] @@ -170,7 +188,7 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server @server.list_tools() async def list_tools(): - """List available tools including workspace tools""" + """List available tools including workspace and category tools""" return [ { "name": "workspace_read", @@ -184,6 +202,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server } }, "required": ["store_name"] + }, + "annotations": { + "agent_accessible": True, # Agent can read workspace stores } }, { @@ -201,6 +222,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server } }, "required": ["store_name", "data"] + }, + "annotations": { + "agent_accessible": True, # Agent can write workspace stores } }, { @@ -228,13 +252,124 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server } }, "required": ["store_name", "patch"] + }, + "annotations": { + "agent_accessible": True, # Agent can patch workspace stores + } + }, + { + "name": "category_write", + "description": "Write a new strategy, indicator, or research script with validation", + "inputSchema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["strategy", "indicator", "research"], + "description": "Category of the script" + }, + "name": { + "type": "string", + "description": "Display name (can contain special characters)" + }, + "description": { + "type": "string", + "description": "LLM-generated description of what this does (required)" + }, + "code": { + "type": "string", + "description": "Python implementation code" + }, + "metadata": { + "type": "object", + "description": "Optional category-specific metadata (e.g., default_length for indicators, data_feeds for strategies)" + } + }, + "required": ["category", "name", "description", "code"] + }, + "annotations": { + "agent_accessible": True, + } + }, + { + "name": "category_edit", + "description": "Edit an existing category script (updates code, description, or metadata)", + "inputSchema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["strategy", "indicator", "research"], + "description": "Category of the script" + }, + "name": { + "type": "string", + "description": "Display name of the existing item" + }, + "code": { + "type": "string", + "description": "Updated Python code (optional, omit to keep existing)" + }, + "description": { + "type": "string", + "description": "Updated description (optional, omit to keep existing)" + }, + "metadata": { + "type": "object", + "description": "Updated metadata fields (optional)" + } + }, + "required": ["category", "name"] + }, + "annotations": { + "agent_accessible": True, + } + }, + { + "name": "category_read", + "description": "Read a category script and its metadata", + "inputSchema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["strategy", "indicator", "research"], + "description": "Category of the script" + }, + "name": { + "type": "string", + "description": "Display name of the item" + } + }, + "required": ["category", "name"] + }, + "annotations": { + "agent_accessible": True, + } + }, + { + "name": "category_list", + "description": "List all items in a category with names and descriptions", + "inputSchema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["strategy", "indicator", "research"], + "description": "Category to list" + } + }, + "required": ["category"] + }, + "annotations": { + "agent_accessible": True, } } ] @server.call_tool() async def handle_tool_call(name: str, arguments: dict): - """Handle tool calls including workspace tools""" + """Handle tool calls including workspace and category tools""" if name == "workspace_read": return workspace_store.read(arguments.get("store_name", "")) elif name == "workspace_write": @@ -247,13 +382,78 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server arguments.get("store_name", ""), arguments.get("patch", []) ) + elif name == "category_write": + return category_manager.write( + category=arguments.get("category", ""), + name=arguments.get("name", ""), + description=arguments.get("description", ""), + code=arguments.get("code", ""), + metadata=arguments.get("metadata") + ) + elif name == "category_edit": + return category_manager.edit( + category=arguments.get("category", ""), + name=arguments.get("name", ""), + code=arguments.get("code"), + description=arguments.get("description"), + metadata=arguments.get("metadata") + ) + elif name == "category_read": + return category_manager.read( + category=arguments.get("category", ""), + name=arguments.get("name", "") + ) + elif name == "category_list": + return category_manager.list_items( + category=arguments.get("category", "") + ) else: raise ValueError(f"Unknown tool: {name}") - logging.info(f"MCP server '{config.mcp_server_name}' created with workspace tools") + logging.info(f"MCP server '{config.mcp_server_name}' created with workspace and category tools") return server +# ============================================================================= +# SSE Transport Setup +# ============================================================================= + +def create_sse_app(mcp_server: Server) -> Starlette: + """Create Starlette app with SSE endpoint for MCP""" + + # Create SSE transport instance + sse = SseServerTransport("/messages/") + + async def handle_sse(request: Request) -> Response: + """Handle SSE connections for MCP""" + async with sse.connect_sse( + request.scope, request.receive, request._send + ) as streams: + await mcp_server.run( + streams[0], + streams[1], + mcp_server.create_initialization_options() + ) + return Response() + + async def handle_health(request: Request) -> Response: + """Health check endpoint for k8s probes and gateway readiness checks""" + return Response( + content='{"status":"ok"}', + media_type="application/json" + ) + + app = Starlette( + routes=[ + Route("/sse", handle_sse), + Mount("/messages/", app=sse.handle_post_message), + Route("/health", handle_health), + ] + ) + + return app + + # ============================================================================= # Main Application # ============================================================================= @@ -347,17 +547,34 @@ class UserContainer: logging.info("User container stopped") async def run(self) -> None: - """Run the MCP server via stdio""" + """Run the MCP server with configured transport""" await self.start() try: - # Run MCP server on stdio - async with stdio_server() as (read_stream, write_stream): - await self.mcp_server.run( - read_stream, - write_stream, - self.mcp_server.create_initialization_options() + if self.config.mcp_transport == "stdio": + # Run MCP server on stdio (for dev/testing) + logging.info("Starting MCP server with stdio transport") + async with stdio_server() as (read_stream, write_stream): + await self.mcp_server.run( + read_stream, + write_stream, + self.mcp_server.create_initialization_options() + ) + elif self.config.mcp_transport == "sse": + # Run MCP server via HTTP/SSE (for production) + logging.info(f"Starting MCP server with SSE transport on {self.config.mcp_http_host}:{self.config.mcp_http_port}") + app = create_sse_app(self.mcp_server) + config = uvicorn.Config( + app, + host=self.config.mcp_http_host, + port=self.config.mcp_http_port, + log_level=os.getenv("LOG_LEVEL", "info").lower(), + access_log=True, ) + server = uvicorn.Server(config) + await server.serve() + else: + raise ValueError(f"Unknown MCP transport: {self.config.mcp_transport}") finally: await self.stop() diff --git a/client-py/setup.py b/client-py/setup.py index 3d5a0176..83da81ca 100644 --- a/client-py/setup.py +++ b/client-py/setup.py @@ -14,8 +14,12 @@ setup( "protobuf>=4.25.0", "pyyaml>=6.0", "aiofiles>=23.0.0", - "mcp>=0.9.0", + "mcp>=1.0.0", "jsonpatch>=1.33", + "starlette>=0.27.0", + "uvicorn>=0.27.0", + "sse-starlette>=1.6.0", + "matplotlib>=3.7.0", ], extras_require={ "dev": [ diff --git a/deploy/k8s/base/agent-quotas.yaml b/deploy/k8s/base/agent-quotas.yaml index 660d8dbf..2ce28dfc 100644 --- a/deploy/k8s/base/agent-quotas.yaml +++ b/deploy/k8s/base/agent-quotas.yaml @@ -22,8 +22,8 @@ spec: memory: "2Gi" cpu: "2000m" min: - memory: "64Mi" - cpu: "50m" + memory: "32Mi" + cpu: "10m" # PVC size limits - type: PersistentVolumeClaim max: diff --git a/deploy/k8s/dev/admission-policy-patch.yaml b/deploy/k8s/dev/admission-policy-patch.yaml index 0c702efb..79c9ed12 100644 --- a/deploy/k8s/dev/admission-policy-patch.yaml +++ b/deploy/k8s/dev/admission-policy-patch.yaml @@ -25,8 +25,7 @@ spec: c.image.startsWith('localhost:5000/dexorder/agent') || c.image.startsWith('dexorder/agent') || c.image.startsWith('dexorder/ai-client-py') || - c.image.startsWith('ai-client-py') || - c.image.startsWith('lifecycle-sidecar')) + c.image.startsWith('dexorder/ai-lifecycle-sidecar')) message: "Only approved dexorder agent images are allowed" reason: Forbidden diff --git a/deploy/k8s/dev/configs/gateway-config.yaml b/deploy/k8s/dev/configs/gateway-config.yaml index 79a40e0c..a6dc251d 100644 --- a/deploy/k8s/dev/configs/gateway-config.yaml +++ b/deploy/k8s/dev/configs/gateway-config.yaml @@ -25,15 +25,40 @@ data: # Default model (if user has no preference) defaults: model_provider: anthropic - model: claude-3-5-sonnet-20241022 + model: claude-sonnet-4-6 + + # License tier model configuration + license_models: + # Free tier models + free: + default: claude-haiku-4-5-20251001 + cost_optimized: claude-haiku-4-5-20251001 + complex: claude-haiku-4-5-20251001 + allowed_models: + - claude-haiku-4-5-20251001 + + # Pro tier models + pro: + default: claude-sonnet-4-6 + cost_optimized: claude-haiku-4-5-20251001 + complex: claude-sonnet-4-6 + blocked_models: + - claude-opus-4-6 + + # Enterprise tier models + enterprise: + default: claude-sonnet-4-6 + cost_optimized: claude-haiku-4-5-20251001 + complex: claude-opus-4-6 # Kubernetes configuration kubernetes: namespace: dexorder-agents in_cluster: true - agent_image: ai-client-py:latest - sidecar_image: lifecycle-sidecar:latest + agent_image: dexorder/ai-client-py:latest + sidecar_image: dexorder/ai-lifecycle-sidecar:latest storage_class: standard + image_pull_policy: Never # For minikube dev - use local images # DragonflyDB (Redis-compatible, for hot storage and session management) redis: diff --git a/deploy/k8s/dev/gateway-dev-patch.yaml b/deploy/k8s/dev/gateway-dev-patch.yaml index af73df5c..6756bbcd 100644 --- a/deploy/k8s/dev/gateway-dev-patch.yaml +++ b/deploy/k8s/dev/gateway-dev-patch.yaml @@ -8,7 +8,7 @@ spec: spec: containers: - name: gateway - image: dexorder/gateway:latest + image: dexorder/ai-gateway:latest imagePullPolicy: Never env: - name: NODE_OPTIONS diff --git a/deploy/k8s/dev/kustomization.yaml b/deploy/k8s/dev/kustomization.yaml index 299ef5c3..f92dba28 100644 --- a/deploy/k8s/dev/kustomization.yaml +++ b/deploy/k8s/dev/kustomization.yaml @@ -132,6 +132,29 @@ generatorOptions: + + + + + + + + + + + + + + + + + + + + + + + diff --git a/deploy/k8s/prod/configs/gateway-config.yaml b/deploy/k8s/prod/configs/gateway-config.yaml index 0e7f77c4..fd91e637 100644 --- a/deploy/k8s/prod/configs/gateway-config.yaml +++ b/deploy/k8s/prod/configs/gateway-config.yaml @@ -24,15 +24,16 @@ data: # Default model (if user has no preference) defaults: model_provider: anthropic - model: claude-3-5-sonnet-20241022 + model: claude-sonnet-4-6 # Kubernetes configuration kubernetes: namespace: dexorder-agents in_cluster: true - agent_image: ghcr.io/dexorder/agent:latest - sidecar_image: ghcr.io/dexorder/lifecycle-sidecar:latest + agent_image: dexorder/ai-client-py:latest + sidecar_image: dexorder/ai-lifecycle-sidecar:latest storage_class: standard + image_pull_policy: Always # For production - always pull from registry # DragonflyDB (Redis-compatible, for hot storage and session management) redis: diff --git a/doc/competition/mmt.gg/companion_capabilities.md b/doc/competition/mmt.gg/companion_capabilities.md new file mode 100644 index 00000000..4fd81004 --- /dev/null +++ b/doc/competition/mmt.gg/companion_capabilities.md @@ -0,0 +1,52 @@ +Tell me about yourself and your capabilities + + +I'm **MMT Companion**, a crypto market assistant embedded directly in your MMT trading terminal. + +Here's what I can do: + +### Market Analysis +- Fetch and analyze **OHLCV candle data** across any timeframe +- Summarize **price action, structure, and key levels** +- Provide **market overviews** with funding rates, liquidations, and trade flow + +### Order Flow & Depth +- Read **live orderbooks** and identify bid/ask walls +- Analyze **orderbook heatmaps** to track liquidity shifts over time +- Break down **cumulative volume delta (CVD)** by trade size bucket + +### Positioning & Derivatives +- Track **open interest** trends and leverage buildup +- Monitor **funding rates** and liquidation clusters + +### Volume & Profiling +- Build **volume profiles** to find POC, value area, and volume gaps + +### Chart Drawing +- Draw **horizontal levels, rectangles, trendlines, and rays** directly on your chart + +### Scripting +- Write, edit, and deploy **MMT scripts** in the built-in editor + +--- + +I always work from your **live chart context** — currently `BTC/USD` on Binance Futures at `5m`. All data I pull is real-time and grounded in actual market feeds, never fabricated. + +What would you like to analyze? + + +Are you able to write automated strategies and execute them? How about backtesting? + +### Scripting — Yes. Execution & Backtesting — No. + +**What I can do:** +- Write and edit **MMT scripts** — indicators, signals, overlays, alerts, and visual tools using the MMT scripting language +- Deploy scripts directly into your **MMT script editor** in the terminal + +**What I cannot do:** +- **Automated execution** — I cannot place, manage, or cancel orders on your behalf. MMT Companion has no order routing capability. +- **Backtesting** — There is no backtesting engine available through MMT scripting or this assistant at this time. + +--- + +In short, I can build powerful **analytical and signal scripts** that run on your live chart, but acting on those signals (trading) remains manual on your end. If you have a strategy idea you want visualized or signaled in real-time, I can script that for you. diff --git a/doc/user_mcp_resources.md b/doc/user_mcp_resources.md index 9decf89e..d507466f 100644 --- a/doc/user_mcp_resources.md +++ b/doc/user_mcp_resources.md @@ -210,7 +210,7 @@ async handleMessage(message: InboundMessage): Promise { // 5. Call Claude with embedded context const response = await anthropic.messages.create({ - model: 'claude-3-5-sonnet-20241022', + model: 'claude-sonnet-4-6', system: systemPrompt, // <-- User profile + workspace + custom prompt messages, // <-- Conversation summary from RAG tools, diff --git a/gateway/.env.example b/gateway/.env.example index bdbde2a4..9364515f 100644 --- a/gateway/.env.example +++ b/gateway/.env.example @@ -22,7 +22,7 @@ OPENROUTER_API_KEY=sk-or-xxxxx # Default model (if user has no preference) DEFAULT_MODEL_PROVIDER=anthropic -DEFAULT_MODEL=claude-3-5-sonnet-20241022 +DEFAULT_MODEL=claude-sonnet-4-6 # Telegram (optional) TELEGRAM_BOT_TOKEN= diff --git a/gateway/ARCHITECTURE.md b/gateway/ARCHITECTURE.md index 1a7a77b5..367f16cc 100644 --- a/gateway/ARCHITECTURE.md +++ b/gateway/ARCHITECTURE.md @@ -32,7 +32,7 @@ const factory = new LLMProviderFactory(config, logger); // Create any model const claude = factory.createModel({ provider: 'anthropic', - model: 'claude-3-5-sonnet-20241022', + model: 'claude-sonnet-4-6', }); const gpt4 = factory.createModel({ @@ -205,7 +205,7 @@ const conversationSummary = await mcpClient.readResource('context://conversation { "preferredModel": { "provider": "anthropic", - "model": "claude-3-5-sonnet-20241022" + "model": "claude-sonnet-4-6" } } diff --git a/gateway/README.md b/gateway/README.md index 204360b5..9a1d497d 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -120,7 +120,7 @@ ANTHROPIC_API_KEY=sk-ant-xxxxx # Optional: Set default model DEFAULT_MODEL_PROVIDER=anthropic -DEFAULT_MODEL=claude-3-5-sonnet-20241022 +DEFAULT_MODEL=claude-sonnet-4-6 ``` 4. Start Ollama and pull embedding model: diff --git a/gateway/config.example.yaml b/gateway/config.example.yaml index f5b6f348..e9d95431 100644 --- a/gateway/config.example.yaml +++ b/gateway/config.example.yaml @@ -19,7 +19,31 @@ database: # Default model (if user has no preference) defaults: model_provider: anthropic - model: claude-3-5-sonnet-20241022 + model: claude-sonnet-4-6 + +# License tier model configuration +license_models: + # Free tier models + free: + default: claude-haiku-4-5-20251001 + cost_optimized: claude-haiku-4-5-20251001 + complex: claude-haiku-4-5-20251001 + allowed_models: + - claude-haiku-4-5-20251001 + + # Pro tier models + pro: + default: claude-sonnet-4-6 + cost_optimized: claude-haiku-4-5-20251001 + complex: claude-sonnet-4-6 + blocked_models: + - claude-opus-4-6 + + # Enterprise tier models + enterprise: + default: claude-sonnet-4-6 + cost_optimized: claude-haiku-4-5-20251001 + complex: claude-opus-4-6 # Kubernetes configuration kubernetes: diff --git a/gateway/schema.sql b/gateway/schema.sql index 102a74c6..744399d3 100644 --- a/gateway/schema.sql +++ b/gateway/schema.sql @@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS user_licenses ( updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); -COMMENT ON COLUMN user_licenses.preferred_model IS 'Optional model preference: {"provider": "anthropic", "model": "claude-3-5-sonnet-20241022", "temperature": 0.7}'; +COMMENT ON COLUMN user_licenses.preferred_model IS 'Optional model preference: {"provider": "anthropic", "model": "claude-sonnet-4-6", "temperature": 0.7}'; CREATE INDEX idx_user_licenses_expires_at ON user_licenses(expires_at) WHERE expires_at IS NOT NULL; diff --git a/gateway/src/channels/telegram-handler.ts b/gateway/src/channels/telegram-handler.ts index f154daf0..b2fa2906 100644 --- a/gateway/src/channels/telegram-handler.ts +++ b/gateway/src/channels/telegram-handler.ts @@ -110,6 +110,7 @@ export class TelegramHandler { userId: authContext.userId, sessionId: authContext.sessionId, content: text, + attachments: [], // TODO: Add image support for Telegram timestamp: new Date(), }; diff --git a/gateway/src/channels/websocket-handler.ts b/gateway/src/channels/websocket-handler.ts index 305c934a..b8ae6534 100644 --- a/gateway/src/channels/websocket-handler.ts +++ b/gateway/src/channels/websocket-handler.ts @@ -158,21 +158,25 @@ export class WebSocketHandler { }), }; - // Create agent harness - const harness = new AgentHarness({ - userId: authContext.userId, - sessionId: authContext.sessionId, - license: authContext.license, - providerConfig: this.config.providerConfig, - logger, - }); + // Declare harness outside try block so it's available in catch + let harness: AgentHarness | undefined; try { - // Initialize workspace and harness + // Initialize workspace first await workspace.initialize(); workspace.setAdapter(wsAdapter); this.workspaces.set(authContext.sessionId, workspace); + // Create agent harness with workspace manager + harness = new AgentHarness({ + userId: authContext.userId, + sessionId: authContext.sessionId, + license: authContext.license, + providerConfig: this.config.providerConfig, + logger, + workspaceManager: workspace, + }); + await harness.initialize(); this.harnesses.set(authContext.sessionId, harness); @@ -220,8 +224,8 @@ export class WebSocketHandler { logger.info({ type: payload.type, request_id: payload.request_id }, 'WebSocket message parsed'); // Route based on message type - if (payload.type === 'message') { - // Chat message - send to agent harness + if (payload.type === 'message' || payload.type === 'agent_user_message') { + // Chat message - send to agent harness with streaming const inboundMessage: InboundMessage = { messageId: randomUUID(), userId: authContext.userId, @@ -231,14 +235,41 @@ export class WebSocketHandler { timestamp: new Date(), }; - const response = await harness.handleMessage(inboundMessage); + if (!harness) { + logger.error('Harness not initialized'); + socket.send(JSON.stringify({ type: 'error', message: 'Session not ready' })); + return; + } - socket.send( - JSON.stringify({ - type: 'message', - ...response, - }) - ); + // Stream response chunks to client + try { + for await (const chunk of harness.streamMessage(inboundMessage)) { + socket.send( + JSON.stringify({ + type: 'agent_chunk', + content: chunk, + done: false, + }) + ); + } + + // Send final chunk with done flag + socket.send( + JSON.stringify({ + type: 'agent_chunk', + content: '', + done: true, + }) + ); + } catch (error) { + logger.error({ error }, 'Error streaming response'); + socket.send( + JSON.stringify({ + type: 'error', + message: 'Failed to generate response', + }) + ); + } } else if (payload.type === 'hello') { // Workspace sync: hello message logger.debug({ seqs: payload.seqs }, 'Handling workspace hello'); @@ -280,8 +311,10 @@ export class WebSocketHandler { this.workspaces.delete(authContext.sessionId); // Cleanup harness - await harness.cleanup(); - this.harnesses.delete(authContext.sessionId); + if (harness) { + await harness.cleanup(); + this.harnesses.delete(authContext.sessionId); + } }); socket.on('error', (error: any) => { @@ -292,7 +325,9 @@ export class WebSocketHandler { socket.close(1011, 'Internal server error'); await workspace.shutdown(); this.workspaces.delete(authContext.sessionId); - await harness.cleanup(); + if (harness) { + await harness.cleanup(); + } } } diff --git a/gateway/src/clients/zmq-relay-client.ts b/gateway/src/clients/zmq-relay-client.ts index 034afc03..a7870f49 100644 --- a/gateway/src/clients/zmq-relay-client.ts +++ b/gateway/src/clients/zmq-relay-client.ts @@ -66,6 +66,7 @@ export class ZMQRelayClient { relayNotificationEndpoint: config.relayNotificationEndpoint, clientId: config.clientId || `gateway-${randomUUID().slice(0, 8)}`, requestTimeout: config.requestTimeout || 30000, + onMetadataUpdate: config.onMetadataUpdate || (async () => {}), }; this.logger = logger; this.notificationTopic = `RESPONSE:${this.config.clientId}`; diff --git a/gateway/src/harness/README.md b/gateway/src/harness/README.md index 1e1f2c34..1d7bf6b0 100644 --- a/gateway/src/harness/README.md +++ b/gateway/src/harness/README.md @@ -101,9 +101,15 @@ See individual workflow READMEs for details. YAML-based configuration: -- `models.yaml`: LLM providers, routing, rate limits - `subagent-routing.yaml`: When to use which subagent +Model configuration is centralized in the main gateway config (`/config/config.yaml`), including: +- Default models and providers +- License tier model assignments (free, pro, enterprise) +- Model routing rules (complexity, cost-optimized) + +Resource limits (token limits, rate limits) are stored in the database per user. + ## User Context Enhanced session context with channel awareness for multi-channel support: diff --git a/gateway/src/harness/agent-harness.ts b/gateway/src/harness/agent-harness.ts index 6cf6688f..53251cfb 100644 --- a/gateway/src/harness/agent-harness.ts +++ b/gateway/src/harness/agent-harness.ts @@ -8,6 +8,7 @@ import { MCPClientConnector } from './mcp-client.js'; import { CONTEXT_URIS, type ResourceContent } from '../types/resources.js'; import { LLMProviderFactory, type ProviderConfig } from '../llm/provider.js'; import { ModelRouter, RoutingStrategy } from '../llm/router.js'; +import type { WorkspaceManager } from '../workspace/workspace-manager.js'; export interface AgentHarnessConfig { userId: string; @@ -15,6 +16,7 @@ export interface AgentHarnessConfig { license: UserLicense; providerConfig: ProviderConfig; logger: FastifyBaseLogger; + workspaceManager?: WorkspaceManager; } /** @@ -33,9 +35,13 @@ export class AgentHarness { private modelFactory: LLMProviderFactory; private modelRouter: ModelRouter; private mcpClient: MCPClientConnector; + private workspaceManager?: WorkspaceManager; + private lastWorkspaceSeq: number = 0; + private isFirstMessage: boolean = true; constructor(config: AgentHarnessConfig) { this.config = config; + this.workspaceManager = config.workspaceManager; this.modelFactory = new LLMProviderFactory(config.providerConfig, config.logger); this.modelRouter = new ModelRouter(this.modelFactory, config.logger); @@ -102,18 +108,14 @@ export class AgentHarness { // 7. Extract text response (tool handling TODO) const assistantMessage = response.content as string; - // 8. Save messages to user's MCP server - this.config.logger.debug('Saving messages to MCP'); - await this.mcpClient.callTool('save_message', { - role: 'user', - content: message.content, - timestamp: message.timestamp.toISOString(), - }); - await this.mcpClient.callTool('save_message', { - role: 'assistant', - content: assistantMessage, - timestamp: new Date().toISOString(), - }); + // TODO: Save messages to Iceberg conversation table instead of MCP + // Should batch-insert periodically or on session end to avoid many small Parquet files + // await icebergConversationStore.appendMessages([...]); + + // Mark first message as processed + if (this.isFirstMessage) { + this.isFirstMessage = false; + } return { messageId: `msg_${Date.now()}`, @@ -157,17 +159,17 @@ export class AgentHarness { yield content; } - // Save after streaming completes - await this.mcpClient.callTool('save_message', { - role: 'user', - content: message.content, - timestamp: message.timestamp.toISOString(), - }); - await this.mcpClient.callTool('save_message', { - role: 'assistant', - content: fullResponse, - timestamp: new Date().toISOString(), - }); + // TODO: Save messages to Iceberg conversation table instead of MCP + // Should batch-insert periodically or on session end to avoid many small Parquet files + // await icebergConversationStore.appendMessages([ + // { role: 'user', content: message.content, timestamp: message.timestamp }, + // { role: 'assistant', content: fullResponse, timestamp: new Date() } + // ]); + + // Mark first message as processed + if (this.isFirstMessage) { + this.isFirstMessage = false; + } } catch (error) { this.config.logger.error({ error }, 'Error streaming message'); throw error; @@ -224,6 +226,15 @@ export class AgentHarness { }); } + // Add workspace delta (for subsequent turns) + const workspaceDelta = this.buildWorkspaceDelta(); + if (workspaceDelta) { + messages.push({ + role: 'user', + content: workspaceDelta, + }); + } + // Add current user message messages.push({ role: 'user', @@ -273,9 +284,18 @@ Available features: ${JSON.stringify(this.config.license.features, null, 2)}`; prompt += `\n\n# User Profile\n${userProfile.text}`; } - // Add workspace context + // Add workspace context from MCP resource (if available) if (workspaceState?.text) { - prompt += `\n\n# Current Workspace\n${workspaceState.text}`; + prompt += `\n\n# Current Workspace (from MCP)\n${workspaceState.text}`; + } + + // Add full workspace state from WorkspaceManager (first message only) + if (this.isFirstMessage && this.workspaceManager) { + const workspaceJSON = this.workspaceManager.serializeState(); + prompt += `\n\n# Workspace State (JSON)\n\`\`\`json\n${workspaceJSON}\n\`\`\``; + + // Record current workspace sequence for delta tracking + this.lastWorkspaceSeq = this.workspaceManager.getCurrentSeq(); } // Add user's custom instructions (highest priority) @@ -286,6 +306,30 @@ Available features: ${JSON.stringify(this.config.license.features, null, 2)}`; return prompt; } + /** + * Build workspace delta message for subsequent turns. + * Returns null if no changes since last message. + */ + private buildWorkspaceDelta(): string | null { + if (!this.workspaceManager || this.isFirstMessage) { + return null; + } + + const changes = this.workspaceManager.getChangesSince(this.lastWorkspaceSeq); + + if (Object.keys(changes).length === 0) { + return null; + } + + // Format changes as JSON + const deltaJSON = JSON.stringify(changes, null, 2); + + // Update sequence marker + this.lastWorkspaceSeq = this.workspaceManager.getCurrentSeq(); + + return `[Workspace Changes Since Last Turn]\n\`\`\`json\n${deltaJSON}\n\`\`\``; + } + /** diff --git a/gateway/src/harness/config/models.yaml b/gateway/src/harness/config/models.yaml deleted file mode 100644 index e1463840..00000000 --- a/gateway/src/harness/config/models.yaml +++ /dev/null @@ -1,110 +0,0 @@ -# Default LLM Model Configuration - -# Default model for general agent tasks -default: - provider: anthropic - model: claude-3-5-sonnet-20241022 - temperature: 0.7 - maxTokens: 4096 - -# Model overrides for specific use cases -models: - # Fast model for simple tasks (routing, classification) - fast: - provider: anthropic - model: claude-3-haiku-20240307 - temperature: 0.3 - maxTokens: 1024 - - # Reasoning model for complex analysis - reasoning: - provider: anthropic - model: claude-3-5-sonnet-20241022 - temperature: 0.5 - maxTokens: 8192 - - # Precise model for code generation/review - code: - provider: anthropic - model: claude-3-5-sonnet-20241022 - temperature: 0.2 - maxTokens: 8192 - - # Creative model for strategy brainstorming - creative: - provider: anthropic - model: claude-3-5-sonnet-20241022 - temperature: 0.9 - maxTokens: 4096 - -# Embedding model configuration -embeddings: - provider: openai - model: text-embedding-3-small - dimensions: 1536 - -# Model routing rules (complexity-based) -routing: - # Simple queries → fast model - simple: - keywords: - - "what is" - - "define" - - "list" - - "show me" - maxTokens: 100 - model: fast - - # Code-related → code model - code: - keywords: - - "code" - - "function" - - "implement" - - "debug" - - "review" - model: code - - # Analysis tasks → reasoning model - analysis: - keywords: - - "analyze" - - "compare" - - "evaluate" - - "assess" - model: reasoning - - # Everything else → default - default: - model: default - -# Cost optimization settings -costControl: - # Cache system prompts (Anthropic prompt caching) - cacheSystemPrompts: true - - # Token limits per license type - tokenLimits: - free: - maxTokensPerMessage: 2048 - maxTokensPerDay: 50000 - pro: - maxTokensPerMessage: 8192 - maxTokensPerDay: 500000 - enterprise: - maxTokensPerMessage: 16384 - maxTokensPerDay: -1 # unlimited - -# Rate limiting -rateLimits: - # Requests per minute by license - requestsPerMinute: - free: 10 - pro: 60 - enterprise: 120 - - # Concurrent requests - concurrentRequests: - free: 1 - pro: 3 - enterprise: 10 diff --git a/gateway/src/harness/mcp-client.ts b/gateway/src/harness/mcp-client.ts index 5856f607..4f8be213 100644 --- a/gateway/src/harness/mcp-client.ts +++ b/gateway/src/harness/mcp-client.ts @@ -1,4 +1,5 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import type { FastifyBaseLogger } from 'fastify'; @@ -11,7 +12,7 @@ export interface MCPClientConfig { /** * MCP client connector for user's container - * Manages connection to user-specific MCP server + * Manages connection to user-specific MCP server via SSE transport */ export class MCPClientConnector { private client: Client | null = null; @@ -23,8 +24,7 @@ export class MCPClientConnector { } /** - * Connect to user's MCP server - * TODO: Implement HTTP/SSE transport instead of stdio for container communication + * Connect to user's MCP server via SSE transport */ async connect(): Promise { if (this.connected) { @@ -34,7 +34,7 @@ export class MCPClientConnector { try { this.config.logger.info( { userId: this.config.userId, url: this.config.mcpServerUrl }, - 'Connecting to user MCP server' + 'Connecting to user MCP server via SSE' ); this.client = new Client( @@ -46,24 +46,18 @@ export class MCPClientConnector { capabilities: { sampling: {}, }, - } as any + } ); - // TODO: Replace with HTTP transport when user containers are ready - // For now, this is a placeholder structure - // const transport = new HTTPTransport(this.config.mcpServerUrl, { - // headers: { - // 'Authorization': `Bearer ${this.config.platformJWT}` - // } - // }); - - // Placeholder: will be replaced with actual container transport - this.config.logger.warn( - 'MCP transport not yet implemented - using placeholder' + // Create SSE transport for HTTP connection to user container + const transport = new SSEClientTransport( + new URL(`${this.config.mcpServerUrl}/sse`) ); + await this.client.connect(transport); + this.connected = true; - this.config.logger.info('Connected to user MCP server'); + this.config.logger.info('Connected to user MCP server via SSE'); } catch (error) { this.config.logger.error( { error, userId: this.config.userId }, @@ -84,12 +78,8 @@ export class MCPClientConnector { try { this.config.logger.debug({ tool: name, args }, 'Calling MCP tool'); - // TODO: Implement when MCP client is connected - // const result = await this.client.callTool({ name, arguments: args }); - // return result; - - // Placeholder response - return { success: true, message: 'MCP tool call placeholder' }; + const result = await this.client.callTool({ name, arguments: args }); + return result; } catch (error) { this.config.logger.error({ error, tool: name }, 'MCP tool call failed'); throw error; @@ -98,27 +88,35 @@ export class MCPClientConnector { /** * List available tools from user's MCP server + * Filters to only return tools marked as agent_accessible */ - async listTools(): Promise> { + async listTools(): Promise> { if (!this.client || !this.connected) { throw new Error('MCP client not connected'); } try { - // TODO: Implement when MCP client is connected - // const tools = await this.client.listTools(); - // return tools; + const response = await this.client.listTools(); - // Placeholder tools (actions only, not context) - return [ - { name: 'save_message', description: 'Save message to conversation history' }, - { name: 'list_strategies', description: 'List user strategies' }, - { name: 'read_strategy', description: 'Read strategy code' }, - { name: 'write_strategy', description: 'Write strategy code' }, - { name: 'run_backtest', description: 'Run backtest on strategy' }, - { name: 'get_watchlist', description: 'Get user watchlist' }, - { name: 'execute_trade', description: 'Execute trade' }, - ]; + // Filter tools to only include agent-accessible ones + const tools = response.tools + .filter((tool: any) => { + // Check if tool has agent_accessible annotation + const annotations = tool.annotations || {}; + return annotations.agent_accessible === true; + }) + .map((tool: any) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })); + + this.config.logger.debug( + { totalTools: response.tools.length, agentAccessibleTools: tools.length }, + 'Listed MCP tools with filtering' + ); + + return tools; } catch (error) { this.config.logger.error({ error }, 'Failed to list MCP tools'); throw error; @@ -127,6 +125,7 @@ export class MCPClientConnector { /** * List available resources from user's MCP server + * Filters to only return resources marked as agent_accessible */ async listResources(): Promise> { if (!this.client || !this.connected) { @@ -134,37 +133,28 @@ export class MCPClientConnector { } try { - // TODO: Implement when MCP client is connected - // const resources = await this.client.listResources(); - // return resources; + const response = await this.client.listResources(); - // Placeholder resources for user context - return [ - { - uri: 'context://user-profile', - name: 'User Profile', - description: 'User trading style, preferences, and background', - mimeType: 'text/plain', - }, - { - uri: 'context://conversation-summary', - name: 'Conversation Summary', - description: 'Semantic summary of recent conversation history with RAG', - mimeType: 'text/plain', - }, - { - uri: 'context://workspace-state', - name: 'Workspace State', - description: 'Current chart, watchlist, and open positions', - mimeType: 'application/json', - }, - { - uri: 'context://system-prompt', - name: 'Custom System Prompt', - description: 'User custom instructions for the assistant', - mimeType: 'text/plain', - }, - ]; + // Filter resources to only include agent-accessible ones + const resources = response.resources + .filter((resource: any) => { + // Check if resource has agent_accessible annotation + const annotations = resource.annotations || {}; + return annotations.agent_accessible === true; + }) + .map((resource: any) => ({ + uri: resource.uri, + name: resource.name, + description: resource.description, + mimeType: resource.mimeType, + })); + + this.config.logger.debug( + { totalResources: response.resources.length, agentAccessibleResources: resources.length }, + 'Listed MCP resources with filtering' + ); + + return resources; } catch (error) { this.config.logger.error({ error }, 'Failed to list MCP resources'); throw error; @@ -182,55 +172,18 @@ export class MCPClientConnector { try { this.config.logger.debug({ uri }, 'Reading MCP resource'); - // TODO: Implement when MCP client is connected - // const resource = await this.client.readResource({ uri }); - // return resource; + const response = await this.client.readResource({ uri }); - // Placeholder resource content - if (uri === 'context://user-profile') { - return { - uri, - mimeType: 'text/plain', - text: `User Profile: -- Trading experience: Intermediate -- Preferred timeframes: 1h, 4h, 1d -- Risk tolerance: Medium -- Focus: Swing trading with technical indicators`, - }; - } else if (uri === 'context://conversation-summary') { - return { - uri, - mimeType: 'text/plain', - text: `Recent Conversation Summary: -[RAG-generated summary would go here] + // Extract the first content item (MCP returns array of contents) + const content = response.contents[0]; -User recently discussed: -- Moving average crossover strategies -- Backtesting on BTC/USDT -- Risk management techniques`, - }; - } else if (uri === 'context://workspace-state') { - return { - uri, - mimeType: 'application/json', - text: JSON.stringify({ - currentChart: { ticker: 'BINANCE:BTC/USDT', timeframe: '1h' }, - watchlist: ['BTC/USDT', 'ETH/USDT', 'SOL/USDT'], - openPositions: [], - }, null, 2), - }; - } else if (uri === 'context://system-prompt') { - return { - uri, - mimeType: 'text/plain', - text: `Custom Instructions: -- Be concise and data-driven -- Always show risk/reward ratios -- Prefer simple strategies over complex ones`, - }; - } - - return { uri, text: '' }; + // Handle union type: content is either TextContent or BlobContent + return { + uri: content.uri, + mimeType: content.mimeType, + text: 'text' in content ? content.text : undefined, + blob: 'blob' in content ? content.blob : undefined, + }; } catch (error) { this.config.logger.error({ error, uri }, 'MCP resource read failed'); throw error; diff --git a/gateway/src/harness/subagents/README.md b/gateway/src/harness/subagents/README.md index 2abcdf97..e5ab834d 100644 --- a/gateway/src/harness/subagents/README.md +++ b/gateway/src/harness/subagents/README.md @@ -42,7 +42,7 @@ name: my-subagent description: What it does # Model override (optional) -model: claude-3-5-sonnet-20241022 +model: claude-sonnet-4-6 temperature: 0.3 maxTokens: 4096 diff --git a/gateway/src/harness/subagents/code-reviewer/config.yaml b/gateway/src/harness/subagents/code-reviewer/config.yaml index d47c2652..1b041f12 100644 --- a/gateway/src/harness/subagents/code-reviewer/config.yaml +++ b/gateway/src/harness/subagents/code-reviewer/config.yaml @@ -4,7 +4,7 @@ name: code-reviewer description: Reviews trading strategy code for bugs, performance issues, and best practices # Model configuration (optional override) -model: claude-3-5-sonnet-20241022 +model: claude-sonnet-4-6 temperature: 0.3 maxTokens: 4096 diff --git a/gateway/src/harness/workflows/README.md b/gateway/src/harness/workflows/README.md index 18ea047a..66eef7a9 100644 --- a/gateway/src/harness/workflows/README.md +++ b/gateway/src/harness/workflows/README.md @@ -334,7 +334,7 @@ timeout: 60000 maxRetries: 3 requiresApproval: false -model: claude-3-5-sonnet-20241022 +model: claude-sonnet-4-6 ``` ### 6. Add Factory Function diff --git a/gateway/src/harness/workflows/strategy-validation/config.yaml b/gateway/src/harness/workflows/strategy-validation/config.yaml index ec8b5fe2..c423e21c 100644 --- a/gateway/src/harness/workflows/strategy-validation/config.yaml +++ b/gateway/src/harness/workflows/strategy-validation/config.yaml @@ -15,5 +15,5 @@ maxValidationRetries: 3 # Max times to retry fixing errors minBacktestScore: 0.5 # Minimum Sharpe ratio to pass # Model override (optional) -model: claude-3-5-sonnet-20241022 +model: claude-sonnet-4-6 temperature: 0.3 diff --git a/gateway/src/harness/workflows/trading-request/config.yaml b/gateway/src/harness/workflows/trading-request/config.yaml index 2030c8fc..0db9500f 100644 --- a/gateway/src/harness/workflows/trading-request/config.yaml +++ b/gateway/src/harness/workflows/trading-request/config.yaml @@ -15,5 +15,5 @@ maxPositionPercent: 0.05 # 5% of portfolio max minRiskRewardRatio: 2.0 # Minimum 2:1 risk/reward # Model override (optional) -model: claude-3-5-sonnet-20241022 +model: claude-sonnet-4-6 temperature: 0.2 diff --git a/gateway/src/k8s/client.ts b/gateway/src/k8s/client.ts index d4170cea..ec259cc2 100644 --- a/gateway/src/k8s/client.ts +++ b/gateway/src/k8s/client.ts @@ -21,6 +21,7 @@ export interface DeploymentSpec { agentImage: string; sidecarImage: string; storageClass: string; + imagePullPolicy?: string; } /** @@ -132,7 +133,8 @@ export class KubernetesClient { .replace(/\{\{pvcName\}\}/g, pvcName) .replace(/\{\{agentImage\}\}/g, spec.agentImage) .replace(/\{\{sidecarImage\}\}/g, spec.sidecarImage) - .replace(/\{\{storageClass\}\}/g, spec.storageClass); + .replace(/\{\{storageClass\}\}/g, spec.storageClass) + .replace(/\{\{imagePullPolicy\}\}/g, spec.imagePullPolicy || 'Always'); // Parse YAML documents (deployment, pvc, service) const documents = yaml.loadAll(rendered) as any[]; diff --git a/gateway/src/k8s/container-manager.ts b/gateway/src/k8s/container-manager.ts index 5d983202..52e944a8 100644 --- a/gateway/src/k8s/container-manager.ts +++ b/gateway/src/k8s/container-manager.ts @@ -7,6 +7,7 @@ export interface ContainerManagerConfig { agentImage: string; sidecarImage: string; storageClass: string; + imagePullPolicy?: string; namespace: string; logger: FastifyBaseLogger; } @@ -82,6 +83,7 @@ export class ContainerManager { agentImage: this.config.agentImage, sidecarImage: this.config.sidecarImage, storageClass: this.config.storageClass, + imagePullPolicy: this.config.imagePullPolicy, }; await this.config.k8sClient.createAgentDeployment(spec); diff --git a/gateway/src/k8s/templates/enterprise-tier.yaml b/gateway/src/k8s/templates/enterprise-tier.yaml index 6d1d1a49..b42abc3a 100644 --- a/gateway/src/k8s/templates/enterprise-tier.yaml +++ b/gateway/src/k8s/templates/enterprise-tier.yaml @@ -40,7 +40,7 @@ spec: containers: - name: agent image: {{agentImage}} - imagePullPolicy: Always + imagePullPolicy: {{imagePullPolicy}} securityContext: allowPrivilegeEscalation: false @@ -72,6 +72,8 @@ spec: value: "3000" - name: ZMQ_CONTROL_PORT value: "5555" + - name: ZMQ_GATEWAY_ENDPOINT + value: "tcp://gateway.default.svc.cluster.local:5571" ports: - name: mcp @@ -102,14 +104,14 @@ spec: readinessProbe: httpGet: - path: /ready + path: /health port: mcp initialDelaySeconds: 5 periodSeconds: 10 - name: lifecycle-sidecar image: {{sidecarImage}} - imagePullPolicy: Always + imagePullPolicy: {{imagePullPolicy}} securityContext: allowPrivilegeEscalation: false diff --git a/gateway/src/k8s/templates/free-tier.yaml b/gateway/src/k8s/templates/free-tier.yaml index c75c2621..6f4b1b1e 100644 --- a/gateway/src/k8s/templates/free-tier.yaml +++ b/gateway/src/k8s/templates/free-tier.yaml @@ -39,7 +39,7 @@ spec: containers: - name: agent image: {{agentImage}} - imagePullPolicy: Always + imagePullPolicy: {{imagePullPolicy}} securityContext: allowPrivilegeEscalation: false @@ -71,7 +71,9 @@ spec: value: "3000" - name: ZMQ_CONTROL_PORT value: "5555" - + - name: ZMQ_GATEWAY_ENDPOINT + value: "tcp://gateway.default.svc.cluster.local:5571" + ports: - name: mcp containerPort: 3000 @@ -101,14 +103,14 @@ spec: readinessProbe: httpGet: - path: /ready + path: /health port: mcp initialDelaySeconds: 5 periodSeconds: 10 - name: lifecycle-sidecar image: {{sidecarImage}} - imagePullPolicy: Always + imagePullPolicy: {{imagePullPolicy}} securityContext: allowPrivilegeEscalation: false diff --git a/gateway/src/k8s/templates/pro-tier.yaml b/gateway/src/k8s/templates/pro-tier.yaml index 76cbe9bb..135d3534 100644 --- a/gateway/src/k8s/templates/pro-tier.yaml +++ b/gateway/src/k8s/templates/pro-tier.yaml @@ -39,7 +39,7 @@ spec: containers: - name: agent image: {{agentImage}} - imagePullPolicy: Always + imagePullPolicy: {{imagePullPolicy}} securityContext: allowPrivilegeEscalation: false @@ -71,6 +71,8 @@ spec: value: "3000" - name: ZMQ_CONTROL_PORT value: "5555" + - name: ZMQ_GATEWAY_ENDPOINT + value: "tcp://gateway.default.svc.cluster.local:5571" ports: - name: mcp @@ -101,14 +103,14 @@ spec: readinessProbe: httpGet: - path: /ready + path: /health port: mcp initialDelaySeconds: 5 periodSeconds: 10 - name: lifecycle-sidecar image: {{sidecarImage}} - imagePullPolicy: Always + imagePullPolicy: {{imagePullPolicy}} securityContext: allowPrivilegeEscalation: false diff --git a/gateway/src/llm/provider.ts b/gateway/src/llm/provider.ts index ace2b4d6..20f98049 100644 --- a/gateway/src/llm/provider.ts +++ b/gateway/src/llm/provider.ts @@ -19,11 +19,33 @@ export interface ModelConfig { maxTokens?: number; } +/** + * License tier model configuration + */ +export interface LicenseTierModels { + default: string; + cost_optimized: string; + complex: string; + allowed_models?: string[]; + blocked_models?: string[]; +} + +/** + * License models configuration + */ +export interface LicenseModelsConfig { + free: LicenseTierModels; + pro: LicenseTierModels; + enterprise: LicenseTierModels; +} + /** * Provider configuration with API keys */ export interface ProviderConfig { anthropicApiKey?: string; + defaultModel?: ModelConfig; + licenseModels?: LicenseModelsConfig; } /** @@ -77,15 +99,26 @@ export class LLMProviderFactory { * Get default model based on environment */ getDefaultModel(): ModelConfig { + if (this.config.defaultModel) { + return this.config.defaultModel; + } + if (!this.config.anthropicApiKey) { throw new Error('Anthropic API key not configured'); } return { provider: LLMProvider.ANTHROPIC, - model: 'claude-3-5-sonnet-20241022', + model: 'claude-sonnet-4-6', }; } + + /** + * Get license models configuration + */ + getLicenseModelsConfig(): LicenseModelsConfig | undefined { + return this.config.licenseModels; + } } /** @@ -94,14 +127,14 @@ export class LLMProviderFactory { export const MODELS = { CLAUDE_SONNET: { provider: LLMProvider.ANTHROPIC, - model: 'claude-3-5-sonnet-20241022', + model: 'claude-sonnet-4-6', }, CLAUDE_HAIKU: { provider: LLMProvider.ANTHROPIC, - model: 'claude-3-5-haiku-20241022', + model: 'claude-haiku-4-5-20251001', }, CLAUDE_OPUS: { provider: LLMProvider.ANTHROPIC, - model: 'claude-3-opus-20240229', + model: 'claude-opus-4-6', }, } as const satisfies Record; diff --git a/gateway/src/llm/router.ts b/gateway/src/llm/router.ts index 247093b0..a9c72605 100644 --- a/gateway/src/llm/router.ts +++ b/gateway/src/llm/router.ts @@ -1,6 +1,6 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { FastifyBaseLogger } from 'fastify'; -import { LLMProviderFactory, type ModelConfig, LLMProvider } from './provider.js'; +import { LLMProviderFactory, type ModelConfig, LLMProvider, type LicenseModelsConfig } from './provider.js'; import type { UserLicense } from '../types/user.js'; /** @@ -25,11 +25,13 @@ export class ModelRouter { private factory: LLMProviderFactory; private logger: FastifyBaseLogger; private defaultModel: ModelConfig; + private licenseModels?: LicenseModelsConfig; constructor(factory: LLMProviderFactory, logger: FastifyBaseLogger) { this.factory = factory; this.logger = logger; this.defaultModel = factory.getDefaultModel(); + this.licenseModels = factory.getLicenseModelsConfig(); } /** @@ -97,37 +99,53 @@ export class ModelRouter { private routeByComplexity(message: string, license: UserLicense): ModelConfig { const isComplex = this.isComplexQuery(message); + // Use configuration if available + if (this.licenseModels) { + const tierConfig = this.licenseModels[license.licenseType]; + if (tierConfig) { + const model = isComplex ? tierConfig.complex : tierConfig.default; + return { provider: this.defaultModel.provider as LLMProvider, model }; + } + } + + // Fallback to hardcoded defaults if (license.licenseType === 'enterprise') { - // Enterprise users get best models for complex queries return isComplex - ? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-opus-20240229' } - : { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' }; + ? { provider: LLMProvider.ANTHROPIC, model: 'claude-opus-4-6' } + : { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' }; } if (license.licenseType === 'pro') { - // Pro users get good models return isComplex - ? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' } - : { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' }; + ? { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' } + : { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' }; } - // Free users get efficient models - return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' }; + return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' }; } /** * Route based on license tier */ private routeByLicenseTier(license: UserLicense): ModelConfig { + // Use configuration if available + if (this.licenseModels) { + const tierConfig = this.licenseModels[license.licenseType]; + if (tierConfig) { + return { provider: this.defaultModel.provider as LLMProvider, model: tierConfig.default }; + } + } + + // Fallback to hardcoded defaults switch (license.licenseType) { case 'enterprise': - return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' }; + return { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' }; case 'pro': - return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' }; + return { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' }; case 'free': - return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' }; + return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' }; default: return this.defaultModel; @@ -137,24 +155,50 @@ export class ModelRouter { /** * Route to cheapest available model */ - private routeByCost(_license: UserLicense): ModelConfig { - // All tiers: use Haiku for cost efficiency - return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' }; + private routeByCost(license: UserLicense): ModelConfig { + // Use configuration if available + if (this.licenseModels) { + const tierConfig = this.licenseModels[license.licenseType]; + if (tierConfig) { + return { provider: this.defaultModel.provider as LLMProvider, model: tierConfig.cost_optimized }; + } + } + + // Fallback: use Haiku for cost efficiency + return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' }; } /** * Check if model is allowed for user's license */ private isModelAllowed(model: ModelConfig, license: UserLicense): boolean { - // Free tier: only Haiku + // Use configuration if available + if (this.licenseModels) { + const tierConfig = this.licenseModels[license.licenseType]; + if (tierConfig) { + // Check allowed_models list if defined + if (tierConfig.allowed_models && tierConfig.allowed_models.length > 0) { + return tierConfig.allowed_models.includes(model.model); + } + + // Check blocked_models list if defined + if (tierConfig.blocked_models && tierConfig.blocked_models.length > 0) { + return !tierConfig.blocked_models.includes(model.model); + } + + // No restrictions if neither list is defined + return true; + } + } + + // Fallback to hardcoded defaults if (license.licenseType === 'free') { - const allowedModels = ['claude-3-5-haiku-20241022']; + const allowedModels = ['claude-haiku-4-5-20251001']; return allowedModels.includes(model.model); } - // Pro: all except Opus if (license.licenseType === 'pro') { - const blockedModels = ['claude-3-opus-20240229']; + const blockedModels = ['claude-opus-4-6']; return !blockedModels.includes(model.model); } diff --git a/gateway/src/main.ts b/gateway/src/main.ts index 27023744..08194571 100644 --- a/gateway/src/main.ts +++ b/gateway/src/main.ts @@ -85,12 +85,35 @@ function loadConfig() { // Authentication configuration authSecret: secretsData.auth?.secret || process.env.AUTH_SECRET || 'change-me-in-production', - // LLM provider API keys + // LLM provider API keys and model configuration providerConfig: { anthropicApiKey: secretsData.llm_providers?.anthropic_api_key || process.env.ANTHROPIC_API_KEY, openaiApiKey: secretsData.llm_providers?.openai_api_key || process.env.OPENAI_API_KEY, googleApiKey: secretsData.llm_providers?.google_api_key || process.env.GOOGLE_API_KEY, openrouterApiKey: secretsData.llm_providers?.openrouter_api_key || process.env.OPENROUTER_API_KEY, + defaultModel: { + provider: configData.defaults?.model_provider || 'anthropic', + model: configData.defaults?.model || 'claude-sonnet-4-6', + }, + licenseModels: { + free: { + default: configData.license_models?.free?.default || 'claude-haiku-4-5-20251001', + cost_optimized: configData.license_models?.free?.cost_optimized || 'claude-haiku-4-5-20251001', + complex: configData.license_models?.free?.complex || 'claude-haiku-4-5-20251001', + allowed_models: configData.license_models?.free?.allowed_models || ['claude-haiku-4-5-20251001'], + }, + pro: { + default: configData.license_models?.pro?.default || 'claude-sonnet-4-6', + cost_optimized: configData.license_models?.pro?.cost_optimized || 'claude-haiku-4-5-20251001', + complex: configData.license_models?.pro?.complex || 'claude-sonnet-4-6', + blocked_models: configData.license_models?.pro?.blocked_models || ['claude-opus-4-6'], + }, + enterprise: { + default: configData.license_models?.enterprise?.default || 'claude-sonnet-4-6', + cost_optimized: configData.license_models?.enterprise?.cost_optimized || 'claude-haiku-4-5-20251001', + complex: configData.license_models?.enterprise?.complex || 'claude-opus-4-6', + }, + }, }, telegramBotToken: secretsData.telegram?.bot_token || process.env.TELEGRAM_BOT_TOKEN || '', @@ -148,6 +171,7 @@ function loadConfig() { agentImage: configData.kubernetes?.agent_image || process.env.AGENT_IMAGE || 'ghcr.io/dexorder/agent:latest', sidecarImage: configData.kubernetes?.sidecar_image || process.env.SIDECAR_IMAGE || 'ghcr.io/dexorder/lifecycle-sidecar:latest', storageClass: configData.kubernetes?.storage_class || process.env.AGENT_STORAGE_CLASS || 'standard', + imagePullPolicy: configData.kubernetes?.image_pull_policy || process.env.IMAGE_PULL_POLICY || 'Always', }, }; } @@ -265,6 +289,7 @@ const containerManager = new ContainerManager({ agentImage: config.kubernetes.agentImage, sidecarImage: config.kubernetes.sidecarImage, storageClass: config.kubernetes.storageClass, + imagePullPolicy: config.kubernetes.imagePullPolicy, namespace: config.kubernetes.namespace, logger: app.log, }); diff --git a/gateway/src/workspace/index.ts b/gateway/src/workspace/index.ts index da54ed0d..440e4310 100644 --- a/gateway/src/workspace/index.ts +++ b/gateway/src/workspace/index.ts @@ -66,7 +66,8 @@ export type { PathTriggerHandler, PathTriggerContext, ChartState, - ChartStore, + ShapesStore, + IndicatorsStore, ChannelState, ChannelInfo, WorkspaceStores, diff --git a/gateway/src/workspace/sync-registry.ts b/gateway/src/workspace/sync-registry.ts index 642617c3..c7a872dd 100644 --- a/gateway/src/workspace/sync-registry.ts +++ b/gateway/src/workspace/sync-registry.ts @@ -404,4 +404,28 @@ export class SyncRegistry { } return states; } + + /** + * Get patches since a given sequence number for a store. + * Returns empty array if no patches available since that sequence. + */ + getPatchesSince(storeName: string, sinceSeq: number): JsonPatchOp[] | null { + const entry = this.entries.get(storeName); + if (!entry) { + return null; + } + + const catchupPatches = entry.getCatchupPatches(sinceSeq); + if (catchupPatches === null) { + return null; // History not available + } + + // Flatten all patches into a single array + const allPatches: JsonPatchOp[] = []; + for (const { patch } of catchupPatches) { + allPatches.push(...patch); + } + + return allPatches; + } } diff --git a/gateway/src/workspace/types.ts b/gateway/src/workspace/types.ts index ee541385..fe95185d 100644 --- a/gateway/src/workspace/types.ts +++ b/gateway/src/workspace/types.ts @@ -84,17 +84,19 @@ export const DEFAULT_STORES: StoreConfig[] = [ symbol: 'BINANCE:BTC/USDT', start_time: null, end_time: null, - interval: '15', + period: '15', selected_shapes: [], }), }, { - name: 'chartStore', + name: 'shapes', persistent: true, - initialState: () => ({ - drawings: {}, - templates: {}, - }), + initialState: () => ({}), + }, + { + name: 'indicators', + persistent: true, + initialState: () => ({}), }, { name: 'channelState', @@ -198,22 +200,70 @@ export interface PathTrigger { */ export interface ChartState { symbol: string; - start_time: number | null; - end_time: number | null; - interval: string; - selected_shapes: string[]; + start_time: number | null; // unix timestamp + end_time: number | null; // unix timestamp + period: string; // OHLC duration (e.g., '15' for 15 minutes) + selected_shapes: string[]; // list of shape ID's } /** - * Chart store - persistent, stores drawings and templates. + * Control point for shapes (drawings/annotations). */ -export interface ChartStore { - drawings: Record; - templates: Record; +export interface ControlPoint { + time: number; // unix timestamp + price: number; + channel?: string; } +/** + * Shape (drawing/annotation) on TradingView chart. + */ +export interface Shape { + id: string; + type: string; + points: ControlPoint[]; + color?: string; + line_width?: number; + line_style?: string; + properties?: Record; + symbol?: string; + created_at?: number; + modified_at?: number; + original_id?: string; +} + +/** + * Shapes store - persistent, stores TradingView drawings and annotations. + */ +export type ShapesStore = Record; + +/** + * Indicator instance on TradingView chart. + */ +export interface IndicatorInstance { + id: string; + talib_name: string; + instance_name: string; + parameters: Record; + tv_study_id?: string; + tv_indicator_name?: string; + tv_inputs?: Record; + visible: boolean; + pane: string; + symbol?: string; + created_at?: number; + modified_at?: number; + original_id?: string; +} + +/** + * Indicators store - persistent, stores TradingView studies/indicators. + */ +export type IndicatorsStore = Record; + /** * Channel state - transient, tracks connected channels. + * NOTE: This store is gateway-only and should NOT be synced to web clients. */ export interface ChannelState { connected: Record; @@ -233,7 +283,8 @@ export interface ChannelInfo { */ export interface WorkspaceStores { chartState: ChartState; - chartStore: ChartStore; + shapes: ShapesStore; + indicators: IndicatorsStore; channelState: ChannelState; [key: string]: unknown; } diff --git a/gateway/src/workspace/workspace-manager.ts b/gateway/src/workspace/workspace-manager.ts index 98885baa..4c346af8 100644 --- a/gateway/src/workspace/workspace-manager.ts +++ b/gateway/src/workspace/workspace-manager.ts @@ -271,6 +271,53 @@ export class WorkspaceManager { return this.registry.getStoreNames(); } + /** + * Serialize entire workspace state as JSON. + */ + serializeState(): string { + const state: Record = {}; + + for (const storeConfig of this.stores) { + const storeState = this.registry.getState(storeConfig.name); + if (storeState !== undefined) { + state[storeConfig.name] = storeState; + } + } + + return JSON.stringify(state, null, 2); + } + + /** + * Get the highest sequence number across all stores. + */ + getCurrentSeq(): number { + let maxSeq = 0; + for (const storeName of this.registry.getStoreNames()) { + const seq = this.registry.getSeq(storeName); + if (seq > maxSeq) { + maxSeq = seq; + } + } + return maxSeq; + } + + /** + * Get all patches since a given sequence number across all stores. + * Returns patches grouped by store name. + */ + getChangesSince(sinceSeq: number): Record { + const changes: Record = {}; + + for (const storeConfig of this.stores) { + const patches = this.registry.getPatchesSince(storeConfig.name, sinceSeq); + if (patches && patches.length > 0) { + changes[storeConfig.name] = patches; + } + } + + return changes; + } + // =========================================================================== // Path Triggers // =========================================================================== diff --git a/web/src/App.vue b/web/src/App.vue index a7d3a594..997d43e8 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -5,10 +5,10 @@ import SplitterPanel from 'primevue/splitterpanel' import ChartView from './components/ChartView.vue' import ChatPanel from './components/ChatPanel.vue' import LoginScreen from './components/LoginScreen.vue' -import { useOrderStore } from './stores/orders' import { useChartStore } from './stores/chart' import { useShapeStore } from './stores/shapes' import { useIndicatorStore } from './stores/indicators' +import { useChannelStore } from './stores/channel' import { useStateSync } from './composables/useStateSync' import { wsManager } from './composables/useWebSocket' import { authService } from './composables/useAuth' @@ -30,10 +30,10 @@ const chartStore = useChartStore() watch(isMobile, (mobile) => { if (mobile) { // Set all chart state to null when chart is hidden - chartStore.chart_state.symbol = null as any - chartStore.chart_state.start_time = null - chartStore.chart_state.end_time = null - chartStore.chart_state.interval = null as any + chartStore.symbol = null as any + chartStore.start_time = null + chartStore.end_time = null + chartStore.period = null as any } }) @@ -90,15 +90,15 @@ const handleAuthenticate = async (email: string, password: string) => { const initializeApp = async () => { // Initialize state sync after successful authentication - const orderStore = useOrderStore() const chartStore = useChartStore() const shapeStore = useShapeStore() const indicatorStore = useIndicatorStore() + const channelStore = useChannelStore() const stateSync = useStateSync({ - OrderStore: orderStore, - ChartStore: chartStore, - ShapeStore: shapeStore, - IndicatorStore: indicatorStore + chartState: chartStore, + shapes: shapeStore, + indicators: indicatorStore, + channelState: channelStore }) stateSyncCleanup = stateSync.cleanup } diff --git a/web/src/components/ChartView.vue b/web/src/components/ChartView.vue index 28ce3ce2..032f6b18 100644 --- a/web/src/components/ChartView.vue +++ b/web/src/components/ChartView.vue @@ -29,9 +29,9 @@ onMounted(() => { datafeed = createTradingViewDatafeed() tvWidget = new window.TradingView.widget({ - symbol: chartStore.chart_state.symbol, // Use symbol from store + symbol: chartStore.symbol, // Use symbol from store datafeed: datafeed, - interval: chartStore.chart_state.interval as any, + interval: chartStore.period as any, container: chartContainer.value!, library_path: '/charting_library/', locale: 'en', @@ -167,8 +167,8 @@ function initializeVisibleRange() { to: new Date(endTime * 1000).toISOString() }) - chartStore.chart_state.start_time = startTime - chartStore.chart_state.end_time = endTime + chartStore.start_time = startTime + chartStore.end_time = endTime } } @@ -183,16 +183,16 @@ function setupChartListeners() { if (symbolInfo && symbolInfo.ticker) { console.log('[ChartView] Symbol changed to:', symbolInfo.ticker) isUpdatingFromChart = true - chartStore.chart_state.symbol = symbolInfo.ticker + chartStore.symbol = symbolInfo.ticker isUpdatingFromChart = false } }) - // Listen for interval changes + // Listen for period changes chart.onIntervalChanged().subscribe(null, (interval: string) => { - console.log('[ChartView] Interval changed to:', interval) + console.log('[ChartView] Period changed to:', interval) isUpdatingFromChart = true - chartStore.chart_state.interval = interval + chartStore.period = interval isUpdatingFromChart = false }) @@ -210,8 +210,8 @@ function setupChartListeners() { }) isUpdatingFromChart = true - chartStore.chart_state.start_time = startTime - chartStore.chart_state.end_time = endTime + chartStore.start_time = startTime + chartStore.end_time = endTime isUpdatingFromChart = false } }) @@ -224,7 +224,7 @@ function setupStoreWatchers() { // Watch for external changes to symbol (e.g., from backend/agent) watch( - () => chartStore.chart_state.symbol, + () => chartStore.symbol, (newSymbol) => { if (isUpdatingFromChart) return // Ignore updates that came from the chart itself @@ -238,16 +238,16 @@ function setupStoreWatchers() { } ) - // Watch for external changes to interval + // Watch for external changes to period watch( - () => chartStore.chart_state.interval, - (newInterval) => { + () => chartStore.period, + (newPeriod) => { if (isUpdatingFromChart) return - console.log('[ChartView] Store interval changed externally to:', newInterval) - if (chart.resolution() !== newInterval) { - chart.setResolution(newInterval, () => { - console.log('[ChartView] Chart interval updated to:', newInterval) + console.log('[ChartView] Store period changed externally to:', newPeriod) + if (chart.resolution() !== newPeriod) { + chart.setResolution(newPeriod, () => { + console.log('[ChartView] Chart period updated to:', newPeriod) }) } } diff --git a/web/src/composables/useTradingViewIndicators.ts b/web/src/composables/useTradingViewIndicators.ts index 196d99d1..9dc25d39 100644 --- a/web/src/composables/useTradingViewIndicators.ts +++ b/web/src/composables/useTradingViewIndicators.ts @@ -412,7 +412,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { return } - const currentSymbol = chartStore.chart_state.symbol + const currentSymbol = chartStore.symbol // If studyId is actually an object, extract the real values let actualStudyId = studyId @@ -539,7 +539,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { return } - const currentSymbol = chartStore.chart_state.symbol + const currentSymbol = chartStore.symbol if (studyId && typeof studyId === 'string') { console.log('[Indicators] Study properties changed for ID:', studyId) @@ -671,7 +671,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { const chart = tvWidget.activeChart() if (!chart) return - const currentSymbol = chartStore.chart_state.symbol + const currentSymbol = chartStore.symbol const allStudies = chart.getAllStudies() if (!allStudies) return @@ -755,7 +755,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { return } - const currentSymbol = chartStore.chart_state.symbol + const currentSymbol = chartStore.symbol // Find added indicators for (const [id, indicator] of Object.entries(newIndicators)) { @@ -815,7 +815,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { const chart = tvWidget.activeChart() if (!chart) return - const currentSymbol = chartStore.chart_state.symbol + const currentSymbol = chartStore.symbol const tvName = ALL_BACKEND_TO_TV_NAMES[indicator.talib_name] if (!tvName) { diff --git a/web/src/composables/useTradingViewShapes.ts b/web/src/composables/useTradingViewShapes.ts index 4cd4a5e3..4821bc8e 100644 --- a/web/src/composables/useTradingViewShapes.ts +++ b/web/src/composables/useTradingViewShapes.ts @@ -207,7 +207,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) { const chart = tvWidget.activeChart() if (!chart) return - const currentSymbol = chartStore.chart_state.symbol + const currentSymbol = chartStore.symbol if (eventType === 'remove') { isUpdatingStore = true @@ -394,7 +394,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) { if (!allShapes) return - const currentSymbol = chartStore.chart_state.symbol + const currentSymbol = chartStore.symbol // Track which shape IDs we've seen const seenIds = new Set() @@ -471,7 +471,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) { const chart = tvWidget.activeChart() if (!chart) return - const currentSymbol = chartStore.chart_state.symbol + const currentSymbol = chartStore.symbol // Find added shapes for (const [id, shape] of Object.entries(newShapes)) { @@ -530,11 +530,11 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) { const chart = tvWidget.activeChart() if (!chart) return - const currentSymbol = chartStore.chart_state.symbol + const currentSymbol = chartStore.symbol - // Get current chart interval and convert to seconds for timestamp canonicalization - const interval = chartStore.chart_state.interval - const intervalSeconds = intervalToSeconds(interval) + // Get current chart period and convert to seconds for timestamp canonicalization + const period = chartStore.period + const intervalSeconds = intervalToSeconds(period) // Convert points to TradingView format and canonicalize timestamps to candle boundaries const tvPoints = shape.points.map(p => { @@ -715,7 +715,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) { .map((obj: any) => obj.id) console.log('[TradingView Shapes] Selection changed:', selectedShapeIds) - chartStore.chart_state.selected_shapes = selectedShapeIds + chartStore.selected_shapes = selectedShapeIds }) } catch (error) { diff --git a/web/src/composables/useWebSocket.ts b/web/src/composables/useWebSocket.ts index bc959973..09548cbb 100644 --- a/web/src/composables/useWebSocket.ts +++ b/web/src/composables/useWebSocket.ts @@ -14,6 +14,11 @@ class WebSocketManager { public isConnected = ref(false) public isAuthenticated = ref(false) private token: string | null = null + private messageQueue: WebSocketMessage[] = [] + private reconnectAttempts = 0 + private maxReconnectAttempts = Infinity // Keep trying indefinitely + private reconnectDelay = 1000 // Start with 1 second + private maxReconnectDelay = 15000 // Max 15 seconds /** * Connect to WebSocket with JWT token for authentication @@ -63,6 +68,7 @@ class WebSocketManager { console.log('[WebSocket] Connected successfully') this.isConnected.value = true this.isAuthenticated.value = false // Wait for 'connected' message from server + this.reconnectAttempts = 0 // Reset reconnection counter resolve() } @@ -76,6 +82,8 @@ class WebSocketManager { if (message.type === 'connected') { console.log('[WebSocket] Received connected message, marking as authenticated') this.isAuthenticated.value = true + // Flush any queued messages now that we're authenticated + this.flushMessageQueue() } // Pass to all handlers @@ -96,6 +104,11 @@ class WebSocketManager { this.isConnected.value = false this.isAuthenticated.value = false console.log('WebSocket disconnected:', event.code, event.reason) + + // Attempt to reconnect if we have a token + if (this.token && !event.wasClean) { + this.scheduleReconnect() + } } // Connection timeout @@ -117,19 +130,69 @@ class WebSocketManager { } send(message: WebSocketMessage) { - console.log('[WebSocket] Attempting to send message:', message.type, 'readyState:', this.ws?.readyState) if (this.ws?.readyState === WebSocket.OPEN) { - console.log('[WebSocket] Sending message:', JSON.stringify(message)) + console.log('[WebSocket] Sending message:', message.type) this.ws.send(JSON.stringify(message)) } else { - console.error('[WebSocket] Cannot send message - WebSocket not open. State:', this.ws?.readyState) + console.log('[WebSocket] Queuing message (not connected yet):', message.type, '- Queue size:', this.messageQueue.length + 1) + this.messageQueue.push(message) + // Trigger reconnection if not already in progress + if (this.token && !this.reconnectTimeout) { + this.scheduleReconnect() + } + } + } + + private scheduleReconnect() { + if (this.reconnectTimeout) { + return // Reconnection already scheduled + } + + // Exponential backoff with max delay + const delay = Math.min( + this.reconnectDelay * Math.pow(2, this.reconnectAttempts), + this.maxReconnectDelay + ) + + console.log(`[WebSocket] Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts + 1})`) + + this.reconnectTimeout = window.setTimeout(() => { + this.reconnectTimeout = null + this.reconnectAttempts++ + + if (this.token) { + console.log('[WebSocket] Attempting to reconnect...') + this.connect(this.token) + .then(() => { + console.log('[WebSocket] Reconnected successfully') + this.reconnectAttempts = 0 // Reset counter on success + this.flushMessageQueue() + }) + .catch(err => { + console.error('[WebSocket] Reconnection failed:', err) + // scheduleReconnect will be called again via onclose + }) + } + }, delay) + } + + private flushMessageQueue() { + if (this.messageQueue.length > 0) { + console.log(`[WebSocket] Flushing ${this.messageQueue.length} queued messages`) + const queue = [...this.messageQueue] + this.messageQueue = [] + queue.forEach(msg => this.send(msg)) } } disconnect() { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null } + this.token = null // Clear token to prevent auto-reconnect + this.messageQueue = [] // Clear message queue + this.reconnectAttempts = 0 if (this.ws) { this.ws.close() this.ws = null diff --git a/web/src/stores/channel.ts b/web/src/stores/channel.ts new file mode 100644 index 00000000..b30e3d8a --- /dev/null +++ b/web/src/stores/channel.ts @@ -0,0 +1,24 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export interface ChannelInfo { + type: string + connectedAt: number + capabilities: { + supportsSync: boolean + supportsImages: boolean + supportsMarkdown: boolean + supportsStreaming: boolean + supportsTradingViewEmbed: boolean + } +} + +export interface ChannelState { + connected: Record +} + +export const useChannelStore = defineStore('channelState', () => { + const connected = ref>({}) + + return { connected } +}) diff --git a/web/src/stores/chart.ts b/web/src/stores/chart.ts index a0e8db58..b6f77662 100644 --- a/web/src/stores/chart.ts +++ b/web/src/stores/chart.ts @@ -5,18 +5,16 @@ export interface ChartState { symbol: string start_time: number | null end_time: number | null - interval: string + period: string selected_shapes: string[] } -export const useChartStore = defineStore('ChartStore', () => { - const chart_state = ref({ - symbol: 'BINANCE:BTC/USDT', - start_time: null, - end_time: null, - interval: '15', - selected_shapes: [] - }) +export const useChartStore = defineStore('chartState', () => { + const symbol = ref('BINANCE:BTC/USDT') + const start_time = ref(null) + const end_time = ref(null) + const period = ref('15') + const selected_shapes = ref([]) - return { chart_state } + return { symbol, start_time, end_time, period, selected_shapes } }) diff --git a/web/src/stores/indicators.ts b/web/src/stores/indicators.ts index 1366c3ff..a51fef14 100644 --- a/web/src/stores/indicators.ts +++ b/web/src/stores/indicators.ts @@ -17,7 +17,7 @@ export interface IndicatorInstance { original_id?: string } -export const useIndicatorStore = defineStore('IndicatorStore', () => { +export const useIndicatorStore = defineStore('indicators', () => { const indicators = ref>({}) // Helper methods diff --git a/web/src/stores/shapes.ts b/web/src/stores/shapes.ts index 1e796da7..dee30007 100644 --- a/web/src/stores/shapes.ts +++ b/web/src/stores/shapes.ts @@ -21,7 +21,7 @@ export interface Shape { original_id?: string // Original ID from backend/agent before TradingView assigns its own ID } -export const useShapeStore = defineStore('ShapeStore', () => { +export const useShapeStore = defineStore('shapes', () => { const shapes = ref>({}) // Helper methods