notoken-core 1.5.1 → 2.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.
Files changed (99) hide show
  1. package/config/chat-responses.json +767 -0
  2. package/config/concept-clusters.json +31 -0
  3. package/config/entities.json +93 -0
  4. package/config/image-prompts.json +20 -0
  5. package/config/intent-vectors.json +1 -0
  6. package/config/intents.json +5023 -65
  7. package/config/ollama-models.json +193 -0
  8. package/config/rules.json +32 -1
  9. package/dist/automation/discordPatchright.d.ts +35 -0
  10. package/dist/automation/discordPatchright.js +424 -0
  11. package/dist/automation/discordSetup.d.ts +31 -0
  12. package/dist/automation/discordSetup.js +338 -0
  13. package/dist/conversation/coreference.js +44 -4
  14. package/dist/conversation/pendingActions.d.ts +55 -0
  15. package/dist/conversation/pendingActions.js +127 -0
  16. package/dist/conversation/store.d.ts +72 -0
  17. package/dist/conversation/store.js +140 -1
  18. package/dist/conversation/topicTracker.d.ts +36 -0
  19. package/dist/conversation/topicTracker.js +141 -0
  20. package/dist/execution/ssh.d.ts +42 -1
  21. package/dist/execution/ssh.js +532 -3
  22. package/dist/handlers/executor.js +3981 -16
  23. package/dist/index.d.ts +25 -3
  24. package/dist/index.js +36 -2
  25. package/dist/nlp/batchParser.d.ts +30 -0
  26. package/dist/nlp/batchParser.js +77 -0
  27. package/dist/nlp/conceptExpansion.d.ts +54 -0
  28. package/dist/nlp/conceptExpansion.js +136 -0
  29. package/dist/nlp/conceptRouter.d.ts +49 -0
  30. package/dist/nlp/conceptRouter.js +302 -0
  31. package/dist/nlp/confidenceCalibrator.d.ts +62 -0
  32. package/dist/nlp/confidenceCalibrator.js +116 -0
  33. package/dist/nlp/correctionLearner.d.ts +45 -0
  34. package/dist/nlp/correctionLearner.js +207 -0
  35. package/dist/nlp/entitySpellCorrect.d.ts +35 -0
  36. package/dist/nlp/entitySpellCorrect.js +141 -0
  37. package/dist/nlp/knowledgeGraph.d.ts +70 -0
  38. package/dist/nlp/knowledgeGraph.js +380 -0
  39. package/dist/nlp/llmFallback.js +28 -1
  40. package/dist/nlp/multiClassifier.js +91 -6
  41. package/dist/nlp/multiIntent.d.ts +43 -0
  42. package/dist/nlp/multiIntent.js +154 -0
  43. package/dist/nlp/parseIntent.d.ts +6 -1
  44. package/dist/nlp/parseIntent.js +180 -5
  45. package/dist/nlp/ruleParser.js +315 -0
  46. package/dist/nlp/semanticSimilarity.d.ts +30 -0
  47. package/dist/nlp/semanticSimilarity.js +174 -0
  48. package/dist/nlp/vocabularyBuilder.d.ts +43 -0
  49. package/dist/nlp/vocabularyBuilder.js +224 -0
  50. package/dist/nlp/wikidata.d.ts +49 -0
  51. package/dist/nlp/wikidata.js +228 -0
  52. package/dist/policy/confirm.d.ts +10 -0
  53. package/dist/policy/confirm.js +39 -0
  54. package/dist/policy/safety.js +6 -4
  55. package/dist/utils/aliases.d.ts +5 -0
  56. package/dist/utils/aliases.js +39 -0
  57. package/dist/utils/analysis.js +71 -15
  58. package/dist/utils/browser.d.ts +64 -0
  59. package/dist/utils/browser.js +364 -0
  60. package/dist/utils/commandHistory.d.ts +20 -0
  61. package/dist/utils/commandHistory.js +108 -0
  62. package/dist/utils/completer.d.ts +17 -0
  63. package/dist/utils/completer.js +79 -0
  64. package/dist/utils/config.js +32 -2
  65. package/dist/utils/dbQuery.d.ts +25 -0
  66. package/dist/utils/dbQuery.js +248 -0
  67. package/dist/utils/discordDiag.d.ts +35 -0
  68. package/dist/utils/discordDiag.js +826 -0
  69. package/dist/utils/diskCleanup.d.ts +36 -0
  70. package/dist/utils/diskCleanup.js +775 -0
  71. package/dist/utils/entityResolver.d.ts +107 -0
  72. package/dist/utils/entityResolver.js +468 -0
  73. package/dist/utils/imageGen.d.ts +92 -0
  74. package/dist/utils/imageGen.js +2031 -0
  75. package/dist/utils/installTracker.d.ts +57 -0
  76. package/dist/utils/installTracker.js +160 -0
  77. package/dist/utils/multiExec.d.ts +21 -0
  78. package/dist/utils/multiExec.js +141 -0
  79. package/dist/utils/openclawDiag.d.ts +29 -0
  80. package/dist/utils/openclawDiag.js +1035 -0
  81. package/dist/utils/output.js +4 -0
  82. package/dist/utils/platform.js +2 -1
  83. package/dist/utils/progressReporter.d.ts +50 -0
  84. package/dist/utils/progressReporter.js +58 -0
  85. package/dist/utils/projectDetect.d.ts +44 -0
  86. package/dist/utils/projectDetect.js +319 -0
  87. package/dist/utils/projectScanner.d.ts +44 -0
  88. package/dist/utils/projectScanner.js +312 -0
  89. package/dist/utils/shellCompat.d.ts +78 -0
  90. package/dist/utils/shellCompat.js +186 -0
  91. package/dist/utils/smartArchive.d.ts +16 -0
  92. package/dist/utils/smartArchive.js +172 -0
  93. package/dist/utils/smartRetry.d.ts +26 -0
  94. package/dist/utils/smartRetry.js +114 -0
  95. package/dist/utils/updater.d.ts +1 -0
  96. package/dist/utils/updater.js +1 -1
  97. package/dist/utils/version.d.ts +20 -0
  98. package/dist/utils/version.js +212 -0
  99. package/package.json +6 -3
@@ -9,6 +9,21 @@ import { withSpinner } from "../utils/spinner.js";
9
9
  import { analyzeOutput } from "../utils/analysis.js";
10
10
  import { smartRead, smartSearch } from "../utils/smartFile.js";
11
11
  import { pluginRegistry } from "../plugins/registry.js";
12
+ import { exec } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ import { testSshConnection } from "../execution/ssh.js";
15
+ import { scanForCleanup, formatCleanupTable, runInteractiveCleanup, smartDriveScan, formatDriveScan } from "../utils/diskCleanup.js";
16
+ import { detectProjects as detectProjectsNew, formatProjectDetection, readProjectConfig, formatPackageScripts, getScriptRunCmd } from "../utils/projectDetect.js";
17
+ import { smartArchive } from "../utils/smartArchive.js";
18
+ import { buildQuery, formatQueryPlan } from "../utils/dbQuery.js";
19
+ import { learnEntity, listEntities } from "../utils/entityResolver.js";
20
+ import { detectMultiTarget, executeMulti } from "../utils/multiExec.js";
21
+ import { diagnoseOpenclaw, autoFixOpenclaw, quickConnectivityCheck } from "../utils/openclawDiag.js";
22
+ const execAsync = promisify(exec);
23
+ import { scanProjects, summarizeDirectory, formatProjectList, formatDirSummary } from "../utils/projectScanner.js";
24
+ import { generateImage, detectImageEngines, formatImageEngineStatus } from "../utils/imageGen.js";
25
+ import { searchWikidata, formatWikiEntity, formatWikiSuggestions } from "../nlp/wikidata.js";
26
+ import { suggestAction } from "../conversation/pendingActions.js";
12
27
  /**
13
28
  * Generic command executor.
14
29
  *
@@ -20,6 +35,12 @@ export async function executeIntent(intent) {
20
35
  if (!def) {
21
36
  throw new Error(`No intent definition found for: ${intent.intent}`);
22
37
  }
38
+ // Learn from this execution — grows the knowledge graph over time
39
+ try {
40
+ const { learnFromExecution } = await import("../nlp/knowledgeGraph.js");
41
+ learnFromExecution(intent.intent, intent.fields, intent.rawText);
42
+ }
43
+ catch { /* knowledge graph not available */ }
23
44
  // Plugin beforeExecute hooks — can cancel execution
