illusion-code 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.
- illusion/__init__.py +24 -0
- illusion/__main__.py +15 -0
- illusion/_frontend/dist/index.mjs +39208 -0
- illusion/_frontend/package.json +27 -0
- illusion/_frontend/src/App.tsx +624 -0
- illusion/_frontend/src/components/CommandPicker.tsx +98 -0
- illusion/_frontend/src/components/Composer.tsx +55 -0
- illusion/_frontend/src/components/ComposerController.tsx +128 -0
- illusion/_frontend/src/components/ConversationView.tsx +750 -0
- illusion/_frontend/src/components/Footer.tsx +25 -0
- illusion/_frontend/src/components/MarkdownContent.tsx +537 -0
- illusion/_frontend/src/components/MarkdownTable.tsx +245 -0
- illusion/_frontend/src/components/ModalHost.tsx +425 -0
- illusion/_frontend/src/components/MultilineTextInput.tsx +250 -0
- illusion/_frontend/src/components/PromptInput.tsx +64 -0
- illusion/_frontend/src/components/SelectModal.tsx +78 -0
- illusion/_frontend/src/components/SidePanel.tsx +175 -0
- illusion/_frontend/src/components/Spinner.tsx +77 -0
- illusion/_frontend/src/components/StatusBar.tsx +142 -0
- illusion/_frontend/src/components/SwarmPanel.tsx +141 -0
- illusion/_frontend/src/components/TodoPanel.tsx +126 -0
- illusion/_frontend/src/components/ToolCallDisplay.tsx +202 -0
- illusion/_frontend/src/components/TranscriptPane.tsx +79 -0
- illusion/_frontend/src/components/WelcomeBanner.tsx +37 -0
- illusion/_frontend/src/hooks/useBackendSession.ts +468 -0
- illusion/_frontend/src/hooks/useTerminalSize.ts +9 -0
- illusion/_frontend/src/i18n.ts +78 -0
- illusion/_frontend/src/index.tsx +42 -0
- illusion/_frontend/src/theme/ThemeContext.tsx +19 -0
- illusion/_frontend/src/theme/builtinThemes.ts +89 -0
- illusion/_frontend/src/types.ts +110 -0
- illusion/_frontend/src/utils/markdown.ts +33 -0
- illusion/_frontend/src/utils/thinking.ts +191 -0
- illusion/_frontend/tsconfig.json +13 -0
- illusion/_web_dist/assets/index-BseIw-ik.css +10 -0
- illusion/_web_dist/assets/index-C_0ZWMuW.js +82 -0
- illusion/_web_dist/index.html +16 -0
- illusion/api/__init__.py +36 -0
- illusion/api/client.py +568 -0
- illusion/api/codex_client.py +563 -0
- illusion/api/compat.py +138 -0
- illusion/api/effort.py +128 -0
- illusion/api/errors.py +57 -0
- illusion/api/openai_client.py +819 -0
- illusion/api/provider.py +148 -0
- illusion/api/registry.py +479 -0
- illusion/api/usage.py +45 -0
- illusion/auth/__init__.py +50 -0
- illusion/auth/copilot.py +419 -0
- illusion/auth/external.py +612 -0
- illusion/auth/flows.py +58 -0
- illusion/auth/manager.py +214 -0
- illusion/auth/storage.py +372 -0
- illusion/bridge/__init__.py +38 -0
- illusion/bridge/manager.py +190 -0
- illusion/bridge/session_runner.py +84 -0
- illusion/bridge/types.py +113 -0
- illusion/bridge/work_secret.py +131 -0
- illusion/cli.py +1228 -0
- illusion/commands/__init__.py +32 -0
- illusion/commands/registry.py +1934 -0
- illusion/config/__init__.py +39 -0
- illusion/config/i18n.py +522 -0
- illusion/config/paths.py +259 -0
- illusion/config/settings.py +564 -0
- illusion/coordinator/__init__.py +41 -0
- illusion/coordinator/agent_definitions.py +1093 -0
- illusion/coordinator/coordinator_mode.py +127 -0
- illusion/engine/__init__.py +95 -0
- illusion/engine/cost_tracker.py +55 -0
- illusion/engine/messages.py +369 -0
- illusion/engine/query.py +632 -0
- illusion/engine/query_engine.py +343 -0
- illusion/engine/stream_events.py +169 -0
- illusion/hooks/__init__.py +67 -0
- illusion/hooks/events.py +43 -0
- illusion/hooks/executor.py +397 -0
- illusion/hooks/hot_reload.py +74 -0
- illusion/hooks/loader.py +133 -0
- illusion/hooks/schemas.py +121 -0
- illusion/hooks/types.py +86 -0
- illusion/mcp/__init__.py +104 -0
- illusion/mcp/client.py +377 -0
- illusion/mcp/config.py +140 -0
- illusion/mcp/types.py +175 -0
- illusion/memory/__init__.py +36 -0
- illusion/memory/manager.py +94 -0
- illusion/memory/memdir.py +58 -0
- illusion/memory/paths.py +57 -0
- illusion/memory/scan.py +120 -0
- illusion/memory/search.py +83 -0
- illusion/memory/types.py +43 -0
- illusion/output_styles/__init__.py +15 -0
- illusion/output_styles/loader.py +64 -0
- illusion/permissions/__init__.py +39 -0
- illusion/permissions/checker.py +174 -0
- illusion/permissions/modes.py +38 -0
- illusion/platforms.py +148 -0
- illusion/plugins/__init__.py +71 -0
- illusion/plugins/bundled/__init__.py +0 -0
- illusion/plugins/installer.py +59 -0
- illusion/plugins/loader.py +301 -0
- illusion/plugins/schemas.py +51 -0
- illusion/plugins/types.py +56 -0
- illusion/prompts/__init__.py +29 -0
- illusion/prompts/claudemd.py +74 -0
- illusion/prompts/context.py +187 -0
- illusion/prompts/environment.py +189 -0
- illusion/prompts/system_prompt.py +155 -0
- illusion/py.typed +0 -0
- illusion/sandbox/__init__.py +29 -0
- illusion/sandbox/adapter.py +174 -0
- illusion/services/__init__.py +59 -0
- illusion/services/compact/__init__.py +1015 -0
- illusion/services/cron.py +338 -0
- illusion/services/cron_scheduler.py +715 -0
- illusion/services/file_history.py +258 -0
- illusion/services/lsp/__init__.py +455 -0
- illusion/services/session_storage.py +237 -0
- illusion/services/token_estimation.py +72 -0
- illusion/skills/__init__.py +60 -0
- illusion/skills/bundled/__init__.py +110 -0
- illusion/skills/bundled/content/batch.md +86 -0
- illusion/skills/bundled/content/coding-guidelines.md +70 -0
- illusion/skills/bundled/content/debug.md +38 -0
- illusion/skills/bundled/content/loop.md +82 -0
- illusion/skills/bundled/content/remember.md +105 -0
- illusion/skills/bundled/content/simplify.md +53 -0
- illusion/skills/bundled/content/skillify.md +113 -0
- illusion/skills/bundled/content/stuck.md +54 -0
- illusion/skills/bundled/content/update-config.md +329 -0
- illusion/skills/bundled/content/verify.md +74 -0
- illusion/skills/loader.py +219 -0
- illusion/skills/registry.py +40 -0
- illusion/skills/types.py +24 -0
- illusion/state/__init__.py +18 -0
- illusion/state/app_state.py +67 -0
- illusion/state/store.py +93 -0
- illusion/swarm/__init__.py +71 -0
- illusion/swarm/agent_executor.py +857 -0
- illusion/swarm/in_process.py +259 -0
- illusion/swarm/subprocess_backend.py +136 -0
- illusion/swarm/team_helpers.py +123 -0
- illusion/swarm/types.py +159 -0
- illusion/swarm/worktree.py +347 -0
- illusion/tasks/__init__.py +33 -0
- illusion/tasks/local_agent_task.py +42 -0
- illusion/tasks/local_shell_task.py +27 -0
- illusion/tasks/manager.py +377 -0
- illusion/tasks/stop_task.py +21 -0
- illusion/tasks/types.py +88 -0
- illusion/tools/__init__.py +126 -0
- illusion/tools/agent_tool.py +388 -0
- illusion/tools/ask_user_question_tool.py +186 -0
- illusion/tools/base.py +149 -0
- illusion/tools/bash_tool.py +413 -0
- illusion/tools/config_tool.py +90 -0
- illusion/tools/cron_tool.py +473 -0
- illusion/tools/enter_plan_mode_tool.py +147 -0
- illusion/tools/enter_worktree_tool.py +188 -0
- illusion/tools/exit_plan_mode_tool.py +69 -0
- illusion/tools/exit_worktree_tool.py +225 -0
- illusion/tools/file_edit_tool.py +283 -0
- illusion/tools/file_read_tool.py +294 -0
- illusion/tools/file_write_tool.py +184 -0
- illusion/tools/glob_tool.py +165 -0
- illusion/tools/grep_tool.py +190 -0
- illusion/tools/list_mcp_resources_tool.py +80 -0
- illusion/tools/lsp_tool.py +333 -0
- illusion/tools/mcp_auth_tool.py +100 -0
- illusion/tools/mcp_tool.py +75 -0
- illusion/tools/notebook_edit_tool.py +242 -0
- illusion/tools/powershell_tool.py +334 -0
- illusion/tools/read_mcp_resource_tool.py +63 -0
- illusion/tools/repl_tool.py +100 -0
- illusion/tools/send_message_tool.py +112 -0
- illusion/tools/shell_common.py +187 -0
- illusion/tools/skill_tool.py +86 -0
- illusion/tools/sleep_tool.py +62 -0
- illusion/tools/structured_output_tool.py +58 -0
- illusion/tools/task_create_tool.py +98 -0
- illusion/tools/task_get_tool.py +94 -0
- illusion/tools/task_list_tool.py +94 -0
- illusion/tools/task_output_tool.py +55 -0
- illusion/tools/task_stop_tool.py +52 -0
- illusion/tools/task_update_tool.py +224 -0
- illusion/tools/team_create_tool.py +236 -0
- illusion/tools/team_delete_tool.py +104 -0
- illusion/tools/todo_write_tool.py +198 -0
- illusion/tools/tool_search_tool.py +156 -0
- illusion/tools/web_fetch_tool.py +264 -0
- illusion/tools/web_search_tool.py +186 -0
- illusion/ui/__init__.py +23 -0
- illusion/ui/app.py +258 -0
- illusion/ui/backend_host.py +1180 -0
- illusion/ui/input.py +86 -0
- illusion/ui/output.py +363 -0
- illusion/ui/permission_dialog.py +47 -0
- illusion/ui/permission_store.py +99 -0
- illusion/ui/protocol.py +384 -0
- illusion/ui/react_launcher.py +280 -0
- illusion/ui/runtime.py +787 -0
- illusion/ui/textual_app.py +603 -0
- illusion/ui/web/__init__.py +10 -0
- illusion/ui/web/server.py +87 -0
- illusion/ui/web/ws_host.py +1197 -0
- illusion/utils/__init__.py +0 -0
- illusion/utils/ripgrep.py +299 -0
- illusion/utils/shell.py +248 -0
- illusion_code-0.1.0.dist-info/METADATA +1159 -0
- illusion_code-0.1.0.dist-info/RECORD +214 -0
- illusion_code-0.1.0.dist-info/WHEEL +4 -0
- illusion_code-0.1.0.dist-info/entry_points.txt +2 -0
- illusion_code-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""文件历史快照模块
|
|
2
|
+
================
|
|
3
|
+
|
|
4
|
+
本模块提供基于文件复制的快照管理,用于支持 /rewind 指令的文件回退。
|
|
5
|
+
|
|
6
|
+
参考 Claude Code 的 copy-on-write 方案:在工具修改文件前备份其内容,
|
|
7
|
+
rewind 时从备份恢复。不依赖 git,可跟踪任意路径的文件。
|
|
8
|
+
|
|
9
|
+
存储位置:~/.illusion/data/file-history/{session_id}/{sha256(path)[:16]}@v{N}
|
|
10
|
+
|
|
11
|
+
主要函数:
|
|
12
|
+
- track_edit: 在工具修改文件前备份(copy-on-write)
|
|
13
|
+
- make_snapshot: 创建快照边界(每条用户消息一次)
|
|
14
|
+
- rewind_to: 回退到指定快照,恢复文件
|
|
15
|
+
|
|
16
|
+
使用示例:
|
|
17
|
+
>>> from illusion.services.file_history import FileHistoryState
|
|
18
|
+
>>> state = FileHistoryState(session_id="abc123", cwd="/project")
|
|
19
|
+
>>> track_edit(state, "/project/file.py")
|
|
20
|
+
>>> make_snapshot(state, "msg-uuid-1")
|
|
21
|
+
>>> rewind_to(state, "msg-uuid-1")
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import shutil
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from hashlib import sha256
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from illusion.config.paths import get_config_dir
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class FileBackup:
|
|
36
|
+
"""单个文件的备份记录。"""
|
|
37
|
+
backup_name: str | None # 备份文件名,None 表示文件当时不存在
|
|
38
|
+
version: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class FileSnapshot:
|
|
43
|
+
"""一个快照:关联到一条用户消息,包含所有跟踪文件的备份映射。"""
|
|
44
|
+
message_id: str
|
|
45
|
+
turn_index: int = 0 # 轮次索引(0-based)
|
|
46
|
+
tracked_backups: dict[str, FileBackup] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class FileHistoryState:
|
|
51
|
+
"""文件历史状态。"""
|
|
52
|
+
session_id: str
|
|
53
|
+
cwd: str
|
|
54
|
+
snapshots: list[FileSnapshot] = field(default_factory=list)
|
|
55
|
+
tracked_files: set[str] = field(default_factory=set)
|
|
56
|
+
_turn_counter: int = 0 # 内部轮次计数器
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _backup_dir(session_id: str) -> Path:
|
|
60
|
+
"""返回备份存储目录。"""
|
|
61
|
+
return get_config_dir() / "file-history" / session_id
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _backup_name(file_path: str, version: int = 1) -> str:
|
|
65
|
+
"""生成备份文件名:sha256(路径@vN)[:16]。不同版本生成不同文件名。"""
|
|
66
|
+
return sha256(f"{file_path}@v{version}".encode("utf-8")).hexdigest()[:16]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _backup_path(session_id: str, backup_name: str) -> Path:
|
|
70
|
+
"""返回备份文件的完整路径。"""
|
|
71
|
+
return _backup_dir(session_id) / backup_name
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_path(file_path: str, cwd: str) -> str:
|
|
75
|
+
"""将路径转为绝对路径。"""
|
|
76
|
+
p = Path(file_path)
|
|
77
|
+
if p.is_absolute():
|
|
78
|
+
return str(p)
|
|
79
|
+
return str(Path(cwd) / file_path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def track_edit(state: FileHistoryState, file_path: str) -> None:
|
|
83
|
+
"""在工具修改文件前备份(copy-on-write)。
|
|
84
|
+
|
|
85
|
+
如果文件在当前快照中已被跟踪,跳过。否则:
|
|
86
|
+
- 文件存在:复制到备份目录
|
|
87
|
+
- 文件不存在:记录 None(标记为"文件当时不存在")
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
state: 文件历史状态
|
|
91
|
+
file_path: 即将被修改的文件路径
|
|
92
|
+
"""
|
|
93
|
+
abs_path = _resolve_path(file_path, state.cwd)
|
|
94
|
+
tracking_key = abs_path # 使用绝对路径作为 key
|
|
95
|
+
|
|
96
|
+
# 检查是否已在当前快照中跟踪
|
|
97
|
+
if state.snapshots:
|
|
98
|
+
current = state.snapshots[-1]
|
|
99
|
+
if tracking_key in current.tracked_backups:
|
|
100
|
+
return # 已跟踪,跳过
|
|
101
|
+
|
|
102
|
+
# 确定版本号:查找最近快照中此文件的备份版本
|
|
103
|
+
version = 1
|
|
104
|
+
for snap in reversed(state.snapshots):
|
|
105
|
+
if tracking_key in snap.tracked_backups:
|
|
106
|
+
version = snap.tracked_backups[tracking_key].version + 1
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
# 创建备份
|
|
110
|
+
bname = _backup_name(abs_path, version)
|
|
111
|
+
bpath = _backup_path(state.session_id, bname)
|
|
112
|
+
|
|
113
|
+
if Path(abs_path).exists():
|
|
114
|
+
# 文件存在:复制备份
|
|
115
|
+
bpath.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
shutil.copy2(abs_path, bpath)
|
|
117
|
+
backup = FileBackup(backup_name=bname, version=version)
|
|
118
|
+
else:
|
|
119
|
+
# 文件不存在(新文件)
|
|
120
|
+
backup = FileBackup(backup_name=None, version=version)
|
|
121
|
+
|
|
122
|
+
# 记录到当前快照
|
|
123
|
+
if state.snapshots:
|
|
124
|
+
state.snapshots[-1].tracked_backups[tracking_key] = backup
|
|
125
|
+
|
|
126
|
+
state.tracked_files.add(tracking_key)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def make_snapshot(state: FileHistoryState, message_id: str) -> None:
|
|
130
|
+
"""创建快照边界。
|
|
131
|
+
|
|
132
|
+
在用户发送消息时调用,为后续的工具编辑创建新的跟踪空间。
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
state: 文件历史状态
|
|
136
|
+
message_id: 关联的消息 ID
|
|
137
|
+
"""
|
|
138
|
+
snapshot = FileSnapshot(message_id=message_id, turn_index=state._turn_counter)
|
|
139
|
+
state._turn_counter += 1
|
|
140
|
+
state.snapshots.append(snapshot)
|
|
141
|
+
# 最多保留 50 个快照
|
|
142
|
+
if len(state.snapshots) > 50:
|
|
143
|
+
evicted = state.snapshots[:-50]
|
|
144
|
+
state.snapshots = state.snapshots[-50:]
|
|
145
|
+
# 清理被驱逐快照的备份文件
|
|
146
|
+
_cleanup_evicted(state, evicted)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def rewind_to(state: FileHistoryState, turn_index: int) -> list[str]:
|
|
150
|
+
"""撤销指定轮次的所有文件修改,并移除该轮及之后的快照。
|
|
151
|
+
|
|
152
|
+
快照的备份记录的是工具执行前的文件状态,所以用目标快照自身的备份
|
|
153
|
+
来恢复文件,就能撤销该轮工具对文件的修改。
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
state: 文件历史状态
|
|
157
|
+
turn_index: 要撤销的轮次索引(0-based)
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
list[str]: 被恢复的文件路径列表
|
|
161
|
+
"""
|
|
162
|
+
# 找到目标快照(turn_index 匹配的最后一个)
|
|
163
|
+
target = None
|
|
164
|
+
target_idx = -1
|
|
165
|
+
for i, snap in enumerate(state.snapshots):
|
|
166
|
+
if snap.turn_index == turn_index:
|
|
167
|
+
target = snap
|
|
168
|
+
target_idx = i
|
|
169
|
+
if target is None:
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
changed: list[str] = []
|
|
173
|
+
for tracking_key in state.tracked_files:
|
|
174
|
+
backup = target.tracked_backups.get(tracking_key)
|
|
175
|
+
if backup is None:
|
|
176
|
+
# 目标快照没有此文件的备份,找最早的备份
|
|
177
|
+
backup = _find_first_backup(state, tracking_key)
|
|
178
|
+
if backup is None:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
if backup.backup_name is None:
|
|
182
|
+
# 文件当时不存在:删除
|
|
183
|
+
p = Path(tracking_key)
|
|
184
|
+
if p.exists():
|
|
185
|
+
p.unlink()
|
|
186
|
+
changed.append(tracking_key)
|
|
187
|
+
else:
|
|
188
|
+
# 从备份恢复(备份内容 = 工具执行前的状态)
|
|
189
|
+
bpath = _backup_path(state.session_id, backup.backup_name)
|
|
190
|
+
if bpath.exists():
|
|
191
|
+
p = Path(tracking_key)
|
|
192
|
+
current_content = p.read_bytes() if p.exists() else None
|
|
193
|
+
backup_content = bpath.read_bytes()
|
|
194
|
+
if current_content != backup_content:
|
|
195
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
shutil.copy2(bpath, tracking_key)
|
|
197
|
+
changed.append(tracking_key)
|
|
198
|
+
|
|
199
|
+
# 移除目标快照及之后的所有快照
|
|
200
|
+
evicted = state.snapshots[target_idx:]
|
|
201
|
+
state.snapshots = state.snapshots[:target_idx]
|
|
202
|
+
_cleanup_evicted(state, evicted)
|
|
203
|
+
|
|
204
|
+
return changed
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _find_first_backup(state: FileHistoryState, tracking_key: str) -> FileBackup | None:
|
|
208
|
+
"""找到文件的最早备份。"""
|
|
209
|
+
for snap in state.snapshots:
|
|
210
|
+
backup = snap.tracked_backups.get(tracking_key)
|
|
211
|
+
if backup is not None:
|
|
212
|
+
return backup
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cleanup_file_history(session_id: str) -> None:
|
|
217
|
+
"""删除指定会话的文件历史目录。"""
|
|
218
|
+
d = _backup_dir(session_id)
|
|
219
|
+
if d.exists():
|
|
220
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def cleanup_all_file_histories() -> int:
|
|
224
|
+
"""删除所有文件历史目录,返回删除的目录数。"""
|
|
225
|
+
base = get_config_dir() / "file-history"
|
|
226
|
+
if not base.exists():
|
|
227
|
+
return 0
|
|
228
|
+
count = 0
|
|
229
|
+
for child in base.iterdir():
|
|
230
|
+
if child.is_dir():
|
|
231
|
+
shutil.rmtree(child, ignore_errors=True)
|
|
232
|
+
count += 1
|
|
233
|
+
# 如果 base 为空,删除它
|
|
234
|
+
try:
|
|
235
|
+
base.rmdir()
|
|
236
|
+
except OSError:
|
|
237
|
+
pass
|
|
238
|
+
return count
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _cleanup_evicted(state: FileHistoryState, evicted: list[FileSnapshot]) -> None:
|
|
242
|
+
"""清理被驱逐快照的孤立备份文件。"""
|
|
243
|
+
# 收集仍被引用的备份名
|
|
244
|
+
still_referenced: set[str] = set()
|
|
245
|
+
for snap in state.snapshots:
|
|
246
|
+
for backup in snap.tracked_backups.values():
|
|
247
|
+
if backup.backup_name:
|
|
248
|
+
still_referenced.add(backup.backup_name)
|
|
249
|
+
|
|
250
|
+
# 删除不再被引用的备份
|
|
251
|
+
for snap in evicted:
|
|
252
|
+
for backup in snap.tracked_backups.values():
|
|
253
|
+
if backup.backup_name and backup.backup_name not in still_referenced:
|
|
254
|
+
bpath = _backup_path(state.session_id, backup.backup_name)
|
|
255
|
+
try:
|
|
256
|
+
bpath.unlink(missing_ok=True)
|
|
257
|
+
except OSError:
|
|
258
|
+
pass
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""
|
|
2
|
+
轻量级代码智能辅助模块 — 用于 LSP 工具
|
|
3
|
+
====================================
|
|
4
|
+
|
|
5
|
+
本模块实现轻量级代码智能功能,比完整的语言服务器集成更小。
|
|
6
|
+
为 Python 源文件提供稳定的只读操作,使模型能够执行类似 Claude Code 工作流程中的定义、引用、悬停和符号查询。
|
|
7
|
+
|
|
8
|
+
主要功能:
|
|
9
|
+
- 列出文档符号
|
|
10
|
+
- 工作区符号搜索
|
|
11
|
+
- 跳转到定义
|
|
12
|
+
- 查找引用
|
|
13
|
+
- 悬停信息
|
|
14
|
+
|
|
15
|
+
类说明:
|
|
16
|
+
- SymbolLocation: 符号位置数据类
|
|
17
|
+
- list_document_symbols: 列出文档符号
|
|
18
|
+
- workspace_symbol_search: 工作区符号搜索
|
|
19
|
+
- go_to_definition: 跳转到定义
|
|
20
|
+
- find_references: 查找引用
|
|
21
|
+
- hover: 悬停信息
|
|
22
|
+
|
|
23
|
+
使用示例:
|
|
24
|
+
>>> from illusion.services.lsp import list_document_symbols, go_to_definition
|
|
25
|
+
>>> # 列出文件中的符号
|
|
26
|
+
>>> symbols = list_document_symbols(Path("src/main.py"))
|
|
27
|
+
>>> # 跳转到定义
|
|
28
|
+
>>> defs = go_to_definition(root=Path("."), file_path=Path("src/main.py"), symbol="my_function")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import ast
|
|
34
|
+
import re
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Python 文件模式
|
|
40
|
+
_PYTHON_GLOB = "*.py"
|
|
41
|
+
# 跳过的目录
|
|
42
|
+
_SKIP_PARTS = {".git", ".hg", ".svn", ".venv", "venv", "__pycache__", "node_modules"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class SymbolLocation:
|
|
47
|
+
"""工作区内的解析符号位置。"""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
kind: str
|
|
51
|
+
path: Path
|
|
52
|
+
line: int
|
|
53
|
+
character: int
|
|
54
|
+
signature: str = ""
|
|
55
|
+
docstring: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_document_symbols(path: Path) -> list[SymbolLocation]:
|
|
59
|
+
"""从 Python 源文件中返回顶层和嵌套符号。"""
|
|
60
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
61
|
+
symbols: list[SymbolLocation] = []
|
|
62
|
+
_collect_symbols(tree, path, symbols, parent=None)
|
|
63
|
+
return symbols
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def workspace_symbol_search(root: Path, query: str) -> list[SymbolLocation]:
|
|
67
|
+
"""返回名称包含 query 的符号。"""
|
|
68
|
+
needle = query.lower().strip()
|
|
69
|
+
if not needle:
|
|
70
|
+
return []
|
|
71
|
+
matches: list[SymbolLocation] = []
|
|
72
|
+
for file_path in iter_python_files(root):
|
|
73
|
+
for symbol in list_document_symbols(file_path):
|
|
74
|
+
if needle in symbol.name.lower():
|
|
75
|
+
matches.append(symbol)
|
|
76
|
+
return matches
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def go_to_definition(
|
|
80
|
+
*,
|
|
81
|
+
root: Path,
|
|
82
|
+
file_path: Path,
|
|
83
|
+
symbol: str | None = None,
|
|
84
|
+
line: int | None = None,
|
|
85
|
+
character: int | None = None,
|
|
86
|
+
) -> list[SymbolLocation]:
|
|
87
|
+
"""解析符号的可能定义。"""
|
|
88
|
+
target = symbol or extract_symbol_at_position(file_path, line=line, character=character)
|
|
89
|
+
if not target:
|
|
90
|
+
return []
|
|
91
|
+
matches: list[SymbolLocation] = []
|
|
92
|
+
for candidate in iter_python_files(root):
|
|
93
|
+
for item in list_document_symbols(candidate):
|
|
94
|
+
if item.name == target:
|
|
95
|
+
matches.append(item)
|
|
96
|
+
return matches
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def find_references(
|
|
100
|
+
*,
|
|
101
|
+
root: Path,
|
|
102
|
+
file_path: Path,
|
|
103
|
+
symbol: str | None = None,
|
|
104
|
+
line: int | None = None,
|
|
105
|
+
character: int | None = None,
|
|
106
|
+
) -> list[tuple[Path, int, str]]:
|
|
107
|
+
"""返回符号的行方向引用。"""
|
|
108
|
+
target = symbol or extract_symbol_at_position(file_path, line=line, character=character)
|
|
109
|
+
if not target:
|
|
110
|
+
return []
|
|
111
|
+
pattern = re.compile(rf"\b{re.escape(target)}\b")
|
|
112
|
+
matches: list[tuple[Path, int, str]] = []
|
|
113
|
+
for candidate in iter_python_files(root):
|
|
114
|
+
for lineno, raw_line in enumerate(candidate.read_text(encoding="utf-8").splitlines(), start=1):
|
|
115
|
+
if pattern.search(raw_line):
|
|
116
|
+
matches.append((candidate, lineno, raw_line.strip()))
|
|
117
|
+
return matches
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def hover(
|
|
121
|
+
*,
|
|
122
|
+
root: Path,
|
|
123
|
+
file_path: Path,
|
|
124
|
+
symbol: str | None = None,
|
|
125
|
+
line: int | None = None,
|
|
126
|
+
character: int | None = None,
|
|
127
|
+
) -> SymbolLocation | None:
|
|
128
|
+
"""返回符号的最佳悬停目标。"""
|
|
129
|
+
matches = go_to_definition(
|
|
130
|
+
root=root,
|
|
131
|
+
file_path=file_path,
|
|
132
|
+
symbol=symbol,
|
|
133
|
+
line=line,
|
|
134
|
+
character=character,
|
|
135
|
+
)
|
|
136
|
+
return matches[0] if matches else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def extract_symbol_at_position(
|
|
140
|
+
file_path: Path,
|
|
141
|
+
*,
|
|
142
|
+
line: int | None,
|
|
143
|
+
character: int | None,
|
|
144
|
+
) -> str | None:
|
|
145
|
+
"""从 1 基数的行/字符位置提取可能的标识符。"""
|
|
146
|
+
if line is None:
|
|
147
|
+
return None
|
|
148
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
149
|
+
if line < 1 or line > len(lines):
|
|
150
|
+
return None
|
|
151
|
+
text = lines[line - 1]
|
|
152
|
+
if not text:
|
|
153
|
+
return None
|
|
154
|
+
index = max(0, min((character or 1) - 1, len(text) - 1))
|
|
155
|
+
for match in re.finditer(r"[A-Za-z_][A-Za-z0-9_]*", text):
|
|
156
|
+
if match.start() <= index < match.end():
|
|
157
|
+
return match.group(0)
|
|
158
|
+
for match in re.finditer(r"[A-Za-z_][A-Za-z0-9_]*", text):
|
|
159
|
+
return match.group(0)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def go_to_implementation(
|
|
164
|
+
*,
|
|
165
|
+
root: Path,
|
|
166
|
+
file_path: Path,
|
|
167
|
+
symbol: str | None = None,
|
|
168
|
+
line: int | None = None,
|
|
169
|
+
character: int | None = None,
|
|
170
|
+
) -> list[SymbolLocation]:
|
|
171
|
+
"""查找类或方法的子类实现。
|
|
172
|
+
|
|
173
|
+
对于类:查找继承该类的子类。
|
|
174
|
+
对于方法:查找子类中重写该方法的位置。
|
|
175
|
+
"""
|
|
176
|
+
target = symbol or extract_symbol_at_position(file_path, line=line, character=character)
|
|
177
|
+
if not target:
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
is_method = "." in target
|
|
181
|
+
base_class = target.split(".")[0] if is_method else target
|
|
182
|
+
method_name = target.split(".")[-1] if is_method else None
|
|
183
|
+
|
|
184
|
+
matches: list[SymbolLocation] = []
|
|
185
|
+
for candidate in iter_python_files(root):
|
|
186
|
+
tree = ast.parse(candidate.read_text(encoding="utf-8"), filename=str(candidate))
|
|
187
|
+
for node in ast.walk(tree):
|
|
188
|
+
if isinstance(node, ast.ClassDef):
|
|
189
|
+
for base in node.bases:
|
|
190
|
+
base_name = _get_ast_name(base)
|
|
191
|
+
if base_name and (base_name == base_class or base_name.endswith(f".{base_class}")):
|
|
192
|
+
if method_name:
|
|
193
|
+
for child in ast.iter_child_nodes(node):
|
|
194
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name == method_name:
|
|
195
|
+
matches.append(SymbolLocation(
|
|
196
|
+
name=f"{node.name}.{child.name}",
|
|
197
|
+
kind="method",
|
|
198
|
+
path=candidate,
|
|
199
|
+
line=child.lineno,
|
|
200
|
+
character=child.col_offset + 1,
|
|
201
|
+
signature=f"def {child.name}(...)",
|
|
202
|
+
docstring=ast.get_docstring(child) or "",
|
|
203
|
+
))
|
|
204
|
+
else:
|
|
205
|
+
matches.append(SymbolLocation(
|
|
206
|
+
name=node.name,
|
|
207
|
+
kind="class",
|
|
208
|
+
path=candidate,
|
|
209
|
+
line=node.lineno,
|
|
210
|
+
character=node.col_offset + 1,
|
|
211
|
+
signature=f"class {node.name}",
|
|
212
|
+
docstring=ast.get_docstring(node) or "",
|
|
213
|
+
))
|
|
214
|
+
return matches
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def prepare_call_hierarchy(
|
|
218
|
+
*,
|
|
219
|
+
root: Path,
|
|
220
|
+
file_path: Path,
|
|
221
|
+
symbol: str | None = None,
|
|
222
|
+
line: int | None = None,
|
|
223
|
+
character: int | None = None,
|
|
224
|
+
) -> SymbolLocation | None:
|
|
225
|
+
"""获取指定位置的调用层次节点。"""
|
|
226
|
+
target = symbol or extract_symbol_at_position(file_path, line=line, character=character)
|
|
227
|
+
if not target:
|
|
228
|
+
return None
|
|
229
|
+
matches = go_to_definition(root=root, file_path=file_path, symbol=target)
|
|
230
|
+
return matches[0] if matches else None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def incoming_calls(
|
|
234
|
+
*,
|
|
235
|
+
root: Path,
|
|
236
|
+
file_path: Path,
|
|
237
|
+
symbol: str | None = None,
|
|
238
|
+
line: int | None = None,
|
|
239
|
+
character: int | None = None,
|
|
240
|
+
) -> list[tuple[Path, int, str, str]]:
|
|
241
|
+
"""查找所有调用指定函数/方法的位置。
|
|
242
|
+
|
|
243
|
+
返回 (调用文件, 行号, 调用者函数名, 行文本) 列表。
|
|
244
|
+
"""
|
|
245
|
+
target = symbol or extract_symbol_at_position(file_path, line=line, character=character)
|
|
246
|
+
if not target:
|
|
247
|
+
return []
|
|
248
|
+
|
|
249
|
+
# 方法名称可能包含类前缀,调用时只用方法名
|
|
250
|
+
call_name = target.split(".")[-1]
|
|
251
|
+
|
|
252
|
+
results: list[tuple[Path, int, str, str]] = []
|
|
253
|
+
for candidate in iter_python_files(root):
|
|
254
|
+
try:
|
|
255
|
+
tree = ast.parse(candidate.read_text(encoding="utf-8"), filename=str(candidate))
|
|
256
|
+
except SyntaxError:
|
|
257
|
+
continue
|
|
258
|
+
for node in ast.walk(tree):
|
|
259
|
+
if isinstance(node, ast.Call):
|
|
260
|
+
callee = _get_ast_name(node.func)
|
|
261
|
+
if callee == target or callee == call_name:
|
|
262
|
+
caller = _find_enclosing_def(tree, node.lineno)
|
|
263
|
+
results.append((candidate, node.lineno, caller or "(module level)", _get_source_line(candidate, node.lineno)))
|
|
264
|
+
return results
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def outgoing_calls(
|
|
268
|
+
*,
|
|
269
|
+
root: Path,
|
|
270
|
+
file_path: Path,
|
|
271
|
+
symbol: str | None = None,
|
|
272
|
+
line: int | None = None,
|
|
273
|
+
character: int | None = None,
|
|
274
|
+
) -> list[tuple[str, Path, int]]:
|
|
275
|
+
"""查找指定函数/方法内部调用的所有函数。
|
|
276
|
+
|
|
277
|
+
返回 (被调用名, 定义文件, 行号) 列表。
|
|
278
|
+
"""
|
|
279
|
+
target = symbol or extract_symbol_at_position(file_path, line=line, character=character)
|
|
280
|
+
if not target:
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
|
|
284
|
+
func_node = _find_func_node(tree, target)
|
|
285
|
+
if func_node is None:
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
seen: set[str] = set()
|
|
289
|
+
results: list[tuple[str, Path, int]] = []
|
|
290
|
+
for node in ast.walk(func_node):
|
|
291
|
+
if node is func_node:
|
|
292
|
+
continue
|
|
293
|
+
if isinstance(node, ast.Call):
|
|
294
|
+
callee = _get_ast_name(node.func)
|
|
295
|
+
if callee and callee not in seen:
|
|
296
|
+
seen.add(callee)
|
|
297
|
+
# 尝试找到被调用函数的定义位置
|
|
298
|
+
defs = go_to_definition(root=root, file_path=file_path, symbol=callee)
|
|
299
|
+
if defs:
|
|
300
|
+
results.append((callee, defs[0].path, defs[0].line))
|
|
301
|
+
else:
|
|
302
|
+
results.append((callee, Path(), 0))
|
|
303
|
+
|
|
304
|
+
return results
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# 内部辅助函数
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _get_ast_name(node: ast.AST) -> str | None:
|
|
313
|
+
"""从 AST 节点提取名称字符串(支持 a.b.c 形式的属性访问)。"""
|
|
314
|
+
if isinstance(node, ast.Name):
|
|
315
|
+
return node.id
|
|
316
|
+
if isinstance(node, ast.Attribute):
|
|
317
|
+
inner = _get_ast_name(node.value)
|
|
318
|
+
if inner is not None:
|
|
319
|
+
return f"{inner}.{node.attr}"
|
|
320
|
+
return node.attr
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _find_enclosing_def(tree: ast.AST, lineno: int) -> str | None:
|
|
325
|
+
"""按行号查找所在的函数/类定义名。"""
|
|
326
|
+
best: str | None = None
|
|
327
|
+
best_end: int = -1
|
|
328
|
+
for node in ast.walk(tree):
|
|
329
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
330
|
+
end = node.end_lineno or node.lineno
|
|
331
|
+
if node.lineno <= lineno <= end and end > best_end:
|
|
332
|
+
best_end = end
|
|
333
|
+
best = node.name
|
|
334
|
+
return best
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _find_func_node(tree: ast.AST, target: str) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
|
|
338
|
+
"""在 AST 树中按名称或 ClassName.method 形式查找函数节点。"""
|
|
339
|
+
parts = target.split(".")
|
|
340
|
+
func_name = parts[-1]
|
|
341
|
+
class_name = parts[0] if len(parts) >= 2 else None
|
|
342
|
+
|
|
343
|
+
for node in ast.walk(tree):
|
|
344
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
345
|
+
if node.name == func_name:
|
|
346
|
+
if class_name:
|
|
347
|
+
# 检查是否在正确的类内
|
|
348
|
+
for parent in ast.walk(tree):
|
|
349
|
+
if isinstance(parent, ast.ClassDef) and parent.name == class_name:
|
|
350
|
+
# 检查 node 是否是 parent 的子节点
|
|
351
|
+
for child in ast.walk(parent):
|
|
352
|
+
if child is node:
|
|
353
|
+
return node
|
|
354
|
+
else:
|
|
355
|
+
return node
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _get_source_line(file_path: Path, lineno: int) -> str:
|
|
360
|
+
"""读取文件指定行的文本。"""
|
|
361
|
+
try:
|
|
362
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
363
|
+
if 1 <= lineno <= len(lines):
|
|
364
|
+
return lines[lineno - 1].strip()
|
|
365
|
+
except OSError:
|
|
366
|
+
pass
|
|
367
|
+
return ""
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def iter_python_files(root: Path) -> list[Path]:
|
|
371
|
+
"""按稳定顺序返回 Python 源文件列表。"""
|
|
372
|
+
files: list[Path] = []
|
|
373
|
+
for path in root.rglob(_PYTHON_GLOB):
|
|
374
|
+
if any(part in _SKIP_PARTS for part in path.parts):
|
|
375
|
+
continue
|
|
376
|
+
if path.is_file():
|
|
377
|
+
files.append(path)
|
|
378
|
+
files.sort()
|
|
379
|
+
return files
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _collect_symbols(
|
|
383
|
+
node: ast.AST,
|
|
384
|
+
path: Path,
|
|
385
|
+
bucket: list[SymbolLocation],
|
|
386
|
+
*,
|
|
387
|
+
parent: str | None,
|
|
388
|
+
) -> None:
|
|
389
|
+
"""递归收集 AST 中的符号。"""
|
|
390
|
+
for child in ast.iter_child_nodes(node):
|
|
391
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
392
|
+
# 函数定义
|
|
393
|
+
name = f"{parent}.{child.name}" if parent else child.name
|
|
394
|
+
args = [arg.arg for arg in child.args.args]
|
|
395
|
+
signature = f"def {child.name}({', '.join(args)})"
|
|
396
|
+
bucket.append(
|
|
397
|
+
SymbolLocation(
|
|
398
|
+
name=name,
|
|
399
|
+
kind="function",
|
|
400
|
+
path=path,
|
|
401
|
+
line=child.lineno,
|
|
402
|
+
character=child.col_offset + 1,
|
|
403
|
+
signature=signature,
|
|
404
|
+
docstring=ast.get_docstring(child) or "",
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
_collect_symbols(child, path, bucket, parent=name)
|
|
408
|
+
elif isinstance(child, ast.ClassDef):
|
|
409
|
+
# 类定义
|
|
410
|
+
name = f"{parent}.{child.name}" if parent else child.name
|
|
411
|
+
bucket.append(
|
|
412
|
+
SymbolLocation(
|
|
413
|
+
name=name,
|
|
414
|
+
kind="class",
|
|
415
|
+
path=path,
|
|
416
|
+
line=child.lineno,
|
|
417
|
+
character=child.col_offset + 1,
|
|
418
|
+
signature=f"class {child.name}",
|
|
419
|
+
docstring=ast.get_docstring(child) or "",
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
_collect_symbols(child, path, bucket, parent=name)
|
|
423
|
+
elif isinstance(child, ast.Assign):
|
|
424
|
+
# 变量赋值
|
|
425
|
+
for target in child.targets:
|
|
426
|
+
if isinstance(target, ast.Name):
|
|
427
|
+
name = f"{parent}.{target.id}" if parent else target.id
|
|
428
|
+
bucket.append(
|
|
429
|
+
SymbolLocation(
|
|
430
|
+
name=name,
|
|
431
|
+
kind="variable",
|
|
432
|
+
path=path,
|
|
433
|
+
line=target.lineno,
|
|
434
|
+
character=target.col_offset + 1,
|
|
435
|
+
signature=f"{target.id} = ...",
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
_collect_symbols(child, path, bucket, parent=parent)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
__all__ = [
|
|
443
|
+
"SymbolLocation",
|
|
444
|
+
"extract_symbol_at_position",
|
|
445
|
+
"find_references",
|
|
446
|
+
"go_to_definition",
|
|
447
|
+
"go_to_implementation",
|
|
448
|
+
"hover",
|
|
449
|
+
"incoming_calls",
|
|
450
|
+
"iter_python_files",
|
|
451
|
+
"list_document_symbols",
|
|
452
|
+
"outgoing_calls",
|
|
453
|
+
"prepare_call_hierarchy",
|
|
454
|
+
"workspace_symbol_search",
|
|
455
|
+
]
|