super-code-assistant 3.3.6__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.
- commands/__init__.py +859 -0
- core/__init__.py +0 -0
- core/config.py +263 -0
- core/config_template.json +7 -0
- core/context.py +271 -0
- core/engine.py +635 -0
- core/file_state.py +279 -0
- core/llm.py +309 -0
- core/model_capabilities.py +45 -0
- core/permissions.py +204 -0
- core/sandbox/__init__.py +15 -0
- core/sandbox/blacklist.py +176 -0
- core/sandbox/config.py +38 -0
- core/sandbox/network.py +136 -0
- core/sandbox/path_protection.py +126 -0
- core/session.py +295 -0
- core/tool.py +45 -0
- features/__init__.py +0 -0
- features/compact.py +945 -0
- features/coordinator.py +105 -0
- features/cost_tracker.py +184 -0
- features/extract_memories.py +326 -0
- features/find_relevant_memories.py +376 -0
- features/git_ai.py +256 -0
- features/memory.py +531 -0
- features/memory_age.py +66 -0
- features/memory_scan.py +153 -0
- features/memory_types.py +34 -0
- features/plan.py +327 -0
- features/skills.py +300 -0
- features/worker_manager.py +232 -0
- mcp/__init__.py +0 -0
- mcp/client.py +112 -0
- mcp/loader.py +80 -0
- mcp/tool_proxy.py +59 -0
- super_code_assistant-3.3.6.dist-info/METADATA +45 -0
- super_code_assistant-3.3.6.dist-info/RECORD +61 -0
- super_code_assistant-3.3.6.dist-info/WHEEL +5 -0
- super_code_assistant-3.3.6.dist-info/entry_points.txt +2 -0
- super_code_assistant-3.3.6.dist-info/top_level.txt +7 -0
- tools/__init__.py +21 -0
- tools/agent.py +132 -0
- tools/ask_user.py +111 -0
- tools/bash.py +77 -0
- tools/file_edit.py +269 -0
- tools/file_read.py +206 -0
- tools/file_write.py +78 -0
- tools/glob_tool.py +81 -0
- tools/grep_tool.py +134 -0
- tools/plan_tools.py +75 -0
- tools/skill.py +108 -0
- tools/tool.py +44 -0
- tools/web_fetch.py +129 -0
- tools/web_search.py +220 -0
- tui/__init__.py +0 -0
- tui/app.py +726 -0
- tui/clipboard_image.py +42 -0
- tui/keylistener.py +140 -0
- tui/prompt.py +752 -0
- tui/query.py +200 -0
- tui/rendering.py +135 -0
features/memory_scan.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""扫描记忆目录,输出 header 清单(filename + mtime + description + type)。
|
|
2
|
+
|
|
3
|
+
本模块只负责扫描与格式化,不做选择 / 写入 / 注入;为下游 Step 5(后台抽取)和
|
|
4
|
+
Step 6(相关性精选)提供共享原语。
|
|
5
|
+
|
|
6
|
+
为什么只读前 30 行:
|
|
7
|
+
单个记忆文件可能很大;我们只需要顶部的 YAML frontmatter,按行读到 30 行即可截断,
|
|
8
|
+
避免把全量内容拉进内存。
|
|
9
|
+
|
|
10
|
+
为什么按 mtime 倒序 + 上限 200:
|
|
11
|
+
记忆目录会随时间累积,老旧记忆相关性低。倒序 + 截断让 Step 6 的
|
|
12
|
+
side-query manifest 体积可控。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# 复用 skills 模块已有的极简 YAML frontmatter 解析器(无 PyYAML 依赖)。
|
|
21
|
+
# 该函数虽以下划线开头属"模块私有",但项目内多处复用属于既定约定;
|
|
22
|
+
# 此处显式 import 保证与 SKILL.md 解析行为完全一致(包括续行处理)。
|
|
23
|
+
from features.skills import _parse_frontmatter
|
|
24
|
+
from features.memory_types import MemoryType, parse_memory_type
|
|
25
|
+
|
|
26
|
+
# 单次扫描返回的最大文件数。超出后按 mtime 倒序截断。
|
|
27
|
+
MAX_MEMORY_FILES = 200
|
|
28
|
+
|
|
29
|
+
# 只读取文件开头多少行用于解析 frontmatter。YAML frontmatter 紧贴文件首部,
|
|
30
|
+
# 30 行足以覆盖最复杂的多字段续行场景,同时显著降低 IO。
|
|
31
|
+
FRONTMATTER_MAX_LINES = 30
|
|
32
|
+
|
|
33
|
+
# MEMORY.md 是"索引"而非"记忆",由 features.memory.load_memory_index 单独处理,
|
|
34
|
+
# 这里必须排除以免被 Step 6 的 selector 误当作可选条目。
|
|
35
|
+
ENTRYPOINT_NAME = "MEMORY.md"
|
|
36
|
+
|
|
37
|
+
# KAIROS 模式(features.memory 已实现)下,logs/YYYY/MM/*.md 是 append-only 日志,
|
|
38
|
+
# 没有 frontmatter,扫描进来全是噪音;显式跳过该子树。
|
|
39
|
+
_LOGS_DIRNAME = "logs"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class MemoryHeader:
|
|
44
|
+
"""单条记忆的 header 摘要,不含正文。
|
|
45
|
+
|
|
46
|
+
- filename: 相对 memory_dir 的 POSIX 路径(跨平台稳定,便于在 prompt 中展示)
|
|
47
|
+
- file_path: 绝对路径,供下游真正打开文件
|
|
48
|
+
- mtime_ms: 毫秒级时间戳(与 JS Date.now() 对齐,方便后续 Step 4 memory_age 复用)
|
|
49
|
+
- description / type: 从 frontmatter 提取,缺失即 None(不报错)
|
|
50
|
+
"""
|
|
51
|
+
filename: str
|
|
52
|
+
file_path: Path
|
|
53
|
+
mtime_ms: float
|
|
54
|
+
description: str | None
|
|
55
|
+
type: MemoryType | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_frontmatter_only(path: Path, max_lines: int = FRONTMATTER_MAX_LINES) -> str:
|
|
59
|
+
"""只读前 max_lines 行。逐行迭代 + 早停,避免把整个文件加载到内存。"""
|
|
60
|
+
chunks: list[str] = []
|
|
61
|
+
# errors="replace" 与 features.memory.load_memory_index 行为保持一致:
|
|
62
|
+
# 编码异常不应让整个扫描流程中断。
|
|
63
|
+
with path.open("r", encoding="utf-8", errors="replace") as f:
|
|
64
|
+
for i, line in enumerate(f):
|
|
65
|
+
if i >= max_lines:
|
|
66
|
+
break
|
|
67
|
+
chunks.append(line)
|
|
68
|
+
return "".join(chunks)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def scan_memory_files(memory_dir: Path) -> list[MemoryHeader]:
|
|
72
|
+
"""递归扫描 memory_dir 下所有 .md 文件并返回 header 列表。
|
|
73
|
+
|
|
74
|
+
排除规则:
|
|
75
|
+
1. 顶层及子目录中的 MEMORY.md(索引文件)
|
|
76
|
+
2. logs/ 子树(KAIROS append-only 日志)
|
|
77
|
+
3. 单文件读取异常(OSError)—— 静默跳过,不影响其它文件
|
|
78
|
+
|
|
79
|
+
返回:按 mtime 倒序、最多 MAX_MEMORY_FILES 条。
|
|
80
|
+
目录不存在 → 返回空列表(首次启动场景)。
|
|
81
|
+
"""
|
|
82
|
+
if not memory_dir.exists():
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
headers: list[MemoryHeader] = []
|
|
86
|
+
# rglob 在不存在的目录上会抛 FileNotFoundError;已在上面 exists() 守卫过
|
|
87
|
+
for path in memory_dir.rglob("*.md"):
|
|
88
|
+
# 排除索引文件(无论在哪一层)
|
|
89
|
+
if path.name == ENTRYPOINT_NAME:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
# 计算相对路径;理论上 rglob 出来的 path 必然在 memory_dir 之下,
|
|
93
|
+
# try/except 是对符号链接 / 边界情况的兜底
|
|
94
|
+
try:
|
|
95
|
+
relative = path.relative_to(memory_dir)
|
|
96
|
+
except ValueError:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# 排除 logs/ 子树(取顶层目录名判断,深度无关)
|
|
100
|
+
if relative.parts and relative.parts[0] == _LOGS_DIRNAME:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
stat = path.stat()
|
|
105
|
+
head_text = _read_frontmatter_only(path)
|
|
106
|
+
except OSError:
|
|
107
|
+
# 文件被并发删除 / 权限不足 / 损坏,跳过即可
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# _parse_frontmatter 在无 frontmatter 时返回 ({}, full_text),不会抛异常
|
|
111
|
+
meta, _body = _parse_frontmatter(head_text)
|
|
112
|
+
|
|
113
|
+
# description 在 frontmatter 中可能是 str / bool / list(_parse_frontmatter 会做类型推断);
|
|
114
|
+
# 这里只接受 str 且非空,其它情况降级为 None
|
|
115
|
+
desc_raw = meta.get("description")
|
|
116
|
+
description = desc_raw.strip() if isinstance(desc_raw, str) and desc_raw.strip() else None
|
|
117
|
+
|
|
118
|
+
headers.append(
|
|
119
|
+
MemoryHeader(
|
|
120
|
+
filename=relative.as_posix(),
|
|
121
|
+
file_path=path,
|
|
122
|
+
# st_mtime 是 float 秒,× 1000 得毫秒;保持 float 不强转 int,
|
|
123
|
+
# 让 Step 4 memory_age 在做天数差时不会因精度丢失
|
|
124
|
+
mtime_ms=stat.st_mtime * 1000.0,
|
|
125
|
+
description=description,
|
|
126
|
+
type=parse_memory_type(meta.get("type")),
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
headers.sort(key=lambda h: h.mtime_ms, reverse=True)
|
|
131
|
+
return headers[:MAX_MEMORY_FILES]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def format_memory_manifest(memories: list[MemoryHeader]) -> str:
|
|
135
|
+
"""格式化为单行清单文本,给 Step 5/6 的 LLM prompt 用。
|
|
136
|
+
|
|
137
|
+
输出样例(每行一条,无尾空行)::
|
|
138
|
+
|
|
139
|
+
- [feedback] feedback_pytest.md (2026-06-08T12:34:56+00:00): use pytest, not unittest
|
|
140
|
+
- notes.md (2026-06-01T09:00:00+00:00)
|
|
141
|
+
|
|
142
|
+
缺 type → 不带 `[xxx]` 前缀;缺 description → 不带末尾冒号。
|
|
143
|
+
"""
|
|
144
|
+
lines: list[str] = []
|
|
145
|
+
for m in memories:
|
|
146
|
+
tag = f"[{m.type}] " if m.type else ""
|
|
147
|
+
# UTC ISO 时间戳,秒级精度,毫秒级精度对模型来说是噪音。
|
|
148
|
+
ts = datetime.fromtimestamp(m.mtime_ms / 1000.0, tz=timezone.utc).isoformat(timespec="seconds")
|
|
149
|
+
if m.description:
|
|
150
|
+
lines.append(f"- {tag}{m.filename} ({ts}): {m.description}")
|
|
151
|
+
else:
|
|
152
|
+
lines.append(f"- {tag}{m.filename} ({ts})")
|
|
153
|
+
return "\n".join(lines)
|
features/memory_types.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""记忆类型枚举与 frontmatter `type` 字段解析。
|
|
2
|
+
|
|
3
|
+
将四种合法类型 (`user / feedback / project / reference`) 收敛在一处,
|
|
4
|
+
给后续的扫描器 (memory_scan)、相关性精选 (find_relevant_memories)、
|
|
5
|
+
抽取 (extract_memories) 共用,避免到处散落 magic string。
|
|
6
|
+
|
|
7
|
+
设计要点:
|
|
8
|
+
- 未知 / 缺失 `type` 字段 → 返回 None,而不是抛异常。盘上的旧记忆
|
|
9
|
+
几乎都没有 `type` 字段,强校验会让它们整体从扫描结果中消失。
|
|
10
|
+
- 区分大小写匹配。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
# 类型别名:受限于 Literal 的四种合法值
|
|
17
|
+
MemoryType = Literal["user", "feedback", "project", "reference"]
|
|
18
|
+
|
|
19
|
+
# 元组形式便于运行时遍历(Literal 自身不可迭代)
|
|
20
|
+
MEMORY_TYPES: tuple[MemoryType, ...] = ("user", "feedback", "project", "reference")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_memory_type(raw: object) -> MemoryType | None:
|
|
24
|
+
"""把任意 frontmatter 字段值解析为 MemoryType,未知 / 非法返回 None。
|
|
25
|
+
|
|
26
|
+
传入 object 而非 str,是因为调用方拿到的通常是 dict.get() 的结果,
|
|
27
|
+
类型未知;在这里集中做类型守卫比每个调用点 isinstance 干净。
|
|
28
|
+
"""
|
|
29
|
+
if not isinstance(raw, str):
|
|
30
|
+
return None
|
|
31
|
+
if raw in MEMORY_TYPES:
|
|
32
|
+
# mypy 无法从 `in MEMORY_TYPES` 收窄到 Literal,需 cast
|
|
33
|
+
return raw # type: ignore[return-value]
|
|
34
|
+
return None
|
features/plan.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Plan mode — explore-before-implement workflow."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import random
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from core.engine import Engine
|
|
10
|
+
from core.tool import Tool
|
|
11
|
+
from core.permissions import PermissionChecker
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Slug generation
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
_ADJECTIVES = [
|
|
18
|
+
"amber", "azure", "bold", "bright", "calm", "clear", "cool", "crisp",
|
|
19
|
+
"dark", "deep", "eager", "fair", "fast", "fierce", "gentle", "golden",
|
|
20
|
+
"lucky", "merry", "noble", "pale", "proud", "quick", "quiet", "sharp",
|
|
21
|
+
"silent", "sleek", "soft", "steady", "swift", "warm", "wild", "wise",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_NOUNS = [
|
|
25
|
+
"arrow", "brook", "castle", "cloud", "comet", "coral", "crane", "dawn",
|
|
26
|
+
"delta", "dove", "dream", "eagle", "ember", "falcon", "fern", "flame",
|
|
27
|
+
"forge", "frost", "harbor", "hawk", "hill", "island", "lake", "leaf",
|
|
28
|
+
"maple", "moon", "ocean", "peak", "pine", "river", "shore", "spark",
|
|
29
|
+
"stone", "storm", "summit", "trail", "valley", "wave", "willow",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _generate_slug() -> str:
|
|
34
|
+
"""生成 plan 文件名(形如 lucky-conjuring-island)。"""
|
|
35
|
+
return f"{random.choice(_ADJECTIVES)}-{random.choice(_NOUNS)}-{random.choice(_NOUNS)}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_plans_dir() -> Path:
|
|
39
|
+
"""返回 plan 文件存储目录。"""
|
|
40
|
+
plans_dir = Path.home() / ".config" / "super-code" / "plans"
|
|
41
|
+
plans_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
return plans_dir
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# PlanModeManager
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
class PlanModeManager:
|
|
50
|
+
"""管理 plan mode 的生命周期:进入、退出、文件管理、提示词注入。
|
|
51
|
+
|
|
52
|
+
先创建 PlanModeManager,再通过 bind_engine() 绑定 engine,
|
|
53
|
+
避免循环依赖(engine 需要 plan_tools,plan_tools 需要 manager)。
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self._engine: Engine | None = None # 主 engine
|
|
58
|
+
self._permissions: PermissionChecker | None = None # 权限检查器
|
|
59
|
+
self._active: bool = False # 是否处于 plan mode
|
|
60
|
+
self._plan_file: Path | None = None # 当前 plan 文件路径
|
|
61
|
+
self._saved_tools: list[Tool] | None = None # 进入前保存的工具列表
|
|
62
|
+
self._saved_prompt: str | None = None # 进入前保存的系统提示词
|
|
63
|
+
|
|
64
|
+
def bind_engine(self, engine: Engine) -> None:
|
|
65
|
+
"""绑定主 engine(构造后调用,避免循环依赖)。"""
|
|
66
|
+
self._engine = engine
|
|
67
|
+
|
|
68
|
+
def set_permissions(self, permissions: PermissionChecker) -> None:
|
|
69
|
+
"""设置权限检查器。"""
|
|
70
|
+
self._permissions = permissions
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_active(self) -> bool:
|
|
74
|
+
return self._active
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def plan_file_path(self) -> str | None:
|
|
78
|
+
return str(self._plan_file) if self._plan_file else None
|
|
79
|
+
|
|
80
|
+
def _sanitize_plan_tool_history(self, normal_tool_names: set[str]) -> None:
|
|
81
|
+
"""清理历史消息中 plan 专属工具的 tool_use/tool_result,避免退出后 API 报错。
|
|
82
|
+
|
|
83
|
+
OpenAI API 要求:assistant 消息中 tool_calls 引用的工具名,必须出现在本次请求的
|
|
84
|
+
tools 参数中。plan 专属工具(如 EnterPlanMode/ExitPlanMode)退出后已从工具集移除,
|
|
85
|
+
若历史中仍有其 tool_use/tool_result,API 会因找不到对应 schema 而报错或挂起。
|
|
86
|
+
|
|
87
|
+
关键约束:此方法可能在工具执行过程中被调用(例如 LLM 调用 ExitPlanModeTool,
|
|
88
|
+
ExitPlanModeTool.execute() 触发 exit(),此时 ExitPlanMode 的 tool_use 已在历史中,
|
|
89
|
+
但其 tool_result 尚未追加)。若此时把该 tool_use 转为 text,engine 随后追加的
|
|
90
|
+
tool_result 就会出现在没有 tool_calls 的 assistant 消息之后,触发 API 400 错误:
|
|
91
|
+
"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'"
|
|
92
|
+
|
|
93
|
+
因此只清理那些在历史中**已有对应 tool_result** 的 plan 专属工具对,
|
|
94
|
+
对于只有 tool_use 尚无 tool_result 的(正在执行中的调用),保持原样不动。
|
|
95
|
+
|
|
96
|
+
处理策略:
|
|
97
|
+
- 先收集历史中所有 tool_result 的 tool_use_id(已完成的调用集合)
|
|
98
|
+
- assistant 消息:只将"已完成"的 plan 专属工具 tool_use 转为 text block
|
|
99
|
+
- user 消息(tool_results):移除对应的 plan 专属 tool_result 条目;
|
|
100
|
+
若该 user 消息的所有条目都被移除,则整条消息也删除
|
|
101
|
+
"""
|
|
102
|
+
messages = self._engine._messages
|
|
103
|
+
|
|
104
|
+
# 第零遍:收集历史中所有已存在的 tool_result 的 tool_use_id(即已完成的工具调用)
|
|
105
|
+
# 只有这些 id 对应的 tool_use 才能安全清理,正在执行中(尚无 tool_result)的不能动
|
|
106
|
+
completed_tool_ids: set[str] = set()
|
|
107
|
+
for msg in messages:
|
|
108
|
+
if msg.get("role") != "user":
|
|
109
|
+
continue
|
|
110
|
+
content = msg.get("content")
|
|
111
|
+
if not isinstance(content, list):
|
|
112
|
+
continue
|
|
113
|
+
for block in content:
|
|
114
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
115
|
+
tid = block.get("tool_use_id", "")
|
|
116
|
+
if tid: # 跳过空 id,避免误匹配
|
|
117
|
+
completed_tool_ids.add(tid)
|
|
118
|
+
|
|
119
|
+
# 第一遍:找出"已完成"的 plan 专属工具 tool_use id,并将其转为 text block
|
|
120
|
+
plan_tool_ids: set[str] = set()
|
|
121
|
+
for msg in messages:
|
|
122
|
+
if msg.get("role") != "assistant":
|
|
123
|
+
continue
|
|
124
|
+
content = msg.get("content")
|
|
125
|
+
if not isinstance(content, list):
|
|
126
|
+
continue
|
|
127
|
+
new_blocks = []
|
|
128
|
+
for block in content:
|
|
129
|
+
tid = block.get("id", "") if isinstance(block, dict) else ""
|
|
130
|
+
is_plan_tool = (
|
|
131
|
+
isinstance(block, dict)
|
|
132
|
+
and block.get("type") == "tool_use"
|
|
133
|
+
and block.get("name") not in normal_tool_names
|
|
134
|
+
and tid in completed_tool_ids # 只处理已完成的调用
|
|
135
|
+
)
|
|
136
|
+
if is_plan_tool:
|
|
137
|
+
plan_tool_ids.add(tid)
|
|
138
|
+
# 转为 text block,保留调用记录供 LLM 理解上下文
|
|
139
|
+
new_blocks.append({
|
|
140
|
+
"type": "text",
|
|
141
|
+
"text": f"[Plan tool used: {block.get('name')}]",
|
|
142
|
+
})
|
|
143
|
+
else:
|
|
144
|
+
new_blocks.append(block)
|
|
145
|
+
msg["content"] = new_blocks
|
|
146
|
+
|
|
147
|
+
if not plan_tool_ids:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# 第二遍:清理 user 消息中对应的 tool_result 条目
|
|
151
|
+
to_delete = []
|
|
152
|
+
for i, msg in enumerate(messages):
|
|
153
|
+
if msg.get("role") != "user":
|
|
154
|
+
continue
|
|
155
|
+
content = msg.get("content")
|
|
156
|
+
if not isinstance(content, list):
|
|
157
|
+
continue
|
|
158
|
+
# 只处理全部为 tool_result 的 user 消息(即工具结果消息)
|
|
159
|
+
if not all(isinstance(b, dict) and b.get("type") == "tool_result" for b in content):
|
|
160
|
+
continue
|
|
161
|
+
filtered = [b for b in content if b.get("tool_use_id") not in plan_tool_ids]
|
|
162
|
+
if not filtered:
|
|
163
|
+
# 所有条目都是 plan 专属工具的结果,整条消息可删除
|
|
164
|
+
to_delete.append(i)
|
|
165
|
+
else:
|
|
166
|
+
msg["content"] = filtered
|
|
167
|
+
|
|
168
|
+
# 倒序删除,避免索引偏移
|
|
169
|
+
for i in reversed(to_delete):
|
|
170
|
+
del messages[i]
|
|
171
|
+
|
|
172
|
+
def get_plan_content(self) -> str | None:
|
|
173
|
+
"""读取 plan 文件内容。"""
|
|
174
|
+
if self._plan_file and self._plan_file.exists():
|
|
175
|
+
try:
|
|
176
|
+
return self._plan_file.read_text(encoding="utf-8")
|
|
177
|
+
except OSError:
|
|
178
|
+
return None
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
# -- enter / exit -------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def enter(self) -> str:
|
|
184
|
+
"""进入 plan mode:创建 plan 文件,切换为只读工具集,注入提示词。"""
|
|
185
|
+
assert self._engine is not None, "PlanModeManager not bound to engine"
|
|
186
|
+
|
|
187
|
+
if self._active:
|
|
188
|
+
return f"Already in plan mode. Plan file: {self._plan_file}"
|
|
189
|
+
|
|
190
|
+
# 决定本次 plan 模式使用的文件路径:
|
|
191
|
+
#
|
|
192
|
+
# 复用策略(修复"plan 文件换代"bug):
|
|
193
|
+
# 若 self._plan_file 已存在(即同一进程中之前进入过 plan 模式,
|
|
194
|
+
# exit() 不会清空此字段),且该文件仍在磁盘上,则复用同一个文件。
|
|
195
|
+
# 这样用户在 plan → normal → plan 来回切换时,LLM 看到的始终是
|
|
196
|
+
# 同一个 plan 文件,避免出现:
|
|
197
|
+
# - 历史中 LLM 提及旧文件名 → 新进入后 LLM 试图编辑旧文件
|
|
198
|
+
# - 权限层只允许编辑新文件 → 编辑被拒 → turn cancelled
|
|
199
|
+
#
|
|
200
|
+
# 新建策略:
|
|
201
|
+
# 首次进入(self._plan_file is None)或上次的文件已被外部删除时,
|
|
202
|
+
# 生成不重复的新 slug 文件名。
|
|
203
|
+
#
|
|
204
|
+
# 跨进程不会复用:self._plan_file 是实例属性,新 super-code 进程
|
|
205
|
+
# 重新构造 PlanModeManager 时 self._plan_file 从 None 开始——
|
|
206
|
+
# 这是合理的会话隔离边界。
|
|
207
|
+
if self._plan_file is not None and self._plan_file.exists():
|
|
208
|
+
path = self._plan_file
|
|
209
|
+
else:
|
|
210
|
+
plans_dir = _get_plans_dir()
|
|
211
|
+
for _ in range(10):
|
|
212
|
+
slug = _generate_slug()
|
|
213
|
+
path = plans_dir / f"{slug}.md"
|
|
214
|
+
if not path.exists():
|
|
215
|
+
break
|
|
216
|
+
self._plan_file = path
|
|
217
|
+
|
|
218
|
+
# 保存当前 engine 状态
|
|
219
|
+
self._saved_tools = list(self._engine._tools.values())
|
|
220
|
+
self._saved_prompt = self._engine.system_prompt
|
|
221
|
+
|
|
222
|
+
# 构建 plan mode 工具集(只读 + plan 工具)
|
|
223
|
+
from tools.plan_tools import EnterPlanModeTool, ExitPlanModeTool
|
|
224
|
+
from tools.ask_user import AskUserQuestionTool
|
|
225
|
+
from tools.file_read import FileReadTool
|
|
226
|
+
from tools.glob_tool import GlobTool
|
|
227
|
+
from tools.grep_tool import GrepTool
|
|
228
|
+
from tools.file_edit import FileEditTool
|
|
229
|
+
from tools.file_write import FileWriteTool
|
|
230
|
+
|
|
231
|
+
plan_tools: list[Tool] = [
|
|
232
|
+
FileReadTool(), GlobTool(), GrepTool(),
|
|
233
|
+
FileEditTool(), FileWriteTool(), # 权限层限制只能编辑 plan 文件
|
|
234
|
+
AskUserQuestionTool(),
|
|
235
|
+
EnterPlanModeTool(self),
|
|
236
|
+
ExitPlanModeTool(self),
|
|
237
|
+
]
|
|
238
|
+
self._engine.set_tools(plan_tools)
|
|
239
|
+
|
|
240
|
+
# 注入 plan mode 系统提示词段落
|
|
241
|
+
from core.context import get_plan_mode_section
|
|
242
|
+
self._engine.system_prompt = self._saved_prompt + "\n\n" + get_plan_mode_section(str(self._plan_file))
|
|
243
|
+
|
|
244
|
+
self._active = True
|
|
245
|
+
|
|
246
|
+
# 切换权限模式
|
|
247
|
+
if self._permissions is not None:
|
|
248
|
+
self._permissions.enter_plan_mode()
|
|
249
|
+
|
|
250
|
+
# 注入通知消息,让 LLM 明确感知已进入 plan mode。
|
|
251
|
+
# 仅修改 system_prompt 不够,历史消息会压制 system prompt 的变化。
|
|
252
|
+
self._engine._messages.append({
|
|
253
|
+
"role": "user",
|
|
254
|
+
"content": "[System: Plan mode is now active. You must NOT edit files or run commands. Only read files and write to the plan file.]",
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
return f"Entered plan mode. Plan file: {self._plan_file}"
|
|
258
|
+
|
|
259
|
+
def exit(self) -> tuple[str, str | None]:
|
|
260
|
+
"""退出 plan mode:恢复工具集和系统提示词,返回 (message, plan_content)。"""
|
|
261
|
+
assert self._engine is not None, "PlanModeManager not bound to engine"
|
|
262
|
+
|
|
263
|
+
if not self._active:
|
|
264
|
+
return ("Not in plan mode.", None)
|
|
265
|
+
|
|
266
|
+
plan_content = self.get_plan_content()
|
|
267
|
+
|
|
268
|
+
# 先恢复权限,再恢复工具
|
|
269
|
+
if self._permissions is not None:
|
|
270
|
+
self._permissions.exit_plan_mode()
|
|
271
|
+
|
|
272
|
+
if self._saved_tools is not None:
|
|
273
|
+
self._engine.set_tools(self._saved_tools)
|
|
274
|
+
if self._saved_prompt is not None:
|
|
275
|
+
self._engine.system_prompt = self._saved_prompt
|
|
276
|
+
|
|
277
|
+
self._active = False
|
|
278
|
+
|
|
279
|
+
# 清理历史消息中 plan 专属工具的 tool_use/tool_result,有两条路径:
|
|
280
|
+
#
|
|
281
|
+
# 路径A:Shift+Tab 手动切换(两轮对话之间,engine 没有正在进行的 submit())
|
|
282
|
+
# 此时所有工具调用都已完成,tool_results 已全部 append 到 _messages,
|
|
283
|
+
# 可以立即清理。**这是当前唯一会被实际触发的路径**:
|
|
284
|
+
# ExitPlanModeTool 已重构为"信号工具"(plan_tools.py),不再调用 exit();
|
|
285
|
+
# 而 Shift+Tab 回调仅在 bordered_prompt 等待输入时生效(tui/app.py),
|
|
286
|
+
# 此时 engine 不可能在 submit() 中。
|
|
287
|
+
#
|
|
288
|
+
# 路径B(防御性保留):理论上 exit() 在 engine.submit() 运行期间被调用的场景
|
|
289
|
+
# engine._turn_start_len is not None 表示 submit() 正在运行,
|
|
290
|
+
# 当前轮的 tool_results 尚未 append 到 _messages(engine 在所有工具执行完后
|
|
291
|
+
# 才统一 append)。若此时立即清理,正在执行中的 tool_use 因找不到对应
|
|
292
|
+
# tool_result 而会被跳过,进而清理失效,下一轮 API 调用报 400。
|
|
293
|
+
# 解决方案:向 engine 注册一次性回调,engine append tool_results 后触发清理。
|
|
294
|
+
# 当前实现中没有真实代码路径走到这里(见上面对路径A的说明),但为防御未来
|
|
295
|
+
# 引入新的 exit() 触发点(如 mid-stream 信号、其它工具触发等)而保留。
|
|
296
|
+
normal_tool_names = {t.name for t in (self._saved_tools or [])}
|
|
297
|
+
if self._engine._turn_start_len is None:
|
|
298
|
+
# 路径A:立即清理
|
|
299
|
+
self._sanitize_plan_tool_history(normal_tool_names)
|
|
300
|
+
else:
|
|
301
|
+
# 路径B:延迟清理,等 engine append tool_results 后触发
|
|
302
|
+
self._engine._post_tool_hooks.append(
|
|
303
|
+
lambda: self._sanitize_plan_tool_history(normal_tool_names)
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
self._saved_tools = None
|
|
307
|
+
self._saved_prompt = None
|
|
308
|
+
|
|
309
|
+
# 注入通知消息,让 LLM 明确感知模式已切换。
|
|
310
|
+
# 不能截断历史,因为 plan 阶段的分析结论需要保留供后续实现使用。
|
|
311
|
+
self._engine._messages.append({
|
|
312
|
+
"role": "user",
|
|
313
|
+
"content": "[System: Plan mode has ended. You are now in normal mode with full tool access. You can implement the plan discussed above.]",
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
plan_path = str(self._plan_file) if self._plan_file else "unknown"
|
|
317
|
+
|
|
318
|
+
if plan_content:
|
|
319
|
+
msg = (
|
|
320
|
+
f"Exited plan mode. Plan saved to: {plan_path}\n\n"
|
|
321
|
+
f"## Plan Content:\n{plan_content}\n\n"
|
|
322
|
+
"You can now review the plan, request modifications, or proceed with implementation."
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
msg = "Exited plan mode. No plan file was written."
|
|
326
|
+
|
|
327
|
+
return (msg, plan_content)
|