wechatbridge-cli 1.3.0__py3-none-any.whl

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.
wechatbridge/grok.py ADDED
@@ -0,0 +1,682 @@
1
+ """grok (Grok Build CLI) runner with per-user session isolation.
2
+
3
+ Mirrors agy.py's interface: run_grok(), handle_grok_slash_command(),
4
+ ensure_user_grok(), get_session_dir() — all imported from runner_common.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import os
11
+ import re
12
+ import shutil
13
+ import time
14
+ import urllib.parse
15
+
16
+ from .config import config
17
+ from .runner_common import (
18
+ sanitize_user_id, get_session_dir, ensure_session_dir, is_first_message, mark_initialized,
19
+ clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
20
+ sanitize_env, terminate_process, update_active_prefs,
21
+ format_error, format_cli_error, EMPTY_REPLY, validate_add_dir,
22
+ )
23
+
24
+ logger = logging.getLogger("grok_runner")
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Per-user .grok directory setup
29
+ # ---------------------------------------------------------------------------
30
+
31
+ def _host_grok_dir() -> str:
32
+ """Host-level ~/.grok (bridge process home), not the per-user session HOME.
33
+
34
+ Auth/login is machine-wide; conversation state stays under the session dir.
35
+ Override with WECHATBRIDGE_HOST_HOME if the service home is not the login home.
36
+ """
37
+ host_home = os.environ.get("WECHATBRIDGE_HOST_HOME") or os.path.expanduser("~")
38
+ return os.path.join(host_home, ".grok")
39
+
40
+
41
+ def _sync_grok_auth(grok_dir: str) -> bool:
42
+ """Make session .grok/auth.json track the host login credentials.
43
+
44
+ Bug fixed: we used to copy auth.json only once. Token refresh updates the
45
+ host ~/.grok/auth.json, while the session kept a stale/missing copy →
46
+ false "Not signed in" even when host login is still valid.
47
+
48
+ Strategy: symlink session auth.json → host auth.json (shared refresh).
49
+ Fall back to copy2 if symlink is not possible.
50
+ Returns True if credentials are available for the child process.
51
+ """
52
+ auth_src = os.path.join(_host_grok_dir(), "auth.json")
53
+ auth_dst = os.path.join(grok_dir, "auth.json")
54
+
55
+ if not os.path.isfile(auth_src):
56
+ logger.warning("Host grok auth missing: %s", auth_src)
57
+ return False
58
+
59
+ try:
60
+ src_real = os.path.realpath(auth_src)
61
+ if os.path.islink(auth_dst) or os.path.exists(auth_dst):
62
+ try:
63
+ if os.path.realpath(auth_dst) == src_real and os.path.isfile(auth_dst):
64
+ return True
65
+ except OSError:
66
+ pass
67
+ try:
68
+ os.unlink(auth_dst)
69
+ except OSError:
70
+ # Windows or busy file — try overwrite via copy below
71
+ pass
72
+
73
+ try:
74
+ os.symlink(src_real, auth_dst)
75
+ logger.info("Linked session auth.json -> %s", src_real)
76
+ return True
77
+ except OSError as e:
78
+ logger.warning("symlink auth.json failed (%s), falling back to copy", e)
79
+ shutil.copy2(auth_src, auth_dst)
80
+ os.chmod(auth_dst, 0o600)
81
+ return True
82
+ except OSError as e:
83
+ logger.warning("Failed to sync auth.json into %s: %s", grok_dir, e)
84
+ return False
85
+
86
+
87
+ def ensure_user_grok(user_id: str) -> str:
88
+ """Ensure per-user .grok directory with auth credentials.
89
+
90
+ Creates session/.grok/ for grok config and conversations.
91
+ Always syncs host auth.json (symlink preferred) so login state matches
92
+ the machine-level `grok login`, not a stale one-shot copy.
93
+ Returns session_dir path (for use as HOME when running grok).
94
+ """
95
+ session_dir = ensure_session_dir(user_id)
96
+ grok_dir = os.path.join(session_dir, ".grok")
97
+ os.makedirs(grok_dir, exist_ok=True)
98
+ try:
99
+ os.chmod(grok_dir, 0o700)
100
+ except OSError:
101
+ pass
102
+
103
+ _sync_grok_auth(grok_dir)
104
+
105
+ return session_dir
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Persona persistence (via --rules injection)
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def _persona_path(session_dir: str) -> str:
113
+ return os.path.join(session_dir, "grok_persona.txt")
114
+
115
+
116
+ def _read_persona(session_dir: str) -> str:
117
+ """Read persona content for --rules injection. Returns empty string if none."""
118
+ path = _persona_path(session_dir)
119
+ if os.path.exists(path):
120
+ try:
121
+ with open(path, "r", encoding="utf-8") as f:
122
+ return f.read().strip()
123
+ except OSError:
124
+ return ""
125
+ return ""
126
+
127
+
128
+ def handle_grok_persona(args: str, user_id: str) -> str:
129
+ """Handle /persona command for grok backend.
130
+
131
+ Stores persona text in grok_persona.txt, injected via --rules at run time.
132
+ Subcommands: set <content>, show, clear, reset (same as clear for grok).
133
+ """
134
+ session_dir = get_session_dir(user_id)
135
+ persona_path = _persona_path(session_dir)
136
+
137
+ parts = args.strip().split(maxsplit=1)
138
+ subcmd = parts[0].lower() if parts else ""
139
+ rest = parts[1] if len(parts) > 1 else ""
140
+
141
+ # set or implicit content
142
+ if subcmd == "set" and rest:
143
+ try:
144
+ with open(persona_path, "w", encoding="utf-8") as f:
145
+ f.write(rest)
146
+ return "✅ **人格文档已更新** ✅"
147
+ except OSError as e:
148
+ logger.error("Failed to write persona for %s: %s", user_id, e)
149
+ return "❌ **写入人格文档失败** ❌"
150
+ elif subcmd and subcmd not in ("show", "clear", "reset", "set"):
151
+ # No subcommand → treat whole args as content
152
+ try:
153
+ with open(persona_path, "w", encoding="utf-8") as f:
154
+ f.write(args.strip())
155
+ return "✅ **人格文档已更新** ✅"
156
+ except OSError as e:
157
+ logger.error("Failed to write persona for %s: %s", user_id, e)
158
+ return "❌ **写入人格文档失败** ❌"
159
+
160
+ # show
161
+ if subcmd == "show":
162
+ if not os.path.exists(persona_path):
163
+ return "(未设置人格文档)"
164
+ try:
165
+ with open(persona_path, "r", encoding="utf-8") as f:
166
+ val = f.read()
167
+ if len(val) > 1500:
168
+ val = val[:1500] + "\n\n(已截断至前1500字符)"
169
+ return val or "(空文档)"
170
+ except OSError as e:
171
+ logger.error("Failed to read persona for %s: %s", user_id, e)
172
+ return "❌ **读取人格文档失败** ❌"
173
+
174
+ # clear
175
+ if subcmd == "clear":
176
+ if os.path.exists(persona_path):
177
+ try:
178
+ os.remove(persona_path)
179
+ return "✅ **人格文档已清除** ✅"
180
+ except OSError as e:
181
+ logger.error("Failed to clear persona for %s: %s", user_id, e)
182
+ return "❌ **清除人格文档失败** ❌"
183
+ return "ℹ️ **本就无人格文档** ℹ️"
184
+
185
+ # reset (grok has no global default persona; same as clear)
186
+ if subcmd == "reset":
187
+ if os.path.exists(persona_path):
188
+ try:
189
+ os.remove(persona_path)
190
+ return "✅ **人格已重置** ✅"
191
+ except OSError as e:
192
+ logger.error("Failed to reset persona for %s: %s", user_id, e)
193
+ return "❌ **重置人格文档失败** ❌"
194
+ return "ℹ️ **本就无人格文档** ℹ️"
195
+
196
+ # empty args
197
+ return "📋 **/persona 用法** 📋\n\n- `/persona <内容>` 设置\n- `/persona show` 查看\n- `/persona clear` 清除\n- `/persona reset` 重置"
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Command builder (pure function for testability)
202
+ # ---------------------------------------------------------------------------
203
+
204
+ def _build_grok_command(prompt: str, prefs: dict, first: bool, persona_content: str = "") -> list:
205
+ """Build grok CLI argv list. Pure function — does not execute.
206
+
207
+ Flag mapping (agy → grok):
208
+ --dangerously-skip-permissions → --always-approve
209
+ --effort → --reasoning-effort
210
+ --mode plan → --permission-mode plan
211
+ -c → --continue
212
+ (persona via GEMINI.md) → --rules <content>
213
+ """
214
+ cmd = [config.grok_binary_path, "--output-format", "json"]
215
+
216
+ mode = prefs.get("mode", "")
217
+ if mode == "plan":
218
+ cmd += ["--permission-mode", "plan"]
219
+ else:
220
+ cmd += ["--always-approve"]
221
+
222
+ model = prefs.get("model", "")
223
+ effort = prefs.get("effort", "")
224
+ if model:
225
+ base_model, embedded_effort = parse_model_effort(model)
226
+ if embedded_effort and effort:
227
+ cmd += ["--model", base_model, "--reasoning-effort", effort]
228
+ elif embedded_effort:
229
+ cmd += ["--model", model]
230
+ else:
231
+ cmd += ["--model", model]
232
+ if effort:
233
+ cmd += ["--reasoning-effort", effort]
234
+ elif effort:
235
+ cmd += ["--reasoning-effort", effort]
236
+
237
+ # Persona injection via --rules
238
+ if persona_content:
239
+ cmd += ["--rules", persona_content]
240
+
241
+ # Session continuation
242
+ if not first:
243
+ cmd += ["--continue"]
244
+
245
+ cmd += ["-p", prompt]
246
+ return cmd
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Artifact extraction from chat_history.jsonl
251
+ # ---------------------------------------------------------------------------
252
+
253
+ def _extract_grok_artifacts(session_dir: str, session_id: str) -> list:
254
+ """Extract (name, abs_path) tuples from grok session chat_history.jsonl.
255
+
256
+ grok stores sessions under $HOME/.grok/sessions/<url-encoded-cwd>/<session-id>/.
257
+ The chat_history.jsonl contains structured tool_calls with file_path arguments
258
+ from write/edit operations.
259
+
260
+ Falls back to empty list on any error (never blocks text reply).
261
+ """
262
+ grok_sessions = os.path.join(session_dir, ".grok", "sessions")
263
+ cwd_encoded = urllib.parse.quote(session_dir, safe="")
264
+ session_path = os.path.join(grok_sessions, cwd_encoded, session_id)
265
+ chat_history = os.path.join(session_path, "chat_history.jsonl")
266
+
267
+ if not os.path.exists(chat_history):
268
+ logger.debug("No chat_history.jsonl at %s", chat_history)
269
+ return []
270
+
271
+ artifacts = []
272
+ seen = set()
273
+ try:
274
+ with open(chat_history, "r", encoding="utf-8") as f:
275
+ for line in f:
276
+ line = line.strip()
277
+ if not line.startswith("{"):
278
+ continue
279
+ try:
280
+ d = json.loads(line)
281
+ except json.JSONDecodeError:
282
+ continue
283
+ if d.get("type") == "assistant" and d.get("tool_calls"):
284
+ for tc in d.get("tool_calls", []):
285
+ name = tc.get("name", "")
286
+ args = tc.get("arguments", "")
287
+ if isinstance(args, str):
288
+ try:
289
+ args = json.loads(args)
290
+ except json.JSONDecodeError:
291
+ continue
292
+ if name in ("write", "edit", "str_replace") and isinstance(args, dict):
293
+ fp = args.get("file_path", "")
294
+ if fp and os.path.isabs(fp):
295
+ art_name = os.path.basename(fp)
296
+ key = (art_name, fp)
297
+ if key not in seen:
298
+ seen.add(key)
299
+ artifacts.append(key)
300
+ except OSError as e:
301
+ logger.warning("Failed to read chat_history.jsonl: %s", e)
302
+
303
+ if artifacts:
304
+ logger.debug("Extracted %d grok artifacts: %s", len(artifacts), [n for n, _ in artifacts[:3]])
305
+ return artifacts
306
+
307
+
308
+ def _parse_grok_output(stdout_text: str, session_dir: str) -> tuple:
309
+ """Parse grok JSON output into (display_text, artifacts).
310
+
311
+ Handles both success JSON ({text, sessionId, ...}) and error JSON
312
+ ({type: error, message: ...}). Falls back to plain text on parse failure.
313
+ """
314
+ if not stdout_text:
315
+ return EMPTY_REPLY, []
316
+
317
+ try:
318
+ data = json.loads(stdout_text)
319
+ except json.JSONDecodeError:
320
+ # Non-JSON output — treat as plain text
321
+ return clean_output(stdout_text) or EMPTY_REPLY, []
322
+
323
+ if data.get("type") == "error":
324
+ msg = data.get("message", "unknown grok error")
325
+ logger.warning("grok error: %s", msg)
326
+ return format_cli_error(msg, backend="grok"), []
327
+
328
+ display = data.get("text", "")
329
+ session_id = data.get("sessionId", "")
330
+
331
+ artifacts = []
332
+ if session_id:
333
+ artifacts = _extract_grok_artifacts(session_dir, session_id)
334
+
335
+ # Strip file:/// links from display (in case grok emits them)
336
+ display = re.sub(
337
+ r"\[([^\]]+)\]\(file:///[^)]+\)",
338
+ r"[\1]",
339
+ display,
340
+ )
341
+
342
+ return clean_output(display) or EMPTY_REPLY, artifacts
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # grok CLI execution
347
+ # ---------------------------------------------------------------------------
348
+
349
+ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
350
+ """Execute grok CLI for a given user message.
351
+
352
+ Mirrors agy.run_agy() interface.
353
+ Returns (cleaned_display_text, list_of_(name, abs_path)_artifacts).
354
+ """
355
+ if timeout is None:
356
+ timeout = config.agy_timeout
357
+
358
+ t0 = time.time()
359
+ session_dir = ensure_user_grok(user_id)
360
+
361
+ # Audit logging
362
+ logger.info("[AUDIT] user=%s prompt=%.200s", user_id, prompt)
363
+ if is_dangerous(prompt):
364
+ logger.warning("[AUDIT] dangerous keyword in prompt from user=%s", user_id)
365
+
366
+ first = is_first_message(session_dir)
367
+ prefs = load_prefs(user_id)
368
+ persona_content = _read_persona(session_dir)
369
+ cmd = _build_grok_command(prompt, prefs, first, persona_content)
370
+
371
+ if first:
372
+ logger.info("First message for user %s, running: grok -p ...", user_id)
373
+ else:
374
+ logger.info("Continuing conversation for user %s, running: grok --continue -p ...", user_id)
375
+
376
+ process = None
377
+ try:
378
+ env = sanitize_env(session_dir)
379
+ env["PAGER"] = "cat"
380
+ env["CI"] = "true"
381
+ env["NONINTERACTIVE"] = "1"
382
+ env["PYTHONUNBUFFERED"] = "1"
383
+
384
+ process = await asyncio.create_subprocess_exec(
385
+ *cmd,
386
+ stdout=asyncio.subprocess.PIPE,
387
+ stderr=asyncio.subprocess.PIPE,
388
+ cwd=session_dir,
389
+ env=env,
390
+ preexec_fn=os.setsid if hasattr(os, "setsid") else None,
391
+ )
392
+
393
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
394
+ process.communicate(),
395
+ timeout=float(timeout),
396
+ )
397
+
398
+ stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
399
+ stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
400
+
401
+ display, artifacts = _parse_grok_output(stdout_text, session_dir)
402
+
403
+ # Failure detection: non-zero exit, or structured error already formatted
404
+ failed = process.returncode != 0 or (
405
+ isinstance(display, str) and display.startswith("❌")
406
+ )
407
+ if process.returncode != 0 and (not display or display == EMPTY_REPLY):
408
+ logger.warning(
409
+ "grok exited with code %s for user %s: %.200s",
410
+ process.returncode, user_id, stderr_text,
411
+ )
412
+ display = format_cli_error(
413
+ stderr_text or "grok 进程异常退出", backend="grok"
414
+ )
415
+ artifacts = []
416
+ failed = True
417
+
418
+ # Only mark session initialized on a real successful reply
419
+ if first and not failed:
420
+ mark_initialized(session_dir)
421
+
422
+ elapsed = time.time() - t0
423
+ logger.info(
424
+ "grok done: user=%s elapsed=%.1fs artifacts=%d output=%d chars failed=%s",
425
+ user_id, elapsed, len(artifacts), len(display), failed,
426
+ )
427
+ return display, artifacts
428
+
429
+ except asyncio.TimeoutError:
430
+ logger.warning("grok execution timed out after %ss for user %s", timeout, user_id)
431
+ await terminate_process(process, graceful=True)
432
+ return format_error("处理超时", f"超过 {timeout} 秒未完成,已终止本次任务。"), []
433
+
434
+ except Exception as e:
435
+ logger.exception("Unexpected error running grok: %s", e)
436
+ await terminate_process(process, graceful=False)
437
+ return format_error("执行出错", str(e)), []
438
+
439
+
440
+ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
441
+ """Run a grok subcommand (e.g., 'models', 'agent') and return cleaned output.
442
+
443
+ Timeout is fixed at 30 seconds.
444
+ Uses per-user session isolation matching run_grok.
445
+ """
446
+ session_dir = ensure_user_grok(user_id)
447
+ cmd = [config.grok_binary_path] + subcmd_args
448
+ try:
449
+ env = sanitize_env(session_dir)
450
+ process = await asyncio.create_subprocess_exec(
451
+ *cmd,
452
+ stdout=asyncio.subprocess.PIPE,
453
+ stderr=asyncio.subprocess.PIPE,
454
+ cwd=session_dir,
455
+ env=env,
456
+ preexec_fn=os.setsid if hasattr(os, "setsid") else None,
457
+ )
458
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
459
+ process.communicate(), timeout=30.0
460
+ )
461
+ stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
462
+ stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
463
+
464
+ if process.returncode != 0:
465
+ logger.warning("grok %s exited with code %s", " ".join(subcmd_args), process.returncode)
466
+ # Prefer stdout (often JSON error) then stderr
467
+ raw = stdout_text or stderr_text or "终端指令执行失败"
468
+ try:
469
+ data = json.loads(stdout_text) if stdout_text else None
470
+ except json.JSONDecodeError:
471
+ data = None
472
+ if isinstance(data, dict) and data.get("type") == "error":
473
+ return format_cli_error(data.get("message") or raw, backend="grok")
474
+ return format_cli_error(raw, backend="grok")
475
+
476
+ return clean_output(stdout_text) or EMPTY_REPLY
477
+
478
+ except asyncio.TimeoutError:
479
+ return format_error("指令超时", "子命令 30 秒内未完成。")
480
+ except Exception as e:
481
+ logger.exception("Subcommand error: %s", e)
482
+ return format_error("执行出错", str(e))
483
+
484
+
485
+ # ---------------------------------------------------------------------------
486
+ # Slash command support
487
+ # ---------------------------------------------------------------------------
488
+
489
+ def _parse_grok_models(output: str) -> list:
490
+ """Parse grok models output into a list of model names.
491
+
492
+ grok output format:
493
+ Available models:
494
+ * grok-4.5 (default)
495
+ * grok-4.5-mini
496
+ """
497
+ models = []
498
+ for line in output.split("\n"):
499
+ line = line.strip()
500
+ if line.startswith("* "):
501
+ name = line[2:].split()[0].split("(")[0].strip()
502
+ if name:
503
+ models.append(name)
504
+ return models
505
+
506
+
507
+ async def _cmd_model(args: str, user_id: str) -> str:
508
+ """Handle /model <name>: validate against grok models list, then save."""
509
+ name = args.strip()
510
+ if not name:
511
+ return "❌ **缺少参数** ❌\n\n`/model <名称>`"
512
+
513
+ output = await _run_grok_subcommand(["models"], user_id)
514
+ if output.startswith("❌"):
515
+ return "❌ **无法获取模型列表** ❌"
516
+
517
+ models = _parse_grok_models(output)
518
+ if not models:
519
+ # Fallback: treat non-empty lines as model names
520
+ models = [line.strip() for line in output.split("\n") if line.strip()]
521
+
522
+ # Exact match
523
+ if name in models:
524
+ _, embedded = parse_model_effort(name)
525
+ if embedded:
526
+ update_active_prefs(user_id, model=name, effort="")
527
+ else:
528
+ update_active_prefs(user_id, model=name)
529
+ return f"✅ **模型已切换** ✅\n\n`{name}`"
530
+
531
+ # Prefix match
532
+ prefix_matches = [m for m in models if m.startswith(name)]
533
+ if prefix_matches:
534
+ matched = prefix_matches[0]
535
+ _, embedded = parse_model_effort(matched)
536
+ if embedded:
537
+ update_active_prefs(user_id, model=matched, effort="")
538
+ else:
539
+ update_active_prefs(user_id, model=matched)
540
+ return f"✅ **模型已切换** ✅\n\n`{matched}`"
541
+
542
+ return f"❌ **模型不存在** ❌\n\n`{name}`"
543
+
544
+
545
+ def _cmd_help() -> str:
546
+ """Build /help response for grok backend."""
547
+ lines = [
548
+ "📋 **wechatbridge 支持指令 (grok)** 📋",
549
+ "",
550
+ "**模型控制**",
551
+ "- `/model <名称>` — 切换模型(用 `/models` 查看可用列表)",
552
+ "- `/models` — 查看可用模型列表",
553
+ "- `/backend <agy|grok>` — 切换后端 CLI",
554
+ "",
555
+ "**对话控制**",
556
+ "- `/clear` 或 `/new` — 重置对话(开始新会话)",
557
+ "- `/fast` — 开启**快速模式**(低推理开销)",
558
+ "- `/planning` — 开启 **planning 模式**",
559
+ "",
560
+ "**工具**",
561
+ "- `/add-dir <路径>` — 添加工作目录(grok 后端暂不支持,仅记录)",
562
+ "- `/agents` — 查看可用 agent",
563
+ "",
564
+ "**人格**",
565
+ "- `/persona <内容>` — 设置你专属的人格文档(支持 show / clear / reset 子命令)",
566
+ "",
567
+ "**其他**",
568
+ "- `/help` — 显示本帮助",
569
+ "",
570
+ "提示:其他 `/` 指令会直接交给 grok 处理。",
571
+ ]
572
+ return "\n".join(lines)
573
+
574
+
575
+ async def handle_grok_slash_command(text: str, user_id: str) -> str | None:
576
+ """Handle /-slash commands for grok backend.
577
+
578
+ Returns str for A/B/C classes, None for D class (passthrough to run_grok).
579
+ """
580
+ parts = text.strip().split(maxsplit=1)
581
+ cmd = parts[0].lower() if parts else text.lower()
582
+ args = parts[1] if len(parts) > 1 else ""
583
+
584
+ # --- B class: dangerous / rejected ---
585
+ B_CMDS = frozenset({"/exit", "/quit", "/logout"})
586
+ if cmd in B_CMDS:
587
+ return "⛔ **该指令在微信端禁用** ⛔"
588
+
589
+ # --- C class: TUI panels (not supported on WeChat) ---
590
+ C_CMDS = frozenset({
591
+ "/config", "/settings", "/context", "/diff", "/artifact", "/tasks",
592
+ "/hooks", "/keybindings", "/permissions", "/statusline",
593
+ "/copy", "/open", "/rename", "/fork", "/branch", "/rewind", "/undo",
594
+ "/resume", "/switch", "/conversation", "/title", "/feedback",
595
+ "/usage", "/quota", "/credits", "/skills",
596
+ })
597
+ if cmd in C_CMDS:
598
+ return f"⚠️ **微信端不支持** ⚠️\n\n`{cmd}`"
599
+
600
+ # --- A class: implemented commands ---
601
+ if cmd == "/help":
602
+ return _cmd_help()
603
+
604
+ if cmd in ("/clear", "/new"):
605
+ session_dir = get_session_dir(user_id)
606
+ flag_path = os.path.join(session_dir, ".initialized")
607
+ try:
608
+ if os.path.exists(flag_path):
609
+ os.remove(flag_path)
610
+ return "✅ **对话已重置** ✅"
611
+ except OSError as e:
612
+ logger.error("Failed to clear session for %s: %s", user_id, e)
613
+ return "❌ **重置失败** ❌"
614
+
615
+ if cmd == "/fast":
616
+ update_active_prefs(user_id, effort="low")
617
+ return "✅ **已开启 fast 模式** ✅"
618
+
619
+ if cmd == "/planning":
620
+ update_active_prefs(user_id, mode="plan")
621
+ return "✅ **已开启 planning 模式** ✅"
622
+
623
+ if cmd == "/model":
624
+ return await _cmd_model(args, user_id)
625
+
626
+ if cmd == "/add-dir":
627
+ path = args.strip()
628
+ if not path:
629
+ return "❌ **缺少参数** ❌\n\n`/add-dir <路径>`"
630
+ ok, result = validate_add_dir(path, user_id)
631
+ if not ok:
632
+ return f"❌ **目录不允许** ❌\n\n{result}"
633
+ resolved = result
634
+ prefs = load_prefs(user_id)
635
+ dirs = prefs.get("add_dirs", [])
636
+ if resolved not in dirs:
637
+ dirs.append(resolved)
638
+ prefs["add_dirs"] = dirs
639
+ save_prefs(user_id, prefs)
640
+ return (
641
+ f"✅ **已记录工作目录** ✅\n\n```\n{resolved}\n```\n\n"
642
+ "ℹ️ grok 后端暂不支持通过命令行传递额外目录。"
643
+ )
644
+
645
+ if cmd == "/agents":
646
+ output = await _run_grok_subcommand(["agent"], user_id)
647
+ return output
648
+
649
+ if cmd == "/models":
650
+ return await _run_grok_subcommand(["models"], user_id)
651
+
652
+ if cmd == "/persona":
653
+ return handle_grok_persona(args, user_id)
654
+
655
+ # --- MCP & Subagent ---
656
+ if cmd == "/mcp":
657
+ if not config.enable_mcp:
658
+ return "ℹ️ **该功能已禁用** ℹ️"
659
+ return (
660
+ "ℹ️ **MCP 工具使用引导** ℹ️\n\n"
661
+ "grok 已配置 MCP server。\n\n"
662
+ "使用方法:用自然语言描述调用,格式为:\n"
663
+ "> 用 `<工具名>` 调用,参数 `<json>`\n\n"
664
+ "示例:\n"
665
+ "> 用 codegraph 的 search 工具搜 ctxmode"
666
+ )
667
+
668
+ if cmd == "/agent":
669
+ if not config.enable_subagent:
670
+ return "ℹ️ **该功能已禁用** ℹ️"
671
+ if not args:
672
+ return "❌ **缺少参数** ❌\n\n`/agent <名称> <任务>`"
673
+ agent_parts = args.split(maxsplit=1)
674
+ agent_name = agent_parts[0]
675
+ agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
676
+ crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
677
+ logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
678
+ result_text, _ = await run_grok(crafted, user_id)
679
+ return result_text
680
+
681
+ # --- D class: passthrough to grok (return None so caller runs run_grok) ---
682
+ return None