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/__init__.py +2 -0
- wechatbridge/__main__.py +4 -0
- wechatbridge/agy.py +630 -0
- wechatbridge/config.py +266 -0
- wechatbridge/grok.py +682 -0
- wechatbridge/ilink.py +849 -0
- wechatbridge/main.py +755 -0
- wechatbridge/runner_common.py +1003 -0
- wechatbridge/update_check.py +175 -0
- wechatbridge_cli-1.3.0.dist-info/METADATA +29 -0
- wechatbridge_cli-1.3.0.dist-info/RECORD +15 -0
- wechatbridge_cli-1.3.0.dist-info/WHEEL +5 -0
- wechatbridge_cli-1.3.0.dist-info/entry_points.txt +2 -0
- wechatbridge_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- wechatbridge_cli-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -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
|