client-py connected
This commit is contained in:
@@ -110,6 +110,7 @@ export class TelegramHandler {
|
||||
userId: authContext.userId,
|
||||
sessionId: authContext.sessionId,
|
||||
content: text,
|
||||
attachments: [], // TODO: Add image support for Telegram
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
|
||||
@@ -158,21 +158,25 @@ export class WebSocketHandler {
|
||||
}),
|
||||
};
|
||||
|
||||
// Create agent harness
|
||||
const harness = new AgentHarness({
|
||||
userId: authContext.userId,
|
||||
sessionId: authContext.sessionId,
|
||||
license: authContext.license,
|
||||
providerConfig: this.config.providerConfig,
|
||||
logger,
|
||||
});
|
||||
// Declare harness outside try block so it's available in catch
|
||||
let harness: AgentHarness | undefined;
|
||||
|
||||
try {
|
||||
// Initialize workspace and harness
|
||||
// Initialize workspace first
|
||||
await workspace.initialize();
|
||||
workspace.setAdapter(wsAdapter);
|
||||
this.workspaces.set(authContext.sessionId, workspace);
|
||||
|
||||
// Create agent harness with workspace manager
|
||||
harness = new AgentHarness({
|
||||
userId: authContext.userId,
|
||||
sessionId: authContext.sessionId,
|
||||
license: authContext.license,
|
||||
providerConfig: this.config.providerConfig,
|
||||
logger,
|
||||
workspaceManager: workspace,
|
||||
});
|
||||
|
||||
await harness.initialize();
|
||||
this.harnesses.set(authContext.sessionId, harness);
|
||||
|
||||
@@ -220,8 +224,8 @@ export class WebSocketHandler {
|
||||
logger.info({ type: payload.type, request_id: payload.request_id }, 'WebSocket message parsed');
|
||||
|
||||
// Route based on message type
|
||||
if (payload.type === 'message') {
|
||||
// Chat message - send to agent harness
|
||||
if (payload.type === 'message' || payload.type === 'agent_user_message') {
|
||||
// Chat message - send to agent harness with streaming
|
||||
const inboundMessage: InboundMessage = {
|
||||
messageId: randomUUID(),
|
||||
userId: authContext.userId,
|
||||
@@ -231,14 +235,41 @@ export class WebSocketHandler {
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const response = await harness.handleMessage(inboundMessage);
|
||||
if (!harness) {
|
||||
logger.error('Harness not initialized');
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Session not ready' }));
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
...response,
|
||||
})
|
||||
);
|
||||
// Stream response chunks to client
|
||||
try {
|
||||
for await (const chunk of harness.streamMessage(inboundMessage)) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'agent_chunk',
|
||||
content: chunk,
|
||||
done: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Send final chunk with done flag
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'agent_chunk',
|
||||
content: '',
|
||||
done: true,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Error streaming response');
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Failed to generate response',
|
||||
})
|
||||
);
|
||||
}
|
||||
} else if (payload.type === 'hello') {
|
||||
// Workspace sync: hello message
|
||||
logger.debug({ seqs: payload.seqs }, 'Handling workspace hello');
|
||||
@@ -280,8 +311,10 @@ export class WebSocketHandler {
|
||||
this.workspaces.delete(authContext.sessionId);
|
||||
|
||||
// Cleanup harness
|
||||
await harness.cleanup();
|
||||
this.harnesses.delete(authContext.sessionId);
|
||||
if (harness) {
|
||||
await harness.cleanup();
|
||||
this.harnesses.delete(authContext.sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (error: any) => {
|
||||
@@ -292,7 +325,9 @@ export class WebSocketHandler {
|
||||
socket.close(1011, 'Internal server error');
|
||||
await workspace.shutdown();
|
||||
this.workspaces.delete(authContext.sessionId);
|
||||
await harness.cleanup();
|
||||
if (harness) {
|
||||
await harness.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ export class ZMQRelayClient {
|
||||
relayNotificationEndpoint: config.relayNotificationEndpoint,
|
||||
clientId: config.clientId || `gateway-${randomUUID().slice(0, 8)}`,
|
||||
requestTimeout: config.requestTimeout || 30000,
|
||||
onMetadataUpdate: config.onMetadataUpdate || (async () => {}),
|
||||
};
|
||||
this.logger = logger;
|
||||
this.notificationTopic = `RESPONSE:${this.config.clientId}`;
|
||||
|
||||
@@ -101,9 +101,15 @@ See individual workflow READMEs for details.
|
||||
|
||||
YAML-based configuration:
|
||||
|
||||
- `models.yaml`: LLM providers, routing, rate limits
|
||||
- `subagent-routing.yaml`: When to use which subagent
|
||||
|
||||
Model configuration is centralized in the main gateway config (`/config/config.yaml`), including:
|
||||
- Default models and providers
|
||||
- License tier model assignments (free, pro, enterprise)
|
||||
- Model routing rules (complexity, cost-optimized)
|
||||
|
||||
Resource limits (token limits, rate limits) are stored in the database per user.
|
||||
|
||||
## User Context
|
||||
|
||||
Enhanced session context with channel awareness for multi-channel support:
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MCPClientConnector } from './mcp-client.js';
|
||||
import { CONTEXT_URIS, type ResourceContent } from '../types/resources.js';
|
||||
import { LLMProviderFactory, type ProviderConfig } from '../llm/provider.js';
|
||||
import { ModelRouter, RoutingStrategy } from '../llm/router.js';
|
||||
import type { WorkspaceManager } from '../workspace/workspace-manager.js';
|
||||
|
||||
export interface AgentHarnessConfig {
|
||||
userId: string;
|
||||
@@ -15,6 +16,7 @@ export interface AgentHarnessConfig {
|
||||
license: UserLicense;
|
||||
providerConfig: ProviderConfig;
|
||||
logger: FastifyBaseLogger;
|
||||
workspaceManager?: WorkspaceManager;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,9 +35,13 @@ export class AgentHarness {
|
||||
private modelFactory: LLMProviderFactory;
|
||||
private modelRouter: ModelRouter;
|
||||
private mcpClient: MCPClientConnector;
|
||||
private workspaceManager?: WorkspaceManager;
|
||||
private lastWorkspaceSeq: number = 0;
|
||||
private isFirstMessage: boolean = true;
|
||||
|
||||
constructor(config: AgentHarnessConfig) {
|
||||
this.config = config;
|
||||
this.workspaceManager = config.workspaceManager;
|
||||
|
||||
this.modelFactory = new LLMProviderFactory(config.providerConfig, config.logger);
|
||||
this.modelRouter = new ModelRouter(this.modelFactory, config.logger);
|
||||
@@ -102,18 +108,14 @@ export class AgentHarness {
|
||||
// 7. Extract text response (tool handling TODO)
|
||||
const assistantMessage = response.content as string;
|
||||
|
||||
// 8. Save messages to user's MCP server
|
||||
this.config.logger.debug('Saving messages to MCP');
|
||||
await this.mcpClient.callTool('save_message', {
|
||||
role: 'user',
|
||||
content: message.content,
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
});
|
||||
await this.mcpClient.callTool('save_message', {
|
||||
role: 'assistant',
|
||||
content: assistantMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// TODO: Save messages to Iceberg conversation table instead of MCP
|
||||
// Should batch-insert periodically or on session end to avoid many small Parquet files
|
||||
// await icebergConversationStore.appendMessages([...]);
|
||||
|
||||
// Mark first message as processed
|
||||
if (this.isFirstMessage) {
|
||||
this.isFirstMessage = false;
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: `msg_${Date.now()}`,
|
||||
@@ -157,17 +159,17 @@ export class AgentHarness {
|
||||
yield content;
|
||||
}
|
||||
|
||||
// Save after streaming completes
|
||||
await this.mcpClient.callTool('save_message', {
|
||||
role: 'user',
|
||||
content: message.content,
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
});
|
||||
await this.mcpClient.callTool('save_message', {
|
||||
role: 'assistant',
|
||||
content: fullResponse,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// TODO: Save messages to Iceberg conversation table instead of MCP
|
||||
// Should batch-insert periodically or on session end to avoid many small Parquet files
|
||||
// await icebergConversationStore.appendMessages([
|
||||
// { role: 'user', content: message.content, timestamp: message.timestamp },
|
||||
// { role: 'assistant', content: fullResponse, timestamp: new Date() }
|
||||
// ]);
|
||||
|
||||
// Mark first message as processed
|
||||
if (this.isFirstMessage) {
|
||||
this.isFirstMessage = false;
|
||||
}
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error }, 'Error streaming message');
|
||||
throw error;
|
||||
@@ -224,6 +226,15 @@ export class AgentHarness {
|
||||
});
|
||||
}
|
||||
|
||||
// Add workspace delta (for subsequent turns)
|
||||
const workspaceDelta = this.buildWorkspaceDelta();
|
||||
if (workspaceDelta) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: workspaceDelta,
|
||||
});
|
||||
}
|
||||
|
||||
// Add current user message
|
||||
messages.push({
|
||||
role: 'user',
|
||||
@@ -273,9 +284,18 @@ Available features: ${JSON.stringify(this.config.license.features, null, 2)}`;
|
||||
prompt += `\n\n# User Profile\n${userProfile.text}`;
|
||||
}
|
||||
|
||||
// Add workspace context
|
||||
// Add workspace context from MCP resource (if available)
|
||||
if (workspaceState?.text) {
|
||||
prompt += `\n\n# Current Workspace\n${workspaceState.text}`;
|
||||
prompt += `\n\n# Current Workspace (from MCP)\n${workspaceState.text}`;
|
||||
}
|
||||
|
||||
// Add full workspace state from WorkspaceManager (first message only)
|
||||
if (this.isFirstMessage && this.workspaceManager) {
|
||||
const workspaceJSON = this.workspaceManager.serializeState();
|
||||
prompt += `\n\n# Workspace State (JSON)\n\`\`\`json\n${workspaceJSON}\n\`\`\``;
|
||||
|
||||
// Record current workspace sequence for delta tracking
|
||||
this.lastWorkspaceSeq = this.workspaceManager.getCurrentSeq();
|
||||
}
|
||||
|
||||
// Add user's custom instructions (highest priority)
|
||||
@@ -286,6 +306,30 @@ Available features: ${JSON.stringify(this.config.license.features, null, 2)}`;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build workspace delta message for subsequent turns.
|
||||
* Returns null if no changes since last message.
|
||||
*/
|
||||
private buildWorkspaceDelta(): string | null {
|
||||
if (!this.workspaceManager || this.isFirstMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const changes = this.workspaceManager.getChangesSince(this.lastWorkspaceSeq);
|
||||
|
||||
if (Object.keys(changes).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format changes as JSON
|
||||
const deltaJSON = JSON.stringify(changes, null, 2);
|
||||
|
||||
// Update sequence marker
|
||||
this.lastWorkspaceSeq = this.workspaceManager.getCurrentSeq();
|
||||
|
||||
return `[Workspace Changes Since Last Turn]\n\`\`\`json\n${deltaJSON}\n\`\`\``;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# Default LLM Model Configuration
|
||||
|
||||
# Default model for general agent tasks
|
||||
default:
|
||||
provider: anthropic
|
||||
model: claude-3-5-sonnet-20241022
|
||||
temperature: 0.7
|
||||
maxTokens: 4096
|
||||
|
||||
# Model overrides for specific use cases
|
||||
models:
|
||||
# Fast model for simple tasks (routing, classification)
|
||||
fast:
|
||||
provider: anthropic
|
||||
model: claude-3-haiku-20240307
|
||||
temperature: 0.3
|
||||
maxTokens: 1024
|
||||
|
||||
# Reasoning model for complex analysis
|
||||
reasoning:
|
||||
provider: anthropic
|
||||
model: claude-3-5-sonnet-20241022
|
||||
temperature: 0.5
|
||||
maxTokens: 8192
|
||||
|
||||
# Precise model for code generation/review
|
||||
code:
|
||||
provider: anthropic
|
||||
model: claude-3-5-sonnet-20241022
|
||||
temperature: 0.2
|
||||
maxTokens: 8192
|
||||
|
||||
# Creative model for strategy brainstorming
|
||||
creative:
|
||||
provider: anthropic
|
||||
model: claude-3-5-sonnet-20241022
|
||||
temperature: 0.9
|
||||
maxTokens: 4096
|
||||
|
||||
# Embedding model configuration
|
||||
embeddings:
|
||||
provider: openai
|
||||
model: text-embedding-3-small
|
||||
dimensions: 1536
|
||||
|
||||
# Model routing rules (complexity-based)
|
||||
routing:
|
||||
# Simple queries → fast model
|
||||
simple:
|
||||
keywords:
|
||||
- "what is"
|
||||
- "define"
|
||||
- "list"
|
||||
- "show me"
|
||||
maxTokens: 100
|
||||
model: fast
|
||||
|
||||
# Code-related → code model
|
||||
code:
|
||||
keywords:
|
||||
- "code"
|
||||
- "function"
|
||||
- "implement"
|
||||
- "debug"
|
||||
- "review"
|
||||
model: code
|
||||
|
||||
# Analysis tasks → reasoning model
|
||||
analysis:
|
||||
keywords:
|
||||
- "analyze"
|
||||
- "compare"
|
||||
- "evaluate"
|
||||
- "assess"
|
||||
model: reasoning
|
||||
|
||||
# Everything else → default
|
||||
default:
|
||||
model: default
|
||||
|
||||
# Cost optimization settings
|
||||
costControl:
|
||||
# Cache system prompts (Anthropic prompt caching)
|
||||
cacheSystemPrompts: true
|
||||
|
||||
# Token limits per license type
|
||||
tokenLimits:
|
||||
free:
|
||||
maxTokensPerMessage: 2048
|
||||
maxTokensPerDay: 50000
|
||||
pro:
|
||||
maxTokensPerMessage: 8192
|
||||
maxTokensPerDay: 500000
|
||||
enterprise:
|
||||
maxTokensPerMessage: 16384
|
||||
maxTokensPerDay: -1 # unlimited
|
||||
|
||||
# Rate limiting
|
||||
rateLimits:
|
||||
# Requests per minute by license
|
||||
requestsPerMinute:
|
||||
free: 10
|
||||
pro: 60
|
||||
enterprise: 120
|
||||
|
||||
# Concurrent requests
|
||||
concurrentRequests:
|
||||
free: 1
|
||||
pro: 3
|
||||
enterprise: 10
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
|
||||
@@ -11,7 +12,7 @@ export interface MCPClientConfig {
|
||||
|
||||
/**
|
||||
* MCP client connector for user's container
|
||||
* Manages connection to user-specific MCP server
|
||||
* Manages connection to user-specific MCP server via SSE transport
|
||||
*/
|
||||
export class MCPClientConnector {
|
||||
private client: Client | null = null;
|
||||
@@ -23,8 +24,7 @@ export class MCPClientConnector {
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to user's MCP server
|
||||
* TODO: Implement HTTP/SSE transport instead of stdio for container communication
|
||||
* Connect to user's MCP server via SSE transport
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected) {
|
||||
@@ -34,7 +34,7 @@ export class MCPClientConnector {
|
||||
try {
|
||||
this.config.logger.info(
|
||||
{ userId: this.config.userId, url: this.config.mcpServerUrl },
|
||||
'Connecting to user MCP server'
|
||||
'Connecting to user MCP server via SSE'
|
||||
);
|
||||
|
||||
this.client = new Client(
|
||||
@@ -46,24 +46,18 @@ export class MCPClientConnector {
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Replace with HTTP transport when user containers are ready
|
||||
// For now, this is a placeholder structure
|
||||
// const transport = new HTTPTransport(this.config.mcpServerUrl, {
|
||||
// headers: {
|
||||
// 'Authorization': `Bearer ${this.config.platformJWT}`
|
||||
// }
|
||||
// });
|
||||
|
||||
// Placeholder: will be replaced with actual container transport
|
||||
this.config.logger.warn(
|
||||
'MCP transport not yet implemented - using placeholder'
|
||||
// Create SSE transport for HTTP connection to user container
|
||||
const transport = new SSEClientTransport(
|
||||
new URL(`${this.config.mcpServerUrl}/sse`)
|
||||
);
|
||||
|
||||
await this.client.connect(transport);
|
||||
|
||||
this.connected = true;
|
||||
this.config.logger.info('Connected to user MCP server');
|
||||
this.config.logger.info('Connected to user MCP server via SSE');
|
||||
} catch (error) {
|
||||
this.config.logger.error(
|
||||
{ error, userId: this.config.userId },
|
||||
@@ -84,12 +78,8 @@ export class MCPClientConnector {
|
||||
try {
|
||||
this.config.logger.debug({ tool: name, args }, 'Calling MCP tool');
|
||||
|
||||
// TODO: Implement when MCP client is connected
|
||||
// const result = await this.client.callTool({ name, arguments: args });
|
||||
// return result;
|
||||
|
||||
// Placeholder response
|
||||
return { success: true, message: 'MCP tool call placeholder' };
|
||||
const result = await this.client.callTool({ name, arguments: args });
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error, tool: name }, 'MCP tool call failed');
|
||||
throw error;
|
||||
@@ -98,27 +88,35 @@ export class MCPClientConnector {
|
||||
|
||||
/**
|
||||
* List available tools from user's MCP server
|
||||
* Filters to only return tools marked as agent_accessible
|
||||
*/
|
||||
async listTools(): Promise<Array<{ name: string; description?: string }>> {
|
||||
async listTools(): Promise<Array<{ name: string; description?: string; inputSchema?: any }>> {
|
||||
if (!this.client || !this.connected) {
|
||||
throw new Error('MCP client not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Implement when MCP client is connected
|
||||
// const tools = await this.client.listTools();
|
||||
// return tools;
|
||||
const response = await this.client.listTools();
|
||||
|
||||
// Placeholder tools (actions only, not context)
|
||||
return [
|
||||
{ name: 'save_message', description: 'Save message to conversation history' },
|
||||
{ name: 'list_strategies', description: 'List user strategies' },
|
||||
{ name: 'read_strategy', description: 'Read strategy code' },
|
||||
{ name: 'write_strategy', description: 'Write strategy code' },
|
||||
{ name: 'run_backtest', description: 'Run backtest on strategy' },
|
||||
{ name: 'get_watchlist', description: 'Get user watchlist' },
|
||||
{ name: 'execute_trade', description: 'Execute trade' },
|
||||
];
|
||||
// Filter tools to only include agent-accessible ones
|
||||
const tools = response.tools
|
||||
.filter((tool: any) => {
|
||||
// Check if tool has agent_accessible annotation
|
||||
const annotations = tool.annotations || {};
|
||||
return annotations.agent_accessible === true;
|
||||
})
|
||||
.map((tool: any) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
}));
|
||||
|
||||
this.config.logger.debug(
|
||||
{ totalTools: response.tools.length, agentAccessibleTools: tools.length },
|
||||
'Listed MCP tools with filtering'
|
||||
);
|
||||
|
||||
return tools;
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error }, 'Failed to list MCP tools');
|
||||
throw error;
|
||||
@@ -127,6 +125,7 @@ export class MCPClientConnector {
|
||||
|
||||
/**
|
||||
* List available resources from user's MCP server
|
||||
* Filters to only return resources marked as agent_accessible
|
||||
*/
|
||||
async listResources(): Promise<Array<{ uri: string; name: string; description?: string; mimeType?: string }>> {
|
||||
if (!this.client || !this.connected) {
|
||||
@@ -134,37 +133,28 @@ export class MCPClientConnector {
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Implement when MCP client is connected
|
||||
// const resources = await this.client.listResources();
|
||||
// return resources;
|
||||
const response = await this.client.listResources();
|
||||
|
||||
// Placeholder resources for user context
|
||||
return [
|
||||
{
|
||||
uri: 'context://user-profile',
|
||||
name: 'User Profile',
|
||||
description: 'User trading style, preferences, and background',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
{
|
||||
uri: 'context://conversation-summary',
|
||||
name: 'Conversation Summary',
|
||||
description: 'Semantic summary of recent conversation history with RAG',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
{
|
||||
uri: 'context://workspace-state',
|
||||
name: 'Workspace State',
|
||||
description: 'Current chart, watchlist, and open positions',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'context://system-prompt',
|
||||
name: 'Custom System Prompt',
|
||||
description: 'User custom instructions for the assistant',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
];
|
||||
// Filter resources to only include agent-accessible ones
|
||||
const resources = response.resources
|
||||
.filter((resource: any) => {
|
||||
// Check if resource has agent_accessible annotation
|
||||
const annotations = resource.annotations || {};
|
||||
return annotations.agent_accessible === true;
|
||||
})
|
||||
.map((resource: any) => ({
|
||||
uri: resource.uri,
|
||||
name: resource.name,
|
||||
description: resource.description,
|
||||
mimeType: resource.mimeType,
|
||||
}));
|
||||
|
||||
this.config.logger.debug(
|
||||
{ totalResources: response.resources.length, agentAccessibleResources: resources.length },
|
||||
'Listed MCP resources with filtering'
|
||||
);
|
||||
|
||||
return resources;
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error }, 'Failed to list MCP resources');
|
||||
throw error;
|
||||
@@ -182,55 +172,18 @@ export class MCPClientConnector {
|
||||
try {
|
||||
this.config.logger.debug({ uri }, 'Reading MCP resource');
|
||||
|
||||
// TODO: Implement when MCP client is connected
|
||||
// const resource = await this.client.readResource({ uri });
|
||||
// return resource;
|
||||
const response = await this.client.readResource({ uri });
|
||||
|
||||
// Placeholder resource content
|
||||
if (uri === 'context://user-profile') {
|
||||
return {
|
||||
uri,
|
||||
mimeType: 'text/plain',
|
||||
text: `User Profile:
|
||||
- Trading experience: Intermediate
|
||||
- Preferred timeframes: 1h, 4h, 1d
|
||||
- Risk tolerance: Medium
|
||||
- Focus: Swing trading with technical indicators`,
|
||||
};
|
||||
} else if (uri === 'context://conversation-summary') {
|
||||
return {
|
||||
uri,
|
||||
mimeType: 'text/plain',
|
||||
text: `Recent Conversation Summary:
|
||||
[RAG-generated summary would go here]
|
||||
// Extract the first content item (MCP returns array of contents)
|
||||
const content = response.contents[0];
|
||||
|
||||
User recently discussed:
|
||||
- Moving average crossover strategies
|
||||
- Backtesting on BTC/USDT
|
||||
- Risk management techniques`,
|
||||
};
|
||||
} else if (uri === 'context://workspace-state') {
|
||||
return {
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({
|
||||
currentChart: { ticker: 'BINANCE:BTC/USDT', timeframe: '1h' },
|
||||
watchlist: ['BTC/USDT', 'ETH/USDT', 'SOL/USDT'],
|
||||
openPositions: [],
|
||||
}, null, 2),
|
||||
};
|
||||
} else if (uri === 'context://system-prompt') {
|
||||
return {
|
||||
uri,
|
||||
mimeType: 'text/plain',
|
||||
text: `Custom Instructions:
|
||||
- Be concise and data-driven
|
||||
- Always show risk/reward ratios
|
||||
- Prefer simple strategies over complex ones`,
|
||||
};
|
||||
}
|
||||
|
||||
return { uri, text: '' };
|
||||
// Handle union type: content is either TextContent or BlobContent
|
||||
return {
|
||||
uri: content.uri,
|
||||
mimeType: content.mimeType,
|
||||
text: 'text' in content ? content.text : undefined,
|
||||
blob: 'blob' in content ? content.blob : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error, uri }, 'MCP resource read failed');
|
||||
throw error;
|
||||
|
||||
@@ -42,7 +42,7 @@ name: my-subagent
|
||||
description: What it does
|
||||
|
||||
# Model override (optional)
|
||||
model: claude-3-5-sonnet-20241022
|
||||
model: claude-sonnet-4-6
|
||||
temperature: 0.3
|
||||
maxTokens: 4096
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ name: code-reviewer
|
||||
description: Reviews trading strategy code for bugs, performance issues, and best practices
|
||||
|
||||
# Model configuration (optional override)
|
||||
model: claude-3-5-sonnet-20241022
|
||||
model: claude-sonnet-4-6
|
||||
temperature: 0.3
|
||||
maxTokens: 4096
|
||||
|
||||
|
||||
@@ -334,7 +334,7 @@ timeout: 60000
|
||||
maxRetries: 3
|
||||
requiresApproval: false
|
||||
|
||||
model: claude-3-5-sonnet-20241022
|
||||
model: claude-sonnet-4-6
|
||||
```
|
||||
|
||||
### 6. Add Factory Function
|
||||
|
||||
@@ -15,5 +15,5 @@ maxValidationRetries: 3 # Max times to retry fixing errors
|
||||
minBacktestScore: 0.5 # Minimum Sharpe ratio to pass
|
||||
|
||||
# Model override (optional)
|
||||
model: claude-3-5-sonnet-20241022
|
||||
model: claude-sonnet-4-6
|
||||
temperature: 0.3
|
||||
|
||||
@@ -15,5 +15,5 @@ maxPositionPercent: 0.05 # 5% of portfolio max
|
||||
minRiskRewardRatio: 2.0 # Minimum 2:1 risk/reward
|
||||
|
||||
# Model override (optional)
|
||||
model: claude-3-5-sonnet-20241022
|
||||
model: claude-sonnet-4-6
|
||||
temperature: 0.2
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface DeploymentSpec {
|
||||
agentImage: string;
|
||||
sidecarImage: string;
|
||||
storageClass: string;
|
||||
imagePullPolicy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +133,8 @@ export class KubernetesClient {
|
||||
.replace(/\{\{pvcName\}\}/g, pvcName)
|
||||
.replace(/\{\{agentImage\}\}/g, spec.agentImage)
|
||||
.replace(/\{\{sidecarImage\}\}/g, spec.sidecarImage)
|
||||
.replace(/\{\{storageClass\}\}/g, spec.storageClass);
|
||||
.replace(/\{\{storageClass\}\}/g, spec.storageClass)
|
||||
.replace(/\{\{imagePullPolicy\}\}/g, spec.imagePullPolicy || 'Always');
|
||||
|
||||
// Parse YAML documents (deployment, pvc, service)
|
||||
const documents = yaml.loadAll(rendered) as any[];
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface ContainerManagerConfig {
|
||||
agentImage: string;
|
||||
sidecarImage: string;
|
||||
storageClass: string;
|
||||
imagePullPolicy?: string;
|
||||
namespace: string;
|
||||
logger: FastifyBaseLogger;
|
||||
}
|
||||
@@ -82,6 +83,7 @@ export class ContainerManager {
|
||||
agentImage: this.config.agentImage,
|
||||
sidecarImage: this.config.sidecarImage,
|
||||
storageClass: this.config.storageClass,
|
||||
imagePullPolicy: this.config.imagePullPolicy,
|
||||
};
|
||||
|
||||
await this.config.k8sClient.createAgentDeployment(spec);
|
||||
|
||||
@@ -40,7 +40,7 @@ spec:
|
||||
containers:
|
||||
- name: agent
|
||||
image: {{agentImage}}
|
||||
imagePullPolicy: Always
|
||||
imagePullPolicy: {{imagePullPolicy}}
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@@ -72,6 +72,8 @@ spec:
|
||||
value: "3000"
|
||||
- name: ZMQ_CONTROL_PORT
|
||||
value: "5555"
|
||||
- name: ZMQ_GATEWAY_ENDPOINT
|
||||
value: "tcp://gateway.default.svc.cluster.local:5571"
|
||||
|
||||
ports:
|
||||
- name: mcp
|
||||
@@ -102,14 +104,14 @@ spec:
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
path: /health
|
||||
port: mcp
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
- name: lifecycle-sidecar
|
||||
image: {{sidecarImage}}
|
||||
imagePullPolicy: Always
|
||||
imagePullPolicy: {{imagePullPolicy}}
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
@@ -39,7 +39,7 @@ spec:
|
||||
containers:
|
||||
- name: agent
|
||||
image: {{agentImage}}
|
||||
imagePullPolicy: Always
|
||||
imagePullPolicy: {{imagePullPolicy}}
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@@ -71,7 +71,9 @@ spec:
|
||||
value: "3000"
|
||||
- name: ZMQ_CONTROL_PORT
|
||||
value: "5555"
|
||||
|
||||
- name: ZMQ_GATEWAY_ENDPOINT
|
||||
value: "tcp://gateway.default.svc.cluster.local:5571"
|
||||
|
||||
ports:
|
||||
- name: mcp
|
||||
containerPort: 3000
|
||||
@@ -101,14 +103,14 @@ spec:
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
path: /health
|
||||
port: mcp
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
- name: lifecycle-sidecar
|
||||
image: {{sidecarImage}}
|
||||
imagePullPolicy: Always
|
||||
imagePullPolicy: {{imagePullPolicy}}
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
@@ -39,7 +39,7 @@ spec:
|
||||
containers:
|
||||
- name: agent
|
||||
image: {{agentImage}}
|
||||
imagePullPolicy: Always
|
||||
imagePullPolicy: {{imagePullPolicy}}
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@@ -71,6 +71,8 @@ spec:
|
||||
value: "3000"
|
||||
- name: ZMQ_CONTROL_PORT
|
||||
value: "5555"
|
||||
- name: ZMQ_GATEWAY_ENDPOINT
|
||||
value: "tcp://gateway.default.svc.cluster.local:5571"
|
||||
|
||||
ports:
|
||||
- name: mcp
|
||||
@@ -101,14 +103,14 @@ spec:
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
path: /health
|
||||
port: mcp
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
- name: lifecycle-sidecar
|
||||
image: {{sidecarImage}}
|
||||
imagePullPolicy: Always
|
||||
imagePullPolicy: {{imagePullPolicy}}
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
@@ -19,11 +19,33 @@ export interface ModelConfig {
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* License tier model configuration
|
||||
*/
|
||||
export interface LicenseTierModels {
|
||||
default: string;
|
||||
cost_optimized: string;
|
||||
complex: string;
|
||||
allowed_models?: string[];
|
||||
blocked_models?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* License models configuration
|
||||
*/
|
||||
export interface LicenseModelsConfig {
|
||||
free: LicenseTierModels;
|
||||
pro: LicenseTierModels;
|
||||
enterprise: LicenseTierModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider configuration with API keys
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
anthropicApiKey?: string;
|
||||
defaultModel?: ModelConfig;
|
||||
licenseModels?: LicenseModelsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,15 +99,26 @@ export class LLMProviderFactory {
|
||||
* Get default model based on environment
|
||||
*/
|
||||
getDefaultModel(): ModelConfig {
|
||||
if (this.config.defaultModel) {
|
||||
return this.config.defaultModel;
|
||||
}
|
||||
|
||||
if (!this.config.anthropicApiKey) {
|
||||
throw new Error('Anthropic API key not configured');
|
||||
}
|
||||
|
||||
return {
|
||||
provider: LLMProvider.ANTHROPIC,
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-sonnet-4-6',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license models configuration
|
||||
*/
|
||||
getLicenseModelsConfig(): LicenseModelsConfig | undefined {
|
||||
return this.config.licenseModels;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,14 +127,14 @@ export class LLMProviderFactory {
|
||||
export const MODELS = {
|
||||
CLAUDE_SONNET: {
|
||||
provider: LLMProvider.ANTHROPIC,
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-sonnet-4-6',
|
||||
},
|
||||
CLAUDE_HAIKU: {
|
||||
provider: LLMProvider.ANTHROPIC,
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
},
|
||||
CLAUDE_OPUS: {
|
||||
provider: LLMProvider.ANTHROPIC,
|
||||
model: 'claude-3-opus-20240229',
|
||||
model: 'claude-opus-4-6',
|
||||
},
|
||||
} as const satisfies Record<string, ModelConfig>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { LLMProviderFactory, type ModelConfig, LLMProvider } from './provider.js';
|
||||
import { LLMProviderFactory, type ModelConfig, LLMProvider, type LicenseModelsConfig } from './provider.js';
|
||||
import type { UserLicense } from '../types/user.js';
|
||||
|
||||
/**
|
||||
@@ -25,11 +25,13 @@ export class ModelRouter {
|
||||
private factory: LLMProviderFactory;
|
||||
private logger: FastifyBaseLogger;
|
||||
private defaultModel: ModelConfig;
|
||||
private licenseModels?: LicenseModelsConfig;
|
||||
|
||||
constructor(factory: LLMProviderFactory, logger: FastifyBaseLogger) {
|
||||
this.factory = factory;
|
||||
this.logger = logger;
|
||||
this.defaultModel = factory.getDefaultModel();
|
||||
this.licenseModels = factory.getLicenseModelsConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,37 +99,53 @@ export class ModelRouter {
|
||||
private routeByComplexity(message: string, license: UserLicense): ModelConfig {
|
||||
const isComplex = this.isComplexQuery(message);
|
||||
|
||||
// Use configuration if available
|
||||
if (this.licenseModels) {
|
||||
const tierConfig = this.licenseModels[license.licenseType];
|
||||
if (tierConfig) {
|
||||
const model = isComplex ? tierConfig.complex : tierConfig.default;
|
||||
return { provider: this.defaultModel.provider as LLMProvider, model };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to hardcoded defaults
|
||||
if (license.licenseType === 'enterprise') {
|
||||
// Enterprise users get best models for complex queries
|
||||
return isComplex
|
||||
? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-opus-20240229' }
|
||||
: { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
|
||||
? { provider: LLMProvider.ANTHROPIC, model: 'claude-opus-4-6' }
|
||||
: { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' };
|
||||
}
|
||||
|
||||
if (license.licenseType === 'pro') {
|
||||
// Pro users get good models
|
||||
return isComplex
|
||||
? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' }
|
||||
: { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
|
||||
? { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' }
|
||||
: { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
|
||||
}
|
||||
|
||||
// Free users get efficient models
|
||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
|
||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Route based on license tier
|
||||
*/
|
||||
private routeByLicenseTier(license: UserLicense): ModelConfig {
|
||||
// Use configuration if available
|
||||
if (this.licenseModels) {
|
||||
const tierConfig = this.licenseModels[license.licenseType];
|
||||
if (tierConfig) {
|
||||
return { provider: this.defaultModel.provider as LLMProvider, model: tierConfig.default };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to hardcoded defaults
|
||||
switch (license.licenseType) {
|
||||
case 'enterprise':
|
||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
|
||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' };
|
||||
|
||||
case 'pro':
|
||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
|
||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' };
|
||||
|
||||
case 'free':
|
||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
|
||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
|
||||
|
||||
default:
|
||||
return this.defaultModel;
|
||||
@@ -137,24 +155,50 @@ export class ModelRouter {
|
||||
/**
|
||||
* Route to cheapest available model
|
||||
*/
|
||||
private routeByCost(_license: UserLicense): ModelConfig {
|
||||
// All tiers: use Haiku for cost efficiency
|
||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
|
||||
private routeByCost(license: UserLicense): ModelConfig {
|
||||
// Use configuration if available
|
||||
if (this.licenseModels) {
|
||||
const tierConfig = this.licenseModels[license.licenseType];
|
||||
if (tierConfig) {
|
||||
return { provider: this.defaultModel.provider as LLMProvider, model: tierConfig.cost_optimized };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use Haiku for cost efficiency
|
||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model is allowed for user's license
|
||||
*/
|
||||
private isModelAllowed(model: ModelConfig, license: UserLicense): boolean {
|
||||
// Free tier: only Haiku
|
||||
// Use configuration if available
|
||||
if (this.licenseModels) {
|
||||
const tierConfig = this.licenseModels[license.licenseType];
|
||||
if (tierConfig) {
|
||||
// Check allowed_models list if defined
|
||||
if (tierConfig.allowed_models && tierConfig.allowed_models.length > 0) {
|
||||
return tierConfig.allowed_models.includes(model.model);
|
||||
}
|
||||
|
||||
// Check blocked_models list if defined
|
||||
if (tierConfig.blocked_models && tierConfig.blocked_models.length > 0) {
|
||||
return !tierConfig.blocked_models.includes(model.model);
|
||||
}
|
||||
|
||||
// No restrictions if neither list is defined
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to hardcoded defaults
|
||||
if (license.licenseType === 'free') {
|
||||
const allowedModels = ['claude-3-5-haiku-20241022'];
|
||||
const allowedModels = ['claude-haiku-4-5-20251001'];
|
||||
return allowedModels.includes(model.model);
|
||||
}
|
||||
|
||||
// Pro: all except Opus
|
||||
if (license.licenseType === 'pro') {
|
||||
const blockedModels = ['claude-3-opus-20240229'];
|
||||
const blockedModels = ['claude-opus-4-6'];
|
||||
return !blockedModels.includes(model.model);
|
||||
}
|
||||
|
||||
|
||||
@@ -85,12 +85,35 @@ function loadConfig() {
|
||||
// Authentication configuration
|
||||
authSecret: secretsData.auth?.secret || process.env.AUTH_SECRET || 'change-me-in-production',
|
||||
|
||||
// LLM provider API keys
|
||||
// LLM provider API keys and model configuration
|
||||
providerConfig: {
|
||||
anthropicApiKey: secretsData.llm_providers?.anthropic_api_key || process.env.ANTHROPIC_API_KEY,
|
||||
openaiApiKey: secretsData.llm_providers?.openai_api_key || process.env.OPENAI_API_KEY,
|
||||
googleApiKey: secretsData.llm_providers?.google_api_key || process.env.GOOGLE_API_KEY,
|
||||
openrouterApiKey: secretsData.llm_providers?.openrouter_api_key || process.env.OPENROUTER_API_KEY,
|
||||
defaultModel: {
|
||||
provider: configData.defaults?.model_provider || 'anthropic',
|
||||
model: configData.defaults?.model || 'claude-sonnet-4-6',
|
||||
},
|
||||
licenseModels: {
|
||||
free: {
|
||||
default: configData.license_models?.free?.default || 'claude-haiku-4-5-20251001',
|
||||
cost_optimized: configData.license_models?.free?.cost_optimized || 'claude-haiku-4-5-20251001',
|
||||
complex: configData.license_models?.free?.complex || 'claude-haiku-4-5-20251001',
|
||||
allowed_models: configData.license_models?.free?.allowed_models || ['claude-haiku-4-5-20251001'],
|
||||
},
|
||||
pro: {
|
||||
default: configData.license_models?.pro?.default || 'claude-sonnet-4-6',
|
||||
cost_optimized: configData.license_models?.pro?.cost_optimized || 'claude-haiku-4-5-20251001',
|
||||
complex: configData.license_models?.pro?.complex || 'claude-sonnet-4-6',
|
||||
blocked_models: configData.license_models?.pro?.blocked_models || ['claude-opus-4-6'],
|
||||
},
|
||||
enterprise: {
|
||||
default: configData.license_models?.enterprise?.default || 'claude-sonnet-4-6',
|
||||
cost_optimized: configData.license_models?.enterprise?.cost_optimized || 'claude-haiku-4-5-20251001',
|
||||
complex: configData.license_models?.enterprise?.complex || 'claude-opus-4-6',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
telegramBotToken: secretsData.telegram?.bot_token || process.env.TELEGRAM_BOT_TOKEN || '',
|
||||
@@ -148,6 +171,7 @@ function loadConfig() {
|
||||
agentImage: configData.kubernetes?.agent_image || process.env.AGENT_IMAGE || 'ghcr.io/dexorder/agent:latest',
|
||||
sidecarImage: configData.kubernetes?.sidecar_image || process.env.SIDECAR_IMAGE || 'ghcr.io/dexorder/lifecycle-sidecar:latest',
|
||||
storageClass: configData.kubernetes?.storage_class || process.env.AGENT_STORAGE_CLASS || 'standard',
|
||||
imagePullPolicy: configData.kubernetes?.image_pull_policy || process.env.IMAGE_PULL_POLICY || 'Always',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -265,6 +289,7 @@ const containerManager = new ContainerManager({
|
||||
agentImage: config.kubernetes.agentImage,
|
||||
sidecarImage: config.kubernetes.sidecarImage,
|
||||
storageClass: config.kubernetes.storageClass,
|
||||
imagePullPolicy: config.kubernetes.imagePullPolicy,
|
||||
namespace: config.kubernetes.namespace,
|
||||
logger: app.log,
|
||||
});
|
||||
|
||||
@@ -66,7 +66,8 @@ export type {
|
||||
PathTriggerHandler,
|
||||
PathTriggerContext,
|
||||
ChartState,
|
||||
ChartStore,
|
||||
ShapesStore,
|
||||
IndicatorsStore,
|
||||
ChannelState,
|
||||
ChannelInfo,
|
||||
WorkspaceStores,
|
||||
|
||||
@@ -404,4 +404,28 @@ export class SyncRegistry {
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patches since a given sequence number for a store.
|
||||
* Returns empty array if no patches available since that sequence.
|
||||
*/
|
||||
getPatchesSince(storeName: string, sinceSeq: number): JsonPatchOp[] | null {
|
||||
const entry = this.entries.get(storeName);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const catchupPatches = entry.getCatchupPatches(sinceSeq);
|
||||
if (catchupPatches === null) {
|
||||
return null; // History not available
|
||||
}
|
||||
|
||||
// Flatten all patches into a single array
|
||||
const allPatches: JsonPatchOp[] = [];
|
||||
for (const { patch } of catchupPatches) {
|
||||
allPatches.push(...patch);
|
||||
}
|
||||
|
||||
return allPatches;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,17 +84,19 @@ export const DEFAULT_STORES: StoreConfig[] = [
|
||||
symbol: 'BINANCE:BTC/USDT',
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
interval: '15',
|
||||
period: '15',
|
||||
selected_shapes: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'chartStore',
|
||||
name: 'shapes',
|
||||
persistent: true,
|
||||
initialState: () => ({
|
||||
drawings: {},
|
||||
templates: {},
|
||||
}),
|
||||
initialState: () => ({}),
|
||||
},
|
||||
{
|
||||
name: 'indicators',
|
||||
persistent: true,
|
||||
initialState: () => ({}),
|
||||
},
|
||||
{
|
||||
name: 'channelState',
|
||||
@@ -198,22 +200,70 @@ export interface PathTrigger {
|
||||
*/
|
||||
export interface ChartState {
|
||||
symbol: string;
|
||||
start_time: number | null;
|
||||
end_time: number | null;
|
||||
interval: string;
|
||||
selected_shapes: string[];
|
||||
start_time: number | null; // unix timestamp
|
||||
end_time: number | null; // unix timestamp
|
||||
period: string; // OHLC duration (e.g., '15' for 15 minutes)
|
||||
selected_shapes: string[]; // list of shape ID's
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart store - persistent, stores drawings and templates.
|
||||
* Control point for shapes (drawings/annotations).
|
||||
*/
|
||||
export interface ChartStore {
|
||||
drawings: Record<string, unknown>;
|
||||
templates: Record<string, unknown>;
|
||||
export interface ControlPoint {
|
||||
time: number; // unix timestamp
|
||||
price: number;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape (drawing/annotation) on TradingView chart.
|
||||
*/
|
||||
export interface Shape {
|
||||
id: string;
|
||||
type: string;
|
||||
points: ControlPoint[];
|
||||
color?: string;
|
||||
line_width?: number;
|
||||
line_style?: string;
|
||||
properties?: Record<string, any>;
|
||||
symbol?: string;
|
||||
created_at?: number;
|
||||
modified_at?: number;
|
||||
original_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shapes store - persistent, stores TradingView drawings and annotations.
|
||||
*/
|
||||
export type ShapesStore = Record<string, Shape>;
|
||||
|
||||
/**
|
||||
* Indicator instance on TradingView chart.
|
||||
*/
|
||||
export interface IndicatorInstance {
|
||||
id: string;
|
||||
talib_name: string;
|
||||
instance_name: string;
|
||||
parameters: Record<string, any>;
|
||||
tv_study_id?: string;
|
||||
tv_indicator_name?: string;
|
||||
tv_inputs?: Record<string, any>;
|
||||
visible: boolean;
|
||||
pane: string;
|
||||
symbol?: string;
|
||||
created_at?: number;
|
||||
modified_at?: number;
|
||||
original_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicators store - persistent, stores TradingView studies/indicators.
|
||||
*/
|
||||
export type IndicatorsStore = Record<string, IndicatorInstance>;
|
||||
|
||||
/**
|
||||
* Channel state - transient, tracks connected channels.
|
||||
* NOTE: This store is gateway-only and should NOT be synced to web clients.
|
||||
*/
|
||||
export interface ChannelState {
|
||||
connected: Record<string, ChannelInfo>;
|
||||
@@ -233,7 +283,8 @@ export interface ChannelInfo {
|
||||
*/
|
||||
export interface WorkspaceStores {
|
||||
chartState: ChartState;
|
||||
chartStore: ChartStore;
|
||||
shapes: ShapesStore;
|
||||
indicators: IndicatorsStore;
|
||||
channelState: ChannelState;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -271,6 +271,53 @@ export class WorkspaceManager {
|
||||
return this.registry.getStoreNames();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize entire workspace state as JSON.
|
||||
*/
|
||||
serializeState(): string {
|
||||
const state: Record<string, unknown> = {};
|
||||
|
||||
for (const storeConfig of this.stores) {
|
||||
const storeState = this.registry.getState(storeConfig.name);
|
||||
if (storeState !== undefined) {
|
||||
state[storeConfig.name] = storeState;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(state, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the highest sequence number across all stores.
|
||||
*/
|
||||
getCurrentSeq(): number {
|
||||
let maxSeq = 0;
|
||||
for (const storeName of this.registry.getStoreNames()) {
|
||||
const seq = this.registry.getSeq(storeName);
|
||||
if (seq > maxSeq) {
|
||||
maxSeq = seq;
|
||||
}
|
||||
}
|
||||
return maxSeq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all patches since a given sequence number across all stores.
|
||||
* Returns patches grouped by store name.
|
||||
*/
|
||||
getChangesSince(sinceSeq: number): Record<string, JsonPatchOp[]> {
|
||||
const changes: Record<string, JsonPatchOp[]> = {};
|
||||
|
||||
for (const storeConfig of this.stores) {
|
||||
const patches = this.registry.getPatchesSince(storeConfig.name, sinceSeq);
|
||||
if (patches && patches.length > 0) {
|
||||
changes[storeConfig.name] = patches;
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Path Triggers
|
||||
// ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user