@aitherium/shell-cli 1.1.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.
package/dist/client.js ADDED
@@ -0,0 +1,528 @@
1
+ /**
2
+ * AitherShell HTTP + SSE client.
3
+ * Pure Node.js — no browser APIs, no React.
4
+ *
5
+ * Works against any AitherOS-compatible backend:
6
+ * - Genesis (full AitherOS stack)
7
+ * - ADK server (standalone agent via `adk serve`)
8
+ * - Any server implementing /chat/stream SSE protocol
9
+ *
10
+ * Chat routing is handled by the backend's /chat/stream endpoint.
11
+ * This client is a thin SSE consumer — no trivial intercept,
12
+ * no fallback chains, no Veil dependency.
13
+ */
14
+ export class GenesisClient {
15
+ baseUrl;
16
+ _authToken = null;
17
+ _tenantId = null;
18
+ _userId = null;
19
+ _backend = null;
20
+ constructor(baseUrl) {
21
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
22
+ }
23
+ /** Probe the backend and detect its type (Genesis vs ADK vs unknown). */
24
+ async detectBackend() {
25
+ if (this._backend)
26
+ return this._backend;
27
+ const info = { type: 'unknown', name: 'offline' };
28
+ try {
29
+ const r = await fetch(`${this.baseUrl}/health`, {
30
+ headers: { Accept: 'application/json' },
31
+ signal: AbortSignal.timeout(5000),
32
+ });
33
+ if (!r.ok) {
34
+ this._backend = info;
35
+ return info;
36
+ }
37
+ const data = await r.json();
38
+ // Genesis health returns generation_ready, vllm_slots_available, tracked_services
39
+ // ADK health returns agent, llm_backend, version
40
+ if (data.generation_ready !== undefined || data.tracked_services !== undefined) {
41
+ info.type = 'genesis';
42
+ info.name = 'Genesis';
43
+ info.generationReady = data.generation_ready;
44
+ info.slotsAvailable = data.vllm_slots_available;
45
+ // Fetch richer status from Genesis /status
46
+ try {
47
+ const statusData = await this.getStatus();
48
+ if (statusData) {
49
+ info.services = statusData.tracked_services ?? statusData.count;
50
+ }
51
+ }
52
+ catch { }
53
+ // Fetch agent count
54
+ try {
55
+ const agentData = await this.getAgents();
56
+ if (agentData?.agents)
57
+ info.agents = agentData.agents.length;
58
+ }
59
+ catch { }
60
+ // LLM status
61
+ try {
62
+ const llmData = await this.getLLMStatus();
63
+ if (llmData)
64
+ info.llmBackend = llmData.model || llmData.default_model;
65
+ }
66
+ catch { }
67
+ }
68
+ else if (data.agent !== undefined || data.llm_backend !== undefined) {
69
+ info.type = 'adk';
70
+ info.name = data.agent || 'agent';
71
+ info.agent = data.agent;
72
+ info.version = data.version;
73
+ info.llmBackend = data.llm_backend;
74
+ // ADK /agents for count
75
+ try {
76
+ const agentData = await this.getAgents();
77
+ if (agentData?.agents)
78
+ info.agents = agentData.agents.length;
79
+ }
80
+ catch { }
81
+ }
82
+ else {
83
+ // Unknown backend that responds to /health
84
+ info.type = 'unknown';
85
+ info.name = data.name || data.service || 'server';
86
+ }
87
+ }
88
+ catch {
89
+ // Backend unreachable
90
+ }
91
+ this._backend = info;
92
+ return info;
93
+ }
94
+ /** Get cached backend info (call detectBackend first). */
95
+ get backend() { return this._backend; }
96
+ /** Set auth token for all subsequent requests. */
97
+ setAuthToken(token, tenantId, userId) {
98
+ this._authToken = token;
99
+ if (tenantId !== undefined)
100
+ this._tenantId = tenantId ?? null;
101
+ if (userId !== undefined)
102
+ this._userId = userId ?? null;
103
+ }
104
+ /** Build auth headers for requests. */
105
+ authHeaders() {
106
+ const headers = {};
107
+ if (this._authToken) {
108
+ headers['Authorization'] = `Bearer ${this._authToken}`;
109
+ // PAT/ACTA keys also go in X-API-Key for billing middleware
110
+ if (this._authToken.startsWith('aither_sk_live_') || this._authToken.startsWith('aither_pat_')) {
111
+ headers['X-API-Key'] = this._authToken;
112
+ }
113
+ }
114
+ if (this._tenantId) {
115
+ headers['X-Tenant-ID'] = this._tenantId;
116
+ }
117
+ if (this._userId) {
118
+ headers['X-User-ID'] = this._userId;
119
+ }
120
+ return headers;
121
+ }
122
+ /* ── Streaming chat — POST /chat/stream (SSE) ───────────── */
123
+ async *streamChat(message, opts = {}) {
124
+ const body = {
125
+ message,
126
+ persona: opts.agent || 'aither',
127
+ ...(opts.mentions && opts.mentions.length > 1 ? { mentions: opts.mentions } : {}),
128
+ session_id: opts.sessionId,
129
+ ...(opts.model ? { model: opts.model } : {}),
130
+ is_local: true,
131
+ llm_priority: opts.priority || 'user',
132
+ ...(opts.clarificationResponse ? { clarification_response: opts.clarificationResponse } : {}),
133
+ ...(opts.sessionContext ? { session_context: opts.sessionContext } : {}),
134
+ ...(opts.effort ? { effort_level: opts.effort } : {}),
135
+ ...(opts.safetyLevel ? { safety_level: opts.safetyLevel } : {}),
136
+ ...(opts.privateMode ? { private_mode: true } : {}),
137
+ ...(opts.attachments?.length ? { attachments: opts.attachments } : {}),
138
+ ...(opts.maxEffort != null ? { max_effort: opts.maxEffort } : {}),
139
+ };
140
+ let response;
141
+ const MAX_RETRIES = 3;
142
+ const RETRY_DELAY_MS = 2000;
143
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
144
+ try {
145
+ response = await fetch(`${this.baseUrl}/chat/stream`, {
146
+ method: 'POST',
147
+ headers: {
148
+ 'Content-Type': 'application/json',
149
+ 'X-Caller-Type': 'PLATFORM',
150
+ ...this.authHeaders(),
151
+ },
152
+ body: JSON.stringify(body),
153
+ signal: opts.signal,
154
+ });
155
+ }
156
+ catch (err) {
157
+ throw new Error(`Cannot connect to Genesis: ${err.message}`);
158
+ }
159
+ // Retry on 503 — but distinguish "still starting" from "pool exhausted"
160
+ if (response.status === 503 && attempt < MAX_RETRIES) {
161
+ const text503 = await response.text().catch(() => '');
162
+ let parsed503 = {};
163
+ try {
164
+ parsed503 = JSON.parse(text503);
165
+ }
166
+ catch { }
167
+ if (parsed503.preflight_rejected || parsed503.detail === 'preflight_capacity_zero') {
168
+ // Pool exhausted — don't retry, tell user immediately
169
+ const retryIn = parsed503.retry_after_s || 30;
170
+ throw new Error(`\x1b[33mSystem busy\x1b[0m — LLM pool exhausted (0 available slots). ` +
171
+ `Try again in ~${retryIn}s.\n` +
172
+ ` The pool auto-recovers. If this persists, run: /pool reset`);
173
+ }
174
+ await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
175
+ continue;
176
+ }
177
+ break;
178
+ }
179
+ if (!response.ok) {
180
+ const text = await response.text().catch(() => '');
181
+ let detail = `HTTP ${response.status}`;
182
+ let parsed = {};
183
+ try {
184
+ parsed = JSON.parse(text);
185
+ detail = parsed.detail || parsed.error || detail;
186
+ }
187
+ catch { /* use status */ }
188
+ if (response.status === 503) {
189
+ if (parsed.preflight_rejected || parsed.generation_ready === false) {
190
+ throw new Error(`System busy — LLM pool exhausted. Try again in ~${parsed.retry_after_s || 30}s.`);
191
+ }
192
+ throw new Error(`Genesis is still starting up — try again in a few seconds (${detail})`);
193
+ }
194
+ throw new Error(detail);
195
+ }
196
+ yield* this.readSSE(response);
197
+ }
198
+ /* ── Session steering — inject input into active session ──── */
199
+ async steer(sessionId, message, action = 'append') {
200
+ try {
201
+ const r = await fetch(`${this.baseUrl}/chat/steer`, {
202
+ method: 'POST',
203
+ headers: { 'Content-Type': 'application/json', 'X-Caller-Type': 'PLATFORM', ...this.authHeaders() },
204
+ body: JSON.stringify({ session_id: sessionId, message, action }),
205
+ signal: AbortSignal.timeout(5000),
206
+ });
207
+ return r.ok ? await r.json() : { ok: false, error: `HTTP ${r.status}` };
208
+ }
209
+ catch (err) {
210
+ return { ok: false, error: err?.message || 'steer failed' };
211
+ }
212
+ }
213
+ /* ── Non-streaming chat ───────────────────────────────────── */
214
+ async chat(message, opts = {}) {
215
+ const res = await fetch(`${this.baseUrl}/chat`, {
216
+ method: 'POST',
217
+ headers: {
218
+ 'Content-Type': 'application/json',
219
+ 'X-Caller-Type': 'PLATFORM',
220
+ ...this.authHeaders(),
221
+ },
222
+ body: JSON.stringify({
223
+ message,
224
+ session_id: opts.sessionId,
225
+ persona: opts.agent,
226
+ include_context: true,
227
+ is_local: true,
228
+ llm_priority: 'user',
229
+ }),
230
+ });
231
+ return res.json();
232
+ }
233
+ /* ── REST endpoints ───────────────────────────────────────── */
234
+ async getStatus() {
235
+ return this.get('/status');
236
+ }
237
+ async getServices() {
238
+ return this.get('/services');
239
+ }
240
+ async getAgents() {
241
+ return this.get('/agents');
242
+ }
243
+ async forgeDispatch(task, opts = {}) {
244
+ const res = await fetch(`${this.baseUrl}/forge/dispatch/sync`, {
245
+ method: 'POST',
246
+ headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
247
+ body: JSON.stringify({
248
+ task,
249
+ agent: opts.agent,
250
+ effort_level: opts.effort ?? 5,
251
+ mode: 'execute',
252
+ parent_agent: 'system',
253
+ }),
254
+ });
255
+ return res.json();
256
+ }
257
+ async getLogs(limit = 20, level, service) {
258
+ const params = new URLSearchParams({ limit: String(limit) });
259
+ if (level)
260
+ params.set('level', level);
261
+ if (service)
262
+ params.set('service', service);
263
+ return this.get(`/chronicle/logs?${params}`);
264
+ }
265
+ async getLLMStatus() {
266
+ try {
267
+ const candidates = [process.env.AITHER_LLM_URL, 'https://localhost:8150', 'http://localhost:8150'].filter(Boolean);
268
+ for (const base of candidates) {
269
+ try {
270
+ const r = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
271
+ if (r.ok)
272
+ return r.json();
273
+ }
274
+ catch { }
275
+ }
276
+ return null;
277
+ }
278
+ catch {
279
+ return null;
280
+ }
281
+ }
282
+ async getGpuStatus() {
283
+ try {
284
+ const candidates = [process.env.AITHER_LLM_URL, 'https://localhost:8150', 'http://localhost:8150'].filter(Boolean);
285
+ for (const base of candidates) {
286
+ try {
287
+ const r = await fetch(`${base}/reasoning/exclusive/status`, { signal: AbortSignal.timeout(2000) });
288
+ if (r.ok)
289
+ return r.json();
290
+ }
291
+ catch { }
292
+ }
293
+ return null;
294
+ }
295
+ catch {
296
+ return null;
297
+ }
298
+ }
299
+ async getLLMModels() {
300
+ try {
301
+ const candidates = [process.env.AITHER_LLM_URL, 'https://localhost:8150', 'http://localhost:8150'].filter(Boolean);
302
+ for (const base of candidates) {
303
+ try {
304
+ const r = await fetch(`${base}/models`, { signal: AbortSignal.timeout(3000) });
305
+ if (r.ok)
306
+ return r.json();
307
+ }
308
+ catch { }
309
+ }
310
+ return { models: [] };
311
+ }
312
+ catch {
313
+ return { models: [] };
314
+ }
315
+ }
316
+ /* ── Helpers ──────────────────────────────────────────────── */
317
+ async parseErrorResponse(r) {
318
+ const text = await r.text().catch(() => '');
319
+ try {
320
+ const data = JSON.parse(text);
321
+ return { error: data.detail || data.error || `HTTP ${r.status}`, status: r.status };
322
+ }
323
+ catch {
324
+ return { error: text || `HTTP ${r.status}`, status: r.status };
325
+ }
326
+ }
327
+ async getDetailed(path) {
328
+ try {
329
+ const r = await fetch(`${this.baseUrl}${path}`, {
330
+ headers: { ...this.authHeaders() },
331
+ signal: AbortSignal.timeout(5000),
332
+ });
333
+ if (!r.ok)
334
+ return this.parseErrorResponse(r);
335
+ return r.json();
336
+ }
337
+ catch (err) {
338
+ return { error: err?.message || 'Request failed', status: 0 };
339
+ }
340
+ }
341
+ async postDetailed(path, body = {}) {
342
+ try {
343
+ const r = await fetch(`${this.baseUrl}${path}`, {
344
+ method: 'POST',
345
+ headers: { 'Content-Type': 'application/json', 'X-Caller-Type': 'PLATFORM', ...this.authHeaders() },
346
+ body: JSON.stringify(body),
347
+ signal: AbortSignal.timeout(15000),
348
+ });
349
+ if (!r.ok)
350
+ return this.parseErrorResponse(r);
351
+ return r.json();
352
+ }
353
+ catch (err) {
354
+ return { error: err?.message || 'Request failed', status: 0 };
355
+ }
356
+ }
357
+ async get(path) {
358
+ const result = await this.getDetailed(path);
359
+ return result?.error ? null : result;
360
+ }
361
+ async post(path, body = {}) {
362
+ const result = await this.postDetailed(path, body);
363
+ return result?.error ? null : result;
364
+ }
365
+ async put(path, body = {}) {
366
+ try {
367
+ const r = await fetch(`${this.baseUrl}${path}`, {
368
+ method: 'PUT',
369
+ headers: { 'Content-Type': 'application/json', 'X-Caller-Type': 'PLATFORM', ...this.authHeaders() },
370
+ body: JSON.stringify(body),
371
+ signal: AbortSignal.timeout(15000),
372
+ });
373
+ if (!r.ok) {
374
+ const text = await r.text().catch(() => '');
375
+ try {
376
+ return { error: JSON.parse(text).detail || `HTTP ${r.status}` };
377
+ }
378
+ catch {
379
+ return { error: `HTTP ${r.status}` };
380
+ }
381
+ }
382
+ return r.json();
383
+ }
384
+ catch {
385
+ return null;
386
+ }
387
+ }
388
+ async patch(path, body = {}) {
389
+ try {
390
+ const r = await fetch(`${this.baseUrl}${path}`, {
391
+ method: 'PATCH',
392
+ headers: { 'Content-Type': 'application/json', 'X-Caller-Type': 'PLATFORM', ...this.authHeaders() },
393
+ body: JSON.stringify(body),
394
+ signal: AbortSignal.timeout(15000),
395
+ });
396
+ if (!r.ok) {
397
+ const text = await r.text().catch(() => '');
398
+ try {
399
+ return { error: JSON.parse(text).detail || `HTTP ${r.status}` };
400
+ }
401
+ catch {
402
+ return { error: `HTTP ${r.status}` };
403
+ }
404
+ }
405
+ return r.json();
406
+ }
407
+ catch {
408
+ return null;
409
+ }
410
+ }
411
+ async delete(path) {
412
+ try {
413
+ const r = await fetch(`${this.baseUrl}${path}`, {
414
+ method: 'DELETE',
415
+ headers: { 'X-Caller-Type': 'PLATFORM', ...this.authHeaders() },
416
+ signal: AbortSignal.timeout(15000),
417
+ });
418
+ if (!r.ok) {
419
+ const text = await r.text().catch(() => '');
420
+ try {
421
+ return { error: JSON.parse(text).detail || `HTTP ${r.status}` };
422
+ }
423
+ catch {
424
+ return { error: `HTTP ${r.status}` };
425
+ }
426
+ }
427
+ return r.json();
428
+ }
429
+ catch {
430
+ return null;
431
+ }
432
+ }
433
+ /**
434
+ * Read SSE stream from a fetch Response.
435
+ * Mirrors shell-core's parseSSEChunk / createSSEReader.
436
+ */
437
+ async *readSSE(response) {
438
+ const body = response.body;
439
+ if (!body)
440
+ throw new Error('No response body');
441
+ const reader = body.getReader();
442
+ const decoder = new TextDecoder();
443
+ let buffer = '';
444
+ const CHUNK_TIMEOUT_MS = 120_000; // 120s between chunks
445
+ const WARN_TIMEOUT_MS = 60_000; // warn after 60s of silence
446
+ let lastChunkAt = Date.now();
447
+ let warningSent = false;
448
+ try {
449
+ while (true) {
450
+ // Race reader.read() against a timeout so the client never hangs
451
+ // if the backend stalls (e.g. reasoning model cold-start, 404 retry loop).
452
+ const readPromise = reader.read();
453
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('SSE chunk timeout')), CHUNK_TIMEOUT_MS));
454
+ // Intermediate warning if no data for 60s
455
+ let warnTimer = null;
456
+ if (!warningSent) {
457
+ warnTimer = setTimeout(() => {
458
+ warningSent = true;
459
+ }, WARN_TIMEOUT_MS);
460
+ }
461
+ let result;
462
+ try {
463
+ result = await Promise.race([readPromise, timeoutPromise]);
464
+ if (warnTimer)
465
+ clearTimeout(warnTimer);
466
+ }
467
+ catch {
468
+ if (warnTimer)
469
+ clearTimeout(warnTimer);
470
+ // Timeout — emit a typed timeout event (not a hard error) so the
471
+ // renderer and REPL can check if the agent is still processing.
472
+ const silentMs = Date.now() - lastChunkAt;
473
+ yield { type: 'stream_timeout', data: { type: 'stream_timeout', timeout_ms: CHUNK_TIMEOUT_MS, silent_ms: silentMs } };
474
+ break;
475
+ }
476
+ lastChunkAt = Date.now();
477
+ const { done, value } = result;
478
+ if (done)
479
+ break;
480
+ buffer += decoder.decode(value, { stream: true });
481
+ // Normalize \r\n → \n (SSE servers may use either)
482
+ buffer = buffer.replace(/\r\n/g, '\n');
483
+ const parts = buffer.split('\n\n');
484
+ buffer = parts.pop() || '';
485
+ for (const part of parts) {
486
+ if (!part.trim())
487
+ continue;
488
+ const event = this.parseBlock(part);
489
+ if (event)
490
+ yield event;
491
+ }
492
+ }
493
+ if (buffer.trim()) {
494
+ const event = this.parseBlock(buffer);
495
+ if (event)
496
+ yield event;
497
+ }
498
+ }
499
+ finally {
500
+ reader.releaseLock();
501
+ }
502
+ }
503
+ parseBlock(block) {
504
+ let eventType;
505
+ let eventData = null;
506
+ for (const line of block.split('\n')) {
507
+ if (line.startsWith('event: ')) {
508
+ eventType = line.slice(7).trim();
509
+ }
510
+ else if (line.startsWith('data: ')) {
511
+ try {
512
+ eventData = JSON.parse(line.slice(6));
513
+ }
514
+ catch {
515
+ eventData = { raw: line.slice(6).trim() };
516
+ }
517
+ }
518
+ }
519
+ if (!eventData)
520
+ return null;
521
+ if (eventType && !eventData.type)
522
+ eventData.type = eventType;
523
+ return {
524
+ type: eventData.type || eventType || 'unknown',
525
+ data: eventData,
526
+ };
527
+ }
528
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * CommandRegistry — Score-based command matching and dynamic loading.
3
+ *
4
+ * Loads commands from:
5
+ * 1. commands.json (static definitions — immediate, offline fallback)
6
+ * 2. Genesis /shell/commands (aggregated catalog — all sources)
7
+ * 3. Genesis /shell/commands/mcp (MCP tools as callable commands)
8
+ * 4. Built-in handlers from commands.ts
9
+ *
10
+ * At REPL startup, loadDynamicCommands() fetches from Genesis and merges
11
+ * new commands into the registry. Static commands.json is never manually
12
+ * edited again — it serves as an offline fallback only.
13
+ */
14
+ import type { GenesisClient } from './client.js';
15
+ import type { ShellConfig } from './config.js';
16
+ export interface CommandEntry {
17
+ name: string;
18
+ category: string;
19
+ description: string;
20
+ aliases: string[];
21
+ subcommands: string[];
22
+ source: string;
23
+ handler?: (client: GenesisClient, args: string, config: ShellConfig) => Promise<void>;
24
+ genesisEndpoint?: string;
25
+ }
26
+ interface DynamicCommandEntry {
27
+ name: string;
28
+ category: string;
29
+ description: string;
30
+ aliases: string[];
31
+ subcommands?: string[];
32
+ source: string;
33
+ genesis_endpoint?: string;
34
+ params?: any[];
35
+ }
36
+ export declare class CommandRegistry {
37
+ private commands;
38
+ private aliasMap;
39
+ private dynamicLoaded;
40
+ /** MCP tools discovered from Genesis — callable via /tools/call. */
41
+ private mcpTools;
42
+ constructor();
43
+ private loadFromJSON;
44
+ /**
45
+ * Fetch the unified command catalog from Genesis /shell/commands.
46
+ * Merges discovered commands into the registry without overwriting
47
+ * existing handlers. Call once at REPL startup.
48
+ */
49
+ loadDynamicCommands(client: GenesisClient): Promise<number>;
50
+ /** Get all discovered MCP tools. */
51
+ getMcpTools(): DynamicCommandEntry[];
52
+ /** Check if an MCP tool exists by name. */
53
+ getMcpTool(name: string): DynamicCommandEntry | undefined;
54
+ /** Register a built-in handler for a command. */
55
+ registerHandler(name: string, handler: CommandEntry['handler']): void;
56
+ /** Resolve a command name (handles aliases). */
57
+ resolve(input: string): CommandEntry | undefined;
58
+ /** Get all command names (including aliases). */
59
+ allNames(): string[];
60
+ /** Get all commands. */
61
+ allCommands(): CommandEntry[];
62
+ /** Whether dynamic commands have been loaded from Genesis. */
63
+ isDynamicLoaded(): boolean;
64
+ /** Score-based fuzzy matching for tab completion. */
65
+ match(partial: string, limit?: number): string[];
66
+ /** Get commands by category. */
67
+ byCategory(): Map<string, CommandEntry[]>;
68
+ get size(): number;
69
+ }
70
+ export declare function getCommandRegistry(): CommandRegistry;
71
+ export {};