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
abyss/mcp/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
MCP 模块
|
|
4
|
+
提供 Model Context Protocol 客户端能力,连接外部 MCP Server
|
|
5
|
+
"""
|
|
6
|
+
from .manager import MCPClientManager, ServerConfig, MCPServerType, create_default_manager
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"MCPClientManager",
|
|
10
|
+
"ServerConfig",
|
|
11
|
+
"MCPServerType",
|
|
12
|
+
"create_default_manager",
|
|
13
|
+
]
|
abyss/mcp/manager.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
MCP Client Manager 模块
|
|
4
|
+
管理多个 MCP Server 连接,支持 stdio 和 HTTP 两种传输(SSE 暂未实现)。
|
|
5
|
+
工具命名规则:mcp__<server_name>__<tool_name>,避免与内置工具冲突。
|
|
6
|
+
|
|
7
|
+
配置文件格式(~/.abyss/mcp.json):
|
|
8
|
+
{
|
|
9
|
+
"mcp_servers": [
|
|
10
|
+
{"name": "github", "transport": "stdio", "command": "npx", "args": [...], "env": {...}},
|
|
11
|
+
{"name": "db", "transport": "http", "url": "http://...", "headers": {...}}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
注:真实子进程连接(JSON-RPC over stdio)在 start() 中实现,
|
|
16
|
+
本模块聚焦配置解析、工具注册表、schema 生成等可单测的核心逻辑。
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
import threading
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from enum import Enum
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MCPServerType(Enum):
|
|
28
|
+
"""MCP Server 传输类型。"""
|
|
29
|
+
STDIO = "stdio"
|
|
30
|
+
HTTP = "http"
|
|
31
|
+
SSE = "sse"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ServerConfig:
|
|
36
|
+
"""单个 MCP Server 的配置。"""
|
|
37
|
+
name: str
|
|
38
|
+
transport: MCPServerType
|
|
39
|
+
command: Optional[str] = None
|
|
40
|
+
args: List[str] = field(default_factory=list)
|
|
41
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
42
|
+
url: Optional[str] = None
|
|
43
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, data: Dict[str, Any]) -> Optional["ServerConfig"]:
|
|
47
|
+
"""从配置字典构造 ServerConfig,必填字段缺失返回 None"""
|
|
48
|
+
name = data.get("name", "").strip()
|
|
49
|
+
transport_str = data.get("transport", "").strip().lower()
|
|
50
|
+
if not name or not transport_str:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
transport = MCPServerType(transport_str)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
if transport == MCPServerType.STDIO:
|
|
58
|
+
command = data.get("command", "").strip()
|
|
59
|
+
if not command:
|
|
60
|
+
return None
|
|
61
|
+
return cls(
|
|
62
|
+
name=name, transport=transport,
|
|
63
|
+
command=command,
|
|
64
|
+
args=data.get("args", []) or [],
|
|
65
|
+
env=data.get("env", {}) or {},
|
|
66
|
+
)
|
|
67
|
+
if transport in (MCPServerType.HTTP, MCPServerType.SSE):
|
|
68
|
+
url = data.get("url", "").strip()
|
|
69
|
+
if not url:
|
|
70
|
+
return None
|
|
71
|
+
return cls(
|
|
72
|
+
name=name, transport=transport,
|
|
73
|
+
url=url,
|
|
74
|
+
headers=data.get("headers", {}) or {},
|
|
75
|
+
)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MCPClientManager:
|
|
80
|
+
"""管理所有已注册的 MCP Server 连接与工具发现。"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, config_path: Optional[Path] = None):
|
|
83
|
+
self.servers: Dict[str, ServerConfig] = {}
|
|
84
|
+
# 工具全名 -> (server_name, original_tool_name, schema)
|
|
85
|
+
self._tools: Dict[str, Tuple[str, str, Dict[str, Any]]] = {}
|
|
86
|
+
# 运行中的子进程(stdio 类型):server_name -> Popen
|
|
87
|
+
self._processes: Dict[str, subprocess.Popen] = {}
|
|
88
|
+
self._lock = threading.Lock()
|
|
89
|
+
|
|
90
|
+
if config_path is not None:
|
|
91
|
+
self._load_config(config_path)
|
|
92
|
+
|
|
93
|
+
def _load_config(self, config_path: Path) -> None:
|
|
94
|
+
"""从 JSON 配置文件加载 mcp_servers"""
|
|
95
|
+
if not config_path.exists():
|
|
96
|
+
return
|
|
97
|
+
try:
|
|
98
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
99
|
+
data = json.load(f)
|
|
100
|
+
except (json.JSONDecodeError, OSError):
|
|
101
|
+
return
|
|
102
|
+
for server_data in data.get("mcp_servers", []) or []:
|
|
103
|
+
cfg = ServerConfig.from_dict(server_data)
|
|
104
|
+
if cfg is not None:
|
|
105
|
+
self.servers[cfg.name] = cfg
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def make_tool_name(server_name: str, tool_name: str) -> str:
|
|
109
|
+
"""生成 MCP 工具的全名:mcp__<server>__<tool>"""
|
|
110
|
+
return f"mcp__{server_name}__{tool_name}"
|
|
111
|
+
|
|
112
|
+
def register_tool(self, server_name: str, tool_schema: Dict[str, Any]) -> None:
|
|
113
|
+
"""注册一个已发现的 MCP 工具"""
|
|
114
|
+
original_name = tool_schema.get("name", "")
|
|
115
|
+
if not original_name:
|
|
116
|
+
return
|
|
117
|
+
full_name = self.make_tool_name(server_name, original_name)
|
|
118
|
+
self._tools[full_name] = (server_name, original_name, tool_schema)
|
|
119
|
+
|
|
120
|
+
def get_server_for_tool(self, full_name: str) -> Tuple[Optional[str], Optional[str]]:
|
|
121
|
+
"""根据工具全名返回 (server_name, original_tool_name),未注册返回 (None, None)"""
|
|
122
|
+
entry = self._tools.get(full_name)
|
|
123
|
+
if entry is None:
|
|
124
|
+
return (None, None)
|
|
125
|
+
return (entry[0], entry[1])
|
|
126
|
+
|
|
127
|
+
def get_openai_schemas(self) -> List[Dict[str, Any]]:
|
|
128
|
+
"""生成所有 MCP 工具的 OpenAI function calling 定义"""
|
|
129
|
+
schemas = []
|
|
130
|
+
for full_name, (_, _, tool_schema) in self._tools.items():
|
|
131
|
+
schemas.append({
|
|
132
|
+
"type": "function",
|
|
133
|
+
"function": {
|
|
134
|
+
"name": full_name,
|
|
135
|
+
"description": tool_schema.get("description", ""),
|
|
136
|
+
"parameters": tool_schema.get("inputSchema", {"type": "object", "properties": {}}),
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
return schemas
|
|
140
|
+
|
|
141
|
+
def list_tool_names(self) -> List[str]:
|
|
142
|
+
"""返回所有已注册 MCP 工具的全名"""
|
|
143
|
+
return list(self._tools.keys())
|
|
144
|
+
|
|
145
|
+
def start_stdio_server(self, server_name: str) -> bool:
|
|
146
|
+
"""启动指定 stdio 类型的 MCP Server 子进程。
|
|
147
|
+
返回 True 表示启动成功。HTTP/SSE 类型无需启动子进程。
|
|
148
|
+
"""
|
|
149
|
+
cfg = self.servers.get(server_name)
|
|
150
|
+
if cfg is None or cfg.transport != MCPServerType.STDIO:
|
|
151
|
+
return False
|
|
152
|
+
with self._lock:
|
|
153
|
+
if server_name in self._processes:
|
|
154
|
+
return True # 已启动
|
|
155
|
+
try:
|
|
156
|
+
env = {**__import__("os").environ, **cfg.env}
|
|
157
|
+
proc = subprocess.Popen(
|
|
158
|
+
[cfg.command] + list(cfg.args),
|
|
159
|
+
stdin=subprocess.PIPE,
|
|
160
|
+
stdout=subprocess.PIPE,
|
|
161
|
+
stderr=subprocess.PIPE,
|
|
162
|
+
env=env,
|
|
163
|
+
text=True,
|
|
164
|
+
encoding="utf-8",
|
|
165
|
+
bufsize=1,
|
|
166
|
+
)
|
|
167
|
+
self._processes[server_name] = proc
|
|
168
|
+
return True
|
|
169
|
+
except Exception:
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
def stop_all(self) -> None:
|
|
173
|
+
"""停止所有运行中的 MCP Server 子进程"""
|
|
174
|
+
with self._lock:
|
|
175
|
+
for name, proc in self._processes.items():
|
|
176
|
+
try:
|
|
177
|
+
proc.terminate()
|
|
178
|
+
proc.wait(timeout=2)
|
|
179
|
+
except Exception:
|
|
180
|
+
try:
|
|
181
|
+
proc.kill()
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
self._processes.clear()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def create_default_manager() -> MCPClientManager:
|
|
188
|
+
"""创建指向 ~/.abyss/mcp.json 的默认 MCPClientManager"""
|
|
189
|
+
return MCPClientManager(config_path=Path.home() / ".abyss" / "mcp.json")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
提示词加载模块
|
|
4
|
+
加载系统提示词并注入环境信息
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_system_prompt(project_path: str = None) -> str:
|
|
10
|
+
"""加载系统提示词,注入当前环境信息。"""
|
|
11
|
+
prompt_path = os.path.join(os.path.dirname(__file__), "system_prompt.md")
|
|
12
|
+
with open(prompt_path, "r", encoding="utf-8") as f:
|
|
13
|
+
prompt = f.read()
|
|
14
|
+
|
|
15
|
+
cwd = project_path or os.getcwd()
|
|
16
|
+
prompt = prompt.replace("{{WORKING_DIR}}", cwd)
|
|
17
|
+
|
|
18
|
+
# 检测项目类型
|
|
19
|
+
if os.path.exists(os.path.join(cwd, "package.json")):
|
|
20
|
+
prompt += "\n\n项目类型: Node.js"
|
|
21
|
+
elif os.path.exists(os.path.join(cwd, "requirements.txt")):
|
|
22
|
+
prompt += "\n\n项目类型: Python"
|
|
23
|
+
elif os.path.exists(os.path.join(cwd, "Cargo.toml")):
|
|
24
|
+
prompt += "\n\n项目类型: Rust"
|
|
25
|
+
|
|
26
|
+
return prompt
|
abyss/session.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
会话管理
|
|
4
|
+
管理一次对话会话的消息历史,支持上下文维护
|
|
5
|
+
"""
|
|
6
|
+
from typing import List, Dict, Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Session:
|
|
10
|
+
"""管理一次对话会话的消息历史,维护完整的对话上下文。"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, system_prompt: str):
|
|
13
|
+
self.messages: List[Dict[str, Any]] = [
|
|
14
|
+
{"role": "system", "content": system_prompt}
|
|
15
|
+
]
|
|
16
|
+
self._tool_call_counter = 0
|
|
17
|
+
|
|
18
|
+
def add_user(self, content: str):
|
|
19
|
+
"""添加用户消息"""
|
|
20
|
+
self.messages.append({"role": "user", "content": content})
|
|
21
|
+
|
|
22
|
+
def add_assistant(self, content: str = None, tool_calls: list = None, reasoning_content: str = None):
|
|
23
|
+
"""添加助手消息(含工具调用和思考链)"""
|
|
24
|
+
msg = {"role": "assistant"}
|
|
25
|
+
if content:
|
|
26
|
+
msg["content"] = content
|
|
27
|
+
if reasoning_content:
|
|
28
|
+
msg["reasoning_content"] = reasoning_content
|
|
29
|
+
if tool_calls:
|
|
30
|
+
msg["tool_calls"] = tool_calls
|
|
31
|
+
self.messages.append(msg)
|
|
32
|
+
|
|
33
|
+
def add_tool_result(self, tool_call_id: str, content: str):
|
|
34
|
+
"""添加工具执行结果"""
|
|
35
|
+
self.messages.append({
|
|
36
|
+
"role": "tool",
|
|
37
|
+
"tool_call_id": tool_call_id,
|
|
38
|
+
"content": content
|
|
39
|
+
})
|
|
40
|
+
self._tool_call_counter += 1
|
|
41
|
+
|
|
42
|
+
def get_messages(self) -> List[Dict[str, Any]]:
|
|
43
|
+
"""获取当前消息列表(用于 API 请求)"""
|
|
44
|
+
return self.messages
|
|
45
|
+
|
|
46
|
+
def get_messages_for_api(self) -> List[Dict[str, Any]]:
|
|
47
|
+
"""获取发给 LLM API 的消息列表。剔除 reasoning_content 以节省 token。
|
|
48
|
+
|
|
49
|
+
reasoning_content 是 AI 自己的上次思考,AI 不需要回看旧思考。
|
|
50
|
+
协议设计参考 OpenAI/Claude 协议,reasoning_content 是单次响应的辅助字段。
|
|
51
|
+
本地审计 get_messages() 仍保留 reasoning_content。
|
|
52
|
+
"""
|
|
53
|
+
result = []
|
|
54
|
+
for m in self.messages:
|
|
55
|
+
if "reasoning_content" in m:
|
|
56
|
+
m = {k: v for k, v in m.items() if k != "reasoning_content"}
|
|
57
|
+
result.append(m)
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
def get_message_count(self) -> int:
|
|
61
|
+
"""获取消息数量(不含 system prompt)"""
|
|
62
|
+
return len(self.messages) - 1
|
|
63
|
+
|
|
64
|
+
def get_tool_call_count(self) -> int:
|
|
65
|
+
"""获取工具调用次数"""
|
|
66
|
+
return self._tool_call_counter
|
|
67
|
+
|
|
68
|
+
def clear(self):
|
|
69
|
+
"""清空历史,保留 system prompt"""
|
|
70
|
+
system = self.messages[0]
|
|
71
|
+
self.messages = [system]
|
|
72
|
+
self._tool_call_counter = 0
|
|
73
|
+
|
|
74
|
+
def get_last_user_message(self) -> Dict[str, Any]:
|
|
75
|
+
"""获取最后一条用户消息"""
|
|
76
|
+
for msg in reversed(self.messages):
|
|
77
|
+
if msg["role"] == "user":
|
|
78
|
+
return msg
|
|
79
|
+
return None
|
abyss/skills/__init__.py
ADDED
abyss/skills/loader.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Skill 加载器模块
|
|
4
|
+
扫描指定目录下的 SKILL.md 文件,解析 YAML frontmatter + 正文,
|
|
5
|
+
支持按关键词匹配 Skill 并生成注入 System Prompt 的文本。
|
|
6
|
+
|
|
7
|
+
SKILL.md 格式遵循 agentskills.io 规范:
|
|
8
|
+
- name(必填,kebab-case):唯一标识
|
|
9
|
+
- description(必填):Agent 据此判断何时加载此技能
|
|
10
|
+
- version/author/tags(可选)
|
|
11
|
+
|
|
12
|
+
加载策略:按需延迟加载——根据用户输入关键词匹配 description。
|
|
13
|
+
"""
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Skill:
|
|
22
|
+
"""单个 Agent Skill:元数据 + 正文内容。"""
|
|
23
|
+
name: str
|
|
24
|
+
description: str
|
|
25
|
+
content: str
|
|
26
|
+
path: Path = None
|
|
27
|
+
version: str = ""
|
|
28
|
+
author: str = ""
|
|
29
|
+
tags: List[str] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SkillLoader:
|
|
33
|
+
"""扫描并加载 Agent Skills,支持按关键词匹配。"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, skill_dirs: List[Path]):
|
|
36
|
+
self.skill_dirs = skill_dirs
|
|
37
|
+
self._skills: Dict[str, Skill] = {}
|
|
38
|
+
|
|
39
|
+
def discover(self) -> List[Skill]:
|
|
40
|
+
"""递归扫描所有 SKILL.md 文件并解析。返回已加载的 Skill 列表"""
|
|
41
|
+
self._skills.clear()
|
|
42
|
+
for skill_dir in self.skill_dirs:
|
|
43
|
+
if not skill_dir.exists() or not skill_dir.is_dir():
|
|
44
|
+
continue
|
|
45
|
+
for md_file in skill_dir.rglob("SKILL.md"):
|
|
46
|
+
skill = self._parse_skill(md_file)
|
|
47
|
+
if skill is not None:
|
|
48
|
+
self._skills[skill.name] = skill
|
|
49
|
+
return list(self._skills.values())
|
|
50
|
+
|
|
51
|
+
def _parse_skill(self, path: Path) -> Optional[Skill]:
|
|
52
|
+
"""解析单个 SKILL.md:拆分 frontmatter 和正文,校验必填字段"""
|
|
53
|
+
try:
|
|
54
|
+
raw = path.read_text(encoding="utf-8")
|
|
55
|
+
except Exception:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
frontmatter, body = self._split_frontmatter(raw)
|
|
59
|
+
if frontmatter is None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
name = frontmatter.get("name", "").strip()
|
|
63
|
+
description = frontmatter.get("description", "").strip()
|
|
64
|
+
if not name or not description:
|
|
65
|
+
return None # spec 要求 name 和 description 必填
|
|
66
|
+
|
|
67
|
+
return Skill(
|
|
68
|
+
name=name,
|
|
69
|
+
description=description,
|
|
70
|
+
content=body.strip(),
|
|
71
|
+
path=path.parent,
|
|
72
|
+
version=str(frontmatter.get("version", "")),
|
|
73
|
+
author=str(frontmatter.get("author", "")),
|
|
74
|
+
tags=frontmatter.get("tags", []) or [],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _split_frontmatter(raw: str):
|
|
79
|
+
"""拆分 YAML frontmatter 和正文。返回 (dict, str),无 frontmatter 返回 (None, raw)"""
|
|
80
|
+
if not raw.startswith("---"):
|
|
81
|
+
return None, raw
|
|
82
|
+
# 找第二个 --- 作为 frontmatter 结束
|
|
83
|
+
end_idx = raw.find("\n---", 3)
|
|
84
|
+
if end_idx == -1:
|
|
85
|
+
return None, raw
|
|
86
|
+
fm_text = raw[3:end_idx].strip()
|
|
87
|
+
body = raw[end_idx + 4:].lstrip("\n")
|
|
88
|
+
# 简单 YAML 解析(仅支持 key: value 和列表)
|
|
89
|
+
fm = {}
|
|
90
|
+
for line in fm_text.splitlines():
|
|
91
|
+
line = line.rstrip()
|
|
92
|
+
if not line or line.startswith("#"):
|
|
93
|
+
continue
|
|
94
|
+
if line.startswith(" - "):
|
|
95
|
+
# 列表项,归到最近一个 key
|
|
96
|
+
if fm:
|
|
97
|
+
last_key = list(fm.keys())[-1]
|
|
98
|
+
if not isinstance(fm[last_key], list):
|
|
99
|
+
fm[last_key] = []
|
|
100
|
+
fm[last_key].append(line[4:].strip())
|
|
101
|
+
continue
|
|
102
|
+
if ":" in line:
|
|
103
|
+
key, _, value = line.partition(":")
|
|
104
|
+
key = key.strip()
|
|
105
|
+
value = value.strip()
|
|
106
|
+
if value:
|
|
107
|
+
fm[key] = value
|
|
108
|
+
else:
|
|
109
|
+
fm[key] = [] # 可能是列表开头
|
|
110
|
+
return fm, body
|
|
111
|
+
|
|
112
|
+
def get(self, name: str) -> Optional[Skill]:
|
|
113
|
+
"""按名获取已加载的 Skill"""
|
|
114
|
+
return self._skills.get(name)
|
|
115
|
+
|
|
116
|
+
def list_names(self) -> List[str]:
|
|
117
|
+
"""返回所有已加载 Skill 名"""
|
|
118
|
+
return list(self._skills.keys())
|
|
119
|
+
|
|
120
|
+
def match_by_keyword(self, user_input: str, limit: int = 3) -> List[Skill]:
|
|
121
|
+
"""根据用户输入关键词匹配 Skill 的 description。
|
|
122
|
+
简单实现:description 中关键词出现在用户输入中即命中。
|
|
123
|
+
更复杂的语义匹配可后续用 embedding 替换。
|
|
124
|
+
"""
|
|
125
|
+
matched = []
|
|
126
|
+
user_lower = user_input.lower()
|
|
127
|
+
for skill in self._skills.values():
|
|
128
|
+
desc_lower = skill.description.lower()
|
|
129
|
+
# 提取 description 中的关键词(中文按字符,英文按单词)
|
|
130
|
+
keywords = re.findall(r"[a-zA-Z]+|[\u4e00-\u9fa5]+", desc_lower)
|
|
131
|
+
hit_count = sum(1 for kw in keywords if len(kw) >= 2 and kw in user_lower)
|
|
132
|
+
if hit_count > 0:
|
|
133
|
+
matched.append((hit_count, skill))
|
|
134
|
+
# 按命中数降序
|
|
135
|
+
matched.sort(key=lambda x: x[0], reverse=True)
|
|
136
|
+
return [s for _, s in matched[:limit]]
|
|
137
|
+
|
|
138
|
+
def build_system_prompt_extension(self, active_skills: List[Skill]) -> str:
|
|
139
|
+
"""生成要注入 System Prompt 的 Skill 内容,用 <skill> 标签包裹"""
|
|
140
|
+
if not active_skills:
|
|
141
|
+
return ""
|
|
142
|
+
parts = []
|
|
143
|
+
for skill in active_skills:
|
|
144
|
+
parts.append(f"<skill name='{skill.name}'>\n{skill.content}\n</skill>")
|
|
145
|
+
return "\n".join(parts)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def create_default_loader() -> SkillLoader:
|
|
149
|
+
"""创建指向 ~/.abyss/skills/ 的默认 SkillLoader"""
|
|
150
|
+
return SkillLoader([Path.home() / ".abyss" / "skills"])
|
abyss/tools/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
工具模块
|
|
4
|
+
提供文件操作、命令执行、联网搜索等工具
|
|
5
|
+
"""
|
|
6
|
+
from .base import Tool
|
|
7
|
+
from .file_read import FileReadTool
|
|
8
|
+
from .file_write import FileWriteTool
|
|
9
|
+
from .file_edit import FileEditTool
|
|
10
|
+
from .shell_exec import ShellExecTool
|
|
11
|
+
from .web_search import WebSearchTool
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Tool",
|
|
15
|
+
"FileReadTool",
|
|
16
|
+
"FileWriteTool",
|
|
17
|
+
"FileEditTool",
|
|
18
|
+
"ShellExecTool",
|
|
19
|
+
"WebSearchTool",
|
|
20
|
+
]
|
abyss/tools/base.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
工具基类
|
|
4
|
+
所有具体工具需继承此类
|
|
5
|
+
"""
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Tool(ABC):
|
|
11
|
+
"""工具基类,所有具体工具需继承此类。"""
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def name(self) -> str:
|
|
16
|
+
"""工具名称。"""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def description(self) -> str:
|
|
22
|
+
"""工具功能描述。"""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def parameters(self) -> Dict[str, Any]:
|
|
28
|
+
"""工具参数 JSON Schema。"""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def execute(self, **kwargs) -> Dict[str, Any]:
|
|
33
|
+
"""执行工具,返回结果字典。"""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
def to_openai_schema(self) -> Dict[str, Any]:
|
|
37
|
+
"""转换为 OpenAI Tool 格式。"""
|
|
38
|
+
return {
|
|
39
|
+
"type": "function",
|
|
40
|
+
"function": {
|
|
41
|
+
"name": self.name,
|
|
42
|
+
"description": self.description,
|
|
43
|
+
"parameters": self.parameters
|
|
44
|
+
}
|
|
45
|
+
}
|
abyss/tools/file_edit.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
文件编辑工具
|
|
4
|
+
通过搜索替换修改文件内容
|
|
5
|
+
"""
|
|
6
|
+
from .base import Tool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileEditTool(Tool):
|
|
10
|
+
"""通过搜索替换修改文件内容的工具。"""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def name(self) -> str:
|
|
14
|
+
return "file_edit"
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def description(self) -> str:
|
|
18
|
+
return "修改已有文件的指定内容,通过精确匹配旧字符串替换为新字符串。"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def parameters(self) -> dict:
|
|
22
|
+
return {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"path": {"type": "string", "description": "文件路径"},
|
|
26
|
+
"old_str": {"type": "string", "description": "要替换的旧内容"},
|
|
27
|
+
"new_str": {"type": "string", "description": "替换后的新内容"}
|
|
28
|
+
},
|
|
29
|
+
"required": ["path", "old_str", "new_str"]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def execute(self, path: str, old_str: str, new_str: str) -> dict:
|
|
33
|
+
"""编辑文件内容"""
|
|
34
|
+
try:
|
|
35
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
36
|
+
content = f.read()
|
|
37
|
+
|
|
38
|
+
if old_str not in content:
|
|
39
|
+
return {"success": False, "error": "未找到匹配的旧内容"}
|
|
40
|
+
|
|
41
|
+
new_content = content.replace(old_str, new_str, 1)
|
|
42
|
+
|
|
43
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
44
|
+
f.write(new_content)
|
|
45
|
+
|
|
46
|
+
return {"success": True, "path": path}
|
|
47
|
+
except Exception as e:
|
|
48
|
+
return {"success": False, "error": str(e)}
|
abyss/tools/file_read.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
文件阅读工具
|
|
4
|
+
读取指定文件的内容,支持指定行范围
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
from .base import Tool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FileReadTool(Tool):
|
|
11
|
+
"""读取文件内容的工具。"""
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def name(self) -> str:
|
|
15
|
+
return "file_read"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def description(self) -> str:
|
|
19
|
+
return "读取指定文件的内容,支持指定行范围。"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def parameters(self) -> dict:
|
|
23
|
+
return {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"path": {"type": "string", "description": "文件路径"},
|
|
27
|
+
"offset": {"type": "integer", "description": "起始行号,默认1"},
|
|
28
|
+
"limit": {"type": "integer", "description": "最大读取行数"}
|
|
29
|
+
},
|
|
30
|
+
"required": ["path"]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def execute(self, path: str, offset: int = 1, limit: int = None) -> dict:
|
|
34
|
+
"""读取文件内容"""
|
|
35
|
+
if not os.path.exists(path):
|
|
36
|
+
return {"success": False, "error": f"文件不存在: {path}"}
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
40
|
+
lines = f.readlines()
|
|
41
|
+
|
|
42
|
+
start = max(0, offset - 1)
|
|
43
|
+
end = start + limit if limit else len(lines)
|
|
44
|
+
content = "".join(lines[start:end])
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
"success": True,
|
|
48
|
+
"path": path,
|
|
49
|
+
"content": content,
|
|
50
|
+
"total_lines": len(lines),
|
|
51
|
+
"read_lines": min(end, len(lines)) - start
|
|
52
|
+
}
|
|
53
|
+
except Exception as e:
|
|
54
|
+
return {"success": False, "error": str(e)}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
文件写入工具
|
|
4
|
+
创建新文件或覆盖写入已有文件
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
from .base import Tool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FileWriteTool(Tool):
|
|
11
|
+
"""创建或覆盖写入文件的工具。"""
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def name(self) -> str:
|
|
15
|
+
return "file_write"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def description(self) -> str:
|
|
19
|
+
return "创建新文件或覆盖写入已有文件。"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def parameters(self) -> dict:
|
|
23
|
+
return {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"path": {"type": "string", "description": "文件路径"},
|
|
27
|
+
"content": {"type": "string", "description": "文件内容"},
|
|
28
|
+
"append": {"type": "boolean", "description": "是否追加,默认false"}
|
|
29
|
+
},
|
|
30
|
+
"required": ["path", "content"]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def execute(self, path: str, content: str, append: bool = False) -> dict:
|
|
34
|
+
"""写入文件内容"""
|
|
35
|
+
try:
|
|
36
|
+
mode = "a" if append else "w"
|
|
37
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
38
|
+
|
|
39
|
+
with open(path, mode, encoding="utf-8") as f:
|
|
40
|
+
f.write(content)
|
|
41
|
+
|
|
42
|
+
return {"success": True, "path": path, "mode": "append" if append else "write"}
|
|
43
|
+
except Exception as e:
|
|
44
|
+
return {"success": False, "error": str(e)}
|