abyss-cli 0.1.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.
- abyss/__init__.py +3 -0
- abyss/ansi_menu.py +559 -0
- abyss/api_client.py +123 -0
- abyss/commands/__init__.py +12 -0
- abyss/commands/slash.py +72 -0
- abyss/config.py +121 -0
- abyss/custom_input.py +382 -0
- abyss/extensions/__init__.py +21 -0
- abyss/extensions/cli.py +160 -0
- abyss/extensions/installer.py +452 -0
- abyss/extensions/registry.py +119 -0
- abyss/extensions/url_parser.py +86 -0
- abyss/hooks/__init__.py +12 -0
- abyss/hooks/runner.py +144 -0
- abyss/logger.py +218 -0
- abyss/main.py +763 -0
- abyss/mcp/__init__.py +13 -0
- abyss/mcp/manager.py +189 -0
- abyss/prompts/__init__.py +26 -0
- abyss/session.py +79 -0
- abyss/skills/__init__.py +12 -0
- abyss/skills/loader.py +150 -0
- abyss/tools/__init__.py +20 -0
- abyss/tools/base.py +45 -0
- abyss/tools/file_edit.py +48 -0
- abyss/tools/file_read.py +54 -0
- abyss/tools/file_write.py +44 -0
- abyss/tools/registry.py +107 -0
- abyss/tools/shell_exec.py +181 -0
- abyss/tools/web_search.py +63 -0
- abyss_cli-0.1.0.dist-info/METADATA +11 -0
- abyss_cli-0.1.0.dist-info/RECORD +35 -0
- abyss_cli-0.1.0.dist-info/WHEEL +5 -0
- abyss_cli-0.1.0.dist-info/entry_points.txt +2 -0
- abyss_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
扩展注册表
|
|
4
|
+
跟踪所有通过 /extension install 安装的扩展,持久化到 ~/.abyss/registry.json。
|
|
5
|
+
记录:ID、类型、来源 URL、subpath、ref、commit hash、安装时间。
|
|
6
|
+
用于:/extension update(按 commit 判断有无更新)、/extension remove(精确清理)。
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import threading
|
|
10
|
+
from dataclasses import dataclass, field, asdict
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExtensionType(Enum):
|
|
17
|
+
"""扩展类型。"""
|
|
18
|
+
SKILL = "skill"
|
|
19
|
+
COMMAND = "command"
|
|
20
|
+
HOOK = "hook"
|
|
21
|
+
MCP = "mcp"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class InstalledExtension:
|
|
26
|
+
"""已安装的扩展记录。"""
|
|
27
|
+
id: str
|
|
28
|
+
type: ExtensionType
|
|
29
|
+
source_url: str
|
|
30
|
+
subpath: str
|
|
31
|
+
ref: str
|
|
32
|
+
commit: str
|
|
33
|
+
installed_at: str
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
d = asdict(self)
|
|
37
|
+
d["type"] = self.type.value
|
|
38
|
+
return d
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_dict(cls, d: dict) -> "InstalledExtension":
|
|
42
|
+
return cls(
|
|
43
|
+
id=d["id"],
|
|
44
|
+
type=ExtensionType(d["type"]),
|
|
45
|
+
source_url=d["source_url"],
|
|
46
|
+
subpath=d.get("subpath", ""),
|
|
47
|
+
ref=d.get("ref", "HEAD"),
|
|
48
|
+
commit=d.get("commit", ""),
|
|
49
|
+
installed_at=d.get("installed_at", ""),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ExtensionRegistry:
|
|
54
|
+
"""已安装扩展的注册表,JSON 持久化。"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, registry_path: Path):
|
|
57
|
+
self._path = registry_path
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
self._items: Dict[str, InstalledExtension] = {}
|
|
60
|
+
self._load()
|
|
61
|
+
|
|
62
|
+
def _load(self) -> None:
|
|
63
|
+
"""从文件加载,文件不存在则空注册表"""
|
|
64
|
+
if not self._path.exists():
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
with open(self._path, "r", encoding="utf-8") as f:
|
|
68
|
+
data = json.load(f)
|
|
69
|
+
for ext_data in data.get("extensions", []):
|
|
70
|
+
ext = InstalledExtension.from_dict(ext_data)
|
|
71
|
+
self._items[ext.id] = ext
|
|
72
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
73
|
+
self._items = {}
|
|
74
|
+
|
|
75
|
+
def _save(self) -> None:
|
|
76
|
+
"""持久化到文件"""
|
|
77
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
data = {"extensions": [ext.to_dict() for ext in self._items.values()]}
|
|
79
|
+
with open(self._path, "w", encoding="utf-8") as f:
|
|
80
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
81
|
+
|
|
82
|
+
def add(self, ext: InstalledExtension) -> None:
|
|
83
|
+
"""添加或覆盖扩展记录"""
|
|
84
|
+
with self._lock:
|
|
85
|
+
self._items[ext.id] = ext
|
|
86
|
+
self._save()
|
|
87
|
+
|
|
88
|
+
def get(self, ext_id: str) -> Optional[InstalledExtension]:
|
|
89
|
+
"""按 ID 获取扩展"""
|
|
90
|
+
return self._items.get(ext_id)
|
|
91
|
+
|
|
92
|
+
def list_all(self) -> List[InstalledExtension]:
|
|
93
|
+
"""返回所有已安装扩展"""
|
|
94
|
+
return list(self._items.values())
|
|
95
|
+
|
|
96
|
+
def list_by_type(self, ext_type: ExtensionType) -> List[InstalledExtension]:
|
|
97
|
+
"""按类型过滤"""
|
|
98
|
+
return [e for e in self._items.values() if e.type == ext_type]
|
|
99
|
+
|
|
100
|
+
def remove(self, ext_id: str) -> bool:
|
|
101
|
+
"""按 ID 移除。返回是否存在并被移除"""
|
|
102
|
+
with self._lock:
|
|
103
|
+
if ext_id in self._items:
|
|
104
|
+
del self._items[ext_id]
|
|
105
|
+
self._save()
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def update_commit(self, ext_id: str, new_commit: str) -> None:
|
|
110
|
+
"""更新扩展的 commit hash,用于 update 子命令"""
|
|
111
|
+
with self._lock:
|
|
112
|
+
if ext_id in self._items:
|
|
113
|
+
self._items[ext_id].commit = new_commit
|
|
114
|
+
self._save()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def create_default_registry() -> ExtensionRegistry:
|
|
118
|
+
"""创建指向 ~/.abyss/registry.json 的默认注册表"""
|
|
119
|
+
return ExtensionRegistry(registry_path=Path.home() / ".abyss" / "registry.json")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
包源 URL 解析器
|
|
4
|
+
解析用户在 install/update 子命令中提供的 URL,提取 git clone 地址、子目录、ref。
|
|
5
|
+
|
|
6
|
+
支持的 URL 格式:
|
|
7
|
+
https://github.com/user/repo
|
|
8
|
+
https://github.com/user/repo.git
|
|
9
|
+
https://github.com/user/repo?subdir=skills/python-test
|
|
10
|
+
https://github.com/user/repo?subdir=skills/py&ref=v1.0
|
|
11
|
+
https://github.com/user/repo/tree/main/skills/python-test
|
|
12
|
+
git@github.com:user/repo.git
|
|
13
|
+
"""
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from urllib.parse import urlparse, parse_qs
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# GitHub tree 路径格式:/tree/<ref>/<subpath>(可在 path 任意位置)
|
|
20
|
+
_GH_TREE_RE = re.compile(r"/tree/([^/]+)(?:/(.*))?$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class PackageSource:
|
|
25
|
+
"""解析后的包来源信息。"""
|
|
26
|
+
clone_url: str
|
|
27
|
+
subpath: str = ""
|
|
28
|
+
ref: str = "HEAD"
|
|
29
|
+
|
|
30
|
+
def __repr__(self):
|
|
31
|
+
return f"PackageSource({self.clone_url}, subpath={self.subpath!r}, ref={self.ref!r})"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_source_url(url: str) -> PackageSource:
|
|
35
|
+
"""解析用户输入的源 URL,返回 PackageSource。无法识别时抛 ValueError。
|
|
36
|
+
|
|
37
|
+
参数:
|
|
38
|
+
url: 用户输入的 URL 或 SSH 路径
|
|
39
|
+
|
|
40
|
+
返回:
|
|
41
|
+
PackageSource(克隆地址, 子目录, ref)
|
|
42
|
+
"""
|
|
43
|
+
url = url.strip()
|
|
44
|
+
if not url:
|
|
45
|
+
raise ValueError("URL 不能为空")
|
|
46
|
+
|
|
47
|
+
# SSH 形式:git@host:user/repo.git
|
|
48
|
+
if url.startswith("git@") and ":" in url:
|
|
49
|
+
return PackageSource(clone_url=url)
|
|
50
|
+
|
|
51
|
+
# 必须含 scheme
|
|
52
|
+
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", url):
|
|
53
|
+
raise ValueError(f"无法识别的 URL 格式: {url}")
|
|
54
|
+
|
|
55
|
+
parsed = urlparse(url)
|
|
56
|
+
if parsed.scheme not in ("http", "https", "git", "ssh"):
|
|
57
|
+
raise ValueError(f"不支持的协议: {parsed.scheme}")
|
|
58
|
+
|
|
59
|
+
# 提取 ?query
|
|
60
|
+
subpath_q = ""
|
|
61
|
+
ref = "HEAD"
|
|
62
|
+
if parsed.query:
|
|
63
|
+
qs = parse_qs(parsed.query)
|
|
64
|
+
if "subdir" in qs and qs["subdir"]:
|
|
65
|
+
subpath_q = qs["subdir"][0]
|
|
66
|
+
if "ref" in qs and qs["ref"]:
|
|
67
|
+
ref = qs["ref"][0]
|
|
68
|
+
|
|
69
|
+
# 解析 /tree/<ref>/<subpath>,在 path 任意位置匹配
|
|
70
|
+
subpath_tree = ""
|
|
71
|
+
tree_match = _GH_TREE_RE.search(parsed.path)
|
|
72
|
+
if tree_match:
|
|
73
|
+
ref = tree_match.group(1)
|
|
74
|
+
subpath_tree = (tree_match.group(2) or "").strip("/")
|
|
75
|
+
# 截掉 /tree/<ref>/<subpath> 及其前面的 /
|
|
76
|
+
base_path = parsed.path[: tree_match.start()].rstrip("/")
|
|
77
|
+
else:
|
|
78
|
+
base_path = parsed.path.rstrip("/")
|
|
79
|
+
|
|
80
|
+
subpath = (subpath_q or subpath_tree).strip("/")
|
|
81
|
+
|
|
82
|
+
clone_url = f"{parsed.scheme}://{parsed.netloc}{base_path}"
|
|
83
|
+
if not clone_url.endswith(".git"):
|
|
84
|
+
clone_url += ".git"
|
|
85
|
+
|
|
86
|
+
return PackageSource(clone_url=clone_url, subpath=subpath, ref=ref)
|
abyss/hooks/__init__.py
ADDED
abyss/hooks/runner.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Hook Runner 模块
|
|
4
|
+
在 Agent 生命周期的特定节点(PreToolUse/PostToolUse/PreLLMRequest 等)
|
|
5
|
+
自动触发用户配置的可执行脚本。
|
|
6
|
+
|
|
7
|
+
目录结构:
|
|
8
|
+
hooks_dir/
|
|
9
|
+
PreToolUse/
|
|
10
|
+
01_validate.bat # Windows
|
|
11
|
+
01_validate.sh # Unix
|
|
12
|
+
PostToolUse/
|
|
13
|
+
log.bat
|
|
14
|
+
|
|
15
|
+
环境变量传递:context 字典中的每个 key 会以 ABYSS_<KEY_UPPER> 形式注入子进程。
|
|
16
|
+
Pre* 事件中钩子退出码非 0 视为阻断信号(block=True)。
|
|
17
|
+
"""
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# 可执行的脚本扩展名(按平台)
|
|
27
|
+
_WIN_EXTS = {".bat", ".cmd", ".ps1"}
|
|
28
|
+
_UNIX_EXTS = {".sh", ".bash", ".py", ""}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class HookResult:
|
|
33
|
+
"""单个钩子执行结果。"""
|
|
34
|
+
name: str
|
|
35
|
+
success: bool
|
|
36
|
+
block: bool
|
|
37
|
+
stdout: str
|
|
38
|
+
stderr: str
|
|
39
|
+
returncode: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HookRunner:
|
|
43
|
+
"""从目录加载并执行生命周期事件钩子。"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, hooks_dir: Optional[Path] = None):
|
|
46
|
+
self._hooks_dir = hooks_dir
|
|
47
|
+
# 事件名 -> 钩子脚本路径列表(按文件名排序)
|
|
48
|
+
self._hooks: Dict[str, List[Path]] = {}
|
|
49
|
+
if hooks_dir is not None:
|
|
50
|
+
self._load_from_dir(hooks_dir)
|
|
51
|
+
|
|
52
|
+
def _load_from_dir(self, hooks_dir: Path) -> None:
|
|
53
|
+
"""扫描 hooks_dir/<event>/ 下的可执行脚本"""
|
|
54
|
+
if not hooks_dir.exists() or not hooks_dir.is_dir():
|
|
55
|
+
return
|
|
56
|
+
for event_dir in hooks_dir.iterdir():
|
|
57
|
+
if not event_dir.is_dir():
|
|
58
|
+
continue
|
|
59
|
+
event_name = event_dir.name
|
|
60
|
+
scripts = []
|
|
61
|
+
for f in event_dir.iterdir():
|
|
62
|
+
if not f.is_file():
|
|
63
|
+
continue
|
|
64
|
+
if not self._is_executable(f):
|
|
65
|
+
continue
|
|
66
|
+
scripts.append(f)
|
|
67
|
+
scripts.sort(key=lambda p: p.name)
|
|
68
|
+
self._hooks[event_name] = scripts
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _is_executable(path: Path) -> bool:
|
|
72
|
+
"""判断文件是否为可执行脚本(按平台扩展名过滤)"""
|
|
73
|
+
ext = path.suffix.lower()
|
|
74
|
+
if sys.platform.startswith("win"):
|
|
75
|
+
return ext in _WIN_EXTS
|
|
76
|
+
return ext in _UNIX_EXTS or os.access(path, os.X_OK)
|
|
77
|
+
|
|
78
|
+
def list_hooks(self, event: str) -> List[Path]:
|
|
79
|
+
"""返回指定事件的所有钩子脚本路径"""
|
|
80
|
+
return list(self._hooks.get(event, []))
|
|
81
|
+
|
|
82
|
+
def run(self, event: str, context: Dict[str, str]) -> List[HookResult]:
|
|
83
|
+
"""执行指定事件的所有钩子,按文件名排序依次运行。
|
|
84
|
+
context 中的键值会以 ABYSS_<KEY_UPPER> 注入子进程环境变量。
|
|
85
|
+
返回每个钩子的执行结果。
|
|
86
|
+
"""
|
|
87
|
+
results = []
|
|
88
|
+
for script in self._hooks.get(event, []):
|
|
89
|
+
result = self._run_script(script, context)
|
|
90
|
+
results.append(result)
|
|
91
|
+
# Pre* 事件中阻断后不再执行后续钩子
|
|
92
|
+
if result.block and event.startswith("Pre"):
|
|
93
|
+
break
|
|
94
|
+
return results
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _run_script(script: Path, context: Dict[str, str]) -> HookResult:
|
|
98
|
+
"""执行单个钩子脚本"""
|
|
99
|
+
env = os.environ.copy()
|
|
100
|
+
for key, value in context.items():
|
|
101
|
+
env_key = f"ABYSS_{key.upper()}"
|
|
102
|
+
env[env_key] = str(value)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
if sys.platform.startswith("win"):
|
|
106
|
+
# Windows 用 cmd 执行 .bat/.cmd
|
|
107
|
+
proc = subprocess.run(
|
|
108
|
+
["cmd", "/c", str(script)],
|
|
109
|
+
capture_output=True, text=True,
|
|
110
|
+
env=env, timeout=30, encoding="utf-8", errors="replace"
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
proc = subprocess.run(
|
|
114
|
+
[str(script)],
|
|
115
|
+
capture_output=True, text=True,
|
|
116
|
+
env=env, timeout=30, encoding="utf-8", errors="replace"
|
|
117
|
+
)
|
|
118
|
+
returncode = proc.returncode
|
|
119
|
+
stdout = proc.stdout or ""
|
|
120
|
+
stderr = proc.stderr or ""
|
|
121
|
+
except subprocess.TimeoutExpired:
|
|
122
|
+
return HookResult(
|
|
123
|
+
name=script.name, success=False, block=False,
|
|
124
|
+
stdout="", stderr="timeout after 30s", returncode=-1
|
|
125
|
+
)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
return HookResult(
|
|
128
|
+
name=script.name, success=False, block=False,
|
|
129
|
+
stdout="", stderr=str(e), returncode=-1
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return HookResult(
|
|
133
|
+
name=script.name,
|
|
134
|
+
success=(returncode == 0),
|
|
135
|
+
block=(returncode != 0),
|
|
136
|
+
stdout=stdout,
|
|
137
|
+
stderr=stderr,
|
|
138
|
+
returncode=returncode,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def create_default_runner() -> HookRunner:
|
|
143
|
+
"""创建指向 ~/.abyss/hooks/ 的默认 HookRunner"""
|
|
144
|
+
return HookRunner(hooks_dir=Path.home() / ".abyss" / "hooks")
|
abyss/logger.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
事件日志系统
|
|
4
|
+
记录 abyss CLI 所有关键事件,包括 API 调用、工具执行、错误、卡顿等。
|
|
5
|
+
日志存储在 ~/.abyss/logs/ 下,按天轮转,保留最近 30 天。
|
|
6
|
+
格式: ISO时间 | 类别 | 事件 | 详情
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import threading
|
|
12
|
+
import glob as _glob
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
LOG_DIR = Path.home() / ".abyss" / "logs"
|
|
17
|
+
LOG_RETENTION_DAYS = 30
|
|
18
|
+
MAX_PAYLOAD_LEN = 2000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EventLogger:
|
|
22
|
+
"""线程安全的事件日志记录器。每日自动轮转,自动清理过期日志。"""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
self._date = None
|
|
28
|
+
self._file = None
|
|
29
|
+
|
|
30
|
+
def _rotate_if_needed(self):
|
|
31
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
32
|
+
if self._date == today and self._file:
|
|
33
|
+
return
|
|
34
|
+
if self._file:
|
|
35
|
+
self._file.close()
|
|
36
|
+
self._date = today
|
|
37
|
+
log_path = LOG_DIR / f"abyss_{today}.log"
|
|
38
|
+
self._file = open(str(log_path), "a", encoding="utf-8", buffering=1)
|
|
39
|
+
self._cleanup_old()
|
|
40
|
+
|
|
41
|
+
def _cleanup_old(self):
|
|
42
|
+
cutoff = datetime.now() - timedelta(days=LOG_RETENTION_DAYS)
|
|
43
|
+
for fp in _glob.glob(str(LOG_DIR / "abyss_*.log")):
|
|
44
|
+
try:
|
|
45
|
+
fname = os.path.basename(fp)
|
|
46
|
+
date_part = fname.replace("abyss_", "").replace(".log", "")
|
|
47
|
+
if datetime.strptime(date_part, "%Y-%m-%d") < cutoff:
|
|
48
|
+
os.remove(fp)
|
|
49
|
+
except (ValueError, OSError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def _write(self, category: str, event: str, payload: str):
|
|
53
|
+
self._rotate_if_needed()
|
|
54
|
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.") + f"{int(time.time() * 1000) % 1000:03d}"
|
|
55
|
+
line = f"{ts} | {category:11s} | {event:24s} | {payload}\n"
|
|
56
|
+
with self._lock:
|
|
57
|
+
self._file.write(line)
|
|
58
|
+
self._file.flush()
|
|
59
|
+
|
|
60
|
+
def _fmt(self, **kwargs) -> str:
|
|
61
|
+
parts = []
|
|
62
|
+
for k, v in kwargs.items():
|
|
63
|
+
if isinstance(v, str) and len(v) > MAX_PAYLOAD_LEN:
|
|
64
|
+
v = v[:MAX_PAYLOAD_LEN] + f"...[{len(v)}]"
|
|
65
|
+
elif isinstance(v, (dict, list)):
|
|
66
|
+
v = json.dumps(v, ensure_ascii=False, default=str)
|
|
67
|
+
if len(v) > MAX_PAYLOAD_LEN:
|
|
68
|
+
v = v[:MAX_PAYLOAD_LEN] + f"...[{len(v)}]"
|
|
69
|
+
parts.append(f"{k}={v}")
|
|
70
|
+
return " ".join(parts)
|
|
71
|
+
|
|
72
|
+
def session_start(self, **kwargs):
|
|
73
|
+
self._write("SESSION", "start", self._fmt(**kwargs))
|
|
74
|
+
|
|
75
|
+
def session_end(self, reason=""):
|
|
76
|
+
self._write("SESSION", "end", f"reason={reason}" if reason else "reason=normal")
|
|
77
|
+
|
|
78
|
+
def user_input(self, text: str):
|
|
79
|
+
self._write("USER_INPUT", "", f"text={text!r}")
|
|
80
|
+
|
|
81
|
+
def slash_command(self, cmd: str):
|
|
82
|
+
self._write("SLASH_CMD", "", f"cmd={cmd}")
|
|
83
|
+
|
|
84
|
+
def api_request(self, **kwargs):
|
|
85
|
+
self._write("API", "request_start", self._fmt(**kwargs))
|
|
86
|
+
|
|
87
|
+
def api_first_token(self, latency_ms: int):
|
|
88
|
+
self._write("API", "first_token", f"latency_ms={latency_ms}")
|
|
89
|
+
|
|
90
|
+
def api_response(self, **kwargs):
|
|
91
|
+
self._write("API", "response", self._fmt(**kwargs))
|
|
92
|
+
|
|
93
|
+
def api_error(self, error: str):
|
|
94
|
+
self._write("API", "error", f"error={error!r}")
|
|
95
|
+
|
|
96
|
+
def api_retry(self, attempt: int, wait_s: float, reason: str):
|
|
97
|
+
self._write("API", "retry", f"attempt={attempt} wait_s={wait_s:.2f} reason={reason}")
|
|
98
|
+
|
|
99
|
+
def tool_start(self, name: str, **kwargs):
|
|
100
|
+
self._write("TOOL", "start", self._fmt(name=name, **kwargs))
|
|
101
|
+
|
|
102
|
+
def tool_end(self, name: str, success: bool, duration_ms: int, **kwargs):
|
|
103
|
+
self._write("TOOL", "end", self._fmt(name=name, success=success, duration_ms=duration_ms, **kwargs))
|
|
104
|
+
|
|
105
|
+
def spinner_start(self, message: str):
|
|
106
|
+
self._write("SPINNER", "start", f"message={message}")
|
|
107
|
+
|
|
108
|
+
def spinner_stop(self, message: str = ""):
|
|
109
|
+
self._write("SPINNER", "stop", f"message={message}" if message else "")
|
|
110
|
+
|
|
111
|
+
def batch_header(self, round_num: int, total: int, max_rounds: int):
|
|
112
|
+
self._write("BATCH", "header", f"round={round_num} total={total} max={max_rounds}")
|
|
113
|
+
|
|
114
|
+
def stream_content_start(self):
|
|
115
|
+
self._write("STREAM", "content_start", "")
|
|
116
|
+
|
|
117
|
+
def stream_reasoning_start(self):
|
|
118
|
+
self._write("STREAM", "reasoning_start", "")
|
|
119
|
+
|
|
120
|
+
def keyboard_interrupt(self):
|
|
121
|
+
self._write("SIGNAL", "keyboard_interrupt", "")
|
|
122
|
+
|
|
123
|
+
def eof(self):
|
|
124
|
+
self._write("SIGNAL", "eof", "")
|
|
125
|
+
|
|
126
|
+
def error(self, message: str, exc_info: str = ""):
|
|
127
|
+
payload = f"message={message!r}"
|
|
128
|
+
if exc_info:
|
|
129
|
+
payload += f" traceback={exc_info!r}"
|
|
130
|
+
self._write("ERROR", "", payload)
|
|
131
|
+
|
|
132
|
+
def flush(self):
|
|
133
|
+
if self._file:
|
|
134
|
+
with self._lock:
|
|
135
|
+
self._file.flush()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_logger = EventLogger()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_logger() -> EventLogger:
|
|
142
|
+
return _logger
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def session_start(**kwargs):
|
|
146
|
+
_logger.session_start(**kwargs)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def session_end(reason=""):
|
|
150
|
+
_logger.session_end(reason)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def user_input(text: str):
|
|
154
|
+
_logger.user_input(text)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def slash_command(cmd: str):
|
|
158
|
+
_logger.slash_command(cmd)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def api_request(**kwargs):
|
|
162
|
+
_logger.api_request(**kwargs)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def api_first_token(latency_ms: int):
|
|
166
|
+
_logger.api_first_token(latency_ms)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def api_response(**kwargs):
|
|
170
|
+
_logger.api_response(**kwargs)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def api_error(error: str):
|
|
174
|
+
_logger.api_error(error)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def api_retry(attempt: int, wait_s: float, reason: str):
|
|
178
|
+
_logger.api_retry(attempt, wait_s, reason)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def tool_start(name: str, **kwargs):
|
|
182
|
+
_logger.tool_start(name, **kwargs)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def tool_end(name: str, success: bool, duration_ms: int, **kwargs):
|
|
186
|
+
_logger.tool_end(name, success, duration_ms, **kwargs)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def spinner_start(message: str):
|
|
190
|
+
_logger.spinner_start(message)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def spinner_stop(message: str = ""):
|
|
194
|
+
_logger.spinner_stop(message)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def batch_header(round_num: int, total: int, max_rounds: int):
|
|
198
|
+
_logger.batch_header(round_num, total, max_rounds)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def stream_content_start():
|
|
202
|
+
_logger.stream_content_start()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def stream_reasoning_start():
|
|
206
|
+
_logger.stream_reasoning_start()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def keyboard_interrupt():
|
|
210
|
+
_logger.keyboard_interrupt()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def eof():
|
|
214
|
+
_logger.eof()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def error(message: str, exc_info: str = ""):
|
|
218
|
+
_logger.error(message, exc_info)
|