24
45
  const proceed = await pluginRegistry.runBeforeExecute({
25
46
  intent: intent.intent,
@@ -29,6 +50,40 @@ export async function executeIntent(intent) {
29
50
  if (proceed === false) {
30
51
  return "[cancelled by plugin]";
31
52
  }
53
+ // ── Context-aware intent announcement ──
54
+ // When the intent is ambiguous (e.g. "diagnose" without a target),
55
+ // use entity focus from conversation to infer what the user means,
56
+ // and announce what we're about to do so the user can redirect.
57
+ try {
58
+ const { getOrCreateConversation, getEntityFocus, setEntityFocus } = await import("../conversation/store.js");
59
+ const conv = getOrCreateConversation(process.cwd());
60
+ // Set focus when user explicitly mentions a service
61
+ const rawLower = intent.rawText.toLowerCase();
62
+ if (rawLower.includes("discord"))
63
+ setEntityFocus(conv, "discord", "service");
64
+ else if (rawLower.includes("openclaw") || rawLower.includes("claw"))
65
+ setEntityFocus(conv, "openclaw", "service");
66
+ else if (rawLower.includes("ollama"))
67
+ setEntityFocus(conv, "ollama", "service");
68
+ else if (rawLower.includes("docker"))
69
+ setEntityFocus(conv, "docker", "service");
70
+ // For ambiguous intents (diagnose, fix, check, status, restart, etc.)
71
+ // without an explicit service name — resolve from entity focus
72
+ const ambiguousVerbs = /^(diagnose|fix|check|troubleshoot|repair|restart|start|stop|status|update)\s*$/i;
73
+ // Don't override notoken.status — bare "status" should stay as system dashboard
74
+ if (intent.intent === "notoken.status") { /* skip context injection */ }
75
+ else if (ambiguousVerbs.test(rawLower.trim()) || (rawLower.match(/^(diagnose|fix|check|troubleshoot|repair)\s+(it|this|that)$/i))) {
76
+ const focus = getEntityFocus(conv);
77
+ if (focus) {
78
+ const target = focus.entityId;
79
+ console.log(`\x1b[2m → ${intent.intent} targeting \x1b[1m${target}\x1b[0m\x1b[2m (based on conversation)\x1b[0m`);
80
+ console.log(`\x1b[2m Say "not that" or specify: "${rawLower.split(/\s/)[0]} openclaw" / "${rawLower.split(/\s/)[0]} discord"\x1b[0m`);
81
+ // Inject the target into rawText for downstream handlers
82
+ intent.rawText = `${intent.rawText} ${target}`;
83
+ }
84
+ }
85
+ }
86
+ catch { /* conversation store not available — skip context */ }
32
87
  // Fuzzy resolve file paths if needed
33
88
  const resolved = await resolveFuzzyFields(intent);
34
89
  const fields = resolved.fields;
@@ -55,25 +110,3935 @@ export async function executeIntent(intent) {
55
110
  // For remote: prepend backup command
56
111
  }
57
112
  }
58
- // Smart file reading — size check, sampling, context search
59
- if (intent.intent === "file.read" || intent.intent === "file.parse") {
60
- const filePath = fields.path ?? "";
61
- if (filePath) {
62
- command = `[smart-read] ${filePath}`;
63
- result = await withSpinner(`Reading ${filePath}...`, () => smartRead(filePath, !isLocal, isLocal ? undefined : environment));
64
- recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
65
- return result;
113
+ // ── Ported handlers ─────────────────────────────────────────────────────────
114
+ const nvmPfx = `for d in "$HOME/.nvm" "/home/"*"/.nvm" "/root/.nvm"; do [ -s "$d/nvm.sh" ] && export NVM_DIR="$d" && . "$d/nvm.sh" && break; done 2>/dev/null; nvm use 22 > /dev/null 2>&1;`;
115
+ // Persist last targeted env so "the other one" works
116
+ const _ocEnvKey = "__notoken_last_oc_env";
117
+ function getLastOcEnv() {
118
+ return process[_ocEnvKey] ?? null;
119
+ }
120
+ function setLastOcEnv(env) {
121
+ process[_ocEnvKey] = env;
122
+ }
123
+ async function detectOcEnv() {
124
+ const isWSL = (await runLocalCommand("grep -qi microsoft /proc/version 2>/dev/null && echo wsl || echo native").catch(() => "native")).trim() === "wsl";
125
+ if (!isWSL)
126
+ return { inWSL: false, wslInstalled: true, winInstalled: false };
127
+ const wslOC = await runLocalCommand("which openclaw 2>/dev/null").catch(() => "");
128
+ const winOC = await runLocalCommand("/mnt/c/Windows/System32/cmd.exe /c \"where openclaw\" 2>/dev/null").catch(() => "");
129
+ return { inWSL: true, wslInstalled: !!wslOC.includes("openclaw"), winInstalled: !!winOC.includes("openclaw") };
130
+ }
131
+ function parseOcTarget(rawText) {
132
+ const t = rawText.toLowerCase();
133
+ if (/\bboth\b/.test(t))
134
+ return "both";
135
+ if (/\b(on\s+)?windows\b|\b(on\s+)?win\b|\bhost\b/.test(t))
136
+ return "windows";
137
+ if (/\b(on\s+|in\s+)?wsl\b|\b(on\s+)?linux\b/.test(t))
138
+ return "wsl";
139
+ if (/\bthe\s+other\s+(one|side|env|environment)\b|\bnot\s+this\s+one\b|\bthe\s+other\b/.test(t)) {
140
+ const last = getLastOcEnv();
141
+ if (last === "wsl")
142
+ return "windows";
143
+ if (last === "windows")
144
+ return "wsl";
145
+ return null; // don't know which "other" means
66
146
  }
147
+ return null; // default — use current env
67
148
  }
68
- if (intent.intent === "file.search_in") {
69
- const filePath = fields.path ?? "";
70
- const query = fields.query ?? "";
71
- if (filePath && query) {
72
- command = `[smart-search] ${query} in ${filePath}`;
73
- result = await withSpinner(`Searching "${query}" in ${filePath}...`, () => smartSearch(filePath, query, !isLocal, isLocal ? undefined : environment));
74
- recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
75
- return result;
149
+ // Resolve Node 22 binary path once — nvm sourcing doesn't survive subshells/nohup
150
+ let _node22Path = null;
151
+ async function getNode22() {
152
+ if (_node22Path)
153
+ return _node22Path;
154
+ const searchDirs = [`${process.env.HOME}/.nvm`, "/home/ino/.nvm", "/root/.nvm"];
155
+ for (const dir of searchDirs) {
156
+ const found = await runLocalCommand(`ls -1 ${dir}/versions/node/v22*/bin/node 2>/dev/null | tail -1`).catch(() => "");
157
+ if (found.trim()) {
158
+ _node22Path = found.trim();
159
+ return _node22Path;
160
+ }
161
+ }
162
+ // Fallback: try system node
163
+ _node22Path = (await runLocalCommand("which node").catch(() => "node")).trim();
164
+ return _node22Path;
165
+ }
166
+ /** Run an openclaw command on the specified environment(s). */
167
+ async function runOcCmd(cmd, target, env, timeout = 30_000) {
168
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m" };
169
+ // Resolve target — default to current env
170
+ const isNativeWindows = process.platform === "win32" && !env.inWSL;
171
+ const effective = target ?? (env.inWSL ? "wsl" : (isNativeWindows ? "windows" : "wsl"));
172
+ setLastOcEnv(effective === "both" ? "wsl" : effective);
173
+ // ── Native Windows: run openclaw directly (no node22/nohup wrapping) ──
174
+ if (isNativeWindows && (effective === "windows" || !target)) {
175
+ setLastOcEnv("windows");
176
+ try {
177
+ return await runLocalCommand(`${cmd} 2>&1`, timeout);
178
+ }
179
+ catch (err) {
180
+ const e = err;
181
+ if (e.stdout?.trim())
182
+ return e.stdout.trim();
183
+ if (e.stderr?.trim())
184
+ return e.stderr.trim();
185
+ throw err;
186
+ }
187
+ }
188
+ // Build openclaw command with Node 22 binary directly (WSL/Linux)
189
+ const node22 = await getNode22();
190
+ const ocBin = (await runLocalCommand("readlink -f $(which openclaw) 2>/dev/null || which openclaw").catch(() => "openclaw")).trim();
191
+ // Replace "openclaw" at start of cmd with node22 + ocBin
192
+ const wslCmd = cmd.replace(/^openclaw\b/, `${node22} ${ocBin}`);
193
+ // Build Windows command — use cmd.exe (PowerShell blocks .ps1 scripts due to execution policy)
194
+ function buildWinCmd(ocCmd) {
195
+ const escaped = ocCmd.replace(/"/g, '\\"');
196
+ return `/mnt/c/Windows/System32/cmd.exe /c "${escaped}" 2>/dev/null`;
197
+ }
198
+ if (effective === "both") {
199
+ const results = [];
200
+ if (env.wslInstalled) {
201
+ results.push(`${cc.bold}${cc.cyan}[WSL]${cc.reset}`);
202
+ results.push(await runLocalCommand(`${wslCmd} 2>&1`, timeout).catch(e => `${cc.yellow}⚠ ${e.message.split("\n")[0]}${cc.reset}`));
203
+ }
204
+ else {
205
+ results.push(`${cc.bold}${cc.cyan}[WSL]${cc.reset} ${cc.dim}Not installed${cc.reset}`);
206
+ }
207
+ if (env.winInstalled) {
208
+ results.push(`\n${cc.bold}${cc.cyan}[Windows]${cc.reset}`);
209
+ results.push(await runLocalCommand(buildWinCmd(cmd), timeout).catch(e => `${cc.yellow}⚠ ${e.message.split("\n")[0]}${cc.reset}`));
210
+ }
211
+ else {
212
+ results.push(`\n${cc.bold}${cc.cyan}[Windows]${cc.reset} ${cc.dim}Not installed${cc.reset}`);
213
+ }
214
+ return results.join("\n");
215
+ }
216
+ if (effective === "windows") {
217
+ if (!env.inWSL)
218
+ return `${cc.yellow}⚠ Not in WSL — can't target Windows host.${cc.reset}`;
219
+ if (!env.winInstalled)
220
+ return `${cc.yellow}⚠ OpenClaw not installed on Windows host.${cc.reset}\n${cc.dim}Install: open PowerShell and run: npm install -g openclaw${cc.reset}`;
221
+ setLastOcEnv("windows");
222
+ try {
223
+ return await runLocalCommand(buildWinCmd(cmd), timeout);
224
+ }
225
+ catch (err) {
226
+ const e = err;
227
+ if (e.stdout?.trim())
228
+ return e.stdout.trim();
229
+ if (e.stderr?.trim())
230
+ return e.stderr.trim();
231
+ throw err;
232
+ }
233
+ }
234
+ // WSL / native Linux — use Node 22 directly
235
+ setLastOcEnv("wsl");
236
+ try {
237
+ return await runLocalCommand(`${wslCmd} 2>&1`, timeout);
238
+ }
239
+ catch (err) {
240
+ const e = err;
241
+ if (e.stdout?.trim())
242
+ return e.stdout.trim();
243
+ if (e.stderr?.trim())
244
+ return e.stderr.trim();
245
+ throw err;
246
+ }
247
+ }
248
+ const MODEL_ALIASES = {
249
+ "opus": "anthropic/claude-opus-4-6", "sonnet": "anthropic/claude-sonnet-4-6", "haiku": "anthropic/claude-haiku-4-5",
250
+ "claude": "anthropic/claude-opus-4-6", "gpt-4o": "openai-codex/gpt-4o", "gpt-5": "openai-codex/gpt-5.4",
251
+ "gpt": "openai-codex/gpt-4o", "chatgpt": "openai-codex/gpt-4o", "codex": "openai-codex/gpt-5.4",
252
+ "openai": "openai-codex/gpt-4o", "gemini": "google/gemini-2.5-pro", "mistral": "mistral/mistral-large",
253
+ "llama": "ollama/llama2:13b", "llama2": "ollama/llama2:13b", "llama3": "ollama/llama3.2", "llama3.2": "ollama/llama3.2",
254
+ "ollama": "ollama/llama2:13b", "codellama": "ollama/codellama", "phi": "ollama/phi3", "qwen": "ollama/qwen2.5",
255
+ "deepseek": "ollama/deepseek-v3",
256
+ };
257
+ // Multi-environment execution
258
+ const multiTargets = detectMultiTarget(intent.rawText);
259
+ if (multiTargets && multiTargets.length > 1)
260
+ return executeMulti(intent, multiTargets);
261
+ // ── OpenClaw handlers (environment-aware) ──────────────────────────────────
262
+ // All openclaw.* intents detect WSL/Windows, support "on windows"/"on wsl"/
263
+ // "the other one"/"both", and verbosely announce which env they're targeting.
264
+ const isOpenclawIntent = intent.intent.startsWith("openclaw.");
265
+ let ocEnv = null;
266
+ let ocTarget = null;
267
+ let ocLabel = "";
268
+ if (isOpenclawIntent) {
269
+ ocEnv = await detectOcEnv();
270
+ ocTarget = parseOcTarget(intent.rawText);
271
+ // Build verbose label
272
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", cyan: "\x1b[36m", yellow: "\x1b[33m" };
273
+ if (ocTarget === "both") {
274
+ ocLabel = `${cc.bold}${cc.cyan}Targeting: both WSL and Windows${cc.reset}`;
275
+ }
276
+ else if (ocTarget === "windows") {
277
+ ocLabel = `${cc.bold}${cc.cyan}Targeting: Windows host${cc.reset}`;
278
+ }
279
+ else if (ocTarget === "wsl") {
280
+ ocLabel = `${cc.bold}${cc.cyan}Targeting: WSL${cc.reset}`;
281
+ }
282
+ else if (ocEnv.inWSL) {
283
+ ocLabel = `${cc.bold}${cc.cyan}Targeting: WSL${cc.reset} ${cc.dim}(say "on windows", "the other one", or "both")${cc.reset}`;
284
+ }
285
+ else {
286
+ ocLabel = `${cc.bold}${cc.cyan}Targeting: local${cc.reset}`;
287
+ }
288
+ console.log(`\n ${ocLabel}\n`);
289
+ }
290
+ // Discord monitor — lightweight watcher that tails gateway logs
291
+ if (intent.intent === "discord.monitor" || intent.rawText.match(/\b(monitor|watch|tail)\b.*\bdiscord\b/i)) {
292
+ const { monitorDiscord } = await import("../utils/discordDiag.js");
293
+ return monitorDiscord();
294
+ }
295
+ // Discord diagnose/fix/check — by intent or raw text match
296
+ if (intent.intent === "discord.diagnose" || intent.intent === "discord.check" || intent.intent === "discord.setup" ||
297
+ intent.rawText.match(/\b(diagnose|fix|check|troubleshoot|repair)\b.*\bdiscord\b|\bdiscord\b.*\b(diagnose|fix|check|troubleshoot|status)\b/i)) {
298
+ const isQuick = intent.intent === "discord.check" || (!!intent.rawText.match(/\b(check|status)\b/i) && !intent.rawText.match(/\b(fix|diagnose|troubleshoot|repair)\b/i));
299
+ const isSetup = intent.intent === "discord.setup" || !!intent.rawText.match(/\bsetup\b.*\bdiscord\b/i);
300
+ try {
301
+ if (isSetup) {
302
+ // Full setup flow — create bot, authorize, configure
303
+ try {
304
+ const { createDiscordBot, authorizeDiscordBot } = await import("../automation/discordPatchright.js");
305
+ const result = await createDiscordBot();
306
+ if (result.success && result.token) {
307
+ // Register with OpenClaw
308
+ const node22 = await getNode22();
309
+ const ocBin = (await runLocalCommand("readlink -f $(which openclaw) 2>/dev/null || which openclaw").catch(() => "openclaw")).trim();
310
+ await runLocalCommand(`${node22} ${ocBin} channels add --channel discord --token "${result.token}" 2>&1`, 15_000).catch(() => "");
311
+ if (result.appId)
312
+ await authorizeDiscordBot(result.appId);
313
+ return `\x1b[32m✓\x1b[0m Discord bot created and configured!\n \x1b[2mRun: "diagnose discord" to verify everything works.\x1b[0m`;
314
+ }
315
+ return `\x1b[33m⚠\x1b[0m Setup incomplete. ${result.success ? "" : "Token not captured."}\n \x1b[2mTry again or run: "setup discord with token YOUR_TOKEN"\x1b[0m`;
316
+ }
317
+ catch {
318
+ // Fallback to manual instructions
319
+ const { diagnoseDiscord } = await import("../utils/discordDiag.js");
320
+ return await diagnoseDiscord();
321
+ }
322
+ }
323
+ else if (isQuick) {
324
+ const { quickDiscordCheck } = await import("../utils/discordDiag.js");
325
+ return await quickDiscordCheck();
326
+ }
327
+ else {
328
+ const { diagnoseDiscord } = await import("../utils/discordDiag.js");
329
+ return await diagnoseDiscord();
330
+ }
331
+ }
332
+ catch (err) {
333
+ return `\x1b[31m✗ Discord diagnostics error: ${err.message.split("\n")[0]}\x1b[0m`;
334
+ }
335
+ }
336
+ // OpenClaw status
337
+ if (intent.intent === "openclaw.status") {
338
+ const diagRemote = environment !== "local" && environment !== "localhost" && hasRealHost(environment);
339
+ return diagRemote ? await quickConnectivityCheck((cmd) => runRemoteCommand(environment, cmd)) : await quickConnectivityCheck();
340
+ }
341
+ // OpenClaw diagnose
342
+ if (intent.intent === "openclaw.diagnose") {
343
+ const diagRemote = environment !== "local" && environment !== "localhost" && hasRealHost(environment);
344
+ return diagRemote
345
+ ? await withSpinner(`Diagnosing on ${environment}...`, () => diagnoseOpenclaw(true, (cmd) => runRemoteCommand(environment, cmd)))
346
+ : await withSpinner("Diagnosing OpenClaw...", () => diagnoseOpenclaw(false));
347
+ }
348
+ // OpenClaw doctor — run diagnostics, or auto-fix if requested
349
+ if (intent.intent === "openclaw.doctor") {
350
+ if (intent.rawText.match(/fix|repair|auto.?fix/i)) {
351
+ return isLocal ? await autoFixOpenclaw() : await autoFixOpenclaw((cmd) => runRemoteCommand(environment, cmd));
352
+ }
353
+ // Without "fix" — run diagnostics (same as openclaw.diagnose)
354
+ const diagRemote = environment !== "local" && environment !== "localhost" && hasRealHost(environment);
355
+ return diagRemote
356
+ ? await withSpinner(`Diagnosing on ${environment}...`, () => diagnoseOpenclaw(true, (cmd) => runRemoteCommand(environment, cmd)))
357
+ : await withSpinner("Diagnosing OpenClaw...", () => diagnoseOpenclaw(false));
358
+ }
359
+ // ── OpenClaw dashboard — open web UI and auto-pair ──
360
+ if (intent.intent === "openclaw.dashboard") {
361
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
362
+ // Check if gateway is running
363
+ const health = await runLocalCommand("curl -sf http://127.0.0.1:18789/health 2>/dev/null").catch(() => "");
364
+ if (!health.includes('"ok"')) {
365
+ console.log(`${cc.yellow}⚠ Gateway not running. Starting it...${cc.reset}`);
366
+ if (process.platform === "win32") {
367
+ const ocPath = (await runLocalCommand("npm config get prefix 2>/dev/null").catch(() => "")).trim();
368
+ const ocEntry = ocPath ? `${ocPath}\\node_modules\\openclaw\\dist\\index.js` : "openclaw";
369
+ await runLocalCommand(`powershell -Command "Start-Process -FilePath node -ArgumentList '${ocEntry}','gateway','--force','--allow-unconfigured' -WindowStyle Hidden" 2>/dev/null`).catch(() => "");
370
+ }
371
+ else {
372
+ await runLocalCommand("nohup openclaw gateway --force --allow-unconfigured > /dev/null 2>&1 &").catch(() => "");
373
+ }
374
+ for (let i = 0; i < 8; i++) {
375
+ await runLocalCommand("sleep 1").catch(() => { });
376
+ const h = await runLocalCommand("curl -sf http://127.0.0.1:18789/health 2>/dev/null").catch(() => "");
377
+ if (h.includes('"ok"'))
378
+ break;
379
+ }
380
+ }
381
+ // Read the pairing token from config
382
+ const { readFileSync: readFS, existsSync: existsFS } = await import("node:fs");
383
+ const userHome = process.env.USERPROFILE || process.env.HOME || "";
384
+ const sep = process.platform === "win32" ? "\\" : "/";
385
+ const configPath = `${userHome}${sep}.openclaw${sep}openclaw.json`;
386
+ let token = "";
387
+ try {
388
+ if (existsFS(configPath)) {
389
+ const config = JSON.parse(readFS(configPath, "utf-8"));
390
+ token = config?.gateway?.auth?.token || "";
391
+ }
392
+ }
393
+ catch { }
394
+ // Try auto-pair with Playwright (fills token and clicks Connect automatically)
395
+ const url = "http://127.0.0.1:18789";
396
+ let autoPaired = false;
397
+ // Ensure Playwright + Chromium are installed
398
+ let hasPlaywright = false;
399
+ try {
400
+ await import("playwright");
401
+ hasPlaywright = true;
402
+ }
403
+ catch {
404
+ console.log(`${cc.cyan}Installing Playwright for browser automation...${cc.reset}`);
405
+ try {
406
+ await withSpinner("Installing Playwright...", () => runLocalCommand("npm install playwright 2>&1", 120_000));
407
+ await withSpinner("Downloading Chromium...", () => runLocalCommand("npx playwright install chromium 2>&1", 300_000));
408
+ hasPlaywright = true;
409
+ console.log(`${cc.green}✓ Playwright + Chromium installed${cc.reset}\n`);
410
+ }
411
+ catch {
412
+ console.log(`${cc.yellow}⚠ Could not install Playwright — falling back to manual pairing${cc.reset}\n`);
413
+ }
414
+ }
415
+ try {
416
+ if (!hasPlaywright)
417
+ throw new Error("no playwright");
418
+ const { chromium } = await import("playwright");
419
+ console.log(`${cc.cyan}Opening OpenClaw dashboard and auto-pairing...${cc.reset}\n`);
420
+ const browser = await chromium.launch({ headless: false });
421
+ const page = await browser.newPage();
422
+ await page.goto(url);
423
+ await page.waitForTimeout(2000);
424
+ // Fill token and click Connect
425
+ const tokenInput = page.locator('input[placeholder*="OPENCLAW_GATEWAY_TOKEN"]');
426
+ if (token && await tokenInput.count() > 0) {
427
+ await tokenInput.fill(token);
428
+ const connectBtn = page.locator('button').filter({ hasText: 'Connect' });
429
+ if (await connectBtn.count() > 0) {
430
+ await connectBtn.first().click();
431
+ await page.waitForTimeout(3000);
432
+ const bodyText = await page.textContent('body');
433
+ if (!bodyText?.includes('How to connect') && !bodyText?.includes('unauthorized')) {
434
+ autoPaired = true;
435
+ }
436
+ }
437
+ }
438
+ else if (await tokenInput.count() === 0) {
439
+ // No token input — already connected
440
+ autoPaired = true;
441
+ }
442
+ // Leave browser open for the user
443
+ if (autoPaired) {
444
+ return `${cc.green}✓${cc.reset} OpenClaw dashboard opened and auto-paired!\n ${cc.cyan}${cc.bold}${url}${cc.reset}\n\n ${cc.dim}The browser is open — you can chat with OpenClaw directly.${cc.reset}`;
445
+ }
446
+ }
447
+ catch {
448
+ // Playwright not available — fall back to manual
449
+ }
450
+ // Fallback: open browser normally and copy token to clipboard
451
+ console.log(`${cc.cyan}Opening OpenClaw dashboard...${cc.reset}\n`);
452
+ try {
453
+ if (process.platform === "win32") {
454
+ await runLocalCommand(`powershell -Command "Start-Process '${url}'" 2>/dev/null`);
455
+ }
456
+ else if (process.platform === "darwin") {
457
+ await runLocalCommand(`open "${url}" 2>/dev/null`);
458
+ }
459
+ else {
460
+ await runLocalCommand(`xdg-open "${url}" 2>/dev/null || wslview "${url}" 2>/dev/null`);
461
+ }
462
+ }
463
+ catch { }
464
+ if (token) {
465
+ try {
466
+ if (process.platform === "win32") {
467
+ await runLocalCommand(`printf '%s' '${token}' | clip 2>/dev/null`);
468
+ }
469
+ else if (process.platform === "darwin") {
470
+ await runLocalCommand(`printf '%s' '${token}' | pbcopy 2>/dev/null`);
471
+ }
472
+ }
473
+ catch { }
474
+ }
475
+ const lines = [
476
+ `${cc.green}✓${cc.reset} OpenClaw dashboard: ${cc.cyan}${cc.bold}${url}${cc.reset}`,
477
+ ];
478
+ if (token) {
479
+ lines.push(``);
480
+ lines.push(` ${cc.bold}Pairing token:${cc.reset} ${cc.cyan}${token}${cc.reset}`);
481
+ lines.push(` ${cc.dim}Paste this into the web UI when it asks to pair.${cc.reset}`);
482
+ if (process.platform === "win32" || process.platform === "darwin") {
483
+ lines.push(` ${cc.green}✓ Already copied to your clipboard — just paste it.${cc.reset}`);
484
+ }
485
+ }
486
+ else {
487
+ lines.push(` ${cc.dim}No auth token — dashboard should connect directly.${cc.reset}`);
488
+ }
489
+ return lines.join("\n");
490
+ }
491
+ // ── OpenClaw channel setup — Telegram, Discord, Matrix, WhatsApp ──
492
+ if (intent.intent === "openclaw.channel.setup") {
493
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
494
+ // Detect which channel from the raw text
495
+ const rawLower = intent.rawText.toLowerCase();
496
+ let channel = null;
497
+ if (/telegram/i.test(rawLower))
498
+ channel = "telegram";
499
+ else if (/discord/i.test(rawLower))
500
+ channel = "discord";
501
+ else if (/matrix/i.test(rawLower))
502
+ channel = "matrix";
503
+ else if (/whatsapp/i.test(rawLower))
504
+ channel = "whatsapp";
505
+ else if (/signal/i.test(rawLower))
506
+ channel = "signal";
507
+ else if (/slack/i.test(rawLower))
508
+ channel = "slack";
509
+ // Only use fields.channel if it's a known channel name
510
+ if (!channel && fields.channel) {
511
+ const fc = fields.channel.toLowerCase();
512
+ if (["telegram", "discord", "matrix", "whatsapp", "signal", "slack", "irc"].includes(fc)) {
513
+ channel = fc;
514
+ }
515
+ }
516
+ // Check if openclaw is installed — use Node 22 directly (openclaw --version fails on Node 18)
517
+ const node22ForChannel = await getNode22();
518
+ const ocBinForChannel = (await runLocalCommand("readlink -f $(which openclaw) 2>/dev/null || which openclaw").catch(() => "")).trim();
519
+ const ocVer = await runLocalCommand(`${node22ForChannel} ${ocBinForChannel} --version 2>/dev/null`).catch(() => "");
520
+ if (!ocVer && !ocBinForChannel.includes("openclaw")) {
521
+ return `${cc.red}✗ OpenClaw is not installed.${cc.reset}\n ${cc.dim}Say: "install openclaw" first.${cc.reset}`;
522
+ }
523
+ const CHANNEL_INFO = {
524
+ telegram: {
525
+ name: "Telegram",
526
+ tokenFlag: "--token",
527
+ browserUrl: "https://t.me/BotFather",
528
+ instructions: [
529
+ `${cc.bold}To set up Telegram:${cc.reset}`,
530
+ ` 1. Open ${cc.cyan}https://t.me/BotFather${cc.reset} in your browser`,
531
+ ` 2. Send ${cc.bold}/newbot${cc.reset} and follow the prompts`,
532
+ ` 3. Copy the bot token (looks like ${cc.dim}123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11${cc.reset})`,
533
+ ` 4. Paste it here when prompted`,
534
+ ],
535
+ },
536
+ discord: {
537
+ name: "Discord",
538
+ tokenFlag: "--token",
539
+ browserUrl: "https://discord.com/developers/applications",
540
+ instructions: [
541
+ `${cc.bold}To set up Discord:${cc.reset}`,
542
+ ` 1. Open ${cc.cyan}https://discord.com/developers/applications${cc.reset}`,
543
+ ` 2. Click ${cc.bold}New Application${cc.reset} → name it → go to ${cc.bold}Bot${cc.reset} tab`,
544
+ ` 3. Click ${cc.bold}Reset Token${cc.reset} and copy the bot token`,
545
+ ` 4. Under ${cc.bold}Privileged Gateway Intents${cc.reset}, enable ${cc.bold}Message Content Intent${cc.reset}`,
546
+ ` 5. Go to ${cc.bold}OAuth2 > URL Generator${cc.reset}, select ${cc.bold}bot${cc.reset} scope + ${cc.bold}Send Messages${cc.reset} permission`,
547
+ ` 6. Copy the invite URL and open it to add the bot to your server`,
548
+ ` 7. Paste the bot token here when prompted`,
549
+ ],
550
+ },
551
+ matrix: {
552
+ name: "Matrix",
553
+ tokenFlag: "--password",
554
+ instructions: [
555
+ `${cc.bold}To set up Matrix:${cc.reset}`,
556
+ ` ${cc.dim}Matrix can run locally — no external account needed.${cc.reset}`,
557
+ ``,
558
+ ` ${cc.bold}Option A: Use an existing Matrix account${cc.reset}`,
559
+ ` You'll need: homeserver URL, user ID, and password`,
560
+ ``,
561
+ ` ${cc.bold}Option B: Create a free account${cc.reset}`,
562
+ ` 1. Open ${cc.cyan}https://app.element.io/#/register${cc.reset}`,
563
+ ` 2. Create an account on matrix.org (or any homeserver)`,
564
+ ` 3. Use those credentials here`,
565
+ ],
566
+ browserUrl: "https://app.element.io/#/register",
567
+ },
568
+ whatsapp: {
569
+ name: "WhatsApp",
570
+ tokenFlag: "",
571
+ loginBased: true,
572
+ instructions: [
573
+ `${cc.bold}To set up WhatsApp:${cc.reset}`,
574
+ ` WhatsApp uses QR code pairing — no token needed.`,
575
+ ` OpenClaw will show a QR code to scan with your phone.`,
576
+ ],
577
+ },
578
+ signal: {
579
+ name: "Signal",
580
+ tokenFlag: "",
581
+ loginBased: true,
582
+ instructions: [
583
+ `${cc.bold}To set up Signal:${cc.reset}`,
584
+ ` Signal requires signal-cli to be installed.`,
585
+ ` Run: ${cc.cyan}openclaw channels add --channel signal${cc.reset}`,
586
+ ],
587
+ },
588
+ slack: {
589
+ name: "Slack",
590
+ tokenFlag: "--bot-token",
591
+ browserUrl: "https://api.slack.com/apps",
592
+ instructions: [
593
+ `${cc.bold}To set up Slack:${cc.reset}`,
594
+ ` 1. Open ${cc.cyan}https://api.slack.com/apps${cc.reset}`,
595
+ ` 2. Create a new app → add Bot Token Scopes`,
596
+ ` 3. Install to workspace and copy the bot token (xoxb-...)`,
597
+ ` 4. Also copy the app token (xapp-...) from Basic Information`,
598
+ ],
599
+ },
600
+ };
601
+ // No channel specified — show menu
602
+ if (!channel) {
603
+ const lines = [
604
+ `\n${cc.bold}${cc.cyan}── OpenClaw Channel Setup ──${cc.reset}\n`,
605
+ ` Available channels:\n`,
606
+ ` ${cc.cyan}1.${cc.reset} ${cc.bold}Telegram${cc.reset} — Bot via BotFather (easiest)`,
607
+ ` ${cc.cyan}2.${cc.reset} ${cc.bold}Discord${cc.reset} — Bot via Developer Portal`,
608
+ ` ${cc.cyan}3.${cc.reset} ${cc.bold}Matrix${cc.reset} — Can run locally, no external account needed`,
609
+ ` ${cc.cyan}4.${cc.reset} ${cc.bold}WhatsApp${cc.reset} — QR code pairing with your phone`,
610
+ ` ${cc.cyan}5.${cc.reset} ${cc.bold}Signal${cc.reset} — Via signal-cli`,
611
+ ` ${cc.cyan}6.${cc.reset} ${cc.bold}Slack${cc.reset} — Bot via Slack API`,
612
+ ``,
613
+ ` ${cc.dim}Say: "setup telegram" or "setup discord" to configure a channel.${cc.reset}`,
614
+ ];
615
+ return lines.join("\n");
616
+ }
617
+ const info = CHANNEL_INFO[channel];
618
+ if (!info) {
619
+ return `${cc.red}✗ Unknown channel: "${channel}"${cc.reset}\n ${cc.dim}Available: ${Object.keys(CHANNEL_INFO).join(", ")}${cc.reset}`;
620
+ }
621
+ // Show instructions
622
+ console.log("");
623
+ for (const line of info.instructions)
624
+ console.log(line);
625
+ console.log("");
626
+ // Open browser to the setup page
627
+ if (info.browserUrl) {
628
+ console.log(`${cc.cyan}Opening ${info.browserUrl} in your browser...${cc.reset}\n`);
629
+ try {
630
+ if (process.platform === "win32") {
631
+ await runLocalCommand(`powershell -Command "Start-Process '${info.browserUrl}'" 2>/dev/null`);
632
+ }
633
+ else if (process.platform === "darwin") {
634
+ await runLocalCommand(`open "${info.browserUrl}" 2>/dev/null`);
635
+ }
636
+ else {
637
+ await runLocalCommand(`xdg-open "${info.browserUrl}" 2>/dev/null || wslview "${info.browserUrl}" 2>/dev/null`);
638
+ }
639
+ }
640
+ catch { /* browser open is best-effort */ }
641
+ }
642
+ // Login-based channels (WhatsApp, Signal) — just run openclaw's interactive login
643
+ if (info.loginBased) {
644
+ console.log(`${cc.cyan}Starting ${info.name} pairing...${cc.reset}\n`);
645
+ try {
646
+ const { execSync } = await import("node:child_process");
647
+ execSync(`openclaw channels login --channel ${channel}`, { stdio: "inherit", timeout: 120_000 });
648
+ // Verify
649
+ const status = await runLocalCommand(`openclaw channels status 2>&1`).catch(() => "");
650
+ if (status.toLowerCase().includes(channel)) {
651
+ return `${cc.green}✓${cc.reset} ${info.name} connected to OpenClaw!`;
652
+ }
653
+ return `${cc.yellow}⚠${cc.reset} ${info.name} pairing may still be in progress.\n ${cc.dim}Check: "openclaw channels status"${cc.reset}`;
654
+ }
655
+ catch {
656
+ return `${cc.yellow}⚠${cc.reset} ${info.name} pairing timed out or was cancelled.\n ${cc.dim}Try manually: openclaw channels login --channel ${channel}${cc.reset}`;
657
+ }
658
+ }
659
+ // Token-based channels — prompt for the token
660
+ const { askForConfirmation: confirm } = await import("../policy/confirm.js");
661
+ const readline = await import("node:readline/promises");
662
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
663
+ try {
664
+ if (channel === "matrix") {
665
+ // Matrix needs homeserver, user ID, and password
666
+ const homeserver = await rl.question(`${cc.cyan}Homeserver URL${cc.reset} (e.g. https://matrix.org): `);
667
+ const userId = await rl.question(`${cc.cyan}User ID${cc.reset} (e.g. @mybot:matrix.org): `);
668
+ const password = await rl.question(`${cc.cyan}Password${cc.reset}: `);
669
+ if (!homeserver.trim() || !userId.trim() || !password.trim()) {
670
+ return `${cc.yellow}⚠ Setup cancelled — missing required fields.${cc.reset}`;
671
+ }
672
+ console.log(`\n${cc.cyan}Configuring Matrix...${cc.reset}`);
673
+ const addOut = await withSpinner("Adding Matrix channel...", () => runLocalCommand(`openclaw channels add --channel matrix --homeserver "${homeserver.trim()}" --user-id "${userId.trim()}" --password "${password.trim()}" 2>&1`, 30_000));
674
+ // Restart gateway to pick up new channel
675
+ await runLocalCommand("openclaw gateway reload 2>&1").catch(() => "");
676
+ const status = await runLocalCommand("openclaw channels status 2>&1").catch(() => "");
677
+ if (status.toLowerCase().includes("matrix")) {
678
+ return `${cc.green}✓${cc.reset} Matrix channel configured!\n ${cc.dim}Homeserver: ${homeserver.trim()}${cc.reset}\n ${cc.dim}User: ${userId.trim()}${cc.reset}\n\n ${cc.dim}Send a message to the bot in Matrix to test.${cc.reset}`;
679
+ }
680
+ return `${cc.yellow}⚠${cc.reset} Matrix added but may need gateway restart.\n ${cc.dim}Try: "restart openclaw"${cc.reset}\n\n${cc.dim}${addOut.substring(0, 300)}${cc.reset}`;
681
+ }
682
+ else if (channel === "slack") {
683
+ // Slack needs bot token + app token
684
+ const botToken = await rl.question(`${cc.cyan}Bot token${cc.reset} (xoxb-...): `);
685
+ const appToken = await rl.question(`${cc.cyan}App token${cc.reset} (xapp-...): `);
686
+ if (!botToken.trim() || !appToken.trim()) {
687
+ return `${cc.yellow}⚠ Setup cancelled — missing required tokens.${cc.reset}`;
688
+ }
689
+ console.log(`\n${cc.cyan}Configuring Slack...${cc.reset}`);
690
+ const addOut = await withSpinner("Adding Slack channel...", () => runLocalCommand(`openclaw channels add --channel slack --bot-token "${botToken.trim()}" --app-token "${appToken.trim()}" 2>&1`, 30_000));
691
+ await runLocalCommand("openclaw gateway reload 2>&1").catch(() => "");
692
+ return `${cc.green}✓${cc.reset} Slack channel configured!\n ${cc.dim}Test it by messaging the bot in Slack.${cc.reset}`;
693
+ }
694
+ else {
695
+ // Discord — try automated bot creation via Patchright first
696
+ if (channel === "discord") {
697
+ console.log(`\n${cc.cyan}Attempting automated Discord bot setup...${cc.reset}\n`);
698
+ try {
699
+ const { createDiscordBot, ensurePatchright } = await import("../automation/discordPatchright.js");
700
+ if (ensurePatchright()) {
701
+ const result = await createDiscordBot("OpenClaw");
702
+ if (result.success && result.token) {
703
+ console.log(`${cc.green}✓ Bot created automatically!${cc.reset}`);
704
+ console.log(`\n${cc.cyan}Adding to OpenClaw...${cc.reset}`);
705
+ await withSpinner("Adding Discord channel...", () => runLocalCommand(`openclaw channels add --channel discord --token "${result.token}" 2>&1`, 30_000));
706
+ await runLocalCommand("openclaw gateway reload 2>&1").catch(() => "");
707
+ const status = await runLocalCommand("openclaw channels status 2>&1").catch(() => "");
708
+ if (status.toLowerCase().includes("discord")) {
709
+ return [
710
+ `${cc.green}✓${cc.reset} Discord bot created and connected to OpenClaw!`,
711
+ ` ${cc.dim}App ID: ${result.appId}${cc.reset}`,
712
+ `\n ${cc.bold}Next:${cc.reset} Invite the bot to your Discord server:`,
713
+ ` ${cc.cyan}https://discord.com/oauth2/authorize?client_id=${result.appId}&scope=bot&permissions=2048${cc.reset}`,
714
+ `\n ${cc.dim}Then send a message in a channel the bot can see to test.${cc.reset}`,
715
+ ].join("\n");
716
+ }
717
+ return `${cc.green}✓${cc.reset} Discord bot created (App: ${result.appId}).\n ${cc.yellow}⚠${cc.reset} Gateway may need restart: "restart openclaw"`;
718
+ }
719
+ }
720
+ }
721
+ catch (autoErr) {
722
+ console.log(`${cc.yellow}⚠ Automated setup failed — falling back to manual token entry.${cc.reset}`);
723
+ console.log(`${cc.dim} ${autoErr.message?.split("\n")[0] ?? ""}${cc.reset}\n`);
724
+ }
725
+ }
726
+ // Manual token entry — Telegram, Discord (fallback)
727
+ const tokenLabel = channel === "telegram" ? "Bot token from BotFather" : "Bot token";
728
+ const token = await rl.question(`${cc.cyan}${tokenLabel}${cc.reset}: `);
729
+ if (!token.trim()) {
730
+ return `${cc.yellow}⚠ Setup cancelled — no token provided.${cc.reset}`;
731
+ }
732
+ console.log(`\n${cc.cyan}Configuring ${info.name}...${cc.reset}`);
733
+ const addOut = await withSpinner(`Adding ${info.name} channel...`, () => runLocalCommand(`openclaw channels add --channel ${channel} ${info.tokenFlag} "${token.trim()}" 2>&1`, 30_000));
734
+ await runLocalCommand("openclaw gateway reload 2>&1").catch(() => "");
735
+ const status = await runLocalCommand("openclaw channels status 2>&1").catch(() => "");
736
+ if (status.toLowerCase().includes(channel)) {
737
+ const lines = [`${cc.green}✓${cc.reset} ${info.name} channel configured!`];
738
+ if (channel === "telegram") {
739
+ lines.push(`\n ${cc.dim}Send a message to your bot in Telegram to test.${cc.reset}`);
740
+ lines.push(` ${cc.dim}Then try: "tell openclaw hello" to see it respond.${cc.reset}`);
741
+ }
742
+ else if (channel === "discord") {
743
+ lines.push(`\n ${cc.dim}Make sure you've invited the bot to a server.${cc.reset}`);
744
+ lines.push(` ${cc.dim}Send a message in a channel the bot can see to test.${cc.reset}`);
745
+ }
746
+ return lines.join("\n");
747
+ }
748
+ return `${cc.yellow}⚠${cc.reset} ${info.name} added but may need gateway restart.\n ${cc.dim}Try: "restart openclaw"${cc.reset}\n\n${cc.dim}${addOut.substring(0, 300)}${cc.reset}`;
749
+ }
750
+ }
751
+ finally {
752
+ rl.close();
753
+ }
754
+ }
755
+ // Codex message — send a prompt to OpenAI Codex CLI
756
+ if (intent.intent === "codex.message") {
757
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
758
+ // Check if codex is installed
759
+ const codexVer = await runLocalCommand("codex --version 2>/dev/null").catch(() => "");
760
+ if (!codexVer) {
761
+ return `${cc.red}✗ Codex CLI is not installed.${cc.reset}\n ${cc.dim}Say: "install codex" first.${cc.reset}`;
762
+ }
763
+ // Check if authenticated — if not, auto-launch login
764
+ const authCheck = await runLocalCommand("codex login status 2>&1").catch(() => "");
765
+ if (!authCheck || /not logged/i.test(authCheck)) {
766
+ console.log(`${cc.yellow}⚠ Codex is not authenticated. Opening browser to log in...${cc.reset}`);
767
+ try {
768
+ const { execSync } = await import("node:child_process");
769
+ execSync("codex login", { stdio: "inherit", timeout: 120_000 });
770
+ // Verify login succeeded
771
+ const recheck = await runLocalCommand("codex login status 2>&1").catch(() => "");
772
+ if (/not logged/i.test(recheck)) {
773
+ return `${cc.red}✗ Authentication failed. Please try again with ${cc.bold}codex login${cc.reset}`;
774
+ }
775
+ console.log(`${cc.green}✓ Codex authenticated successfully.${cc.reset}\n`);
776
+ }
777
+ catch {
778
+ return `${cc.red}✗ Authentication was cancelled or timed out.${cc.reset}\n Run ${cc.bold}codex login${cc.reset} manually to authenticate.`;
779
+ }
780
+ }
781
+ // Extract the message from the raw text
782
+ const msgMatch = intent.rawText.match(/(?:tell|ask|message|say(?:\s+hello)?\s+to|send|talk\s+to)\s+codex\s+(.*)/i);
783
+ const message = msgMatch?.[1]?.trim() || fields.message || intent.rawText;
784
+ console.log(`${cc.dim}Sending to Codex: "${message}"${cc.reset}`);
785
+ try {
786
+ const codexOut = await runLocalCommand(`codex exec ${JSON.stringify(message)} 2>&1`, 120_000);
787
+ if (codexOut.trim()) {
788
+ return `\n${cc.bold}${cc.cyan}Codex:${cc.reset} ${codexOut.trim()}`;
789
+ }
790
+ return `${cc.yellow}⚠ Codex returned no output.${cc.reset}`;
791
+ }
792
+ catch (err) {
793
+ return `${cc.red}✗ ${err.message.split("\n")[0]}${cc.reset}`;
794
+ }
795
+ }
796
+ // OpenClaw message — send to the targeted env
797
+ if (intent.intent === "openclaw.message") {
798
+ const msgMatch = intent.rawText.match(/(?:tell|ask|message|say to|send)\s+(?:openclaw|claw)\s+(.*)/i);
799
+ const message = msgMatch?.[1]?.trim() || fields.message || intent.rawText;
800
+ console.log(`\x1b[2mSending to OpenClaw: "${message}"\x1b[0m`);
801
+ try {
802
+ const agentCmd = `openclaw agent --agent main --message ${JSON.stringify(message)} --json`;
803
+ const agentOut = await runOcCmd(agentCmd, ocTarget, ocEnv, 90_000);
804
+ const jsonStart = agentOut.indexOf("{");
805
+ if (jsonStart >= 0) {
806
+ const json = JSON.parse(agentOut.substring(jsonStart));
807
+ const reply = json?.result?.payloads?.[0]?.text ?? json?.reply ?? json?.text;
808
+ if (reply)
809
+ return `\n\x1b[1m\x1b[36mOpenClaw:\x1b[0m ${reply}`;
810
+ }
811
+ return agentOut.substring(0, 500);
812
+ }
813
+ catch (err) {
814
+ return `\x1b[31m✗ ${err.message.split("\n")[0]}\x1b[0m`;
815
+ }
816
+ }
817
+ // OpenClaw model — check or switch LLM on targeted env
818
+ if (intent.intent === "openclaw.model") {
819
+ const skipWords = new Set(["openclaw", "model", "llm", "to", "the", "set", "switch", "change", "use", "using", "which", "what", "is", "on", "windows", "wsl", "linux", "host", "both", "other", "one", "side"]);
820
+ const words = intent.rawText.toLowerCase().split(/\s+/).filter((w) => !skipWords.has(w) && w.length > 1);
821
+ const lastWord = words[words.length - 1];
822
+ const lastTwo = words.slice(-2).join(" ");
823
+ let requestedModel = MODEL_ALIASES[lastTwo] ?? MODEL_ALIASES[lastWord ?? ""] ?? undefined;
824
+ if (!requestedModel) {
825
+ const m = intent.rawText.match(/([\w-]+\/[\w.-]+)/);
826
+ if (m)
827
+ requestedModel = m[1];
828
+ }
829
+ if (requestedModel) {
830
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
831
+ // If switching to an Ollama model, validate it meets OpenClaw requirements
832
+ if (requestedModel.startsWith("ollama/")) {
833
+ const ollamaModelName = requestedModel.replace("ollama/", "");
834
+ const OPENCLAW_MIN_CTX = 16_000;
835
+ // Check if Ollama is running
836
+ const ollamaUp = await runLocalCommand("curl -sf http://localhost:11434/api/tags 2>/dev/null").catch(() => "");
837
+ if (!ollamaUp.includes("models")) {
838
+ return `${cc.red}✗ Ollama is not running.${cc.reset}\n ${cc.dim}Start it: "start ollama"${cc.reset}`;
839
+ }
840
+ // Performance check — GPU, WSL, and cross-environment benchmarking
841
+ const hasGpu = !!(await runLocalCommand("nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null").catch(() => "")).trim();
842
+ const checkIsWSL = (await runLocalCommand("grep -qi microsoft /proc/version 2>/dev/null && echo wsl || echo native").catch(() => "native")).trim() === "wsl";
843
+ const ollamaPs = await runLocalCommand("curl -sf http://localhost:11434/api/ps 2>/dev/null").catch(() => "{}");
844
+ const usingVram = ollamaPs.includes('"size_vram":') && !ollamaPs.includes('"size_vram":0');
845
+ const perfWarnings = [];
846
+ if (!hasGpu) {
847
+ perfWarnings.push(`${cc.yellow}⚠ No CUDA GPU detected — Ollama will use CPU only.${cc.reset}`);
848
+ perfWarnings.push(` ${cc.dim}OpenClaw agent calls may take 3-5 minutes on CPU.${cc.reset}`);
849
+ }
850
+ else if (!usingVram) {
851
+ perfWarnings.push(`${cc.yellow}⚠ GPU available but Ollama is not using VRAM.${cc.reset}`);
852
+ perfWarnings.push(` ${cc.dim}Check CUDA drivers or restart Ollama.${cc.reset}`);
853
+ }
854
+ if (checkIsWSL) {
855
+ perfWarnings.push(`${cc.yellow}⚠ Running in WSL — filesystem bridge adds latency for model loading.${cc.reset}`);
856
+ if (!hasGpu) {
857
+ perfWarnings.push(` ${cc.dim}Recommend using Claude/Codex for OpenClaw (fast, cloud-hosted).${cc.reset}`);
858
+ perfWarnings.push(` ${cc.dim}Ollama works best for notoken's own LLM fallback (simpler prompts).${cc.reset}`);
859
+ }
860
+ }
861
+ if (perfWarnings.length > 0) {
862
+ console.log(`\n${cc.bold}Performance check:${cc.reset}`);
863
+ for (const w of perfWarnings)
864
+ console.log(` ${w}`);
865
+ console.log(` ${cc.dim}Estimated response time: ${hasGpu ? "5-15s" : checkIsWSL ? "3-5 min" : "1-3 min"}${cc.reset}\n`);
866
+ }
867
+ // Load model database for context window info
868
+ let modelDb = {};
869
+ try {
870
+ const { readFileSync, existsSync } = await import("node:fs");
871
+ const { resolve } = await import("node:path");
872
+ for (const p of [resolve(process.cwd(), "packages/core/config/ollama-models.json"), resolve(process.cwd(), "config/ollama-models.json")]) {
873
+ if (existsSync(p)) {
874
+ modelDb = JSON.parse(readFileSync(p, "utf-8")).models ?? {};
875
+ break;
876
+ }
877
+ }
878
+ }
879
+ catch { /* */ }
880
+ const modelInfo = modelDb[ollamaModelName] ?? modelDb[ollamaModelName.split(":")[0]];
881
+ const ctxWindow = modelInfo?.context ?? 0;
882
+ // Check if model is installed
883
+ const installed = ollamaUp.includes(`"${ollamaModelName}"`) || ollamaUp.includes(`"${ollamaModelName}:latest"`) || ollamaUp.includes(`"${ollamaModelName}:`);
884
+ if (ctxWindow > 0 && ctxWindow < OPENCLAW_MIN_CTX) {
885
+ // Model exists in our DB and context is too small
886
+ const compatible = Object.entries(modelDb)
887
+ .filter(([_, m]) => m.context >= OPENCLAW_MIN_CTX)
888
+ .sort((a, b) => a[1].sizeGB - b[1].sizeGB);
889
+ const lines = [`\n${cc.red}✗ ${ollamaModelName} has ${ctxWindow.toLocaleString()} token context — OpenClaw needs at least ${OPENCLAW_MIN_CTX.toLocaleString()}.${cc.reset}\n`];
890
+ if (compatible.length > 0) {
891
+ lines.push(` ${cc.bold}Compatible models:${cc.reset}`);
892
+ for (const [name, m] of compatible.slice(0, 5)) {
893
+ const isInstalled = ollamaUp.includes(`"${name}"`);
894
+ lines.push(` ${isInstalled ? cc.green + "✓" : cc.dim + "○"}${cc.reset} ${cc.bold}${name}${cc.reset} — ${m.context.toLocaleString()} ctx, ${m.sizeGB}GB ${isInstalled ? cc.dim + "(installed)" + cc.reset : ""}`);
895
+ }
896
+ const best = compatible[0][0];
897
+ lines.push(`\n ${cc.dim}Try: "switch openclaw to ${best}"${cc.reset}`);
898
+ // Suggest pulling if not installed
899
+ const bestInstalled = ollamaUp.includes(`"${best}"`);
900
+ if (!bestInstalled) {
901
+ lines.push(` ${cc.dim}Or: "ollama pull ${best}" first, then switch${cc.reset}`);
902
+ suggestAction({ action: `ollama pull ${best}`, description: `Pull ${best} for OpenClaw`, type: "intent" });
903
+ }
904
+ }
905
+ return lines.join("\n");
906
+ }
907
+ // Model not in DB or context OK — check if installed
908
+ if (!installed) {
909
+ // Check disk space before suggesting pull
910
+ const dfOut = await runLocalCommand("df -BG / | tail -1 | awk '{print $4}'").catch(() => "0G");
911
+ const freeGB = parseInt(dfOut);
912
+ const needsGB = modelInfo?.sizeGB ?? 4;
913
+ const lines = [`\n${cc.yellow}⚠ ${ollamaModelName} is not installed in Ollama.${cc.reset}\n`];
914
+ if (modelInfo) {
915
+ lines.push(` ${cc.bold}${modelInfo.name}${cc.reset} — ${modelInfo.parameters}, ${modelInfo.sizeGB}GB download`);
916
+ lines.push(` Context: ${modelInfo.context.toLocaleString()} tokens ${modelInfo.context >= OPENCLAW_MIN_CTX ? cc.green + "✓ OK for OpenClaw" + cc.reset : cc.red + "✗ too small" + cc.reset}`);
917
+ }
918
+ // Check if models should be moved to another drive first
919
+ if (freeGB < needsGB + 5) {
920
+ lines.push(`\n ${cc.yellow}⚠ Only ${freeGB}GB free. Consider moving Ollama models first:${cc.reset}`);
921
+ lines.push(` ${cc.dim} "move ollama models to /mnt/d/ollama"${cc.reset}`);
922
+ }
923
+ lines.push(`\n ${cc.bold}I can pull it for you.${cc.reset} Want me to do that?`);
924
+ suggestAction({ action: `ollama pull ${ollamaModelName}`, description: `Pull ${ollamaModelName} then switch OpenClaw`, type: "intent" });
925
+ return lines.join("\n");
926
+ }
927
+ }
928
+ // If Ollama model — ensure provider auth is registered first
929
+ if (requestedModel.startsWith("ollama/")) {
930
+ console.log(`${cc.dim}Registering Ollama provider auth...${cc.reset}`);
931
+ // Use expect to non-interactively register Ollama auth token
932
+ const expectAvailable = await runLocalCommand("which expect 2>/dev/null").catch(() => "");
933
+ const node22 = await getNode22();
934
+ const ocBin = (await runLocalCommand("readlink -f $(which openclaw) 2>/dev/null || which openclaw").catch(() => "openclaw")).trim();
935
+ if (expectAvailable) {
936
+ await runLocalCommand(`expect -c '
937
+ set timeout 10
938
+ spawn ${node22} ${ocBin} models auth paste-token --provider ollama
939
+ expect "Paste token"
940
+ send "ollama-local\\r"
941
+ expect eof
942
+ ' 2>&1`, 15_000).catch(() => "");
943
+ }
944
+ else {
945
+ // Fallback: write auth profile directly to the auth-profiles.json
946
+ try {
947
+ const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
948
+ const authFile = `${process.env.HOME}/.openclaw/agents/main/agent/auth-profiles.json`;
949
+ let profiles = {};
950
+ if (existsSync(authFile))
951
+ profiles = JSON.parse(readFileSync(authFile, "utf-8"));
952
+ if (!profiles["ollama:manual"]) {
953
+ profiles["ollama:manual"] = { provider: "ollama", mode: "token", token: "ollama-local", created: new Date().toISOString() };
954
+ writeFileSync(authFile, JSON.stringify(profiles, null, 2));
955
+ console.log(`${cc.green}✓${cc.reset} Ollama auth profile written`);
956
+ }
957
+ }
958
+ catch { /* */ }
959
+ }
960
+ }
961
+ const switchCmd = `openclaw models set "${requestedModel}"`;
962
+ result = await withSpinner(`Switching to ${requestedModel}...`, () => runOcCmd(switchCmd, ocTarget, ocEnv));
963
+ // If Ollama model — also restart gateway so it picks up the new config
964
+ if (requestedModel.startsWith("ollama/")) {
965
+ console.log(`${cc.dim}Restarting gateway to pick up Ollama model...${cc.reset}`);
966
+ await runLocalCommand("pkill -f openclaw-gateway 2>/dev/null").catch(() => "");
967
+ await runLocalCommand("sleep 2");
968
+ const node22 = await getNode22();
969
+ const ocBin = (await runLocalCommand("readlink -f $(which openclaw) 2>/dev/null || which openclaw").catch(() => "openclaw")).trim();
970
+ const ocConfig = await runLocalCommand("cat /root/.openclaw/openclaw.json 2>/dev/null || echo '{}'").catch(() => "{}");
971
+ const ollamaEnv = ocConfig.includes('"ollama/') ? 'OLLAMA_API_KEY="ollama-local" OLLAMA_HOST="http://localhost:11434" ' : "";
972
+ await runLocalCommand(`bash -c '${ollamaEnv}nohup ${node22} ${ocBin} gateway --force --allow-unconfigured > /tmp/openclaw-start.log 2>&1 &'`).catch(() => "");
973
+ // Wait for health
974
+ for (let i = 0; i < 8; i++) {
975
+ await runLocalCommand("sleep 1");
976
+ const health = await runLocalCommand("curl -sf http://127.0.0.1:18789/health 2>/dev/null").catch(() => "");
977
+ if (health.includes('"ok"'))
978
+ break;
979
+ }
980
+ }
981
+ return result + `\n${cc.green}✓${cc.reset} OpenClaw now using: ${cc.bold}${requestedModel}${cc.reset}`;
982
+ }
983
+ result = await withSpinner("Checking model...", () => runOcCmd("openclaw models status --plain", ocTarget, ocEnv));
984
+ return `\n\x1b[1m\x1b[36m── OpenClaw LLM ──\x1b[0m\n\n Current: \x1b[32m${result.trim()}\x1b[0m\n\n Switch: opus, sonnet, haiku, gpt-4o, codex, gemini, llama, ollama\n \x1b[2mSay: "switch openclaw to sonnet" or "switch openclaw to sonnet on windows"\x1b[0m`;
985
+ }
986
+ // OpenClaw service management (start/stop/restart) — environment-aware
987
+ if (intent.intent === "openclaw.start" || intent.intent === "openclaw.stop" || intent.intent === "openclaw.restart") {
988
+ const action = intent.intent.split(".")[1];
989
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
990
+ const targetEnv = ocTarget ?? (ocEnv?.inWSL ? "wsl" : (process.platform === "win32" ? "windows" : "wsl"));
991
+ // ── Native Windows actions (not WSL) — must come before WSL-to-Windows host block ──
992
+ const isNativeWin = process.platform === "win32" && !ocEnv?.inWSL;
993
+ if (isNativeWin) {
994
+ if (action === "stop" || action === "restart") {
995
+ await runLocalCommand(`powershell -Command "Get-WmiObject Win32_Process -Filter \\"Name='node.exe'\\" | Where-Object { \\$_.CommandLine -match 'openclaw.*gateway' } | ForEach-Object { \\$_.Terminate() }" 2>/dev/null`).catch(() => "");
996
+ await runLocalCommand("sleep 2").catch(() => "");
997
+ if (action === "stop") {
998
+ const check = await runLocalCommand(`powershell -Command "Get-WmiObject Win32_Process -Filter \\"Name='node.exe'\\" | Where-Object { \\$_.CommandLine -match 'openclaw.*gateway' } | Select-Object ProcessId" 2>/dev/null`).catch(() => "");
999
+ return check && /\d+/.test(check)
1000
+ ? `${cc.yellow}⚠ Gateway may still be running.${cc.reset}`
1001
+ : `${cc.green}✓${cc.reset} OpenClaw gateway stopped.`;
1002
+ }
1003
+ }
1004
+ if (action === "start" || action === "restart") {
1005
+ const ocPath = (await runLocalCommand("npm config get prefix 2>/dev/null").catch(() => "")).trim();
1006
+ const ocEntry = ocPath ? `${ocPath}\\node_modules\\openclaw\\dist\\index.js` : "openclaw";
1007
+ const userHome = process.env.USERPROFILE || "";
1008
+ const ocConfigBash = `$(cygpath '${userHome}\\.openclaw\\openclaw.json')`;
1009
+ const ocConfig = await runLocalCommand(`cat "${ocConfigBash}" 2>/dev/null || echo '{}'`).catch(() => "{}");
1010
+ const isOllamaModel = ocConfig.includes('"ollama/');
1011
+ const envVars = isOllamaModel ? '$env:OLLAMA_API_KEY="ollama-local"; $env:OLLAMA_HOST="http://localhost:11434"; ' : "";
1012
+ await runLocalCommand(`powershell -Command "${envVars}Start-Process -FilePath node -ArgumentList '${ocEntry}','gateway','--force','--allow-unconfigured' -WindowStyle Hidden" 2>/dev/null`).catch(() => "");
1013
+ let healthy = false;
1014
+ for (let i = 0; i < 10; i++) {
1015
+ await runLocalCommand("sleep 1").catch(() => { });
1016
+ const health = await runLocalCommand("curl -sf http://127.0.0.1:18789/health 2>/dev/null").catch(() => "");
1017
+ if (health.includes('"ok"')) {
1018
+ healthy = true;
1019
+ break;
1020
+ }
1021
+ }
1022
+ const modelName = ocConfig.match(/"primary"\s*:\s*"([^"]+)"/)?.[1] ?? "unknown";
1023
+ return healthy
1024
+ ? `${cc.green}✓${cc.reset} OpenClaw gateway ${action}ed.\n ${cc.bold}Model:${cc.reset} ${modelName}\n ${cc.bold}Health:${cc.reset} ${cc.green}http://127.0.0.1:18789${cc.reset}\n ${cc.dim}TUI: openclaw tui | Chat: "tell openclaw hello"${cc.reset}`
1025
+ : `${cc.yellow}⚠${cc.reset} Gateway ${action}ing but not healthy yet.\n\n ${cc.dim}Check: "is openclaw running"${cc.reset}`;
1026
+ }
1027
+ }
1028
+ // ── Windows host actions (from WSL) ──
1029
+ if (targetEnv === "windows" || targetEnv === "both") {
1030
+ if (!ocEnv?.inWSL) {
1031
+ if (targetEnv === "windows")
1032
+ return `${cc.yellow}⚠ Not in WSL — can't target Windows host.${cc.reset}`;
1033
+ }
1034
+ else {
1035
+ const winLabel = targetEnv === "both" ? `${cc.bold}${cc.cyan}[Windows]${cc.reset} ` : "";
1036
+ if (action === "stop" || action === "restart") {
1037
+ // Find and kill OpenClaw node processes on Windows
1038
+ const killOut = await runLocalCommand(`/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "Get-WmiObject Win32_Process -Filter \\"Name='node.exe'\\" | Where { \\$_.CommandLine -match 'openclaw' } | ForEach { \\$_.Terminate() }" 2>/dev/null`).catch(() => "");
1039
+ await runLocalCommand("sleep 2");
1040
+ if (action === "stop") {
1041
+ // Verify stopped
1042
+ const checkPs = await runLocalCommand(`/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "Get-WmiObject Win32_Process -Filter \\"Name='node.exe'\\" | Select -Exp CommandLine" 2>/dev/null`).catch(() => "");
1043
+ const stillRunning = checkPs.includes("openclaw");
1044
+ if (targetEnv !== "both") {
1045
+ return stillRunning
1046
+ ? `${cc.yellow}⚠ Windows OpenClaw may still be running.${cc.reset}`
1047
+ : `${cc.green}✓${cc.reset} OpenClaw stopped on Windows host.`;
1048
+ }
1049
+ console.log(stillRunning
1050
+ ? `${winLabel}${cc.yellow}⚠ May still be running${cc.reset}`
1051
+ : `${winLabel}${cc.green}✓${cc.reset} Stopped`);
1052
+ }
1053
+ }
1054
+ if (action === "start" || action === "restart") {
1055
+ // Start OpenClaw on Windows host via PowerShell
1056
+ await runLocalCommand(`/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "Start-Process node -ArgumentList 'C:\\Users\\Dino\\AppData\\Roaming\\npm\\node_modules\\openclaw\\dist\\index.js','gateway','--force','--allow-unconfigured' -WindowStyle Hidden" 2>/dev/null`).catch(() => "");
1057
+ // Wait for health
1058
+ let winHealthy = false;
1059
+ for (let i = 0; i < 8; i++) {
1060
+ await runLocalCommand("sleep 1");
1061
+ const health = await runLocalCommand("curl -sf http://127.0.0.1:18789/health 2>/dev/null").catch(() => "");
1062
+ if (health.includes('"ok"')) {
1063
+ winHealthy = true;
1064
+ break;
1065
+ }
1066
+ }
1067
+ // Get Windows model
1068
+ const winConfig = await runLocalCommand("cmd.exe /c 'type \"%USERPROFILE%\\.openclaw\\openclaw.json\"' 2>/dev/null").catch(() => "");
1069
+ const winModel = winConfig.match(/"primary"\s*:\s*"([^"]+)"/)?.[1] ?? "unknown";
1070
+ if (targetEnv !== "both") {
1071
+ return winHealthy
1072
+ ? `${cc.green}✓${cc.reset} OpenClaw gateway ${action}ed on Windows host.\n ${cc.bold}Model:${cc.reset} ${winModel}\n ${cc.bold}Health:${cc.reset} ${cc.green}http://127.0.0.1:18789${cc.reset}`
1073
+ : `${cc.yellow}⚠${cc.reset} Windows gateway ${action}ing but not healthy yet.\n ${cc.dim}Check: "is openclaw running on windows"${cc.reset}`;
1074
+ }
1075
+ console.log(winHealthy
1076
+ ? `${winLabel}${cc.green}✓${cc.reset} ${action}ed — model: ${winModel}`
1077
+ : `${winLabel}${cc.yellow}⚠${cc.reset} ${action}ing but not healthy yet`);
1078
+ }
1079
+ }
1080
+ }
1081
+ // ── WSL / Linux actions ──
1082
+ if (!isNativeWin && (targetEnv === "wsl" || targetEnv === "both")) {
1083
+ const wslLabel = targetEnv === "both" ? `${cc.bold}${cc.cyan}[WSL]${cc.reset} ` : "";
1084
+ if (action === "stop" || action === "restart") {
1085
+ await runLocalCommand("pkill -f openclaw-gateway 2>/dev/null").catch(() => "");
1086
+ await runLocalCommand("sleep 1");
1087
+ if (action === "stop") {
1088
+ const check = await runLocalCommand("pgrep -f openclaw-gateway 2>/dev/null").catch(() => "");
1089
+ const msg = check ? `${cc.yellow}⚠ Still running (PID ${check.trim()})${cc.reset}` : `${cc.green}✓${cc.reset} Stopped`;
1090
+ if (targetEnv !== "both")
1091
+ return `${msg.replace("Stopped", "OpenClaw gateway stopped.")}`;
1092
+ console.log(`${wslLabel}${msg}`);
1093
+ if (targetEnv === "both")
1094
+ return ""; // both sides reported above
1095
+ }
1096
+ }
1097
+ if (action === "start" || action === "restart") {
1098
+ // Check if Windows gateway already owns port 18789
1099
+ if (targetEnv === "wsl") {
1100
+ const portCheck = await runLocalCommand("curl -sf http://127.0.0.1:18789/health 2>/dev/null").catch(() => "");
1101
+ if (portCheck.includes('"ok"')) {
1102
+ const hostPs = await runLocalCommand(`/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "Get-WmiObject Win32_Process -Filter \\"Name='node.exe'\\" | Select -Exp CommandLine" 2>/dev/null`).catch(() => "");
1103
+ if (hostPs.includes("openclaw") && hostPs.includes("gateway")) {
1104
+ return `${cc.yellow}⚠${cc.reset} Port 18789 is in use by Windows host OpenClaw.\n ${cc.dim}Stop it first: "stop openclaw on windows"\n Or use the Windows gateway: "tell openclaw hello"${cc.reset}`;
1105
+ }
1106
+ }
1107
+ }
1108
+ const node22 = await getNode22();
1109
+ const ocPath = (await runLocalCommand("readlink -f $(which openclaw) 2>/dev/null || which openclaw").catch(() => "openclaw")).trim();
1110
+ const ocConfig = await runLocalCommand("cat /root/.openclaw/openclaw.json 2>/dev/null || echo '{}'").catch(() => "{}");
1111
+ const isOllamaModel = ocConfig.includes('"ollama/');
1112
+ const ollamaEnv = isOllamaModel ? 'OLLAMA_API_KEY="ollama-local" OLLAMA_HOST="http://localhost:11434" ' : "";
1113
+ const startCmd = `bash -c '${ollamaEnv}nohup ${node22} ${ocPath} gateway --force --allow-unconfigured > /tmp/openclaw-start.log 2>&1 & echo $!'`;
1114
+ await runLocalCommand(startCmd).catch(() => "");
1115
+ let healthy = false;
1116
+ for (let i = 0; i < 8; i++) {
1117
+ await runLocalCommand("sleep 1");
1118
+ const health = await runLocalCommand("curl -sf http://127.0.0.1:18789/health 2>/dev/null").catch(() => "");
1119
+ if (health.includes('"ok"')) {
1120
+ healthy = true;
1121
+ break;
1122
+ }
1123
+ }
1124
+ const configModel = await runLocalCommand("grep -o '\"primary\":\"[^\"]*\"' /root/.openclaw/openclaw.json 2>/dev/null").catch(() => "");
1125
+ const modelName = configModel.match(/"primary":"([^"]+)"/)?.[1] ?? "unknown";
1126
+ if (healthy) {
1127
+ const msg = `${cc.green}✓${cc.reset} OpenClaw gateway ${action}ed.\n ${cc.bold}Model:${cc.reset} ${modelName}\n ${cc.bold}Health:${cc.reset} ${cc.green}http://127.0.0.1:18789${cc.reset}\n ${cc.dim}TUI: openclaw tui | Chat: "tell openclaw hello"${cc.reset}`;
1128
+ if (targetEnv !== "both")
1129
+ return msg;
1130
+ console.log(`${wslLabel}${cc.green}✓${cc.reset} ${action}ed — model: ${modelName}`);
1131
+ }
1132
+ else {
1133
+ const logs = await runLocalCommand("cat /tmp/openclaw-start.log 2>/dev/null | tail -5").catch(() => "");
1134
+ const msg = `${cc.yellow}⚠${cc.reset} Gateway ${action}ing but not healthy yet.\n${cc.dim}${logs}${cc.reset}\n\n ${cc.dim}Check: "is openclaw running"${cc.reset}`;
1135
+ if (targetEnv !== "both")
1136
+ return msg;
1137
+ console.log(`${wslLabel}${cc.yellow}⚠${cc.reset} not healthy yet`);
1138
+ }
1139
+ }
1140
+ if (targetEnv === "both" && action === "stop")
1141
+ return "";
1142
+ }
1143
+ return "";
1144
+ }
1145
+ // Notoken model — check or switch LLM backend
1146
+ if (intent.intent === "notoken.model") {
1147
+ const { getLLMBackend } = await import("../nlp/llmFallback.js");
1148
+ const switchMatch = intent.rawText.match(/(?:switch|change|use)\s+(?:notoken\s+(?:to\s+)?|(?:to\s+)?)(\S+)/i);
1149
+ const target = (fields.model?.trim() || switchMatch?.[1])?.toLowerCase();
1150
+ if (target && ["claude", "ollama", "chatgpt", "codex"].includes(target)) {
1151
+ if (target === "codex") {
1152
+ // Verify codex is installed
1153
+ try {
1154
+ await runLocalCommand("codex --version");
1155
+ }
1156
+ catch {
1157
+ return `\x1b[31m✗ Codex CLI not found.\x1b[0m\n\x1b[2m Install: "install codex" or npm install -g @openai/codex\x1b[0m`;
1158
+ }
1159
+ process.env.NOTOKEN_LLM_CLI = "codex";
1160
+ delete process.env.NOTOKEN_LLM_ENDPOINT;
1161
+ }
1162
+ else if (target === "chatgpt") {
1163
+ process.env.NOTOKEN_LLM_CLI = "";
1164
+ process.env.NOTOKEN_LLM_ENDPOINT = "https://api.openai.com/v1/chat/completions";
1165
+ }
1166
+ else {
1167
+ process.env.NOTOKEN_LLM_CLI = target;
1168
+ delete process.env.NOTOKEN_LLM_ENDPOINT;
1169
+ }
1170
+ return `\x1b[32m✓\x1b[0m Notoken now using: \x1b[1m${target}\x1b[0m\n\x1b[2mSet NOTOKEN_LLM_CLI=${target} to make permanent.\x1b[0m`;
1171
+ }
1172
+ const backend = getLLMBackend();
1173
+ const ollamaUp = await runLocalCommand("curl -sf http://localhost:11434/api/tags 2>/dev/null | head -1").catch(() => "");
1174
+ const codexOk = await runLocalCommand("codex --version 2>/dev/null").catch(() => "");
1175
+ return `\n\x1b[1m\x1b[36m── Notoken LLM ──\x1b[0m\n\n Current: ${backend ? `\x1b[32m${backend}\x1b[0m` : "\x1b[33mnone\x1b[0m"}\n\n Available:\n ${await runLocalCommand("which claude 2>/dev/null").catch(() => "") ? "\x1b[32m✓" : "\x1b[2m○"}\x1b[0m claude\n ${ollamaUp.includes("models") ? "\x1b[32m✓" : "\x1b[2m○"}\x1b[0m ollama\n ${process.env.OPENAI_API_KEY ? "\x1b[32m✓" : "\x1b[2m○"}\x1b[0m chatgpt\n ${codexOk ? "\x1b[32m✓" : "\x1b[2m○"}\x1b[0m codex\n\n \x1b[2mSwitch: "use claude", "use ollama", "use codex"\x1b[0m`;
1176
+ }
1177
+ // Notoken status — comprehensive overview
1178
+ // notoken.jobs — in one-shot mode just say "use interactive mode"
1179
+ if (intent.intent === "notoken.jobs") {
1180
+ return `\x1b[32m✓\x1b[0m No background tasks (one-shot mode).\n\x1b[2m Run \x1b[1mnotoken\x1b[0m\x1b[2m for interactive mode with background task support.\x1b[0m`;
1181
+ }
1182
+ if (intent.intent === "notoken.status") {
1183
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m", magenta: "\x1b[35m" };
1184
+ const lines = [];
1185
+ lines.push(`\n${cc.bold}${cc.cyan}══════════════════════════════════════${cc.reset}`);
1186
+ lines.push(`${cc.bold}${cc.cyan} Notoken — Status${cc.reset}`);
1187
+ lines.push(`${cc.bold}${cc.cyan}══════════════════════════════════════${cc.reset}\n`);
1188
+ // CLI version
1189
+ const cliVer = "1.7.0"; // from package.json
1190
+ lines.push(` ${cc.bold}CLI:${cc.reset} ${cc.green}✓${cc.reset} notoken v${cliVer}`);
1191
+ // Desktop app detection
1192
+ const isWSL = (await runLocalCommand("grep -qi microsoft /proc/version 2>/dev/null && echo wsl || echo native").catch(() => "native")).trim() === "wsl";
1193
+ let appInstalled = false;
1194
+ let appPath = "";
1195
+ if (isWSL) {
1196
+ // Check Windows Program Files for Notoken app
1197
+ const winCheck = await runLocalCommand("cmd.exe /c 'where notoken-app 2>nul || dir /s /b \"C:\\Program Files\\Notoken\\notoken-app.exe\" 2>nul || dir /s /b \"%LOCALAPPDATA%\\Programs\\Notoken\\notoken-app.exe\" 2>nul' 2>/dev/null").catch(() => "");
1198
+ appInstalled = winCheck.includes("notoken");
1199
+ appPath = winCheck.trim().split("\n")[0] || "";
1200
+ }
1201
+ else {
1202
+ // Check Linux — look for AppImage or installed binary
1203
+ const linuxCheck = await runLocalCommand("which notoken-app 2>/dev/null || ls ~/Applications/Notoken*.AppImage 2>/dev/null || ls /opt/notoken/notoken-app 2>/dev/null").catch(() => "");
1204
+ appInstalled = !!linuxCheck.trim();
1205
+ appPath = linuxCheck.trim().split("\n")[0] || "";
1206
+ }
1207
+ if (appInstalled) {
1208
+ lines.push(` ${cc.bold}App:${cc.reset} ${cc.green}✓${cc.reset} Notoken Desktop ${cc.dim}(${appPath})${cc.reset}`);
1209
+ }
1210
+ else {
1211
+ lines.push(` ${cc.bold}App:${cc.reset} ${cc.dim}○ Notoken Desktop not installed${cc.reset}`);
1212
+ lines.push(` ${cc.dim}Get it: "install notoken app" or ${cc.cyan}https://notoken.sh/download${cc.reset}`);
1213
+ }
1214
+ // LLM Backend
1215
+ const { getLLMBackend } = await import("../nlp/llmFallback.js");
1216
+ const backend = getLLMBackend();
1217
+ lines.push(` ${cc.bold}LLM:${cc.reset} ${backend ? `${cc.green}✓${cc.reset} ${backend}` : `${cc.yellow}○${cc.reset} none configured`}`);
1218
+ // Environment
1219
+ lines.push(`\n ${cc.bold}Environment:${cc.reset} ${isWSL ? "WSL" : "Linux"}`);
1220
+ // Components
1221
+ lines.push(`\n ${cc.bold}Components:${cc.reset}`);
1222
+ const claudeVer = await runLocalCommand("claude --version 2>/dev/null | head -1").catch(() => "");
1223
+ lines.push(` ${claudeVer ? `${cc.green}✓` : `${cc.dim}○`}${cc.reset} Claude Code ${claudeVer ? cc.dim + claudeVer.trim() + cc.reset : ""}`);
1224
+ const codexVer = await runLocalCommand("codex --version 2>/dev/null | head -1").catch(() => "");
1225
+ lines.push(` ${codexVer ? `${cc.green}✓` : `${cc.dim}○`}${cc.reset} Codex CLI ${codexVer ? cc.dim + codexVer.trim() + cc.reset : ""}`);
1226
+ const ollamaVer = await runLocalCommand("ollama --version 2>/dev/null | head -1").catch(() => "");
1227
+ const ollamaUp = await runLocalCommand("curl -sf http://localhost:11434/api/tags 2>/dev/null | head -1").catch(() => "");
1228
+ lines.push(` ${ollamaVer ? `${cc.green}✓` : `${cc.dim}○`}${cc.reset} Ollama ${ollamaVer ? (ollamaUp.includes("models") ? cc.green + "running" + cc.reset : cc.yellow + "stopped" + cc.reset) : ""}`);
1229
+ const ocVer = await runLocalCommand(`bash -c '${nvmPfx} openclaw --version 2>/dev/null | head -1'`).catch(() => "");
1230
+ const ocUp = await runLocalCommand("curl -sf http://127.0.0.1:18789/health 2>/dev/null").catch(() => "");
1231
+ lines.push(` ${ocVer ? `${cc.green}✓` : `${cc.dim}○`}${cc.reset} OpenClaw ${ocVer ? (ocUp.includes('"ok"') ? cc.green + "running" + cc.reset : cc.yellow + "stopped" + cc.reset) : ""}`);
1232
+ const dockerVer = await runLocalCommand("docker --version 2>/dev/null | head -1").catch(() => "");
1233
+ lines.push(` ${dockerVer ? `${cc.green}✓` : `${cc.dim}○`}${cc.reset} Docker ${dockerVer ? cc.dim + dockerVer.trim().replace("Docker version ", "v") + cc.reset : ""}`);
1234
+ // Interfaces
1235
+ lines.push(`\n ${cc.bold}Interfaces:${cc.reset}`);
1236
+ lines.push(` ${cc.green}✓${cc.reset} ${cc.bold}CLI${cc.reset} — type commands or natural language in terminal`);
1237
+ lines.push(` ${appInstalled ? `${cc.green}✓` : `${cc.dim}○`}${cc.reset} ${cc.bold}Desktop App${cc.reset} — point-and-click GUI + chat ${appInstalled ? "" : cc.dim + "(install: \"install notoken app\")" + cc.reset}`);
1238
+ if (ocUp.includes('"ok"')) {
1239
+ lines.push(` ${cc.green}✓${cc.reset} ${cc.bold}OpenClaw Chat${cc.reset} — messaging via Telegram/Discord/Matrix/TUI`);
1240
+ }
1241
+ lines.push(`\n ${cc.dim}Website: https://notoken.sh${cc.reset}`);
1242
+ lines.push(` ${cc.dim}Say: "install notoken app", "how to install claude", "ollama status"${cc.reset}`);
1243
+ return lines.join("\n");
1244
+ }
1245
+ // Notoken desktop app — download and install
1246
+ if (intent.intent === "notoken.install_app") {
1247
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
1248
+ const isWSL = (await runLocalCommand("grep -qi microsoft /proc/version 2>/dev/null && echo wsl || echo native").catch(() => "native")).trim() === "wsl";
1249
+ const isWin = process.platform === "win32";
1250
+ const isMac = process.platform === "darwin";
1251
+ const lines = [];
1252
+ lines.push(`\n${cc.bold}${cc.cyan}── Notoken Desktop App ──${cc.reset}\n`);
1253
+ lines.push(` ${cc.bold}Notoken${cc.reset} — point-and-click GUI + chat interface`);
1254
+ lines.push(` Everything the CLI does, but with a visual interface.\n`);
1255
+ const baseUrl = "https://notoken.sh/download";
1256
+ if (isWSL || isWin) {
1257
+ // Windows — download .exe installer
1258
+ const arch = await runLocalCommand("cmd.exe /c 'echo %PROCESSOR_ARCHITECTURE%' 2>/dev/null").catch(() => "AMD64");
1259
+ const archLabel = arch.trim().replace(/\r/g, "").includes("ARM") ? "arm64" : "x64";
1260
+ const exeUrl = `${baseUrl}/notoken-setup-win-${archLabel}.exe`;
1261
+ lines.push(` ${cc.bold}Platform:${cc.reset} Windows ${archLabel}`);
1262
+ lines.push(` ${cc.bold}Download:${cc.reset} ${cc.cyan}${exeUrl}${cc.reset}\n`);
1263
+ // Try to download and run
1264
+ const downloadDir = isWSL
1265
+ ? await runLocalCommand("cmd.exe /c 'echo %USERPROFILE%\\Downloads' 2>/dev/null").catch(() => "C:\\Users\\Downloads")
1266
+ : `${process.env.USERPROFILE || "C:\\Users"}\\Downloads`;
1267
+ const downloadPath = downloadDir.trim().replace(/\r/g, "");
1268
+ const installerName = `notoken-setup-win-${archLabel}.exe`;
1269
+ lines.push(` ${cc.bold}Installing...${cc.reset}`);
1270
+ console.log(lines.join("\n"));
1271
+ try {
1272
+ if (isWSL) {
1273
+ // Download via PowerShell on Windows host
1274
+ const psCmd = `powershell.exe -Command "Invoke-WebRequest -Uri '${exeUrl}' -OutFile '${downloadPath}\\\\${installerName}' -UseBasicParsing"`;
1275
+ await withSpinner("Downloading...", () => runLocalCommand(psCmd, 120_000));
1276
+ console.log(`\n ${cc.green}✓${cc.reset} Downloaded to ${cc.bold}${downloadPath}\\${installerName}${cc.reset}`);
1277
+ // Launch the installer
1278
+ await runLocalCommand(`cmd.exe /c 'start "" "${downloadPath}\\\\${installerName}"' 2>/dev/null`).catch(() => "");
1279
+ return `\n ${cc.green}✓${cc.reset} Installer launched. Follow the setup wizard.\n\n ${cc.dim}After install, you can launch Notoken from the Start menu\n or run: notoken-app${cc.reset}`;
1280
+ }
1281
+ else {
1282
+ // Native Windows
1283
+ const psCmd = `powershell -Command "Invoke-WebRequest -Uri '${exeUrl}' -OutFile '$env:TEMP\\${installerName}' -UseBasicParsing; Start-Process '$env:TEMP\\${installerName}'"`;
1284
+ await withSpinner("Downloading and launching installer...", () => runLocalCommand(psCmd, 120_000));
1285
+ return `\n ${cc.green}✓${cc.reset} Installer launched. Follow the setup wizard.`;
1286
+ }
1287
+ }
1288
+ catch (err) {
1289
+ return `\n ${cc.yellow}⚠${cc.reset} Auto-download failed. Download manually:\n ${cc.cyan}${exeUrl}${cc.reset}\n\n ${cc.dim}Or open in browser: "open ${baseUrl}"${cc.reset}`;
1290
+ }
1291
+ }
1292
+ else if (isMac) {
1293
+ const archOut = await runLocalCommand("uname -m").catch(() => "x86_64");
1294
+ const archLabel = archOut.trim() === "arm64" ? "arm64" : "x64";
1295
+ const dmgUrl = `${baseUrl}/notoken-setup-mac-${archLabel}.dmg`;
1296
+ lines.push(` ${cc.bold}Platform:${cc.reset} macOS ${archLabel}`);
1297
+ lines.push(` ${cc.bold}Download:${cc.reset} ${cc.cyan}${dmgUrl}${cc.reset}\n`);
1298
+ try {
1299
+ await withSpinner("Downloading...", () => runLocalCommand(`curl -fSL -o /tmp/notoken-setup.dmg "${dmgUrl}" 2>&1`, 120_000));
1300
+ await runLocalCommand("open /tmp/notoken-setup.dmg").catch(() => "");
1301
+ return lines.join("\n") + `\n ${cc.green}✓${cc.reset} Downloaded and opened. Drag Notoken to Applications.`;
1302
+ }
1303
+ catch {
1304
+ return lines.join("\n") + `\n ${cc.yellow}⚠${cc.reset} Download manually: ${cc.cyan}${dmgUrl}${cc.reset}`;
1305
+ }
1306
+ }
1307
+ else {
1308
+ // Linux — AppImage
1309
+ const archOut = await runLocalCommand("uname -m").catch(() => "x86_64");
1310
+ const archLabel = archOut.trim() === "aarch64" ? "arm64" : "x64";
1311
+ const appImageUrl = `${baseUrl}/notoken-app-linux-${archLabel}.AppImage`;
1312
+ lines.push(` ${cc.bold}Platform:${cc.reset} Linux ${archLabel}`);
1313
+ lines.push(` ${cc.bold}Download:${cc.reset} ${cc.cyan}${appImageUrl}${cc.reset}\n`);
1314
+ try {
1315
+ const appDir = `${process.env.HOME}/Applications`;
1316
+ await runLocalCommand(`mkdir -p "${appDir}"`);
1317
+ await withSpinner("Downloading...", () => runLocalCommand(`curl -fSL -o "${appDir}/Notoken.AppImage" "${appImageUrl}" 2>&1`, 120_000));
1318
+ await runLocalCommand(`chmod +x "${appDir}/Notoken.AppImage"`);
1319
+ return lines.join("\n") + `\n ${cc.green}✓${cc.reset} Installed to ${cc.bold}${appDir}/Notoken.AppImage${cc.reset}\n\n ${cc.dim}Run: ~/Applications/Notoken.AppImage${cc.reset}`;
1320
+ }
1321
+ catch {
1322
+ return lines.join("\n") + `\n ${cc.yellow}⚠${cc.reset} Download manually: ${cc.cyan}${appImageUrl}${cc.reset}`;
1323
+ }
1324
+ }
1325
+ }
1326
+ // Ollama model management
1327
+ if (intent.intent === "ollama.models" || intent.intent === "ollama.list") {
1328
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
1329
+ // Get system resources
1330
+ const ramOut = await runLocalCommand("free -b | grep Mem | awk '{print $2}'").catch(() => "0");
1331
+ const totalRAMGB = Math.round(parseInt(ramOut) / 1073741824);
1332
+ const freeRamOut = await runLocalCommand("free -b | grep Mem | awk '{print $7}'").catch(() => "0");
1333
+ const freeRAMGB = Math.round(parseInt(freeRamOut) / 1073741824);
1334
+ // Get installed models
1335
+ const installed = await runLocalCommand("ollama list 2>&1").catch(() => "Ollama not running");
1336
+ const lines = [];
1337
+ lines.push(`\n${cc.bold}${cc.cyan}── Ollama Models ──${cc.reset}\n`);
1338
+ lines.push(` ${cc.bold}System:${cc.reset} ${totalRAMGB}GB RAM (${freeRAMGB}GB available)\n`);
1339
+ // Load model database
1340
+ let modelDb = {};
1341
+ try {
1342
+ const { readFileSync, existsSync } = await import("node:fs");
1343
+ const { resolve } = await import("node:path");
1344
+ for (const p of [resolve(process.cwd(), "packages/core/config/ollama-models.json"), resolve(process.cwd(), "config/ollama-models.json")]) {
1345
+ if (existsSync(p)) {
1346
+ modelDb = JSON.parse(readFileSync(p, "utf-8")).models ?? {};
1347
+ break;
1348
+ }
1349
+ }
1350
+ }
1351
+ catch { /* no model db */ }
1352
+ lines.push(` ${cc.bold}Installed:${cc.reset}`);
1353
+ if (installed.includes("NAME")) {
1354
+ for (const line of installed.split("\n").slice(1).filter(Boolean)) {
1355
+ const parts = line.trim().split(/\s+/);
1356
+ const name = parts[0];
1357
+ const size = parts[2] ? `${parts[2]} ${parts[3] ?? ""}`.trim() : "";
1358
+ const info = modelDb[name] ?? modelDb[name.split(":")[0]];
1359
+ lines.push(` ${cc.green}✓${cc.reset} ${cc.bold}${name}${cc.reset} ${cc.dim}${size}${cc.reset}${info ? ` ${info.description}` : ""}`);
1360
+ }
1361
+ }
1362
+ else {
1363
+ lines.push(` ${cc.dim}No models installed.${cc.reset}`);
1364
+ }
1365
+ // Recommend models based on RAM
1366
+ const recommended = Object.entries(modelDb).filter(([_, m]) => m.recRAMGB <= totalRAMGB && m.tier !== "frontier");
1367
+ if (recommended.length > 0) {
1368
+ lines.push(`\n ${cc.bold}Recommended for your system (${totalRAMGB}GB RAM):${cc.reset}`);
1369
+ for (const [name, m] of recommended.slice(0, 6)) {
1370
+ const canRun = m.minRAMGB <= freeRAMGB ? `${cc.green}✓ can run now${cc.reset}` :
1371
+ m.minRAMGB <= totalRAMGB ? `${cc.yellow}⚠ may need to close other apps${cc.reset}` :
1372
+ `${cc.red}✗ not enough RAM${cc.reset}`;
1373
+ lines.push(` ${cc.cyan}${name.padEnd(20)}${cc.reset} ${m.parameters.padEnd(8)} ${m.sizeGB}GB ${canRun} ${cc.dim}${m.description.substring(0, 50)}${cc.reset}`);
1374
+ }
1375
+ }
1376
+ // Models that are too big
1377
+ const tooBig = Object.entries(modelDb).filter(([_, m]) => m.minRAMGB > totalRAMGB);
1378
+ if (tooBig.length > 0) {
1379
+ lines.push(`\n ${cc.dim}Too large for this system: ${tooBig.map(([n]) => n).join(", ")}${cc.reset}`);
1380
+ }
1381
+ lines.push(`\n ${cc.dim}Pull: "ollama pull llama3.2" or "ollama pull codellama"${cc.reset}`);
1382
+ return lines.join("\n");
1383
+ }
1384
+ if (intent.intent === "ollama.pull") {
1385
+ const model = fields.model ?? intent.rawText.match(/pull\s+(\S+)/)?.[1] ?? "llama3.2";
1386
+ // Load model info
1387
+ let modelInfo = null;
1388
+ try {
1389
+ const { readFileSync, existsSync } = await import("node:fs");
1390
+ const { resolve } = await import("node:path");
1391
+ for (const p of [resolve(process.cwd(), "packages/core/config/ollama-models.json"), resolve(process.cwd(), "config/ollama-models.json")]) {
1392
+ if (existsSync(p)) {
1393
+ const db = JSON.parse(readFileSync(p, "utf-8")).models ?? {};
1394
+ modelInfo = db[model] ?? db[model.split(":")[0]];
1395
+ break;
1396
+ }
1397
+ }
1398
+ }
1399
+ catch { /* */ }
1400
+ // Check resources
1401
+ const dfOut = await runLocalCommand("df -BG / | tail -1 | awk '{print $4}'").catch(() => "0G");
1402
+ const freeGB = parseInt(dfOut);
1403
+ const ramOut = await runLocalCommand("free -b | grep Mem | awk '{print $7}'").catch(() => "0");
1404
+ const freeRAMGB = Math.round(parseInt(ramOut) / 1073741824);
1405
+ const lines = [];
1406
+ if (modelInfo) {
1407
+ lines.push(`\n\x1b[1m\x1b[36m── ${modelInfo.name} ──\x1b[0m\n`);
1408
+ lines.push(` Provider: ${modelInfo.provider}`);
1409
+ lines.push(` Parameters: ${modelInfo.parameters}`);
1410
+ lines.push(` Download: ${modelInfo.sizeGB}GB`);
1411
+ lines.push(` RAM needed: ${modelInfo.minRAMGB}GB min, ${modelInfo.recRAMGB}GB recommended`);
1412
+ lines.push(` Context: ${modelInfo.context.toLocaleString()} tokens`);
1413
+ lines.push(` Capabilities: ${modelInfo.capabilities.join(", ")}`);
1414
+ lines.push(` ${modelInfo.description}\n`);
1415
+ // Resource check — auto-detect other drives for more space
1416
+ if (modelInfo.sizeGB > freeGB || freeGB < modelInfo.sizeGB + 5) {
1417
+ // Check if models can be moved to another drive
1418
+ const pullIsWSL = (await runLocalCommand("grep -qi microsoft /proc/version 2>/dev/null && echo wsl || echo native").catch(() => "native")).trim() === "wsl";
1419
+ let altDrive = "";
1420
+ let altFreeGB = 0;
1421
+ if (pullIsWSL) {
1422
+ for (const drive of ["/mnt/d", "/mnt/e", "/mnt/f"]) {
1423
+ const altDf = await runLocalCommand(`df -BG "${drive}" 2>/dev/null | tail -1 | awk '{print $4}'`).catch(() => "0G");
1424
+ const altFree = parseInt(altDf);
1425
+ if (altFree > altFreeGB) {
1426
+ altFreeGB = altFree;
1427
+ altDrive = drive;
1428
+ }
1429
+ }
1430
+ }
1431
+ if (modelInfo.sizeGB > freeGB) {
1432
+ lines.push(` \x1b[31m✗ Not enough disk space: need ${modelInfo.sizeGB}GB, only ${freeGB}GB free.\x1b[0m`);
1433
+ if (altDrive && altFreeGB >= modelInfo.sizeGB + 5) {
1434
+ lines.push(` \x1b[33m→ ${altDrive} has ${altFreeGB}GB free. Move models there first:\x1b[0m`);
1435
+ lines.push(` \x1b[2m "move ollama models to ${altDrive}/ollama"\x1b[0m`);
1436
+ suggestAction({ action: `move ollama models to ${altDrive}/ollama`, description: `Move Ollama models to ${altDrive} then pull ${model}`, type: "intent" });
1437
+ }
1438
+ else {
1439
+ lines.push(` \x1b[2m Run "free up space" to make room.\x1b[0m`);
1440
+ }
1441
+ return lines.join("\n");
1442
+ }
1443
+ // Tight on space — warn and suggest move
1444
+ if (altDrive && altFreeGB > freeGB * 2) {
1445
+ lines.push(` \x1b[33m⚠ Space is tight (${freeGB}GB free). Consider moving models to ${altDrive} (${altFreeGB}GB free):\x1b[0m`);
1446
+ lines.push(` \x1b[2m "move ollama models to ${altDrive}/ollama"\x1b[0m`);
1447
+ }
1448
+ }
1449
+ if (modelInfo.minRAMGB > freeRAMGB) {
1450
+ lines.push(` \x1b[33m⚠ Tight on RAM: model needs ${modelInfo.minRAMGB}GB, you have ${freeRAMGB}GB free.\x1b[0m`);
1451
+ lines.push(` \x1b[2m It may run slowly or require closing other apps.\x1b[0m`);
1452
+ }
1453
+ else {
1454
+ lines.push(` \x1b[32m✓ Resources OK: ${freeGB}GB disk, ${freeRAMGB}GB RAM available.\x1b[0m`);
1455
+ }
1456
+ }
1457
+ else if (freeGB < 5) {
1458
+ return `\x1b[31m⚠ Only ${freeGB}GB free — models typically need 2-8GB. Free up space first.\x1b[0m`;
1459
+ }
1460
+ console.log(lines.join("\n"));
1461
+ console.log(`\n\x1b[2mPulling ${model}... this may take a few minutes.\x1b[0m`);
1462
+ result = await withSpinner(`Pulling ${model}...`, () => runLocalCommand(`ollama pull ${model} 2>&1`, 300_000));
1463
+ return result;
1464
+ }
1465
+ // Ollama storage — check location & disk usage
1466
+ if (intent.intent === "ollama.storage") {
1467
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
1468
+ const lines = [];
1469
+ lines.push(`\n${cc.bold}${cc.cyan}── Ollama Storage ──${cc.reset}\n`);
1470
+ // Detect WSL vs native
1471
+ const isWSL = await runLocalCommand("grep -qi microsoft /proc/version 2>/dev/null && echo wsl || echo native").catch(() => "native");
1472
+ const inWSL = isWSL.trim() === "wsl";
1473
+ // Detect where Ollama is running
1474
+ const ollamaInWSL = await runLocalCommand("pgrep -x ollama 2>/dev/null").catch(() => "");
1475
+ const ollamaOnHost = inWSL ? await runLocalCommand("cmd.exe /c 'tasklist /FI \"IMAGENAME eq ollama.exe\" /NH' 2>/dev/null").catch(() => "") : "";
1476
+ const hostHasOllama = ollamaOnHost.includes("ollama.exe");
1477
+ if (inWSL) {
1478
+ lines.push(` ${cc.bold}Environment:${cc.reset} WSL`);
1479
+ if (ollamaInWSL)
1480
+ lines.push(` ${cc.green}✓${cc.reset} Ollama running ${cc.bold}inside WSL${cc.reset} (PID: ${ollamaInWSL.trim().split("\n")[0]})`);
1481
+ else
1482
+ lines.push(` ${cc.dim}○ Ollama not running inside WSL${cc.reset}`);
1483
+ if (hostHasOllama)
1484
+ lines.push(` ${cc.green}✓${cc.reset} Ollama running on ${cc.bold}Windows host${cc.reset}`);
1485
+ else
1486
+ lines.push(` ${cc.dim}○ Ollama not detected on Windows host${cc.reset}`);
1487
+ }
1488
+ else {
1489
+ lines.push(` ${cc.bold}Environment:${cc.reset} Native Linux`);
1490
+ if (ollamaInWSL)
1491
+ lines.push(` ${cc.green}✓${cc.reset} Ollama running (PID: ${ollamaInWSL.trim().split("\n")[0]})`);
1492
+ else
1493
+ lines.push(` ${cc.dim}○ Ollama not running${cc.reset}`);
1494
+ }
1495
+ // Model storage paths
1496
+ const envModels = process.env.OLLAMA_MODELS || "";
1497
+ const defaultPaths = ["/usr/share/ollama/.ollama/models", `${process.env.HOME}/.ollama/models`];
1498
+ let modelDir = envModels || "";
1499
+ if (!modelDir) {
1500
+ for (const dp of defaultPaths) {
1501
+ const exists = await runLocalCommand(`[ -d "${dp}" ] && echo yes || echo no`).catch(() => "no");
1502
+ if (exists.trim() === "yes") {
1503
+ modelDir = dp;
1504
+ break;
1505
+ }
1506
+ }
1507
+ }
1508
+ if (modelDir) {
1509
+ lines.push(`\n ${cc.bold}Model directory:${cc.reset} ${modelDir}${envModels ? ` ${cc.dim}(OLLAMA_MODELS)${cc.reset}` : ""}`);
1510
+ const usage = await runLocalCommand(`du -sh "${modelDir}" 2>/dev/null | awk '{print $1}'`).catch(() => "unknown");
1511
+ const dfOut = await runLocalCommand(`df -h "${modelDir}" 2>/dev/null | tail -1`).catch(() => "");
1512
+ lines.push(` ${cc.bold}Models size:${cc.reset} ${usage.trim()}`);
1513
+ if (dfOut) {
1514
+ const parts = dfOut.trim().split(/\s+/);
1515
+ lines.push(` ${cc.bold}Drive:${cc.reset} ${parts[0] ?? "?"} — ${parts[3] ?? "?"} free of ${parts[1] ?? "?"}`);
1516
+ }
1517
+ // List individual models
1518
+ const modelList = await runLocalCommand(`ls -1 "${modelDir}/manifests/registry.ollama.ai/library/" 2>/dev/null`).catch(() => "");
1519
+ if (modelList.trim()) {
1520
+ lines.push(`\n ${cc.bold}Stored models:${cc.reset}`);
1521
+ for (const m of modelList.trim().split("\n")) {
1522
+ const mSize = await runLocalCommand(`du -sh "${modelDir}/manifests/registry.ollama.ai/library/${m}" 2>/dev/null | awk '{print $1}'`).catch(() => "?");
1523
+ lines.push(` ${cc.green}•${cc.reset} ${m} ${cc.dim}(${mSize.trim()})${cc.reset}`);
1524
+ }
1525
+ }
1526
+ }
1527
+ else {
1528
+ lines.push(`\n ${cc.yellow}⚠ No model directory found.${cc.reset}`);
1529
+ }
1530
+ // GPU detection
1531
+ const nvidiaGpu = await runLocalCommand("nvidia-smi --query-gpu=name,memory.total,memory.used --format=csv,noheader,nounits 2>/dev/null").catch(() => "");
1532
+ if (nvidiaGpu.trim()) {
1533
+ lines.push(`\n ${cc.bold}GPU:${cc.reset}`);
1534
+ for (const gpu of nvidiaGpu.trim().split("\n")) {
1535
+ const [name, total, used] = gpu.split(",").map(s => s.trim());
1536
+ lines.push(` ${cc.green}✓${cc.reset} ${name} — ${used}MB / ${total}MB VRAM`);
1537
+ }
1538
+ }
1539
+ else {
1540
+ const intelGpu = await runLocalCommand("lspci 2>/dev/null | grep -i 'vga\\|3d\\|display'").catch(() => "");
1541
+ if (intelGpu.trim()) {
1542
+ lines.push(`\n ${cc.bold}GPU:${cc.reset}`);
1543
+ for (const g of intelGpu.trim().split("\n")) {
1544
+ const gpuName = g.replace(/^.*:\s*/, "").trim();
1545
+ lines.push(` ${cc.yellow}⚠${cc.reset} ${gpuName} ${cc.dim}(no CUDA — Ollama will use CPU)${cc.reset}`);
1546
+ }
1547
+ }
1548
+ else {
1549
+ lines.push(`\n ${cc.bold}GPU:${cc.reset} ${cc.dim}None detected — Ollama will use CPU only${cc.reset}`);
1550
+ }
1551
+ }
1552
+ // Ollama process memory usage
1553
+ if (ollamaInWSL) {
1554
+ const memUsage = await runLocalCommand("ps -p " + ollamaInWSL.trim().split("\n")[0] + " -o rss= 2>/dev/null").catch(() => "");
1555
+ if (memUsage.trim()) {
1556
+ const rssKB = parseInt(memUsage.trim());
1557
+ const rssMB = Math.round(rssKB / 1024);
1558
+ const rssGB = (rssKB / 1048576).toFixed(1);
1559
+ lines.push(`\n ${cc.bold}Memory usage:${cc.reset} ${rssMB >= 1024 ? rssGB + "GB" : rssMB + "MB"}`);
1560
+ }
1561
+ }
1562
+ // Service info
1563
+ const svcStatus = await runLocalCommand("systemctl is-active ollama 2>/dev/null").catch(() => "");
1564
+ const svcEnabled = await runLocalCommand("systemctl is-enabled ollama 2>/dev/null").catch(() => "");
1565
+ if (svcStatus.trim()) {
1566
+ const active = svcStatus.trim() === "active";
1567
+ lines.push(`\n ${cc.bold}Service:${cc.reset} ${active ? `${cc.green}active${cc.reset}` : `${cc.red}${svcStatus.trim()}${cc.reset}`}${svcEnabled.trim() === "enabled" ? ` ${cc.dim}(enabled on boot)${cc.reset}` : ""}`);
1568
+ lines.push(` ${cc.dim}Control: "start ollama", "stop ollama", "restart ollama"${cc.reset}`);
1569
+ }
1570
+ // Windows host Ollama storage (if in WSL)
1571
+ if (inWSL && hostHasOllama) {
1572
+ const winHome = await runLocalCommand("cmd.exe /c 'echo %USERPROFILE%' 2>/dev/null").catch(() => "");
1573
+ const winPath = winHome.trim().replace(/\r/g, "");
1574
+ if (winPath) {
1575
+ const wslWinPath = winPath.replace(/\\/g, "/").replace(/^([A-Z]):/i, (_, d) => `/mnt/${d.toLowerCase()}`);
1576
+ const winModelDir = `${wslWinPath}/.ollama/models`;
1577
+ const winUsage = await runLocalCommand(`du -sh "${winModelDir}" 2>/dev/null | awk '{print $1}'`).catch(() => "");
1578
+ if (winUsage.trim()) {
1579
+ lines.push(`\n ${cc.bold}Windows host models:${cc.reset} ${winModelDir}`);
1580
+ lines.push(` ${cc.bold}Size:${cc.reset} ${winUsage.trim()}`);
1581
+ }
1582
+ }
76
1583
  }
1584
+ lines.push(`\n ${cc.dim}Move models: "move ollama models to /mnt/d/ollama"${cc.reset}`);
1585
+ return lines.join("\n");
1586
+ }
1587
+ // Ollama move — relocate models to a different directory
1588
+ if (intent.intent === "ollama.move") {
1589
+ const dest = fields.destination ?? intent.rawText.match(/(?:to|→)\s+(\S+)/i)?.[1];
1590
+ if (!dest)
1591
+ return `\x1b[33mUsage: move ollama models to <path>\x1b[0m\n\x1b[2m Example: "move ollama models to /mnt/d/ollama"\x1b[0m`;
1592
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
1593
+ const lines = [];
1594
+ // Find current model dir
1595
+ const defaultPaths = ["/usr/share/ollama/.ollama/models", `${process.env.HOME}/.ollama/models`];
1596
+ let srcDir = process.env.OLLAMA_MODELS || "";
1597
+ if (!srcDir) {
1598
+ for (const dp of defaultPaths) {
1599
+ const exists = await runLocalCommand(`[ -d "${dp}" ] && echo yes || echo no`).catch(() => "no");
1600
+ if (exists.trim() === "yes") {
1601
+ srcDir = dp;
1602
+ break;
1603
+ }
1604
+ }
1605
+ }
1606
+ if (!srcDir)
1607
+ return `${cc.red}✗ Could not find Ollama model directory.${cc.reset}`;
1608
+ const usage = await runLocalCommand(`du -sh "${srcDir}" 2>/dev/null | awk '{print $1}'`).catch(() => "?");
1609
+ lines.push(`\n${cc.bold}${cc.cyan}── Move Ollama Models ──${cc.reset}\n`);
1610
+ lines.push(` ${cc.bold}From:${cc.reset} ${srcDir} (${usage.trim()})`);
1611
+ lines.push(` ${cc.bold}To:${cc.reset} ${dest}\n`);
1612
+ // Check destination drive space
1613
+ const destParent = dest.replace(/\/[^/]*$/, "") || "/";
1614
+ const dfOut = await runLocalCommand(`df -BG "${destParent}" 2>/dev/null | tail -1 | awk '{print $4}'`).catch(() => "0G");
1615
+ const freeGB = parseInt(dfOut);
1616
+ const srcSizeOut = await runLocalCommand(`du -sB1G "${srcDir}" 2>/dev/null | awk '{print $1}'`).catch(() => "0");
1617
+ const srcGB = parseInt(srcSizeOut);
1618
+ if (freeGB < srcGB + 1) {
1619
+ return `${cc.red}✗ Not enough space at ${dest}: need ~${srcGB}GB, only ${freeGB}GB free.${cc.reset}`;
1620
+ }
1621
+ lines.push(` ${cc.green}✓${cc.reset} Space OK: ${freeGB}GB free, need ~${srcGB}GB\n`);
1622
+ // Execute the move
1623
+ lines.push(` ${cc.dim}Step 1: Create destination...${cc.reset}`);
1624
+ await runLocalCommand(`mkdir -p "${dest}"`);
1625
+ lines.push(` ${cc.dim}Step 2: Copy models (this may take a while)...${cc.reset}`);
1626
+ console.log(lines.join("\n"));
1627
+ await withSpinner("Copying models...", () => runLocalCommand(`cp -a "${srcDir}/." "${dest}/" 2>&1`, 600_000));
1628
+ // Update systemd service if it exists
1629
+ const serviceFile = await runLocalCommand("systemctl cat ollama 2>/dev/null | head -1 | sed 's/^# //'").catch(() => "");
1630
+ const svcPath = serviceFile.trim();
1631
+ if (svcPath && svcPath.endsWith(".service")) {
1632
+ const hasEnv = await runLocalCommand(`grep -c OLLAMA_MODELS "${svcPath}" 2>/dev/null`).catch(() => "0");
1633
+ if (parseInt(hasEnv.trim()) === 0) {
1634
+ await runLocalCommand(`sed -i '/\\[Service\\]/a Environment="OLLAMA_MODELS=${dest}"' "${svcPath}" 2>&1`);
1635
+ }
1636
+ else {
1637
+ await runLocalCommand(`sed -i 's|OLLAMA_MODELS=.*|OLLAMA_MODELS=${dest}"|' "${svcPath}" 2>&1`);
1638
+ }
1639
+ await runLocalCommand("systemctl daemon-reload 2>&1");
1640
+ await runLocalCommand("systemctl restart ollama 2>&1");
1641
+ const verify = await runLocalCommand("systemctl is-active ollama 2>&1").catch(() => "unknown");
1642
+ return `${cc.green}✓${cc.reset} Models moved to ${cc.bold}${dest}${cc.reset}\n ${cc.green}✓${cc.reset} Service updated: OLLAMA_MODELS=${dest}\n ${cc.green}✓${cc.reset} Ollama restarted: ${verify.trim()}\n\n ${cc.dim}Old models at ${srcDir} can be removed once verified.\n Run: rm -rf "${srcDir}"${cc.reset}`;
1643
+ }
1644
+ // No systemd — set env var
1645
+ process.env.OLLAMA_MODELS = dest;
1646
+ return `${cc.green}✓${cc.reset} Models copied to ${cc.bold}${dest}${cc.reset}\n ${cc.yellow}⚠${cc.reset} Set OLLAMA_MODELS=${dest} in your environment to make permanent.\n ${cc.dim}Add to ~/.bashrc: export OLLAMA_MODELS="${dest}"\n Old models at ${srcDir} can be removed once verified.${cc.reset}`;
1647
+ }
1648
+ // Ollama service management (start/stop/restart)
1649
+ if (intent.intent === "ollama.start" || intent.intent === "ollama.stop" || intent.intent === "ollama.restart") {
1650
+ const action = intent.intent.split(".")[1]; // start, stop, restart
1651
+ const isWSL = (await runLocalCommand("grep -qi microsoft /proc/version 2>/dev/null && echo wsl || echo native").catch(() => "native")).trim() === "wsl";
1652
+ // Check if Ollama is managed by systemd (Linux/WSL service)
1653
+ const hasSystemd = (await runLocalCommand("systemctl list-unit-files ollama.service 2>/dev/null | grep -c ollama").catch(() => "0")).trim() !== "0";
1654
+ if (hasSystemd) {
1655
+ result = await withSpinner(`${action}ing Ollama service...`, () => runLocalCommand(`systemctl ${action} ollama 2>&1`, 15_000));
1656
+ const status = await runLocalCommand("systemctl is-active ollama 2>&1").catch(() => "unknown");
1657
+ return `\x1b[32m✓\x1b[0m Ollama service ${action}ed. Status: \x1b[1m${status.trim()}\x1b[0m`;
1658
+ }
1659
+ // WSL — check if running on Windows host
1660
+ if (isWSL) {
1661
+ const hostOllama = await runLocalCommand("cmd.exe /c 'tasklist /FI \"IMAGENAME eq ollama.exe\" /NH' 2>/dev/null").catch(() => "");
1662
+ if (hostOllama.includes("ollama.exe") || action === "start") {
1663
+ if (action === "stop") {
1664
+ await runLocalCommand("cmd.exe /c 'taskkill /IM ollama.exe /F' 2>/dev/null").catch(() => "");
1665
+ return `\x1b[32m✓\x1b[0m Ollama stopped on Windows host.`;
1666
+ }
1667
+ else if (action === "start") {
1668
+ await runLocalCommand("cmd.exe /c 'start \"\" \"C:\\Users\\%USERNAME%\\AppData\\Local\\Programs\\Ollama\\ollama app.exe\"' 2>/dev/null").catch(() => "");
1669
+ return `\x1b[32m✓\x1b[0m Ollama starting on Windows host...\n\x1b[2m It may take a moment to become available.\x1b[0m`;
1670
+ }
1671
+ else {
1672
+ await runLocalCommand("cmd.exe /c 'taskkill /IM ollama.exe /F' 2>/dev/null").catch(() => "");
1673
+ await runLocalCommand("cmd.exe /c 'start \"\" \"C:\\Users\\%USERNAME%\\AppData\\Local\\Programs\\Ollama\\ollama app.exe\"' 2>/dev/null").catch(() => "");
1674
+ return `\x1b[32m✓\x1b[0m Ollama restarted on Windows host.`;
1675
+ }
1676
+ }
1677
+ }
1678
+ // Fallback — try running ollama serve directly
1679
+ if (action === "start") {
1680
+ await runLocalCommand("nohup ollama serve > /dev/null 2>&1 &");
1681
+ return `\x1b[32m✓\x1b[0m Ollama server starting...\n\x1b[2m It may take a moment to become available at localhost:11434\x1b[0m`;
1682
+ }
1683
+ else if (action === "stop") {
1684
+ await runLocalCommand("pkill -x ollama 2>/dev/null").catch(() => "");
1685
+ return `\x1b[32m✓\x1b[0m Ollama stopped.`;
1686
+ }
1687
+ else {
1688
+ await runLocalCommand("pkill -x ollama 2>/dev/null").catch(() => "");
1689
+ await runLocalCommand("nohup ollama serve > /dev/null 2>&1 &");
1690
+ return `\x1b[32m✓\x1b[0m Ollama restarted.`;
1691
+ }
1692
+ }
1693
+ // Ollama remove — delete a model
1694
+ if (intent.intent === "ollama.remove") {
1695
+ const model = fields.model ?? intent.rawText.match(/(?:remove|delete|rm)\s+(?:ollama\s+(?:model\s+)?)?(\S+)/i)?.[1];
1696
+ if (!model)
1697
+ return `\x1b[33mUsage: ollama remove <model>\x1b[0m\n\x1b[2m Example: "ollama remove llama3.2"\x1b[0m`;
1698
+ result = await withSpinner(`Removing ${model}...`, () => runLocalCommand(`ollama rm ${model} 2>&1`, 30_000));
1699
+ return result.includes("deleted") ? `\x1b[32m✓\x1b[0m Model ${model} removed.` : result;
1700
+ }
1701
+ // Codex CLI handlers
1702
+ if (intent.intent === "codex.status") {
1703
+ try {
1704
+ const ver = await runLocalCommand("codex --version 2>&1");
1705
+ const apiKey = process.env.OPENAI_API_KEY ? "\x1b[32m✓ OPENAI_API_KEY set\x1b[0m" : "\x1b[33m⚠ OPENAI_API_KEY not set\x1b[0m";
1706
+ return `\x1b[32m✓\x1b[0m Codex CLI installed: \x1b[1m${ver.trim()}\x1b[0m\n ${apiKey}`;
1707
+ }
1708
+ catch {
1709
+ return `\x1b[31m✗ Codex CLI not installed.\x1b[0m\n\x1b[2m Install: "install codex" or npm install -g @openai/codex\x1b[0m`;
1710
+ }
1711
+ }
1712
+ if (intent.intent === "codex.install") {
1713
+ try {
1714
+ await runLocalCommand("codex --version");
1715
+ return `\x1b[32m✓\x1b[0m Codex CLI already installed.`;
1716
+ }
1717
+ catch { /* continue */ }
1718
+ console.log(`\x1b[2mInstalling Codex CLI...\x1b[0m`);
1719
+ result = await withSpinner("Installing Codex CLI...", () => runLocalCommand("npm install -g @openai/codex 2>&1", 120_000));
1720
+ const ver = await runLocalCommand("codex --version 2>&1").catch(() => "unknown");
1721
+ return `\x1b[32m✓\x1b[0m Codex CLI installed: ${ver.trim()}\n\x1b[2m Set OPENAI_API_KEY to use it.\x1b[0m`;
1722
+ }
1723
+ if (intent.intent === "codex.run") {
1724
+ const task = fields.task ?? intent.rawText.replace(/^(?:codex|ask codex|use codex for|codex do|run codex)\s*/i, "").trim();
1725
+ if (!task)
1726
+ return `\x1b[33mUsage: codex run <task>\x1b[0m\n\x1b[2m Example: "ask codex to refactor this function"\x1b[0m`;
1727
+ try {
1728
+ await runLocalCommand("codex --version");
1729
+ }
1730
+ catch {
1731
+ return `\x1b[31m✗ Codex CLI not found.\x1b[0m\n\x1b[2m Install: "install codex"\x1b[0m`;
1732
+ }
1733
+ console.log(`\x1b[2mRunning Codex: "${task}"\x1b[0m`);
1734
+ result = await withSpinner("Codex working...", () => runLocalCommand(`codex "${task.replace(/"/g, '\\"')}" 2>&1`, 120_000));
1735
+ return result;
1736
+ }
1737
+ // ── Node.js upgrade helper — tries multiple strategies, finds new binary even if PATH is stale ──
1738
+ async function upgradeNode(minMajor, cc, run, spin) {
1739
+ const isWin = process.platform === "win32";
1740
+ // Helper: find Node binary >= minMajor, even if not on current PATH
1741
+ async function findNodeBinary() {
1742
+ // Check current PATH first
1743
+ const ver = await run("node --version 2>/dev/null").catch(() => "");
1744
+ if (ver && parseInt(ver.replace("v", "")) >= minMajor)
1745
+ return "node";
1746
+ if (isWin) {
1747
+ // Search common Windows install locations
1748
+ const paths = [
1749
+ "C:/Program Files/nodejs/node.exe",
1750
+ "C:/Program Files (x86)/nodejs/node.exe",
1751
+ ];
1752
+ for (const p of paths) {
1753
+ const v = await run(`"${p}" --version 2>/dev/null`).catch(() => "");
1754
+ if (v && parseInt(v.replace("v", "")) >= minMajor)
1755
+ return p;
1756
+ }
1757
+ // Check nvm-windows install paths
1758
+ const nvmRoot = (await run(`powershell -Command 'Write-Output $env:NVM_HOME' 2>/dev/null`).catch(() => "")).trim();
1759
+ if (nvmRoot) {
1760
+ const found = await run(`ls -1d "${nvmRoot}"/v${minMajor}* 2>/dev/null | head -1`).catch(() => "");
1761
+ if (found.trim()) {
1762
+ const p = `${found.trim()}/node.exe`;
1763
+ const v = await run(`"${p}" --version 2>/dev/null`).catch(() => "");
1764
+ if (v && parseInt(v.replace("v", "")) >= minMajor)
1765
+ return p;
1766
+ }
1767
+ }
1768
+ }
1769
+ else {
1770
+ // Check nvm directories
1771
+ const nvmDirs = [`${process.env.HOME}/.nvm`, "/home/ino/.nvm", "/root/.nvm"];
1772
+ for (const dir of nvmDirs) {
1773
+ const found = await run(`ls -1 ${dir}/versions/node/v${minMajor}*/bin/node 2>/dev/null | tail -1`).catch(() => "");
1774
+ if (found.trim())
1775
+ return found.trim();
1776
+ }
1777
+ }
1778
+ return null;
1779
+ }
1780
+ // Step 0: Maybe it's already installed but not on PATH
1781
+ const existing = await findNodeBinary();
1782
+ if (existing) {
1783
+ if (existing !== "node") {
1784
+ const dir = existing.replace(/[/\\]node(\.exe)?$/, "");
1785
+ process.env.PATH = `${dir}${isWin ? ";" : ":"}${process.env.PATH}`;
1786
+ }
1787
+ return { ok: true, message: `Node.js ${minMajor}+ already available`, nodePath: existing };
1788
+ }
1789
+ // Step 1: Try version managers
1790
+ if (isWin) {
1791
+ // nvm-windows
1792
+ const nvmWin = await run("nvm version 2>/dev/null").catch(() => "");
1793
+ if (nvmWin && /\d+\.\d+/.test(nvmWin)) {
1794
+ await spin(`Installing Node ${minMajor} via nvm-windows...`, () => run(`nvm install ${minMajor} 2>&1`, 120_000));
1795
+ await run(`nvm use ${minMajor} 2>&1`).catch(() => "");
1796
+ const found = await findNodeBinary();
1797
+ if (found) {
1798
+ if (found !== "node") {
1799
+ const dir = found.replace(/[/\\]node(\.exe)?$/, "");
1800
+ process.env.PATH = `${dir};${process.env.PATH}`;
1801
+ }
1802
+ return { ok: true, message: `Node.js ${minMajor} installed via nvm-windows`, nodePath: found };
1803
+ }
1804
+ }
1805
+ // fnm
1806
+ const fnm = await run("fnm --version 2>/dev/null").catch(() => "");
1807
+ if (fnm && fnm.includes("fnm")) {
1808
+ await spin(`Installing Node ${minMajor} via fnm...`, () => run(`fnm install ${minMajor} && fnm use ${minMajor} 2>&1`, 120_000));
1809
+ const found = await findNodeBinary();
1810
+ if (found)
1811
+ return { ok: true, message: `Node.js ${minMajor} installed via fnm`, nodePath: found };
1812
+ }
1813
+ }
1814
+ else {
1815
+ // nvm (Linux/WSL)
1816
+ const nvmSrc = `for d in "$HOME/.nvm" "/home/"*"/.nvm" "/root/.nvm"; do [ -s "$d/nvm.sh" ] && export NVM_DIR="$d" && . "$d/nvm.sh" && break; done`;
1817
+ const nvmVer = await run(`bash -c '${nvmSrc} 2>/dev/null && nvm --version' 2>/dev/null`).catch(() => "");
1818
+ if (nvmVer && /\d+\.\d+/.test(nvmVer)) {
1819
+ await spin(`Installing Node ${minMajor} via nvm...`, () => run(`bash -c '${nvmSrc} && nvm install ${minMajor}' 2>&1`, 120_000));
1820
+ const found = await findNodeBinary();
1821
+ if (found) {
1822
+ if (found !== "node") {
1823
+ const dir = found.replace(/\/node$/, "");
1824
+ process.env.PATH = `${dir}:${process.env.PATH}`;
1825
+ }
1826
+ return { ok: true, message: `Node.js ${minMajor} installed via nvm`, nodePath: found };
1827
+ }
1828
+ }
1829
+ }
1830
+ // Step 2: No version manager found — install one, then use it
1831
+ if (isWin) {
1832
+ // Try installing nvm-windows first (doesn't require admin, allows version switching)
1833
+ console.log(`${cc.cyan}No version manager found. Installing nvm-windows...${cc.reset}`);
1834
+ try {
1835
+ const nvmInstallUrl = "https://github.com/coreybutler/nvm-windows/releases/latest/download/nvm-noinstall.zip";
1836
+ const winTemp = (await run(`powershell -Command 'Write-Output $env:TEMP' 2>/dev/null`).catch(() => "")).trim() || "C:\\Windows\\Temp";
1837
+ const nvmDir = `${process.env.APPDATA || winTemp}\\nvm`;
1838
+ const nvmZipBash = `$(cygpath '${winTemp}')/nvm-noinstall.zip`;
1839
+ const nvmDirBash = `$(cygpath '${nvmDir}')`;
1840
+ // Download nvm-windows
1841
+ const hasCurl = await run("curl --version 2>/dev/null").catch(() => "");
1842
+ if (hasCurl && hasCurl.includes("curl")) {
1843
+ await spin("Downloading nvm-windows...", () => run(`curl -fsSL -o "${nvmZipBash}" "${nvmInstallUrl}" 2>&1`, 60_000));
1844
+ }
1845
+ else {
1846
+ await spin("Downloading nvm-windows...", () => run(`powershell -Command "& { Invoke-WebRequest -Uri '${nvmInstallUrl}' -OutFile '${winTemp}\\nvm-noinstall.zip' }" 2>&1`, 60_000));
1847
+ }
1848
+ // Extract and configure
1849
+ await run(`mkdir -p "${nvmDirBash}" && unzip -o "${nvmZipBash}" -d "${nvmDirBash}" 2>&1`).catch(() => "");
1850
+ // Add to PATH for this session
1851
+ process.env.NVM_HOME = nvmDir;
1852
+ process.env.PATH = `${nvmDir};${process.env.PATH}`;
1853
+ // Verify nvm works
1854
+ const nvmCheck = await run("nvm version 2>/dev/null").catch(() => "");
1855
+ if (nvmCheck && /\d+\.\d+/.test(nvmCheck)) {
1856
+ console.log(`${cc.green}✓ nvm-windows installed${cc.reset}`);
1857
+ await spin(`Installing Node ${minMajor} via nvm-windows...`, () => run(`nvm install ${minMajor} 2>&1`, 120_000));
1858
+ await run(`nvm use ${minMajor} 2>&1`).catch(() => "");
1859
+ const found = await findNodeBinary();
1860
+ if (found) {
1861
+ if (found !== "node") {
1862
+ const dir = found.replace(/[/\\]node(\.exe)?$/, "");
1863
+ process.env.PATH = `${dir};${process.env.PATH}`;
1864
+ }
1865
+ return { ok: true, message: `Node.js ${minMajor} installed via nvm-windows`, nodePath: found };
1866
+ }
1867
+ }
1868
+ }
1869
+ catch {
1870
+ console.log(`${cc.yellow}⚠ nvm-windows install failed, trying direct Node installer...${cc.reset}`);
1871
+ }
1872
+ // Fallback: direct MSI install (requires admin)
1873
+ const adminCheck = await run(`powershell -Command "& { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) }" 2>&1`).catch(() => "False");
1874
+ if (adminCheck.trim() !== "True") {
1875
+ return { ok: false, message: `${cc.red}✗ Node.js ${minMajor}+ required but current Node is too old.${cc.reset}\n ${cc.dim}Admin privileges required to upgrade. Run as Administrator, or install manually:${cc.reset}\n ${cc.dim} • nvm-windows: https://github.com/coreybutler/nvm-windows${cc.reset}\n ${cc.dim} • Node.js: https://nodejs.org/${cc.reset}` };
1876
+ }
1877
+ // Download MSI — try curl first, fall back to PowerShell
1878
+ const msiUrl = `https://nodejs.org/dist/v${minMajor}.15.0/node-v${minMajor}.15.0-x64.msi`;
1879
+ const msiTemp = (await run(`powershell -Command 'Write-Output $env:TEMP' 2>/dev/null`).catch(() => "")).trim() || "C:\\Windows\\Temp";
1880
+ const msiWinPath = `${msiTemp}\\node${minMajor}.msi`;
1881
+ const msiBashPath = `$(cygpath '${msiTemp}')/node${minMajor}.msi`;
1882
+ const hasCurlMsi = await run("curl --version 2>/dev/null").catch(() => "");
1883
+ try {
1884
+ if (hasCurlMsi && hasCurlMsi.includes("curl")) {
1885
+ await spin(`Downloading Node ${minMajor}...`, () => run(`curl -fsSL -o "${msiBashPath}" "${msiUrl}" 2>&1`, 180_000));
1886
+ }
1887
+ else {
1888
+ await spin(`Downloading Node ${minMajor}...`, () => run(`powershell -Command "& { Invoke-WebRequest -Uri '${msiUrl}' -OutFile '${msiWinPath}' }" 2>&1`, 180_000));
1889
+ }
1890
+ }
1891
+ catch (dlErr) {
1892
+ // Download failed with first method — try the other
1893
+ console.log(`${cc.yellow}⚠ Download failed, trying alternate method...${cc.reset}`);
1894
+ try {
1895
+ if (hasCurlMsi && hasCurlMsi.includes("curl")) {
1896
+ await spin(`Downloading (PowerShell)...`, () => run(`powershell -Command "& { Invoke-WebRequest -Uri '${msiUrl}' -OutFile '${msiWinPath}' }" 2>&1`, 180_000));
1897
+ }
1898
+ else {
1899
+ await spin(`Downloading (curl)...`, () => run(`curl -fsSL -o "${msiBashPath}" "${msiUrl}" 2>&1`, 180_000));
1900
+ }
1901
+ }
1902
+ catch {
1903
+ return { ok: false, message: `${cc.red}✗ Could not download Node.js ${minMajor} installer.${cc.reset}\n ${cc.dim}Download manually: ${msiUrl}${cc.reset}` };
1904
+ }
1905
+ }
1906
+ // Install MSI
1907
+ try {
1908
+ await spin(`Installing Node ${minMajor}...`, () => run(`powershell -Command "Start-Process msiexec.exe -ArgumentList '/i','${msiWinPath}','/qn' -Wait; Remove-Item '${msiWinPath}' -ErrorAction SilentlyContinue" 2>&1`, 180_000));
1909
+ }
1910
+ catch {
1911
+ return { ok: false, message: `${cc.red}✗ MSI installer failed.${cc.reset}\n ${cc.dim}Try installing manually: ${msiUrl}${cc.reset}` };
1912
+ }
1913
+ // Find the new binary (PATH may be stale)
1914
+ const found = await findNodeBinary();
1915
+ if (found) {
1916
+ if (found !== "node") {
1917
+ const dir = found.replace(/[/\\]node(\.exe)?$/, "");
1918
+ process.env.PATH = `${dir};${process.env.PATH}`;
1919
+ }
1920
+ return { ok: true, message: `Node.js ${minMajor} installed successfully`, nodePath: found };
1921
+ }
1922
+ return { ok: false, message: `${cc.yellow}⚠ Node ${minMajor} installer ran but couldn't find the binary.${cc.reset}\n ${cc.dim}Restart your terminal, then try again.${cc.reset}` };
1923
+ }
1924
+ else {
1925
+ // Linux: install nvm + Node
1926
+ const nvmSrc = `for d in "$HOME/.nvm" "/home/"*"/.nvm" "/root/.nvm"; do [ -s "$d/nvm.sh" ] && export NVM_DIR="$d" && . "$d/nvm.sh" && break; done`;
1927
+ try {
1928
+ await spin(`Installing nvm + Node ${minMajor}...`, () => run(`curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh 2>/dev/null | bash 2>&1; bash -c '${nvmSrc} && nvm install ${minMajor}' 2>&1`, 180_000));
1929
+ }
1930
+ catch {
1931
+ return { ok: false, message: `${cc.red}✗ Failed to install nvm + Node ${minMajor}.${cc.reset}\n ${cc.dim}Install manually: curl -o- https://nvm.sh | bash && nvm install ${minMajor}${cc.reset}` };
1932
+ }
1933
+ const found = await findNodeBinary();
1934
+ if (found) {
1935
+ if (found !== "node") {
1936
+ const dir = found.replace(/\/node$/, "");
1937
+ process.env.PATH = `${dir}:${process.env.PATH}`;
1938
+ }
1939
+ return { ok: true, message: `Node.js ${minMajor} installed via nvm`, nodePath: found };
1940
+ }
1941
+ return { ok: false, message: `${cc.red}✗ Node ${minMajor} installed but not found on PATH.${cc.reset}\n ${cc.dim}Restart your terminal, then try again.${cc.reset}` };
1942
+ }
1943
+ }
1944
+ // Shared tool install registry
1945
+ const INSTALL_INFO = {
1946
+ claude: { name: "Claude Code CLI", install: "npm install -g @anthropic-ai/claude-code", check: "claude --version", description: "Anthropic's Claude Code — AI-assisted development", notes: "Requires Node.js 18+. After install, run `claude` to authenticate." },
1947
+ codex: { name: "OpenAI Codex CLI", install: "npm install -g @openai/codex", check: "codex --version", description: "OpenAI Codex — coding agent with GPT-4o/5", notes: "Requires Node.js 18+. Set OPENAI_API_KEY after install." },
1948
+ ollama: { name: "Ollama", install: "curl -fsSL https://ollama.com/install.sh | sh", check: "ollama --version", description: "Run AI models locally — no cloud tokens needed", notes: "After install: `ollama pull llama3.2` to download a model." },
1949
+ docker: { name: "Docker", install: "curl -fsSL https://get.docker.com | sh", check: "docker --version", description: "Container runtime for packaging and deploying apps", notes: "On WSL, install Docker Desktop on Windows and enable WSL integration." },
1950
+ convex: { name: "Convex CLI", install: "npm install -g convex", check: "npx convex --version", description: "Convex backend platform CLI", notes: "Run `npx convex dev` to start a project." },
1951
+ openclaw: { name: "OpenClaw CLI", install: "npm install -g openclaw", check: "openclaw --version", description: "OpenClaw messaging gateway CLI", notes: "Requires Node.js 22+. Run `openclaw setup` after install." },
1952
+ node: { name: "Node.js", install: "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash && nvm install --lts", check: "node --version", description: "JavaScript runtime", notes: "Uses nvm for version management. Restart terminal after install." },
1953
+ bun: { name: "Bun", install: "curl -fsSL https://bun.sh/install | bash", check: "bun --version", description: "Fast JavaScript runtime and toolkit", notes: "Alternative to Node.js with built-in bundler and test runner." },
1954
+ certbot: { name: "Certbot", install: "sudo apt install -y certbot", check: "certbot --version", description: "Let's Encrypt SSL certificate manager", notes: "On RHEL/Fedora: `sudo dnf install certbot`" },
1955
+ "notoken-app": { name: "Notoken Desktop App", install: "echo 'Say: install notoken app'", check: "which notoken-app 2>/dev/null || echo ''", description: "Point-and-click GUI + chat — everything the CLI does, visually", notes: "Download from https://notoken.sh/download or say: \"install notoken app\"" },
1956
+ };
1957
+ const TOOL_ALIASES = { "claude-code": "claude", "anthropic": "claude", "openai": "codex", "gpt": "codex", "chatgpt": "codex", "nvm": "node", "nodejs": "node", "claw": "openclaw" };
1958
+ function resolveToolName(raw) {
1959
+ const toolMatch = raw.match(/(?:install|setup|get|download)\s+(\S+)/i)
1960
+ ?? raw.match(/(\S+)\s+(?:install|setup)/i)
1961
+ ?? raw.match(/(?:how.*?(?:install|setup|get))\s+(\S+)/i);
1962
+ let name = (toolMatch?.[1] ?? "").toLowerCase().replace(/[?.!]/g, "");
1963
+ return TOOL_ALIASES[name] ?? name;
1964
+ }
1965
+ // Tool install info — "how do I install claude", "give me the command to install codex"
1966
+ if (intent.intent === "tool.info") {
1967
+ let toolName = resolveToolName(intent.rawText) || (fields.tool ?? "").toLowerCase();
1968
+ toolName = TOOL_ALIASES[toolName] ?? toolName;
1969
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m" };
1970
+ if (!toolName || !INSTALL_INFO[toolName]) {
1971
+ const lines = [`\n${cc.bold}${cc.cyan}── Available Tools ──${cc.reset}\n`];
1972
+ for (const [key, info] of Object.entries(INSTALL_INFO)) {
1973
+ const installed = await runLocalCommand(info.check + " 2>/dev/null").catch(() => "");
1974
+ const status = installed ? `${cc.green}✓ installed${cc.reset}` : `${cc.dim}○ not installed${cc.reset}`;
1975
+ lines.push(` ${cc.bold}${key.padEnd(12)}${cc.reset} ${status} ${cc.dim}${info.description}${cc.reset}`);
1976
+ }
1977
+ lines.push(`\n ${cc.dim}Say: "how to install claude" or "install codex"${cc.reset}`);
1978
+ return lines.join("\n");
1979
+ }
1980
+ const info = INSTALL_INFO[toolName];
1981
+ const installed = await runLocalCommand(info.check + " 2>/dev/null").catch(() => "");
1982
+ const lines = [`\n${cc.bold}${cc.cyan}── ${info.name} ──${cc.reset}\n`];
1983
+ lines.push(` ${info.description}\n`);
1984
+ if (installed) {
1985
+ lines.push(` ${cc.green}✓ Already installed:${cc.reset} ${installed.trim()}\n`);
1986
+ lines.push(` ${cc.bold}Install command:${cc.reset}`);
1987
+ lines.push(` ${cc.cyan}${info.install}${cc.reset}\n`);
1988
+ lines.push(` ${cc.bold}Verify:${cc.reset} ${info.check}`);
1989
+ if (info.notes)
1990
+ lines.push(`\n ${cc.yellow}Note:${cc.reset} ${info.notes}`);
1991
+ }
1992
+ else {
1993
+ lines.push(` ${cc.bold}Install command:${cc.reset}`);
1994
+ lines.push(` ${cc.cyan}${info.install}${cc.reset}\n`);
1995
+ lines.push(` ${cc.bold}Verify:${cc.reset} ${info.check}`);
1996
+ if (info.notes)
1997
+ lines.push(`\n ${cc.yellow}Note:${cc.reset} ${info.notes}`);
1998
+ lines.push(`\n ${cc.bold}I can install it for you.${cc.reset} Want me to do that?`);
1999
+ // Register pending action so "yes"/"do it" triggers the install
2000
+ suggestAction({ action: `install ${toolName}`, description: `Install ${info.name}`, type: "intent" });
2001
+ }
2002
+ return lines.join("\n");
2003
+ }
2004
+ // Tool install — "install claude", "install openclaw", "install codex"
2005
+ if (intent.intent === "tool.install") {
2006
+ let toolName = resolveToolName(intent.rawText) || (fields.tool ?? "").toLowerCase();
2007
+ toolName = TOOL_ALIASES[toolName] ?? toolName;
2008
+ // Don't let env qualifiers get mistaken for tool names
2009
+ if (["windows", "wsl", "linux", "host", "both"].includes(toolName)) {
2010
+ toolName = resolveToolName(intent.rawText.replace(/\b(on\s+)?(windows|wsl|linux|host|both)\b/gi, "").trim()) || toolName;
2011
+ }
2012
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
2013
+ if (!toolName || !INSTALL_INFO[toolName]) {
2014
+ return `${cc.red}✗ Unknown tool: "${toolName || "?"}"\x1b[0m\n\n ${cc.dim}Available: ${Object.keys(INSTALL_INFO).join(", ")}${cc.reset}`;
2015
+ }
2016
+ const info = INSTALL_INFO[toolName];
2017
+ const wantWindows = !!intent.rawText.match(/\b(on\s+)?windows\b|\b(on\s+)?win\b|\bon\s+d\b|\bd\s+drive\b/i);
2018
+ // Check if already installed — but for "on windows" requests, check Windows specifically
2019
+ if (!wantWindows) {
2020
+ const existing = await runLocalCommand(info.check + " 2>/dev/null").catch(() => "");
2021
+ if (existing) {
2022
+ return `${cc.green}✓${cc.reset} ${info.name} is already installed: ${cc.bold}${existing.trim()}${cc.reset}`;
2023
+ }
2024
+ }
2025
+ // Check Node.js for npm-based tools
2026
+ if (info.install.startsWith("npm ")) {
2027
+ const nodeVer = await runLocalCommand("node --version 2>/dev/null").catch(() => "");
2028
+ if (!nodeVer) {
2029
+ return `${cc.red}✗ Node.js is required to install ${info.name}.${cc.reset}\n ${cc.dim}Say: "install node" first.${cc.reset}`;
2030
+ }
2031
+ // Check minimum Node version from notes (e.g. "Requires Node.js 22+")
2032
+ const minNodeMatch = info.notes?.match(/Node\.js\s+(\d+)\+/);
2033
+ if (minNodeMatch) {
2034
+ const minMajor = parseInt(minNodeMatch[1]);
2035
+ const currentMajor = parseInt(nodeVer.replace("v", ""));
2036
+ if (currentMajor < minMajor) {
2037
+ console.log(`${cc.yellow}⚠ ${info.name} requires Node.js ${minMajor}+ (current: ${nodeVer.trim()})${cc.reset}`);
2038
+ console.log(`${cc.cyan}Upgrading Node.js to ${minMajor}...${cc.reset}\n`);
2039
+ const upgraded = await upgradeNode(minMajor, cc, runLocalCommand, withSpinner);
2040
+ if (!upgraded.ok) {
2041
+ return upgraded.message;
2042
+ }
2043
+ console.log(`${cc.green}✓ ${upgraded.message}${cc.reset}\n`);
2044
+ }
2045
+ }
2046
+ }
2047
+ // ── Special handling: Ollama in WSL — recommend/install Windows native for GPU ──
2048
+ if (toolName === "ollama") {
2049
+ const installIsWSL = (await runLocalCommand("grep -qi microsoft /proc/version 2>/dev/null && echo wsl || echo native").catch(() => "native")).trim() === "wsl";
2050
+ if (installIsWSL) {
2051
+ // Check for GPU on Windows host
2052
+ const gpuInfo = await runLocalCommand("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command \"Get-WmiObject Win32_VideoController | Select -Exp Name\" 2>/dev/null").catch(() => "");
2053
+ const hasNvidiaGpu = gpuInfo.toLowerCase().includes("nvidia");
2054
+ // Check if Ollama already installed on Windows
2055
+ const winOllama = await runLocalCommand("/mnt/c/Windows/System32/cmd.exe /c \"where ollama\" 2>/dev/null").catch(() => "");
2056
+ const winInstalled = winOllama.includes("ollama");
2057
+ // Check target from user input — "install ollama on windows", "install ollama on d drive"
2058
+ const wantWindows = intent.rawText.match(/\b(on\s+)?windows\b|\b(on\s+)?win\b|\bon\s+d\b|\bd\s+drive\b/i);
2059
+ const wantWSL = intent.rawText.match(/\b(on\s+|in\s+)?wsl\b|\b(on\s+)?linux\b/i);
2060
+ if (winInstalled && !wantWSL) {
2061
+ const winVer = await runLocalCommand("/mnt/c/Windows/System32/cmd.exe /c \"ollama --version\" 2>/dev/null").catch(() => "");
2062
+ return `${cc.green}✓${cc.reset} Ollama already installed on Windows: ${cc.bold}${winVer.trim().replace(/\r/g, "")}${cc.reset}${hasNvidiaGpu ? `\n ${cc.green}✓${cc.reset} GPU: ${gpuInfo.trim().replace(/\r/g, "")}` : ""}`;
2063
+ }
2064
+ if ((hasNvidiaGpu && !wantWSL) || wantWindows) {
2065
+ // Install Ollama natively on Windows for GPU access
2066
+ console.log(`\n${cc.bold}${cc.cyan}── Installing Ollama for Windows ──${cc.reset}\n`);
2067
+ if (hasNvidiaGpu) {
2068
+ console.log(` ${cc.green}✓${cc.reset} GPU detected: ${cc.bold}${gpuInfo.trim().replace(/\r/g, "")}${cc.reset}`);
2069
+ console.log(` ${cc.dim}Installing natively on Windows for GPU acceleration.${cc.reset}`);
2070
+ console.log(` ${cc.dim}(WSL Ollama would be CPU-only — 10-50x slower)${cc.reset}\n`);
2071
+ }
2072
+ // Check D: drive space
2073
+ const dFree = await runLocalCommand("df -BG /mnt/d 2>/dev/null | tail -1 | awk '{print $4}'").catch(() => "0G");
2074
+ const dFreeGB = parseInt(dFree);
2075
+ // Download Ollama installer to D: drive
2076
+ const installDir = "D:\\\\Ollama";
2077
+ const installerUrl = "https://ollama.com/download/OllamaSetup.exe";
2078
+ const downloadPath = "D:\\\\OllamaSetup.exe";
2079
+ try {
2080
+ // Check if installer was already downloaded (e.g. user cancelled and wants to retry)
2081
+ const existingInstaller = await runLocalCommand(`/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "if(Test-Path '${downloadPath}'){(Get-Item '${downloadPath}').Length}" 2>/dev/null`).catch(() => "0");
2082
+ const existingSize = parseInt(existingInstaller.trim()) || 0;
2083
+ if (existingSize > 100_000_000) {
2084
+ // Installer already exists (>100MB) — skip download
2085
+ console.log(` ${cc.green}✓${cc.reset} Installer already downloaded (${(existingSize / 1e9).toFixed(1)}GB)`);
2086
+ console.log(` ${cc.dim}Relaunching...${cc.reset}`);
2087
+ }
2088
+ else {
2089
+ console.log(` ${cc.dim}Downloading Ollama installer...${cc.reset}`);
2090
+ await withSpinner("Downloading Ollama...", () => runLocalCommand(`/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "Invoke-WebRequest -Uri '${installerUrl}' -OutFile '${downloadPath}' -UseBasicParsing" 2>/dev/null`, 600_000));
2091
+ }
2092
+ // Set OLLAMA_MODELS to D: drive before installing
2093
+ console.log(` ${cc.dim}Setting models directory to D:\\\\Ollama\\\\models...${cc.reset}`);
2094
+ await runLocalCommand(`/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "[Environment]::SetEnvironmentVariable('OLLAMA_MODELS', 'D:\\\\Ollama\\\\models', 'User')" 2>/dev/null`).catch(() => "");
2095
+ // Launch installer — use PowerShell Start-Process (cmd.exe start has file lock issues)
2096
+ console.log(` ${cc.dim}Launching installer...${cc.reset}`);
2097
+ await runLocalCommand(`/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "Start-Process '${downloadPath.replace(/\\\\/g, "\\")}'" 2>/dev/null`).catch(() => "");
2098
+ const lines = [
2099
+ `\n${cc.green}✓${cc.reset} Ollama installer launched on Windows.\n`,
2100
+ `${cc.bold}${cc.cyan}── Setup Instructions ──${cc.reset}\n`,
2101
+ ` ${cc.bold}1.${cc.reset} The installer window should appear on your desktop`,
2102
+ ` ${cc.dim}If you don't see it, check your taskbar for "Ollama Setup"${cc.reset}`,
2103
+ ` ${cc.bold}2.${cc.reset} Click ${cc.bold}"Install"${cc.reset} — installs to AppData\\Local\\Programs\\Ollama`,
2104
+ ` ${cc.bold}3.${cc.reset} Wait for it to finish — adds Ollama to your PATH`,
2105
+ ` ${cc.bold}4.${cc.reset} Ollama starts automatically in the system tray`,
2106
+ ` ${cc.dim}Look for the llama icon near your clock${cc.reset}`,
2107
+ ` ${cc.bold}5.${cc.reset} It serves on ${cc.cyan}http://localhost:11434${cc.reset} — both WSL and Windows can use it\n`,
2108
+ ` ${cc.bold}Models:${cc.reset} D:\\Ollama\\models ${cc.dim}(set via OLLAMA_MODELS)${cc.reset}`,
2109
+ hasNvidiaGpu ? ` ${cc.bold}GPU:${cc.reset} ${cc.green}${gpuInfo.trim().replace(/\r/g, "")} — CUDA acceleration enabled${cc.reset}` : "",
2110
+ `\n${cc.bold}${cc.cyan}── After Install ──${cc.reset}\n`,
2111
+ ` Verify: ${cc.cyan}ollama --version${cc.reset} ${cc.dim}(open PowerShell or say "is ollama installed")${cc.reset}`,
2112
+ ` Pull: ${cc.cyan}ollama pull llama3.2${cc.reset} ${cc.dim}(2GB, 131K context, fast on GPU)${cc.reset}`,
2113
+ ` Test: ${cc.cyan}ollama run llama3.2 "hello"${cc.reset}`,
2114
+ ` Or say: ${cc.cyan}"ollama pull llama3.2"${cc.reset} — notoken handles it\n`,
2115
+ ` ${cc.dim}notoken will auto-detect the Windows Ollama on next status check${cc.reset}`,
2116
+ ].filter(Boolean);
2117
+ return lines.join("\n");
2118
+ }
2119
+ catch (err) {
2120
+ return `${cc.yellow}⚠${cc.reset} Auto-download failed. Install manually:\n ${cc.cyan}${installerUrl}${cc.reset}\n ${cc.dim}Set OLLAMA_MODELS=D:\\Ollama\\models before installing${cc.reset}`;
2121
+ }
2122
+ }
2123
+ // WSL install but warn about CPU-only
2124
+ if (hasNvidiaGpu && wantWSL) {
2125
+ console.log(`${cc.yellow}⚠ Installing in WSL — GPU (${gpuInfo.trim().replace(/\r/g, "")}) won't be used.${cc.reset}`);
2126
+ console.log(` ${cc.dim}For GPU acceleration, say: "install ollama on windows"${cc.reset}\n`);
2127
+ }
2128
+ }
2129
+ }
2130
+ console.log(`\n${cc.cyan}Installing ${info.name}...${cc.reset}`);
2131
+ console.log(`${cc.dim} Running: ${info.install}${cc.reset}\n`);
2132
+ // ── Install with retry and self-healing ──
2133
+ const maxAttempts = 2;
2134
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2135
+ try {
2136
+ result = await withSpinner(`Installing ${info.name}...`, () => runLocalCommand(info.install + " 2>&1", 300_000));
2137
+ // Verify installation
2138
+ let ver = await runLocalCommand(info.check + " 2>/dev/null").catch(() => "");
2139
+ if (ver) {
2140
+ const lines = [`${cc.green}✓${cc.reset} ${info.name} installed successfully: ${cc.bold}${ver.trim()}${cc.reset}`];
2141
+ if (info.notes)
2142
+ lines.push(`\n ${cc.yellow}Next:${cc.reset} ${info.notes}`);
2143
+ // Post-install: for openclaw, run onboard + start gateway + pair device + open dashboard
2144
+ if (toolName === "openclaw") {
2145
+ const { readFileSync: rfs, existsSync: efs, writeFileSync: wfs, mkdirSync: mfs } = await import("node:fs");
2146
+ const { dirname: dn } = await import("node:path");
2147
+ const home = process.env.USERPROFILE || process.env.HOME || "";
2148
+ const sep = process.platform === "win32" ? "\\" : "/";
2149
+ const ocHome = `${home}${sep}.openclaw`;
2150
+ const configPath = `${ocHome}${sep}openclaw.json`;
2151
+ // Step 1: Run non-interactive onboard (sets up config, workspace, auth)
2152
+ console.log(`\n${cc.cyan}Running OpenClaw onboard...${cc.reset}`);
2153
+ const authChoice = efs(`${home}${sep}.claude${sep}.credentials.json`) ? "anthropic-cli" : "skip";
2154
+ await withSpinner("Setting up OpenClaw...", () => runLocalCommand(`openclaw onboard --mode local --non-interactive --accept-risk --auth-choice ${authChoice} --skip-channels --skip-skills --skip-daemon --skip-health --skip-search --skip-ui 2>&1`, 60_000)).catch(() => "");
2155
+ lines.push(`${cc.green}✓${cc.reset} OpenClaw onboarded`);
2156
+ // Step 2: Fix model ID (onboard may set claude-cli/ prefix which gateway doesn't recognize)
2157
+ try {
2158
+ if (efs(configPath)) {
2159
+ const config = JSON.parse(rfs(configPath, "utf-8"));
2160
+ const primary = config?.agents?.defaults?.model?.primary || "";
2161
+ if (primary.startsWith("claude-cli/")) {
2162
+ const fixedModel = primary.replace("claude-cli/", "anthropic/");
2163
+ config.agents.defaults.model.primary = fixedModel;
2164
+ if (config.agents.defaults.models) {
2165
+ delete config.agents.defaults.models[primary];
2166
+ config.agents.defaults.models[fixedModel] = {};
2167
+ }
2168
+ wfs(configPath, JSON.stringify(config, null, 2));
2169
+ lines.push(`${cc.green}✓${cc.reset} Model set to ${fixedModel}`);
2170
+ }
2171
+ }
2172
+ }
2173
+ catch { }
2174
+ // Step 3: Sync Claude Code OAuth token to openclaw auth-profiles
2175
+ const claudeCreds = `${home}${sep}.claude${sep}.credentials.json`;
2176
+ try {
2177
+ if (efs(claudeCreds)) {
2178
+ const creds = JSON.parse(rfs(claudeCreds, "utf-8"));
2179
+ const claudeToken = creds?.claudeAiOauth?.accessToken;
2180
+ if (claudeToken) {
2181
+ const authPath = `${ocHome}${sep}agents${sep}main${sep}agent${sep}auth-profiles.json`;
2182
+ let profiles = { version: 1, profiles: {} };
2183
+ if (efs(authPath))
2184
+ profiles = JSON.parse(rfs(authPath, "utf-8"));
2185
+ else
2186
+ mfs(dn(authPath), { recursive: true });
2187
+ profiles.profiles["anthropic:claude-oauth"] = { type: "oauth", provider: "anthropic", access: claudeToken, expires: Date.now() + 86400000 };
2188
+ wfs(authPath, JSON.stringify(profiles, null, 2));
2189
+ lines.push(`${cc.green}✓${cc.reset} Claude Code token synced`);
2190
+ }
2191
+ }
2192
+ }
2193
+ catch { }
2194
+ // Step 4: Start gateway
2195
+ console.log(`${cc.cyan}Starting gateway...${cc.reset}`);
2196
+ if (process.platform === "win32") {
2197
+ const ocPrefix = (await runLocalCommand("npm config get prefix 2>/dev/null").catch(() => "")).trim();
2198
+ const ocEntry = ocPrefix ? `${ocPrefix}\\node_modules\\openclaw\\dist\\index.js` : "openclaw";
2199
+ await runLocalCommand(`powershell -Command "Start-Process -FilePath node -ArgumentList '${ocEntry}','gateway','--force','--allow-unconfigured' -WindowStyle Hidden" 2>/dev/null`).catch(() => "");
2200
+ }
2201
+ else {
2202
+ await runLocalCommand("nohup openclaw gateway --force --allow-unconfigured > /dev/null 2>&1 &").catch(() => "");
2203
+ }
2204
+ let gwUp = false;
2205
+ for (let i = 0; i < 10; i++) {
2206
+ await runLocalCommand("sleep 1").catch(() => { });
2207
+ const h = await runLocalCommand("curl -sf http://127.0.0.1:18789/health 2>/dev/null").catch(() => "");
2208
+ if (h.includes('"ok"')) {
2209
+ gwUp = true;
2210
+ break;
2211
+ }
2212
+ }
2213
+ if (gwUp) {
2214
+ lines.push(`${cc.green}✓${cc.reset} Gateway started on http://127.0.0.1:18789`);
2215
+ // Step 5: Auto-pair CLI device with full admin scopes
2216
+ try {
2217
+ const devicesDir = `${ocHome}${sep}devices`;
2218
+ const pairedPath = `${devicesDir}${sep}paired.json`;
2219
+ const pendingPath = `${devicesDir}${sep}pending.json`;
2220
+ if (efs(pairedPath) && efs(pendingPath)) {
2221
+ const paired = JSON.parse(rfs(pairedPath, "utf-8"));
2222
+ const pending = JSON.parse(rfs(pendingPath, "utf-8"));
2223
+ const fullScopes = ["operator.admin", "operator.read", "operator.write", "operator.approvals", "operator.pairing"];
2224
+ // Approve all pending requests
2225
+ let approved = 0;
2226
+ for (const [reqId, req] of Object.entries(pending)) {
2227
+ const deviceId = req.deviceId;
2228
+ if (paired[deviceId]) {
2229
+ paired[deviceId].scopes = fullScopes;
2230
+ paired[deviceId].approvedScopes = fullScopes;
2231
+ paired[deviceId].clientId = req.clientId || paired[deviceId].clientId;
2232
+ paired[deviceId].clientMode = req.clientMode || paired[deviceId].clientMode;
2233
+ if (paired[deviceId].tokens?.operator) {
2234
+ paired[deviceId].tokens.operator.scopes = fullScopes;
2235
+ }
2236
+ paired[deviceId].approvedAtMs = Date.now();
2237
+ approved++;
2238
+ }
2239
+ }
2240
+ // Also upgrade any existing devices with limited scopes
2241
+ for (const [deviceId, device] of Object.entries(paired)) {
2242
+ if (!device.scopes?.includes("operator.admin")) {
2243
+ device.scopes = fullScopes;
2244
+ device.approvedScopes = fullScopes;
2245
+ if (device.tokens?.operator)
2246
+ device.tokens.operator.scopes = fullScopes;
2247
+ device.approvedAtMs = Date.now();
2248
+ approved++;
2249
+ }
2250
+ }
2251
+ if (approved > 0) {
2252
+ wfs(pairedPath, JSON.stringify(paired, null, 2));
2253
+ wfs(pendingPath, "{}");
2254
+ lines.push(`${cc.green}✓${cc.reset} Device pairing configured (${approved} device(s))`);
2255
+ }
2256
+ }
2257
+ }
2258
+ catch { }
2259
+ // Step 6: Open dashboard with Playwright auto-pair
2260
+ lines.push(`\n${cc.cyan}Opening dashboard...${cc.reset}`);
2261
+ try {
2262
+ const { chromium } = await import("playwright");
2263
+ const browser = await chromium.launch({ headless: false });
2264
+ const page = await browser.newPage();
2265
+ await page.goto("http://127.0.0.1:18789");
2266
+ await page.waitForTimeout(2000);
2267
+ const tokenInput = page.locator('input[placeholder*="OPENCLAW_GATEWAY_TOKEN"]');
2268
+ let gwToken = "";
2269
+ try {
2270
+ gwToken = JSON.parse(rfs(configPath, "utf-8"))?.gateway?.auth?.token || "";
2271
+ }
2272
+ catch { }
2273
+ if (gwToken && await tokenInput.count() > 0) {
2274
+ await tokenInput.fill(gwToken);
2275
+ const btn = page.locator('button').filter({ hasText: 'Connect' });
2276
+ if (await btn.count() > 0)
2277
+ await btn.first().click();
2278
+ await page.waitForTimeout(2000);
2279
+ }
2280
+ lines.push(`${cc.green}✓${cc.reset} Dashboard opened and paired!`);
2281
+ }
2282
+ catch {
2283
+ try {
2284
+ if (process.platform === "win32")
2285
+ await runLocalCommand(`powershell -Command "Start-Process 'http://127.0.0.1:18789'" 2>/dev/null`);
2286
+ else
2287
+ await runLocalCommand(`xdg-open "http://127.0.0.1:18789" 2>/dev/null || open "http://127.0.0.1:18789" 2>/dev/null`);
2288
+ }
2289
+ catch { }
2290
+ lines.push(`${cc.dim}Dashboard: http://127.0.0.1:18789${cc.reset}`);
2291
+ }
2292
+ }
2293
+ else {
2294
+ lines.push(`${cc.yellow}⚠${cc.reset} Gateway didn't start — try: "start openclaw"`);
2295
+ }
2296
+ }
2297
+ return lines.join("\n");
2298
+ }
2299
+ // ── Verification failed — diagnose and retry ──
2300
+ if (attempt < maxAttempts) {
2301
+ console.log(`${cc.yellow}⚠ Installed but verification failed. Diagnosing...${cc.reset}`);
2302
+ // Scenario 1: Node version mismatch after MSI install (shell has stale PATH)
2303
+ const minNodeMatch2 = info.notes?.match(/Node\.js\s+(\d+)\+/);
2304
+ if (minNodeMatch2 && process.platform === "win32") {
2305
+ const minMajor2 = parseInt(minNodeMatch2[1]);
2306
+ // Search for the newly installed Node binary directly
2307
+ const searchPaths = [
2308
+ `C:/Program Files/nodejs/node.exe`,
2309
+ `C:/Program Files (x86)/nodejs/node.exe`,
2310
+ ];
2311
+ let newNodePath = "";
2312
+ for (const p of searchPaths) {
2313
+ const found = await runLocalCommand(`test -f "${p}" && "${p}" --version 2>/dev/null`).catch(() => "");
2314
+ if (found && parseInt(found.replace("v", "")) >= minMajor2) {
2315
+ newNodePath = p;
2316
+ break;
2317
+ }
2318
+ }
2319
+ // Also check nvm-windows install paths
2320
+ if (!newNodePath) {
2321
+ const nvmRoot = (await runLocalCommand(`powershell -Command 'Write-Output $env:NVM_HOME' 2>/dev/null`).catch(() => "")).trim();
2322
+ if (nvmRoot) {
2323
+ const found = await runLocalCommand(`ls -1 "${nvmRoot}"/v${minMajor2}*/node.exe 2>/dev/null | head -1`).catch(() => "");
2324
+ if (found.trim())
2325
+ newNodePath = found.trim();
2326
+ }
2327
+ }
2328
+ if (newNodePath) {
2329
+ console.log(`${cc.cyan}Found Node ${minMajor2}+ at ${newNodePath} — refreshing PATH...${cc.reset}`);
2330
+ // Update PATH for this process and retry
2331
+ const nodeDir = newNodePath.replace(/\/node\.exe$/, "").replace(/\\node\.exe$/, "");
2332
+ process.env.PATH = `${nodeDir};${process.env.PATH}`;
2333
+ // Re-run npm install with the new Node
2334
+ console.log(`${cc.cyan}Retrying installation with Node ${minMajor2}+...${cc.reset}\n`);
2335
+ continue;
2336
+ }
2337
+ }
2338
+ // Scenario 2: Binary installed but not on PATH (npm global bin not in PATH)
2339
+ const npmBin = await runLocalCommand("npm config get prefix 2>/dev/null").catch(() => "");
2340
+ if (npmBin.trim()) {
2341
+ const binDir = process.platform === "win32" ? npmBin.trim() : `${npmBin.trim()}/bin`;
2342
+ if (!process.env.PATH?.includes(binDir)) {
2343
+ console.log(`${cc.cyan}Adding npm global bin to PATH: ${binDir}${cc.reset}`);
2344
+ process.env.PATH = `${binDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`;
2345
+ // Retry verification
2346
+ ver = await runLocalCommand(info.check + " 2>/dev/null").catch(() => "");
2347
+ if (ver) {
2348
+ const lines = [`${cc.green}✓${cc.reset} ${info.name} installed successfully: ${cc.bold}${ver.trim()}${cc.reset}`];
2349
+ if (info.notes)
2350
+ lines.push(`\n ${cc.yellow}Next:${cc.reset} ${info.notes}`);
2351
+ return lines.join("\n");
2352
+ }
2353
+ }
2354
+ }
2355
+ // Scenario 3: Tool needs a specific Node but `node` on PATH is still old
2356
+ // Try running the check with the tool's expected Node directly
2357
+ if (minNodeMatch2 && process.platform === "win32") {
2358
+ const curVer = await runLocalCommand("node --version 2>/dev/null").catch(() => "unknown");
2359
+ console.log(`${cc.yellow}Node ${minNodeMatch2[1]}+ may have installed but this shell still uses ${curVer.trim()}.${cc.reset}`);
2360
+ console.log(`${cc.cyan}Searching for the new Node binary...${cc.reset}`);
2361
+ }
2362
+ }
2363
+ return `${cc.yellow}⚠${cc.reset} Install completed but could not verify. Try: ${cc.cyan}${info.check}${cc.reset}\n\n${cc.dim}Output:\n${result.substring(0, 500)}${cc.reset}\n\n ${cc.dim}If Node was just upgraded, restart your terminal and try again.${cc.reset}`;
2364
+ }
2365
+ catch (err) {
2366
+ const errMsg = err.message.split("\n")[0];
2367
+ // Self-healing: if npm install failed, diagnose why
2368
+ if (attempt < maxAttempts) {
2369
+ // Check if it's a permissions error
2370
+ if (errMsg.includes("EACCES") || errMsg.includes("permission denied")) {
2371
+ console.log(`${cc.yellow}⚠ Permission error — retrying with sudo...${cc.reset}`);
2372
+ try {
2373
+ result = await withSpinner(`Installing ${info.name} (sudo)...`, () => runLocalCommand(`sudo ${info.install} 2>&1`, 300_000));
2374
+ const ver = await runLocalCommand(info.check + " 2>/dev/null").catch(() => "");
2375
+ if (ver) {
2376
+ return `${cc.green}✓${cc.reset} ${info.name} installed successfully: ${cc.bold}${ver.trim()}${cc.reset}`;
2377
+ }
2378
+ }
2379
+ catch { /* fall through to error */ }
2380
+ }
2381
+ // Check if it's a network/registry error
2382
+ if (errMsg.includes("ETIMEDOUT") || errMsg.includes("ENOTFOUND") || errMsg.includes("fetch failed")) {
2383
+ console.log(`${cc.yellow}⚠ Network error — retrying in 3s...${cc.reset}`);
2384
+ await runLocalCommand("sleep 3").catch(() => { });
2385
+ continue;
2386
+ }
2387
+ }
2388
+ return `${cc.red}✗ Installation failed:${cc.reset} ${errMsg}\n\n ${cc.dim}Try manually: ${info.install}${cc.reset}`;
2389
+ }
2390
+ }
2391
+ return `${cc.red}✗ Installation failed after ${maxAttempts} attempts.${cc.reset}\n ${cc.dim}Try manually: ${info.install}${cc.reset}`;
2392
+ }
2393
+ // Discord diagnose/fix/check — "diagnose discord", "fix discord", "check discord"
2394
+ if (intent.rawText.match(/\b(diagnose|fix|check|troubleshoot|repair)\b.*\bdiscord\b|\bdiscord\b.*\b(diagnose|fix|check|troubleshoot|status)\b/i)) {
2395
+ const isQuick = !!intent.rawText.match(/\b(check|status)\b/i) && !intent.rawText.match(/\b(fix|diagnose|troubleshoot|repair)\b/i);
2396
+ try {
2397
+ if (isQuick) {
2398
+ const { quickDiscordCheck } = await import("../utils/discordDiag.js");
2399
+ return await quickDiscordCheck();
2400
+ }
2401
+ else {
2402
+ const { diagnoseDiscord } = await import("../utils/discordDiag.js");
2403
+ return await diagnoseDiscord();
2404
+ }
2405
+ }
2406
+ catch (err) {
2407
+ return `\x1b[31m✗ Discord diagnostics error: ${err.message.split("\n")[0]}\x1b[0m`;
2408
+ }
2409
+ }
2410
+ // Discord/channel setup — "setup discord", "add discord channel", "connect discord"
2411
+ if ((intent.intent === "openclaw.configure" || intent.intent === "openclaw.channel.setup" || intent.intent === "tool.install") &&
2412
+ intent.rawText.match(/\bdiscord\b/i)) {
2413
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
2414
+ // Check if user provided a token directly: "setup discord with token abc123"
2415
+ const tokenMatch = intent.rawText.match(/token\s+(\S+)/i);
2416
+ if (tokenMatch) {
2417
+ const token = tokenMatch[1];
2418
+ console.log(`${cc.dim}Registering Discord bot token with OpenClaw...${cc.reset}`);
2419
+ const node22 = await getNode22();
2420
+ const ocBin = (await runLocalCommand("readlink -f $(which openclaw) 2>/dev/null || which openclaw").catch(() => "openclaw")).trim();
2421
+ try {
2422
+ await runLocalCommand(`${node22} ${ocBin} channels add --channel discord --token "${token}" 2>&1`, 15_000);
2423
+ return `${cc.green}✓${cc.reset} Discord channel registered!\n ${cc.dim}Restart OpenClaw: "restart openclaw"${cc.reset}`;
2424
+ }
2425
+ catch (err) {
2426
+ return `${cc.red}✗ Failed to register: ${err.message.split("\n")[0]}${cc.reset}`;
2427
+ }
2428
+ }
2429
+ // Try Playwright automation
2430
+ try {
2431
+ const { setupDiscordChannel } = await import("../automation/discordSetup.js");
2432
+ return await setupDiscordChannel();
2433
+ }
2434
+ catch {
2435
+ // Playwright not available — show manual instructions
2436
+ }
2437
+ // Manual instructions
2438
+ const lines = [
2439
+ `\n${cc.bold}${cc.cyan}── Discord Bot Setup ──${cc.reset}\n`,
2440
+ ` ${cc.bold}Step 1:${cc.reset} Open ${cc.cyan}https://discord.com/developers/applications${cc.reset}`,
2441
+ ` ${cc.bold}Step 2:${cc.reset} Click ${cc.bold}"New Application"${cc.reset} → name it "OpenClaw" → Create`,
2442
+ ` ${cc.bold}Step 3:${cc.reset} Left sidebar → ${cc.bold}"Bot"${cc.reset} → click ${cc.bold}"Reset Token"${cc.reset} → ${cc.yellow}Copy the token${cc.reset}`,
2443
+ ` ${cc.bold}Step 4:${cc.reset} Scroll down → enable ${cc.bold}"Message Content Intent"${cc.reset} → Save`,
2444
+ ` ${cc.bold}Step 5:${cc.reset} Left sidebar → ${cc.bold}"OAuth2" → "URL Generator"${cc.reset}`,
2445
+ ` Check: ${cc.cyan}bot${cc.reset} scope → then: ${cc.cyan}Send Messages${cc.reset} + ${cc.cyan}Read Messages/View Channels${cc.reset}`,
2446
+ ` ${cc.bold}Step 6:${cc.reset} Copy the generated URL → open it → pick your server → Authorize\n`,
2447
+ ` ${cc.bold}Then tell notoken:${cc.reset}`,
2448
+ ` ${cc.cyan}"setup discord with token YOUR_BOT_TOKEN"${cc.reset}\n`,
2449
+ ` ${cc.dim}notoken will register it with OpenClaw and restart the gateway.${cc.reset}`,
2450
+ ];
2451
+ // Open browser for the user
2452
+ try {
2453
+ await runLocalCommand(`/mnt/c/Windows/System32/cmd.exe /c "start https://discord.com/developers/applications" 2>/dev/null`).catch(() => "");
2454
+ lines.push(`\n ${cc.green}✓${cc.reset} Opened Discord Developer Portal in your browser.`);
2455
+ }
2456
+ catch { /* */ }
2457
+ return lines.join("\n");
2458
+ }
2459
+ // Weather — uses wttr.in (free, no API key)
2460
+ if (intent.intent === "weather.current") {
2461
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m" };
2462
+ let location = fields.location ?? "";
2463
+ // Auto-detect location if not specified
2464
+ if (!location) {
2465
+ try {
2466
+ const geoResp = await fetch("https://ipinfo.io/json", { signal: AbortSignal.timeout(5000) });
2467
+ if (geoResp.ok) {
2468
+ const geo = await geoResp.json();
2469
+ location = geo.city ?? geo.region ?? "";
2470
+ if (location)
2471
+ console.log(`${cc.dim}Detected location: ${location}${cc.reset}`);
2472
+ }
2473
+ }
2474
+ catch { /* use empty — wttr.in will auto-detect */ }
2475
+ }
2476
+ const query = encodeURIComponent(location);
2477
+ try {
2478
+ // Get compact weather from wttr.in
2479
+ const resp = await fetch(`https://wttr.in/${query}?format=j1`, { signal: AbortSignal.timeout(10000) });
2480
+ if (!resp.ok)
2481
+ throw new Error(`wttr.in returned ${resp.status}`);
2482
+ const data = await resp.json();
2483
+ const current = data.current_condition?.[0];
2484
+ const area = data.nearest_area?.[0];
2485
+ const forecast = data.weather?.slice(0, 3);
2486
+ if (!current)
2487
+ return `${cc.yellow}⚠${cc.reset} Could not get weather data.`;
2488
+ const cityName = area?.areaName?.[0]?.value ?? location ?? "your location";
2489
+ const region = area?.region?.[0]?.value ?? "";
2490
+ const country = area?.country?.[0]?.value ?? "";
2491
+ const lines = [];
2492
+ lines.push(`\n${cc.bold}${cc.cyan}── Weather: ${cityName}${region ? `, ${region}` : ""}${country ? ` (${country})` : ""} ──${cc.reset}\n`);
2493
+ // Current conditions
2494
+ const tempC = current.temp_C;
2495
+ const tempF = current.temp_F;
2496
+ const desc = current.weatherDesc?.[0]?.value ?? "Unknown";
2497
+ const feelsLikeC = current.FeelsLikeC;
2498
+ const feelsLikeF = current.FeelsLikeF;
2499
+ const humidity = current.humidity;
2500
+ const wind = current.windspeedKmph;
2501
+ const windDir = current.winddir16Point;
2502
+ const precip = current.precipMM;
2503
+ const visibility = current.visibility;
2504
+ const uv = current.uvIndex;
2505
+ lines.push(` ${cc.bold}Now:${cc.reset} ${desc}`);
2506
+ lines.push(` ${cc.bold}Temp:${cc.reset} ${tempC}°C / ${tempF}°F ${feelsLikeC !== tempC ? `(feels like ${feelsLikeC}°C / ${feelsLikeF}°F)` : ""}`);
2507
+ lines.push(` ${cc.bold}Humidity:${cc.reset} ${humidity}% ${cc.bold}Wind:${cc.reset} ${wind} km/h ${windDir}`);
2508
+ if (parseFloat(precip) > 0)
2509
+ lines.push(` ${cc.bold}Precipitation:${cc.reset} ${precip}mm`);
2510
+ lines.push(` ${cc.bold}Visibility:${cc.reset} ${visibility}km ${cc.bold}UV:${cc.reset} ${uv}`);
2511
+ // 3-day forecast
2512
+ if (forecast?.length) {
2513
+ lines.push(`\n ${cc.bold}Forecast:${cc.reset}`);
2514
+ for (const day of forecast) {
2515
+ const date = day.date;
2516
+ const maxC = day.maxtempC;
2517
+ const minC = day.mintempC;
2518
+ const maxF = day.maxtempF;
2519
+ const minF = day.mintempF;
2520
+ const dayDesc = day.hourly?.[4]?.weatherDesc?.[0]?.value ?? "—";
2521
+ const rainChance = day.hourly?.[4]?.chanceofrain ?? "0";
2522
+ lines.push(` ${cc.dim}${date}${cc.reset} ${dayDesc} — ${minC}–${maxC}°C / ${minF}–${maxF}°F${parseInt(rainChance) > 30 ? ` 🌧 ${rainChance}% rain` : ""}`);
2523
+ }
2524
+ }
2525
+ // Also get the ASCII art version for fun
2526
+ const asciiResp = await fetch(`https://wttr.in/${query}?format=3`, { signal: AbortSignal.timeout(5000) }).catch(() => null);
2527
+ if (asciiResp?.ok) {
2528
+ const ascii = await asciiResp.text();
2529
+ lines.push(`\n ${cc.dim}${ascii.trim()}${cc.reset}`);
2530
+ }
2531
+ return lines.join("\n");
2532
+ }
2533
+ catch (err) {
2534
+ // Fallback to simple text format
2535
+ try {
2536
+ const simpleResp = await fetch(`https://wttr.in/${query}?format=%l:+%c+%t+%h+%w`, { signal: AbortSignal.timeout(5000) });
2537
+ if (simpleResp.ok)
2538
+ return await simpleResp.text();
2539
+ }
2540
+ catch { }
2541
+ return `${cc.yellow}⚠${cc.reset} Could not fetch weather: ${err.message}`;
2542
+ }
2543
+ }
2544
+ // News headlines — fetches from RSS feeds
2545
+ if (intent.intent === "news.headlines") {
2546
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m" };
2547
+ // Load RSS feeds from config or use defaults
2548
+ let feeds = [
2549
+ { name: "Hacker News", url: "https://hnrss.org/frontpage?count=5" },
2550
+ { name: "Tech", url: "https://feeds.arstechnica.com/arstechnica/technology-lab?count=5" },
2551
+ ];
2552
+ try {
2553
+ const { readFileSync, existsSync } = await import("node:fs");
2554
+ const { resolve } = await import("node:path");
2555
+ const feedFile = resolve(process.env.NOTOKEN_HOME ?? `${process.env.HOME}/.notoken`, "news-feeds.json");
2556
+ if (existsSync(feedFile)) {
2557
+ feeds = JSON.parse(readFileSync(feedFile, "utf-8")).feeds ?? feeds;
2558
+ }
2559
+ }
2560
+ catch { }
2561
+ const lines = [];
2562
+ lines.push(`\n${cc.bold}${cc.cyan}── Headlines ──${cc.reset}\n`);
2563
+ for (const feed of feeds) {
2564
+ try {
2565
+ const resp = await fetch(feed.url, { signal: AbortSignal.timeout(8000) });
2566
+ if (!resp.ok)
2567
+ continue;
2568
+ const xml = await resp.text();
2569
+ // Simple XML parsing for RSS <item><title>
2570
+ const items = xml.match(/<item>[\s\S]*?<\/item>/g)?.slice(0, 5) ?? [];
2571
+ if (items.length === 0)
2572
+ continue;
2573
+ lines.push(` ${cc.bold}${feed.name}:${cc.reset}`);
2574
+ for (const item of items) {
2575
+ const title = item.match(/<title><!\[CDATA\[(.*?)\]\]>|<title>(.*?)<\/title>/)?.[1] ?? item.match(/<title>(.*?)<\/title>/)?.[1] ?? "";
2576
+ const link = item.match(/<link>(.*?)<\/link>/)?.[1] ?? "";
2577
+ if (title) {
2578
+ lines.push(` ${cc.cyan}•${cc.reset} ${title.trim()}`);
2579
+ if (link)
2580
+ lines.push(` ${cc.dim}${link.trim()}${cc.reset}`);
2581
+ }
2582
+ }
2583
+ lines.push("");
2584
+ }
2585
+ catch { /* skip failed feed */ }
2586
+ }
2587
+ if (lines.length <= 2) {
2588
+ lines.push(` ${cc.dim}Could not fetch news. Check your internet connection.${cc.reset}`);
2589
+ }
2590
+ lines.push(` ${cc.dim}Customize feeds: ~/.notoken/news-feeds.json${cc.reset}`);
2591
+ return lines.join("\n");
2592
+ }
2593
+ // Database size check
2594
+ if (intent.intent === "db.size") {
2595
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m" };
2596
+ const lines = [];
2597
+ lines.push(`\n${cc.bold}${cc.cyan}── Database Size ──${cc.reset}\n`);
2598
+ // Check MySQL
2599
+ const mysql = await runLocalCommand("mysql -e \"SELECT table_schema AS 'Database', ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS 'Size (MB)' FROM information_schema.tables GROUP BY table_schema ORDER BY SUM(data_length + index_length) DESC;\" 2>/dev/null").catch(() => "");
2600
+ if (mysql.trim()) {
2601
+ lines.push(` ${cc.bold}MySQL:${cc.reset}`);
2602
+ for (const l of mysql.trim().split("\n").slice(0, 10)) {
2603
+ lines.push(` ${cc.dim}${l}${cc.reset}`);
2604
+ }
2605
+ }
2606
+ // Check PostgreSQL
2607
+ const pg = await runLocalCommand("sudo -u postgres psql -c \"SELECT pg_database.datname AS database, pg_size_pretty(pg_database_size(pg_database.datname)) AS size FROM pg_database ORDER BY pg_database_size(pg_database.datname) DESC LIMIT 10;\" 2>/dev/null").catch(() => "");
2608
+ if (pg.trim()) {
2609
+ lines.push(`${mysql.trim() ? "\n" : ""} ${cc.bold}PostgreSQL:${cc.reset}`);
2610
+ for (const l of pg.trim().split("\n").slice(0, 10)) {
2611
+ lines.push(` ${cc.dim}${l}${cc.reset}`);
2612
+ }
2613
+ }
2614
+ // Check MongoDB
2615
+ const mongo = await runLocalCommand("mongosh --quiet --eval 'db.adminCommand(\"listDatabases\").databases.forEach(d => print(d.name + \": \" + (d.sizeOnDisk/1024/1024).toFixed(2) + \" MB\"))' 2>/dev/null").catch(() => "");
2616
+ if (mongo.trim()) {
2617
+ lines.push(`${(mysql.trim() || pg.trim()) ? "\n" : ""} ${cc.bold}MongoDB:${cc.reset}`);
2618
+ for (const l of mongo.trim().split("\n").slice(0, 10)) {
2619
+ lines.push(` ${cc.dim}${l}${cc.reset}`);
2620
+ }
2621
+ }
2622
+ // Check SQLite files
2623
+ const sqlite = await runLocalCommand("find /var/lib /home -name '*.db' -o -name '*.sqlite' -o -name '*.sqlite3' 2>/dev/null | head -5").catch(() => "");
2624
+ if (sqlite.trim()) {
2625
+ lines.push(`\n ${cc.bold}SQLite files:${cc.reset}`);
2626
+ for (const f of sqlite.trim().split("\n")) {
2627
+ const size = await runLocalCommand(`du -sh "${f}" 2>/dev/null | awk '{print $1}'`).catch(() => "?");
2628
+ lines.push(` ${cc.dim}${size.trim().padEnd(8)} ${f}${cc.reset}`);
2629
+ }
2630
+ }
2631
+ if (!mysql.trim() && !pg.trim() && !mongo.trim() && !sqlite.trim()) {
2632
+ lines.push(` ${cc.dim}No databases detected (MySQL, PostgreSQL, MongoDB, SQLite).${cc.reset}`);
2633
+ }
2634
+ return lines.join("\n");
2635
+ }
2636
+ // Security scan — check for attacks, brute force, DDoS, suspicious activity
2637
+ if (intent.intent === "security.scan") {
2638
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
2639
+ const lines = [];
2640
+ // Detect WSL
2641
+ const secIsWSL = (await runLocalCommand("grep -qi microsoft /proc/version 2>/dev/null && echo wsl || echo native").catch(() => "native")).trim() === "wsl";
2642
+ lines.push(`\n${cc.bold}${cc.cyan}══════════════════════════════════════${cc.reset}`);
2643
+ lines.push(`${cc.bold}${cc.cyan} Security Scan${cc.reset}${secIsWSL ? ` ${cc.dim}(WSL + Windows)${cc.reset}` : ""}`);
2644
+ lines.push(`${cc.bold}${cc.cyan}══════════════════════════════════════${cc.reset}\n`);
2645
+ // 1. Identify user's own IP (SSH_CLIENT or who)
2646
+ const sshClient = process.env.SSH_CLIENT?.split(" ")[0] ?? "";
2647
+ const whoOutput = await runLocalCommand("who -u 2>/dev/null | head -5").catch(() => "");
2648
+ const myIPs = new Set();
2649
+ if (sshClient)
2650
+ myIPs.add(sshClient);
2651
+ for (const m of whoOutput.matchAll(/(\d+\.\d+\.\d+\.\d+)/g))
2652
+ myIPs.add(m[1]);
2653
+ // Also get local IPs
2654
+ const localIPs = await runLocalCommand("hostname -I 2>/dev/null").catch(() => "");
2655
+ for (const ip of localIPs.trim().split(/\s+/))
2656
+ if (ip)
2657
+ myIPs.add(ip);
2658
+ lines.push(` ${cc.bold}Your IPs:${cc.reset} ${[...myIPs].join(", ") || "localhost"}`);
2659
+ // 2. SSH brute force — failed login attempts
2660
+ lines.push(`\n ${cc.bold}── SSH (port 22) ──${cc.reset}`);
2661
+ const authLog = await runLocalCommand("grep 'Failed password\\|Invalid user\\|authentication failure' /var/log/auth.log 2>/dev/null | tail -100").catch(() => "");
2662
+ const authLogBtmp = await runLocalCommand("lastb 2>/dev/null | head -20").catch(() => "");
2663
+ if (authLog) {
2664
+ // Count failed attempts per IP in last 100 lines
2665
+ const ipCounts = {};
2666
+ for (const m of authLog.matchAll(/from\s+(\d+\.\d+\.\d+\.\d+)/g)) {
2667
+ ipCounts[m[1]] = (ipCounts[m[1]] || 0) + 1;
2668
+ }
2669
+ const sorted = Object.entries(ipCounts).sort((a, b) => b[1] - a[1]);
2670
+ const totalFailed = Object.values(ipCounts).reduce((a, b) => a + b, 0);
2671
+ if (totalFailed > 0) {
2672
+ const recentFailed = await runLocalCommand("grep -c 'Failed password' /var/log/auth.log 2>/dev/null").catch(() => "0");
2673
+ lines.push(` ${totalFailed > 50 ? cc.red + "⚠" : cc.yellow + "⚠"}${cc.reset} ${cc.bold}${recentFailed.trim()} total failed login attempts${cc.reset}`);
2674
+ // Show top attackers
2675
+ lines.push(` ${cc.bold}Top sources:${cc.reset}`);
2676
+ for (const [ip, count] of sorted.slice(0, 10)) {
2677
+ const isMe = myIPs.has(ip);
2678
+ const severity = count > 50 ? cc.red : count > 10 ? cc.yellow : cc.dim;
2679
+ lines.push(` ${severity}${count.toString().padStart(5)} attempts${cc.reset} from ${cc.bold}${ip}${cc.reset}${isMe ? ` ${cc.green}← your IP${cc.reset}` : ""}`);
2680
+ }
2681
+ // Check if any IP has >100 attempts (likely brute force)
2682
+ const bruteForce = sorted.filter(([, c]) => c > 100);
2683
+ if (bruteForce.length > 0) {
2684
+ lines.push(`\n ${cc.red}${cc.bold}⚠ BRUTE FORCE DETECTED:${cc.reset} ${bruteForce.length} IP(s) with 100+ attempts`);
2685
+ lines.push(` ${cc.dim}Block with: "block ip ${bruteForce[0][0]}" or "enable fail2ban"${cc.reset}`);
2686
+ }
2687
+ }
2688
+ else {
2689
+ lines.push(` ${cc.green}✓${cc.reset} No failed SSH login attempts`);
2690
+ }
2691
+ }
2692
+ else {
2693
+ lines.push(` ${cc.dim}No auth log found (may need root access)${cc.reset}`);
2694
+ }
2695
+ // Check fail2ban status
2696
+ const fail2ban = await runLocalCommand("fail2ban-client status sshd 2>/dev/null").catch(() => "");
2697
+ if (fail2ban.includes("Banned")) {
2698
+ const bannedMatch = fail2ban.match(/Currently banned:\s*(\d+)/);
2699
+ const bannedIPs = fail2ban.match(/Banned IP list:\s*(.*)/);
2700
+ lines.push(` ${cc.green}✓${cc.reset} fail2ban active — ${bannedMatch?.[1] ?? "?"} IP(s) banned`);
2701
+ if (bannedIPs?.[1]?.trim())
2702
+ lines.push(` ${cc.dim}Banned: ${bannedIPs[1].trim()}${cc.reset}`);
2703
+ }
2704
+ else {
2705
+ lines.push(` ${cc.yellow}○${cc.reset} fail2ban not active ${cc.dim}(recommend: "install fail2ban")${cc.reset}`);
2706
+ }
2707
+ // 3. Active network connections — look for DDoS patterns
2708
+ lines.push(`\n ${cc.bold}── Network Connections ──${cc.reset}`);
2709
+ const connections = await runLocalCommand("ss -tun state established 2>/dev/null | awk '{print $5}' | grep -oP '\\d+\\.\\d+\\.\\d+\\.\\d+' | sort | uniq -c | sort -rn | head -15").catch(() => "");
2710
+ if (connections.trim()) {
2711
+ const connLines = connections.trim().split("\n");
2712
+ let totalConns = 0;
2713
+ let suspiciousConns = 0;
2714
+ lines.push(` ${cc.bold}Active connections by source IP:${cc.reset}`);
2715
+ for (const cl of connLines) {
2716
+ const match = cl.trim().match(/(\d+)\s+(\d+\.\d+\.\d+\.\d+)/);
2717
+ if (!match)
2718
+ continue;
2719
+ const [, countStr, ip] = match;
2720
+ const count = parseInt(countStr);
2721
+ totalConns += count;
2722
+ const isMe = myIPs.has(ip);
2723
+ const severity = count > 100 ? cc.red : count > 20 ? cc.yellow : cc.dim;
2724
+ if (count > 20 && !isMe)
2725
+ suspiciousConns++;
2726
+ lines.push(` ${severity}${count.toString().padStart(5)} connections${cc.reset} from ${cc.bold}${ip}${cc.reset}${isMe ? ` ${cc.green}← you${cc.reset}` : count > 50 ? ` ${cc.red}⚠ suspicious${cc.reset}` : ""}`);
2727
+ }
2728
+ lines.push(` ${cc.dim}Total: ${totalConns} established connections${cc.reset}`);
2729
+ if (suspiciousConns > 0) {
2730
+ lines.push(` ${cc.red}${cc.bold}⚠ ${suspiciousConns} source(s) with unusually high connection count${cc.reset}`);
2731
+ }
2732
+ }
2733
+ else {
2734
+ lines.push(` ${cc.dim}No established connections (or ss not available)${cc.reset}`);
2735
+ }
2736
+ // 4. Check listening ports for unexpected services
2737
+ lines.push(`\n ${cc.bold}── Open Ports ──${cc.reset}`);
2738
+ const listening = await runLocalCommand("ss -tlnp 2>/dev/null | grep LISTEN | awk '{print $4, $6}' | head -15").catch(() => "");
2739
+ if (listening.trim()) {
2740
+ const knownPorts = { "22": "SSH", "53": "DNS", "80": "HTTP", "443": "HTTPS", "111": "RPC", "3000": "Dev server", "3306": "MySQL", "5432": "PostgreSQL", "6379": "Redis", "8080": "HTTP alt", "8443": "HTTPS alt", "9090": "Prometheus", "11434": "Ollama", "18789": "OpenClaw", "18791": "OpenClaw ws" };
2741
+ for (const l of listening.trim().split("\n")) {
2742
+ const portMatch = l.match(/:(\d+)\s/);
2743
+ const procMatch = l.match(/users:\(\("([^"]+)"/);
2744
+ if (portMatch) {
2745
+ const port = portMatch[1];
2746
+ const proc = procMatch?.[1] ?? "unknown";
2747
+ const known = knownPorts[port];
2748
+ lines.push(` ${known ? cc.green + "✓" : cc.yellow + "?"}${cc.reset} :${port} ${cc.bold}${proc}${cc.reset}${known ? ` ${cc.dim}(${known})${cc.reset}` : ` ${cc.yellow}← unknown service${cc.reset}`}`);
2749
+ }
2750
+ }
2751
+ }
2752
+ // 5. Web server access — check for request floods
2753
+ lines.push(`\n ${cc.bold}── Web Traffic ──${cc.reset}`);
2754
+ const webLog = await runLocalCommand("tail -500 /var/log/nginx/access.log 2>/dev/null || tail -500 /var/log/apache2/access.log 2>/dev/null || tail -500 /var/log/httpd/access_log 2>/dev/null").catch(() => "");
2755
+ if (webLog.trim()) {
2756
+ const webIPs = {};
2757
+ for (const m of webLog.matchAll(/^(\d+\.\d+\.\d+\.\d+)/gm)) {
2758
+ webIPs[m[1]] = (webIPs[m[1]] || 0) + 1;
2759
+ }
2760
+ const webSorted = Object.entries(webIPs).sort((a, b) => b[1] - a[1]);
2761
+ const totalReqs = Object.values(webIPs).reduce((a, b) => a + b, 0);
2762
+ lines.push(` ${cc.dim}${totalReqs} requests in recent logs${cc.reset}`);
2763
+ // Show top requesters
2764
+ const floodThreshold = totalReqs * 0.5; // If one IP makes >50% of requests
2765
+ for (const [ip, count] of webSorted.slice(0, 5)) {
2766
+ const isMe = myIPs.has(ip);
2767
+ const isFlood = count > floodThreshold && count > 50;
2768
+ lines.push(` ${isFlood ? cc.red + "⚠" : cc.dim + " "}${cc.reset} ${count.toString().padStart(5)} requests from ${cc.bold}${ip}${cc.reset}${isMe ? ` ${cc.green}← you${cc.reset}` : ""}${isFlood ? ` ${cc.red}FLOOD${cc.reset}` : ""}`);
2769
+ }
2770
+ }
2771
+ else {
2772
+ lines.push(` ${cc.dim}No web server logs found${cc.reset}`);
2773
+ }
2774
+ // 6. Initial assessment summary
2775
+ lines.push(`\n${cc.bold}${cc.cyan}── Initial Assessment ──${cc.reset}`);
2776
+ const hasBruteForce = authLog.split("\n").length > 50;
2777
+ const hasFail2ban = fail2ban.includes("Banned");
2778
+ if (!authLog && !webLog.trim() && !connections.trim()) {
2779
+ lines.push(` ${cc.green}✓ No attack indicators found. System looks clean.${cc.reset}`);
2780
+ }
2781
+ else if (hasBruteForce && !hasFail2ban) {
2782
+ lines.push(` ${cc.yellow}⚠ SSH brute force attempts detected — recommend enabling fail2ban${cc.reset}`);
2783
+ }
2784
+ else if (hasFail2ban) {
2785
+ lines.push(` ${cc.green}✓ fail2ban is active and blocking attackers.${cc.reset}`);
2786
+ }
2787
+ else {
2788
+ lines.push(` ${cc.green}✓ No significant attack patterns detected.${cc.reset}`);
2789
+ }
2790
+ // 7. Security tools — detect, install if missing, run deep scans
2791
+ lines.push(`\n${cc.bold}${cc.cyan}── Security Tools (Linux/WSL) ──${cc.reset}`);
2792
+ const tools = [
2793
+ { name: "rkhunter", pkg: "rkhunter", check: "which rkhunter", scan: "rkhunter --check --skip-keypress --report-warnings-only 2>&1", description: "Rootkit scanner" },
2794
+ { name: "chkrootkit", pkg: "chkrootkit", check: "which chkrootkit", scan: "chkrootkit 2>&1 | grep -i 'INFECTED\\|warning\\|suspicious' || echo 'No infections found'", description: "Rootkit checker" },
2795
+ { name: "ClamAV", pkg: "clamav", check: "which clamscan", scan: "freshclam --quiet 2>/dev/null; clamscan --recursive --infected --no-summary /tmp /var/tmp /home 2>&1 | head -20", description: "Antivirus scanner" },
2796
+ { name: "Lynis", pkg: "lynis", check: "which lynis", scan: "lynis audit system --quick --no-colors 2>&1 | tail -30", description: "Security auditing tool" },
2797
+ { name: "fail2ban", pkg: "fail2ban", check: "which fail2ban-client", scan: "fail2ban-client status 2>&1", description: "Intrusion prevention" },
2798
+ ];
2799
+ const installed = [];
2800
+ const missing = [];
2801
+ for (const tool of tools) {
2802
+ const exists = await runLocalCommand(`${tool.check} 2>/dev/null`).catch(() => "");
2803
+ if (exists.trim()) {
2804
+ installed.push(tool);
2805
+ lines.push(` ${cc.green}✓${cc.reset} ${cc.bold}${tool.name}${cc.reset} — ${tool.description}`);
2806
+ }
2807
+ else {
2808
+ missing.push(tool);
2809
+ lines.push(` ${cc.dim}○ ${tool.name}${cc.reset} — ${tool.description} ${cc.dim}(not installed)${cc.reset}`);
2810
+ }
2811
+ }
2812
+ // Print initial assessment first
2813
+ console.log(lines.join("\n"));
2814
+ // Auto-install missing tools
2815
+ if (missing.length > 0) {
2816
+ console.log(`\n ${cc.cyan}Installing ${missing.length} missing security tool(s)...${cc.reset}`);
2817
+ const pkgMgr = await runLocalCommand("which apt-get >/dev/null 2>&1 && echo apt || which dnf >/dev/null 2>&1 && echo dnf || which yum >/dev/null 2>&1 && echo yum || echo none").catch(() => "none");
2818
+ const mgr = pkgMgr.trim();
2819
+ for (const tool of missing) {
2820
+ if (mgr === "none") {
2821
+ console.log(` ${cc.yellow}⚠${cc.reset} Can't auto-install ${tool.name} — no package manager found`);
2822
+ continue;
2823
+ }
2824
+ const installCmd = mgr === "apt" ? `DEBIAN_FRONTEND=noninteractive apt-get install -y ${tool.pkg}` : `${mgr} install -y ${tool.pkg}`;
2825
+ console.log(` ${cc.dim}Installing ${tool.name}...${cc.reset}`);
2826
+ try {
2827
+ await runLocalCommand(`${installCmd} 2>&1`, 120_000);
2828
+ const verify = await runLocalCommand(`${tool.check} 2>/dev/null`).catch(() => "");
2829
+ if (verify.trim()) {
2830
+ console.log(` ${cc.green}✓${cc.reset} ${tool.name} installed`);
2831
+ installed.push(tool);
2832
+ // Special: update rkhunter database after install
2833
+ if (tool.name === "rkhunter")
2834
+ await runLocalCommand("rkhunter --update --propupd 2>/dev/null").catch(() => "");
2835
+ // Special: update ClamAV database after install
2836
+ if (tool.name === "ClamAV") {
2837
+ console.log(` ${cc.dim}Updating virus definitions...${cc.reset}`);
2838
+ await runLocalCommand("freshclam --quiet 2>/dev/null", 120_000).catch(() => "");
2839
+ }
2840
+ }
2841
+ else {
2842
+ console.log(` ${cc.yellow}⚠${cc.reset} ${tool.name} install may have failed`);
2843
+ }
2844
+ }
2845
+ catch {
2846
+ console.log(` ${cc.yellow}⚠${cc.reset} Could not install ${tool.name}`);
2847
+ }
2848
+ }
2849
+ }
2850
+ // Run deep scans as background jobs
2851
+ if (installed.length > 0) {
2852
+ console.log(`\n${cc.bold}${cc.cyan}── Running Deep Scans ──${cc.reset}`);
2853
+ console.log(` ${cc.dim}Scans run in background — results will appear when done.${cc.reset}`);
2854
+ console.log(` ${cc.dim}Type "/jobs" to check progress.${cc.reset}\n`);
2855
+ const scanResults = [];
2856
+ const scanPromises = [];
2857
+ for (const tool of installed) {
2858
+ // Skip fail2ban — it's a service, not a scanner
2859
+ if (tool.name === "fail2ban")
2860
+ continue;
2861
+ console.log(` ${cc.cyan}↗${cc.reset} Starting ${cc.bold}${tool.name}${cc.reset} scan...`);
2862
+ const scanPromise = (async () => {
2863
+ try {
2864
+ const output = await runLocalCommand(tool.scan, 300_000);
2865
+ const trimmed = output.trim();
2866
+ const hasIssues = /infected|warning|suspicious|rootkit|backdoor|trojan/i.test(trimmed);
2867
+ scanResults.push(`\n${hasIssues ? cc.red + "⚠" : cc.green + "✓"}${cc.reset} ${cc.bold}${tool.name} results:${cc.reset}`);
2868
+ // Show first 15 lines of output
2869
+ const outputLines = trimmed.split("\n").slice(0, 15);
2870
+ for (const ol of outputLines) {
2871
+ scanResults.push(` ${cc.dim}${ol}${cc.reset}`);
2872
+ }
2873
+ if (trimmed.split("\n").length > 15) {
2874
+ scanResults.push(` ${cc.dim}... (${trimmed.split("\n").length - 15} more lines)${cc.reset}`);
2875
+ }
2876
+ }
2877
+ catch (err) {
2878
+ scanResults.push(` ${cc.yellow}⚠${cc.reset} ${tool.name} scan error: ${err.message.split("\n")[0]}`);
2879
+ }
2880
+ })();
2881
+ scanPromises.push(scanPromise);
2882
+ }
2883
+ // Wait for all scans to complete
2884
+ await Promise.all(scanPromises);
2885
+ // Print all results
2886
+ console.log(`\n${cc.bold}${cc.cyan}── Scan Results ──${cc.reset}`);
2887
+ for (const r of scanResults)
2888
+ console.log(r);
2889
+ // Final verdict
2890
+ const allClean = !scanResults.some(r => /⚠/.test(r) && /infected|warning|suspicious|rootkit/i.test(r));
2891
+ // Windows-side scanning (when in WSL)
2892
+ if (secIsWSL) {
2893
+ console.log(`\n${cc.bold}${cc.cyan}── Windows Security ──${cc.reset}`);
2894
+ // Windows Defender status
2895
+ console.log(` ${cc.dim}Checking Windows Defender...${cc.reset}`);
2896
+ const defenderStatus = await runLocalCommand("powershell.exe -Command \"Get-MpComputerStatus | Select-Object -Property AntivirusEnabled,RealTimeProtectionEnabled,AntivirusSignatureLastUpdated | Format-List\" 2>/dev/null").catch(() => "");
2897
+ if (defenderStatus.includes("AntivirusEnabled")) {
2898
+ const avEnabled = defenderStatus.includes("AntivirusEnabled") && defenderStatus.includes("True");
2899
+ const rtEnabled = defenderStatus.includes("RealTimeProtectionEnabled") && /RealTimeProtectionEnabled\s*:\s*True/i.test(defenderStatus);
2900
+ const sigDate = defenderStatus.match(/AntivirusSignatureLastUpdated\s*:\s*(.+)/)?.[1]?.trim() ?? "unknown";
2901
+ console.log(` ${avEnabled ? cc.green + "✓" : cc.red + "✗"}${cc.reset} Windows Defender: ${avEnabled ? "enabled" : "disabled"}`);
2902
+ console.log(` ${rtEnabled ? cc.green + "✓" : cc.yellow + "⚠"}${cc.reset} Real-time protection: ${rtEnabled ? "on" : "off"}`);
2903
+ console.log(` ${cc.dim}Signatures updated: ${sigDate}${cc.reset}`);
2904
+ }
2905
+ else {
2906
+ console.log(` ${cc.dim}Could not check Windows Defender status${cc.reset}`);
2907
+ }
2908
+ // Recent threats detected by Defender
2909
+ const threats = await runLocalCommand("powershell.exe -Command \"Get-MpThreatDetection | Select-Object -First 5 -Property ThreatID,DomainUser,ProcessName,ActionSuccess | Format-Table -AutoSize\" 2>/dev/null").catch(() => "");
2910
+ if (threats.trim() && !threats.includes("No threats")) {
2911
+ const threatLines = threats.trim().split("\n").filter(l => l.trim());
2912
+ if (threatLines.length > 2) { // Has header + data
2913
+ console.log(` ${cc.yellow}⚠${cc.reset} ${cc.bold}Recent threats detected by Defender:${cc.reset}`);
2914
+ for (const tl of threatLines.slice(0, 7)) {
2915
+ console.log(` ${cc.dim}${tl.trim()}${cc.reset}`);
2916
+ }
2917
+ }
2918
+ else {
2919
+ console.log(` ${cc.green}✓${cc.reset} No recent threats detected by Defender`);
2920
+ }
2921
+ }
2922
+ else {
2923
+ console.log(` ${cc.green}✓${cc.reset} No recent threats detected by Defender`);
2924
+ }
2925
+ // Windows failed logins from Security Event Log
2926
+ const winLogins = await runLocalCommand("powershell.exe -Command \"Get-WinEvent -FilterHashtable @{LogName='Security';ID=4625} -MaxEvents 10 2>\\$null | Select-Object -Property TimeCreated,Message | Format-List\" 2>/dev/null").catch(() => "");
2927
+ if (winLogins.trim() && !winLogins.includes("No events")) {
2928
+ const failCount = (winLogins.match(/TimeCreated/g) || []).length;
2929
+ if (failCount > 0) {
2930
+ console.log(` ${cc.yellow}⚠${cc.reset} ${failCount} failed Windows login attempt(s) in event log`);
2931
+ }
2932
+ else {
2933
+ console.log(` ${cc.green}✓${cc.reset} No failed Windows login attempts`);
2934
+ }
2935
+ }
2936
+ else {
2937
+ console.log(` ${cc.green}✓${cc.reset} No failed Windows login attempts`);
2938
+ }
2939
+ // Windows network connections
2940
+ const winConns = await runLocalCommand("powershell.exe -Command \"Get-NetTCPConnection -State Established | Group-Object RemoteAddress | Sort-Object Count -Descending | Select-Object -First 10 Count,Name | Format-Table -AutoSize\" 2>/dev/null").catch(() => "");
2941
+ if (winConns.trim()) {
2942
+ console.log(` ${cc.bold}Top Windows connections:${cc.reset}`);
2943
+ for (const wl of winConns.trim().split("\n").slice(0, 8)) {
2944
+ if (wl.trim())
2945
+ console.log(` ${cc.dim}${wl.trim()}${cc.reset}`);
2946
+ }
2947
+ }
2948
+ // Quick Defender scan option
2949
+ console.log(`\n ${cc.dim}Run Windows Defender quick scan: "scan windows for viruses"${cc.reset}`);
2950
+ console.log(` ${cc.dim}Full scan: powershell.exe -Command "Start-MpScan -ScanType QuickScan"${cc.reset}`);
2951
+ }
2952
+ console.log(`\n${cc.bold}${cc.cyan}── Final Verdict ──${cc.reset}`);
2953
+ if (allClean) {
2954
+ console.log(` ${cc.green}${cc.bold}✓ All scans passed. No threats detected.${cc.reset}`);
2955
+ }
2956
+ else {
2957
+ console.log(` ${cc.red}${cc.bold}⚠ Issues found — review scan results above.${cc.reset}`);
2958
+ }
2959
+ console.log(` ${cc.dim}For ongoing protection: "install fail2ban", "enable firewall"${cc.reset}`);
2960
+ }
2961
+ return "";
2962
+ }
2963
+ // Casual chat responses — loaded from config/chat-responses.json
2964
+ if (intent.intent.startsWith("chat.")) {
2965
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", cyan: "\x1b[36m", yellow: "\x1b[33m" };
2966
+ const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
2967
+ // Load responses from JSON — user can customize
2968
+ let responses = {};
2969
+ try {
2970
+ const { readFileSync, existsSync } = await import("node:fs");
2971
+ const { resolve } = await import("node:path");
2972
+ for (const p of [
2973
+ resolve(process.env.NOTOKEN_HOME ?? `${process.env.HOME}/.notoken`, "chat-responses.json"),
2974
+ resolve(process.cwd(), "packages/core/config/chat-responses.json"),
2975
+ resolve(process.cwd(), "config/chat-responses.json"),
2976
+ ]) {
2977
+ if (existsSync(p)) {
2978
+ responses = JSON.parse(readFileSync(p, "utf-8")).responses ?? {};
2979
+ break;
2980
+ }
2981
+ }
2982
+ }
2983
+ catch { /* use empty — fallback below */ }
2984
+ // Template variables for dynamic responses
2985
+ const fillTemplate = async (text) => {
2986
+ let filled = text;
2987
+ if (filled.includes("{{uptime}}")) {
2988
+ const up = await runLocalCommand("uptime -p 2>/dev/null || uptime").catch(() => "unknown");
2989
+ filled = filled.replace(/\{\{uptime\}\}/g, up.trim().replace("up ", ""));
2990
+ }
2991
+ if (filled.includes("{{load}}")) {
2992
+ const ld = await runLocalCommand("cat /proc/loadavg 2>/dev/null | awk '{print $1}'").catch(() => "?");
2993
+ filled = filled.replace(/\{\{load\}\}/g, ld.trim());
2994
+ }
2995
+ if (filled.includes("{{llm}}")) {
2996
+ const { getLLMBackend } = await import("../nlp/llmFallback.js");
2997
+ const llm = getLLMBackend();
2998
+ filled = filled.replace(/\{\{llm\}\}/g, llm ? `LLM: ${llm}.` : "");
2999
+ }
3000
+ if (filled.includes("{{loadStatus}}")) {
3001
+ const ld = await runLocalCommand("cat /proc/loadavg 2>/dev/null | awk '{print $1}'").catch(() => "0");
3002
+ filled = filled.replace(/\{\{loadStatus\}\}/g, parseFloat(ld) < 2 ? "System is chill." : "System's a bit busy.");
3003
+ }
3004
+ if (filled.includes("{{intentCount}}")) {
3005
+ const { loadIntents } = await import("../utils/config.js");
3006
+ filled = filled.replace(/\{\{intentCount\}\}/g, String(loadIntents().length));
3007
+ }
3008
+ return filled;
3009
+ };
3010
+ const chatType = intent.intent.replace("chat.", "");
3011
+ let key = chatType;
3012
+ // Empathy subtypes
3013
+ if (chatType === "empathy") {
3014
+ if (/frustrat|sucks|broken|doesn'?t work/i.test(intent.rawText))
3015
+ key = "empathy_frustrated";
3016
+ else if (/tired|bored/i.test(intent.rawText))
3017
+ key = "empathy_tired";
3018
+ else if (/confused|stuck|lost/i.test(intent.rawText))
3019
+ key = "empathy_confused";
3020
+ else
3021
+ key = "empathy_frustrated"; // default
3022
+ }
3023
+ const pool = responses[key];
3024
+ if (pool && pool.length > 0) {
3025
+ const raw = pick(pool);
3026
+ const filled = await fillTemplate(raw);
3027
+ // Add ANSI coloring
3028
+ const colorized = filled
3029
+ .replace(/^([^.!?\n]+[.!?])/, `${cc.green}$1${cc.reset}`) // First sentence green
3030
+ .replace(/\n/g, `\n `); // Indent continuation
3031
+ return `\n ${colorized}`;
3032
+ }
3033
+ // Fallback if no JSON responses loaded
3034
+ return `${cc.cyan}I'm NoToken.${cc.reset} Type "help" to see what I can do.`;
3035
+ }
3036
+ // Entity define/list
3037
+ if (intent.intent === "entity.define")
3038
+ return learnEntity(intent.rawText) ?? "Could not understand. Try: 'metroplex is 66.94.115.165'";
3039
+ if (intent.intent === "entity.list")
3040
+ return listEntities();
3041
+ // Disk cleanup / scan
3042
+ if (intent.intent === "disk.cleanup") {
3043
+ const targets = await withSpinner("Scanning disk...", () => scanForCleanup());
3044
+ console.log(formatCleanupTable(targets));
3045
+ return targets.length > 0 ? await runInteractiveCleanup(targets) : "";
3046
+ }
3047
+ if (intent.intent === "disk.scan") {
3048
+ const drives = await withSpinner("Scanning drives...", () => smartDriveScan());
3049
+ return await formatDriveScan(drives, false);
3050
+ }
3051
+ // Database query
3052
+ if (intent.intent === "db.query" || intent.intent === "db.tables" || intent.intent === "db.describe") {
3053
+ let dbType = "postgres";
3054
+ try {
3055
+ await runLocalCommand("which psql");
3056
+ }
3057
+ catch {
3058
+ try {
3059
+ await runLocalCommand("which mysql");
3060
+ dbType = "mysql";
3061
+ }
3062
+ catch { /* */ }
3063
+ }
3064
+ const qr = buildQuery(intent.rawText, fields, dbType);
3065
+ if (!qr.query)
3066
+ return qr.explanation;
3067
+ console.log(formatQueryPlan(qr));
3068
+ const { askForConfirmation } = await import("../policy/confirm.js");
3069
+ if (!(await askForConfirmation("\nRun this query?")))
3070
+ return "\x1b[2mCancelled.\x1b[0m";
3071
+ return isLocal ? await withSpinner("Running...", () => runLocalCommand(qr.command)) : await withSpinner(`Running on ${environment}...`, () => runRemoteCommand(environment, qr.command));
3072
+ }
3073
+ // Project detect/install/update/run
3074
+ if (intent.intent === "project.detect") {
3075
+ const p = detectProjectsNew();
3076
+ const i = readProjectConfig();
3077
+ return formatProjectDetection(p) + (i ? "\n" + formatPackageScripts(i) : "");
3078
+ }
3079
+ if (intent.intent === "project.install") {
3080
+ const p = detectProjectsNew();
3081
+ if (!p.length)
3082
+ return "No project found.";
3083
+ return await withSpinner(`${p[0].installCmd}...`, () => runLocalCommand(p[0].installCmd));
3084
+ }
3085
+ if (intent.intent === "project.update") {
3086
+ const p = detectProjectsNew();
3087
+ if (!p.length)
3088
+ return "No project found.";
3089
+ return await withSpinner(`${p[0].updateCmd}...`, () => runLocalCommand(p[0].updateCmd));
3090
+ }
3091
+ if (intent.intent === "project.run") {
3092
+ const s = fields.script ?? "dev";
3093
+ const cmd = getScriptRunCmd(s);
3094
+ if (!cmd) {
3095
+ const i = readProjectConfig();
3096
+ return i ? `"${s}" not found.\n${formatPackageScripts(i)}` : "No project.";
3097
+ }
3098
+ return await runLocalCommand(cmd);
3099
+ }
3100
+ // SSH test
3101
+ if (intent.intent === "ssh.test")
3102
+ return await testSshConnection(environment);
3103
+ // Smart archive (local only)
3104
+ if (intent.intent === "archive.tar" && isLocal) {
3105
+ const source = fields.source ?? process.cwd();
3106
+ const includeAll = intent.rawText.match(/include.?(all|everything)|with.?node.?modules|no.?exclude/i) !== null;
3107
+ command = "[smart-archive]";
3108
+ result = await smartArchive({ source, destination: fields.destination, includeAll });
3109
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
3110
+ return result;
3111
+ }
3112
+ // OpenClaw nvm wrapper for template commands
3113
+ if (intent.intent.startsWith("openclaw.") && def.command.includes("openclaw") && !def.command.startsWith("[")) {
3114
+ const nvmWrap = `for d in "$HOME/.nvm" "/home/"*"/.nvm" "/root/.nvm"; do [ -s "$d/nvm.sh" ] && export NVM_DIR="$d" && . "$d/nvm.sh" && break; done 2>/dev/null; nvm use 22 > /dev/null 2>&1;`;
3115
+ command = interpolateCommand(def, fields);
3116
+ const wrappedCmd = `bash -c '${nvmWrap} ${command}'`;
3117
+ try {
3118
+ result = await withSpinner(`${intent.intent}...`, () => runLocalCommand(wrappedCmd));
3119
+ }
3120
+ catch (err) {
3121
+ result = `\x1b[31m✗ ${err.message.split("\n")[0]}\x1b[0m`;
3122
+ }
3123
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
3124
+ return result;
3125
+ }
3126
+ // ── End ported handlers ────────────────────────────────────────────────────
3127
+ // Smart file reading — size check, sampling, context search
3128
+ if (intent.intent === "file.read" || intent.intent === "file.parse") {
3129
+ const filePath = fields.path ?? "";
3130
+ if (filePath) {
3131
+ command = `[smart-read] ${filePath}`;
3132
+ result = await withSpinner(`Reading ${filePath}...`, () => smartRead(filePath, !isLocal, isLocal ? undefined : environment));
3133
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
3134
+ return result;
3135
+ }
3136
+ }
3137
+ if (intent.intent === "file.search_in") {
3138
+ const filePath = fields.path ?? "";
3139
+ const query = fields.query ?? "";
3140
+ if (filePath && query) {
3141
+ command = `[smart-search] ${query} in ${filePath}`;
3142
+ result = await withSpinner(`Searching "${query}" in ${filePath}...`, () => smartSearch(filePath, query, !isLocal, isLocal ? undefined : environment));
3143
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
3144
+ return result;
3145
+ }
3146
+ }
3147
+ // Knowledge lookup — Wikidata
3148
+ if (intent.intent === "knowledge.lookup") {
3149
+ // Extract the full topic from raw text, not the truncated field
3150
+ const topic = intent.rawText
3151
+ .replace(/^(what|who)\s+(is|are|was|were)\s+/i, "")
3152
+ .replace(/^(tell\s+me\s+about|define|lookup|look\s+up|explain|info\s+about|information\s+about|facts\s+about|learn\s+about|whats\s+a|what\s+are)\s*/i, "")
3153
+ .replace(/\?$/, "").trim()
3154
+ || (fields.topic ?? "");
3155
+ command = `[wiki-lookup] ${topic}`;
3156
+ const wikiResult = await searchWikidata(topic);
3157
+ if (wikiResult.found && wikiResult.entity) {
3158
+ result = formatWikiEntity(wikiResult.entity);
3159
+ }
3160
+ else if (wikiResult.suggestions?.length) {
3161
+ result = formatWikiSuggestions(wikiResult.suggestions);
3162
+ }
3163
+ else {
3164
+ result = `No information found for "${topic}"`;
3165
+ }
3166
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: wikiResult.found });
3167
+ return result;
3168
+ }
3169
+ // Where is everything installed
3170
+ if (intent.intent === "ai.where_installed") {
3171
+ const { detectImageEngines, getDriveInfo } = await import("../utils/imageGen.js");
3172
+ const { execSync: ex } = await import("node:child_process");
3173
+ const tryCmd = (cmd) => { try {
3174
+ return ex(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }).trim();
3175
+ }
3176
+ catch {
3177
+ return null;
3178
+ } };
3179
+ const engines = detectImageEngines();
3180
+ const lines = [];
3181
+ lines.push("\x1b[1m\x1b[36mInstall Locations\x1b[0m\n");
3182
+ // Local SD engines
3183
+ const installed = engines.filter(e => e.installed && e.path);
3184
+ if (installed.length > 0) {
3185
+ lines.push("\x1b[1mLocal Engines:\x1b[0m");
3186
+ for (const e of installed) {
3187
+ const size = tryCmd(`du -sh "${e.path}" 2>/dev/null`)?.split("\t")[0] ?? "?";
3188
+ lines.push(` \x1b[32m✓\x1b[0m \x1b[1m${e.engine}\x1b[0m: ${e.path} (${size})`);
3189
+ }
3190
+ lines.push("");
3191
+ }
3192
+ // Docker images
3193
+ const dockerImages = tryCmd("docker images --format '{{.Repository}}:{{.Tag}} {{.Size}} {{.ID}}' 2>/dev/null | grep -i 'stable-diffusion\\|ai-dock\\|comfyui\\|sd-webui'");
3194
+ if (dockerImages) {
3195
+ lines.push("\x1b[1mDocker Images:\x1b[0m");
3196
+ for (const img of dockerImages.split("\n").filter(l => l.trim())) {
3197
+ lines.push(` \x1b[36m${img}\x1b[0m`);
3198
+ }
3199
+ lines.push("");
3200
+ }
3201
+ // Docker data root
3202
+ const dockerRoot = tryCmd("docker info 2>/dev/null | grep 'Docker Root Dir' | awk '{print $NF}'");
3203
+ if (dockerRoot) {
3204
+ const dockerDrive = getDriveInfo(dockerRoot);
3205
+ lines.push(`\x1b[1mDocker Data:\x1b[0m ${dockerRoot}`);
3206
+ if (dockerDrive)
3207
+ lines.push(` Drive: ${dockerDrive.mount} — ${dockerDrive.freeGB}GB free (${dockerDrive.usedPct}% used)`);
3208
+ const dockerSize = tryCmd(`du -sh ${dockerRoot} 2>/dev/null`)?.split("\t")[0];
3209
+ if (dockerSize)
3210
+ lines.push(` Total Docker disk usage: ${dockerSize}`);
3211
+ lines.push("");
3212
+ }
3213
+ // Models
3214
+ const modelsDirs = [
3215
+ ...engines.filter(e => e.path).map(e => `${e.path}/models`),
3216
+ ];
3217
+ for (const mDir of modelsDirs) {
3218
+ const models = tryCmd(`find "${mDir}" -name "*.safetensors" -o -name "*.ckpt" 2>/dev/null`);
3219
+ if (models) {
3220
+ lines.push(`\x1b[1mAI Models:\x1b[0m`);
3221
+ for (const m of models.split("\n").filter(l => l.trim())) {
3222
+ const size = tryCmd(`du -sh "${m}" 2>/dev/null`)?.split("\t")[0] ?? "?";
3223
+ lines.push(` ${size}\t${m}`);
3224
+ }
3225
+ lines.push("");
3226
+ }
3227
+ }
3228
+ // Generated images
3229
+ const genDir = (await import("../utils/paths.js")).USER_HOME + "/generated-images";
3230
+ const imgCount = tryCmd(`ls "${genDir}"/*.png 2>/dev/null | wc -l`)?.trim() ?? "0";
3231
+ const imgSize = tryCmd(`du -sh "${genDir}" 2>/dev/null`)?.split("\t")[0] ?? "0";
3232
+ lines.push(`\x1b[1mGenerated Images:\x1b[0m ${genDir}`);
3233
+ lines.push(` ${imgCount} images (${imgSize})`);
3234
+ result = lines.join("\n");
3235
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command: "[ai-where]", environment, success: true });
3236
+ return result;
3237
+ }
3238
+ // GPU info
3239
+ // Full SD diagnosis — end-to-end test: check, restart, generate, monitor
3240
+ if (intent.intent === "ai.diagnose") {
3241
+ const { detectGpu, detectImageEngines, getDriveInfo, generateImage } = await import("../utils/imageGen.js");
3242
+ const { execSync: exDiag, spawn: spawnDiag } = await import("node:child_process");
3243
+ const tryCmd = (cmd) => { try {
3244
+ return exDiag(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }).trim();
3245
+ }
3246
+ catch {
3247
+ return null;
3248
+ } };
3249
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
3250
+ const say = (msg) => { console.error(msg); lines.push(msg); };
3251
+ const lines = [];
3252
+ let issues = 0;
3253
+ const wantsRestart = /restart/i.test(intent.rawText);
3254
+ say(`\n${cc.bold}${cc.cyan}Running Image Generation Diagnosis...${cc.reset}\n`);
3255
+ // 1. GPU
3256
+ say(`${cc.dim}Checking GPU...${cc.reset}`);
3257
+ const gpu = detectGpu();
3258
+ if (gpu.hasNvidia) {
3259
+ lines.push(`${cc.green}✓${cc.reset} GPU: ${gpu.gpuName} (${gpu.vram})`);
3260
+ if (gpu.vramFree)
3261
+ lines.push(` VRAM free: ${gpu.vramFree} | Temp: ${gpu.gpuTemp ?? "?"} | Util: ${gpu.gpuUtil ?? "?"}`);
3262
+ if (gpu.gpuError) {
3263
+ lines.push(` ${cc.yellow}⚠ ${gpu.gpuError}${cc.reset}`);
3264
+ issues++;
3265
+ }
3266
+ }
3267
+ else {
3268
+ lines.push(`${cc.yellow}⚠ No GPU — CPU mode only${cc.reset}`);
3269
+ issues++;
3270
+ }
3271
+ // 2. Engine installed?
3272
+ say(`${cc.dim}Checking if image engine is installed...${cc.reset}`);
3273
+ const engines = detectImageEngines();
3274
+ const installed = engines.find(e => e.installed && e.path && e.engine !== "docker");
3275
+ if (installed) {
3276
+ lines.push(`${cc.green}✓${cc.reset} Engine: ${installed.engine} at ${installed.path}`);
3277
+ const du = tryCmd(`du -sh "${installed.path}" 2>/dev/null`);
3278
+ if (du)
3279
+ lines.push(` Size: ${du.split("\t")[0]}`);
3280
+ }
3281
+ else {
3282
+ lines.push(`${cc.red}✗ No local engine installed${cc.reset}`);
3283
+ lines.push(` Fix: ${cc.cyan}notoken install stable-diffusion${cc.reset}`);
3284
+ issues++;
3285
+ }
3286
+ // 3. Model downloaded?
3287
+ say(`${cc.dim}Checking if AI model is downloaded...${cc.reset}`);
3288
+ if (installed?.path) {
3289
+ const { readdirSync: rd } = await import("node:fs");
3290
+ const modelsDir = `${installed.path}/models/Stable-diffusion`;
3291
+ try {
3292
+ const models = rd(modelsDir).filter((f) => f.endsWith(".safetensors") || f.endsWith(".ckpt"));
3293
+ if (models.length > 0) {
3294
+ lines.push(`${cc.green}✓${cc.reset} Model: ${models[0]}`);
3295
+ }
3296
+ else {
3297
+ lines.push(`${cc.red}✗ No model downloaded${cc.reset}`);
3298
+ lines.push(` Fix: model will download on first launch, or manually download SD 1.5`);
3299
+ issues++;
3300
+ }
3301
+ }
3302
+ catch {
3303
+ lines.push(`${cc.yellow}⚠ Cannot check models directory${cc.reset}`);
3304
+ }
3305
+ }
3306
+ // 4. API running?
3307
+ say(`${cc.dim}Checking if server is responding...${cc.reset}`);
3308
+ const apiUp = !!tryCmd("curl -sf --max-time 3 http://localhost:7860/sdapi/v1/sd-models 2>/dev/null");
3309
+ if (apiUp) {
3310
+ lines.push(`${cc.green}✓${cc.reset} API: running at http://localhost:7860`);
3311
+ // Check what mode
3312
+ const flags = tryCmd("curl -sf --max-time 3 http://localhost:7860/sdapi/v1/cmd-flags 2>/dev/null");
3313
+ const cpuMode = flags?.includes('"skip_torch_cuda_test":true');
3314
+ lines.push(` Mode: ${cpuMode ? "CPU" : "GPU"}`);
3315
+ // Check progress (is it busy?)
3316
+ const prog = tryCmd("curl -sf --max-time 3 http://localhost:7860/sdapi/v1/progress 2>/dev/null");
3317
+ if (prog) {
3318
+ try {
3319
+ const p = JSON.parse(prog);
3320
+ if (p.progress > 0 && p.progress < 1) {
3321
+ lines.push(` ${cc.cyan}Currently generating: ${Math.round(p.progress * 100)}%${cc.reset}`);
3322
+ }
3323
+ }
3324
+ catch { }
3325
+ }
3326
+ }
3327
+ else {
3328
+ lines.push(`${cc.red}✗ API: not responding at localhost:7860${cc.reset}`);
3329
+ // Check if process exists
3330
+ const proc = tryCmd("ps aux | grep launch.py | grep -v grep | head -1");
3331
+ if (proc) {
3332
+ lines.push(` ${cc.yellow}Process running but API not responding — may still be loading${cc.reset}`);
3333
+ lines.push(` ${cc.dim}Wait a minute and check again: "image status"${cc.reset}`);
3334
+ }
3335
+ else {
3336
+ lines.push(` ${cc.dim}Engine not running.${cc.reset}`);
3337
+ if (wantsRestart && installed) {
3338
+ lines.push(`\n ${cc.cyan}Restarting...${cc.reset}`);
3339
+ }
3340
+ else {
3341
+ lines.push(` Fix: ${cc.cyan}"start sd"${cc.reset} or ${cc.cyan}"restart sd"${cc.reset}`);
3342
+ }
3343
+ }
3344
+ issues++;
3345
+ }
3346
+ // 5. Disk space
3347
+ if (installed?.path) {
3348
+ const drive = getDriveInfo(installed.path);
3349
+ if (drive) {
3350
+ if (drive.freeGB < 5) {
3351
+ lines.push(`${cc.red}✗ Low disk: ${drive.freeGB}GB free on ${drive.mount}${cc.reset}`);
3352
+ issues++;
3353
+ }
3354
+ else {
3355
+ lines.push(`${cc.green}✓${cc.reset} Disk: ${drive.freeGB}GB free on ${drive.mount}`);
3356
+ }
3357
+ }
3358
+ }
3359
+ // 6. Docker
3360
+ const dockerRunning = engines.find(e => e.engine === "docker" && e.running);
3361
+ if (dockerRunning) {
3362
+ lines.push(`${cc.green}✓${cc.reset} Docker SD container running`);
3363
+ }
3364
+ // ── Phase 2: Fix issues automatically ──
3365
+ lines.push("");
3366
+ if (!installed) {
3367
+ lines.push(`${cc.bold}Action: Install needed${cc.reset} — say "install stable diffusion"`);
3368
+ result = lines.join("\n");
3369
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command: "[ai-diagnose]", environment, success: true });
3370
+ return result;
3371
+ }
3372
+ const instPath = installed.path ?? "";
3373
+ // If not running, start it
3374
+ if (!apiUp) {
3375
+ say(`\n${cc.cyan}▶ Server is not running. Starting engine automatically...${cc.reset}`);
3376
+ const { resolve: rp } = await import("node:path");
3377
+ const { existsSync: fe } = await import("node:fs");
3378
+ // Try GPU first, fall back to CPU if errors detected
3379
+ let useGpu = gpu.hasNvidia && !gpu.gpuError;
3380
+ let mode = useGpu ? "GPU" : "CPU";
3381
+ let args = useGpu
3382
+ ? ["launch.py", "--api", "--listen", "--skip-install"]
3383
+ : ["launch.py", "--api", "--listen", "--skip-torch-cuda-test", "--no-half", "--skip-install"];
3384
+ const venvPy = rp(instPath, "venv", "bin", "python");
3385
+ const child = spawnDiag(fe(venvPy) ? venvPy : "python3", args, {
3386
+ cwd: instPath, detached: true, stdio: "ignore",
3387
+ env: { ...process.env, PATH: `/usr/lib/wsl/lib:${process.env.PATH}` },
3388
+ });
3389
+ child.unref();
3390
+ // Wait for API (up to 2 min)
3391
+ lines.push(`${cc.dim} Waiting for API (${mode} mode)...${cc.reset}`);
3392
+ let started = false;
3393
+ for (let i = 0; i < 40; i++) {
3394
+ await new Promise(r => setTimeout(r, 3000));
3395
+ if (tryCmd("curl -sf --max-time 2 http://localhost:7860/sdapi/v1/sd-models >/dev/null 2>&1") !== null) {
3396
+ started = true;
3397
+ break;
3398
+ }
3399
+ // Check for crash
3400
+ const newErrors = parseInt(tryCmd("dmesg 2>/dev/null | grep -ci 'dxgkio_reserve_gpu_va' 2>/dev/null") ?? "0") || 0;
3401
+ if (useGpu && newErrors > (parseInt(tryCmd("echo 1290") ?? "0") || 0)) {
3402
+ lines.push(`${cc.red} ✗ GPU crashed! Switching to CPU mode...${cc.reset}`);
3403
+ try {
3404
+ exDiag("pkill -9 -f launch.py 2>/dev/null", { stdio: "ignore", timeout: 5000 });
3405
+ }
3406
+ catch { }
3407
+ await new Promise(r => setTimeout(r, 3000));
3408
+ useGpu = false;
3409
+ mode = "CPU";
3410
+ args = ["launch.py", "--api", "--listen", "--skip-torch-cuda-test", "--no-half", "--skip-install"];
3411
+ const child2 = spawnDiag(fe(venvPy) ? venvPy : "python3", args, {
3412
+ cwd: instPath, detached: true, stdio: "ignore",
3413
+ env: { ...process.env, PATH: `/usr/lib/wsl/lib:${process.env.PATH}` },
3414
+ });
3415
+ child2.unref();
3416
+ lines.push(`${cc.dim} Retrying in CPU mode...${cc.reset}`);
3417
+ }
3418
+ if (i % 10 === 9)
3419
+ lines.push(`${cc.dim} Still loading... (${(i + 1) * 3}s)${cc.reset}`);
3420
+ }
3421
+ if (started) {
3422
+ lines.push(`${cc.green} ✓ Engine started in ${mode} mode${cc.reset}`);
3423
+ }
3424
+ else {
3425
+ lines.push(`${cc.red} ✗ Engine did not start after 2 minutes${cc.reset}`);
3426
+ lines.push(`${cc.dim} Check log: tail ~/.notoken/.sd-forge.log${cc.reset}`);
3427
+ result = lines.join("\n");
3428
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command: "[ai-diagnose]", environment, success: false });
3429
+ return result;
3430
+ }
3431
+ }
3432
+ // ── Phase 3: Test generation ──
3433
+ say("");
3434
+ say(`${cc.cyan}▶ Now testing: generating a test image to verify everything works...${cc.reset}`);
3435
+ // Check memory before
3436
+ say(`${cc.dim}Checking GPU memory usage before generation...${cc.reset}`);
3437
+ const memBefore = tryCmd("PATH=/usr/lib/wsl/lib:$PATH nvidia-smi --query-gpu=memory.used --format=csv,noheader 2>/dev/null");
3438
+ if (memBefore)
3439
+ lines.push(`${cc.dim} VRAM before: ${memBefore}${cc.reset}`);
3440
+ const testResult = await generateImage("test image: a red circle on white background");
3441
+ // Check memory during/after
3442
+ const memAfter = tryCmd("PATH=/usr/lib/wsl/lib:$PATH nvidia-smi --query-gpu=memory.used --format=csv,noheader 2>/dev/null");
3443
+ if (memAfter)
3444
+ lines.push(`${cc.dim} VRAM after: ${memAfter}${cc.reset}`);
3445
+ if (testResult.success) {
3446
+ lines.push(`${cc.green} ✓ Test image generated successfully!${cc.reset}`);
3447
+ if (testResult.imagePath) {
3448
+ lines.push(`${cc.dim} Saved: ${testResult.imagePath}${cc.reset}`);
3449
+ // Clean up test image
3450
+ try {
3451
+ (await import("node:fs")).writeFileSync(testResult.imagePath, "");
3452
+ }
3453
+ catch { }
3454
+ }
3455
+ }
3456
+ else {
3457
+ lines.push(`${cc.red} ✗ Test generation failed: ${testResult.error ?? "unknown"}${cc.reset}`);
3458
+ issues++;
3459
+ }
3460
+ // ── Phase 4: Check if server survived ──
3461
+ say("");
3462
+ say(`${cc.dim}Verifying server is still running after generation...${cc.reset}`);
3463
+ const stillUp = !!tryCmd("curl -sf --max-time 3 http://localhost:7860/sdapi/v1/sd-models 2>/dev/null");
3464
+ if (stillUp) {
3465
+ lines.push(`${cc.green} ✓ Server still running after test${cc.reset}`);
3466
+ }
3467
+ else {
3468
+ lines.push(`${cc.red} ✗ Server crashed during generation!${cc.reset}`);
3469
+ lines.push(`${cc.dim} This usually means GPU VRAM ran out. Try: "switch to cpu mode"${cc.reset}`);
3470
+ issues++;
3471
+ }
3472
+ // ── Summary ──
3473
+ lines.push("");
3474
+ if (issues === 0) {
3475
+ lines.push(`${cc.green}${cc.bold}All checks passed!${cc.reset} Image generation is fully working.`);
3476
+ lines.push(`${cc.dim}Say: "generate a picture of a cat"${cc.reset}`);
3477
+ }
3478
+ else {
3479
+ lines.push(`${cc.yellow}${issues} issue(s) found.${cc.reset}`);
3480
+ suggestAction({ action: "switch to cpu mode", description: "Try CPU mode for stability", type: "intent" });
3481
+ }
3482
+ result = lines.join("\n");
3483
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command: "[ai-diagnose]", environment, success: issues === 0 });
3484
+ return result;
3485
+ }
3486
+ if (intent.intent === "hardware.gpu") {
3487
+ const { detectGpu } = await import("../utils/imageGen.js");
3488
+ const gpu = detectGpu();
3489
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
3490
+ const lines = [`${cc.bold}${cc.cyan}GPU Information${cc.reset}\n`];
3491
+ if (gpu.hasNvidia) {
3492
+ lines.push(` ${cc.green}✓${cc.reset} ${cc.bold}${gpu.gpuName}${cc.reset}`);
3493
+ if (gpu.vram)
3494
+ lines.push(` VRAM: ${gpu.vram} total${gpu.vramFree ? `, ${gpu.vramFree} free` : ""}`);
3495
+ if (gpu.gpuTemp)
3496
+ lines.push(` Temperature: ${gpu.gpuTemp}`);
3497
+ if (gpu.gpuUtil)
3498
+ lines.push(` Utilization: ${gpu.gpuUtil}`);
3499
+ if (gpu.driverVersion)
3500
+ lines.push(` Driver: ${gpu.driverVersion}`);
3501
+ if (gpu.cudaVersion)
3502
+ lines.push(` CUDA: ${gpu.cudaVersion}`);
3503
+ if (gpu.wslCuda)
3504
+ lines.push(` WSL CUDA: ${cc.green}available${cc.reset}`);
3505
+ if (gpu.gpuError)
3506
+ lines.push(` ${cc.red}⚠ Error: ${gpu.gpuError}${cc.reset}`);
3507
+ }
3508
+ else if (gpu.hasAmd) {
3509
+ lines.push(` ${cc.green}✓${cc.reset} AMD GPU detected`);
3510
+ }
3511
+ else {
3512
+ lines.push(` ${cc.yellow}No GPU detected${cc.reset} — running in CPU mode`);
3513
+ }
3514
+ result = lines.join("\n");
3515
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command: "[gpu-info]", environment, success: true });
3516
+ return result;
3517
+ }
3518
+ // GPU/CPU mode switch — restarts engine with appropriate flags
3519
+ if (intent.intent === "ai.gpu_mode") {
3520
+ const { detectGpu, detectImageEngines } = await import("../utils/imageGen.js");
3521
+ const { spawn: spawnChild } = await import("node:child_process");
3522
+ const { execSync: exSync } = await import("node:child_process");
3523
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
3524
+ const wantGpu = /gpu|graphics|nvidia|cuda/i.test(intent.rawText) && !/cpu|disable|off|without/i.test(intent.rawText);
3525
+ const gpu = detectGpu();
3526
+ const engines = detectImageEngines();
3527
+ if (wantGpu && !gpu.hasNvidia) {
3528
+ result = `${cc.red}No NVIDIA GPU detected.${cc.reset} Only CPU mode available.`;
3529
+ }
3530
+ else if (wantGpu && gpu.gpuError && !/force/i.test(intent.rawText)) {
3531
+ result = `${cc.yellow}⚠ GPU detected (${gpu.gpuName}) but has issues:${cc.reset}\n ${cc.dim}${gpu.gpuError}${cc.reset}\n\n GPU mode may crash. Say ${cc.cyan}"force gpu mode"${cc.reset} to try anyway, or use ${cc.cyan}"cpu mode"${cc.reset} (recommended).`;
3532
+ suggestAction({ action: "switch to cpu mode", description: "Use CPU mode (stable)", type: "intent" });
3533
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command: "[gpu-mode-warn]", environment, success: true });
3534
+ return result;
3535
+ }
3536
+ else {
3537
+ const engine = engines.find(e => e.installed && e.path && e.engine !== "docker");
3538
+ if (!engine?.path) {
3539
+ result = `${cc.yellow}No local engine installed.${cc.reset} Say "install stable diffusion" first.`;
3540
+ }
3541
+ else {
3542
+ // Kill running engine
3543
+ console.error(`${cc.dim}Stopping current engine...${cc.reset}`);
3544
+ try {
3545
+ exSync("pkill -9 -f 'launch.py' 2>/dev/null", { stdio: "ignore", timeout: 5000 });
3546
+ }
3547
+ catch { }
3548
+ await new Promise(r => setTimeout(r, 3000));
3549
+ const mode = wantGpu ? "GPU" : "CPU";
3550
+ const launchArgs = wantGpu
3551
+ ? ["launch.py", "--api", "--listen", "--skip-install"]
3552
+ : ["launch.py", "--api", "--listen", "--skip-torch-cuda-test", "--no-half", "--skip-install"];
3553
+ const { resolve: resolvePath } = await import("node:path");
3554
+ const { existsSync: fileExists } = await import("node:fs");
3555
+ const venvPy = resolvePath(engine.path, "venv", "bin", "python");
3556
+ console.error(`${cc.cyan}Restarting in ${mode} mode...${cc.reset}`);
3557
+ const child = spawnChild(fileExists(venvPy) ? venvPy : "python3", launchArgs, {
3558
+ cwd: engine.path, detached: true, stdio: "ignore",
3559
+ env: { ...process.env, PATH: `/usr/lib/wsl/lib:${process.env.PATH}` },
3560
+ });
3561
+ child.unref();
3562
+ // Wait for API (up to 3 min)
3563
+ let ready = false;
3564
+ for (let i = 0; i < 60; i++) {
3565
+ await new Promise(r => setTimeout(r, 3000));
3566
+ try {
3567
+ exSync("curl -sf --max-time 2 http://localhost:7860/sdapi/v1/sd-models >/dev/null 2>&1", { timeout: 5000 });
3568
+ ready = true;
3569
+ break;
3570
+ }
3571
+ catch { }
3572
+ if (i % 10 === 9)
3573
+ console.error(`${cc.dim} Still starting... (${(i + 1) * 3}s)${cc.reset}`);
3574
+ }
3575
+ if (ready) {
3576
+ result = `${cc.green}✓${cc.reset} Restarted in ${cc.bold}${mode} mode${cc.reset}\n API: http://localhost:7860`;
3577
+ if (wantGpu)
3578
+ result += `\n Using: ${gpu.gpuName}${gpu.vramFree ? ` (${gpu.vramFree} free)` : ""}`;
3579
+ }
3580
+ else {
3581
+ result = `${cc.yellow}⚠ Engine started but API not responding.${cc.reset}`;
3582
+ if (wantGpu) {
3583
+ result += `\n GPU mode may have crashed. Try: ${cc.cyan}"switch to cpu mode"${cc.reset}`;
3584
+ suggestAction({ action: "switch to cpu mode", description: "Fall back to CPU", type: "intent" });
3585
+ }
3586
+ }
3587
+ }
3588
+ }
3589
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command: `[gpu-mode]`, environment, success: true });
3590
+ return result;
3591
+ }
3592
+ // Start SD — auto-detects GPU, verifies it started
3593
+ if (intent.intent === "ai.start_sd") {
3594
+ const { detectGpu, detectImageEngines } = await import("../utils/imageGen.js");
3595
+ const { spawn: spawnChild, execSync: exStart } = await import("node:child_process");
3596
+ const tryCmd = (cmd) => { try {
3597
+ return exStart(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }).trim();
3598
+ }
3599
+ catch {
3600
+ return null;
3601
+ } };
3602
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
3603
+ const engines = detectImageEngines();
3604
+ // Check what's currently on port 7860
3605
+ const portOwner = tryCmd("ss -tlnp 2>/dev/null | grep ':7860' | head -1") ?? tryCmd("lsof -i:7860 2>/dev/null | head -2");
3606
+ const apiUp = !!tryCmd("curl -sf --max-time 2 http://localhost:7860/sdapi/v1/sd-models 2>/dev/null");
3607
+ const running = engines.find(e => e.running);
3608
+ if (apiUp && running) {
3609
+ const platform = running.platform ?? "unknown";
3610
+ console.error(`${cc.yellow}Port 7860 is already in use by ${running.engine} [${platform}]${cc.reset}`);
3611
+ if (running.pid)
3612
+ console.error(`${cc.dim} PID: ${running.pid}${cc.reset}`);
3613
+ // Check if user wants to switch
3614
+ const wantsWSL = /wsl|linux/i.test(intent.rawText);
3615
+ const wantsWindows = /windows|win|stability matrix|sm/i.test(intent.rawText);
3616
+ if (wantsWSL && platform === "windows") {
3617
+ console.error(`${cc.cyan}Stopping Windows engine to start WSL engine...${cc.reset}`);
3618
+ // Can't kill Windows process from WSL directly — tell user
3619
+ console.error(`${cc.dim} Please close Stability Matrix on Windows, then say "start sd" again.${cc.reset}`);
3620
+ result = `${cc.yellow}Windows engine is using port 7860.${cc.reset} Close Stability Matrix first, then try again.`;
3621
+ }
3622
+ else if (wantsWindows && platform === "wsl") {
3623
+ console.error(`${cc.cyan}Stopping WSL engine to start Windows engine...${cc.reset}`);
3624
+ try {
3625
+ exStart("pkill -9 -f 'launch.py' 2>/dev/null", { stdio: "ignore", timeout: 5000 });
3626
+ }
3627
+ catch { }
3628
+ await new Promise(r => setTimeout(r, 3000));
3629
+ console.error(`${cc.green}✓${cc.reset} WSL engine stopped. Launching Stability Matrix...`);
3630
+ const smDir = engines.find(e => e.engine === "stability-matrix")?.path;
3631
+ if (smDir) {
3632
+ try {
3633
+ const winPath = tryCmd(`wslpath -w "${smDir}" 2>/dev/null`);
3634
+ if (winPath)
3635
+ exStart(`/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "Start-Process '${winPath}\\StabilityMatrix.exe'" 2>/dev/null`, { stdio: "ignore", timeout: 10000 });
3636
+ }
3637
+ catch { }
3638
+ }
3639
+ result = `${cc.green}✓${cc.reset} WSL engine stopped. Stability Matrix launched on Windows.\n ${cc.dim}Start a UI inside SM, then say "image status" to check.${cc.reset}`;
3640
+ }
3641
+ else {
3642
+ result = `${cc.green}✓${cc.reset} Already running: ${running.engine} [${platform}] at ${running.url}`;
3643
+ }
3644
+ }
3645
+ else if (apiUp && !running) {
3646
+ // Something else is on port 7860
3647
+ console.error(`${cc.yellow}⚠ Port 7860 is in use by an unknown process${cc.reset}`);
3648
+ if (portOwner)
3649
+ console.error(`${cc.dim} ${portOwner}${cc.reset}`);
3650
+ result = `${cc.yellow}Port 7860 is already in use by something else.${cc.reset}\n ${cc.dim}Check with: ss -tlnp | grep 7860${cc.reset}\n ${cc.dim}Or use a different port.${cc.reset}`;
3651
+ }
3652
+ else {
3653
+ // Port free — start engine
3654
+ const engine = engines.find(e => e.installed && e.path && e.engine !== "docker" && e.engine !== "stability-matrix");
3655
+ if (!engine?.path) {
3656
+ // Try launching Stability Matrix instead
3657
+ const sm = engines.find(e => e.engine === "stability-matrix" && e.installed);
3658
+ if (sm?.path) {
3659
+ console.error(`${cc.cyan}Launching Stability Matrix...${cc.reset}`);
3660
+ try {
3661
+ const winPath = tryCmd(`wslpath -w "${sm.path}" 2>/dev/null`);
3662
+ if (winPath)
3663
+ exStart(`/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "Start-Process '${winPath}\\StabilityMatrix.exe'" 2>/dev/null`, { stdio: "ignore", timeout: 10000 });
3664
+ }
3665
+ catch { }
3666
+ result = `${cc.cyan}Stability Matrix launched.${cc.reset} Start a UI inside it, then say "image status".`;
3667
+ }
3668
+ else {
3669
+ result = `${cc.yellow}No local engine installed.${cc.reset} Say "install stable diffusion" first.`;
3670
+ }
3671
+ }
3672
+ else {
3673
+ const gpu = detectGpu();
3674
+ const useGpu = gpu.hasNvidia && !gpu.gpuError && !/cpu/i.test(intent.rawText);
3675
+ const mode = useGpu ? "GPU" : "CPU";
3676
+ const args = useGpu
3677
+ ? ["launch.py", "--api", "--listen", "--skip-install"]
3678
+ : ["launch.py", "--api", "--listen", "--skip-torch-cuda-test", "--no-half", "--skip-install"];
3679
+ console.error(`${cc.cyan}Starting ${engine.engine} [${engine.platform}] in ${mode} mode...${cc.reset}`);
3680
+ const { resolve: rp } = await import("node:path");
3681
+ const { existsSync: fe } = await import("node:fs");
3682
+ const venvPy = rp(engine.path, "venv", "bin", "python");
3683
+ const child = spawnChild(fe(venvPy) ? venvPy : "python3", args, {
3684
+ cwd: engine.path, detached: true, stdio: "ignore",
3685
+ env: { ...process.env, PATH: `/usr/lib/wsl/lib:${process.env.PATH}` },
3686
+ });
3687
+ child.unref();
3688
+ result = `${cc.dim}Starting ${engine.engine} [${engine.platform}] in ${mode} mode... say "image status" to check.${cc.reset}`;
3689
+ suggestAction({ action: "image status", description: "Check if engine started", type: "intent" });
3690
+ }
3691
+ }
3692
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command: "[start-sd]", environment, success: true });
3693
+ return result;
3694
+ }
3695
+ // Stop SD — detects what's running and stops the right one
3696
+ if (intent.intent === "ai.stop_sd") {
3697
+ const { detectImageEngines } = await import("../utils/imageGen.js");
3698
+ const { execSync: exStop } = await import("node:child_process");
3699
+ const tryCmd = (cmd) => { try {
3700
+ return exStop(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }).trim();
3701
+ }
3702
+ catch {
3703
+ return null;
3704
+ } };
3705
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", green: "\x1b[32m", yellow: "\x1b[33m", dim: "\x1b[2m", cyan: "\x1b[36m" };
3706
+ const engines = detectImageEngines();
3707
+ const running = engines.find(e => e.running);
3708
+ if (!running) {
3709
+ result = `${cc.dim}No engine running.${cc.reset}`;
3710
+ }
3711
+ else {
3712
+ const platform = running.platform ?? "unknown";
3713
+ console.error(`${cc.dim}Stopping ${running.engine} [${platform}]...${cc.reset}`);
3714
+ if (platform === "wsl" || platform === "linux") {
3715
+ try {
3716
+ exStop("pkill -9 -f 'launch.py' 2>/dev/null", { stdio: "ignore", timeout: 5000 });
3717
+ }
3718
+ catch { }
3719
+ await new Promise(r => setTimeout(r, 2000));
3720
+ const stillUp = !!tryCmd("curl -sf --max-time 2 http://localhost:7860/ 2>/dev/null");
3721
+ result = stillUp
3722
+ ? `${cc.yellow}WSL engine killed but port 7860 still responding — Windows engine may be running too.${cc.reset}`
3723
+ : `${cc.green}✓${cc.reset} ${running.engine} [${platform}] stopped.`;
3724
+ }
3725
+ else if (platform === "windows") {
3726
+ console.error(`${cc.yellow}Cannot stop Windows processes from WSL directly.${cc.reset}`);
3727
+ result = `${cc.yellow}The engine is running on Windows (Stability Matrix).${cc.reset}\n Close it from the Stability Matrix UI or Windows Task Manager.\n ${cc.dim}Or say "stop wsl engine" to stop only the WSL one.${cc.reset}`;
3728
+ }
3729
+ else {
3730
+ try {
3731
+ exStop("pkill -9 -f 'launch.py' 2>/dev/null", { stdio: "ignore", timeout: 5000 });
3732
+ }
3733
+ catch { }
3734
+ await new Promise(r => setTimeout(r, 2000));
3735
+ result = `${cc.green}✓${cc.reset} Engine stopped.`;
3736
+ }
3737
+ }
3738
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command: "[stop-sd]", environment, success: true });
3739
+ return result;
3740
+ }
3741
+ // Install SD with optional user-specified path
3742
+ if (intent.intent === "ai.install_sd") {
3743
+ const { resolveUserPath } = await import("../utils/imageGen.js");
3744
+ // Extract drive/path from rawText: "on D drive", "on /mnt/f"
3745
+ const rawLower = intent.rawText.toLowerCase();
3746
+ const pathMatch = rawLower.match(/\b(?:on|in|at|to)\s+([a-z]\s*drive|\/\S+)/i);
3747
+ if (pathMatch) {
3748
+ const resolved = resolveUserPath(pathMatch[1]);
3749
+ if (resolved) {
3750
+ process.env.NOTOKEN_INSTALL_DIR = resolved;
3751
+ console.error(`\x1b[36mInstall location:\x1b[0m ${resolved}`);
3752
+ }
3753
+ }
3754
+ // The field parser may have extracted a drive letter as the "engine" field
3755
+ // e.g. "install stable diffusion on D drive" → engine: "d"
3756
+ const engineField = fields.engine ?? "";
3757
+ let engine = "auto1111";
3758
+ if (/^[a-z]$/i.test(engineField)) {
3759
+ // Single letter = drive letter, not an engine name
3760
+ const resolved = resolveUserPath(`${engineField} drive`);
3761
+ if (resolved) {
3762
+ process.env.NOTOKEN_INSTALL_DIR = resolved;
3763
+ console.error(`\x1b[36mInstall location:\x1b[0m ${resolved}`);
3764
+ }
3765
+ // Default to auto1111
3766
+ }
3767
+ else if (engineField.includes("comfy")) {
3768
+ engine = "comfyui";
3769
+ }
3770
+ else if (engineField.includes("fooocus") || engineField.includes("focus")) {
3771
+ engine = "fooocus";
3772
+ }
3773
+ else if (engineField.includes("docker")) {
3774
+ engine = "docker";
3775
+ }
3776
+ command = `[install-sd] ${engine}`;
3777
+ const { installImageEngine } = await import("../utils/imageGen.js");
3778
+ const installResult = await installImageEngine(engine);
3779
+ result = installResult.message;
3780
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: installResult.success });
3781
+ return result;
3782
+ }
3783
+ // Image generation — natural language to image
3784
+ if (intent.intent === "ai.generate_image") {
3785
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
3786
+ // Extract the prompt — strip command prefix
3787
+ let prompt = intent.rawText
3788
+ .replace(/^(can you|could you|please|will you|would you)\s+/i, "")
3789
+ .replace(/^(generate|create|make|draw|paint|imagine)\s+(me\s+)?/i, "")
3790
+ .replace(/^(a\s+)?(picture|image|photo|drawing|painting|art|artwork)\s+(of\s+)?/i, "")
3791
+ .replace(/\s+(and\s+)?(show|open|display|view)\s+(it\s+)?(to\s+)?(me|us)?\s*$/i, "")
3792
+ .replace(/\s+(please|for me|for us)\s*$/i, "")
3793
+ .trim();
3794
+ // Load random prompts from JSON file — user can add their own
3795
+ let RANDOM_PROMPTS = ["a beautiful landscape at sunset"];
3796
+ try {
3797
+ const { readFileSync, existsSync } = await import("node:fs");
3798
+ const { resolve } = await import("node:path");
3799
+ for (const p of [
3800
+ resolve(process.env.NOTOKEN_HOME ?? `${process.env.HOME}/.notoken`, "image-prompts.json"),
3801
+ resolve(process.cwd(), "packages/core/config/image-prompts.json"),
3802
+ resolve(process.cwd(), "config/image-prompts.json"),
3803
+ ]) {
3804
+ if (existsSync(p)) {
3805
+ RANDOM_PROMPTS = JSON.parse(readFileSync(p, "utf-8")).prompts ?? RANDOM_PROMPTS;
3806
+ break;
3807
+ }
3808
+ }
3809
+ }
3810
+ catch { /* use default */ }
3811
+ // Check if this is a bare "can you generate an image?" or "generate an image" with no actual prompt
3812
+ const isBareCan = (!prompt || prompt === "?" || /^(an?\s+)?(image|picture|photo|art|artwork|drawing|painting|one)?\??$/i.test(prompt));
3813
+ if (isBareCan) {
3814
+ // Check if image generation is set up
3815
+ const engines = detectImageEngines();
3816
+ const localInstalled = engines.some(e => e.installed);
3817
+ if (!localInstalled) {
3818
+ return `${cc.yellow}⚠${cc.reset} No image generation engine installed.\n\n ${cc.bold}Options:${cc.reset}\n ${cc.cyan}1.${cc.reset} Install locally: ${cc.dim}"install stable diffusion"${cc.reset}\n ${cc.cyan}2.${cc.reset} Use cloud API: ${cc.dim}set STABILITY_API_KEY or OPENAI_API_KEY${cc.reset}\n\n ${cc.dim}Say "install stable diffusion" to get started.${cc.reset}`;
3819
+ }
3820
+ // Engine installed — generate a random image
3821
+ prompt = RANDOM_PROMPTS[Math.floor(Math.random() * RANDOM_PROMPTS.length)];
3822
+ console.log(`${cc.cyan}Generating random image...${cc.reset}`);
3823
+ console.log(`${cc.dim}Prompt: "${prompt}"${cc.reset}`);
3824
+ }
3825
+ if (!prompt || /^(an?\s+)?(image|picture|photo|art|one)\??$/i.test(prompt)) {
3826
+ prompt = RANDOM_PROMPTS[Math.floor(Math.random() * RANDOM_PROMPTS.length)];
3827
+ console.log(`${cc.dim}Random prompt: "${prompt}"${cc.reset}`);
3828
+ }
3829
+ prompt = (prompt || fields.prompt) ?? "a beautiful landscape";
3830
+ command = `[image-gen] ${prompt}`;
3831
+ const genResult = await generateImage(prompt);
3832
+ result = genResult.message ?? genResult.error ?? "Unknown error";
3833
+ if (genResult.imagePath) {
3834
+ result += `\n\nFile: ${genResult.imagePath}`;
3835
+ // Auto-open the image if running locally
3836
+ if (isLocal) {
3837
+ try {
3838
+ const { execSync: run } = await import("node:child_process");
3839
+ const os = (await import("node:os")).platform();
3840
+ const isWSL = !!(() => { try {
3841
+ return run("grep -qi microsoft /proc/version && echo wsl", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 2000 }).trim();
3842
+ }
3843
+ catch {
3844
+ return null;
3845
+ } })();
3846
+ if (isWSL) {
3847
+ const winPath = run(`wslpath -w "${genResult.imagePath}" 2>/dev/null`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 }).trim();
3848
+ // Use PowerShell — cmd.exe often not in PATH for root in WSL
3849
+ run(`/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "Start-Process '${winPath}'" 2>/dev/null`, { stdio: "ignore" });
3850
+ }
3851
+ else if (os === "darwin") {
3852
+ run(`open "${genResult.imagePath}"`, { stdio: "ignore" });
3853
+ }
3854
+ else if (os === "win32") {
3855
+ run(`start "" "${genResult.imagePath}"`, { stdio: "ignore", shell: "cmd.exe" });
3856
+ }
3857
+ else {
3858
+ run(`xdg-open "${genResult.imagePath}" 2>/dev/null`, { stdio: "ignore" });
3859
+ }
3860
+ }
3861
+ catch { }
3862
+ }
3863
+ }
3864
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: genResult.success });
3865
+ return result;
3866
+ }
3867
+ // Image engine status
3868
+ if (intent.intent === "ai.image_status") {
3869
+ command = "[image-status]";
3870
+ const engines = detectImageEngines();
3871
+ const hasLocal = engines.some(e => e.installed && e.engine !== "docker" && e.running);
3872
+ // Detect if user is asking to GO offline, not just checking status
3873
+ const wantsOffline = /\b(can we|can i|how do i|how can i|let'?s|lets|i want to|set up|enable|switch to|go|do it|run it|run this)\b.*\b(offline|local|locally|private)\b/i.test(intent.rawText)
3874
+ || /\b(offline|local|locally|private)\b.*\b(install|set up|put|place)\b/i.test(intent.rawText);
3875
+ // Extract drive/path from the same sentence: "on D drive", "on /mnt/f"
3876
+ const driveMatch = intent.rawText.match(/\b(?:on|in|at|to)\s+([a-z]\s*drive|\/\S+)/i);
3877
+ if (driveMatch) {
3878
+ const { resolveUserPath } = await import("../utils/imageGen.js");
3879
+ const resolved = resolveUserPath(driveMatch[1]);
3880
+ if (resolved) {
3881
+ process.env.NOTOKEN_INSTALL_DIR = resolved;
3882
+ console.error(`\x1b[36mInstall location:\x1b[0m ${resolved} (from "${driveMatch[1]}")`);
3883
+ }
3884
+ }
3885
+ if (wantsOffline && !hasLocal) {
3886
+ // User wants offline — pick the best install path automatically
3887
+ const { installImageEngine, detectGpu } = await import("../utils/imageGen.js");
3888
+ const gpu = detectGpu();
3889
+ const { execSync: ex } = await import("node:child_process");
3890
+ const exec = (cmd) => { try {
3891
+ return ex(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }).trim();
3892
+ }
3893
+ catch {
3894
+ return null;
3895
+ } };
3896
+ const hasDocker = !!exec("docker --version");
3897
+ const hasPython = !!exec("python3 --version") || !!exec("python --version");
3898
+ const pyVer = exec("python3 --version") ?? exec("python --version") ?? "not installed";
3899
+ console.error(`\x1b[1m\x1b[35mSetting up offline image generation\x1b[0m\n`);
3900
+ console.error(`Currently using cloud API. Setting up local generation...\n`);
3901
+ if (gpu.hasNvidia) {
3902
+ console.error(`\x1b[32m✓ GPU: ${gpu.gpuName}${gpu.vram ? ` (${gpu.vram})` : ""}\x1b[0m`);
3903
+ }
3904
+ else {
3905
+ console.error(`\x1b[33m⚠ No GPU — CPU mode (slower but works)\x1b[0m`);
3906
+ }
3907
+ console.error(`${hasDocker ? "\x1b[32m✓" : "\x1b[31m✗"}\x1b[0m Docker: ${hasDocker ? "available" : "not installed"}`);
3908
+ console.error(`${hasPython ? "\x1b[32m✓" : "\x1b[31m✗"}\x1b[0m Python: ${pyVer}`);
3909
+ console.error(`\x1b[2mCancel anytime with Ctrl+C\x1b[0m\n`);
3910
+ suggestAction({
3911
+ action: "generate a picture of a cat",
3912
+ description: "Generate a test image to verify offline setup works",
3913
+ type: "intent",
3914
+ });
3915
+ // Strategy: Stability Matrix first (frictionless), then Docker, then Python
3916
+ let installResult = null;
3917
+ // 1. Stability Matrix — all-in-one, no pip/git/build tools needed
3918
+ const isWSL = !!exec("grep -qi microsoft /proc/version && echo wsl");
3919
+ const os = (await import("node:os")).platform();
3920
+ if (os === "win32" || isWSL) {
3921
+ console.error(`\x1b[1mStrategy: Stability Matrix\x1b[0m — all-in-one launcher, zero dependencies\n`);
3922
+ const smUrl = "https://github.com/LykosAI/StabilityMatrix/releases/latest/download/StabilityMatrix-win-x64.zip";
3923
+ const { getDriveInfo } = await import("../utils/imageGen.js");
3924
+ const installDir = process.env.NOTOKEN_INSTALL_DIR ?? (isWSL ? "/mnt/d/notoken/ai" : "D:\\notoken\\ai");
3925
+ const smDir = isWSL ? `${installDir}/StabilityMatrix` : `${installDir}\\StabilityMatrix`;
3926
+ const smZip = isWSL ? "/tmp/StabilityMatrix.zip" : `${process.env.TEMP ?? "C:\\Temp"}\\StabilityMatrix.zip`;
3927
+ console.error(` Downloading Stability Matrix...`);
3928
+ try {
3929
+ if (isWSL) {
3930
+ exec(`mkdir -p "${installDir}"`);
3931
+ // Download via curl in WSL
3932
+ const { execSync: exSync } = await import("node:child_process");
3933
+ exSync(`curl -L -o "${smZip}" "${smUrl}"`, { stdio: "inherit", timeout: 300000 });
3934
+ exSync(`unzip -o -q "${smZip}" -d "${smDir}"`, { stdio: "inherit", timeout: 60000 });
3935
+ console.error(`\x1b[32m✓\x1b[0m Downloaded to ${smDir}`);
3936
+ // Open it on Windows side
3937
+ try {
3938
+ const winPath = exec(`wslpath -w "${smDir}"`);
3939
+ exSync(`/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "Start-Process '${winPath}\\StabilityMatrix.exe'" 2>/dev/null`, { stdio: "ignore" });
3940
+ console.error(`\x1b[32m✓\x1b[0m Launched on Windows!`);
3941
+ }
3942
+ catch { }
3943
+ installResult = {
3944
+ success: true,
3945
+ message: [
3946
+ `\x1b[32m✓\x1b[0m Stability Matrix installed at ${smDir}`,
3947
+ ``,
3948
+ `It's now open on your Windows desktop.`,
3949
+ ` 1. Choose a UI (Forge, ComfyUI, or Fooocus)`,
3950
+ ` 2. It downloads everything automatically`,
3951
+ ` 3. Once running, say "generate a picture of a cat"`,
3952
+ ``,
3953
+ `\x1b[1mStability Matrix handles ALL dependencies — no Python, pip, or git needed.\x1b[0m`,
3954
+ ].join("\n"),
3955
+ };
3956
+ }
3957
+ else {
3958
+ // Native Windows
3959
+ const { execSync: exSync } = await import("node:child_process");
3960
+ exSync(`powershell -Command "Invoke-WebRequest -Uri '${smUrl}' -OutFile '${smZip}'"`, { stdio: "inherit", timeout: 300000, shell: "cmd.exe" });
3961
+ exSync(`powershell -Command "Expand-Archive -Path '${smZip}' -DestinationPath '${smDir}' -Force"`, { stdio: "inherit", timeout: 60000, shell: "cmd.exe" });
3962
+ exSync(`start "" "${smDir}\\StabilityMatrix.exe"`, { stdio: "ignore", shell: "cmd.exe" });
3963
+ installResult = {
3964
+ success: true,
3965
+ message: [
3966
+ `\x1b[32m✓\x1b[0m Stability Matrix installed at ${smDir}`,
3967
+ ` It's now open — choose a UI and it downloads everything.`,
3968
+ ` Say "generate a picture of a cat" when ready.`,
3969
+ ].join("\n"),
3970
+ };
3971
+ }
3972
+ }
3973
+ catch (smErr) {
3974
+ console.error(`\x1b[33m⚠ Stability Matrix download failed: ${smErr instanceof Error ? smErr.message : smErr}\x1b[0m`);
3975
+ console.error(` Manual download: https://lykos.ai\n`);
3976
+ }
3977
+ }
3978
+ // 2. Docker fallback
3979
+ if (!installResult?.success) {
3980
+ let dockerHasSpace = false;
3981
+ if (hasDocker) {
3982
+ const { getDriveInfo } = await import("../utils/imageGen.js");
3983
+ const dockerRoot = exec("docker info 2>/dev/null | grep 'Docker Root Dir' | awk '{print $NF}'") ?? "/var/lib/docker";
3984
+ const dockerDrive = getDriveInfo(dockerRoot);
3985
+ dockerHasSpace = (dockerDrive?.freeGB ?? 0) >= 16;
3986
+ if (!dockerHasSpace) {
3987
+ console.error(`\x1b[33m⚠ Docker: only ${dockerDrive?.freeGB ?? 0}GB free (need ~15GB)\x1b[0m\n`);
3988
+ }
3989
+ }
3990
+ if (hasDocker && dockerHasSpace) {
3991
+ console.error(`\x1b[1mStrategy: Docker\x1b[0m — containerized, pre-built\n`);
3992
+ installResult = await installImageEngine("docker");
3993
+ }
3994
+ }
3995
+ // 3. Python fallback
3996
+ if (!installResult?.success && hasPython) {
3997
+ console.error(`\x1b[1mStrategy: Using Python + git\x1b[0m — installing directly to ${process.env.NOTOKEN_INSTALL_DIR ?? "best drive"}\n`);
3998
+ installResult = await installImageEngine("auto1111");
3999
+ }
4000
+ if (installResult?.success) {
4001
+ result = installResult.message + `\n\n\x1b[1mSay "try it" or "generate a picture of a cat" to test.\x1b[0m`;
4002
+ }
4003
+ else {
4004
+ // Nothing worked — show standalone download options
4005
+ result = [
4006
+ `\x1b[33m${installResult?.message ?? "Could not install automatically."}\x1b[0m\n`,
4007
+ `\x1b[1mDownload a standalone installer (no Docker or Python needed):\x1b[0m`,
4008
+ ` \x1b[1mStability Matrix:\x1b[0m https://lykos.ai`,
4009
+ ` \x1b[1mEasy Diffusion:\x1b[0m https://easydiffusion.github.io`,
4010
+ ` \x1b[1mFooocus:\x1b[0m https://github.com/lllyasviel/Fooocus`,
4011
+ `\n\x1b[2mOr: notoken install stability-matrix\x1b[0m`,
4012
+ ].join("\n");
4013
+ }
4014
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
4015
+ return result;
4016
+ }
4017
+ result = formatImageEngineStatus(engines);
4018
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
4019
+ return result;
4020
+ }
4021
+ // Project scanning — rich local output instead of raw find command
4022
+ if (intent.intent === "project.scan") {
4023
+ let scanPath = fields.path ?? ".";
4024
+ if (["here", "this", "this folder", "this directory", "."].includes(scanPath))
4025
+ scanPath = process.cwd();
4026
+ command = `[project-scan] ${scanPath}`;
4027
+ const projects = scanProjects(scanPath);
4028
+ result = formatProjectList(projects, scanPath);
4029
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
4030
+ return result;
4031
+ }
4032
+ // Directory listing — rich summary
4033
+ if (intent.intent === "dir.list" || intent.intent === "dir.summary") {
4034
+ let dirPath = fields.path ?? ".";
4035
+ if (!dirPath || ["here", "this", "this folder", "this directory", "."].includes(dirPath))
4036
+ dirPath = process.cwd();
4037
+ command = `[dir-summary] ${dirPath}`;
4038
+ const summary = summarizeDirectory(dirPath);
4039
+ result = formatDirSummary(summary);
4040
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
4041
+ return result;
77
4042
  }
78
4043
  // Route git intents through simple-git for better output
79
4044
  if (intent.intent.startsWith("git.")) {