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.
@@ -0,0 +1,1003 @@
1
+ """Shared logic for agy and grok CLI backends.
2
+
3
+ Contains session isolation, preference persistence, output cleanup,
4
+ dangerous prompt detection, and process management helpers used by
5
+ both agy.py and grok.py.
6
+ """
7
+
8
+ import asyncio
9
+ import hashlib
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ import shutil
15
+ import signal
16
+ import sys
17
+ import time
18
+
19
+ from .config import config
20
+
21
+ logger = logging.getLogger("wechatbridge.runner")
22
+
23
+ # ANSI escape code pattern
24
+ ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
25
+ # HTML tag pattern
26
+ HTML_TAG_RE = re.compile(r"<[^>]+>")
27
+
28
+ # Sensitive env var prefixes to strip from child process environments
29
+ _SENSITIVE_PREFIXES = (
30
+ "TOKEN", "KEY", "SECRET", "PASSWORD",
31
+ "AWS", "GITHUB", "GITLAB", "CREDENTIAL",
32
+ )
33
+ # Segment names that mark a var as secret when they appear as a path part
34
+ _SENSITIVE_SEGMENTS = frozenset({
35
+ "TOKEN", "KEY", "SECRET", "PASSWORD", "PASSWD",
36
+ "CREDENTIAL", "CREDENTIALS", "APIKEY", "API_KEY",
37
+ })
38
+ _SENSITIVE_SUFFIXES = (
39
+ "_TOKEN", "_KEY", "_SECRET", "_PASSWORD", "_PASSWD",
40
+ "_CREDENTIAL", "_CREDENTIALS", "_APIKEY",
41
+ )
42
+
43
+
44
+ def sanitize_user_id(user_id: str) -> str:
45
+ """Convert a WeChat user ID to a filesystem-safe directory name.
46
+
47
+ Uses a short hash suffix for uniqueness while keeping a readable prefix.
48
+ """
49
+ h = hashlib.sha256(user_id.encode()).hexdigest()[:12]
50
+ safe = re.sub(r"[^a-zA-Z0-9_]", "_", user_id)[:48]
51
+ return f"{safe}_{h}"
52
+
53
+
54
+ def get_session_dir(user_id: str) -> str:
55
+ """Get the per-user session directory path."""
56
+ return os.path.join(config.session_base_dir, sanitize_user_id(user_id))
57
+
58
+
59
+ def ensure_session_dir(user_id: str) -> str:
60
+ """Create per-user session dir with mode 0700 and return its path."""
61
+ path = get_session_dir(user_id)
62
+ try:
63
+ os.makedirs(path, exist_ok=True)
64
+ os.chmod(path, 0o700)
65
+ except OSError as e:
66
+ logger.warning("Failed to ensure session dir %s: %s", path, e)
67
+ return path
68
+
69
+
70
+ def path_is_under(path: str, root: str) -> bool:
71
+ """True if path is the same as root or a realpath child of root."""
72
+ try:
73
+ real = os.path.realpath(path)
74
+ root_real = os.path.realpath(root)
75
+ except OSError:
76
+ return False
77
+ if real == root_real:
78
+ return True
79
+ prefix = root_real if root_real.endswith(os.sep) else root_real + os.sep
80
+ return real.startswith(prefix)
81
+
82
+
83
+ def validate_add_dir(path: str, user_id: str) -> tuple[bool, str]:
84
+ """Validate /add-dir path: must exist as dir and sit under allowed roots.
85
+
86
+ Session dir is always allowed; extra roots from config.add_dir_roots.
87
+ Returns (ok, message_or_resolved_path).
88
+ """
89
+ raw = (path or "").strip()
90
+ if not raw:
91
+ return False, "路径为空"
92
+ expanded = os.path.expanduser(raw)
93
+ if not os.path.isabs(expanded):
94
+ # Relative paths resolve against session dir
95
+ expanded = os.path.join(get_session_dir(user_id), expanded)
96
+ try:
97
+ resolved = os.path.realpath(expanded)
98
+ except OSError as e:
99
+ return False, f"无法解析路径: {e}"
100
+ if not os.path.isdir(resolved):
101
+ return False, "路径不存在或不是目录"
102
+
103
+ allowed_roots = [get_session_dir(user_id)]
104
+ for r in getattr(config, "add_dir_roots", []) or []:
105
+ if r:
106
+ allowed_roots.append(os.path.expanduser(r))
107
+
108
+ if not any(path_is_under(resolved, root) for root in allowed_roots):
109
+ return False, (
110
+ "路径不在允许范围内。"
111
+ "仅允许会话目录"
112
+ + (" 或 WECHATBRIDGE_ADD_DIR_ROOTS 内路径" if config.add_dir_roots else "")
113
+ + "。"
114
+ )
115
+ return True, resolved
116
+
117
+
118
+ def is_first_message(session_dir: str) -> bool:
119
+ """Check if this user has no existing conversation."""
120
+ return not os.path.exists(os.path.join(session_dir, ".initialized"))
121
+
122
+
123
+ def mark_initialized(session_dir: str) -> None:
124
+ """Create .initialized flag file after first message."""
125
+ try:
126
+ os.makedirs(session_dir, exist_ok=True)
127
+ try:
128
+ os.chmod(session_dir, 0o700)
129
+ except OSError:
130
+ pass
131
+ with open(os.path.join(session_dir, ".initialized"), "w") as f:
132
+ f.write("1")
133
+ except OSError as e:
134
+ logger.error("Failed to mark session initialized: %s", e)
135
+
136
+
137
+ def clean_output(text: str) -> str:
138
+ """Remove ANSI escape codes and HTML tags from CLI output."""
139
+ text = ANSI_RE.sub("", text)
140
+ text = HTML_TAG_RE.sub("", text)
141
+ text = re.sub(r"\n{3,}", "\n\n", text)
142
+ return text.strip()
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # WeChat error reply format (fixed header + body)
147
+ # ---------------------------------------------------------------------------
148
+
149
+ # Shown when CLI returns successfully but with no display text
150
+ EMPTY_REPLY = "(无回复内容)"
151
+
152
+
153
+ def format_error(title: str, detail: str = "") -> str:
154
+ """Standard error bubble: header line only, body below.
155
+
156
+ Example:
157
+ ❌ **未登录** ❌
158
+
159
+ Grok 凭证不可用。
160
+ """
161
+ title = (title or "错误").strip().replace("\n", " ")
162
+ detail = (detail or "").strip()
163
+ if detail:
164
+ return f"❌ **{title}** ❌\n\n{detail}"
165
+ return f"❌ **{title}** ❌"
166
+
167
+
168
+ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
169
+ """Map CLI stderr/JSON error text into Chinese title + fixed header.
170
+
171
+ Never put the whole raw English blob into the ❌ **...** ❌ title line.
172
+ Known cases get a Chinese title + short Chinese explanation; raw text
173
+ is kept under「原始信息」only when it is not already Chinese-heavy.
174
+ """
175
+ raw = clean_output(raw_message or "") or "未知错误"
176
+ lower = raw.lower()
177
+ backend = (backend or "").strip().lower()
178
+ name = "Grok" if backend == "grok" else ("agy" if backend == "agy" else "CLI")
179
+
180
+ def _with_raw(title: str, zh: str) -> str:
181
+ # Avoid duplicating if raw is already the zh line
182
+ if raw.strip() == zh.strip():
183
+ return format_error(title, zh)
184
+ return format_error(title, f"{zh}\n\n原始信息:\n{raw}")
185
+
186
+ # Auth / login
187
+ if (
188
+ "not signed in" in lower
189
+ or "authenticate" in lower
190
+ or "login --device" in lower
191
+ or "grok login" in lower
192
+ or "please log in" in lower
193
+ or "please login" in lower
194
+ or "not authenticated" in lower
195
+ or "unauthorized" in lower
196
+ or "401" in lower and ("auth" in lower or "token" in lower or "login" in lower)
197
+ or ("xai_api_key" in lower and ("sign" in lower or "login" in lower or "auth" in lower))
198
+ ):
199
+ return _with_raw(
200
+ "未登录",
201
+ f"{name} 未登录,或读不到有效凭证。"
202
+ + (" 请在本机执行 `grok login --device-code`,或设置 `XAI_API_KEY`。" if backend == "grok" else " 请检查本机登录/凭证是否有效。"),
203
+ )
204
+
205
+ # Rate limit / quota
206
+ if (
207
+ "rate limit" in lower
208
+ or "rate_limit" in lower
209
+ or "too many requests" in lower
210
+ or "quota" in lower
211
+ or "resource exhausted" in lower
212
+ or "429" in lower
213
+ ):
214
+ return _with_raw("请求过于频繁", "触发限流或额度不足,请稍后再试。")
215
+
216
+ # Network
217
+ if (
218
+ "connection refused" in lower
219
+ or "connection reset" in lower
220
+ or "network is unreachable" in lower
221
+ or "name or service not known" in lower
222
+ or "temporary failure in name resolution" in lower
223
+ or "ssl" in lower and ("error" in lower or "certificate" in lower)
224
+ or "econnreset" in lower
225
+ or "econnrefused" in lower
226
+ or "fetch failed" in lower
227
+ or "socket hang up" in lower
228
+ ):
229
+ return _with_raw("网络错误", "连不上服务,请检查网络后重试。")
230
+
231
+ # Cascade / API hang (agy)
232
+ if "timeout waiting for cascade" in lower or "timeout waiting for response" in lower:
233
+ return _with_raw("级联超时", "模型 API 级联推理超时,请稍后重试或简化指令。")
234
+
235
+ if "permission" in lower and ("denied" in lower or "refuse" in lower or "rejected" in lower):
236
+ return _with_raw("权限不足", "没有执行该操作的权限。")
237
+
238
+ if "timeout" in lower or "timed out" in lower or "deadline exceeded" in lower:
239
+ return _with_raw("超时", "等待响应超时,请稍后重试。")
240
+
241
+ if "model" in lower and (
242
+ "not found" in lower
243
+ or "unknown" in lower
244
+ or "invalid" in lower
245
+ or "does not exist" in lower
246
+ or "unsupported" in lower
247
+ or "not supported" in lower
248
+ or "no such" in lower
249
+ ):
250
+ return _with_raw("模型无效", "指定的模型不可用,请用 `/models` 查看后重选。")
251
+
252
+ if "command not found" in lower or "not a command" in lower:
253
+ return _with_raw("命令不可用", f"{name} 可执行文件可能未安装或不在 PATH 中。")
254
+
255
+ if "not found" in lower or "no such file" in lower or "enoent" in lower:
256
+ return _with_raw("未找到", "请求的资源或文件不存在。")
257
+
258
+ # Generic CLI failure — short Chinese title, body is raw (may still be English)
259
+ title = "执行失败"
260
+ if backend == "grok":
261
+ title = "Grok 执行失败"
262
+ elif backend == "agy":
263
+ title = "agy 执行失败"
264
+ return format_error(title, raw)
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Per-user preference persistence (per-backend model/effort/mode memory)
269
+ # ---------------------------------------------------------------------------
270
+
271
+ KNOWN_BACKENDS = ("agy", "grok")
272
+ BACKEND_SCOPED_KEYS = ("model", "effort", "mode")
273
+
274
+
275
+ def _default_backend() -> str:
276
+ b = getattr(config, "backend", "agy") or "agy"
277
+ return b if b in KNOWN_BACKENDS else "agy"
278
+
279
+
280
+ def _empty_backend_slot() -> dict:
281
+ return {"model": "", "effort": "", "mode": ""}
282
+
283
+
284
+ def _slot_from(data) -> dict:
285
+ """Normalize a by_backend slot to model/effort/mode strings."""
286
+ if not isinstance(data, dict):
287
+ return _empty_backend_slot()
288
+ return {
289
+ "model": data.get("model") or "",
290
+ "effort": data.get("effort") or "",
291
+ "mode": data.get("mode") or "",
292
+ }
293
+
294
+
295
+ def default_prefs() -> dict:
296
+ """Fresh prefs: empty model/effort/mode means CLI built-in default."""
297
+ backend = _default_backend()
298
+ return {
299
+ "model": "",
300
+ "effort": "",
301
+ "mode": "",
302
+ "add_dirs": [],
303
+ "backend": backend,
304
+ "by_backend": {b: _empty_backend_slot() for b in KNOWN_BACKENDS},
305
+ }
306
+
307
+
308
+ def normalize_prefs(data: dict | None) -> dict:
309
+ """Fill defaults, migrate flat prefs → by_backend, ensure structure.
310
+
311
+ Migration (no by_backend yet): copy top-level model/effort/mode into the
312
+ *current* backend slot only; other backends stay empty (project default).
313
+ """
314
+ base = default_prefs()
315
+ if not isinstance(data, dict):
316
+ return base
317
+
318
+ backend = data.get("backend") or base["backend"]
319
+ if backend not in KNOWN_BACKENDS:
320
+ backend = base["backend"]
321
+
322
+ model = data.get("model") if data.get("model") is not None else ""
323
+ effort = data.get("effort") if data.get("effort") is not None else ""
324
+ mode = data.get("mode") if data.get("mode") is not None else ""
325
+ if not isinstance(model, str):
326
+ model = str(model) if model else ""
327
+ if not isinstance(effort, str):
328
+ effort = str(effort) if effort else ""
329
+ if not isinstance(mode, str):
330
+ mode = str(mode) if mode else ""
331
+
332
+ add_dirs = data.get("add_dirs", [])
333
+ if not isinstance(add_dirs, list):
334
+ add_dirs = []
335
+
336
+ raw_by = data.get("by_backend")
337
+ if isinstance(raw_by, dict):
338
+ by_backend = {b: _slot_from(raw_by.get(b)) for b in KNOWN_BACKENDS}
339
+ # Keep any extra backend keys only if well-formed (forward-compatible)
340
+ for k, v in raw_by.items():
341
+ if k not in by_backend and isinstance(v, dict):
342
+ by_backend[k] = _slot_from(v)
343
+ else:
344
+ # Legacy flat file: attribute current active fields to current backend only
345
+ by_backend = {b: _empty_backend_slot() for b in KNOWN_BACKENDS}
346
+ by_backend[backend] = {
347
+ "model": model or "",
348
+ "effort": effort or "",
349
+ "mode": mode or "",
350
+ }
351
+
352
+ return {
353
+ "model": model or "",
354
+ "effort": effort or "",
355
+ "mode": mode or "",
356
+ "add_dirs": add_dirs,
357
+ "backend": backend,
358
+ "by_backend": by_backend,
359
+ }
360
+
361
+
362
+ def sync_active_to_memory(prefs: dict) -> None:
363
+ """Write top-level model/effort/mode into by_backend[current backend]."""
364
+ backend = prefs.get("backend") or _default_backend()
365
+ if backend not in KNOWN_BACKENDS:
366
+ backend = _default_backend()
367
+ prefs["backend"] = backend
368
+ by = prefs.setdefault("by_backend", {})
369
+ if not isinstance(by, dict):
370
+ by = {}
371
+ prefs["by_backend"] = by
372
+ slot = _empty_backend_slot()
373
+ for k in BACKEND_SCOPED_KEYS:
374
+ slot[k] = prefs.get(k) or ""
375
+ by[backend] = slot
376
+ # Ensure sibling backends exist
377
+ for b in KNOWN_BACKENDS:
378
+ if b not in by or not isinstance(by.get(b), dict):
379
+ by[b] = _empty_backend_slot()
380
+
381
+
382
+ def apply_memory_to_active(prefs: dict, backend: str) -> None:
383
+ """Load by_backend[backend] into top-level model/effort/mode.
384
+
385
+ Empty slot → empty active fields (CLI default / project default).
386
+ """
387
+ if backend not in KNOWN_BACKENDS:
388
+ backend = _default_backend()
389
+ by = prefs.get("by_backend") if isinstance(prefs.get("by_backend"), dict) else {}
390
+ slot = _slot_from(by.get(backend))
391
+ for k in BACKEND_SCOPED_KEYS:
392
+ prefs[k] = slot.get(k) or ""
393
+
394
+
395
+ def switch_backend_prefs(prefs: dict, new_backend: str) -> tuple[str, str]:
396
+ """Snapshot current backend memory, switch, restore target memory.
397
+
398
+ Returns (old_backend, new_backend). Mutates prefs in place.
399
+ First visit to a backend leaves model/effort/mode empty (CLI default).
400
+ """
401
+ if new_backend not in KNOWN_BACKENDS:
402
+ raise ValueError(f"unknown backend: {new_backend}")
403
+ old = prefs.get("backend") or _default_backend()
404
+ if old not in KNOWN_BACKENDS:
405
+ old = _default_backend()
406
+ # Persist whatever is active under the backend we are leaving
407
+ prefs["backend"] = old
408
+ sync_active_to_memory(prefs)
409
+ prefs["backend"] = new_backend
410
+ apply_memory_to_active(prefs, new_backend)
411
+ # Keep target slot materialised even if empty
412
+ sync_active_to_memory(prefs)
413
+ return old, new_backend
414
+
415
+
416
+ def update_active_prefs(user_id: str, **fields) -> dict:
417
+ """Load prefs, update top-level fields, mirror into current backend memory, save.
418
+
419
+ Use for /model, /fast, /planning and any backend-scoped preference change.
420
+ Unknown keys are still written to the top level (e.g. add_dirs) but only
421
+ model/effort/mode are mirrored into by_backend.
422
+ """
423
+ prefs = load_prefs(user_id)
424
+ for k, v in fields.items():
425
+ if v is None:
426
+ prefs[k] = "" if k in BACKEND_SCOPED_KEYS else v
427
+ else:
428
+ prefs[k] = v
429
+ if any(k in BACKEND_SCOPED_KEYS for k in fields):
430
+ sync_active_to_memory(prefs)
431
+ save_prefs(user_id, prefs)
432
+ return prefs
433
+
434
+
435
+ def format_model_label(model: str) -> str:
436
+ """Human-readable model for switch replies."""
437
+ model = (model or "").strip()
438
+ if not model:
439
+ return "后端默认(未指定)"
440
+ return model
441
+
442
+
443
+ def load_prefs(user_id: str) -> dict:
444
+ """Load per-user preferences from prefs.json (normalized + migrated).
445
+
446
+ After normalize, active model/effort/mode are re-aligned from
447
+ by_backend[current] so a stale top-level field cannot outlive memory.
448
+ """
449
+ session_dir = get_session_dir(user_id)
450
+ prefs_path = os.path.join(session_dir, "prefs.json")
451
+ try:
452
+ if os.path.exists(prefs_path):
453
+ with open(prefs_path, "r") as f:
454
+ data = json.load(f)
455
+ prefs = normalize_prefs(data)
456
+ apply_memory_to_active(prefs, prefs.get("backend") or _default_backend())
457
+ return prefs
458
+ except (OSError, json.JSONDecodeError) as e:
459
+ logger.warning("Failed to load prefs for %s: %s", user_id, e)
460
+ return default_prefs()
461
+
462
+
463
+ def save_prefs(user_id: str, prefs: dict) -> None:
464
+ """Save per-user preferences to prefs.json (normalized structure)."""
465
+ session_dir = get_session_dir(user_id)
466
+ os.makedirs(session_dir, exist_ok=True)
467
+ prefs_path = os.path.join(session_dir, "prefs.json")
468
+ try:
469
+ payload = normalize_prefs(prefs)
470
+ # Prefer caller's active fields / backend / add_dirs / by_backend
471
+ for k in BACKEND_SCOPED_KEYS:
472
+ if k in prefs:
473
+ payload[k] = prefs.get(k) or ""
474
+ if prefs.get("backend") in KNOWN_BACKENDS:
475
+ payload["backend"] = prefs["backend"]
476
+ if isinstance(prefs.get("add_dirs"), list):
477
+ payload["add_dirs"] = prefs["add_dirs"]
478
+ if isinstance(prefs.get("by_backend"), dict):
479
+ for b, slot in prefs["by_backend"].items():
480
+ payload["by_backend"][b] = _slot_from(slot)
481
+ for b in KNOWN_BACKENDS:
482
+ payload["by_backend"].setdefault(b, _empty_backend_slot())
483
+ # Current backend slot always mirrors active model/effort/mode
484
+ sync_active_to_memory(payload)
485
+ with open(prefs_path, "w") as f:
486
+ json.dump(payload, f, ensure_ascii=False, indent=2)
487
+ # Keep caller's dict in sync with what was written
488
+ prefs.update(payload)
489
+ except OSError as e:
490
+ logger.error("Failed to save prefs for %s: %s", user_id, e)
491
+
492
+
493
+ # ---------------------------------------------------------------------------
494
+ # Dangerous prompt detection
495
+ # ---------------------------------------------------------------------------
496
+
497
+ # Confirm gate: hardcoded dangerous keyword fallbacks (used when config.confirm_keywords is empty)
498
+ # Prefer concrete shell/destructive patterns; avoid bare Chinese words like「删除」that false-positive daily chat.
499
+ _DANGEROUS_KEYWORDS = [
500
+ "rm -rf /", "rm -rf/*", "rm -rf ~", "rm -rf~",
501
+ "curl |sh", "curl|sh", "curl | bash", "curl|bash",
502
+ "wget -o- | sh", "wget|sh", "wget|bash",
503
+ "mkfs.", "mkfs ", "dd if=", ":(){", "fork bomb",
504
+ "chmod -r 777 /", "chmod -r 777/",
505
+ "drop table", "drop database",
506
+ "format c:", "del /f /s",
507
+ "格式化磁盘", "格式化硬盘", "清空系统", "删掉所有", "删除全部",
508
+ "卸载系统",
509
+ ]
510
+
511
+
512
+ def is_dangerous(prompt: str) -> bool:
513
+ """Check if a prompt contains dangerous keywords.
514
+
515
+ Uses config.confirm_keywords if non-empty, otherwise falls back to
516
+ the hardcoded _DANGEROUS_KEYWORDS list.
517
+ """
518
+ keywords = config.confirm_keywords if config.confirm_keywords else _DANGEROUS_KEYWORDS
519
+ lower = prompt.lower()
520
+ for kw in keywords:
521
+ if kw.lower() in lower:
522
+ return True
523
+ return False
524
+
525
+
526
+ # ---------------------------------------------------------------------------
527
+ # Model/effort parsing
528
+ # ---------------------------------------------------------------------------
529
+
530
+ def parse_model_effort(model: str) -> tuple[str, str | None]:
531
+ """Split 'gemini-3.6-flash-high' -> ('gemini-3.6-flash', 'high').
532
+
533
+ Returns (base_model, embedded_effort) where embedded_effort is None if
534
+ the model name does not end with -high, -medium, or -low.
535
+ """
536
+ for suffix in ("-high", "-medium", "-low"):
537
+ if model.endswith(suffix):
538
+ base = model[: -len(suffix)]
539
+ effort = suffix[1:] # strip leading dash
540
+ return base, effort
541
+ return model, None
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # Subprocess environment and process management
546
+ # ---------------------------------------------------------------------------
547
+
548
+ def _is_sensitive_env_name(name: str) -> bool:
549
+ """True if env var name looks like a secret (prefix, segment, or suffix)."""
550
+ u = (name or "").upper()
551
+ if not u:
552
+ return False
553
+ if u.startswith(_SENSITIVE_PREFIXES):
554
+ return True
555
+ if u.endswith(_SENSITIVE_SUFFIXES):
556
+ return True
557
+ if "API_KEY" in u or "ACCESS_KEY" in u or "SECRET_KEY" in u or "AUTH_TOKEN" in u:
558
+ return True
559
+ for part in re.split(r"[._-]+", u):
560
+ if part in _SENSITIVE_SEGMENTS:
561
+ return True
562
+ return False
563
+
564
+
565
+ def sanitize_env(session_dir: str) -> dict:
566
+ """Build a clean environment dict for CLI subprocesses.
567
+
568
+ Strips sensitive vars (including XAI_API_KEY / OPENAI_API_KEY style names),
569
+ sets HOME (and USERPROFILE on Windows) to session_dir for per-user isolation.
570
+ """
571
+ env = {
572
+ k: v for k, v in os.environ.items()
573
+ if not _is_sensitive_env_name(k)
574
+ }
575
+ env["HOME"] = session_dir
576
+ if sys.platform == "win32":
577
+ env["USERPROFILE"] = session_dir
578
+ return env
579
+
580
+
581
+ # Temporary / cache roots under each user session (safe to expire file-by-file).
582
+ _SESSION_TEMP_REL_DIRS = (
583
+ "images",
584
+ "files",
585
+ ".cache",
586
+ os.path.join(".gemini", "antigravity-cli", "scratch"),
587
+ os.path.join(".gemini", "antigravity-cli", "crashes"),
588
+ os.path.join(".gemini", "antigravity-cli", "log"),
589
+ os.path.join(".gemini", "antigravity-cli", "cache"),
590
+ os.path.join(".grok", "logs"),
591
+ )
592
+
593
+ # Dialogue history — cleaned as *units* (never split SQLite sidecars / session trees).
594
+ _HISTORY_CONVERSATIONS_REL = os.path.join(
595
+ ".gemini", "antigravity-cli", "conversations"
596
+ )
597
+ _HISTORY_BRAIN_REL = os.path.join(".gemini", "antigravity-cli", "brain")
598
+ _HISTORY_KNOWLEDGE_REL = os.path.join(".gemini", "antigravity-cli", "knowledge")
599
+ _HISTORY_GROK_SESSIONS_REL = os.path.join(".grok", "sessions")
600
+
601
+ _SESSION_HISTORY_REL_DIRS = (
602
+ _HISTORY_CONVERSATIONS_REL,
603
+ _HISTORY_BRAIN_REL,
604
+ _HISTORY_KNOWLEDGE_REL,
605
+ _HISTORY_GROK_SESSIONS_REL,
606
+ )
607
+
608
+ # SQLite sidecar suffixes that must share fate with the main ``*.db`` file.
609
+ _SQLITE_SIDECAR_TAILS = ("-wal", "-shm", "-journal")
610
+
611
+
612
+ def _remove_old_files_under(root: str, cutoff: float) -> int:
613
+ """Delete files under root older than cutoff; prune empty subdirs.
614
+
615
+ For independent temp files only (images/cache/scratch). Does not remove
616
+ ``root`` itself. Returns number of files removed.
617
+ """
618
+ if not os.path.isdir(root):
619
+ return 0
620
+ removed = 0
621
+ try:
622
+ for dirpath, dirnames, filenames in os.walk(root, topdown=False):
623
+ for fn in filenames:
624
+ path = os.path.join(dirpath, fn)
625
+ try:
626
+ if not os.path.isfile(path) and not os.path.islink(path):
627
+ continue
628
+ if os.path.getmtime(path) < cutoff:
629
+ os.remove(path)
630
+ removed += 1
631
+ logger.info("Session cleanup: removed %s", path)
632
+ except OSError as e:
633
+ logger.warning("Session cleanup failed %s: %s", path, e)
634
+ if dirpath == root:
635
+ continue
636
+ try:
637
+ if not os.listdir(dirpath):
638
+ os.rmdir(dirpath)
639
+ except OSError:
640
+ pass
641
+ except OSError as e:
642
+ logger.warning("Session cleanup walk failed %s: %s", root, e)
643
+ return removed
644
+
645
+
646
+ def _file_mtime(path: str) -> float | None:
647
+ try:
648
+ return os.path.getmtime(path)
649
+ except OSError:
650
+ return None
651
+
652
+
653
+ def _tree_newest_mtime(root: str) -> float | None:
654
+ """Newest *file* mtime under root (directory mtimes are ignored).
655
+
656
+ Parent dirs get a fresh mtime on mkdir/unlink of children; using them would
657
+ keep idle trees forever or falsely mark them active. Empty trees fall back
658
+ to the directory mtime so empty shells can still be pruned.
659
+ """
660
+ if os.path.isfile(root) or os.path.islink(root):
661
+ return _file_mtime(root)
662
+ if not os.path.isdir(root):
663
+ return _file_mtime(root)
664
+ newest: float | None = None
665
+ try:
666
+ for dirpath, _, filenames in os.walk(root):
667
+ for fn in filenames:
668
+ m = _file_mtime(os.path.join(dirpath, fn))
669
+ if m is not None and (newest is None or m > newest):
670
+ newest = m
671
+ except OSError as e:
672
+ logger.warning("Session cleanup mtime walk failed %s: %s", root, e)
673
+ if newest is None:
674
+ return _file_mtime(root)
675
+ return newest
676
+
677
+
678
+ def _paths_newest_mtime(paths: list[str]) -> float | None:
679
+ newest: float | None = None
680
+ for path in paths:
681
+ if os.path.isdir(path) and not os.path.islink(path):
682
+ m = _tree_newest_mtime(path)
683
+ else:
684
+ m = _file_mtime(path)
685
+ if m is not None and (newest is None or m > newest):
686
+ newest = m
687
+ return newest
688
+
689
+
690
+ def _delete_path_unit(path: str) -> int:
691
+ """Remove a file or directory tree. Returns number of *files* removed (est.)."""
692
+ removed = 0
693
+ try:
694
+ if os.path.isdir(path) and not os.path.islink(path):
695
+ for dirpath, _, filenames in os.walk(path):
696
+ removed += len(filenames)
697
+ shutil.rmtree(path)
698
+ logger.info("Session cleanup: removed tree %s", path)
699
+ elif os.path.lexists(path):
700
+ os.remove(path)
701
+ removed = 1
702
+ logger.info("Session cleanup: removed %s", path)
703
+ except OSError as e:
704
+ logger.warning("Session cleanup failed %s: %s", path, e)
705
+ return removed
706
+
707
+
708
+ def _remove_idle_unit(paths: list[str], cutoff: float) -> int:
709
+ """If the unit's newest mtime is older than cutoff, delete every path in it.
710
+
711
+ Re-checks mtime immediately before delete (narrows TOCTOU). Keeps the whole
712
+ unit if *any* member is still fresh — never splits SQLite sidecars.
713
+ """
714
+ paths = [p for p in paths if os.path.lexists(p)]
715
+ if not paths:
716
+ return 0
717
+ newest = _paths_newest_mtime(paths)
718
+ if newest is None or newest >= cutoff:
719
+ return 0
720
+ # Re-check right before mutating
721
+ newest2 = _paths_newest_mtime(paths)
722
+ if newest2 is None or newest2 >= cutoff:
723
+ return 0
724
+ removed = 0
725
+ for path in paths:
726
+ removed += _delete_path_unit(path)
727
+ return removed
728
+
729
+
730
+ def _sqlite_unit_key(filename: str) -> str | None:
731
+ """Map ``id.db`` / ``id.db-wal`` / ``id.db-shm`` / ``id.db-journal`` → ``id.db``.
732
+
733
+ Returns None if the name is not a SQLite main DB or known sidecar.
734
+ """
735
+ if filename.endswith(".db"):
736
+ return filename
737
+ for tail in _SQLITE_SIDECAR_TAILS:
738
+ # e.g. foo.db-wal → foo.db
739
+ suf = ".db" + tail
740
+ if filename.endswith(suf):
741
+ return filename[: -len(tail)]
742
+ return None
743
+
744
+
745
+ def _clean_conversation_dbs(conv_dir: str, cutoff: float) -> int:
746
+ """Expire idle agy conversation SQLite DBs as whole units (db+wal+shm)."""
747
+ if not os.path.isdir(conv_dir):
748
+ return 0
749
+ groups: dict[str, list[str]] = {}
750
+ loose: list[str] = []
751
+ try:
752
+ names = os.listdir(conv_dir)
753
+ except OSError as e:
754
+ logger.warning("Session cleanup list failed %s: %s", conv_dir, e)
755
+ return 0
756
+ for name in names:
757
+ path = os.path.join(conv_dir, name)
758
+ if os.path.isdir(path) and not os.path.islink(path):
759
+ # Unexpected subdir: treat as tree unit
760
+ loose.append(path)
761
+ continue
762
+ key = _sqlite_unit_key(name)
763
+ if key is not None:
764
+ groups.setdefault(key, []).append(path)
765
+ else:
766
+ loose.append(path)
767
+ removed = 0
768
+ for key, members in groups.items():
769
+ removed += _remove_idle_unit(members, cutoff)
770
+ for path in loose:
771
+ removed += _remove_idle_unit([path], cutoff)
772
+ return removed
773
+
774
+
775
+ def _clean_child_units(parent: str, cutoff: float) -> int:
776
+ """Expire each direct child (file or directory tree) by its newest mtime."""
777
+ if not os.path.isdir(parent):
778
+ return 0
779
+ removed = 0
780
+ try:
781
+ names = os.listdir(parent)
782
+ except OSError as e:
783
+ logger.warning("Session cleanup list failed %s: %s", parent, e)
784
+ return 0
785
+ for name in names:
786
+ path = os.path.join(parent, name)
787
+ removed += _remove_idle_unit([path], cutoff)
788
+ return removed
789
+
790
+
791
+ def _clean_grok_sessions(sessions_root: str, cutoff: float) -> int:
792
+ """Expire idle grok session trees: sessions/<cwd-key>/<session-id>/ as units.
793
+
794
+ Top-level index files (e.g. session_search.sqlite) are their own units.
795
+ Empty cwd-key dirs are pruned afterward.
796
+ """
797
+ if not os.path.isdir(sessions_root):
798
+ return 0
799
+ removed = 0
800
+ try:
801
+ names = os.listdir(sessions_root)
802
+ except OSError as e:
803
+ logger.warning("Session cleanup list failed %s: %s", sessions_root, e)
804
+ return 0
805
+ for name in names:
806
+ path = os.path.join(sessions_root, name)
807
+ if os.path.isfile(path) or os.path.islink(path):
808
+ removed += _remove_idle_unit([path], cutoff)
809
+ continue
810
+ if not os.path.isdir(path):
811
+ continue
812
+ # cwd-key bucket: expire each session-id child as a full tree
813
+ try:
814
+ children = os.listdir(path)
815
+ except OSError as e:
816
+ logger.warning("Session cleanup list failed %s: %s", path, e)
817
+ continue
818
+ for child in children:
819
+ child_path = os.path.join(path, child)
820
+ removed += _remove_idle_unit([child_path], cutoff)
821
+ try:
822
+ if not os.listdir(path):
823
+ os.rmdir(path)
824
+ logger.info("Session cleanup: removed empty dir %s", path)
825
+ except OSError:
826
+ pass
827
+ return removed
828
+
829
+
830
+ def _clean_user_history(user_dir: str, cutoff: float) -> int:
831
+ """Unit-based dialogue history cleanup for one user session directory."""
832
+ removed = 0
833
+ removed += _clean_conversation_dbs(
834
+ os.path.join(user_dir, _HISTORY_CONVERSATIONS_REL), cutoff
835
+ )
836
+ removed += _clean_child_units(
837
+ os.path.join(user_dir, _HISTORY_BRAIN_REL), cutoff
838
+ )
839
+ removed += _clean_child_units(
840
+ os.path.join(user_dir, _HISTORY_KNOWLEDGE_REL), cutoff
841
+ )
842
+ removed += _clean_grok_sessions(
843
+ os.path.join(user_dir, _HISTORY_GROK_SESSIONS_REL), cutoff
844
+ )
845
+ return removed
846
+
847
+
848
+ def _dir_has_any_file(root: str) -> bool:
849
+ if not os.path.isdir(root):
850
+ return False
851
+ try:
852
+ for dirpath, _, filenames in os.walk(root):
853
+ if filenames:
854
+ return True
855
+ except OSError:
856
+ return False
857
+ return False
858
+
859
+
860
+ def _clear_initialized_if_no_history(user_dir: str) -> bool:
861
+ """If no dialogue history left, drop .initialized so next turn starts fresh.
862
+
863
+ Avoids --continue against an empty/missing conversation after TTL cleanup.
864
+ """
865
+ for rel in _SESSION_HISTORY_REL_DIRS:
866
+ if _dir_has_any_file(os.path.join(user_dir, rel)):
867
+ return False
868
+ flag = os.path.join(user_dir, ".initialized")
869
+ if not os.path.exists(flag):
870
+ return False
871
+ try:
872
+ os.remove(flag)
873
+ logger.info(
874
+ "Session cleanup: cleared .initialized for %s (no history left)",
875
+ user_dir,
876
+ )
877
+ return True
878
+ except OSError as e:
879
+ logger.warning("Session cleanup: failed to clear .initialized %s: %s", flag, e)
880
+ return False
881
+
882
+
883
+ def clean_session_data(
884
+ retention_days: int | None = None,
885
+ history_retention_days: int | None = None,
886
+ ) -> int:
887
+ """Remove old session artifacts under each user directory.
888
+
889
+ Two TTLs:
890
+ - Temps (default ``session_retention_days``, often 7d): file-level mtime
891
+ under images/files/.cache/scratch/logs/...
892
+ - Dialogue history (default ``history_retention_days``, 30d): **unit** idle
893
+ time = newest mtime in the unit. Units are:
894
+ * agy ``conversations``: each ``*.db`` + ``*.db-wal/shm/journal`` together
895
+ * agy ``brain`` / ``knowledge``: each top-level child tree/file
896
+ * grok ``sessions``: each ``<cwd>/<session-id>/`` tree; top-level indexes alone
897
+
898
+ Never splits a unit (prevents half-deleted SQLite DBs). Does **not** touch
899
+ prefs, auth links, or CLI install trees.
900
+
901
+ If a user's history dirs are fully empty after cleanup, removes
902
+ ``.initialized`` so the next message starts a new conversation.
903
+
904
+ Returns number of removed files (trees count as their file totals).
905
+ """
906
+ if retention_days is None:
907
+ retention_days = config.session_retention_days
908
+ if history_retention_days is None:
909
+ history_retention_days = config.history_retention_days
910
+ base = config.session_base_dir
911
+ if not os.path.isdir(base):
912
+ return 0
913
+ now = time.time()
914
+ temp_cutoff = now - max(int(retention_days), 0) * 86400
915
+ hist_cutoff = now - max(int(history_retention_days), 0) * 86400
916
+ removed = 0
917
+ try:
918
+ for name in os.listdir(base):
919
+ user_dir = os.path.join(base, name)
920
+ if not os.path.isdir(user_dir):
921
+ continue
922
+ for rel in _SESSION_TEMP_REL_DIRS:
923
+ removed += _remove_old_files_under(
924
+ os.path.join(user_dir, rel), temp_cutoff
925
+ )
926
+ removed += _clean_user_history(user_dir, hist_cutoff)
927
+ _clear_initialized_if_no_history(user_dir)
928
+ except OSError as e:
929
+ logger.error("Session data cleanup error: %s", e)
930
+ return removed
931
+
932
+
933
+ def clean_session_media(retention_days: int | None = None) -> int:
934
+ """Backward-compatible alias for :func:`clean_session_data` (temps + history)."""
935
+ return clean_session_data(retention_days=retention_days)
936
+
937
+
938
+ def split_message_chunks(text: str, limit: int | None = None) -> list[str]:
939
+ """Split a long reply into chunks under limit characters (prefer newlines).
940
+
941
+ Cuts by position only — no rstrip/lstrip at boundaries — so
942
+ ''.join(chunks) always equals the original text.
943
+ """
944
+ if limit is None:
945
+ limit = config.message_chunk_chars
946
+ if limit <= 0:
947
+ return [text or ""]
948
+ text = text or ""
949
+ if len(text) <= limit:
950
+ return [text]
951
+
952
+ chunks: list[str] = []
953
+ rest = text
954
+ while rest:
955
+ if len(rest) <= limit:
956
+ chunks.append(rest)
957
+ break
958
+ window = rest[:limit]
959
+ # Prefer break at newline, then space; never cut at <=0 (would infinite-loop)
960
+ cut = window.rfind("\n")
961
+ if cut <= 0 or cut < limit // 3:
962
+ cut = window.rfind(" ")
963
+ if cut <= 0 or cut < limit // 3:
964
+ cut = limit
965
+ chunks.append(rest[:cut])
966
+ rest = rest[cut:]
967
+ # Drop pure-empty pieces only (should not happen with cut > 0)
968
+ return [c for c in chunks if c != ""] or [""]
969
+
970
+
971
+ async def terminate_process(process, graceful: bool = True) -> None:
972
+ """Terminate a subprocess with Unix process-group or Windows direct kill.
973
+
974
+ graceful: SIGTERM → 2s wait → SIGKILL (Unix); kill() (Windows).
975
+ Non-graceful: SIGKILL immediately (Unix); kill() (Windows).
976
+ """
977
+ if not process or not process.pid:
978
+ return
979
+ try:
980
+ if hasattr(os, "getpgid") and hasattr(os, "killpg"):
981
+ pgid = os.getpgid(process.pid)
982
+ if graceful:
983
+ os.killpg(pgid, signal.SIGTERM)
984
+ logger.info("Sent SIGTERM to process group %s for graceful lock release", pgid)
985
+ for _ in range(20):
986
+ if process.returncode is not None:
987
+ break
988
+ await asyncio.sleep(0.1)
989
+ if process.returncode is None:
990
+ os.killpg(pgid, signal.SIGKILL)
991
+ logger.info("Sent SIGKILL to process group %s after grace period", pgid)
992
+ else:
993
+ os.killpg(pgid, signal.SIGKILL)
994
+ else:
995
+ # Windows: no process groups, kill directly
996
+ process.kill()
997
+ logger.info("Killed process %s directly (non-Unix)", process.pid)
998
+ except (ProcessLookupError, PermissionError, OSError) as e:
999
+ logger.warning("Failed to terminate process: %s", e)
1000
+ try:
1001
+ await process.wait()
1002
+ except Exception:
1003
+ pass