@darksol/terminal 0.4.6 → 0.4.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@darksol/terminal",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "DARKSOL Terminal — unified CLI for all DARKSOL services. Market intel, trading, oracle, casino, and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,37 @@
1
1
  import fetch from 'node-fetch';
2
2
  import { getConfig, setConfig } from '../config/store.js';
3
- import { hasKey, getKeyAuto } from '../config/keys.js';
3
+ import { hasKey, hasAnyLLM, getKeyAuto, addKeyDirect, SERVICES } from '../config/keys.js';
4
4
  import { ethers } from 'ethers';
5
+ import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+
9
+ // ══════════════════════════════════════════════════
10
+ // CHAT LOG PERSISTENCE
11
+ // ══════════════════════════════════════════════════
12
+ const CHAT_LOG_DIR = join(homedir(), '.darksol', 'chat-logs');
13
+
14
+ function ensureChatLogDir() {
15
+ if (!existsSync(CHAT_LOG_DIR)) mkdirSync(CHAT_LOG_DIR, { recursive: true });
16
+ }
17
+
18
+ function logChat(role, content) {
19
+ ensureChatLogDir();
20
+ const date = new Date().toISOString().slice(0, 10);
21
+ const time = new Date().toISOString().slice(11, 19);
22
+ const file = join(CHAT_LOG_DIR, `${date}.jsonl`);
23
+ const entry = JSON.stringify({ ts: new Date().toISOString(), time, role, content });
24
+ appendFileSync(file, entry + '\n');
25
+ }
26
+
27
+ function getChatHistory(limit = 20) {
28
+ ensureChatLogDir();
29
+ const date = new Date().toISOString().slice(0, 10);
30
+ const file = join(CHAT_LOG_DIR, `${date}.jsonl`);
31
+ if (!existsSync(file)) return [];
32
+ const lines = readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean);
33
+ return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
34
+ }
5
35
 
6
36
  // ══════════════════════════════════════════════════
7
37
  // WEB SHELL COMMAND HANDLER
