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.
Files changed (60) hide show
  1. claxcode/__init__.py +3 -0
  2. claxcode/api/__init__.py +1 -0
  3. claxcode/api/chat.py +51 -0
  4. claxcode/api/session.py +66 -0
  5. claxcode/api/subagents.py +82 -0
  6. claxcode/api/tools.py +38 -0
  7. claxcode/cli.py +1214 -0
  8. claxcode/cli_completion.py +174 -0
  9. claxcode/config.py +54 -0
  10. claxcode/core/__init__.py +1 -0
  11. claxcode/core/agent.py +327 -0
  12. claxcode/core/checkpoint.py +100 -0
  13. claxcode/core/context.py +114 -0
  14. claxcode/core/models.py +446 -0
  15. claxcode/extensions/__init__.py +1 -0
  16. claxcode/extensions/hooks.py +131 -0
  17. claxcode/extensions/mcp.py +13 -0
  18. claxcode/extensions/plugins.py +10 -0
  19. claxcode/extensions/skills.py +205 -0
  20. claxcode/main.py +55 -0
  21. claxcode/memory/__init__.py +23 -0
  22. claxcode/memory/auto_memory.py +68 -0
  23. claxcode/memory/long_term.py +196 -0
  24. claxcode/memory/project_context.py +20 -0
  25. claxcode/memory/project_memory.py +194 -0
  26. claxcode/memory/session_store.py +206 -0
  27. claxcode/models/__init__.py +1 -0
  28. claxcode/models/session.py +25 -0
  29. claxcode/models/subagent.py +25 -0
  30. claxcode/models/tool.py +24 -0
  31. claxcode/security/__init__.py +1 -0
  32. claxcode/security/audit.py +54 -0
  33. claxcode/security/permissions.py +29 -0
  34. claxcode/skills/bug-fix/SKILL.md +33 -0
  35. claxcode/skills/code-review/SKILL.md +35 -0
  36. claxcode/skills/python-refactor/SKILL.md +22 -0
  37. claxcode/skills/task-workflow/SKILL.md +38 -0
  38. claxcode/subagents/__init__.py +1 -0
  39. claxcode/subagents/base.py +108 -0
  40. claxcode/subagents/executor.py +16 -0
  41. claxcode/subagents/explore.py +25 -0
  42. claxcode/subagents/planner.py +25 -0
  43. claxcode/tools/__init__.py +14 -0
  44. claxcode/tools/admin.py +170 -0
  45. claxcode/tools/base.py +19 -0
  46. claxcode/tools/browser.py +499 -0
  47. claxcode/tools/code_intelligence.py +68 -0
  48. claxcode/tools/custom_example.py +76 -0
  49. claxcode/tools/execution.py +114 -0
  50. claxcode/tools/file_operations.py +287 -0
  51. claxcode/tools/memory_tool.py +29 -0
  52. claxcode/tools/registry.py +35 -0
  53. claxcode/tools/search.py +157 -0
  54. claxcode/tools/ssh.py +534 -0
  55. claxcode/tools/web.py +83 -0
  56. claxcode-0.1.1.dist-info/METADATA +359 -0
  57. claxcode-0.1.1.dist-info/RECORD +60 -0
  58. claxcode-0.1.1.dist-info/WHEEL +4 -0
  59. claxcode-0.1.1.dist-info/entry_points.txt +3 -0
  60. 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