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