@latentforce/latentgraph 1.0.0

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,334 @@
1
+ import WebSocket from 'ws';
2
+ import { EventEmitter } from 'events';
3
+ import { WS_URL } from '../utils/config.js';
4
+ import { ToolsExecutor } from './tools-executor.js';
5
+ const RECONNECT_INTERVAL = 5000; // 5 seconds - matching extension
6
+ const MAX_RECONNECT_ATTEMPTS = 100; // matching extension
7
+ /**
8
+ * WebSocket client matching extension's websocket-client.js
9
+ */
10
+ export class WebSocketClient extends EventEmitter {
11
+ ws = null;
12
+ authenticated = false;
13
+ projectInfo = null;
14
+ userInfo = null;
15
+ toolsExecutor = null;
16
+ shouldReconnect = true;
17
+ reconnectTimer = null;
18
+ apiKey;
19
+ projectId;
20
+ wsUrl;
21
+ isConnecting = false;
22
+ reconnectAttempts = 0;
23
+ maxReconnectAttempts = MAX_RECONNECT_ATTEMPTS;
24
+ workspaceRoot;
25
+ constructor(options) {
26
+ super();
27
+ this.apiKey = options.apiKey;
28
+ this.projectId = options.projectId;
29
+ this.wsUrl = options.wsUrl || WS_URL;
30
+ this.workspaceRoot = options.workspaceRoot || process.cwd();
31
+ // Initialize tools executor
32
+ this.toolsExecutor = new ToolsExecutor(this, this.workspaceRoot);
33
+ }
34
+ /**
35
+ * Set the tools executor
36
+ */
37
+ setToolsExecutor(toolsExecutor) {
38
+ this.toolsExecutor = toolsExecutor;
39
+ console.log('[WS-Client] Tools executor set');
40
+ }
41
+ /**
42
+ * Connect to WebSocket server
43
+ */
44
+ async connect() {
45
+ if (!this.apiKey || !this.projectId) {
46
+ throw new Error('API key and project ID are required');
47
+ }
48
+ this.shouldReconnect = true;
49
+ this.reconnectAttempts = 0;
50
+ console.log(`[WS-Client] Initiating connection to project: ${this.projectId}`);
51
+ return this._attemptConnection();
52
+ }
53
+ /**
54
+ * Internal method to attempt connection
55
+ */
56
+ async _attemptConnection() {
57
+ if (this.isConnecting) {
58
+ console.log('[WS-Client] Connection attempt already in progress, skipping...');
59
+ return;
60
+ }
61
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
62
+ console.error(`[WS-Client] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`);
63
+ this.emit('max_reconnects_reached');
64
+ this.shouldReconnect = false;
65
+ return;
66
+ }
67
+ this.isConnecting = true;
68
+ this.reconnectAttempts++;
69
+ // Emit reconnecting event
70
+ this.emit('reconnecting', this.reconnectAttempts);
71
+ return new Promise((resolve, reject) => {
72
+ // IMPORTANT: Use project_id in URL, matching extension
73
+ const wsUrl = `${this.wsUrl}/ws/extension/${this.projectId}`;
74
+ console.log(`[WS-Client] Connecting to ${wsUrl} (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
75
+ try {
76
+ this.ws = new WebSocket(wsUrl);
77
+ }
78
+ catch (error) {
79
+ console.error('[WS-Client] Failed to create WebSocket:', error);
80
+ this.isConnecting = false;
81
+ this._scheduleReconnect();
82
+ reject(error);
83
+ return;
84
+ }
85
+ // Connection timeout (10 seconds) - matching extension
86
+ const timeout = setTimeout(() => {
87
+ console.log('[WS-Client] Connection timeout (10s)');
88
+ if (this.ws) {
89
+ this.ws.close();
90
+ }
91
+ this.isConnecting = false;
92
+ this._scheduleReconnect();
93
+ reject(new Error('Connection timeout'));
94
+ }, 10000);
95
+ // WebSocket opened - send auth
96
+ this.ws.on('open', () => {
97
+ console.log('[WS-Client] ✓ WebSocket connection opened');
98
+ this.emit('connecting');
99
+ // Send authentication message - matching extension format
100
+ const authMessage = {
101
+ type: 'auth',
102
+ api_key: this.apiKey,
103
+ project_id: this.projectId,
104
+ };
105
+ console.log('[WS-Client] Sending authentication...');
106
+ this.ws.send(JSON.stringify(authMessage));
107
+ });
108
+ // Message received
109
+ this.ws.on('message', (data) => {
110
+ try {
111
+ const message = JSON.parse(data.toString());
112
+ if (message.type === 'auth_success') {
113
+ clearTimeout(timeout);
114
+ this.authenticated = true;
115
+ this.userInfo = message.user;
116
+ this.projectInfo = message.project;
117
+ this.isConnecting = false;
118
+ this.reconnectAttempts = 0;
119
+ console.log('[WS-Client] ✓✓✓ AUTHENTICATION SUCCESSFUL ✓✓✓');
120
+ if (this.userInfo) {
121
+ console.log(`[WS-Client] User: ${this.userInfo.email}`);
122
+ }
123
+ if (this.projectInfo) {
124
+ console.log(`[WS-Client] Project: ${this.projectInfo.project_name}`);
125
+ }
126
+ if (this.reconnectTimer) {
127
+ clearTimeout(this.reconnectTimer);
128
+ this.reconnectTimer = null;
129
+ }
130
+ // Emit connected event
131
+ this.emit('connected', this.projectInfo);
132
+ resolve(message);
133
+ }
134
+ else if (message.type === 'auth_failed') {
135
+ clearTimeout(timeout);
136
+ console.error('[WS-Client] ❌ AUTHENTICATION FAILED:', message.message);
137
+ this.isConnecting = false;
138
+ this.shouldReconnect = false; // Don't retry on auth failure
139
+ this.emit('auth_failed', message.message);
140
+ reject(new Error(message.message));
141
+ }
142
+ else {
143
+ // Handle other messages
144
+ this.handleMessage(message);
145
+ }
146
+ }
147
+ catch (err) {
148
+ console.error('[WS-Client] Error parsing message:', err);
149
+ }
150
+ });
151
+ // WebSocket error
152
+ this.ws.on('error', (error) => {
153
+ clearTimeout(timeout);
154
+ console.error('[WS-Client] WebSocket error:', error.message);
155
+ this.isConnecting = false;
156
+ this.emit('error', error);
157
+ this._scheduleReconnect();
158
+ reject(error);
159
+ });
160
+ // WebSocket closed
161
+ this.ws.on('close', (code, reason) => {
162
+ clearTimeout(timeout);
163
+ console.log(`[WS-Client] Connection closed (code: ${code}, reason: ${reason?.toString() || 'none'})`);
164
+ this.authenticated = false;
165
+ this.isConnecting = false;
166
+ this.emit('disconnected', code, reason?.toString());
167
+ if (this.shouldReconnect) {
168
+ this._scheduleReconnect();
169
+ }
170
+ });
171
+ });
172
+ }
173
+ /**
174
+ * Schedule reconnection attempt
175
+ */
176
+ _scheduleReconnect() {
177
+ if (this.reconnectTimer || !this.shouldReconnect) {
178
+ return;
179
+ }
180
+ console.log(`[WS-Client] Scheduling reconnect in ${RECONNECT_INTERVAL}ms... (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
181
+ this.reconnectTimer = setTimeout(() => {
182
+ this.reconnectTimer = null;
183
+ console.log('[WS-Client] Attempting to reconnect...');
184
+ this._attemptConnection().catch(err => {
185
+ console.error('[WS-Client] Reconnection failed:', err.message);
186
+ });
187
+ }, RECONNECT_INTERVAL);
188
+ }
189
+ /**
190
+ * Handle incoming messages
191
+ */
192
+ async handleMessage(message) {
193
+ console.log(`[WS-Client] ← Received: ${message.type}`);
194
+ if (message.type === 'execute_tool') {
195
+ await this.handleToolRequest(message);
196
+ }
197
+ else if (message.type === 'tool_ack') {
198
+ console.log(`[WS-Client] Server acknowledged tool: ${message.tool} (${message.request_id})`);
199
+ this.emit('tool_acknowledged', message);
200
+ }
201
+ else if (message.type === 'pong') {
202
+ console.log('[WS-Client] Pong received');
203
+ this.emit('pong');
204
+ }
205
+ else if (message.type === 'error') {
206
+ console.error('[WS-Client] Server error:', message.message);
207
+ this.emit('server_error', message.message);
208
+ }
209
+ else if (message.type === 'user_input_required') {
210
+ console.log('[WS-Client] 🔔 User input required notification received');
211
+ console.log(`[WS-Client] Agent: ${message.agent_name}`);
212
+ console.log(`[WS-Client] Questions: ${message.questions_count}`);
213
+ this.emit('user_input_required', {
214
+ agent_id: message.agent_id,
215
+ agent_name: message.agent_name,
216
+ questions_count: message.questions_count,
217
+ message: message.message
218
+ });
219
+ }
220
+ else {
221
+ console.log('[WS-Client] Unknown message type:', message.type);
222
+ this.emit('message', message);
223
+ }
224
+ }
225
+ /**
226
+ * Handle tool execution request from server
227
+ */
228
+ async handleToolRequest(message) {
229
+ const toolName = message.tool;
230
+ const params = message.params || {};
231
+ const requestId = message.request_id;
232
+ console.log(`[WS-Client] ═══════════════════════════════════`);
233
+ console.log(`[WS-Client] TOOL EXECUTION REQUEST`);
234
+ console.log(`[WS-Client] Tool: ${toolName}`);
235
+ console.log(`[WS-Client] Request ID: ${requestId}`);
236
+ console.log(`[WS-Client] Params:`, JSON.stringify(params, null, 2));
237
+ console.log(`[WS-Client] ═══════════════════════════════════`);
238
+ if (!this.toolsExecutor) {
239
+ console.error('[WS-Client] ❌ Tools executor not available');
240
+ await this.sendMessage({
241
+ type: 'tool_error',
242
+ tool: toolName,
243
+ request_id: requestId,
244
+ error: 'Tools executor not initialized'
245
+ });
246
+ return;
247
+ }
248
+ try {
249
+ console.log(`[WS-Client] Executing tool: ${toolName}...`);
250
+ const result = await this.toolsExecutor.executeTool(toolName, params);
251
+ console.log(`[WS-Client] ✓ Tool execution completed`);
252
+ console.log(`[WS-Client] Result status: ${result?.status || 'unknown'}`);
253
+ await this.sendMessage({
254
+ type: 'tool_result',
255
+ tool: toolName,
256
+ result: result,
257
+ request_id: requestId,
258
+ timestamp: new Date().toISOString()
259
+ });
260
+ console.log(`[WS-Client] → Tool result sent to server`);
261
+ this.emit('tool_executed', { tool: toolName, result, requestId });
262
+ }
263
+ catch (error) {
264
+ console.error(`[WS-Client] ❌ Tool execution failed:`, error);
265
+ await this.sendMessage({
266
+ type: 'tool_error',
267
+ tool: toolName,
268
+ request_id: requestId,
269
+ error: error.message,
270
+ timestamp: new Date().toISOString()
271
+ });
272
+ this.emit('tool_error', { tool: toolName, error, requestId });
273
+ }
274
+ }
275
+ /**
276
+ * Send a message to the server
277
+ */
278
+ async sendMessage(message) {
279
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
280
+ throw new Error('WebSocket not connected');
281
+ }
282
+ this.ws.send(JSON.stringify(message));
283
+ }
284
+ /**
285
+ * Send a ping to keep connection alive
286
+ */
287
+ async ping() {
288
+ if (this.isConnected()) {
289
+ await this.sendMessage({ type: 'ping' });
290
+ console.log('[WS-Client] → Sent: ping');
291
+ }
292
+ }
293
+ /**
294
+ * Check if connected and authenticated
295
+ */
296
+ isConnected() {
297
+ return this.authenticated && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
298
+ }
299
+ /**
300
+ * Disconnect from server
301
+ */
302
+ disconnect() {
303
+ console.log('[WS-Client] Manual disconnect requested');
304
+ this.shouldReconnect = false;
305
+ if (this.reconnectTimer) {
306
+ clearTimeout(this.reconnectTimer);
307
+ this.reconnectTimer = null;
308
+ }
309
+ if (this.ws) {
310
+ this.ws.close();
311
+ this.ws = null;
312
+ }
313
+ this.authenticated = false;
314
+ this.emit('disconnected', 1000, 'Manual disconnect');
315
+ }
316
+ /**
317
+ * Get project information
318
+ */
319
+ getProjectInfo() {
320
+ return this.projectInfo;
321
+ }
322
+ /**
323
+ * Get user information
324
+ */
325
+ getUserInfo() {
326
+ return this.userInfo;
327
+ }
328
+ /**
329
+ * Get current reconnection attempt count
330
+ */
331
+ getReconnectAttempts() {
332
+ return this.reconnectAttempts;
333
+ }
334
+ }
package/build/index.js ADDED
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { createRequire } from 'module';
4
+ const require = createRequire(import.meta.url);
5
+ const { version } = require('../package.json');
6
+ const program = new Command();
7
+ program
8
+ .name('lgraph')
9
+ .description('Shift CLI - AI-powered code intelligence and dependency analysis.\n\n' +
10
+ 'Shift indexes your codebase, builds a dependency relationship graph (DRG),\n' +
11
+ 'and provides AI-powered insights via MCP tools.\n\n' +
12
+ 'Quick start:\n' +
13
+ ' lgraph start Start the daemon and configure project\n' +
14
+ ' lgraph init Scan and index the project\n' +
15
+ ' lgraph update-drg Build/update the dependency graph\n' +
16
+ ' lgraph status Check current status\n\n' +
17
+ 'Configuration:\n' +
18
+ ' Global config: ~/.shift/config.json\n' +
19
+ ' Project config: .shift/config.json')
20
+ .version(version);
21
+ // MCP server mode (default when run via MCP host)
22
+ program
23
+ .command('mcp', { isDefault: true, hidden: true })
24
+ .description('Start MCP server on stdio')
25
+ .action(async () => {
26
+ const mcpExplicitlyRequested = process.argv.includes('mcp');
27
+ if (process.stdin.isTTY && !mcpExplicitlyRequested) {
28
+ program.outputHelp();
29
+ return;
30
+ }
31
+ const { startMcpServer } = await import('./mcp-server.js');
32
+ await startMcpServer();
33
+ });
34
+ // --- start ---
35
+ const startCmd = program
36
+ .command('start')
37
+ .description('Start the Shift daemon for this project')
38
+ .option('--guest', 'Use guest authentication (auto-creates a temporary project)')
39
+ .option('--api-key <key>', 'Provide your Shift API key directly instead of interactive prompt')
40
+ .option('--project-name <name>', 'Create a new project or match an existing one by name')
41
+ .option('--project-id <id>', 'Link to an existing project by its UUID')
42
+ .option('--template <id>', 'Use a specific migration template when creating the project')
43
+ .action(async (options) => {
44
+ const { startCommand } = await import('./cli/commands/start.js');
45
+ await startCommand({
46
+ guest: options.guest,
47
+ apiKey: options.apiKey,
48
+ projectName: options.projectName,
49
+ projectId: options.projectId,
50
+ template: options.template,
51
+ });
52
+ });
53
+ startCmd.addHelpText('after', `
54
+ Details:
55
+ Resolves authentication, configures the project, and launches a
56
+ background daemon that maintains a WebSocket connection to the
57
+ Shift backend.
58
+
59
+ Examples:
60
+ lgraph start Interactive setup
61
+ lgraph start --guest Quick start without API key
62
+ lgraph start --api-key <key> --project-name "My App"
63
+ `);
64
+ // --- init ---
65
+ const initCmd = program
66
+ .command('init')
67
+ .description('Initialize and scan the project for file indexing')
68
+ .option('-f, --force', 'Force re-indexing even if the project is already indexed')
69
+ .option('--guest', 'Use guest authentication (auto-creates a temporary project)')
70
+ .option('--api-key <key>', 'Provide your Shift API key directly instead of interactive prompt')
71
+ .option('--project-name <name>', 'Create a new project or match an existing one by name')
72
+ .option('--project-id <id>', 'Link to an existing project by its UUID')
73
+ .option('--template <id>', 'Use a specific migration template when creating the project')
74
+ .action(async (options) => {
75
+ const { initCommand } = await import('./cli/commands/init.js');
76
+ await initCommand({
77
+ force: options.force ?? false,
78
+ guest: options.guest,
79
+ apiKey: options.apiKey,
80
+ projectName: options.projectName,
81
+ projectId: options.projectId,
82
+ template: options.template,
83
+ });
84
+ });
85
+ initCmd.addHelpText('after', `
86
+ Details:
87
+ Performs a full project scan — collects the file tree, categorizes files
88
+ (source, config, assets), gathers git info, and sends everything to the
89
+ Shift backend for indexing. Automatically starts the daemon if not running.
90
+
91
+ If the project is already indexed, you will be prompted to re-index.
92
+ Use --force to skip the prompt.
93
+
94
+ Examples:
95
+ lgraph init Interactive initialization
96
+ lgraph init --force Force re-index without prompt
97
+ lgraph init --guest Quick init with guest auth
98
+ `);
99
+ // --- stop ---
100
+ const stopCmd = program
101
+ .command('stop')
102
+ .description('Stop the Shift daemon for this project')
103
+ .action(async () => {
104
+ const { stopCommand } = await import('./cli/commands/stop.js');
105
+ await stopCommand();
106
+ });
107
+ stopCmd.addHelpText('after', `
108
+ Details:
109
+ Terminates the background daemon process. The daemon can be
110
+ restarted later with "lgraph start".
111
+ `);
112
+ // --- status ---
113
+ const statusCmd = program
114
+ .command('status')
115
+ .description('Show the current Shift status')
116
+ .action(async () => {
117
+ const { statusCommand } = await import('./cli/commands/status.js');
118
+ await statusCommand();
119
+ });
120
+ statusCmd.addHelpText('after', `
121
+ Details:
122
+ Displays a comprehensive overview of:
123
+ - API key configuration (guest or authenticated)
124
+ - Project details (name, ID)
125
+ - Registered agents
126
+ - Backend indexing status and file count
127
+ - Daemon process status (PID, WebSocket connection, uptime)
128
+ `);
129
+ // --- update-drg ---
130
+ const updateDrgCmd = program
131
+ .command('update-drg')
132
+ .description('Update the dependency relationship graph (DRG)')
133
+ .option('-m, --mode <mode>', 'Update mode: "baseline" (all files) or "incremental" (git-changed only)', 'incremental')
134
+ .action(async (options) => {
135
+ const { updateDrgCommand } = await import('./cli/commands/update-drg.js');
136
+ await updateDrgCommand({ mode: options.mode });
137
+ });
138
+ updateDrgCmd.addHelpText('after', `
139
+ Details:
140
+ Scans files, detects git changes,
141
+ and sends file contents to the backend for
142
+ dependency analysis.
143
+
144
+ Modes:
145
+ incremental Send only git-changed files (default, faster)
146
+ baseline Send all JS/TS files (full re-analysis)
147
+
148
+ Examples:
149
+ lgraph update-drg Incremental update
150
+ lgraph update-drg -m baseline Full re-analysis
151
+ `);
152
+ // --- add mcp servers---
153
+ const addCmd = program
154
+ .command('add <tool>')
155
+ .description('Add Shift MCP server to an AI coding tool')
156
+ .action(async (tool) => {
157
+ const { addCommand } = await import('./cli/commands/add.js');
158
+ await addCommand(tool);
159
+ });
160
+ addCmd.addHelpText('after', `
161
+ Supported tools:
162
+ shiftcode Write to shiftcode.json
163
+ claude-code Configure via Claude Code CLI
164
+ opencode Write to opencode.json
165
+ codex Configure via Codex CLI
166
+ copilot Write to .vscode/mcp.json
167
+ droid Configure via Factory-Droid CLI
168
+
169
+ Examples:
170
+ lgraph add shiftcode
171
+ lgraph add claude-code
172
+ lgraph add copilot
173
+ lgraph add opencode
174
+ `);
175
+ // --- config ---
176
+ const configCmd = program
177
+ .command('config [action] [key] [value]')
178
+ .description('Manage Shift configuration (URLs, API key)')
179
+ .action(async (action, key, value) => {
180
+ const { configCommand } = await import('./cli/commands/config.js');
181
+ await configCommand(action, key, value);
182
+ });
183
+ configCmd.addHelpText('after', `
184
+ Actions:
185
+ show Display current configuration (default)
186
+ set <key> <val> Set a configuration value
187
+ clear [key] Clear a specific key or all configuration
188
+
189
+ Configurable keys:
190
+ api-key Your Shift API key
191
+ api-url Backend API URL
192
+ orch-url Orchestrator URL
193
+ ws-url WebSocket URL
194
+
195
+ URLs can also be set via environment variables:
196
+ SHIFT_API_URL, SHIFT_ORCH_URL, SHIFT_WS_URL
197
+
198
+ Examples:
199
+ lgraph config Show config
200
+ lgraph config set api-key sk-abc123 Set API key
201
+ lgraph config set api-url http://localhost:9000 Set API URL
202
+ lgraph config clear api-key Clear API key
203
+ lgraph config clear Clear all config
204
+ `);
205
+ program.parse();