helix-assistant-cli 0.0.0-stage → 0.2.4

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.
Files changed (51) hide show
  1. package/bin/helix.js +3 -0
  2. package/dist/client/agent-client.d.ts +60 -0
  3. package/dist/client/agent-client.js +322 -0
  4. package/dist/client/agent-launcher.d.ts +30 -0
  5. package/dist/client/agent-launcher.js +339 -0
  6. package/dist/client/types.d.ts +88 -0
  7. package/dist/client/types.js +2 -0
  8. package/dist/commands/apikey.d.ts +3 -0
  9. package/dist/commands/apikey.js +123 -0
  10. package/dist/commands/browser.d.ts +2 -0
  11. package/dist/commands/browser.js +35 -0
  12. package/dist/commands/mcp.d.ts +3 -0
  13. package/dist/commands/mcp.js +102 -0
  14. package/dist/commands/mic.d.ts +2 -0
  15. package/dist/commands/mic.js +57 -0
  16. package/dist/commands/skills.d.ts +3 -0
  17. package/dist/commands/skills.js +105 -0
  18. package/dist/config/env.d.ts +6 -0
  19. package/dist/config/env.js +71 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +193 -0
  22. package/dist/ui/autocomplete.d.ts +25 -0
  23. package/dist/ui/autocomplete.js +142 -0
  24. package/dist/ui/banner.d.ts +15 -0
  25. package/dist/ui/banner.js +84 -0
  26. package/dist/ui/chat.d.ts +1 -0
  27. package/dist/ui/chat.js +5 -0
  28. package/dist/ui/colors.d.ts +34 -0
  29. package/dist/ui/colors.js +69 -0
  30. package/dist/ui/repl.d.ts +31 -0
  31. package/dist/ui/repl.js +448 -0
  32. package/package.json +30 -3
  33. package/src/client/agent-client.ts +387 -0
  34. package/src/client/agent-launcher.ts +374 -0
  35. package/src/client/types.ts +95 -0
  36. package/src/commands/apikey.ts +129 -0
  37. package/src/commands/browser.ts +39 -0
  38. package/src/commands/mcp.ts +105 -0
  39. package/src/commands/mic.ts +57 -0
  40. package/src/commands/skills.ts +109 -0
  41. package/src/config/env.ts +63 -0
  42. package/src/index.ts +208 -0
  43. package/src/ui/autocomplete.ts +153 -0
  44. package/src/ui/banner.ts +85 -0
  45. package/src/ui/chat.ts +1 -0
  46. package/src/ui/colors.ts +70 -0
  47. package/src/ui/repl.ts +504 -0
  48. package/tests/cli.test.ts +187 -0
  49. package/tsconfig.json +19 -0
  50. package/tsconfig.test.json +7 -0
  51. package/README.md +0 -3
