Files
TraderAI/traderai/tools.py
T
2026-06-09 11:24:15 -04:00

2854 lines
130 KiB
Python

from __future__ import annotations
import uuid
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
from traderai.memory import MemoryStore
from traderai.negotiations import UEX_NEGOTIATION_CLOSE_ENDPOINT
from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
from traderai.uex_client import UEXClient
from traderai.wikelo_projects_client import WikeloProjectsClient
ToolHandler = Callable[..., Awaitable[dict[str, Any]]]
UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
"categories": {"params": ["type", "section"], "auth": False, "group": "reference"},
"categories_attributes": {"params": ["id_category", "category_name", "category_type"], "auth": False, "group": "reference"},
"cities": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"commodities": {"params": ["id", "name", "code", "slug"], "auth": False, "group": "trade"},
"commodities_alerts": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "trade"},
"commodities_averages": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "trade"},
"commodities_prices": {
"params": ["id_terminal", "id_commodity", "terminal_name", "terminal_code", "terminal_slug", "commodity_name", "commodity_code", "commodity_slug"],
"auth": False,
"group": "trade",
},
"commodities_prices_all": {"params": [], "auth": False, "group": "trade", "heavy": True},
"commodities_prices_history": {"params": ["id_terminal", "id_commodity", "game_version"], "auth": False, "group": "trade", "history": True},
"commodities_ranking": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "trade"},
"commodities_raw_averages": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "mining"},
"commodities_raw_prices": {"params": ["id_terminal", "id_commodity", "terminal_name", "commodity_name"], "auth": False, "group": "mining"},
"commodities_raw_prices_all": {"params": [], "auth": False, "group": "mining", "heavy": True},
"commodities_routes": {"params": ["id_terminal_origin", "id_terminal_destination", "id_commodity", "terminal_origin_name", "terminal_destination_name", "commodity_name"], "auth": False, "group": "trade"},
"commodities_status": {"params": [], "auth": False, "group": "trade"},
"companies": {"params": ["id", "name", "code"], "auth": False, "group": "reference"},
"contacts": {"params": ["id", "name"], "auth": False, "group": "reference"},
"contracts": {"params": ["id", "name", "slug"], "auth": False, "group": "reference"},
"crew": {"params": ["id", "name", "slug"], "auth": False, "group": "reference"},
"currencies_index": {"params": ["code"], "auth": False, "group": "reference"},
"currencies_index_history": {"params": ["currency", "date_from", "date_to"], "auth": False, "group": "reference", "history": True},
"data_extract": {"params": ["table"], "auth": False, "group": "data"},
"data_parameters": {"params": ["endpoint"], "auth": False, "group": "data"},
"factions": {"params": ["id", "name", "slug"], "auth": False, "group": "reference"},
"fleet": {"params": ["username"], "auth": False, "group": "vehicles"},
"fuel_prices": {"params": ["id_terminal", "terminal_name", "terminal_code", "terminal_slug"], "auth": False, "group": "trade"},
"fuel_prices_all": {"params": [], "auth": False, "group": "trade", "heavy": True},
"game_versions": {"params": [], "auth": False, "group": "reference"},
"items": {"params": ["id", "id_category", "name", "uuid", "slug"], "auth": False, "group": "items"},
"items_attributes": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "items"},
"items_prices": {"params": ["id_item", "id_terminal", "item_name", "terminal_name"], "auth": False, "group": "items"},
"items_prices_all": {"params": [], "auth": False, "group": "items", "heavy": True},
"jump_points": {"params": ["id", "name", "slug"], "auth": False, "group": "locations"},
"jurisdictions": {"params": ["id", "name"], "auth": False, "group": "locations"},
"marketplace_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"},
"marketplace_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
"marketplace_favorites": {"params": ["id_listing"], "auth": True, "group": "marketplace"},
"marketplace_listings": {"params": ["id", "slug", "username", "id_item", "operation"], "auth": False, "group": "marketplace"},
"marketplace_negotiations": {"params": ["id", "id_listing", "hash"], "auth": True, "group": "marketplace"},
"marketplace_negotiations_messages": {"params": ["hash", "id_negotiation"], "auth": True, "group": "marketplace"},
"marketplace_prices_averages": {
"params": ["id_item", "item_name", "item_slug", "id_category", "currency", "quality_tier"],
"auth": False,
"group": "marketplace",
},
"marketplace_prices_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
"marketplace_prices_history": {
"params": [
"id_item",
"id_listing",
"id_terminal",
"id_star_system",
"id_category",
"item_uuid",
"item_name",
"operation",
"quality_tier",
"currency",
"game_version",
"date_start",
"date_end",
],
"auth": False,
"group": "marketplace",
"history": True,
},
"marketplace_trends": {
"params": ["id_item", "item_name", "item_slug", "id_category", "currency", "quality_tier"],
"auth": False,
"group": "marketplace",
},
"moons": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"orbits_distances": {"params": ["id_origin", "id_destination"], "auth": False, "group": "locations"},
"organizations": {"params": ["sid", "name"], "auth": False, "group": "reference"},
"outposts": {"params": ["id", "id_moon", "id_planet", "name", "slug"], "auth": False, "group": "locations"},
"planets": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"poi": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"refineries_audits": {"params": ["id_terminal", "terminal_name"], "auth": False, "group": "mining"},
"refineries_capacities": {"params": ["id_terminal", "terminal_name"], "auth": False, "group": "mining"},
"refineries_methods": {"params": ["id", "name"], "auth": False, "group": "mining"},
"refineries_yields": {"params": ["id_terminal", "id_commodity", "terminal_name", "commodity_name"], "auth": False, "group": "mining"},
"release_notes": {"params": [], "auth": False, "group": "reference"},
"space_stations": {"params": ["id", "id_star_system", "id_planet", "id_moon", "name", "slug"], "auth": False, "group": "locations"},
"star_systems": {"params": ["id", "name", "code", "slug"], "auth": False, "group": "locations"},
"terminals": {"params": ["id", "id_star_system", "name", "code", "slug"], "auth": False, "group": "locations"},
"terminals_distances": {"params": ["id_terminal_origin", "id_terminal_destination"], "auth": False, "group": "locations"},
"user": {"params": ["username"], "auth": False, "group": "user"},
"user_notifications": {"params": [], "auth": True, "group": "user"},
"user_refineries_jobs": {"params": ["id"], "auth": True, "group": "user"},
"user_trades": {"params": ["id"], "auth": True, "group": "user"},
"vehicles": {"params": ["id", "name", "slug", "uuid"], "auth": False, "group": "vehicles"},
"vehicles_loaners": {"params": ["id_vehicle", "vehicle_name", "vehicle_slug"], "auth": False, "group": "vehicles"},
"vehicles_prices": {"params": ["id_vehicle", "vehicle_name", "vehicle_slug"], "auth": False, "group": "vehicles"},
"vehicles_purchases_prices": {"params": ["id_vehicle", "id_terminal", "vehicle_name", "terminal_name"], "auth": False, "group": "vehicles"},
"vehicles_purchases_prices_all": {"params": [], "auth": False, "group": "vehicles", "heavy": True},
"vehicles_rentals_prices": {"params": ["id_vehicle", "id_terminal", "vehicle_name", "terminal_name"], "auth": False, "group": "vehicles"},
"vehicles_rentals_prices_all": {"params": [], "auth": False, "group": "vehicles", "heavy": True},
"wallet_balance": {"params": [], "auth": True, "group": "user"},
}
UEX_POST_RESOURCES = {
"data_submit",
"marketplace_advertise",
"marketplace_negotiations_messages",
"user_refineries_jobs_add",
"user_trades_add",
"user_trades_edit",
"wallet_add",
}
UEX_DELETE_RESOURCES = {
"marketplace_listings",
"user_refineries_jobs_remove",
"user_trades_remove",
}
UEX_RESOURCE_DESCRIPTIONS = {
"commodities_prices_history": "Historical commodity prices at a terminal. Requires id_terminal and id_commodity; accepts game_version. UEX limits this to 500 rows.",
"marketplace_prices_history": "Historical marketplace price snapshots, one row per listing per price change. Requires at least one filter; supports date_start/date_end and up to 1000 records.",
"marketplace_trends": "Current UEX marketplace trend metrics for an item. Use this when the user asks for trends, price movement, demand, or what the market is doing now.",
"currencies_index_history": "Historical UEX currency index snapshots with basket component detail. Supports currency, date_from, and date_to timestamps.",
}
UEX_PRODUCTION_WRITE_RESOURCES = {
"marketplace_advertise",
"marketplace_negotiations_messages",
UEX_NEGOTIATION_CLOSE_ENDPOINT,
}
@dataclass
class PendingAction:
id: str
label: str
endpoint: str
payload: dict[str, Any]
method: str = "POST"
metadata: dict[str, Any] | None = None
class ToolRegistry:
def __init__(
self,
uex: UEXClient,
require_write_approval: bool = True,
memory: MemoryStore | None = None,
scheduler: WakeScheduler | None = None,
scmdb: SCMDBClient | None = None,
cornerstone: CornerstoneClient | None = None,
scwiki: StarCitizenWikiClient | None = None,
wikelo: WikeloProjectsClient | None = None,
plan_store: Any | None = None,
plan_runner: Any | None = None,
negotiation_sync: Any | None = None,
) -> None:
self.uex = uex
self.scmdb = scmdb or SCMDBClient()
self.cornerstone = cornerstone or CornerstoneClient()
self.scwiki = scwiki or StarCitizenWikiClient()
self.wikelo = wikelo or WikeloProjectsClient()
self.require_write_approval = require_write_approval
self.memory = memory
self.scheduler = scheduler
self.plan_store = plan_store
self.plan_runner = plan_runner
self.negotiation_sync = negotiation_sync
self.pending_actions: dict[str, PendingAction] = {}
self._chat_images_var: ContextVar[list[dict[str, Any]]] = ContextVar("chat_images", default=[])
self.handlers: dict[str, ToolHandler] = {
"search_marketplace_listings": self.search_marketplace_listings,
"get_marketplace_listing": self.get_marketplace_listing,
"get_marketplace_trends": self.get_marketplace_trends,
"list_marketplace_negotiations": self.list_marketplace_negotiations,
"get_negotiation_messages": self.get_negotiation_messages,
"draft_negotiation_message": self.draft_negotiation_message,
"list_local_negotiations": self.list_local_negotiations,
"get_local_negotiation": self.get_local_negotiation,
"search_local_negotiation_messages": self.search_local_negotiation_messages,
"draft_negotiation_close": self.draft_negotiation_close,
"draft_negotiation_rating": self.draft_negotiation_rating,
"draft_marketplace_listing": self.draft_marketplace_listing,
"remember_user_fact": self.remember_user_fact,
"recall_memory": self.recall_memory,
"schedule_wake_job": self.schedule_wake_job,
"list_wake_jobs": self.list_wake_jobs,
"create_continual_plan": self.create_continual_plan,
"list_continual_plans": self.list_continual_plans,
"get_continual_plan": self.get_continual_plan,
"pause_continual_plan": self.pause_continual_plan,
"resume_continual_plan": self.resume_continual_plan,
"cancel_continual_plan": self.cancel_continual_plan,
"delete_continual_plan": self.delete_continual_plan,
"run_continual_plan_now": self.run_continual_plan_now,
"check_uex_notifications": self.check_uex_notifications,
"list_scmdb_versions": self.list_scmdb_versions,
"search_scmdb_missions": self.search_scmdb_missions,
"get_scmdb_mission_rewards": self.get_scmdb_mission_rewards,
"search_scwiki_pages": self.search_scwiki_pages,
"get_scwiki_page": self.get_scwiki_page,
"search_scwiki_vehicles": self.search_scwiki_vehicles,
"get_scwiki_vehicle": self.get_scwiki_vehicle,
"search_wikelo_ship_projects": self.search_wikelo_ship_projects,
"get_wikelo_ship_project": self.get_wikelo_ship_project,
"search_cornerstone_items": self.search_cornerstone_items,
"get_cornerstone_item_locations": self.get_cornerstone_item_locations,
"get_cornerstone_item_media": self.get_cornerstone_item_media,
"draft_marketplace_listing_with_cornerstone_image": self.draft_marketplace_listing_with_cornerstone_image,
}
self.handlers["uex_api_catalog"] = self.uex_api_catalog
self.handlers["uex_get"] = self.uex_get
self.handlers["uex_draft_post"] = self.uex_draft_post
self.handlers["uex_draft_delete"] = self.uex_draft_delete
self.handlers["search_uex_api_index"] = self.search_uex_api_index
self.handlers["summarize_uex_commodity_price_history"] = self.summarize_uex_commodity_price_history
self.handlers["summarize_uex_marketplace_price_history"] = self.summarize_uex_marketplace_price_history
self.handlers["summarize_uex_currency_index_history"] = self.summarize_uex_currency_index_history
for resource in UEX_GET_RESOURCES:
self.handlers[self._get_tool_name(resource)] = self._make_get_handler(resource)
for resource in UEX_POST_RESOURCES:
self.handlers[self._post_tool_name(resource)] = self._make_post_handler(resource)
for resource in UEX_DELETE_RESOURCES:
self.handlers[self._delete_tool_name(resource)] = self._make_delete_handler(resource)
@property
def schemas(self) -> list[dict[str, Any]]:
return [
self._api_index_schema(),
*self._uex_get_schemas(),
*self._history_summary_schemas(),
*self._uex_post_schemas(),
*self._uex_delete_schemas(),
*self._scmdb_schemas(),
*self._scwiki_schemas(),
*self._wikelo_schemas(),
*self._cornerstone_schemas(),
{
"type": "function",
"function": {
"name": "search_marketplace_listings",
"description": "Search active/current UEX marketplace listings only. Prices are in-game aUEC/UEC credits, not real-world dollars. Do not use this as historical sale or completed-sale information. UEX returns up to 100 active listings; filters are applied locally.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Text to search in title, description, location, advertiser, or slug."},
"operation": {"type": "string", "enum": ["buy", "sell"]},
"type": {"type": "string", "enum": ["item", "service", "contract"]},
"username": {"type": "string", "description": "Advertiser IGN."},
"location": {"type": "string"},
"min_price": {"type": "number"},
"max_price": {"type": "number"},
"limit": {"type": "integer", "minimum": 1, "maximum": 25},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_marketplace_listing",
"description": "Fetch a specific UEX marketplace listing by id or slug.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"slug": {"type": "string"},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_marketplace_trends",
"description": "Fetch current UEX marketplace trend metrics for an item, including WTS and WTB averages plus negotiation counts.",
"parameters": {
"type": "object",
"properties": {
"id_item": {"type": "integer"},
"item_name": {"type": "string"},
"item_slug": {"type": "string"},
"id_category": {"type": "integer"},
"currency": {"type": "string", "description": "Optional currency filter such as UEC, WIF, or MGS."},
"quality_tier": {"type": "integer", "minimum": 0, "maximum": 7},
},
},
},
},
{
"type": "function",
"function": {
"name": "list_marketplace_negotiations",
"description": "List authenticated marketplace negotiations for the configured UEX user.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"id_listing": {"type": "integer"},
"hash": {"type": "string"},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_negotiation_messages",
"description": "Fetch authenticated messages from a marketplace negotiation by hash or id_negotiation.",
"parameters": {
"type": "object",
"properties": {
"hash": {"type": "string"},
"id_negotiation": {"type": "integer"},
},
},
},
},
{
"type": "function",
"function": {
"name": "draft_negotiation_message",
"description": "Draft a message or offer to a UEX negotiation. This creates a pending action that must be approved before sending.",
"parameters": {
"type": "object",
"required": ["message"],
"properties": {
"message": {"type": "string"},
"hash": {"type": "string"},
"id_negotiation": {"type": "integer"},
"id_listing": {"type": "integer"},
"plan_id": {"type": "string"},
"plan_item_id": {"type": "integer"},
"candidate_id": {"type": "integer"},
"listing_slug": {"type": "string"},
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
},
},
},
},
{
"type": "function",
"function": {
"name": "list_local_negotiations",
"description": "List locally synced UEX negotiations with unread and status details.",
"parameters": {
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["all", "open", "closed"]},
"unread_only": {"type": "boolean"},
"search": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_local_negotiation",
"description": "Get a locally synced UEX negotiation with compact metadata and recent messages.",
"parameters": {
"type": "object",
"properties": {
"hash": {"type": "string"},
},
"required": ["hash"],
},
},
},
{
"type": "function",
"function": {
"name": "search_local_negotiation_messages",
"description": "Search locally cached negotiation message text so the assistant can reference prior UEX conversations without re-fetching them.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 20},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "draft_negotiation_close",
"description": "Draft closing or rating a UEX negotiation. This creates a pending action that must be approved before sending.",
"parameters": {
"type": "object",
"properties": {
"hash": {"type": "string"},
"id_negotiation": {"type": "integer"},
"deal_closed": {"type": "boolean"},
"deal_value": {"type": "number"},
"currency": {"type": "string"},
"clarity_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"speed_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"respect_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"fairness_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"comment": {"type": "string"},
},
"required": ["deal_closed"],
},
},
},
{
"type": "function",
"function": {
"name": "draft_negotiation_rating",
"description": "Alias for drafting a UEX negotiation close/rating action.",
"parameters": {
"type": "object",
"properties": {
"hash": {"type": "string"},
"id_negotiation": {"type": "integer"},
"deal_closed": {"type": "boolean"},
"deal_value": {"type": "number"},
"currency": {"type": "string"},
"clarity_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"speed_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"respect_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"fairness_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"comment": {"type": "string"},
},
"required": ["deal_closed"],
},
},
},
{
"type": "function",
"function": {
"name": "draft_marketplace_listing",
"description": "Draft a new UEX marketplace listing. Listing prices are in-game aUEC/UEC credits, not real-world dollars. This creates a pending action that must be approved before posting. Prefer draft_marketplace_listing_with_cornerstone_image for item posts when a Cornerstone image is useful.",
"parameters": {
"type": "object",
"required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"],
"properties": {
"id_item": {"type": "integer"},
"id_star_system": {"type": "integer"},
"id_organization": {"type": "integer"},
"id_category": {"type": "integer"},
"operation": {"type": "string", "enum": ["buy", "sell"]},
"type": {"type": "string", "enum": ["item", "service", "contract"]},
"unit": {"type": "string"},
"title": {"type": "string"},
"description": {"type": "string"},
"price": {"type": "number"},
"currency": {"type": "string", "enum": ["UEC", "WIF"]},
"language": {"type": "string", "default": "en_US"},
"location": {"type": "string"},
"source": {"type": "string"},
"availability": {"type": "string"},
"in_stock": {"type": "integer"},
"durability": {"type": "integer", "minimum": 0, "maximum": 100},
"video_url": {"type": "string"},
"image_data": {"type": "string", "description": "Base64 JPG or PNG image data for UEX upload."},
"use_attached_image": {
"type": "boolean",
"description": "When true, reuse an image pasted into the current chat as the listing image_data.",
},
"attached_image_index": {
"type": "integer",
"minimum": 0,
"description": "Zero-based pasted image index to reuse when use_attached_image is true.",
},
"hours_expiration": {"type": "integer"},
"is_hidden": {"type": "integer", "enum": [0, 1]},
"is_tv_allowed": {"type": "integer", "enum": [0, 1]},
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
},
},
},
},
{
"type": "function",
"function": {
"name": "remember_user_fact",
"description": "Persist a durable user preference, identity detail, trading rule, or long-term note for future chats.",
"parameters": {
"type": "object",
"required": ["content"],
"properties": {
"content": {"type": "string"},
"kind": {"type": "string", "enum": ["user", "preference", "trading", "project", "note"], "default": "note"},
"importance": {"type": "integer", "minimum": 1, "maximum": 5, "default": 3},
},
},
},
},
{
"type": "function",
"function": {
"name": "recall_memory",
"description": "Search long-term memory for relevant prior facts, preferences, and chat context.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 6},
},
},
},
},
{
"type": "function",
"function": {
"name": "schedule_wake_job",
"description": "Create a scheduled wake-up job for the assistant. Use either run_at for one-time jobs or cron for recurring jobs.",
"parameters": {
"type": "object",
"required": ["prompt"],
"properties": {
"prompt": {"type": "string", "description": "What the AI should consider or do when it wakes."},
"run_at": {"type": "string", "description": "ISO datetime for a one-time wake job, such as 2026-05-05T20:30:00-04:00."},
"cron": {"type": "string", "description": "Five-field cron expression for recurring jobs, such as 0 9 * * *."},
},
},
},
},
{
"type": "function",
"function": {
"name": "list_wake_jobs",
"description": "List currently enabled scheduled assistant wake jobs.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "create_continual_plan",
"description": "Create a durable multi-run plan. Use this for long-running marketplace work over days. kind=buying uses structured listing/candidate tracking; kind=custom continues through an agent wake prompt. All UEX writes are draft-only for approval.",
"parameters": {
"type": "object",
"required": ["title", "objective"],
"properties": {
"title": {"type": "string"},
"objective": {"type": "string"},
"kind": {"type": "string", "enum": ["buying", "custom"], "default": "buying"},
"cadence": {"type": "string", "description": "Five-field cron expression, default every six hours."},
"constraints": {"type": "object", "description": "Plan-specific options such as message_tone, excluded_sellers, preferred_locations, max_unit_price, or custom instructions."},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"item_name": {"type": "string"},
"desired_quantity": {"type": "integer", "minimum": 1},
"max_unit_price": {"type": "number"},
},
},
},
},
},
},
},
{
"type": "function",
"function": {
"name": "list_continual_plans",
"description": "List durable continual plans and their statuses.",
"parameters": {"type": "object", "properties": {"include_inactive": {"type": "boolean", "default": True}}},
},
},
{
"type": "function",
"function": {
"name": "get_continual_plan",
"description": "Get one continual plan with checklist items, candidates, negotiations, and event history.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{
"type": "function",
"function": {
"name": "pause_continual_plan",
"description": "Pause a continual plan so scheduled runs stop.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{
"type": "function",
"function": {
"name": "resume_continual_plan",
"description": "Resume a paused or needs-input continual plan. It only becomes active when it has checklist items.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{
"type": "function",
"function": {
"name": "cancel_continual_plan",
"description": "Cancel a continual plan.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{
"type": "function",
"function": {
"name": "delete_continual_plan",
"description": "Delete a continual plan and all of its stored checklist items, candidates, negotiations, and event history.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{
"type": "function",
"function": {
"name": "run_continual_plan_now",
"description": "Run one continual plan immediately and put the result in the Inbox.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{
"type": "function",
"function": {
"name": "check_uex_notifications",
"description": "Check authenticated UEX user notifications and return unread pending notifications.",
"parameters": {"type": "object", "properties": {}},
},
},
]
async def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
handler = self.handlers.get(name)
if not handler:
return {"error": f"Unknown tool: {name}"}
try:
return await handler(**arguments)
except Exception as exc:
return {"error": str(exc)}
@contextmanager
def chat_image_scope(self, images: list[dict[str, Any]] | None):
token = self._chat_images_var.set(self._normalize_chat_images(images))
try:
yield
finally:
self._chat_images_var.reset(token)
async def approve(self, action_id: str) -> dict[str, Any]:
action = self.pending_actions.pop(action_id, None)
if not action:
return {"error": f"Pending action not found: {action_id}"}
if action.method == "DELETE":
result = await self.uex.delete(action.endpoint, action.payload, authenticated=True)
else:
result = await self.uex.post(action.endpoint, self._production_payload(action.endpoint, action.payload), authenticated=True)
self._record_pending_action_result(action, "approved", result)
return result
async def decline(self, action_id: str) -> dict[str, Any]:
action = self.pending_actions.pop(action_id, None)
if not action:
return {"error": f"Pending action not found: {action_id}"}
self._record_pending_action_result(action, "declined", {})
return {
"declined": True,
"pending_action": {
"id": action.id,
"label": action.label,
"method": action.method,
"endpoint": action.endpoint,
"payload": self._display_payload(action.payload),
"metadata": action.metadata or {},
},
}
async def uex_api_catalog(self, group: str | None = None, resource: str | None = None) -> dict[str, Any]:
if resource:
key = self._validate_resource(resource, UEX_GET_RESOURCES)
info = UEX_GET_RESOURCES[key]
return {
"resource": key,
"method": "GET",
"group": info["group"],
"authenticated": info["auth"],
"heavy": bool(info.get("heavy")),
"params": info["params"],
"write_resources": {
"post": sorted(UEX_POST_RESOURCES),
"delete": sorted(UEX_DELETE_RESOURCES),
},
}
grouped: dict[str, list[dict[str, Any]]] = {}
for name, info in sorted(UEX_GET_RESOURCES.items()):
if group and info["group"] != group:
continue
grouped.setdefault(info["group"], []).append(
{
"resource": name,
"params": info["params"],
"auth": info["auth"],
"heavy": bool(info.get("heavy")),
}
)
return {
"get": grouped,
"post": sorted(UEX_POST_RESOURCES),
"delete": sorted(UEX_DELETE_RESOURCES),
"usage": "Call uex_get(resource, params, fields, limit, mode). Use fields and limit to keep responses small.",
}
async def uex_get(
self,
resource: str,
params: dict[str, Any] | None = None,
fields: list[str] | None = None,
search: str | None = None,
limit: int = 10,
offset: int = 0,
mode: str = "summary",
) -> dict[str, Any]:
resource = self._validate_resource(resource, UEX_GET_RESOURCES)
info = UEX_GET_RESOURCES[resource]
cleaned_params = self._filter_params(params or {}, info["params"])
response = await self.uex.get(resource, cleaned_params, authenticated=bool(info["auth"]))
data = response.get("data")
items = self._as_list(data)
total = len(items)
if search:
needle = search.casefold()
items = [item for item in items if needle in self._search_text(item)]
filtered_total = len(items)
offset = max(0, offset)
limit = max(1, min(limit, 100))
window = items[offset : offset + limit]
compacted = [
self._project_item(item, fields=fields, mode=mode)
for item in window
]
return {
"status": response.get("status"),
"resource": resource,
"params": cleaned_params,
"total": total,
"matched": filtered_total,
"returned": len(compacted),
"offset": offset,
"truncated": offset + len(compacted) < filtered_total,
"items": compacted,
}
async def uex_draft_post(self, resource: str, payload: dict[str, Any], label: str | None = None) -> dict[str, Any]:
resource = self._validate_resource(resource, UEX_POST_RESOURCES)
return self._pending(label or f"POST {resource}", resource, payload, method="POST")
async def uex_draft_delete(
self,
resource: str,
params: dict[str, Any] | None = None,
label: str | None = None,
) -> dict[str, Any]:
resource = self._validate_resource(resource, UEX_DELETE_RESOURCES)
return self._pending(label or f"DELETE {resource}", resource, params or {}, method="DELETE")
async def search_uex_api_index(
self,
query: str = "",
group: str | None = None,
history_only: bool = False,
limit: int = 20,
) -> dict[str, Any]:
needle = query.casefold().strip()
matches = []
for resource, info in sorted(UEX_GET_RESOURCES.items()):
if group and info["group"] != group:
continue
if history_only and not info.get("history"):
continue
haystack = " ".join(
[
resource,
info["group"],
" ".join(info["params"]),
UEX_RESOURCE_DESCRIPTIONS.get(resource, ""),
]
).casefold()
if needle and needle not in haystack:
continue
matches.append(self._resource_index_entry("GET", resource, info))
if len(matches) >= max(1, min(limit, 50)):
break
post_matches = []
if not history_only:
for resource in sorted(UEX_POST_RESOURCES):
if group and group != "write":
continue
if needle and needle not in resource.casefold():
continue
post_matches.append(
{
"method": "POST",
"resource": resource,
"tool": self._post_tool_name(resource),
"approval_required": True,
"docs_url": self._docs_url("post", resource),
}
)
delete_matches = []
if not history_only:
for resource in sorted(UEX_DELETE_RESOURCES):
if group and group != "write":
continue
if needle and needle not in resource.casefold():
continue
delete_matches.append(
{
"method": "DELETE",
"resource": resource,
"tool": self._delete_tool_name(resource),
"approval_required": True,
"docs_url": self._docs_url("delete", resource),
}
)
return {
"count": len(matches) + len(post_matches) + len(delete_matches),
"get": matches,
"post": post_matches[: max(0, min(limit, 50) - len(matches))],
"delete": delete_matches[: max(0, min(limit, 50) - len(matches) - len(post_matches))],
}
async def summarize_uex_commodity_price_history(
self,
id_terminal: int,
id_commodity: int,
game_version: str | None = None,
limit: int = 100,
) -> dict[str, Any]:
return await self._history_summary(
"commodities_prices_history",
{"id_terminal": id_terminal, "id_commodity": id_commodity, "game_version": game_version},
value_fields=["price_buy", "price_sell", "scu_buy", "scu_sell", "scu_sell_stock"],
label_fields=["commodity_name", "terminal_name", "game_version"],
limit=limit,
)
async def summarize_uex_marketplace_price_history(
self,
id_item: str | int | None = None,
id_listing: int | None = None,
id_terminal: int | None = None,
id_star_system: int | None = None,
id_category: int | None = None,
item_uuid: str | None = None,
item_name: str | None = None,
operation: str | None = None,
quality_tier: int | None = None,
currency: str | None = None,
game_version: str | None = None,
date_start: str | None = None,
date_end: str | None = None,
limit: int = 250,
) -> dict[str, Any]:
params = {
"id_item": id_item,
"id_listing": id_listing,
"id_terminal": id_terminal,
"id_star_system": id_star_system,
"id_category": id_category,
"item_uuid": item_uuid,
"item_name": item_name,
"operation": operation,
"quality_tier": quality_tier,
"currency": currency,
"game_version": game_version,
"date_start": date_start,
"date_end": date_end,
}
return await self._history_summary(
"marketplace_prices_history",
params,
value_fields=["price", "quality"],
label_fields=["item_name", "operation", "currency", "terminal_name", "game_version"],
limit=limit,
)
async def summarize_uex_currency_index_history(
self,
currency: str | None = None,
date_from: int | None = None,
date_to: int | None = None,
limit: int = 365,
) -> dict[str, Any]:
return await self._history_summary(
"currencies_index_history",
{"currency": currency, "date_from": date_from, "date_to": date_to},
value_fields=["index_value", "basket_value", "data_window_days"],
label_fields=["currency", "methodology"],
limit=limit,
)
def _make_get_handler(self, resource: str) -> ToolHandler:
async def handler(**arguments: Any) -> dict[str, Any]:
fields = arguments.pop("fields", None)
search = arguments.pop("search", None)
limit = arguments.pop("limit", 10)
offset = arguments.pop("offset", 0)
mode = arguments.pop("mode", "summary")
return await self.uex_get(
resource,
params=arguments,
fields=fields,
search=search,
limit=limit,
offset=offset,
mode=mode,
)
return handler
def _make_post_handler(self, resource: str) -> ToolHandler:
async def handler(payload: dict[str, Any], label: str | None = None) -> dict[str, Any]:
return await self.uex_draft_post(resource, payload, label=label)
return handler
def _make_delete_handler(self, resource: str) -> ToolHandler:
async def handler(label: str | None = None, **params: Any) -> dict[str, Any]:
return await self.uex_draft_delete(resource, params, label=label)
return handler
@classmethod
def _uex_get_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": cls._get_tool_name(resource),
"description": cls._get_tool_description(resource, info),
"parameters": cls._get_tool_parameters(info["params"]),
},
}
for resource, info in sorted(UEX_GET_RESOURCES.items())
]
@classmethod
def _api_index_schema(cls) -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": "search_uex_api_index",
"description": "Search the indexed UEX API tool catalog by topic, resource, parameter, or group. Use to discover exact tool names, especially history tools.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"group": {
"type": "string",
"enum": ["trade", "marketplace", "items", "vehicles", "locations", "mining", "user", "reference", "data", "write"],
},
"history_only": {"type": "boolean", "default": False},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20},
},
},
},
}
@classmethod
def _history_summary_schemas(cls) -> list[dict[str, Any]]:
controls = {
"limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 250},
}
return [
{
"type": "function",
"function": {
"name": "summarize_uex_commodity_price_history",
"description": "Summarize historical commodity price and inventory changes for one commodity at one terminal.",
"parameters": {
"type": "object",
"required": ["id_terminal", "id_commodity"],
"properties": {
"id_terminal": {"type": "integer"},
"id_commodity": {"type": "integer"},
"game_version": {"type": "string"},
**controls,
},
},
},
},
{
"type": "function",
"function": {
"name": "summarize_uex_marketplace_price_history",
"description": "Summarize marketplace historical price snapshots for an item, listing, terminal, category, system, or date range.",
"parameters": {
"type": "object",
"properties": {
"id_item": {"oneOf": [{"type": "integer"}, {"type": "string"}]},
"id_listing": {"type": "integer"},
"id_terminal": {"type": "integer"},
"id_star_system": {"type": "integer"},
"id_category": {"type": "integer"},
"item_uuid": {"type": "string"},
"item_name": {"type": "string"},
"operation": {"type": "string", "enum": ["buy", "sell"]},
"quality_tier": {"type": "integer", "minimum": 0, "maximum": 4},
"currency": {"type": "string"},
"game_version": {"type": "string"},
"date_start": {"type": "string", "description": "YYYY-MM-DD"},
"date_end": {"type": "string", "description": "YYYY-MM-DD"},
**controls,
},
},
},
},
{
"type": "function",
"function": {
"name": "summarize_uex_currency_index_history",
"description": "Summarize historical UEX currency index snapshots and basket value changes.",
"parameters": {
"type": "object",
"properties": {
"currency": {"type": "string"},
"date_from": {"type": "integer", "description": "Unix timestamp."},
"date_to": {"type": "integer", "description": "Unix timestamp."},
**controls,
},
},
},
},
]
@classmethod
def _scmdb_schemas(cls) -> list[dict[str, Any]]:
version_controls = {
"version": {"type": "string", "description": "SCMDB game-data version, such as 4.7.2-live.11715810."},
"channel": {"type": "string", "enum": ["live", "ptu", "latest"], "default": "live"},
}
return [
{
"type": "function",
"function": {
"name": "list_scmdb_versions",
"description": "List SCMDB mission-data versions. Use this when the user asks which Star Citizen game versions are available.",
"parameters": {
"type": "object",
"properties": {
"channel": {"type": "string", "enum": ["live", "ptu", "latest"]},
},
},
},
},
{
"type": "function",
"function": {
"name": "search_scmdb_missions",
"description": "Search SCMDB Star Citizen missions/contracts and return compact reward summaries: UEC, reputation, item, blueprint, and hauling rewards.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Text to search in title, debug name, description, faction, mission type, or reward names."},
"mission_type": {"type": "string", "description": "Mission type such as Hauling, Delivery, Bounty Hunter, Mercenary, Racing, Salvage, or Mining."},
"category": {"type": "string"},
"faction": {"type": "string"},
"system": {"type": "string", "description": "Star system such as Stanton or Pyro."},
"illegal": {"type": "boolean"},
"include_legacy": {"type": "boolean", "default": True},
"limit": {"type": "integer", "minimum": 1, "maximum": 25, "default": 10},
**version_controls,
},
},
},
},
{
"type": "function",
"function": {
"name": "get_scmdb_mission_rewards",
"description": "Fetch detailed SCMDB rewards and requirements for one Star Citizen mission/contract by id, debug name, or title.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string"},
"debug_name": {"type": "string"},
"title": {"type": "string"},
"include_legacy": {"type": "boolean", "default": True},
**version_controls,
},
},
},
},
]
@classmethod
def _scwiki_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "search_scwiki_pages",
"description": "Search Star Citizen Wiki pages on starcitizen.tools and return concise summaries for general game knowledge.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Page title or topic to search for."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_scwiki_page",
"description": "Fetch one Star Citizen Wiki page summary by title or page id.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"pageid": {"type": "integer"},
"chars": {"type": "integer", "minimum": 120, "maximum": 1200, "default": 700},
},
},
},
},
{
"type": "function",
"function": {
"name": "search_scwiki_vehicles",
"description": "Search Star Citizen Wiki structured vehicle data for ships and vehicles.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Ship or vehicle name to search for."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_scwiki_vehicle",
"description": "Fetch one Star Citizen Wiki vehicle summary, including MSRP and in-game purchase locations when available.",
"parameters": {
"type": "object",
"properties": {
"slug": {"type": "string", "description": "Vehicle slug such as anvl-carrack."},
"query": {"type": "string", "description": "Vehicle name if the slug is not known."},
},
},
},
},
]
@classmethod
def _wikelo_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "search_wikelo_ship_projects",
"description": "Search Wikelo ship projects and their required materials from wikelo-projects.com. Use this when the user asks for Wikelo ship requirements or build materials.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Ship or project name to search for, such as Polaris, Idris, Zeus, or Guardian."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_wikelo_ship_project",
"description": "Fetch one Wikelo ship project with its required materials and contribution progress.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Wikelo ship project id."},
"ship_name": {"type": "string", "description": "Ship or project name if the project id is not known."},
},
},
},
},
]
@classmethod
def _cornerstone_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "search_cornerstone_items",
"description": "Search Cornerstone Universal Item Finder items. Use this to find exact item names and ids before asking where an item is sold.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Item name to search for."},
"sold_only": {"type": "boolean", "default": False, "description": "Only return items marked as sold in-game by Cornerstone."},
"limit": {"type": "integer", "minimum": 1, "maximum": 25, "default": 10},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_cornerstone_item_locations",
"description": "Fetch where a Star Citizen item is sold using Cornerstone Universal Item Finder, including store/location, base price, and verified date.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Cornerstone item id from search_cornerstone_items."},
"query": {"type": "string", "description": "Item name if id is not known."},
"location": {"type": "string", "description": "Optional local filter for system, planet, station, city, or shop name."},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_cornerstone_item_media",
"description": "Fetch Cornerstone item page media, especially image URLs that can be used when drafting UEX marketplace listings.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Cornerstone item id from search_cornerstone_items."},
"query": {"type": "string", "description": "Item name if id is not known."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "draft_marketplace_listing_with_cornerstone_image",
"description": "Draft a UEX marketplace listing and source the listing image from Cornerstone. The image is downloaded as base64 image_data and included in the pending action. Nothing is posted until user approval.",
"parameters": {
"type": "object",
"required": ["item_query", "id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"],
"properties": {
"item_query": {"type": "string", "description": "Cornerstone item name to source an image from."},
"cornerstone_id": {"type": "string", "description": "Cornerstone item id, if already known."},
"id_item": {"type": "integer"},
"id_star_system": {"type": "integer"},
"id_terminal": {"type": "integer"},
"id_organization": {"type": "integer"},
"id_category": {"type": "integer"},
"operation": {"type": "string", "enum": ["buy", "sell", "rent", "trade"]},
"type": {"type": "string", "enum": ["item", "service", "contract"]},
"unit": {"type": "string"},
"title": {"type": "string"},
"description": {"type": "string"},
"price": {"type": "number"},
"currency": {"type": "string", "enum": ["UEC"]},
"language": {"type": "string", "default": "en_US"},
"location": {"type": "string"},
"source": {"type": "string", "enum": ["looted", "pledged", "purchased_in_game", "pirated", "gifted"]},
"availability": {"type": "string"},
"in_stock": {"type": "integer"},
"durability": {"type": "integer", "minimum": 0, "maximum": 100},
"video_url": {"type": "string"},
"image_data": {"type": "string", "description": "Base64 JPG or PNG image data for UEX upload."},
"use_attached_image": {
"type": "boolean",
"description": "When true, reuse an image pasted into the current chat as the listing image_data instead of sourcing from Cornerstone.",
},
"attached_image_index": {
"type": "integer",
"minimum": 0,
"description": "Zero-based pasted image index to reuse when use_attached_image is true.",
},
"hours_expiration": {"type": "integer"},
"is_hidden": {"type": "integer", "enum": [0, 1]},
"is_tv_allowed": {"type": "integer", "enum": [0, 1]},
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
"require_image": {"type": "boolean", "default": False, "description": "Return an error instead of drafting if no Cornerstone JPG/PNG image can be sourced."},
},
},
},
},
]
@classmethod
def _uex_post_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": cls._post_tool_name(resource),
"description": f"Draft UEX POST /{resource}/ for user approval. Nothing is sent until approval.",
"parameters": {
"type": "object",
"required": ["payload"],
"properties": {
"payload": {"type": "object", "description": f"JSON body for UEX POST /{resource}/."},
"label": {"type": "string", "description": "Short approval label."},
},
},
},
}
for resource in sorted(UEX_POST_RESOURCES)
]
@classmethod
def _uex_delete_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": cls._delete_tool_name(resource),
"description": f"Draft UEX DELETE /{resource}/ for user approval. Nothing is deleted until approval.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"label": {"type": "string", "description": "Short approval label."},
},
},
},
}
for resource in sorted(UEX_DELETE_RESOURCES)
]
@classmethod
def _get_tool_parameters(cls, endpoint_params: list[str]) -> dict[str, Any]:
properties = {
param: cls._query_param_schema(param)
for param in endpoint_params
}
properties.update(
{
"fields": {
"type": "array",
"items": {"type": "string"},
"description": "Fields to keep in each result row.",
},
"search": {"type": "string", "description": "Local text filter after UEX returns data."},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 10},
"offset": {"type": "integer", "minimum": 0, "default": 0},
"mode": {"type": "string", "enum": ["summary", "full"], "default": "summary"},
}
)
return {"type": "object", "properties": properties}
@staticmethod
def _query_param_schema(param: str) -> dict[str, Any]:
if param == "id" or param.startswith("id_") or param in {"date_from", "date_to", "quality_tier"}:
return {"type": "integer"}
return {"type": "string"}
@staticmethod
def _get_tool_description(resource: str, info: dict[str, Any]) -> str:
auth = " Authenticated." if info["auth"] else ""
heavy = " Heavy endpoint; use fields and limit." if info.get("heavy") else ""
history = " History endpoint." if info.get("history") else ""
description = UEX_RESOURCE_DESCRIPTIONS.get(resource)
if description:
return f"GET UEX /{resource}/ with compact, token-limited results. {description}{auth}{heavy}"
return f"GET UEX /{resource}/ with compact, token-limited results.{history}{auth}{heavy}"
@staticmethod
def _get_tool_name(resource: str) -> str:
return f"get_uex_{resource}"
@staticmethod
def _post_tool_name(resource: str) -> str:
return f"draft_uex_{resource}"
@staticmethod
def _delete_tool_name(resource: str) -> str:
return f"delete_uex_{resource}"
@classmethod
def _resource_index_entry(cls, method: str, resource: str, info: dict[str, Any]) -> dict[str, Any]:
return {
"method": method,
"resource": resource,
"tool": cls._get_tool_name(resource),
"group": info["group"],
"params": info["params"],
"authenticated": info["auth"],
"history": bool(info.get("history")),
"heavy": bool(info.get("heavy")),
"description": UEX_RESOURCE_DESCRIPTIONS.get(resource, ""),
"docs_url": cls._docs_url("get", resource),
}
@staticmethod
def _docs_url(method: str, resource: str) -> str:
return f"https://uexcorp.space/api/documentation/id/{method}_{resource}/"
async def search_marketplace_listings(
self,
query: str | None = None,
operation: str | None = None,
type: str | None = None,
username: str | None = None,
location: str | None = None,
min_price: float | None = None,
max_price: float | None = None,
limit: int = 10,
) -> dict[str, Any]:
response = await self.uex.get("marketplace_listings", {"username": username})
listings = response.get("data") or []
filtered = []
q = (query or "").casefold()
loc = (location or "").casefold()
for listing in listings:
if operation and listing.get("operation") != operation:
continue
if type and listing.get("type") != type:
continue
if min_price is not None and float(listing.get("price") or 0) < min_price:
continue
if max_price is not None and float(listing.get("price") or 0) > max_price:
continue
if loc and loc not in str(listing.get("location") or "").casefold():
continue
haystack = " ".join(str(listing.get(k) or "") for k in ["title", "description", "location", "user_username", "slug"]).casefold()
if q and q not in haystack:
continue
filtered.append(self._summarize_listing(listing))
if len(filtered) >= max(1, min(limit, 25)):
break
return {"count": len(filtered), "listings": filtered}
async def get_marketplace_listing(self, id: int | None = None, slug: str | None = None) -> dict[str, Any]:
response = await self.uex.get("marketplace_listings", {"id": id, "slug": slug})
return {"listing": response.get("data")}
async def get_marketplace_trends(
self,
id_item: int | None = None,
item_name: str | None = None,
item_slug: str | None = None,
id_category: int | None = None,
currency: str | None = None,
quality_tier: int | None = None,
) -> dict[str, Any]:
response = await self.uex.get(
"marketplace_trends",
{
"id_item": id_item,
"item_name": item_name,
"item_slug": item_slug,
"id_category": id_category,
"currency": currency,
"quality_tier": quality_tier,
},
)
trends = [
self._summarize_marketplace_trend(item)
for item in self._as_list(response.get("data"))
if isinstance(item, dict)
]
return {
"status": response.get("status"),
"count": len(trends),
"filters": {
key: value
for key, value in {
"id_item": id_item,
"item_name": item_name,
"item_slug": item_slug,
"id_category": id_category,
"currency": currency,
"quality_tier": quality_tier,
}.items()
if value is not None
},
"trends": trends,
}
async def list_marketplace_negotiations(
self,
id: int | None = None,
id_listing: int | None = None,
hash: str | None = None,
) -> dict[str, Any]:
response = await self.uex.get("marketplace_negotiations", {"id": id, "id_listing": id_listing, "hash": hash}, authenticated=True)
negotiations = [
self._summarize_negotiation(item)
for item in self._as_list(response.get("data"))
if isinstance(item, dict)
]
return {**response, "data": negotiations, "negotiations": negotiations}
async def get_negotiation_messages(self, hash: str | None = None, id_negotiation: int | None = None) -> dict[str, Any]:
return await self.uex.get("marketplace_negotiations_messages", {"hash": hash, "id_negotiation": id_negotiation}, authenticated=True)
async def list_local_negotiations(
self,
status: str = "all",
unread_only: bool = False,
search: str = "",
limit: int = 10,
) -> dict[str, Any]:
if self.negotiation_sync is None:
return {"error": "Negotiation sync is not configured."}
negotiations = self.negotiation_sync.list_negotiations(
status=status,
unread_only=unread_only,
search=search,
limit=limit,
)
return {"count": len(negotiations), "negotiations": negotiations}
async def get_local_negotiation(self, hash: str) -> dict[str, Any]:
if self.negotiation_sync is None:
return {"error": "Negotiation sync is not configured."}
negotiation = self.negotiation_sync.get_negotiation(hash, mark_read=False)
if not negotiation:
return {"error": f"Negotiation not found: {hash}"}
return {"negotiation": negotiation}
async def search_local_negotiation_messages(self, query: str, limit: int = 8) -> dict[str, Any]:
if self.negotiation_sync is None:
return {"error": "Negotiation sync is not configured."}
matches = self.negotiation_sync.search_messages(query, limit=limit)
return {"count": len(matches), "matches": matches}
async def draft_negotiation_message(
self,
message: str,
hash: str | None = None,
id_negotiation: int | None = None,
id_listing: int | None = None,
plan_id: str | None = None,
plan_item_id: int | None = None,
candidate_id: int | None = None,
listing_slug: str | None = None,
is_production: int = 1,
) -> dict[str, Any]:
payload = {"message": message, "hash": hash, "id_negotiation": id_negotiation, "id_listing": id_listing, "is_production": is_production}
metadata = {
"plan_id": plan_id,
"plan_item_id": plan_item_id,
"candidate_id": candidate_id,
"listing_id": id_listing,
"listing_slug": listing_slug,
"hash": hash,
"id_negotiation": id_negotiation,
}
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload, metadata=metadata)
async def draft_marketplace_listing(self, **payload: Any) -> dict[str, Any]:
attached_image = self._attach_chat_image(payload)
if attached_image.get("error"):
return {"error": attached_image["error"]}
return self._pending(
"Post marketplace listing",
"marketplace_advertise",
payload,
metadata=attached_image.get("metadata"),
)
async def draft_negotiation_close(
self,
deal_closed: bool,
hash: str | None = None,
id_negotiation: int | None = None,
deal_value: float | None = None,
currency: str | None = None,
clarity_rating: int | None = None,
speed_rating: int | None = None,
respect_rating: int | None = None,
fairness_rating: int | None = None,
comment: str | None = None,
) -> dict[str, Any]:
payload = {
"hash": hash,
"id_negotiation": id_negotiation,
"deal_closed": 1 if deal_closed else 0,
"deal_value": deal_value,
"currency": currency,
"clarity_rating": clarity_rating,
"speed_rating": speed_rating,
"respect_rating": respect_rating,
"fairness_rating": fairness_rating,
"comment": comment,
}
metadata = {
"hash": hash,
"id_negotiation": id_negotiation,
"kind": "negotiation_close",
}
return self._pending("Close negotiation", UEX_NEGOTIATION_CLOSE_ENDPOINT, payload, metadata=metadata)
async def draft_negotiation_rating(self, **payload: Any) -> dict[str, Any]:
return await self.draft_negotiation_close(**payload)
async def draft_marketplace_listing_with_cornerstone_image(
self,
item_query: str,
cornerstone_id: str | None = None,
**payload: Any,
) -> dict[str, Any]:
require_image = bool(payload.pop("require_image", False))
attached_image = self._attach_chat_image(payload)
if attached_image.get("error"):
return {"error": attached_image["error"]}
item = await self._resolve_cornerstone_item(id=cornerstone_id, query=item_query)
if not item:
return {"error": "No Cornerstone item matched. Provide cornerstone_id or a more specific item_query."}
page = await self.cornerstone.get_item_page(str(item["id"]))
parsed = parse_cornerstone_item_page(page["html"], page["url"])
media = parsed.get("media") or []
image_result: dict[str, Any] | None = None
image_error = ""
for media_item in media:
try:
image_result = await self.cornerstone.get_image_data(media_item["url"])
break
except Exception as exc:
image_error = str(exc)
if image_result and not payload.get("image_data"):
payload["image_data"] = image_result["image_data"]
elif require_image and not payload.get("image_data"):
return {
"error": "Cornerstone item matched, but no usable JPG/PNG image could be sourced.",
"cornerstone": {
"item": {"id": item.get("id"), "name": parsed.get("name") or item.get("name")},
"url": page["url"],
"media": media,
"image_error": image_error,
},
}
payload.setdefault("id_item", self._int_or_none(item.get("id")))
metadata = {
"cornerstone_item_id": item.get("id"),
"cornerstone_item_name": parsed.get("name") or item.get("name"),
"cornerstone_url": page["url"],
"cornerstone_image_url": image_result.get("url") if image_result else None,
"cornerstone_image_content_type": image_result.get("content_type") if image_result else None,
"cornerstone_image_size_bytes": image_result.get("size_bytes") if image_result else None,
"cornerstone_image_status": "user_attached" if attached_image.get("metadata") else ("included" if image_result else "not_found"),
"cornerstone_image_error": image_error or None,
}
if attached_image.get("metadata"):
metadata.update(attached_image["metadata"])
return self._pending("Post marketplace listing with Cornerstone image", "marketplace_advertise", payload, metadata=metadata)
async def remember_user_fact(self, content: str, kind: str = "note", importance: int = 3) -> dict[str, Any]:
if self.memory is None:
return {"error": "Memory store is not configured."}
return {"memory": self.memory.remember(kind, content, importance)}
async def recall_memory(self, query: str = "", limit: int = 6) -> dict[str, Any]:
if self.memory is None:
return {"error": "Memory store is not configured."}
return {"memories": self.memory.recall(query, max(1, min(limit, 10)))}
async def schedule_wake_job(
self,
prompt: str,
run_at: str | None = None,
cron: str | None = None,
) -> dict[str, Any]:
if self.scheduler is None:
return {"error": "Scheduler is not configured."}
if bool(run_at) == bool(cron):
return {"error": "Provide exactly one of run_at or cron."}
if run_at:
return {"scheduled_job": self.scheduler.schedule_date(run_at, prompt)}
return {"scheduled_job": self.scheduler.schedule_cron(cron or "", prompt)}
async def list_wake_jobs(self) -> dict[str, Any]:
if self.scheduler is None:
return {"error": "Scheduler is not configured."}
return {"scheduled_jobs": self.scheduler.list_jobs()}
async def create_continual_plan(
self,
title: str,
objective: str,
kind: str = "buying",
items: list[dict[str, Any]] | None = None,
constraints: dict[str, Any] | None = None,
cadence: str | None = None,
) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
plan = self.plan_store.create_plan(title, kind=kind, objective=objective, items=items or [], constraints=constraints or {}, cadence=cadence)
if self.scheduler is not None and plan.get("status") == "active":
self.scheduler.schedule_plan(plan)
plan = self.plan_store.get_plan(plan["id"]) or plan
return {"plan": plan}
async def list_continual_plans(self, include_inactive: bool = True) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
return {"plans": self.plan_store.list_plans(include_inactive=include_inactive)}
async def get_continual_plan(self, plan_id: str) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
plan = self.plan_store.get_plan(plan_id)
if not plan:
return {"error": f"Plan not found: {plan_id}"}
return {"plan": plan}
async def pause_continual_plan(self, plan_id: str) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
if self.scheduler is not None:
self.scheduler.unschedule_plan(plan_id)
return {"plan": self.plan_store.set_status(plan_id, "paused")}
async def resume_continual_plan(self, plan_id: str) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
plan = self.plan_store.get_plan(plan_id)
if not plan:
return {"error": f"Plan not found: {plan_id}"}
next_status = "active" if plan.get("items") else "needs_input"
plan = self.plan_store.set_status(plan_id, next_status)
if self.scheduler is not None and plan and plan.get("status") == "active":
self.scheduler.schedule_plan(plan)
plan = self.plan_store.get_plan(plan_id)
return {"plan": plan}
async def cancel_continual_plan(self, plan_id: str) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
if self.scheduler is not None:
self.scheduler.unschedule_plan(plan_id)
return {"plan": self.plan_store.set_status(plan_id, "canceled")}
async def delete_continual_plan(self, plan_id: str) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
plan = self.plan_store.get_plan(plan_id)
if not plan:
return {"error": f"Plan not found: {plan_id}"}
if self.scheduler is not None:
self.scheduler.unschedule_plan(plan_id)
deleted = self.plan_store.delete_plan(plan_id)
if not deleted:
return {"error": f"Plan not found: {plan_id}"}
return {"deleted": True, "plan_id": plan_id, "summary": f"Deleted plan {plan.get('title') or plan_id}."}
async def run_continual_plan_now(self, plan_id: str) -> dict[str, Any]:
if self.plan_runner is None:
return {"error": "Continual plan runner is not configured."}
return await self.plan_runner.run_plan(plan_id)
async def check_uex_notifications(self) -> dict[str, Any]:
response = await self.uex.get_user_notifications()
notifications = response.get("notifications") or []
pending = [item for item in notifications if not item.get("date_read")]
return {"count": len(pending), "notifications": pending}
async def list_scmdb_versions(self, channel: str | None = None) -> dict[str, Any]:
versions = await self.scmdb.list_versions()
channel_filter = (channel or "").casefold().strip()
if channel_filter in {"live", "ptu"}:
versions = [
item
for item in versions
if f"-{channel_filter}." in str(item.get("version", "")).casefold()
]
elif channel_filter not in {"", "latest"}:
return {"error": "SCMDB channel must be live, ptu, or latest."}
return {
"source": self.scmdb.base_url,
"count": len(versions),
"versions": versions,
"default_channel": "live",
}
async def search_scmdb_missions(
self,
query: str = "",
mission_type: str | None = None,
category: str | None = None,
faction: str | None = None,
system: str | None = None,
illegal: bool | None = None,
include_legacy: bool = True,
limit: int = 10,
version: str | None = None,
channel: str = "live",
) -> dict[str, Any]:
data = await self.scmdb.get_data(version=version, channel=channel)
q = (query or "").casefold().strip()
mission_type_filter = (mission_type or "").casefold().strip()
category_filter = (category or "").casefold().strip()
faction_filter = (faction or "").casefold().strip()
system_filter = (system or "").casefold().strip()
matched = []
for source, mission in self._scmdb_contracts(data, include_legacy=include_legacy):
summary = self._summarize_scmdb_mission(data, mission, source=source)
if mission_type_filter and mission_type_filter not in str(summary.get("mission_type") or "").casefold():
continue
if category_filter and category_filter not in str(summary.get("category") or "").casefold():
continue
if faction_filter and faction_filter not in str(summary.get("faction") or "").casefold():
continue
if system_filter and system_filter not in " ".join(summary.get("systems") or []).casefold():
continue
if illegal is not None and bool(summary.get("illegal")) != illegal:
continue
if q and q not in self._scmdb_search_text(data, mission, summary):
continue
matched.append(summary)
limit = max(1, min(limit, 25))
return {
"source": self.scmdb.base_url,
"version": data.get("version"),
"matched": len(matched),
"returned": min(len(matched), limit),
"truncated": len(matched) > limit,
"missions": matched[:limit],
}
async def get_scmdb_mission_rewards(
self,
id: str | None = None,
debug_name: str | None = None,
title: str | None = None,
include_legacy: bool = True,
version: str | None = None,
channel: str = "live",
) -> dict[str, Any]:
if not any([id, debug_name, title]):
return {"error": "Provide id, debug_name, or title."}
data = await self.scmdb.get_data(version=version, channel=channel)
exact = []
fuzzy = []
id_filter = (id or "").casefold().strip()
debug_filter = (debug_name or "").casefold().strip()
title_filter = (title or "").casefold().strip()
for source, mission in self._scmdb_contracts(data, include_legacy=include_legacy):
mission_id = str(mission.get("id") or "").casefold()
mission_debug = str(mission.get("debugName") or "").casefold()
mission_title = str(mission.get("title") or "").casefold()
if id_filter and mission_id == id_filter:
exact.append((source, mission))
elif debug_filter and mission_debug == debug_filter:
exact.append((source, mission))
elif title_filter and mission_title == title_filter:
exact.append((source, mission))
elif title_filter and title_filter in mission_title:
fuzzy.append((source, mission))
elif debug_filter and debug_filter in mission_debug:
fuzzy.append((source, mission))
candidates = exact or fuzzy
if len(candidates) != 1:
return {
"source": self.scmdb.base_url,
"version": data.get("version"),
"matched": len(candidates),
"error": "No SCMDB mission matched." if not candidates else "Multiple SCMDB missions matched; refine by id or debug_name.",
"matches": [
self._summarize_scmdb_mission(data, mission, source=source)
for source, mission in candidates[:10]
],
}
source, mission = candidates[0]
return {
"source": self.scmdb.base_url,
"version": data.get("version"),
"mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True),
}
async def search_scwiki_pages(self, query: str, limit: int = 5) -> dict[str, Any]:
pages = await self.scwiki.search_pages(query, limit=limit)
return {"source": self.scwiki.base_url, "query": query, "matched": len(pages), "pages": pages}
async def get_scwiki_page(
self,
title: str | None = None,
pageid: int | None = None,
chars: int = 700,
) -> dict[str, Any]:
page = await self.scwiki.get_page_summary(title=title, pageid=pageid, chars=chars)
if not page:
return {"error": "No Star Citizen Wiki page matched."}
return {"source": self.scwiki.base_url, "page": page}
async def search_scwiki_vehicles(self, query: str, limit: int = 5) -> dict[str, Any]:
groups = await self.scwiki.search_verse(query)
vehicles_group = next((item for item in groups if item.get("type") == "vehicles"), None)
results = [
self._summarize_scwiki_vehicle_search(item)
for item in (vehicles_group or {}).get("results", [])[: max(1, min(limit, 10))]
if isinstance(item, dict)
]
return {"source": self.scwiki.api_base_url, "query": query, "matched": len(results), "vehicles": results}
async def get_scwiki_vehicle(self, slug: str | None = None, query: str | None = None) -> dict[str, Any]:
resolved_slug = slug
if not resolved_slug:
if not query:
return {"error": "Provide slug or query."}
groups = await self.scwiki.search_verse(query)
vehicles_group = next((item for item in groups if item.get("type") == "vehicles"), None)
candidates = [
item
for item in (vehicles_group or {}).get("results", [])
if isinstance(item, dict) and item.get("api_url")
]
if not candidates:
return {"error": "No Star Citizen Wiki vehicle matched."}
resolved_slug = str(candidates[0]["api_url"]).rstrip("/").rsplit("/", 1)[-1]
vehicle = await self.scwiki.get_vehicle(resolved_slug)
return {"source": self.scwiki.api_base_url, "vehicle": self._summarize_scwiki_vehicle(vehicle)}
async def search_wikelo_ship_projects(self, query: str, limit: int = 5) -> dict[str, Any]:
projects = await self.wikelo.list_ship_projects()
q = (query or "").casefold().strip()
matches = []
for project in projects:
score = self._wikelo_ship_match_score(q, project)
if q and score <= 0:
continue
matches.append((score, project))
matches.sort(
key=lambda match: (
-match[0],
str(match[1].get("ship_name") or "").casefold(),
str(match[1].get("id") or ""),
)
)
limit = max(1, min(limit, 10))
return {
"source": f"{self.wikelo.base_url}/Ships",
"query": query,
"matched": len(matches),
"projects": [self._summarize_wikelo_ship_project(item) for _, item in matches[:limit]],
}
async def get_wikelo_ship_project(self, project_id: str | None = None, ship_name: str | None = None) -> dict[str, Any]:
projects = await self.wikelo.list_ship_projects()
if project_id:
for project in projects:
if str(project.get("id") or "").strip() == str(project_id).strip():
return {"source": f"{self.wikelo.base_url}/Ships", "project": self._summarize_wikelo_ship_project(project, detailed=True)}
return {"error": "No Wikelo ship project matched that id."}
if not ship_name:
return {"error": "Provide project_id or ship_name."}
ranked = [
(self._wikelo_ship_match_score(ship_name.casefold().strip(), project), project)
for project in projects
]
ranked = [match for match in ranked if match[0] > 0]
ranked.sort(key=lambda match: (-match[0], str(match[1].get("ship_name") or "").casefold()))
if not ranked:
return {"error": "No Wikelo ship project matched."}
return {"source": f"{self.wikelo.base_url}/Ships", "project": self._summarize_wikelo_ship_project(ranked[0][1], detailed=True)}
async def search_cornerstone_items(
self,
query: str = "",
sold_only: bool = False,
limit: int = 10,
) -> dict[str, Any]:
items = await self.cornerstone.list_items()
q = (query or "").casefold().strip()
matches = []
for item in items:
if sold_only and not item.get("sold"):
continue
score = self._cornerstone_match_score(q, str(item.get("name") or ""))
if q and score <= 0:
continue
matches.append((score, item))
matches.sort(key=lambda match: (-match[0], str(match[1].get("name") or "").casefold()))
limit = max(1, min(limit, 25))
compacted = [
{
"id": item.get("id"),
"name": item.get("name"),
"sold": bool(item.get("sold")),
"url": f"{self.cornerstone.base_url}/Search/{item.get('id')}",
}
for _, item in matches[:limit]
]
return {
"source": self.cornerstone.base_url,
"matched": len(matches),
"returned": len(compacted),
"truncated": len(matches) > limit,
"items": compacted,
}
async def get_cornerstone_item_locations(
self,
id: str | None = None,
query: str | None = None,
location: str | None = None,
limit: int = 20,
) -> dict[str, Any]:
item = await self._resolve_cornerstone_item(id=id, query=query)
if not item:
return {"error": "No Cornerstone item matched. Provide an id or a more specific query."}
page = await self.cornerstone.get_item_page(str(item["id"]))
parsed = parse_cornerstone_item_page(page["html"], page["url"])
locations = parsed.get("locations") or []
location_filter = (location or "").casefold().strip()
if location_filter:
locations = [
entry
for entry in locations
if location_filter in str(entry.get("location") or "").casefold()
]
limit = max(1, min(limit, 50))
return {
"source": self.cornerstone.base_url,
"url": page["url"],
"item": {
"id": item.get("id"),
"name": parsed.get("name") or item.get("name"),
"sold": bool(item.get("sold")),
"general": parsed.get("general") or {},
},
"matched_locations": len(locations),
"returned": min(len(locations), limit),
"truncated": len(locations) > limit,
"locations": locations[:limit],
}
async def get_cornerstone_item_media(
self,
id: str | None = None,
query: str | None = None,
limit: int = 5,
) -> dict[str, Any]:
item = await self._resolve_cornerstone_item(id=id, query=query)
if not item:
return {"error": "No Cornerstone item matched. Provide an id or a more specific query."}
page = await self.cornerstone.get_item_page(str(item["id"]))
parsed = parse_cornerstone_item_page(page["html"], page["url"])
media = parsed.get("media") or []
limit = max(1, min(limit, 10))
return {
"source": self.cornerstone.base_url,
"url": page["url"],
"item": {
"id": item.get("id"),
"name": parsed.get("name") or item.get("name"),
"sold": bool(item.get("sold")),
"general": parsed.get("general") or {},
},
"returned": min(len(media), limit),
"truncated": len(media) > limit,
"media": media[:limit],
}
def _pending(
self,
label: str,
endpoint: str,
payload: dict[str, Any],
method: str = "POST",
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
action_id = str(uuid.uuid4())
payload = {key: value for key, value in payload.items() if value is not None}
metadata = {key: value for key, value in (metadata or {}).items() if value is not None}
payload = self._production_payload(endpoint, payload)
self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload, method, metadata)
return {
"pending_action": {
"id": action_id,
"label": label,
"method": method,
"endpoint": endpoint,
"payload": self._display_payload(payload),
"metadata": metadata,
"approval_required": self.require_write_approval,
}
}
@staticmethod
def _display_payload(payload: dict[str, Any]) -> dict[str, Any]:
display = dict(payload)
image_data = display.get("image_data")
if isinstance(image_data, str) and image_data:
display["image_data"] = f"<base64 image data redacted; {len(image_data)} characters>"
return display
def _attach_chat_image(self, payload: dict[str, Any]) -> dict[str, Any]:
attached_index = payload.pop("attached_image_index", None)
use_attached_image = bool(payload.pop("use_attached_image", False) or attached_index is not None)
if payload.get("image_data") or not use_attached_image:
return {}
image = self._chat_image(attached_index or 0)
if not image:
return {"error": "No pasted chat image is available at the requested attached_image_index."}
payload["image_data"] = image["image_data"]
return {
"metadata": {
"attached_chat_image_name": image.get("name"),
"attached_chat_image_content_type": image.get("content_type"),
"attached_chat_image_index": attached_index or 0,
"attached_chat_image_status": "included",
}
}
def _chat_image(self, index: int) -> dict[str, Any] | None:
images = self._chat_images_var.get()
if 0 <= index < len(images):
return images[index]
return None
@staticmethod
def _normalize_chat_images(images: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
for image in images or []:
if not isinstance(image, dict):
continue
image_data = str(image.get("image_data") or "").strip()
if not image_data:
continue
normalized.append(
{
"name": str(image.get("name") or "").strip() or "pasted-image.png",
"content_type": str(image.get("content_type") or "image/png").strip() or "image/png",
"image_data": image_data,
}
)
return normalized
@staticmethod
def _int_or_none(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _record_pending_action_result(self, action: PendingAction, result_kind: str, result: dict[str, Any]) -> None:
metadata = action.metadata or {}
plan_id = metadata.get("plan_id")
if not plan_id or self.plan_store is None:
return
message = f"{action.label} {result_kind} for continual plan."
event_metadata = {"action_id": action.id, "endpoint": action.endpoint, "payload": action.payload, "result": result, **metadata}
self.plan_store.add_event(plan_id, result_kind, message, event_metadata)
if result_kind == "approved" and action.endpoint == "marketplace_negotiations_messages":
self.plan_store.add_negotiation(
plan_id,
metadata.get("plan_item_id"),
metadata.get("candidate_id"),
{**metadata, "status": "approved"},
)
@staticmethod
def _production_payload(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
if endpoint not in UEX_PRODUCTION_WRITE_RESOURCES:
return payload
next_payload = dict(payload)
next_payload["is_production"] = 1
return next_payload
@staticmethod
def _validate_resource(resource: str, allowed: dict[str, Any] | set[str]) -> str:
normalized = resource.strip().strip("/").casefold()
if normalized not in allowed:
choices = sorted(allowed.keys() if isinstance(allowed, dict) else allowed)
near = [name for name in choices if normalized in name or name in normalized][:8]
hint = f" Did you mean: {', '.join(near)}?" if near else ""
raise ValueError(f"Unsupported UEX resource: {resource}.{hint}")
return normalized
@staticmethod
def _filter_params(params: dict[str, Any], allowed_params: list[str]) -> dict[str, Any]:
if not allowed_params:
return {key: value for key, value in params.items() if value is not None}
allowed = set(allowed_params)
return {key: value for key, value in params.items() if key in allowed and value is not None}
@staticmethod
def _as_list(data: Any) -> list[Any]:
if data is None:
return []
if isinstance(data, list):
return data
return [data]
@classmethod
def _project_item(cls, item: Any, fields: list[str] | None = None, mode: str = "summary") -> Any:
if not isinstance(item, dict):
return item
if fields:
return {field: cls._compact_scalar(item.get(field)) for field in fields if field in item}
if mode == "full":
return {key: cls._compact_scalar(value) for key, value in item.items()}
priority = [
"id",
"uuid",
"code",
"slug",
"name",
"title",
"type",
"section",
"operation",
"price",
"currency",
"unit",
"location",
"terminal_name",
"commodity_name",
"item_name",
"vehicle_name",
"price_buy",
"price_sell",
"scu_buy",
"scu_sell",
"scu_sell_stock",
"status_buy",
"status_sell",
"date_modified",
"date_added",
]
selected: dict[str, Any] = {}
for key in priority:
if key in item and item[key] not in (None, ""):
selected[key] = cls._compact_scalar(item[key])
for key, value in item.items():
if len(selected) >= 16:
break
if key in selected or value in (None, ""):
continue
if isinstance(value, (str, int, float, bool)):
selected[key] = cls._compact_scalar(value)
return selected
@staticmethod
def _compact_scalar(value: Any) -> Any:
if isinstance(value, str) and len(value) > 240:
return value[:237] + "..."
if isinstance(value, list):
return value[:5]
if isinstance(value, dict):
return {key: nested_value for key, nested_value in list(value.items())[:12]}
return value
@classmethod
def _search_text(cls, item: Any) -> str:
if isinstance(item, dict):
return " ".join(str(value) for value in item.values() if isinstance(value, (str, int, float))).casefold()
return str(item).casefold()
async def _history_summary(
self,
resource: str,
params: dict[str, Any],
value_fields: list[str],
label_fields: list[str],
limit: int,
) -> dict[str, Any]:
info = UEX_GET_RESOURCES[resource]
cleaned_params = self._filter_params(params, info["params"])
response = await self.uex.get(resource, cleaned_params, authenticated=bool(info["auth"]))
rows = [
row
for row in self._as_list(response.get("data"))
if isinstance(row, dict)
][: max(1, min(limit, 1000))]
rows_sorted = sorted(rows, key=lambda row: int(row.get("date_added") or 0))
latest = rows_sorted[-1] if rows_sorted else {}
earliest = rows_sorted[0] if rows_sorted else {}
summaries = {
field: self._numeric_history_summary(rows_sorted, field)
for field in value_fields
if any(self._is_number(row.get(field)) for row in rows_sorted)
}
labels = {
field: latest.get(field)
for field in label_fields
if latest.get(field) not in (None, "")
}
sample_fields = ["id", "date_added", *label_fields, *value_fields]
recent = [
self._project_item(row, fields=sample_fields, mode="summary")
for row in list(reversed(rows_sorted[-5:]))
]
return {
"status": response.get("status"),
"resource": resource,
"params": cleaned_params,
"count": len(rows),
"date_start": earliest.get("date_added"),
"date_end": latest.get("date_added"),
"labels": labels,
"metrics": summaries,
"recent": recent,
"docs_url": self._docs_url("get", resource),
}
@classmethod
def _numeric_history_summary(cls, rows: list[dict[str, Any]], field: str) -> dict[str, Any]:
points = [
(int(row.get("date_added") or 0), float(row[field]))
for row in rows
if cls._is_number(row.get(field))
]
values = [value for _, value in points]
first_date, first_value = points[0]
last_date, last_value = points[-1]
change = last_value - first_value
pct_change = (change / first_value * 100) if first_value else None
return {
"first": first_value,
"first_date": first_date,
"latest": last_value,
"latest_date": last_date,
"min": min(values),
"max": max(values),
"avg": round(sum(values) / len(values), 4),
"change": round(change, 4),
"pct_change": round(pct_change, 4) if pct_change is not None else None,
"points": len(points),
}
@staticmethod
def _is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
@staticmethod
def _scmdb_contracts(data: dict[str, Any], include_legacy: bool = True) -> list[tuple[str, dict[str, Any]]]:
contracts = [
("contracts", mission)
for mission in data.get("contracts") or []
if isinstance(mission, dict)
]
if include_legacy:
contracts.extend(
("legacyContracts", mission)
for mission in data.get("legacyContracts") or []
if isinstance(mission, dict)
)
return contracts
@classmethod
def _summarize_scmdb_mission(
cls,
data: dict[str, Any],
mission: dict[str, Any],
source: str,
detailed: bool = False,
) -> dict[str, Any]:
summary: dict[str, Any] = {
"id": mission.get("id"),
"debug_name": mission.get("debugName"),
"source": source,
"title": mission.get("title"),
"mission_type": mission.get("missionType"),
"category": mission.get("category"),
"faction": cls._scmdb_faction_name(data, mission.get("factionGuid")),
"systems": mission.get("systems") or [],
"illegal": bool(mission.get("illegal")),
"can_be_shared": bool(mission.get("canBeShared")),
"once_only": bool(mission.get("onceOnly")),
"time_to_complete_minutes": mission.get("timeToComplete"),
"max_players": mission.get("maxPlayersPerInstance"),
"cooldown_minutes": mission.get("personalCooldownTime"),
"min_standing": cls._scmdb_standing(mission.get("minStanding")),
"max_standing": cls._scmdb_standing(mission.get("maxStanding")),
"rewards": cls._scmdb_rewards(data, mission, detailed=detailed),
}
if detailed:
summary.update(
{
"description": cls._compact_scalar(mission.get("description")),
"locations": cls._scmdb_pool_names(data, "locationPools", mission.get("locations"), limit=20),
"destinations": cls._scmdb_pool_names(data, "locationPools", mission.get("destinations"), limit=20),
"prerequisites": cls._compact_scalar(mission.get("prerequisites")),
"available_in_prison": bool(mission.get("availableInPrison")),
"reaccept_after_abandoning": bool(mission.get("canReacceptAfterAbandoning")),
"reaccept_after_failing": bool(mission.get("canReacceptAfterFailing")),
"hide_in_mobiglas": bool(mission.get("hideInMobiGlas")),
}
)
return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
@classmethod
def _scmdb_rewards(cls, data: dict[str, Any], mission: dict[str, Any], detailed: bool = False) -> dict[str, Any]:
rewards: dict[str, Any] = {
"uec": mission.get("rewardUEC"),
"buy_in": mission.get("buyIn"),
"dynamic_uec": mission.get("rewardIsDynamic"),
"reputation": cls._scmdb_indexed_reputation(data, mission.get("factionRewardsIndex")),
"failure_reputation": cls._scmdb_reputation_rewards(data, mission.get("factionRewards_fail")),
"items": cls._scmdb_item_rewards(mission.get("itemRewards")),
"blueprints": cls._scmdb_blueprint_rewards(data, mission.get("blueprintRewards"), detailed=detailed),
"hauling": cls._scmdb_hauling_orders(data, mission.get("haulingOrders")),
"partial_payouts": cls._scmdb_partial_payouts(data, mission.get("partialRewardPayoutIndex")),
}
return {key: value for key, value in rewards.items() if value not in (None, "", [], {})}
@classmethod
def _scmdb_indexed_reputation(cls, data: dict[str, Any], index: Any) -> list[dict[str, Any]]:
if not isinstance(index, int):
return []
pools = data.get("factionRewardsPools") or []
if index < 0 or index >= len(pools):
return []
return cls._scmdb_reputation_rewards(data, pools[index])
@classmethod
def _scmdb_reputation_rewards(cls, data: dict[str, Any], rewards: Any) -> list[dict[str, Any]]:
if not isinstance(rewards, list):
return []
result = []
for reward in rewards:
if not isinstance(reward, dict):
continue
result.append(
{
"faction": cls._scmdb_faction_name(data, reward.get("factionGuid")),
"scope": cls._scmdb_scope_name(data, reward.get("scopeGuid")),
"amount": reward.get("amount"),
}
)
return [item for item in result if item.get("amount") not in (None, "")]
@classmethod
def _scmdb_item_rewards(cls, rewards: Any) -> list[dict[str, Any]]:
if not isinstance(rewards, list):
return []
return [
{
"name": reward.get("name"),
"amount": reward.get("amount"),
}
for reward in rewards
if isinstance(reward, dict)
]
@classmethod
def _scmdb_blueprint_rewards(cls, data: dict[str, Any], rewards: Any, detailed: bool = False) -> list[dict[str, Any]]:
if not isinstance(rewards, list):
return []
result = []
for reward in rewards:
if not isinstance(reward, dict):
continue
pool_id = reward.get("blueprintPool")
pool = (data.get("blueprintPools") or {}).get(pool_id) or {}
blueprints = pool.get("blueprints") or []
result.append(
{
"pool": pool.get("name") or reward.get("poolName"),
"chance": reward.get("chance"),
"trigger": reward.get("trigger"),
"blueprints": [
cls._compact_scalar(item.get("name"))
for item in blueprints[: 20 if detailed else 5]
if isinstance(item, dict) and item.get("name")
],
}
)
return result
@classmethod
def _scmdb_hauling_orders(cls, data: dict[str, Any], orders: Any) -> list[dict[str, Any]]:
if not isinstance(orders, list):
return []
result = []
resources = data.get("resourcePools") or {}
for order in orders:
if not isinstance(order, dict):
continue
resource = resources.get(order.get("resource")) or {}
result.append(
{
"resource": resource.get("name") or order.get("resource"),
"min_scu": order.get("minSCU"),
"max_scu": order.get("maxSCU"),
"max_container_size_scu": order.get("maxContainerSize"),
}
)
return result
@classmethod
def _scmdb_partial_payouts(cls, data: dict[str, Any], index: Any) -> list[dict[str, Any]]:
if not isinstance(index, int):
return []
pools = data.get("partialRewardPayoutPools") or []
if index < 0 or index >= len(pools):
return []
payouts = pools[index]
if not isinstance(payouts, list):
return []
return [
{
"min_percent": payout.get("minPercentage"),
"max_percent": payout.get("maxPercentage"),
"currency_multiplier": payout.get("currencyRewardMultiplier"),
"reputation_multipliers": payout.get("reputationMultipliers"),
}
for payout in payouts
if isinstance(payout, dict)
]
@staticmethod
def _scmdb_faction_name(data: dict[str, Any], guid: Any) -> str | None:
if not guid:
return None
faction = (data.get("factions") or {}).get(guid)
if isinstance(faction, dict):
return faction.get("name") or guid
return str(guid)
@staticmethod
def _scmdb_scope_name(data: dict[str, Any], guid: Any) -> str | None:
if not guid:
return None
scope = (data.get("scopes") or {}).get(guid)
if isinstance(scope, dict):
return scope.get("scopeName") or guid
return str(guid)
@staticmethod
def _scmdb_standing(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
return {
key: value.get(key)
for key in ("name", "minReputation", "scopeName")
if value.get(key) not in (None, "")
}
@staticmethod
def _scmdb_pool_names(data: dict[str, Any], pool_key: str, keys: Any, limit: int = 10) -> list[str]:
if not isinstance(keys, list):
return []
pool = data.get(pool_key) or {}
names = []
for key in keys[:limit]:
item = pool.get(key)
if isinstance(item, dict):
names.append(str(item.get("name") or key))
else:
names.append(str(key))
return names
@classmethod
def _scmdb_search_text(cls, data: dict[str, Any], mission: dict[str, Any], summary: dict[str, Any]) -> str:
pieces = [
summary.get("title"),
summary.get("debug_name"),
summary.get("mission_type"),
summary.get("category"),
summary.get("faction"),
mission.get("description"),
" ".join(summary.get("systems") or []),
]
rewards = summary.get("rewards") or {}
pieces.append(str(rewards.get("uec") or ""))
for item in rewards.get("reputation") or []:
pieces.extend([item.get("faction"), item.get("scope"), item.get("amount")])
for item in rewards.get("items") or []:
pieces.extend([item.get("name"), item.get("amount")])
for item in rewards.get("blueprints") or []:
pieces.extend([item.get("pool"), " ".join(item.get("blueprints") or [])])
for item in rewards.get("hauling") or []:
pieces.extend([item.get("resource"), item.get("min_scu"), item.get("max_scu")])
return " ".join(str(piece) for piece in pieces if piece not in (None, "")).casefold()
async def _resolve_cornerstone_item(self, id: str | None = None, query: str | None = None) -> dict[str, Any] | None:
items = await self.cornerstone.list_items()
id_filter = (id or "").casefold().strip()
if id_filter:
for item in items:
if str(item.get("id") or "").casefold() == id_filter:
return item
return {"id": id, "name": id, "sold": True}
q = (query or "").casefold().strip()
if not q:
return None
exact = [item for item in items if str(item.get("name") or "").casefold() == q]
if exact:
exact.sort(key=lambda item: not bool(item.get("sold")))
return exact[0]
scored = [
(self._cornerstone_match_score(q, str(item.get("name") or "")), item)
for item in items
]
scored = [match for match in scored if match[0] > 0]
if not scored:
return None
scored.sort(key=lambda match: (-match[0], not bool(match[1].get("sold")), str(match[1].get("name") or "").casefold()))
return scored[0][1]
@staticmethod
def _cornerstone_match_score(query: str, name: str) -> int:
if not query:
return 1
normalized = name.casefold()
if normalized == query:
return 10000
if normalized.startswith(query):
return 9000 - len(normalized)
if query in normalized:
return 8000 - normalized.index(query)
tokens = [token for token in query.split() if token]
if tokens and all(token in normalized for token in tokens):
return 7000 - len(normalized)
return 0
@staticmethod
def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]:
return {
"id": listing.get("id"),
"slug": listing.get("slug"),
"title": listing.get("title"),
"operation": listing.get("operation"),
"type": listing.get("type"),
"price": listing.get("price"),
"currency": listing.get("currency"),
"unit": listing.get("unit"),
"location": listing.get("location"),
"availability": listing.get("availability"),
"in_stock": listing.get("in_stock"),
"advertiser": listing.get("user_username"),
"expires_at": listing.get("date_expiration"),
}
@staticmethod
def _summarize_marketplace_trend(trend: dict[str, Any]) -> dict[str, Any]:
return {
"id_item": trend.get("id_item"),
"item_name": trend.get("item_name"),
"item_slug": trend.get("item_slug"),
"currency": trend.get("currency"),
"sell": {
"avg_price": trend.get("price_avg_sell"),
"avg_price_month": trend.get("price_avg_month_sell"),
"min_price": trend.get("price_min_sell"),
"max_price": trend.get("price_max_sell"),
"listings_count": trend.get("listings_count_sell"),
},
"buy": {
"avg_price": trend.get("price_avg_buy"),
"avg_price_month": trend.get("price_avg_month_buy"),
"min_price": trend.get("price_min_buy"),
"max_price": trend.get("price_max_buy"),
"listings_count": trend.get("listings_count_buy"),
},
"total_listings_count": trend.get("total_listings_count"),
"negotiations_count": trend.get("negotiations_count"),
"negotiations_open": trend.get("negotiations_open"),
"negotiations_success": trend.get("negotiations_success"),
"link_prices": trend.get("link_prices"),
"link_prices_history": trend.get("link_prices_history"),
}
@staticmethod
def _summarize_scwiki_vehicle_search(vehicle: dict[str, Any]) -> dict[str, Any]:
return {
"name": vehicle.get("name"),
"class_name": vehicle.get("class_name"),
"career": vehicle.get("extra_label"),
"api_url": vehicle.get("api_url"),
"web_url": vehicle.get("web_url"),
}
@staticmethod
def _summarize_scwiki_vehicle(vehicle: dict[str, Any]) -> dict[str, Any]:
purchases = []
for entry in ((vehicle.get("uex_prices") or {}).get("purchase") or []):
if not isinstance(entry, dict):
continue
location = entry.get("starmap_location") or {}
purchases.append(
{
"price_buy": entry.get("price_buy"),
"terminal_name": entry.get("terminal_name"),
"location": location.get("name"),
"parent_location": location.get("parent_name"),
"star_system": location.get("star_system_name"),
"game_version": entry.get("game_version"),
"date_updated": entry.get("date_updated"),
"uex_link": entry.get("uex_link"),
}
)
return {
"name": vehicle.get("name") or vehicle.get("game_name"),
"game_name": vehicle.get("game_name"),
"slug": vehicle.get("slug"),
"manufacturer": (vehicle.get("manufacturer") or {}).get("name"),
"career": vehicle.get("career"),
"role": vehicle.get("role"),
"size_class": vehicle.get("size_class"),
"cargo_capacity": vehicle.get("cargo_capacity"),
"crew": vehicle.get("crew"),
"msrp": vehicle.get("msrp"),
"pledge_url": vehicle.get("pledge_url"),
"purchase_locations": purchases,
"description": ((vehicle.get("description") or {}).get("en_EN") or (vehicle.get("game_description") or {}).get("en_EN")),
"web_url": vehicle.get("web_url"),
"updated_at": vehicle.get("updated_at"),
"version": vehicle.get("version"),
}
@staticmethod
def _wikelo_ship_match_score(query: str, project: dict[str, Any]) -> int:
if not query:
return 1
ship_name = str(project.get("ship_name") or "").casefold()
description = str(project.get("description") or "").casefold()
materials = " ".join(
str(item.get("material_name") or "").casefold()
for item in (project.get("required_materials") or [])
if isinstance(item, dict)
)
haystack = " ".join(part for part in [ship_name, description, materials] if part)
if ship_name == query:
return 10000
if query in ship_name:
return 9000 - ship_name.index(query)
if query in description:
return 7000 - description.index(query)
if query in materials:
return 5000 - materials.index(query)
tokens = [token for token in query.split() if token]
if tokens and all(token in haystack for token in tokens):
return 3000 - len(haystack)
return 0
@classmethod
def _summarize_wikelo_ship_project(cls, project: dict[str, Any], detailed: bool = False) -> dict[str, Any]:
materials = []
for item in (project.get("required_materials") or []):
if not isinstance(item, dict):
continue
quantity_needed = item.get("quantity_needed")
quantity_collected = item.get("quantity_collected")
materials.append(
{
"material_name": item.get("material_name"),
"quantity_needed": int(quantity_needed) if isinstance(quantity_needed, (int, float)) and float(quantity_needed).is_integer() else quantity_needed,
"quantity_collected": int(quantity_collected) if isinstance(quantity_collected, (int, float)) and float(quantity_collected).is_integer() else quantity_collected,
}
)
summary = {
"id": project.get("id"),
"ship_name": project.get("ship_name"),
"description": project.get("description"),
"status": project.get("status"),
"privacy": project.get("privacy"),
"owner_name": project.get("owner_name"),
"org_name": project.get("org_name"),
"home_port": project.get("home_port"),
"ship_image": project.get("ship_image"),
"materials_count": len(materials),
"required_materials": materials if detailed else materials[:12],
"source_url": f"https://wikelo-projects.com/Ships",
}
return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
@classmethod
def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]:
summary = cls._project_item(negotiation, mode="summary")
state = cls._negotiation_state(negotiation)
summary.update(
{
"state": state["state"],
"is_open": state["is_open"],
"state_reason": state["reason"],
}
)
for key in ("hash", "id_listing", "id_user", "id_user_seller", "id_user_buyer", "date_closed"):
if key in negotiation and key not in summary:
summary[key] = negotiation.get(key)
return summary
@staticmethod
def _negotiation_state(negotiation: dict[str, Any]) -> dict[str, Any]:
closed_flags = [
"is_closed",
"closed",
"is_cancelled",
"is_canceled",
"is_archived",
"marked_closed",
]
for key in closed_flags:
value = negotiation.get(key)
if value in (True, 1, "1", "true", "True", "yes", "closed"):
return {"state": "closed", "is_open": False, "reason": f"{key} is set"}
closed_dates = [
"date_closed",
"date_completed",
"date_cancelled",
"date_canceled",
"closed_at",
"completed_at",
"cancelled_at",
"canceled_at",
]
for key in closed_dates:
value = negotiation.get(key)
if value not in (None, "", 0, "0", False):
return {"state": "closed", "is_open": False, "reason": f"{key} is populated"}
status = str(negotiation.get("status") or negotiation.get("state") or "").casefold()
if status in {"closed", "cancelled", "canceled", "completed", "declined", "accepted", "rejected"}:
return {"state": "closed", "is_open": False, "reason": f"status is {status}"}
if status in {"open", "active", "pending", "new"}:
return {"state": "open", "is_open": True, "reason": f"status is {status}"}
return {"state": "open", "is_open": True, "reason": "no closed flag, closed date, or closed status was present"}