morpheus-cli 0.7.7 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,302 @@
1
+ import WebSocket from 'ws';
2
+ import { DisplayManager } from '../display.js';
3
+ import { ConfigManager } from '../../config/manager.js';
4
+ import { SMITH_PROTOCOL_VERSION } from './types.js';
5
+ /**
6
+ * SmithConnection — WebSocket client wrapper for a single Morpheus → Smith connection.
7
+ * Handles connection lifecycle, reconnection with exponential backoff, auth, and heartbeat.
8
+ */
9
+ export class SmithConnection {
10
+ ws = null;
11
+ entry;
12
+ registry;
13
+ display = DisplayManager.getInstance();
14
+ messageHandlers = [];
15
+ pendingTasks = new Map();
16
+ reconnectTimer = null;
17
+ heartbeatTimer = null;
18
+ reconnectAttempt = 0;
19
+ maxReconnectDelay = 30000;
20
+ intentionalClose = false;
21
+ _connected = false;
22
+ _authFailed = false;
23
+ constructor(entry, registry) {
24
+ this.entry = entry;
25
+ this.registry = registry;
26
+ }
27
+ get connected() {
28
+ return this._connected;
29
+ }
30
+ /** Connect to the Smith WebSocket server */
31
+ async connect() {
32
+ this.intentionalClose = false;
33
+ this.registry.updateState(this.entry.name, 'connecting');
34
+ return new Promise((resolve, reject) => {
35
+ const config = ConfigManager.getInstance().getSmithsConfig();
36
+ const scheme = this.entry.tls ? 'wss' : 'ws';
37
+ const url = `${scheme}://${this.entry.host}:${this.entry.port}`;
38
+ try {
39
+ this.ws = new WebSocket(url, {
40
+ handshakeTimeout: config.connection_timeout_ms,
41
+ headers: {
42
+ 'x-smith-auth': this.entry.auth_token,
43
+ 'x-smith-protocol-version': String(SMITH_PROTOCOL_VERSION),
44
+ },
45
+ });
46
+ }
47
+ catch (err) {
48
+ this.registry.updateState(this.entry.name, 'error');
49
+ reject(err);
50
+ return;
51
+ }
52
+ const connectionTimeout = setTimeout(() => {
53
+ if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
54
+ this.ws.terminate();
55
+ const err = new Error(`Connection timeout to Smith '${this.entry.name}'`);
56
+ this.registry.updateState(this.entry.name, 'error');
57
+ reject(err);
58
+ }
59
+ }, config.connection_timeout_ms);
60
+ this.ws.on('open', () => {
61
+ clearTimeout(connectionTimeout);
62
+ this._connected = true;
63
+ this.reconnectAttempt = 0;
64
+ this.registry.updateState(this.entry.name, 'online');
65
+ this.startHeartbeat();
66
+ this.display.log(`Connected to Smith '${this.entry.name}' at ${url}`, {
67
+ source: 'SmithConnection',
68
+ level: 'info',
69
+ });
70
+ resolve();
71
+ });
72
+ this.ws.on('message', (data) => {
73
+ try {
74
+ const message = JSON.parse(data.toString());
75
+ this.handleMessage(message);
76
+ }
77
+ catch (err) {
78
+ this.display.log(`Invalid message from Smith '${this.entry.name}': ${err.message}`, {
79
+ source: 'SmithConnection',
80
+ level: 'warning',
81
+ });
82
+ }
83
+ });
84
+ this.ws.on('close', (code, reason) => {
85
+ clearTimeout(connectionTimeout);
86
+ this._connected = false;
87
+ this.stopHeartbeat();
88
+ this.registry.updateState(this.entry.name, 'offline');
89
+ // Reject all pending tasks
90
+ for (const [id, pending] of this.pendingTasks) {
91
+ clearTimeout(pending.timer);
92
+ pending.reject(new Error(`Connection to Smith '${this.entry.name}' closed`));
93
+ }
94
+ this.pendingTasks.clear();
95
+ if (!this.intentionalClose) {
96
+ this.display.log(`Smith '${this.entry.name}' disconnected (code: ${code}). Reconnecting...`, { source: 'SmithConnection', level: 'warning' });
97
+ this.scheduleReconnect();
98
+ }
99
+ });
100
+ this.ws.on('error', (err) => {
101
+ clearTimeout(connectionTimeout);
102
+ // Detect 401 auth failures — no point retrying
103
+ if (err.message?.includes('401')) {
104
+ this._authFailed = true;
105
+ }
106
+ this.display.log(`WebSocket error with Smith '${this.entry.name}': ${err.message}`, {
107
+ source: 'SmithConnection',
108
+ level: 'error',
109
+ });
110
+ this.registry.updateState(this.entry.name, 'error');
111
+ // 'close' event will fire after 'error', which handles reconnection
112
+ });
113
+ });
114
+ }
115
+ /** Send a message to the Smith */
116
+ send(message) {
117
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
118
+ throw new Error(`Smith '${this.entry.name}' is not connected`);
119
+ }
120
+ this.ws.send(JSON.stringify(message));
121
+ }
122
+ /**
123
+ * Send a task to the Smith and wait for the result.
124
+ * Returns a promise that resolves when the Smith sends a task_result.
125
+ */
126
+ sendTask(taskId, tool, args) {
127
+ const config = ConfigManager.getInstance().getSmithsConfig();
128
+ return new Promise((resolve, reject) => {
129
+ const timer = setTimeout(() => {
130
+ this.pendingTasks.delete(taskId);
131
+ reject(new Error(`Task ${taskId} timed out on Smith '${this.entry.name}'`));
132
+ }, config.task_timeout_ms);
133
+ this.pendingTasks.set(taskId, { resolve, reject, timer });
134
+ try {
135
+ this.send({
136
+ type: 'task',
137
+ id: taskId,
138
+ payload: { tool, args },
139
+ });
140
+ }
141
+ catch (err) {
142
+ clearTimeout(timer);
143
+ this.pendingTasks.delete(taskId);
144
+ reject(err);
145
+ }
146
+ });
147
+ }
148
+ /** Returns true if the given entry differs from what this connection was created with */
149
+ hasEntryChanged(entry) {
150
+ return (entry.host !== this.entry.host ||
151
+ entry.port !== this.entry.port ||
152
+ entry.auth_token !== this.entry.auth_token ||
153
+ (entry.tls ?? false) !== (this.entry.tls ?? false));
154
+ }
155
+ /** Register a handler for incoming messages */
156
+ onMessage(handler) {
157
+ this.messageHandlers.push(handler);
158
+ }
159
+ /** Remove a previously registered message handler */
160
+ offMessage(handler) {
161
+ const idx = this.messageHandlers.indexOf(handler);
162
+ if (idx !== -1)
163
+ this.messageHandlers.splice(idx, 1);
164
+ }
165
+ /** Disconnect gracefully */
166
+ async disconnect() {
167
+ this.intentionalClose = true;
168
+ this.stopHeartbeat();
169
+ if (this.reconnectTimer) {
170
+ clearTimeout(this.reconnectTimer);
171
+ this.reconnectTimer = null;
172
+ }
173
+ // Reject pending tasks
174
+ for (const [id, pending] of this.pendingTasks) {
175
+ clearTimeout(pending.timer);
176
+ pending.reject(new Error('Connection intentionally closed'));
177
+ }
178
+ this.pendingTasks.clear();
179
+ if (this.ws) {
180
+ return new Promise((resolve) => {
181
+ if (this.ws.readyState === WebSocket.OPEN) {
182
+ this.ws.once('close', () => {
183
+ this._connected = false;
184
+ resolve();
185
+ });
186
+ this.ws.close(1000, 'Morpheus shutting down');
187
+ // Force close after 3s if server doesn't respond
188
+ setTimeout(() => {
189
+ if (this.ws?.readyState !== WebSocket.CLOSED) {
190
+ this.ws?.terminate();
191
+ }
192
+ this._connected = false;
193
+ resolve();
194
+ }, 3000);
195
+ }
196
+ else {
197
+ this.ws?.terminate();
198
+ this._connected = false;
199
+ resolve();
200
+ }
201
+ });
202
+ }
203
+ }
204
+ // ─── Private ───
205
+ handleMessage(message) {
206
+ switch (message.type) {
207
+ case 'task_result':
208
+ this.handleTaskResult(message);
209
+ break;
210
+ case 'pong':
211
+ this.handlePong(message);
212
+ break;
213
+ case 'register':
214
+ this.handleRegister(message);
215
+ break;
216
+ case 'config_report':
217
+ this.handleConfigReport(message);
218
+ break;
219
+ case 'task_progress':
220
+ // Forward to all handlers for real-time progress
221
+ break;
222
+ }
223
+ // Forward to all registered handlers
224
+ for (const handler of this.messageHandlers) {
225
+ try {
226
+ handler(message);
227
+ }
228
+ catch (err) {
229
+ this.display.log(`Message handler error: ${err.message}`, {
230
+ source: 'SmithConnection',
231
+ level: 'warning',
232
+ });
233
+ }
234
+ }
235
+ }
236
+ handleTaskResult(message) {
237
+ const pending = this.pendingTasks.get(message.id);
238
+ if (pending) {
239
+ clearTimeout(pending.timer);
240
+ this.pendingTasks.delete(message.id);
241
+ pending.resolve(message.result);
242
+ }
243
+ }
244
+ handlePong(message) {
245
+ this.registry.updateState(this.entry.name, 'online', message.stats);
246
+ }
247
+ handleRegister(message) {
248
+ if (message.protocol_version !== SMITH_PROTOCOL_VERSION) {
249
+ this.display.log(`Smith '${this.entry.name}' protocol version mismatch: expected ${SMITH_PROTOCOL_VERSION}, got ${message.protocol_version}`, { source: 'SmithConnection', level: 'warning' });
250
+ }
251
+ const smith = this.registry.get(this.entry.name);
252
+ if (smith) {
253
+ smith.capabilities = message.capabilities;
254
+ }
255
+ }
256
+ handleConfigReport(message) {
257
+ this.registry.updateConfig(this.entry.name, message.devkit);
258
+ }
259
+ startHeartbeat() {
260
+ const config = ConfigManager.getInstance().getSmithsConfig();
261
+ this.heartbeatTimer = setInterval(() => {
262
+ try {
263
+ this.send({ type: 'ping', timestamp: Date.now() });
264
+ }
265
+ catch {
266
+ // Connection may be broken — close event will handle reconnect
267
+ }
268
+ }, config.heartbeat_interval_ms);
269
+ }
270
+ stopHeartbeat() {
271
+ if (this.heartbeatTimer) {
272
+ clearInterval(this.heartbeatTimer);
273
+ this.heartbeatTimer = null;
274
+ }
275
+ }
276
+ scheduleReconnect() {
277
+ if (this.intentionalClose)
278
+ return;
279
+ // Auth failures won't self-resolve — don't retry
280
+ if (this._authFailed) {
281
+ this.display.log(`Smith '${this.entry.name}' — authentication failed (401). Check auth_token in config. Not retrying.`, { source: 'SmithConnection', level: 'error' });
282
+ this.registry.updateState(this.entry.name, 'offline');
283
+ return;
284
+ }
285
+ // Exponential backoff: 1s → 2s → 4s → … → 30s (cap), then keeps retrying every 30s indefinitely.
286
+ // We never give up — when the Smith comes back online, Morpheus reconnects automatically.
287
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), this.maxReconnectDelay);
288
+ // Cap the exponent so the delay stays at maxReconnectDelay once reached
289
+ if (delay < this.maxReconnectDelay) {
290
+ this.reconnectAttempt++;
291
+ }
292
+ this.display.log(`Reconnecting to Smith '${this.entry.name}' in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempt + 1})`, { source: 'SmithConnection', level: 'info' });
293
+ this.reconnectTimer = setTimeout(async () => {
294
+ try {
295
+ await this.connect();
296
+ }
297
+ catch {
298
+ // connect() failure will trigger 'close' → scheduleReconnect again
299
+ }
300
+ }, delay);
301
+ }
302
+ }
@@ -0,0 +1,214 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import { tool } from '@langchain/core/tools';
3
+ import { HumanMessage, SystemMessage, AIMessage } from '@langchain/core/messages';
4
+ import { DisplayManager } from '../display.js';
5
+ import { SmithRegistry } from './registry.js';
6
+ import { ConfigManager } from '../../config/manager.js';
7
+ import { ProviderFactory } from '../providers/factory.js';
8
+ import { buildDevKit } from '../../devkit/index.js';
9
+ import { SQLiteChatMessageHistory } from '../memory/sqlite.js';
10
+ /**
11
+ * SmithDelegator — delegates natural-language tasks to a specific Smith.
12
+ *
13
+ * Works like Apoc: creates a LangChain ReactAgent with proxy tools
14
+ * that forward execution to the remote Smith via WebSocket.
15
+ * The LLM plans which DevKit tools to call, and each tool invocation
16
+ * is sent to the Smith for actual execution.
17
+ */
18
+ export class SmithDelegator {
19
+ static instance;
20
+ display = DisplayManager.getInstance();
21
+ registry = SmithRegistry.getInstance();
22
+ constructor() { }
23
+ static getInstance() {
24
+ if (!SmithDelegator.instance) {
25
+ SmithDelegator.instance = new SmithDelegator();
26
+ }
27
+ return SmithDelegator.instance;
28
+ }
29
+ /**
30
+ * Build proxy tools that forward calls to a Smith via WebSocket.
31
+ * Uses local DevKit schemas for tool definitions, filtered by Smith capabilities.
32
+ */
33
+ buildProxyTools(smithName) {
34
+ const smith = this.registry.get(smithName);
35
+ if (!smith)
36
+ return [];
37
+ const connection = this.registry.getConnection(smithName);
38
+ if (!connection)
39
+ return [];
40
+ const capabilities = new Set(smith.capabilities);
41
+ // Build local DevKit tools for schema extraction only
42
+ const localTools = buildDevKit({
43
+ working_dir: process.cwd(),
44
+ allowed_commands: [],
45
+ timeout_ms: 30000,
46
+ sandbox_dir: process.cwd(),
47
+ readonly_mode: false,
48
+ enable_filesystem: true,
49
+ enable_shell: true,
50
+ enable_git: true,
51
+ enable_network: true,
52
+ });
53
+ // Create proxy tools — same schema, but execution forwards to Smith
54
+ return localTools
55
+ .filter(t => capabilities.has(t.name))
56
+ .map(localTool => tool(async (args) => {
57
+ const taskId = uuidv4();
58
+ this.display.log(`Smith '${smithName}' → ${localTool.name}`, {
59
+ source: 'SmithDelegator',
60
+ level: 'info',
61
+ });
62
+ const progressHandler = (msg) => {
63
+ if (msg.type === 'task_progress' && msg.id === taskId) {
64
+ this.display.log(`Smith '${smithName}' → ${msg.progress.message}`, {
65
+ source: 'SmithDelegator',
66
+ level: 'info',
67
+ });
68
+ }
69
+ };
70
+ connection.onMessage(progressHandler);
71
+ try {
72
+ const result = await connection.sendTask(taskId, localTool.name, args);
73
+ if (result.success) {
74
+ return typeof result.data === 'string'
75
+ ? result.data
76
+ : JSON.stringify(result.data, null, 2);
77
+ }
78
+ else {
79
+ return `Error: ${result.error}`;
80
+ }
81
+ }
82
+ catch (err) {
83
+ return `Error executing ${localTool.name} on Smith '${smithName}': ${err.message}`;
84
+ }
85
+ finally {
86
+ connection.offMessage(progressHandler);
87
+ }
88
+ }, {
89
+ name: localTool.name,
90
+ description: localTool.description,
91
+ schema: localTool.schema,
92
+ }));
93
+ }
94
+ /**
95
+ * Delegate a task to a specific Smith by name.
96
+ * Creates an LLM agent with proxy tools, plans tool calls, and executes on the Smith.
97
+ */
98
+ async delegate(smithName, task, context) {
99
+ const smith = this.registry.get(smithName);
100
+ if (!smith) {
101
+ return `❌ Smith '${smithName}' not found. Available: ${this.registry.list().map(s => s.name).join(', ') || 'none'}`;
102
+ }
103
+ if (smith.state !== 'online') {
104
+ return `❌ Smith '${smithName}' is ${smith.state}. Cannot delegate.`;
105
+ }
106
+ const connection = this.registry.getConnection(smithName);
107
+ if (!connection || !connection.connected) {
108
+ return `❌ No active connection to Smith '${smithName}'.`;
109
+ }
110
+ this.display.log(`Delegating to Smith '${smithName}': ${task.slice(0, 100)}...`, {
111
+ source: 'SmithDelegator',
112
+ level: 'info',
113
+ meta: { smith: smithName },
114
+ });
115
+ try {
116
+ // Build proxy tools for this Smith's capabilities
117
+ const proxyTools = this.buildProxyTools(smithName);
118
+ if (proxyTools.length === 0) {
119
+ return `❌ Smith '${smithName}' has no available tools.`;
120
+ }
121
+ // Create a fresh ReactAgent with proxy tools
122
+ const config = ConfigManager.getInstance().get();
123
+ const llmConfig = config.apoc || config.llm;
124
+ const agent = await ProviderFactory.createBare(llmConfig, proxyTools);
125
+ const osInfo = smith.stats?.os ? ` running ${smith.stats.os}` : '';
126
+ const hostname = smith.stats?.hostname ? ` (hostname: ${smith.stats.hostname})` : '';
127
+ const systemMessage = new SystemMessage(`You are a remote task executor for Smith '${smithName}'.
128
+ Your tools execute on a remote machine at ${smith.host}:${smith.port}${osInfo}${hostname}.
129
+
130
+ Execute the requested task using the available tools. Be direct and efficient.
131
+ If a task fails, report the error clearly.
132
+ Respond in the same language as the task.`);
133
+ const userContent = context
134
+ ? `Context: ${context}\n\nTask: ${task}`
135
+ : task;
136
+ const messages = [systemMessage, new HumanMessage(userContent)];
137
+ const response = await agent.invoke({ messages });
138
+ // Extract final response
139
+ const lastMessage = response.messages[response.messages.length - 1];
140
+ const content = typeof lastMessage.content === 'string'
141
+ ? lastMessage.content
142
+ : JSON.stringify(lastMessage.content);
143
+ // Persist token usage to session history
144
+ try {
145
+ const history = new SQLiteChatMessageHistory({ sessionId: 'smith' });
146
+ try {
147
+ const persisted = new AIMessage(content);
148
+ persisted.usage_metadata = lastMessage.usage_metadata
149
+ ?? lastMessage.response_metadata?.usage
150
+ ?? lastMessage.response_metadata?.tokenUsage
151
+ ?? lastMessage.usage;
152
+ persisted.provider_metadata = {
153
+ provider: llmConfig.provider,
154
+ model: llmConfig.model,
155
+ };
156
+ await history.addMessage(persisted);
157
+ }
158
+ finally {
159
+ history.close();
160
+ }
161
+ }
162
+ catch {
163
+ // Non-critical — don't fail the delegation over token tracking
164
+ }
165
+ this.display.log(`Smith '${smithName}' delegation completed.`, {
166
+ source: 'SmithDelegator',
167
+ level: 'info',
168
+ });
169
+ return content;
170
+ }
171
+ catch (err) {
172
+ this.display.log(`Smith delegation error: ${err.message}`, {
173
+ source: 'SmithDelegator',
174
+ level: 'error',
175
+ });
176
+ return `❌ Smith '${smithName}' delegation failed: ${err.message}`;
177
+ }
178
+ }
179
+ /**
180
+ * Ping a specific Smith and return latency info.
181
+ */
182
+ async ping(smithName) {
183
+ const smith = this.registry.get(smithName);
184
+ if (!smith) {
185
+ return { online: false, error: `Smith '${smithName}' not found` };
186
+ }
187
+ const connection = this.registry.getConnection(smithName);
188
+ if (!connection || !connection.connected) {
189
+ return { online: false, error: `No active connection to Smith '${smithName}'` };
190
+ }
191
+ const start = Date.now();
192
+ try {
193
+ connection.send({ type: 'ping', timestamp: start });
194
+ // Wait for pong (up to 5s)
195
+ return new Promise((resolve) => {
196
+ const handler = (msg) => {
197
+ if (msg.type === 'pong') {
198
+ clearTimeout(timeout);
199
+ connection.offMessage(handler);
200
+ resolve({ online: true, latencyMs: Date.now() - start });
201
+ }
202
+ };
203
+ const timeout = setTimeout(() => {
204
+ connection.offMessage(handler);
205
+ resolve({ online: false, error: 'Ping timeout (5s)' });
206
+ }, 5000);
207
+ connection.onMessage(handler);
208
+ });
209
+ }
210
+ catch (err) {
211
+ return { online: false, error: err.message };
212
+ }
213
+ }
214
+ }
@@ -0,0 +1,4 @@
1
+ export { SmithRegistry } from './registry.js';
2
+ export { SmithConnection } from './connection.js';
3
+ export { SmithDelegator } from './delegator.js';
4
+ export * from './types.js';