claxcode 0.1.1__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.
- claxcode/__init__.py +3 -0
- claxcode/api/__init__.py +1 -0
- claxcode/api/chat.py +51 -0
- claxcode/api/session.py +66 -0
- claxcode/api/subagents.py +82 -0
- claxcode/api/tools.py +38 -0
- claxcode/cli.py +1214 -0
- claxcode/cli_completion.py +174 -0
- claxcode/config.py +54 -0
- claxcode/core/__init__.py +1 -0
- claxcode/core/agent.py +327 -0
- claxcode/core/checkpoint.py +100 -0
- claxcode/core/context.py +114 -0
- claxcode/core/models.py +446 -0
- claxcode/extensions/__init__.py +1 -0
- claxcode/extensions/hooks.py +131 -0
- claxcode/extensions/mcp.py +13 -0
- claxcode/extensions/plugins.py +10 -0
- claxcode/extensions/skills.py +205 -0
- claxcode/main.py +55 -0
- claxcode/memory/__init__.py +23 -0
- claxcode/memory/auto_memory.py +68 -0
- claxcode/memory/long_term.py +196 -0
- claxcode/memory/project_context.py +20 -0
- claxcode/memory/project_memory.py +194 -0
- claxcode/memory/session_store.py +206 -0
- claxcode/models/__init__.py +1 -0
- claxcode/models/session.py +25 -0
- claxcode/models/subagent.py +25 -0
- claxcode/models/tool.py +24 -0
- claxcode/security/__init__.py +1 -0
- claxcode/security/audit.py +54 -0
- claxcode/security/permissions.py +29 -0
- claxcode/skills/bug-fix/SKILL.md +33 -0
- claxcode/skills/code-review/SKILL.md +35 -0
- claxcode/skills/python-refactor/SKILL.md +22 -0
- claxcode/skills/task-workflow/SKILL.md +38 -0
- claxcode/subagents/__init__.py +1 -0
- claxcode/subagents/base.py +108 -0
- claxcode/subagents/executor.py +16 -0
- claxcode/subagents/explore.py +25 -0
- claxcode/subagents/planner.py +25 -0
- claxcode/tools/__init__.py +14 -0
- claxcode/tools/admin.py +170 -0
- claxcode/tools/base.py +19 -0
- claxcode/tools/browser.py +499 -0
- claxcode/tools/code_intelligence.py +68 -0
- claxcode/tools/custom_example.py +76 -0
- claxcode/tools/execution.py +114 -0
- claxcode/tools/file_operations.py +287 -0
- claxcode/tools/memory_tool.py +29 -0
- claxcode/tools/registry.py +35 -0
- claxcode/tools/search.py +157 -0
- claxcode/tools/ssh.py +534 -0
- claxcode/tools/web.py +83 -0
- claxcode-0.1.1.dist-info/METADATA +359 -0
- claxcode-0.1.1.dist-info/RECORD +60 -0
- claxcode-0.1.1.dist-info/WHEEL +4 -0
- claxcode-0.1.1.dist-info/entry_points.txt +3 -0
- claxcode-0.1.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Input completion for the interactive CLI (@file mentions and /commands).
|
|
2
|
+
|
|
3
|
+
Built on prompt_toolkit. The file index is rebuilt lazily with a short TTL
|
|
4
|
+
so completion stays cheap even on large projects; dependency/cache dirs
|
|
5
|
+
(from tools.search.IGNORED_DIRS) are pruned.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable, Iterable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
17
|
+
from prompt_toolkit.document import Document
|
|
18
|
+
|
|
19
|
+
from claxcode.tools.search import IGNORED_DIRS
|
|
20
|
+
|
|
21
|
+
MAX_FILES = 5000
|
|
22
|
+
MAX_SUGGESTIONS = 30
|
|
23
|
+
CACHE_TTL = 30.0
|
|
24
|
+
|
|
25
|
+
UNSUPPORTED_FILE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
|
|
26
|
+
COMMAND_ARGUMENTS = {
|
|
27
|
+
"/mode": [
|
|
28
|
+
("ask", "写操作和执行命令前逐项确认"),
|
|
29
|
+
("auto_edit", "自动允许全部工具"),
|
|
30
|
+
("plan", "只读规划,阻止写操作和命令执行"),
|
|
31
|
+
("auto_safe", "允许读写,阻止命令执行"),
|
|
32
|
+
("yolo", "不做任何限制,谨慎使用"),
|
|
33
|
+
],
|
|
34
|
+
"/skills": [
|
|
35
|
+
("list", "列出已加载技能"),
|
|
36
|
+
("reload", "重新加载技能"),
|
|
37
|
+
("create", "创建项目技能"),
|
|
38
|
+
],
|
|
39
|
+
"/project": [("list", "列出项目"), ("switch", "切换项目"), ("info", "当前项目信息")],
|
|
40
|
+
"/memory": [
|
|
41
|
+
("overview", "记忆总览"),
|
|
42
|
+
("history", "当前会话历史"),
|
|
43
|
+
("list", "长期记忆列表"),
|
|
44
|
+
("search", "搜索长期记忆"),
|
|
45
|
+
("export", "导出长期记忆"),
|
|
46
|
+
("import", "导入长期记忆"),
|
|
47
|
+
("delete", "删除长期记忆"),
|
|
48
|
+
("learn", "添加自动记忆"),
|
|
49
|
+
("forget", "删除自动记忆"),
|
|
50
|
+
("facts", "自动记忆列表"),
|
|
51
|
+
],
|
|
52
|
+
"/rollback": [("list", "列出可回滚快照")],
|
|
53
|
+
}
|
|
54
|
+
_AT_COMPLETION_RE = re.compile(r'(?<![A-Za-z0-9_@])@(?:"([^"]*)|([^\s,。;:、!?]*))$')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ProjectFileIndex:
|
|
58
|
+
"""Cached list of project-relative file paths for @ completion."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, root: Path):
|
|
61
|
+
self._root = root
|
|
62
|
+
self._files: list[str] = []
|
|
63
|
+
self._built_at = 0.0
|
|
64
|
+
|
|
65
|
+
def files(self) -> list[str]:
|
|
66
|
+
if time.monotonic() - self._built_at > CACHE_TTL:
|
|
67
|
+
self._rebuild()
|
|
68
|
+
return self._files
|
|
69
|
+
|
|
70
|
+
def _rebuild(self) -> None:
|
|
71
|
+
files: list[str] = []
|
|
72
|
+
stack = [self._root]
|
|
73
|
+
while stack and len(files) < MAX_FILES:
|
|
74
|
+
current = stack.pop()
|
|
75
|
+
try:
|
|
76
|
+
entries = sorted(current.iterdir())
|
|
77
|
+
except OSError:
|
|
78
|
+
continue
|
|
79
|
+
for p in entries:
|
|
80
|
+
if p.is_dir():
|
|
81
|
+
if p.name not in IGNORED_DIRS and not p.is_symlink():
|
|
82
|
+
stack.append(p)
|
|
83
|
+
elif p.is_file() and p.suffix.lower() not in UNSUPPORTED_FILE_SUFFIXES:
|
|
84
|
+
files.append(str(p.relative_to(self._root)).replace("\\", "/"))
|
|
85
|
+
self._files = files
|
|
86
|
+
self._built_at = time.monotonic()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _fuzzy_match(needle: str, candidate: str) -> bool:
|
|
90
|
+
"""Case-insensitive subsequence match (e.g. 'agt' matches 'agent.py')."""
|
|
91
|
+
it = iter(candidate.lower())
|
|
92
|
+
return all(ch in it for ch in needle.lower())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _file_rank(needle: str, path: str) -> tuple[int, int, str]:
|
|
96
|
+
needle_lower = needle.lower()
|
|
97
|
+
path_lower = path.lower()
|
|
98
|
+
basename = path_lower.rsplit("/", 1)[-1]
|
|
99
|
+
if not needle_lower:
|
|
100
|
+
rank = 0
|
|
101
|
+
elif path_lower == needle_lower or basename == needle_lower:
|
|
102
|
+
rank = 1
|
|
103
|
+
elif basename.startswith(needle_lower):
|
|
104
|
+
rank = 2
|
|
105
|
+
elif path_lower.startswith(needle_lower):
|
|
106
|
+
rank = 3
|
|
107
|
+
elif needle_lower in basename:
|
|
108
|
+
rank = 4
|
|
109
|
+
elif needle_lower in path_lower:
|
|
110
|
+
rank = 5
|
|
111
|
+
else:
|
|
112
|
+
rank = 6
|
|
113
|
+
return rank, len(path), path_lower
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CliCompleter(Completer):
|
|
117
|
+
"""Complete @file mentions, /slash commands, and common command arguments."""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
project_path: Path,
|
|
122
|
+
commands_provider: Callable[[], list[tuple[str, str]]],
|
|
123
|
+
):
|
|
124
|
+
self._index = ProjectFileIndex(project_path)
|
|
125
|
+
self._commands_provider = commands_provider
|
|
126
|
+
|
|
127
|
+
def get_completions(
|
|
128
|
+
self, document: Document, _complete_event: Any
|
|
129
|
+
) -> Iterable[Completion]:
|
|
130
|
+
text = document.text_before_cursor
|
|
131
|
+
mention = _AT_COMPLETION_RE.search(text)
|
|
132
|
+
if mention:
|
|
133
|
+
quoted, unquoted = mention.groups()
|
|
134
|
+
needle = quoted if quoted is not None else unquoted
|
|
135
|
+
needs_quote = quoted is not None and not document.text_after_cursor.startswith('"')
|
|
136
|
+
closing_quote = '"' if needs_quote else ""
|
|
137
|
+
yield from self._file_completions(needle, closing_quote)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
if "\n" in text or not text.startswith("/"):
|
|
141
|
+
return
|
|
142
|
+
argument = re.fullmatch(r"(/\S+)[ \t]+([^\s]*)", text)
|
|
143
|
+
if argument:
|
|
144
|
+
yield from self._argument_completions(argument.group(1), argument.group(2))
|
|
145
|
+
elif re.fullmatch(r"/\S*", text):
|
|
146
|
+
yield from self._command_completions(text)
|
|
147
|
+
|
|
148
|
+
def _file_completions(self, needle: str, suffix: str = "") -> Iterable[Completion]:
|
|
149
|
+
candidates = [p for p in self._index.files() if not needle or _fuzzy_match(needle, p)]
|
|
150
|
+
candidates.sort(key=lambda p: _file_rank(needle, p))
|
|
151
|
+
for path in candidates[:MAX_SUGGESTIONS]:
|
|
152
|
+
yield Completion(path + suffix, start_position=-len(needle), display=path)
|
|
153
|
+
|
|
154
|
+
def _command_completions(self, needle: str) -> Iterable[Completion]:
|
|
155
|
+
needle_lower = needle.lower()
|
|
156
|
+
for name, description in self._commands_provider():
|
|
157
|
+
if name.lower().startswith(needle_lower):
|
|
158
|
+
yield Completion(
|
|
159
|
+
name,
|
|
160
|
+
start_position=-len(needle),
|
|
161
|
+
display=name,
|
|
162
|
+
display_meta=description,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _argument_completions(self, command: str, needle: str) -> Iterable[Completion]:
|
|
166
|
+
needle_lower = needle.lower()
|
|
167
|
+
for value, description in COMMAND_ARGUMENTS.get(command.lower(), []):
|
|
168
|
+
if value.startswith(needle_lower):
|
|
169
|
+
yield Completion(
|
|
170
|
+
value,
|
|
171
|
+
start_position=-len(needle),
|
|
172
|
+
display=value,
|
|
173
|
+
display_meta=description,
|
|
174
|
+
)
|
claxcode/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Global configuration for ClaxCode."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Settings(BaseSettings):
|
|
10
|
+
"""Environment-driven settings."""
|
|
11
|
+
|
|
12
|
+
# App
|
|
13
|
+
app_name: str = "ClaxCode"
|
|
14
|
+
debug: bool = Field(default=False, validation_alias="CLAXCODE_DEBUG")
|
|
15
|
+
|
|
16
|
+
# LLM
|
|
17
|
+
llm_provider: str = "openai" # openai | anthropic | generic_openai
|
|
18
|
+
llm_api_key: str = ""
|
|
19
|
+
llm_base_url: str = ""
|
|
20
|
+
llm_model: str = "gpt-4o"
|
|
21
|
+
llm_temperature: float = 0.2
|
|
22
|
+
llm_max_tokens: int = 4096
|
|
23
|
+
llm_custom_config: str = "" # JSON string for custom provider settings
|
|
24
|
+
|
|
25
|
+
# Context
|
|
26
|
+
max_context_tokens: int = 128000
|
|
27
|
+
context_compression_threshold: float = 0.85
|
|
28
|
+
|
|
29
|
+
# Storage
|
|
30
|
+
database_url: str = "sqlite+aiosqlite:///./.claxcode/claxcode.db"
|
|
31
|
+
snapshots_dir: Path = Path("./.claxcode/snapshots")
|
|
32
|
+
project_context_file: str = "CLAUDE.md"
|
|
33
|
+
|
|
34
|
+
# Security
|
|
35
|
+
permission_mode: str = "ask" # ask | auto_edit | plan | auto_safe | yolo
|
|
36
|
+
|
|
37
|
+
# Server
|
|
38
|
+
host: str = "127.0.0.1"
|
|
39
|
+
port: int = 8000
|
|
40
|
+
|
|
41
|
+
# Tool Execution
|
|
42
|
+
tool_timeout: int = 60 # seconds
|
|
43
|
+
|
|
44
|
+
# Browser
|
|
45
|
+
browser_headless: bool = True # headless mode for Playwright browser tools
|
|
46
|
+
|
|
47
|
+
model_config = SettingsConfigDict(
|
|
48
|
+
env_file=".env",
|
|
49
|
+
env_file_encoding="utf-8",
|
|
50
|
+
extra="ignore",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
settings = Settings()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core engine for ClaxCode."""
|
claxcode/core/agent.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Agentic Loop controller."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from claxcode.config import settings
|
|
10
|
+
from claxcode.core.checkpoint import CheckpointManager
|
|
11
|
+
from claxcode.core.context import ContextManager
|
|
12
|
+
from claxcode.core.models import get_provider
|
|
13
|
+
from claxcode.extensions.hooks import HookManager
|
|
14
|
+
from claxcode.extensions.skills import SkillManager
|
|
15
|
+
from claxcode.memory.auto_memory import auto_memory
|
|
16
|
+
from claxcode.memory.project_context import load_project_context
|
|
17
|
+
from claxcode.security.audit import audit_logger
|
|
18
|
+
from claxcode.security.permissions import PermissionMode, check_permission
|
|
19
|
+
from claxcode.tools.base import Tool
|
|
20
|
+
from claxcode.tools.execution import clear_tool_cancelled
|
|
21
|
+
from claxcode.tools.registry import registry
|
|
22
|
+
|
|
23
|
+
DEFAULT_SYSTEM_PROMPT = """你是 ClaxCode,一个智能编程助手。
|
|
24
|
+
你帮助用户编写、编辑、调试和理解代码。
|
|
25
|
+
当你需要与项目交互时,请使用提供的工具。
|
|
26
|
+
始终逐步思考并解释你的推理过程。
|
|
27
|
+
|
|
28
|
+
## 工作原则
|
|
29
|
+
1. 先理解后行动:改代码前先用工具读懂相关实现,不臆测路径、签名和行为。
|
|
30
|
+
2. 最小改动:只做任务要求的事,用最少代码、最简单实现,不顺手重构。
|
|
31
|
+
3. 小步验证:每完成一个改动就验证(跑测试或读回文件),再往下走。
|
|
32
|
+
4. 诚实汇报:没验证过的不要声称完成;遇到阻塞如实说明并给出下一步建议。"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Agent:
|
|
36
|
+
"""Main agent implementing Gather -> Think -> Act -> Verify loop."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
session_id: UUID,
|
|
41
|
+
project_path: str = ".",
|
|
42
|
+
permission_mode: str = "ask",
|
|
43
|
+
skill_manager: SkillManager | None = None,
|
|
44
|
+
debug: bool | None = None,
|
|
45
|
+
approval_callback: Callable[[str, dict[str, Any]], Awaitable[bool]] | None = None,
|
|
46
|
+
):
|
|
47
|
+
self.session_id = session_id
|
|
48
|
+
self.project_path = Path(project_path).resolve()
|
|
49
|
+
self.permission_mode = permission_mode
|
|
50
|
+
self.approval_callback = approval_callback
|
|
51
|
+
self.debug = settings.debug if debug is None else debug
|
|
52
|
+
self.provider = get_provider()
|
|
53
|
+
self.checkpoint = CheckpointManager()
|
|
54
|
+
self.hooks = HookManager(self.project_path)
|
|
55
|
+
self.max_iterations = 10
|
|
56
|
+
|
|
57
|
+
# Build system prompt: system-level -> project-level -> skills
|
|
58
|
+
system_prompt_path = Path.home() / ".claxcode" / "system_prompt.md"
|
|
59
|
+
if system_prompt_path.exists():
|
|
60
|
+
system = system_prompt_path.read_text(encoding="utf-8")
|
|
61
|
+
else:
|
|
62
|
+
system = DEFAULT_SYSTEM_PROMPT
|
|
63
|
+
|
|
64
|
+
project_ctx = load_project_context(self.project_path)
|
|
65
|
+
if project_ctx:
|
|
66
|
+
system += f"\n\n# Project Context\n{project_ctx}"
|
|
67
|
+
|
|
68
|
+
# Auto-load skills (built-in + user + project) if not provided
|
|
69
|
+
if skill_manager is None:
|
|
70
|
+
skill_manager = SkillManager()
|
|
71
|
+
skill_manager.load_defaults(self.project_path)
|
|
72
|
+
|
|
73
|
+
self._system_prompt_before_skills = system
|
|
74
|
+
skills_fragment = skill_manager.to_prompt_fragment()
|
|
75
|
+
if skills_fragment:
|
|
76
|
+
system += f"\n\n{skills_fragment}"
|
|
77
|
+
|
|
78
|
+
self._base_system_prompt = system
|
|
79
|
+
self.context = ContextManager(system_prompt=system)
|
|
80
|
+
|
|
81
|
+
def update_skills(self, skill_manager: SkillManager) -> None:
|
|
82
|
+
"""Replace the skill fragment while preserving dynamically loaded memories."""
|
|
83
|
+
memory_suffix = self.context.system_prompt.removeprefix(self._base_system_prompt)
|
|
84
|
+
system = self._system_prompt_before_skills
|
|
85
|
+
skills_fragment = skill_manager.to_prompt_fragment()
|
|
86
|
+
if skills_fragment:
|
|
87
|
+
system += f"\n\n{skills_fragment}"
|
|
88
|
+
self._base_system_prompt = system
|
|
89
|
+
self.context.system_prompt = system + memory_suffix
|
|
90
|
+
if self.context.messages and self.context.messages[0].get("role") == "system":
|
|
91
|
+
self.context.messages[0]["content"] = self.context.system_prompt
|
|
92
|
+
|
|
93
|
+
async def _load_memories(self, user_message: str) -> None:
|
|
94
|
+
"""Refresh scoped memories for each user message without duplicating them."""
|
|
95
|
+
from claxcode.memory import long_term_memory, project_memory
|
|
96
|
+
|
|
97
|
+
auto_fragment = auto_memory.to_prompt_fragment()
|
|
98
|
+
auto_prompt = f"\n\n{auto_fragment}" if auto_fragment else ""
|
|
99
|
+
fragments: list[str] = []
|
|
100
|
+
project_summary = ""
|
|
101
|
+
try:
|
|
102
|
+
project = await project_memory.get_project(str(self.project_path))
|
|
103
|
+
if project:
|
|
104
|
+
project_summary = project.get("context_summary", "").strip()
|
|
105
|
+
if project_summary:
|
|
106
|
+
fragments.append(
|
|
107
|
+
f"\n# 项目滚动摘要(历史状态,需结合当前代码验证)\n{project_summary}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
memories = await long_term_memory.search(
|
|
111
|
+
keywords=[user_message],
|
|
112
|
+
project_path=str(self.project_path),
|
|
113
|
+
include_global=True,
|
|
114
|
+
limit=3,
|
|
115
|
+
)
|
|
116
|
+
if not memories:
|
|
117
|
+
memories = await long_term_memory.search(
|
|
118
|
+
project_path=str(self.project_path), include_global=True, limit=2
|
|
119
|
+
)
|
|
120
|
+
lines = ["\n# 相关长期记忆(仅作历史参考,当前指令优先)"]
|
|
121
|
+
for memory in memories:
|
|
122
|
+
content = memory["compressed_content"].strip()
|
|
123
|
+
if not content or content == project_summary:
|
|
124
|
+
continue
|
|
125
|
+
scope = "全局" if not memory["project_path"] else "项目"
|
|
126
|
+
lines.append(f"- [{scope}] {content[:400]}")
|
|
127
|
+
if len(lines) > 1:
|
|
128
|
+
fragments.append("\n".join(lines))
|
|
129
|
+
except Exception:
|
|
130
|
+
fragments = [] # 长期记忆加载失败不应阻塞主流程或全局偏好
|
|
131
|
+
|
|
132
|
+
self.context.system_prompt = self._base_system_prompt + auto_prompt + "".join(fragments)
|
|
133
|
+
if self.context.messages and self.context.messages[0].get("role") == "system":
|
|
134
|
+
self.context.messages[0]["content"] = self.context.system_prompt
|
|
135
|
+
|
|
136
|
+
async def run(self, user_message: str) -> AsyncIterator[dict]:
|
|
137
|
+
"""Stream agent events."""
|
|
138
|
+
clear_tool_cancelled()
|
|
139
|
+
await self._load_memories(user_message)
|
|
140
|
+
|
|
141
|
+
yield {"type": "thinking", "content": "正在收集上下文..."}
|
|
142
|
+
self.context.add_message("user", user_message)
|
|
143
|
+
|
|
144
|
+
for iteration in range(self.max_iterations):
|
|
145
|
+
# Prepare available tools
|
|
146
|
+
available_tools = [
|
|
147
|
+
{
|
|
148
|
+
"type": "function",
|
|
149
|
+
"function": {
|
|
150
|
+
"name": t.name,
|
|
151
|
+
"description": t.description,
|
|
152
|
+
"parameters": t.input_schema,
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
for t in registry.list_tools()
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
yield {"type": "thinking", "content": f"思考中... (步骤 {iteration + 1})"}
|
|
159
|
+
|
|
160
|
+
if await self._compress_context_if_needed():
|
|
161
|
+
yield {"type": "thinking", "content": "上下文过长,已压缩早期对话。"}
|
|
162
|
+
|
|
163
|
+
messages = self.context.get_messages()
|
|
164
|
+
|
|
165
|
+
# Debug: emit raw context
|
|
166
|
+
if self.debug:
|
|
167
|
+
yield {
|
|
168
|
+
"type": "debug",
|
|
169
|
+
"label": "发送给 LLM 的原始上下文",
|
|
170
|
+
"payload": {
|
|
171
|
+
"messages": messages,
|
|
172
|
+
"tools_count": len(available_tools),
|
|
173
|
+
"tools": [t["function"]["name"] for t in available_tools],
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
content_buffer = ""
|
|
178
|
+
pending_tool_calls: list[dict] = []
|
|
179
|
+
|
|
180
|
+
async for chunk in self.provider.stream_chat(
|
|
181
|
+
messages=messages,
|
|
182
|
+
tools=available_tools if available_tools else None,
|
|
183
|
+
):
|
|
184
|
+
if isinstance(chunk, str):
|
|
185
|
+
content_buffer += chunk
|
|
186
|
+
yield {"type": "message_delta", "content": chunk}
|
|
187
|
+
elif isinstance(chunk, dict) and chunk.get("type") == "tool_call":
|
|
188
|
+
pending_tool_calls.append(chunk)
|
|
189
|
+
yield {
|
|
190
|
+
"type": "tool_call",
|
|
191
|
+
"name": chunk["name"],
|
|
192
|
+
"arguments": chunk["arguments"],
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if not pending_tool_calls:
|
|
196
|
+
# Assistant finished without tools
|
|
197
|
+
self.context.add_message("assistant", content_buffer)
|
|
198
|
+
if self.debug:
|
|
199
|
+
yield {
|
|
200
|
+
"type": "debug",
|
|
201
|
+
"label": "助手响应(无工具调用)",
|
|
202
|
+
"payload": {"content": content_buffer},
|
|
203
|
+
}
|
|
204
|
+
yield {"type": "done"}
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
# ACT: execute tools
|
|
208
|
+
assistant_tool_calls = []
|
|
209
|
+
for tc in pending_tool_calls:
|
|
210
|
+
assistant_tool_calls.append(
|
|
211
|
+
{
|
|
212
|
+
"id": tc["id"],
|
|
213
|
+
"type": "function",
|
|
214
|
+
"function": {
|
|
215
|
+
"name": tc["name"],
|
|
216
|
+
"arguments": json.dumps(tc["arguments"]),
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
self.context.add_assistant_tool_calls(assistant_tool_calls)
|
|
221
|
+
|
|
222
|
+
if self.debug:
|
|
223
|
+
yield {
|
|
224
|
+
"type": "debug",
|
|
225
|
+
"label": "来自 LLM 的工具调用",
|
|
226
|
+
"payload": {"tool_calls": pending_tool_calls},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for tc in pending_tool_calls:
|
|
230
|
+
name = tc["name"]
|
|
231
|
+
args = tc["arguments"]
|
|
232
|
+
yield {"type": "tool_result_start", "name": name}
|
|
233
|
+
|
|
234
|
+
result = await self._execute_tool(name, args)
|
|
235
|
+
yield {"type": "tool_result", "name": name, "output": result}
|
|
236
|
+
self.context.add_tool_result(tc["id"], name, result)
|
|
237
|
+
|
|
238
|
+
if self.debug:
|
|
239
|
+
yield {
|
|
240
|
+
"type": "debug",
|
|
241
|
+
"label": f"工具结果: {name}",
|
|
242
|
+
"payload": {"arguments": args, "result": result},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# VERIFY: lightweight step (just a thinking marker)
|
|
246
|
+
yield {"type": "thinking", "content": "正在验证结果..."}
|
|
247
|
+
|
|
248
|
+
yield {"type": "done", "warning": "已达到最大迭代次数"}
|
|
249
|
+
|
|
250
|
+
async def _execute_tool(self, name: str, args: dict[str, Any]) -> str:
|
|
251
|
+
tool = registry.get(name)
|
|
252
|
+
if tool is None:
|
|
253
|
+
return f"错误:工具 '{name}' 不存在"
|
|
254
|
+
|
|
255
|
+
allowed, reason = await self._check_tool_permission(tool, args)
|
|
256
|
+
if not allowed:
|
|
257
|
+
audit_logger.log_tool_call(
|
|
258
|
+
self.session_id, name, args, f"已阻止: {reason}", allowed=False
|
|
259
|
+
)
|
|
260
|
+
return f"[权限阻止] 工具 '{name}': {reason}"
|
|
261
|
+
|
|
262
|
+
hook_ok, hook_reason = await self.hooks.run_pre(name, args)
|
|
263
|
+
if not hook_ok:
|
|
264
|
+
audit_logger.log_tool_call(
|
|
265
|
+
self.session_id, name, args, f"钩子阻止: {hook_reason}", allowed=False
|
|
266
|
+
)
|
|
267
|
+
return f"[钩子阻止] 工具 '{name}': {hook_reason}"
|
|
268
|
+
|
|
269
|
+
# Snapshot before file edits (kept in every mode for rollback safety)
|
|
270
|
+
if "write" in tool.required_permissions and "path" in args:
|
|
271
|
+
await self.checkpoint.snapshot(self.session_id, args["path"])
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
result = await tool.execute(**args)
|
|
275
|
+
except Exception as exc:
|
|
276
|
+
result = f"执行 {name} 时出错: {exc}"
|
|
277
|
+
|
|
278
|
+
hook_output = await self.hooks.run_post(name, args, result)
|
|
279
|
+
if hook_output:
|
|
280
|
+
result += f"\n\n[Hook 输出]\n{hook_output}"
|
|
281
|
+
|
|
282
|
+
audit_logger.log_tool_call(self.session_id, name, args, result, allowed=True)
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
async def _check_tool_permission(self, tool: Tool, args: dict[str, Any]) -> tuple[bool, str]:
|
|
286
|
+
"""Check the permission mode; in ask mode consult the approval callback."""
|
|
287
|
+
try:
|
|
288
|
+
mode = PermissionMode(self.permission_mode)
|
|
289
|
+
except ValueError:
|
|
290
|
+
mode = PermissionMode.ASK
|
|
291
|
+
|
|
292
|
+
allowed, reason = check_permission(mode, tool.name, tool.required_permissions)
|
|
293
|
+
if allowed:
|
|
294
|
+
return True, ""
|
|
295
|
+
if mode == PermissionMode.ASK and self.approval_callback is not None:
|
|
296
|
+
if await self.approval_callback(tool.name, args):
|
|
297
|
+
return True, ""
|
|
298
|
+
reason = "用户拒绝了执行"
|
|
299
|
+
return False, reason
|
|
300
|
+
|
|
301
|
+
async def _compress_context_if_needed(self) -> bool:
|
|
302
|
+
"""Replace the oldest half of the history with a summary when over threshold."""
|
|
303
|
+
if not self.context.over_threshold():
|
|
304
|
+
return False
|
|
305
|
+
removed = self.context.pop_oldest(max(1, len(self.context.messages) // 2))
|
|
306
|
+
summary = await self._summarize_messages(removed)
|
|
307
|
+
self.context.insert_summary(summary)
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
async def _summarize_messages(self, messages: list[dict[str, Any]]) -> str:
|
|
311
|
+
"""Summarize dropped messages with the LLM; fall back to a placeholder."""
|
|
312
|
+
lines = [f"{m.get('role')}: {str(m.get('content', ''))[:500]}" for m in messages]
|
|
313
|
+
prompt = (
|
|
314
|
+
"请用中文简要总结以下早期对话的关键信息(用户目标、已完成的工作、"
|
|
315
|
+
"重要文件路径和结论),200 字以内:\n\n" + "\n".join(lines)[:8000]
|
|
316
|
+
)
|
|
317
|
+
summary = ""
|
|
318
|
+
try:
|
|
319
|
+
async for chunk in self.provider.stream_chat(
|
|
320
|
+
messages=[{"role": "user", "content": prompt}], tools=None
|
|
321
|
+
):
|
|
322
|
+
if isinstance(chunk, str):
|
|
323
|
+
summary += chunk
|
|
324
|
+
except Exception:
|
|
325
|
+
summary = ""
|
|
326
|
+
summary = summary.strip() or f"[已省略 {len(messages)} 条早期对话消息]"
|
|
327
|
+
return f"[早期对话摘要]\n{summary}"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Pre-edit snapshot and rollback system."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from claxcode.config import settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CheckpointManager:
|
|
16
|
+
"""Creates filesystem snapshots before edits and restores them on demand."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.base_dir = settings.snapshots_dir
|
|
20
|
+
|
|
21
|
+
def _session_snapshot_dir(self, session_id: UUID) -> Path:
|
|
22
|
+
ts = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f")
|
|
23
|
+
return self.base_dir / str(session_id) / ts
|
|
24
|
+
|
|
25
|
+
async def snapshot(self, session_id: UUID, file_path: str) -> Path:
|
|
26
|
+
"""Copy file to snapshot directory before editing."""
|
|
27
|
+
target = Path(file_path)
|
|
28
|
+
if not target.exists():
|
|
29
|
+
return Path()
|
|
30
|
+
snap_dir = self._session_snapshot_dir(session_id)
|
|
31
|
+
while snap_dir.exists(): # same-microsecond collision: wait for a fresh timestamp
|
|
32
|
+
await asyncio.sleep(0.001)
|
|
33
|
+
snap_dir = self._session_snapshot_dir(session_id)
|
|
34
|
+
snap_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
dest = snap_dir / target.name
|
|
36
|
+
shutil.copy2(target, dest)
|
|
37
|
+
self._write_manifest(snap_dir, target)
|
|
38
|
+
return dest
|
|
39
|
+
|
|
40
|
+
def _write_manifest(self, snap_dir: Path, target: Path) -> None:
|
|
41
|
+
"""Record the original path of each snapshotted file."""
|
|
42
|
+
manifest = snap_dir / "manifest.json"
|
|
43
|
+
data: dict[str, str] = {}
|
|
44
|
+
if manifest.exists():
|
|
45
|
+
with suppress(json.JSONDecodeError, OSError):
|
|
46
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
47
|
+
data[target.name] = str(target.resolve())
|
|
48
|
+
manifest.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
49
|
+
|
|
50
|
+
def _read_manifest(self, snap_dir: Path) -> dict[str, str]:
|
|
51
|
+
manifest = snap_dir / "manifest.json"
|
|
52
|
+
if not manifest.exists():
|
|
53
|
+
return {}
|
|
54
|
+
with suppress(json.JSONDecodeError, OSError):
|
|
55
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
56
|
+
if isinstance(data, dict):
|
|
57
|
+
return data
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
def list_snapshots(self, session_id: UUID, limit: int = 10) -> list[dict[str, Any]]:
|
|
61
|
+
"""Most recent snapshot batches, newest first."""
|
|
62
|
+
if limit < 1:
|
|
63
|
+
return []
|
|
64
|
+
session_dir = self.base_dir / str(session_id)
|
|
65
|
+
if not session_dir.exists():
|
|
66
|
+
return []
|
|
67
|
+
entries = []
|
|
68
|
+
for snap_dir in sorted(session_dir.iterdir(), reverse=True):
|
|
69
|
+
if not snap_dir.is_dir():
|
|
70
|
+
continue
|
|
71
|
+
files = list(self._read_manifest(snap_dir).values())
|
|
72
|
+
if not files: # legacy snapshots without a manifest
|
|
73
|
+
files = [
|
|
74
|
+
f.name for f in snap_dir.iterdir() if f.is_file() and f.name != "manifest.json"
|
|
75
|
+
]
|
|
76
|
+
entries.append({"timestamp": snap_dir.name, "files": files})
|
|
77
|
+
if len(entries) >= limit:
|
|
78
|
+
break
|
|
79
|
+
return entries
|
|
80
|
+
|
|
81
|
+
def restore(self, session_id: UUID, steps: int = 1) -> list[str]:
|
|
82
|
+
"""Restore files from the `steps` most recent snapshots.
|
|
83
|
+
|
|
84
|
+
For each file the oldest copy wins, so the file returns to its state
|
|
85
|
+
before all of the rolled-back actions. Returns restored paths.
|
|
86
|
+
"""
|
|
87
|
+
batches = self.list_snapshots(session_id, limit=steps)
|
|
88
|
+
restored: list[str] = []
|
|
89
|
+
for batch in reversed(batches): # oldest batch first
|
|
90
|
+
snap_dir = self.base_dir / str(session_id) / batch["timestamp"]
|
|
91
|
+
for name, original in self._read_manifest(snap_dir).items():
|
|
92
|
+
if original in restored:
|
|
93
|
+
continue
|
|
94
|
+
src = snap_dir / name
|
|
95
|
+
if not src.exists():
|
|
96
|
+
continue
|
|
97
|
+
Path(original).parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
shutil.copy2(src, original)
|
|
99
|
+
restored.append(original)
|
|
100
|
+
return restored
|