@@ -0,0 +1,387 @@
1
+ import WebSocket from 'ws';
2
+ import { EventEmitter } from 'events';
3
+ import type {
4
+ AgentMessage,
5
+ ConfirmationRequest,
6
+ HelixSettings,
7
+ McpServerConfig,
8
+ ModelSummary,
9
+ ProviderSummary,
10
+ SkillSummary,
11
+ TaskSummary,
12
+ UserTextMessagePayload,
13
+ } from './types';
14
+
15
+ type ResponseResolver = (data: unknown, error?: string) => void;
16
+
17
+ export interface AgentClientOptions {
18
+ wsUrl?: string;
19
+ wsToken?: string;
20
+ autoReconnect?: boolean;
21
+ reconnectIntervalMs?: number;
22
+ }
23
+
24
+ export class AgentClient extends EventEmitter {
25
+ private ws: WebSocket | null = null;
26
+ private wsUrl: string;
27
+ private wsToken?: string;
28
+ private autoReconnect: boolean;
29
+ private reconnectIntervalMs: number;
30
+ private reconnecting: boolean = false;
31
+ private isExplicitlyClosed: boolean = false;
32
+ private pendingRequests: Map<string, ResponseResolver> = new Map();
33
+ private requestTimeouts: Map<string, NodeJS.Timeout> = new Map();
34
+ private isConnected: boolean = false;
35
+
36
+ constructor(options: AgentClientOptions = {}) {
37
+ super();
38
+ const { getOrGenerateWsToken } = require('./agent-launcher');
39
+ this.wsUrl = options.wsUrl || 'ws://127.0.0.1:8765';
40
+ this.wsToken = options.wsToken || getOrGenerateWsToken();
41
+ this.autoReconnect = options.autoReconnect ?? true;
42
+ this.reconnectIntervalMs = options.reconnectIntervalMs ?? 3000;
43
+ }
44
+
45
+ public get connected(): boolean {
46
+ return this.isConnected && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
47
+ }
48
+
49
+ public get url(): string {
50
+ return this.wsUrl;
51
+ }
52
+
53
+ private failedAttempts: number = 0;
54
+ private autoSpawnAttempted: boolean = false;
55
+ private _isAgentStarting: boolean = false;
56
+
57
+ public get isStarting(): boolean {
58
+ return this._isAgentStarting;
59
+ }
60
+
61
+ public connect(): Promise<void> {
62
+ this.isExplicitlyClosed = false;
63
+ return new Promise((resolve, reject) => {
64
+ let settled = false;
65
+
66
+ try {
67
+ const headers: Record<string, string> = {};
68
+ if (this.wsToken) {
69
+ headers['Authorization'] = `Bearer ${this.wsToken}`;
70
+ }
71
+
72
+ const ws = new WebSocket(this.wsUrl, { headers });
73
+ this.ws = ws;
74
+
75
+ ws.on('open', () => {
76
+ this.isConnected = true;
77
+ this.reconnecting = false;
78
+ settled = true;
79
+ this.emit('connected', this.wsUrl);
80
+ resolve();
81
+ });
82
+
83
+ ws.on('message', (data: WebSocket.Data) => {
84
+ try {
85
+ const raw = data.toString();
86
+ const message = JSON.parse(raw) as AgentMessage;
87
+ this._handleMessage(message);
88
+ } catch (err) {
89
+ this.emit('parse_error', err);
90
+ }
91
+ });
92
+
93
+ ws.on('error', (err: Error) => {
94
+ this.emit('client_error', err);
95
+ if (!settled) {
96
+ settled = true;
97
+ reject(err);
98
+ }
99
+ });
100
+
101
+ ws.on('close', (code: number, reason: Buffer) => {
102
+ this.isConnected = false;
103
+ if (this.ws === ws) {
104
+ this.ws = null;
105
+ }
106
+ if (!this._isAgentStarting) {
107
+ this.emit('disconnected', { code, reason: reason.toString() });
108
+ }
109
+
110
+ if (!this.isExplicitlyClosed && this.autoReconnect && !this._isAgentStarting) {
111
+ this._scheduleReconnect();
112
+ }
113
+
114
+ if (!settled) {
115
+ settled = true;
116
+ reject(new Error(`WebSocket closed prematurely (${code}): ${reason.toString()}`));
117
+ }
118
+ });
119
+ } catch (err) {
120
+ if (!settled) {
121
+ settled = true;
122
+ reject(err);
123
+ }
124
+ }
125
+ });
126
+ }
127
+
128
+ private _scheduleReconnect(): void {
129
+ if (this.reconnecting || this.isExplicitlyClosed || this._isAgentStarting) return;
130
+ this.reconnecting = true;
131
+ this.failedAttempts++;
132
+
133
+ // If 3 consecutive failures occur, try to auto-launch the local Python agent
134
+ if (this.failedAttempts >= 3 && !this.autoSpawnAttempted) {
135
+ this.autoSpawnAttempted = true;
136
+ this._isAgentStarting = true;
137
+ this.emit('auto_spawning_agent');
138
+
139
+ (async () => {
140
+ try {
141
+ const { spawnAgentDaemon, waitForAgentReady, getOrGenerateWsToken } = require('./agent-launcher');
142
+ const token = getOrGenerateWsToken();
143
+ if (!this.wsToken) {
144
+ this.wsToken = token;
145
+ }
146
+
147
+ const spawnResult = await spawnAgentDaemon();
148
+ if (!spawnResult.success) {
149
+ this._isAgentStarting = false;
150
+ this.reconnecting = false;
151
+ this.emit('agent_start_failed', { error: spawnResult.error });
152
+ return;
153
+ }
154
+
155
+ // Lock retries: poll quietly until background service finishes starting up AND auth succeeds
156
+ const ready = await waitForAgentReady(
157
+ 8765,
158
+ this.wsToken,
159
+ 45000,
160
+ 1000,
161
+ (elapsedSec: number) => {
162
+ this.emit('agent_starting_progress', { elapsedSec });
163
+ }
164
+ );
165
+
166
+ this._isAgentStarting = false;
167
+
168
+ if (ready) {
169
+ this.failedAttempts = 0;
170
+ this.autoSpawnAttempted = false;
171
+ this.reconnecting = false;
172
+ this.emit('agent_ready');
173
+ try {
174
+ await this.connect();
175
+ } catch {
176
+ this._scheduleReconnect();
177
+ }
178
+ } else {
179
+ this.reconnecting = false;
180
+ this.emit('agent_start_failed', {
181
+ error: 'El servicio del agente no respondió en el puerto 8765 dentro de 45s.',
182
+ });
183
+ if (!this.isExplicitlyClosed && this.autoReconnect) {
184
+ this._scheduleReconnect();
185
+ }
186
+ }
187
+ } catch (e) {
188
+ this._isAgentStarting = false;
189
+ this.reconnecting = false;
190
+ this.emit('agent_start_failed', { error: (e as Error).message });
191
+ if (!this.isExplicitlyClosed && this.autoReconnect) {
192
+ this._scheduleReconnect();
193
+ }
194
+ }
195
+ })();
196
+
197
+ return;
198
+ }
199
+
200
+ this.emit('reconnecting', {
201
+ intervalMs: this.reconnectIntervalMs,
202
+ attempt: this.failedAttempts,
203
+ });
204
+
205
+ setTimeout(async () => {
206
+ if (this.isExplicitlyClosed || this._isAgentStarting) {
207
+ this.reconnecting = false;
208
+ return;
209
+ }
210
+ try {
211
+ await this.connect();
212
+ this.failedAttempts = 0;
213
+ this.autoSpawnAttempted = false;
214
+ this.reconnecting = false;
215
+ } catch {
216
+ this.reconnecting = false;
217
+ this._scheduleReconnect();
218
+ }
219
+ }, this.reconnectIntervalMs);
220
+ }
221
+
222
+ private _handleMessage(message: AgentMessage): void {
223
+ // 1. Check if it's a response to a pending request
224
+ if (message.request_id && this.pendingRequests.has(message.request_id)) {
225
+ const resolver = this.pendingRequests.get(message.request_id)!;
226
+ const timeout = this.requestTimeouts.get(message.request_id);
227
+ if (timeout) clearTimeout(timeout);
228
+ this.requestTimeouts.delete(message.request_id);
229
+ this.pendingRequests.delete(message.request_id);
230
+ resolver(message.payload, message.error);
231
+ return;
232
+ }
233
+
234
+ // 2. Emit corresponding typed events (dispatch once per type)
235
+ const type = message.type || 'unknown';
236
+ this.emit('message', message);
237
+ this.emit(type, message.payload);
238
+ }
239
+
240
+ public request<T = unknown>(type: string, payload: unknown = {}, timeoutMs: number = 15000): Promise<T> {
241
+ return new Promise((resolve, reject) => {
242
+ if (!this.connected) {
243
+ reject(new Error('Agent WebSocket is not connected'));
244
+ return;
245
+ }
246
+
247
+ const requestId = `cli_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
248
+ const message: AgentMessage = {
249
+ type,
250
+ payload,
251
+ request_id: requestId,
252
+ timestamp: new Date().toISOString(),
253
+ };
254
+
255
+ const timeout = setTimeout(() => {
256
+ this.pendingRequests.delete(requestId);
257
+ this.requestTimeouts.delete(requestId);
258
+ reject(new Error(`Agent request timeout after ${timeoutMs}ms for ${type}`));
259
+ }, timeoutMs);
260
+
261
+ this.requestTimeouts.set(requestId, timeout);
262
+ this.pendingRequests.set(requestId, (data: unknown, error?: string) => {
263
+ if (error) {
264
+ reject(new Error(error));
265
+ } else {
266
+ resolve(data as T);
267
+ }
268
+ });
269
+
270
+ this.ws?.send(JSON.stringify(message));
271
+ });
272
+ }
273
+
274
+ public send(type: string, payload: unknown = {}): boolean {
275
+ if (!this.connected) return false;
276
+ const message: AgentMessage = {
277
+ type,
278
+ payload,
279
+ timestamp: new Date().toISOString(),
280
+ };
281
+ try {
282
+ this.ws?.send(JSON.stringify(message));
283
+ return true;
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ // --- High level client operations ---
290
+
291
+ public sendUserText(
292
+ text: string,
293
+ conversationId: string,
294
+ history: Array<{ role: 'user' | 'assistant'; content: string }> = [],
295
+ attachments?: Array<{ name: string; mime_type: string; size?: number; data_base64: string }>,
296
+ ): boolean {
297
+ const payload: UserTextMessagePayload = {
298
+ text,
299
+ conversation_id: conversationId,
300
+ conversation_history: history,
301
+ attachments,
302
+ };
303
+ return this.send('USER_TEXT', payload);
304
+ }
305
+
306
+ public async getTasks(): Promise<TaskSummary[]> {
307
+ return this.request<TaskSummary[]>('GET_TASKS', {});
308
+ }
309
+
310
+ public async getProviders(): Promise<ProviderSummary[]> {
311
+ return this.request<ProviderSummary[]>('GET_PROVIDERS', {});
312
+ }
313
+
314
+ public async getModels(requirements: Record<string, unknown> = {}): Promise<ModelSummary[]> {
315
+ return this.request<ModelSummary[]>('GET_MODELS', { requirements });
316
+ }
317
+
318
+ public async getSettings(): Promise<HelixSettings> {
319
+ return this.request<HelixSettings>('GET_SETTINGS', {});
320
+ }
321
+
322
+ public async saveSettings(settings: Partial<HelixSettings>): Promise<HelixSettings> {
323
+ return this.request<HelixSettings>('SAVE_SETTINGS', { settings });
324
+ }
325
+
326
+ public async validateKey(provider: string, slot: number, key: string): Promise<string> {
327
+ return this.request<string>('VALIDATE_KEY', { provider, slot, key });
328
+ }
329
+
330
+ public async getSkills(): Promise<SkillSummary[]> {
331
+ return this.request<SkillSummary[]>('GET_SKILLS', {});
332
+ }
333
+
334
+ public async saveSkill(skill: Record<string, unknown>): Promise<SkillSummary> {
335
+ return this.request<SkillSummary>('SAVE_SKILL', { skill });
336
+ }
337
+
338
+ public async deleteSkill(name: string): Promise<{ deleted: boolean }> {
339
+ return this.request<{ deleted: boolean }>('DELETE_SKILL', { name });
340
+ }
341
+
342
+ public async getMcpServers(): Promise<McpServerConfig[]> {
343
+ return this.request<McpServerConfig[]>('GET_MCP_SERVERS', {});
344
+ }
345
+
346
+ public async getMemoryStatus(): Promise<Record<string, unknown>> {
347
+ return this.request<Record<string, unknown>>('GET_MEMORY_STATUS', {});
348
+ }
349
+
350
+ public async clearMemory(): Promise<void> {
351
+ return this.request<void>('CLEAR_MEMORY', {});
352
+ }
353
+
354
+ public cancelTask(taskId: string): void {
355
+ this.send('TASK_CANCEL', { task_id: taskId });
356
+ }
357
+
358
+ public emergencyStop(): void {
359
+ this.send('TASK_CANCEL_ALL', {});
360
+ }
361
+
362
+ public confirmAction(requestId: string): void {
363
+ this.send('CONFIRMATION_GRANTED', { request_id: requestId });
364
+ }
365
+
366
+ public rejectAction(requestId: string): void {
367
+ this.send('CONFIRMATION_REJECTED', { request_id: requestId });
368
+ }
369
+
370
+ public close(): void {
371
+ this.isExplicitlyClosed = true;
372
+ for (const timeout of this.requestTimeouts.values()) {
373
+ clearTimeout(timeout);
374
+ }
375
+ this.requestTimeouts.clear();
376
+ for (const resolver of this.pendingRequests.values()) {
377
+ resolver(undefined, 'Client disconnected');
378
+ }
379
+ this.pendingRequests.clear();
380
+ if (this.ws) {
381
+ this.ws.removeAllListeners();
382
+ this.ws.close();
383
+ this.ws = null;
384
+ }
385
+ this.isConnected = false;
386
+ }
387
+ }