@@ -39,6 +69,128 @@ const USDC_ADDRESSES = {
39
69
 
40
70
  const ERC20_ABI = ['function balanceOf(address) view returns (uint256)'];
41
71
 
72
+ /**
73
+ * Handle interactive menu selections from the client
74
+ */
75
+ export async function handleMenuSelect(id, value, item, ws) {
76
+ switch (id) {
77
+ case 'wallet_select':
78
+ return await showWalletDetail(value, ws);
79
+
80
+ case 'wallet_action':
81
+ switch (value) {
82
+ case 'receive': {
83
+ const name = getConfig('activeWallet');
84
+ if (!name) return {};
85
+ const { loadWallet } = await import('../wallet/keystore.js');
86
+ const w = loadWallet(name);
87
+ ws.sendLine('');
88
+ ws.sendLine(`${ANSI.gold} ◆ RECEIVE — ${name}${ANSI.reset}`);
89
+ ws.sendLine(`${ANSI.dim} ${'─'.repeat(50)}${ANSI.reset}`);
90
+ ws.sendLine('');
91
+ ws.sendLine(` ${ANSI.white}Your address:${ANSI.reset}`);
92
+ ws.sendLine('');
93
+ const addr = w.address;
94
+ ws.sendLine(` ${ANSI.dim}┌${'─'.repeat(addr.length + 4)}┐${ANSI.reset}`);
95
+ ws.sendLine(` ${ANSI.dim}│ ${ANSI.gold}${addr}${ANSI.dim} │${ANSI.reset}`);
96
+ ws.sendLine(` ${ANSI.dim}└${'─'.repeat(addr.length + 4)}┘${ANSI.reset}`);
97
+ ws.sendLine('');
98
+ ws.sendLine(` ${ANSI.dim}Works on ALL EVM chains: Base • Ethereum • Arbitrum • Optimism • Polygon${ANSI.reset}`);
99
+ ws.sendLine(` ${ANSI.red}Make sure the sender is on the same chain!${ANSI.reset}`);
100
+ ws.sendLine('');
101
+ return {};
102
+ }
103
+ case 'send':
104
+ ws.sendLine('');
105
+ ws.sendLine(` ${ANSI.gold}◆ SEND${ANSI.reset}`);
106
+ ws.sendLine(` ${ANSI.dim}Sending requires wallet password — use the CLI:${ANSI.reset}`);
107
+ ws.sendLine(` ${ANSI.gold}darksol send --to 0x... --amount 0.1 --token ETH${ANSI.reset}`);
108
+ ws.sendLine(` ${ANSI.gold}darksol send${ANSI.reset} ${ANSI.dim}(interactive mode)${ANSI.reset}`);
109
+ ws.sendLine('');
110
+ return {};
111
+ case 'portfolio':
112
+ return await handleCommand('portfolio', ws);
113
+ case 'history':
114
+ return await handleCommand('history', ws);
115
+ case 'switch': {
116
+ const chains = ['base', 'ethereum', 'arbitrum', 'optimism', 'polygon'];
117
+ const current = getConfig('chain') || 'base';
118
+ ws.sendMenu('chain_select', '◆ Select Chain', chains.map(c => ({
119
+ value: c,
120
+ label: c === current ? `★ ${c}` : c,
121
+ desc: c === current ? 'current' : '',
122
+ })));
123
+ return {};
124
+ }
125
+ case 'back':
126
+ ws.sendLine('');
127
+ return {};
128
+ }
129
+ break;
130
+
131
+ case 'chain_select':
132
+ setConfig('chain', value);
133
+ ws.sendLine('');
134
+ ws.sendLine(` ${ANSI.green}✓ Chain set to ${value}${ANSI.reset}`);
135
+ ws.sendLine('');
136
+ return {};
137
+
138
+ case 'config_action':
139
+ if (value === 'chain') {
140
+ const chains = ['base', 'ethereum', 'arbitrum', 'optimism', 'polygon'];
141
+ const current = getConfig('chain') || 'base';
142
+ ws.sendMenu('chain_select', '◆ Select Chain', chains.map(c => ({
143
+ value: c,
144
+ label: c === current ? `★ ${c}` : c,
145
+ desc: c === current ? 'current' : '',
146
+ })));
147
+ return {};
148
+ }
149
+ if (value === 'keys') {
150
+ return await handleCommand('keys', ws);
151
+ }
152
+ ws.sendLine('');
153
+ return {};
154
+
155
+ case 'main_menu':
156
+ return await handleCommand(value, ws);
157
+ }
158
+
159
+ return {};
160
+ }
161
+
162
+ /**
163
+ * AI status check — shown on connection
164
+ */
165
+ export function getAIStatus() {
166
+ const gold = '\x1b[38;2;255;215;0m';
167
+ const green = '\x1b[38;2;0;255;136m';
168
+ const red = '\x1b[38;2;233;69;96m';
169
+ const dim = '\x1b[38;2;102;102;102m';
170
+ const reset = '\x1b[0m';
171
+
172
+ const providers = ['openai', 'anthropic', 'openrouter', 'ollama'];
173
+ const connected = providers.filter(p => hasKey(p));
174
+
175
+ if (connected.length > 0) {
176
+ const names = connected.map(p => SERVICES[p]?.name || p).join(', ');
177
+ return ` ${green}● AI ready${reset} ${dim}(${names})${reset}\r\n ${dim}Type ${gold}ai <question>${dim} to start chatting. Chat logs saved to ~/.darksol/chat-logs/${reset}\r\n\r\n`;
178
+ }
179
+
180
+ return [
181
+ ` ${red}○ AI not configured${reset} ${dim}— no LLM provider connected${reset}`,
182
+ '',
183
+ ` ${gold}Quick setup — pick one:${reset}`,
184
+ ` ${green}keys add openai sk-...${reset} ${dim}OpenAI (GPT-4o)${reset}`,
185
+ ` ${green}keys add anthropic sk-ant-...${reset} ${dim}Anthropic (Claude)${reset}`,
186
+ ` ${green}keys add openrouter sk-or-...${reset} ${dim}OpenRouter (any model)${reset}`,
187
+ ` ${green}keys add ollama http://...${reset} ${dim}Ollama (free, local)${reset}`,
188
+ '',
189
+ ` ${dim}Or run the full setup wizard: ${gold}darksol setup${reset}`,
190
+ '',
191
+ ].join('\r\n');
192
+ }
193
+
42
194
  /**
43
195
  * Handle a command string, return { output } or stream via ws helpers
44
196
  */
@@ -80,6 +232,12 @@ export async function handleCommand(cmd, ws) {
80
232
  case 'ask':
81
233
  case 'chat':
82
234
  return await cmdAI(args, ws);
235
+ case 'keys':
236
+ case 'llm':
237
+ return await cmdKeys(args, ws);
238
+ case 'logs':
239
+ case 'chatlog':
240
+ return await cmdChatLogs(args, ws);
83
241
  default: {
84
242
  // Fuzzy: if it looks like natural language, route to AI
85
243
  const nlKeywords = /\b(swap|buy|sell|send|transfer|price|what|how|should|analyze|check|balance|gas|dca)\b/i;
@@ -334,47 +492,121 @@ async function cmdMarket(args, ws) {
334
492
  // WALLET
335
493
  // ══════════════════════════════════════════════════
336
494
  async function cmdWallet(args, ws) {
337
- const sub = args[0] || 'list';
495
+ const sub = args[0];
496
+
497
+ // If a specific subcommand, handle directly
498
+ if (sub === 'balance') {
499
+ return await showWalletDetail(args[1] || getConfig('activeWallet'), ws);
500
+ }
338
501
 
339
- if (sub === 'list') {
340
- const { listWallets } = await import('../wallet/keystore.js');
341
- const wallets = listWallets();
342
- const active = getConfig('activeWallet');
502
+ if (sub === 'use' && args[1]) {
503
+ setConfig('activeWallet', args[1]);
504
+ ws.sendLine(` ${ANSI.green}✓ Active wallet set to "${args[1]}"${ANSI.reset}`);
505
+ ws.sendLine('');
506
+ return {};
507
+ }
343
508
 
509
+ // Default: interactive wallet picker
510
+ const { listWallets } = await import('../wallet/keystore.js');
511
+ const wallets = listWallets();
512
+ const active = getConfig('activeWallet');
513
+
514
+ if (wallets.length === 0) {
344
515
  ws.sendLine(`${ANSI.gold} ◆ WALLETS${ANSI.reset}`);
345
516
  ws.sendLine(`${ANSI.dim} ${'─'.repeat(50)}${ANSI.reset}`);
346
-
347
- if (wallets.length === 0) {
348
- ws.sendLine(` ${ANSI.dim}No wallets. Create one in the CLI: darksol wallet create${ANSI.reset}`);
349
- } else {
350
- for (const w of wallets) {
351
- const indicator = w === active ? `${ANSI.gold}► ${ANSI.reset}` : ' ';
352
- ws.sendLine(` ${indicator}${ANSI.white}${w}${ANSI.reset}`);
353
- }
354
- }
355
-
517
+ ws.sendLine(` ${ANSI.dim}No wallets found.${ANSI.reset}`);
518
+ ws.sendLine(` ${ANSI.dim}Create one: ${ANSI.gold}darksol wallet create <name>${ANSI.reset}`);
356
519
  ws.sendLine('');
357
520
  return {};
358
521
  }
359
522
 
360
- if (sub === 'balance') {
361
- const name = args[1] || getConfig('activeWallet');
362
- if (!name) return { output: ` ${ANSI.red}No active wallet${ANSI.reset}\r\n` };
523
+ if (wallets.length === 1) {
524
+ // Only one wallet go straight to it
525
+ return await showWalletDetail(wallets[0].name, ws);
526
+ }
363
527
 
364
- const { loadWallet } = await import('../wallet/keystore.js');
365
- const w = loadWallet(name);
366
- const chain = getConfig('chain') || 'base';
367
- const provider = new ethers.JsonRpcProvider(RPCS[chain]);
368
- const bal = parseFloat(ethers.formatEther(await provider.getBalance(w.address)));
528
+ // Multiple wallets show interactive menu
529
+ const menuItems = wallets.map(w => ({
530
+ value: w.name,
531
+ label: `${w.name === active ? '★ ' : ''}${w.name}`,
532
+ desc: `${w.address.slice(0, 6)}...${w.address.slice(-4)}`,
533
+ }));
534
+
535
+ ws.sendMenu('wallet_select', '◆ Select Wallet', menuItems);
536
+ return {};
537
+ }
538
+
539
+ async function showWalletDetail(name, ws) {
540
+ if (!name) {
541
+ ws.sendLine(` ${ANSI.red}No wallet selected${ANSI.reset}`);
542
+ ws.sendLine('');
543
+ return {};
544
+ }
369
545
 
370
- ws.sendLine(`${ANSI.gold} BALANCE ${name}${ANSI.reset}`);
371
- ws.sendLine(`${ANSI.dim} ${w.address}${ANSI.reset}`);
372
- ws.sendLine(` ${ANSI.white}${bal.toFixed(6)} ETH${ANSI.reset} on ${chain}`);
546
+ const { loadWallet } = await import('../wallet/keystore.js');
547
+ let walletData;
548
+ try {
549
+ walletData = loadWallet(name);
550
+ } catch {
551
+ ws.sendLine(` ${ANSI.red}Wallet "${name}" not found${ANSI.reset}`);
373
552
  ws.sendLine('');
374
553
  return {};
375
554
  }
376
555
 
377
- return { output: ` ${ANSI.dim}Wallet commands: list, balance${ANSI.reset}\r\n` };
556
+ const chain = getConfig('chain') || 'base';
557
+ const provider = new ethers.JsonRpcProvider(RPCS[chain]);
558
+
559
+ ws.sendLine(`${ANSI.gold} ◆ WALLET — ${name}${ANSI.reset}`);
560
+ ws.sendLine(`${ANSI.dim} ${'─'.repeat(50)}${ANSI.reset}`);
561
+ ws.sendLine(` ${ANSI.darkGold}Address${ANSI.reset} ${ANSI.white}${walletData.address}${ANSI.reset}`);
562
+ ws.sendLine(` ${ANSI.darkGold}Chain${ANSI.reset} ${ANSI.white}${chain}${ANSI.reset}`);
563
+ ws.sendLine(` ${ANSI.darkGold}Created${ANSI.reset} ${ANSI.dim}${walletData.createdAt ? new Date(walletData.createdAt).toLocaleDateString() : 'unknown'}${ANSI.reset}`);
564
+ ws.sendLine(` ${ANSI.darkGold}Encryption${ANSI.reset} ${ANSI.dim}AES-256-GCM + scrypt${ANSI.reset}`);
565
+
566
+ // Fetch balance
567
+ ws.sendLine('');
568
+ ws.sendLine(` ${ANSI.dim}Fetching balance...${ANSI.reset}`);
569
+
570
+ try {
571
+ const balance = await provider.getBalance(walletData.address);
572
+ const ethBal = parseFloat(ethers.formatEther(balance));
573
+
574
+ // Also check USDC
575
+ const usdcAddr = USDC_ADDRESSES[chain];
576
+ let usdcBal = 0;
577
+ if (usdcAddr) {
578
+ try {
579
+ const usdc = new ethers.Contract(usdcAddr, ['function balanceOf(address) view returns (uint256)'], provider);
580
+ const raw = await usdc.balanceOf(walletData.address);
581
+ usdcBal = parseFloat(ethers.formatUnits(raw, 6));
582
+ } catch {}
583
+ }
584
+
585
+ // Get ETH price for USD value
586
+ const ethPrice = await getEthPrice();
587
+ const usdValue = (ethBal * ethPrice) + usdcBal;
588
+
589
+ // Overwrite "Fetching..." line
590
+ ws.sendLine(`\x1b[1A\x1b[2K ${ANSI.darkGold}ETH${ANSI.reset} ${ANSI.white}${ethBal.toFixed(6)}${ANSI.reset} ${ANSI.dim}($${(ethBal * ethPrice).toFixed(2)})${ANSI.reset}`);
591
+ ws.sendLine(` ${ANSI.darkGold}USDC${ANSI.reset} ${ANSI.white}$${usdcBal.toFixed(2)}${ANSI.reset}`);
592
+ ws.sendLine(` ${ANSI.darkGold}Total${ANSI.reset} ${ANSI.green}$${usdValue.toFixed(2)}${ANSI.reset}`);
593
+ } catch (err) {
594
+ ws.sendLine(` ${ANSI.red}Balance fetch failed: ${err.message}${ANSI.reset}`);
595
+ }
596
+
597
+ ws.sendLine('');
598
+
599
+ // Show action menu
600
+ ws.sendMenu('wallet_action', '◆ What would you like to do?', [
601
+ { value: 'receive', label: '📥 Receive', desc: 'Show address to receive funds' },
602
+ { value: 'send', label: '📤 Send', desc: 'Send ETH or tokens (CLI required)' },
603
+ { value: 'portfolio', label: '📊 Portfolio', desc: 'Multi-chain balance view' },
604
+ { value: 'history', label: '📜 History', desc: 'Transaction history' },
605
+ { value: 'switch', label: '🔄 Switch chain', desc: `Currently: ${chain}` },
606
+ { value: 'back', label: '← Back', desc: '' },
607
+ ]);
608
+
609
+ return {};
378
610
  }
379
611
 
380
612
  // ══════════════════════════════════════════════════
@@ -513,8 +745,16 @@ async function cmdConfig(ws) {
513
745
  ws.sendLine(` ${ANSI.darkGold}Wallet${ANSI.reset} ${ANSI.white}${wallet}${ANSI.reset}`);
514
746
  ws.sendLine(` ${ANSI.darkGold}Slippage${ANSI.reset} ${ANSI.white}${slippage}%${ANSI.reset}`);
515
747
  ws.sendLine(` ${ANSI.darkGold}Mail${ANSI.reset} ${ANSI.white}${email}${ANSI.reset}`);
516
- ws.sendLine(` ${ANSI.darkGold}AI${ANSI.reset} ${hasKey('openai') || hasKey('anthropic') || hasKey('openrouter') || hasKey('ollama') ? `${ANSI.green}● Ready${ANSI.reset}` : `${ANSI.dim}○ Not configured${ANSI.reset}`}`);
748
+ ws.sendLine(` ${ANSI.darkGold}AI${ANSI.reset} ${hasAnyLLM() ? `${ANSI.green}● Ready${ANSI.reset}` : `${ANSI.dim}○ Not configured${ANSI.reset}`}`);
517
749
  ws.sendLine('');
750
+
751
+ // Offer interactive config
752
+ ws.sendMenu('config_action', '◆ Configure', [
753
+ { value: 'chain', label: '🔗 Change chain', desc: `Currently: ${chain}` },
754
+ { value: 'keys', label: '🔑 LLM / API keys', desc: '' },
755
+ { value: 'back', label: '← Back', desc: '' },
756
+ ]);
757
+
518
758
  return {};
519
759
  }
520
760
 
@@ -649,6 +889,9 @@ COMMAND REFERENCE:
649
889
  enriched += `\n\n[Live market data: ${priceData.join(', ')}]`;
650
890
  }
651
891
 
892
+ // Log user message
893
+ logChat('user', input);
894
+
652
895
  // Send to LLM
653
896
  ws.sendLine(` ${ANSI.dim}Thinking...${ANSI.reset}`);
654
897
 
@@ -656,6 +899,9 @@ COMMAND REFERENCE:
656
899
  const result = await engine.chat(enriched);
657
900
  const usage = engine.getUsage();
658
901
 
902
+ // Log AI response
903
+ logChat('assistant', result.content);
904
+
659
905
  // Display response with formatting
660
906
  ws.sendLine('');
661
907
  ws.sendLine(`${ANSI.gold} ◆ DARKSOL AI${ANSI.reset}`);
@@ -690,6 +936,130 @@ COMMAND REFERENCE:
690
936
  return {};
691
937
  }
692
938
 
939
+ // ══════════════════════════════════════════════════
940
+ // KEYS — LLM provider configuration from web shell
941
+ // ══════════════════════════════════════════════════
942
+ async function cmdKeys(args, ws) {
943
+ const sub = args[0]?.toLowerCase();
944
+
945
+ if (sub === 'add' && args[1]) {
946
+ const service = args[1].toLowerCase();
947
+ const key = args[2];
948
+ const svc = SERVICES[service];
949
+
950
+ if (!svc) {
951
+ ws.sendLine(` ${ANSI.red}✗ Unknown service: ${service}${ANSI.reset}`);
952
+ ws.sendLine(` ${ANSI.dim}Available: openai, anthropic, openrouter, ollama${ANSI.reset}`);
953
+ ws.sendLine('');
954
+ return {};
955
+ }
956
+
957
+ if (!key) {
958
+ ws.sendLine(` ${ANSI.red}✗ No key provided${ANSI.reset}`);
959
+ ws.sendLine(` ${ANSI.dim}Usage: keys add ${service} <your-api-key>${ANSI.reset}`);
960
+ ws.sendLine(` ${ANSI.dim}Get a key: ${svc.docsUrl}${ANSI.reset}`);
961
+ ws.sendLine('');
962
+ return {};
963
+ }
964
+
965
+ if (svc.validate && !svc.validate(key)) {
966
+ ws.sendLine(` ${ANSI.red}✗ Invalid key format for ${svc.name}${ANSI.reset}`);
967
+ ws.sendLine('');
968
+ return {};
969
+ }
970
+
971
+ try {
972
+ addKeyDirect(service, key);
973
+ ws.sendLine(` ${ANSI.green}✓ ${svc.name} key stored securely${ANSI.reset}`);
974
+
975
+ // Clear cached engine so it picks up new key
976
+ chatEngines.delete(ws);
977
+ ws.sendLine(` ${ANSI.dim}AI session refreshed — type ${ANSI.gold}ai <question>${ANSI.dim} to chat${ANSI.reset}`);
978
+ ws.sendLine('');
979
+ } catch (err) {
980
+ ws.sendLine(` ${ANSI.red}✗ Failed to store key: ${err.message}${ANSI.reset}`);
981
+ ws.sendLine('');
982
+ }
983
+ return {};
984
+ }
985
+
986
+ if (sub === 'remove' && args[1]) {
987
+ const service = args[1].toLowerCase();
988
+ // Can't remove via web shell without password prompt — point to CLI
989
+ ws.sendLine(` ${ANSI.dim}To remove keys, use the CLI:${ANSI.reset}`);
990
+ ws.sendLine(` ${ANSI.gold}darksol keys remove ${service}${ANSI.reset}`);
991
+ ws.sendLine('');
992
+ return {};
993
+ }
994
+
995
+ // Default: show status
996
+ ws.sendLine(`${ANSI.gold} ◆ API KEYS / LLM CONFIG${ANSI.reset}`);
997
+ ws.sendLine(`${ANSI.dim} ${'─'.repeat(50)}${ANSI.reset}`);
998
+ ws.sendLine('');
999
+
1000
+ const llmProviders = ['openai', 'anthropic', 'openrouter', 'ollama'];
1001
+ ws.sendLine(` ${ANSI.gold}LLM Providers:${ANSI.reset}`);
1002
+ for (const p of llmProviders) {
1003
+ const svc = SERVICES[p];
1004
+ const has = hasKey(p);
1005
+ const status = has ? `${ANSI.green}● Connected${ANSI.reset}` : `${ANSI.dim}○ Not set${ANSI.reset}`;
1006
+ ws.sendLine(` ${status} ${ANSI.white}${svc.name.padEnd(20)}${ANSI.reset}${ANSI.dim}${svc.description}${ANSI.reset}`);
1007
+ }
1008
+
1009
+ ws.sendLine('');
1010
+ ws.sendLine(` ${ANSI.gold}Quick Setup:${ANSI.reset}`);
1011
+ ws.sendLine(` ${ANSI.green}keys add openai sk-...${ANSI.reset} ${ANSI.dim}Add OpenAI key${ANSI.reset}`);
1012
+ ws.sendLine(` ${ANSI.green}keys add anthropic sk-ant-...${ANSI.reset} ${ANSI.dim}Add Anthropic key${ANSI.reset}`);
1013
+ ws.sendLine(` ${ANSI.green}keys add openrouter sk-or-...${ANSI.reset} ${ANSI.dim}Add OpenRouter key${ANSI.reset}`);
1014
+ ws.sendLine(` ${ANSI.green}keys add ollama http://...${ANSI.reset} ${ANSI.dim}Add Ollama host${ANSI.reset}`);
1015
+ ws.sendLine('');
1016
+
1017
+ const dataProviders = ['coingecko', 'dexscreener', 'alchemy', 'agentmail'];
1018
+ ws.sendLine(` ${ANSI.gold}Other Services:${ANSI.reset}`);
1019
+ for (const p of dataProviders) {
1020
+ const svc = SERVICES[p];
1021
+ if (!svc) continue;
1022
+ const has = hasKey(p);
1023
+ const status = has ? `${ANSI.green}●${ANSI.reset}` : `${ANSI.dim}○${ANSI.reset}`;
1024
+ ws.sendLine(` ${status} ${ANSI.white}${svc.name.padEnd(20)}${ANSI.reset}${ANSI.dim}${svc.description}${ANSI.reset}`);
1025
+ }
1026
+
1027
+ ws.sendLine('');
1028
+ ws.sendLine(` ${ANSI.dim}Keys are AES-256-GCM encrypted at ~/.darksol/keys/vault.json${ANSI.reset}`);
1029
+ ws.sendLine('');
1030
+ return {};
1031
+ }
1032
+
1033
+ // ══════════════════════════════════════════════════
1034
+ // CHAT LOGS — View conversation history
1035
+ // ══════════════════════════════════════════════════
1036
+ async function cmdChatLogs(args, ws) {
1037
+ const limit = parseInt(args[0]) || 20;
1038
+ const history = getChatHistory(limit);
1039
+
1040
+ ws.sendLine(`${ANSI.gold} ◆ CHAT LOG${ANSI.reset}`);
1041
+ ws.sendLine(`${ANSI.dim} ${'─'.repeat(50)}${ANSI.reset}`);
1042
+ ws.sendLine('');
1043
+
1044
+ if (history.length === 0) {
1045
+ ws.sendLine(` ${ANSI.dim}No chat history today. Start with: ai <question>${ANSI.reset}`);
1046
+ ws.sendLine('');
1047
+ return {};
1048
+ }
1049
+
1050
+ for (const entry of history) {
1051
+ const time = entry.time || '';
1052
+ const role = entry.role === 'user' ? `${ANSI.gold}You${ANSI.reset}` : `${ANSI.green}AI${ANSI.reset}`;
1053
+ const preview = entry.content.split('\n')[0].slice(0, 80);
1054
+ ws.sendLine(` ${ANSI.dim}${time}${ANSI.reset} ${role}: ${preview}${entry.content.length > 80 ? ANSI.dim + '...' + ANSI.reset : ''}`);
1055
+ }
1056
+
1057
+ ws.sendLine('');
1058
+ ws.sendLine(` ${ANSI.dim}Logs: ~/.darksol/chat-logs/ (${history.length} messages shown)${ANSI.reset}`);
1059
+ ws.sendLine('');
1060
+ return {};
1061
+ }
1062
+
693
1063
  // ══════════════════════════════════════════════════
694
1064
  // SEND / RECEIVE (web shell — info only, actual sends require CLI)
695
1065
  // ══════════════════════════════════════════════════
package/src/web/server.js CHANGED
@@ -20,7 +20,7 @@ const __dirname = dirname(__filename);
20
20
  * Command handler registry — maps command strings to async functions
21
21
  * that return { output: string } with ANSI stripped for web
22
22
  */
23
- import { handleCommand } from './commands.js';
23
+ import { handleCommand, getAIStatus } from './commands.js';
24
24
 
25
25
  export async function startWebShell(opts = {}) {
26
26
  process.on('uncaughtException', (err) => {
@@ -89,10 +89,38 @@ export async function startWebShell(opts = {}) {
89
89
  data: getBanner(),
90
90
  }));
91
91
 
92
+ // AI connection check right after banner
93
+ const aiStatus = getAIStatus();
94
+ ws.send(JSON.stringify({
95
+ type: 'output',
96
+ data: aiStatus,
97
+ }));
98
+
92
99
  ws.on('message', async (raw) => {
93
100
  try {
94
101
  const msg = JSON.parse(raw.toString());
95
102
 
103
+ if (msg.type === 'menu_select') {
104
+ // User selected something from an interactive menu
105
+ try {
106
+ const { handleMenuSelect } = await import('./commands.js');
107
+ const result = await handleMenuSelect(msg.id, msg.value, msg.item, {
108
+ send: (text) => ws.send(JSON.stringify({ type: 'output', data: text })),
109
+ sendLine: (text) => ws.send(JSON.stringify({ type: 'output', data: text + '\r\n' })),
110
+ sendMenu: (id, title, items) => ws.send(JSON.stringify({ type: 'menu', id, title, items })),
111
+ });
112
+ if (result?.output) {
113
+ ws.send(JSON.stringify({ type: 'output', data: result.output }));
114
+ }
115
+ } catch (err) {
116
+ ws.send(JSON.stringify({
117
+ type: 'output',
118
+ data: `\r\n \x1b[31m✗ Error: ${err.message}\x1b[0m\r\n`,
119
+ }));
120
+ }
121
+ return;
122
+ }
123
+
96
124
  if (msg.type === 'command') {
97
125
  const cmd = msg.data.trim();
98
126
 
@@ -126,6 +154,7 @@ export async function startWebShell(opts = {}) {
126
154
  const result = await handleCommand(cmd, {
127
155
  send: (text) => ws.send(JSON.stringify({ type: 'output', data: text })),
128
156
  sendLine: (text) => ws.send(JSON.stringify({ type: 'output', data: text + '\r\n' })),
157
+ sendMenu: (id, title, items) => ws.send(JSON.stringify({ type: 'menu', id, title, items })),
129
158
  });
130
159
 
131
160
  if (result?.output) {
@@ -4,11 +4,20 @@
4
4
 
5
5
  const PROMPT = '\x1b[38;2;255;215;0m❯\x1b[0m ';
6
6
  const PROMPT_LENGTH = 2; // visible chars: ❯ + space
7
+ const A = {
8
+ gold: '\x1b[38;2;255;215;0m',
9
+ dim: '\x1b[38;2;102;102;102m',
10
+ green: '\x1b[38;2;0;255;136m',
11
+ red: '\x1b[38;2;233;69;96m',
12
+ white: '\x1b[1;37m',
13
+ blue: '\x1b[38;2;68;136;255m',
14
+ r: '\x1b[0m',
15
+ };
7
16
 
8
17
  const COMMANDS = [
9
- 'price', 'watch', 'gas', 'portfolio', 'history', 'market',
10
- 'wallet', 'mail', 'oracle', 'casino', 'facilitator', 'config',
11
- 'help', 'clear', 'banner', 'exit',
18
+ 'ai', 'price', 'watch', 'gas', 'portfolio', 'history', 'market',
19
+ 'wallet', 'send', 'receive', 'mail', 'keys', 'oracle', 'casino',
20
+ 'facilitator', 'config', 'logs', 'help', 'clear', 'banner', 'exit',
12
21
  ];
13
22
 
14
23
  let term;
@@ -18,8 +27,14 @@ let commandHistory = [];
18
27
  let historyIndex = -1;
19
28
  let connected = false;
20
29
 
30
+ // ── MENU STATE ────────────────────────────────
31
+ let menuActive = false;
32
+ let menuItems = [];
33
+ let menuIndex = 0;
34
+ let menuId = '';
35
+ let menuTitle = '';
36
+
21
37
  async function init() {
22
- // Load xterm.js
23
38
  term = new Terminal({
24
39
  theme: {
25
40
  background: '#0a0a1a',
@@ -64,16 +79,42 @@ async function init() {
64
79
 
65
80
  window.addEventListener('resize', () => fitAddon.fit());
66
81
 
67
- // Connect WebSocket
68
82
  connectWS();
69
83
 
70
- // Input handling
71
84
  term.onKey(({ key, domEvent }) => {
72
85
  const code = domEvent.keyCode;
73
86
  const ctrl = domEvent.ctrlKey;
74
87
 
88
+ // ── MENU MODE ──
89
+ if (menuActive) {
90
+ if (code === 38) { // Up
91
+ if (menuIndex > 0) { menuIndex--; renderMenu(); }
92
+ return;
93
+ }
94
+ if (code === 40) { // Down
95
+ if (menuIndex < menuItems.length - 1) { menuIndex++; renderMenu(); }
96
+ return;
97
+ }
98
+ if (code === 13) { // Enter — select
99
+ const selected = menuItems[menuIndex];
100
+ menuActive = false;
101
+ // Clear menu display
102
+ term.write('\x1b[?25h'); // show cursor
103
+ sendMenuSelection(menuId, selected);
104
+ return;
105
+ }
106
+ if (code === 27 || (ctrl && code === 67)) { // Esc or Ctrl+C — cancel
107
+ menuActive = false;
108
+ term.write('\r\n');
109
+ term.write(` ${A.dim}Cancelled${A.r}\r\n\r\n`);
110
+ writePrompt();
111
+ return;
112
+ }
113
+ return; // Ignore other keys in menu mode
114
+ }
115
+
116
+ // ── NORMAL MODE ──
75
117
  if (ctrl && code === 67) {
76
- // Ctrl+C — cancel
77
118
  currentLine = '';
78
119
  term.write('^C\r\n');
79
120
  writePrompt();
@@ -81,14 +122,12 @@ async function init() {
81
122
  }
82
123
 
83
124
  if (ctrl && code === 76) {
84
- // Ctrl+L — clear
85
125
  term.clear();
86
126
  writePrompt();
87
127
  return;
88
128
  }
89
129
 
90
130
  if (code === 13) {
91
- // Enter
92
131
  term.write('\r\n');
93
132
  const cmd = currentLine.trim();
94
133
 
@@ -106,7 +145,6 @@ async function init() {
106
145
  }
107
146
 
108
147
  if (code === 8) {
109
- // Backspace
110
148
  if (currentLine.length > 0) {
111
149
  currentLine = currentLine.slice(0, -1);
112
150
  term.write('\b \b');
@@ -115,7 +153,6 @@ async function init() {
115
153
  }
116
154
 
117
155
  if (code === 38) {
118
- // Arrow up — history
119
156
  if (historyIndex < commandHistory.length - 1) {
120
157
  historyIndex++;
121
158
  replaceInput(commandHistory[historyIndex]);
@@ -124,7 +161,6 @@ async function init() {
124
161
  }
125
162
 
126
163
  if (code === 40) {
127
- // Arrow down — history
128
164
  if (historyIndex > 0) {
129
165
  historyIndex--;
130
166
  replaceInput(commandHistory[historyIndex]);
@@ -136,13 +172,11 @@ async function init() {
136
172
  }
137
173
 
138
174
  if (code === 9) {
139
- // Tab — autocomplete
140
175
  domEvent.preventDefault();
141
176
  autocomplete();
142
177
  return;
143
178
  }
144
179
 
145
- // Printable characters
146
180
  if (key.length === 1 && !ctrl) {
147
181
  currentLine += key;
148
182
  term.write(key);
@@ -151,7 +185,7 @@ async function init() {
151
185
 
152
186
  // Paste support
153
187
  term.onData((data) => {
154
- // Only handle paste (multi-char input)
188
+ if (menuActive) return;
155
189
  if (data.length > 1 && !data.startsWith('\x1b')) {
156
190
  const clean = data.replace(/[\r\n]/g, '');
157
191
  currentLine += clean;
@@ -162,6 +196,67 @@ async function init() {
162
196
  term.focus();
163
197
  }
164
198
 
199
+ // ── MENU RENDERING ────────────────────────────
200
+ function renderMenu() {
201
+ // Move cursor up to redraw (clear previous menu)
202
+ const totalLines = menuItems.length + 2; // title + items + hint
203
+ term.write(`\x1b[${totalLines}A\x1b[J`);
204
+
205
+ term.write(` ${A.gold}${menuTitle}${A.r}\r\n`);
206
+
207
+ for (let i = 0; i < menuItems.length; i++) {
208
+ const item = menuItems[i];
209
+ const label = item.label || item.value || String(item);
210
+ const desc = item.desc ? ` ${A.dim}${item.desc}${A.r}` : '';
211
+
212
+ if (i === menuIndex) {
213
+ term.write(` ${A.gold}► ${A.white}${label}${A.r}${desc}\r\n`);
214
+ } else {
215
+ term.write(` ${A.dim}${label}${A.r}${desc}\r\n`);
216
+ }
217
+ }
218
+
219
+ term.write(` ${A.dim}↑/↓ navigate • Enter select • Esc cancel${A.r}\r\n`);
220
+ }
221
+
222
+ function showMenu(id, title, items) {
223
+ menuActive = true;
224
+ menuId = id;
225
+ menuTitle = title;
226
+ menuItems = items;
227
+ menuIndex = 0;
228
+
229
+ term.write('\x1b[?25l'); // hide cursor during menu
230
+ term.write('\r\n');
231
+
232
+ // Initial render
233
+ term.write(` ${A.gold}${menuTitle}${A.r}\r\n`);
234
+ for (let i = 0; i < menuItems.length; i++) {
235
+ const item = menuItems[i];
236
+ const label = item.label || item.value || String(item);
237
+ const desc = item.desc ? ` ${A.dim}${item.desc}${A.r}` : '';
238
+
239
+ if (i === menuIndex) {
240
+ term.write(` ${A.gold}► ${A.white}${label}${A.r}${desc}\r\n`);
241
+ } else {
242
+ term.write(` ${A.dim}${label}${A.r}${desc}\r\n`);
243
+ }
244
+ }
245
+ term.write(` ${A.dim}↑/↓ navigate • Enter select • Esc cancel${A.r}\r\n`);
246
+ }
247
+
248
+ function sendMenuSelection(id, item) {
249
+ if (ws && ws.readyState === WebSocket.OPEN) {
250
+ ws.send(JSON.stringify({
251
+ type: 'menu_select',
252
+ id,
253
+ value: item.value || item.label || String(item),
254
+ item,
255
+ }));
256
+ }
257
+ }
258
+
259
+ // ── WEBSOCKET ─────────────────────────────────
165
260
  function connectWS() {
166
261
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
167
262
  ws = new WebSocket(`${protocol}//${window.location.host}`);
@@ -177,13 +272,15 @@ function connectWS() {
177
272
 
178
273
  if (msg.type === 'output') {
179
274
  term.write(msg.data);
180
- writePrompt();
275
+ if (!menuActive) writePrompt();
181
276
  } else if (msg.type === 'clear') {
182
277
  term.clear();
183
278
  writePrompt();
279
+ } else if (msg.type === 'menu') {
280
+ // Server is requesting user to pick from a menu
281
+ showMenu(msg.id, msg.title, msg.items);
184
282
  }
185
283
  } catch {
186
- // Raw text fallback
187
284
  term.write(event.data);
188
285
  }
189
286
  };
@@ -191,12 +288,8 @@ function connectWS() {
191
288
  ws.onclose = () => {
192
289
  connected = false;
193
290
  updateStatus(false);
194
- term.write('\r\n\x1b[38;2;233;69;96m ⚡ Connection lost. Reconnecting...\x1b[0m\r\n');
195
-
196
- // Reconnect after 3s
197
- setTimeout(() => {
198
- connectWS();
199
- }, 3000);
291
+ term.write(`\r\n${A.red} ⚡ Connection lost. Reconnecting...${A.r}\r\n`);
292
+ setTimeout(() => connectWS(), 3000);
200
293
  };
201
294
 
202
295
  ws.onerror = () => {
@@ -209,7 +302,7 @@ function sendCommand(cmd) {
209
302
  if (ws && ws.readyState === WebSocket.OPEN) {
210
303
  ws.send(JSON.stringify({ type: 'command', data: cmd }));
211
304
  } else {
212
- term.write('\x1b[38;2;233;69;96m ✗ Not connected\x1b[0m\r\n');
305
+ term.write(`${A.red} ✗ Not connected${A.r}\r\n`);
213
306
  writePrompt();
214
307
  }
215
308
  }
@@ -219,28 +312,20 @@ function writePrompt() {
219
312
  }
220
313
 
221
314
  function replaceInput(text) {
222
- // Clear current input
223
315
  const clearLen = currentLine.length;
224
- for (let i = 0; i < clearLen; i++) {
225
- term.write('\b \b');
226
- }
316
+ for (let i = 0; i < clearLen; i++) term.write('\b \b');
227
317
  currentLine = text;
228
318
  term.write(text);
229
319
  }
230
320
 
231
321
  function autocomplete() {
232
322
  if (!currentLine) return;
233
-
234
323
  const matches = COMMANDS.filter((c) => c.startsWith(currentLine.toLowerCase()));
235
-
236
324
  if (matches.length === 1) {
237
325
  replaceInput(matches[0]);
238
326
  } else if (matches.length > 1) {
239
- // Show options
240
327
  term.write('\r\n');
241
- term.write(
242
- matches.map((m) => ` \x1b[38;2;102;102;102m${m}\x1b[0m`).join(' ')
243
- );
328
+ term.write(matches.map((m) => ` ${A.dim}${m}${A.r}`).join(' '));
244
329
  term.write('\r\n');
245
330
  writePrompt();
246
331
  term.write(currentLine);
@@ -250,13 +335,8 @@ function autocomplete() {
250
335
  function updateStatus(isConnected) {
251
336
  const dot = document.querySelector('.status-dot');
252
337
  const text = document.querySelector('.status-text');
253
- if (dot) {
254
- dot.className = isConnected ? 'status-dot' : 'status-dot disconnected';
255
- }
256
- if (text) {
257
- text.textContent = isConnected ? 'Connected' : 'Disconnected';
258
- }
338
+ if (dot) dot.className = isConnected ? 'status-dot' : 'status-dot disconnected';
339
+ if (text) text.textContent = isConnected ? 'Connected' : 'Disconnected';
259
340
  }
260
341
 
261
- // Boot
262
342
  document.addEventListener('DOMContentLoaded', init);