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
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Step 6 — 按相关性精选记忆并注入到当轮 user message。
|
|
2
|
+
|
|
3
|
+
用户每次提问 → 扫记忆目录 → side-query 让小模型从 manifest 里挑最多 3 条 →
|
|
4
|
+
把这些记忆的**摘要**(filename + frontmatter description,每条前面拼
|
|
5
|
+
Step 4 freshness warning)打包成一个 <system-reminder> 块,附在 user message
|
|
6
|
+
前面。需要细节时由模型用 Read 工具打开记忆文件,避免把全文注入历史重复计费。
|
|
7
|
+
|
|
8
|
+
为什么走 side-query 而不是简单的关键字匹配:
|
|
9
|
+
关键字(如 "auth" 命中 feedback-handler.md)会过度触发;模型挑选有上下文判断,
|
|
10
|
+
准确率高得多。max_tokens=256 + JSON schema 让 side-query 成本可忽略。
|
|
11
|
+
|
|
12
|
+
为什么注入摘要而不是全文:
|
|
13
|
+
全文注入(每条至多 8000 字符)会留在对话历史里每一轮重复计费,且误选的
|
|
14
|
+
"垃圾记忆"污染面大。摘要每条仅 ~30-50 token,注入块同时给出记忆目录路径,
|
|
15
|
+
模型需要细节时用 Read 按需读取。
|
|
16
|
+
|
|
17
|
+
为什么加会话级节流:
|
|
18
|
+
side-query 的价值在于"话题开始时给一次方向";同一会话 5 分钟内反复挑选
|
|
19
|
+
边际收益低。缓存未命中 + 节流窗口内 → 跳过 side-query(不写缓存,窗口过后
|
|
20
|
+
同话题仍可重新查询)。
|
|
21
|
+
|
|
22
|
+
最小改动原则:
|
|
23
|
+
- 不修改 engine / context / run_query / permissions
|
|
24
|
+
- 仅暴露 build_relevant_memories_prefix(...) 一个供 tui/app.py 调用的入口
|
|
25
|
+
- 任何失败(IO / API / JSON 解析)静默降级返回空字符串
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import time
|
|
31
|
+
from difflib import SequenceMatcher
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from features.memory_age import memory_freshness_text
|
|
36
|
+
from features.memory_scan import (
|
|
37
|
+
ENTRYPOINT_NAME,
|
|
38
|
+
MemoryHeader,
|
|
39
|
+
format_memory_manifest,
|
|
40
|
+
scan_memory_files,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# side-query 至多挑选这么多记忆。
|
|
44
|
+
MAX_SELECTED = 3
|
|
45
|
+
|
|
46
|
+
# 单次 side-query 的 token 上限。返回 JSON 很短(几个 filename),256 已足够冗余。
|
|
47
|
+
SELECT_MAX_TOKENS = 256
|
|
48
|
+
|
|
49
|
+
# 会话级节流:距上次 side-query 不足该秒数时直接跳过(不调 LLM、不写缓存)。
|
|
50
|
+
# side-query 的价值在"话题开始时给一次方向",5 分钟内连续提问围绕同一工作
|
|
51
|
+
# 上下文,重复挑选边际收益低;话题漂移也等窗口过后再查。
|
|
52
|
+
THROTTLE_SECONDS = 900
|
|
53
|
+
|
|
54
|
+
# 触发 side-query 的最低记忆数:少于这个数量直接全注入(不调用 LLM 反而更便宜)。
|
|
55
|
+
MIN_MEMORIES_FOR_SIDE_QUERY = 2
|
|
56
|
+
|
|
57
|
+
# 性能优化:跳过明显不需要记忆的输入(方案 3)
|
|
58
|
+
# - 太短:上下文不足,selector 也挑不准
|
|
59
|
+
# - 单 token 输入:通常是 "ok / yes / 继续 / stop" 这种确认信号
|
|
60
|
+
# 阈值松一点(10 字符)覆盖大多数确认词,不会误伤"修一下 bug"这种短指令
|
|
61
|
+
_SKIP_LOOKUP_MIN_CHARS = 10
|
|
62
|
+
_CONFIRM_WORDS = {
|
|
63
|
+
"yes", "no", "ok", "okay", "y", "n",
|
|
64
|
+
"continue", "stop", "go", "next", "done",
|
|
65
|
+
"继续", "停", "好", "好的", "嗯", "对",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# 性能优化:连续提问缓存(方案 1)
|
|
69
|
+
# 同一会话内连续提问大概率围绕同一话题("看 auth.py" → "它有 bug 吗" → "修一下"),
|
|
70
|
+
# 上一次精选出的记忆完全可以复用。用 SequenceMatcher 算文本相似度,> 阈值即命中。
|
|
71
|
+
_CACHE_SIMILARITY_THRESHOLD = 0.6
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# 模块级缓存。封进 dict 是为了让 reset 一次性清空、也便于将来切到 LRU。
|
|
75
|
+
# memory_dir 不同 → 不复用(避免切项目时拿错记忆)。
|
|
76
|
+
_lookup_cache: dict[str, Any] = {
|
|
77
|
+
"memory_dir": None, # str | None:上次查询时的 memory_dir 绝对路径
|
|
78
|
+
"query": None, # str | None:上次查询的 user_input
|
|
79
|
+
"prefix": "", # str:上次产出的 prefix 文本
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# 会话级节流状态:上次实际发起 side-query(LLM 调用)的单调时钟时间戳。
|
|
83
|
+
# None = 本会话尚未查询过。用 time.monotonic 而非 time.time:不受系统时钟调整影响。
|
|
84
|
+
_last_side_query_at: float | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _throttle_active() -> bool:
|
|
88
|
+
"""距上次 side-query 是否仍在节流窗口内。"""
|
|
89
|
+
if _last_side_query_at is None:
|
|
90
|
+
return False
|
|
91
|
+
return time.monotonic() - _last_side_query_at < THROTTLE_SECONDS
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _mark_side_query() -> None:
|
|
95
|
+
"""记录一次 side-query 发起时刻(无论成败,只要发了 LLM 调用就算)。"""
|
|
96
|
+
global _last_side_query_at
|
|
97
|
+
_last_side_query_at = time.monotonic()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_SELECT_SYSTEM_PROMPT = (
|
|
102
|
+
"You are selecting memory files that will be useful to a coding assistant "
|
|
103
|
+
"processing the user's query. You will be given the user's query and a list of "
|
|
104
|
+
"available memory files with their filenames and one-line descriptions.\n\n"
|
|
105
|
+
f"Return a JSON object with key 'selected_memories' whose value is a list of "
|
|
106
|
+
f"filenames (at most {MAX_SELECTED}). Include only memories that are clearly "
|
|
107
|
+
"useful based on their name and description. If unsure, do NOT include — "
|
|
108
|
+
"be selective. If no memory is clearly useful, return an empty list."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# 公共数据
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _select_with_side_query(query: str, memories: list[MemoryHeader],
|
|
117
|
+
llm_client: Any, model: str) -> list[str]:
|
|
118
|
+
"""向 LLM 发起一次轻量 side-query,让它从 manifest 中挑选最多 MAX_SELECTED 个 filename。
|
|
119
|
+
|
|
120
|
+
设计要点:
|
|
121
|
+
- 直接复用主对话同款 LLMClient.create(非流式),不引入新依赖
|
|
122
|
+
- prompt 里强制要求"JSON 对象 + key=selected_memories",本端用 json.loads 解析
|
|
123
|
+
- 任何异常(API / 解析 / 类型不符)返回空列表,调用方据此降级
|
|
124
|
+
- 返回值会被白名单过滤:只保留 manifest 中真实存在的 filename,避免幻觉
|
|
125
|
+
"""
|
|
126
|
+
manifest = format_memory_manifest(memories)
|
|
127
|
+
user_msg = (
|
|
128
|
+
f"User query:\n{query}\n\n"
|
|
129
|
+
f"Available memories:\n{manifest}\n\n"
|
|
130
|
+
"Respond ONLY with a JSON object like: "
|
|
131
|
+
"{\"selected_memories\": [\"file1.md\", \"file2.md\"]}\n"
|
|
132
|
+
"Do not wrap in markdown fences. Do not add any commentary."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
result = llm_client.create(
|
|
137
|
+
model=model,
|
|
138
|
+
max_tokens=SELECT_MAX_TOKENS,
|
|
139
|
+
messages=[{"role": "user", "content": user_msg}],
|
|
140
|
+
system=_SELECT_SYSTEM_PROMPT,
|
|
141
|
+
)
|
|
142
|
+
except Exception:
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
# 提取 text 块
|
|
146
|
+
text = ""
|
|
147
|
+
content = getattr(result, "content", None)
|
|
148
|
+
if isinstance(content, list):
|
|
149
|
+
for block in content:
|
|
150
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
151
|
+
text += str(block.get("text", ""))
|
|
152
|
+
|
|
153
|
+
if not text.strip():
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
# 一些模型仍会偶尔加 ```json 包装,宽容处理
|
|
157
|
+
cleaned = text.strip()
|
|
158
|
+
if cleaned.startswith("```"):
|
|
159
|
+
# 去掉第一行 ```... 与最后一行 ```
|
|
160
|
+
lines = cleaned.splitlines()
|
|
161
|
+
if len(lines) >= 2:
|
|
162
|
+
cleaned = "\n".join(lines[1:-1])
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
parsed = json.loads(cleaned)
|
|
166
|
+
except (json.JSONDecodeError, ValueError):
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
if not isinstance(parsed, dict):
|
|
170
|
+
return []
|
|
171
|
+
raw = parsed.get("selected_memories")
|
|
172
|
+
if not isinstance(raw, list):
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
# 白名单过滤 + 去重 + 截断到 MAX_SELECTED
|
|
176
|
+
valid_filenames = {m.filename for m in memories}
|
|
177
|
+
selected: list[str] = []
|
|
178
|
+
seen: set[str] = set()
|
|
179
|
+
for item in raw:
|
|
180
|
+
if not isinstance(item, str):
|
|
181
|
+
continue
|
|
182
|
+
if item in valid_filenames and item not in seen:
|
|
183
|
+
seen.add(item)
|
|
184
|
+
selected.append(item)
|
|
185
|
+
if len(selected) >= MAX_SELECTED:
|
|
186
|
+
break
|
|
187
|
+
return selected
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def find_relevant_memories(query: str, memory_dir: Path,
|
|
191
|
+
llm_client: Any, model: str) -> list[MemoryHeader]:
|
|
192
|
+
"""返回与 query 最相关的 MemoryHeader 列表(最多 MAX_SELECTED 条)。
|
|
193
|
+
|
|
194
|
+
流程:
|
|
195
|
+
1. scan_memory_files 拿全部 header(自动排除 MEMORY.md / logs/)
|
|
196
|
+
2. 记忆数 < MIN_MEMORIES_FOR_SIDE_QUERY → 全部返回,省一次 LLM 调用
|
|
197
|
+
3. 否则走 _select_with_side_query
|
|
198
|
+
任何失败静默返回空列表。
|
|
199
|
+
"""
|
|
200
|
+
if not query.strip():
|
|
201
|
+
return []
|
|
202
|
+
try:
|
|
203
|
+
memories = scan_memory_files(memory_dir)
|
|
204
|
+
except Exception:
|
|
205
|
+
return []
|
|
206
|
+
if not memories:
|
|
207
|
+
return []
|
|
208
|
+
if len(memories) < MIN_MEMORIES_FOR_SIDE_QUERY:
|
|
209
|
+
return memories
|
|
210
|
+
|
|
211
|
+
# 记录 side-query 发起时刻:只要发了 LLM 调用就计入节流(成败与否)
|
|
212
|
+
_mark_side_query()
|
|
213
|
+
selected_names = _select_with_side_query(query, memories, llm_client, model)
|
|
214
|
+
if not selected_names:
|
|
215
|
+
return []
|
|
216
|
+
by_name = {m.filename: m for m in memories}
|
|
217
|
+
return [by_name[name] for name in selected_names if name in by_name]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# 注入文本构造
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def build_relevant_memories_prefix(query: str, memory_dir: Path,
|
|
225
|
+
llm_client: Any, model: str) -> str:
|
|
226
|
+
"""供 tui/app.py 调用的总入口。
|
|
227
|
+
|
|
228
|
+
返回值规范:
|
|
229
|
+
- 拿不到相关记忆 / 失败 / 短输入 / 节流窗口内 → 空字符串(调用方直接
|
|
230
|
+
`prefix + user_input`,空串自然降级为 user_input)
|
|
231
|
+
- 有相关记忆 → 一段 <system-reminder>...</system-reminder> 包裹的摘要文本,
|
|
232
|
+
末尾自带 "\n\n" 分隔符,便于直接拼到 user_input 前面
|
|
233
|
+
|
|
234
|
+
性能优化(不影响功能正确性):
|
|
235
|
+
1. _should_skip_lookup:短输入 / 确认词直接跳过,零 LLM 调用
|
|
236
|
+
2. _cache_hit:连续提问同话题命中缓存,零 LLM 调用
|
|
237
|
+
3. 命中缓存时 freshness 也是缓存的旧值——可接受,新鲜度对几秒/几分钟内的
|
|
238
|
+
话题切换没有实际差异
|
|
239
|
+
4. _throttle_active:缓存未命中 + 距上次 side-query 不足 THROTTLE_SECONDS
|
|
240
|
+
→ 直接返回空串。**刻意不写缓存**:避免把节流导致的空结果永久缓存,
|
|
241
|
+
窗口过后同话题提问仍能重新查询。
|
|
242
|
+
"""
|
|
243
|
+
if _should_skip_lookup(query):
|
|
244
|
+
return ""
|
|
245
|
+
|
|
246
|
+
memory_dir_key = str(memory_dir)
|
|
247
|
+
cached = _cache_hit(query, memory_dir_key)
|
|
248
|
+
if cached is not None:
|
|
249
|
+
return cached
|
|
250
|
+
|
|
251
|
+
if _throttle_active():
|
|
252
|
+
return ""
|
|
253
|
+
|
|
254
|
+
prefix = _build_prefix_uncached(query, memory_dir, llm_client, model)
|
|
255
|
+
_cache_store(query, memory_dir_key, prefix)
|
|
256
|
+
return prefix
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _build_prefix_uncached(query: str, memory_dir: Path,
|
|
260
|
+
llm_client: Any, model: str) -> str:
|
|
261
|
+
"""实际执行精选 + 拼装的内核。从 build_relevant_memories_prefix 拆出来,
|
|
262
|
+
便于缓存包装;签名 / 行为与原函数完全一致。"""
|
|
263
|
+
selected = find_relevant_memories(query, memory_dir, llm_client, model)
|
|
264
|
+
if not selected:
|
|
265
|
+
return ""
|
|
266
|
+
|
|
267
|
+
parts: list[str] = []
|
|
268
|
+
for h in selected:
|
|
269
|
+
# MEMORY.md 不应出现(scan_memory_files 已过滤),这里再保险一道
|
|
270
|
+
if h.filename == ENTRYPOINT_NAME:
|
|
271
|
+
continue
|
|
272
|
+
# Step 4:≥2 天的记忆带 freshness 警告;新鲜的不带(freshness_text 返回 "")
|
|
273
|
+
freshness = memory_freshness_text(h.mtime_ms)
|
|
274
|
+
freshness_block = f"<system-reminder>{freshness}</system-reminder>\n" if freshness else ""
|
|
275
|
+
# 注入摘要而非全文:description 来自 frontmatter(scan 时已解析),
|
|
276
|
+
# 缺描述时降级占位,细节由模型按需 Read 记忆文件
|
|
277
|
+
desc = h.description or "(no description — read the file for details)"
|
|
278
|
+
parts.append(f"## {h.filename}\n{freshness_block}{desc}")
|
|
279
|
+
|
|
280
|
+
if not parts:
|
|
281
|
+
return ""
|
|
282
|
+
|
|
283
|
+
body = "\n\n".join(parts)
|
|
284
|
+
return (
|
|
285
|
+
"<system-reminder>\n"
|
|
286
|
+
f"Relevant memories selected for this turn ({len(parts)}):\n"
|
|
287
|
+
"Summaries only — use the Read tool to open a memory file if you need details.\n"
|
|
288
|
+
f"Memory directory: {memory_dir}\n\n"
|
|
289
|
+
f"{body}\n"
|
|
290
|
+
"</system-reminder>\n\n"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# 性能优化辅助函数(方案 1 + 方案 3)
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def _should_skip_lookup(query: str) -> bool:
|
|
299
|
+
"""判断是否跳过 side-query。方案 3:避免对确认词 / 超短输入也起 LLM 往返。
|
|
300
|
+
|
|
301
|
+
规则(任一命中即跳过):
|
|
302
|
+
- strip 后为空
|
|
303
|
+
- 总字符数 < _SKIP_LOOKUP_MIN_CHARS
|
|
304
|
+
- 全部是确认词 / 终止词(大小写不敏感)
|
|
305
|
+
"""
|
|
306
|
+
s = query.strip()
|
|
307
|
+
if not s:
|
|
308
|
+
return True
|
|
309
|
+
if len(s) < _SKIP_LOOKUP_MIN_CHARS:
|
|
310
|
+
# 短输入:再检查一次是不是确认词,是的话明确跳过;不是的话也跳过
|
|
311
|
+
# (10 字符以下不足以触发有意义的 selector 判断)
|
|
312
|
+
return True
|
|
313
|
+
# 长度够但全是确认词(如 "yes please" / "OK 继续" 这种)也跳过
|
|
314
|
+
if s.lower() in _CONFIRM_WORDS:
|
|
315
|
+
return True
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _cache_hit(query: str, memory_dir_key: str) -> str | None:
|
|
320
|
+
"""命中返回缓存的 prefix(可能是空字符串也算命中),未命中返回 None。
|
|
321
|
+
|
|
322
|
+
命中条件:memory_dir 完全相同 + 与上次 query 文本相似度 ≥ 阈值。
|
|
323
|
+
用 str | None 而不是 (bool, str):None 明确表示"未命中",与"命中但 prefix=空"区分。
|
|
324
|
+
"""
|
|
325
|
+
if _lookup_cache["memory_dir"] != memory_dir_key:
|
|
326
|
+
return None
|
|
327
|
+
last_query = _lookup_cache["query"]
|
|
328
|
+
if not last_query:
|
|
329
|
+
return None
|
|
330
|
+
# SequenceMatcher 在短文本上计算很快(数十微秒级),不引入新依赖
|
|
331
|
+
similarity = SequenceMatcher(None, last_query, query).ratio()
|
|
332
|
+
if similarity >= _CACHE_SIMILARITY_THRESHOLD:
|
|
333
|
+
return _lookup_cache["prefix"]
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _cache_store(query: str, memory_dir_key: str, prefix: str) -> None:
|
|
338
|
+
"""覆盖式写入;不做 LRU——TUI 是单用户单进程,一对游标足够。"""
|
|
339
|
+
_lookup_cache["memory_dir"] = memory_dir_key
|
|
340
|
+
_lookup_cache["query"] = query
|
|
341
|
+
_lookup_cache["prefix"] = prefix
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def will_need_side_query(query: str, memory_dir: Path) -> bool:
|
|
345
|
+
"""纯本地判断:`build_relevant_memories_prefix()` 是否会触发 LLM side-query。
|
|
346
|
+
|
|
347
|
+
供 TUI 层在调用前缀构建函数前决定是否显示 "Searching memories…" spinner。
|
|
348
|
+
不复用内部函数的判断结果——刻意独立实现,避免未来任一端的逻辑变更造成
|
|
349
|
+
调用层与实现层的隐性耦合。性能:仅做 scan_memory_files(内存文件数通常
|
|
350
|
+
≤100,~ms 级),无网络 IO。
|
|
351
|
+
|
|
352
|
+
与 build_relevant_memories_prefix 的判定顺序保持一致:
|
|
353
|
+
skip_lookup → cache_hit → throttle → scan。
|
|
354
|
+
"""
|
|
355
|
+
if _should_skip_lookup(query):
|
|
356
|
+
return False
|
|
357
|
+
memory_dir_key = str(memory_dir)
|
|
358
|
+
if _cache_hit(query, memory_dir_key) is not None:
|
|
359
|
+
return False
|
|
360
|
+
if _throttle_active():
|
|
361
|
+
return False
|
|
362
|
+
try:
|
|
363
|
+
memories = scan_memory_files(memory_dir)
|
|
364
|
+
except Exception:
|
|
365
|
+
return False
|
|
366
|
+
return len(memories) >= MIN_MEMORIES_FOR_SIDE_QUERY
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def reset_relevant_memories_cache() -> None:
|
|
370
|
+
"""供测试 / `/clear` 命令调用,清空缓存与节流状态。生产场景换记忆目录会自动
|
|
371
|
+
失效,一般不需要手动 reset。"""
|
|
372
|
+
global _last_side_query_at
|
|
373
|
+
_lookup_cache["memory_dir"] = None
|
|
374
|
+
_lookup_cache["query"] = None
|
|
375
|
+
_lookup_cache["prefix"] = ""
|
|
376
|
+
_last_side_query_at = None
|
features/git_ai.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Git AI 集成 — 在 AI 编辑文件前后调用 git ai checkpoint,
|
|
2
|
+
用于统计 AI 代码占比(git-ai status)。
|
|
3
|
+
|
|
4
|
+
工作原理:
|
|
5
|
+
编辑前:checkpoint agent-v1 type=human → 把上次 AI 写入到现在的人工改动标记为 human
|
|
6
|
+
编辑后:checkpoint agent-v1 type=ai_agent → 把本次 AI 编辑标记为 AI
|
|
7
|
+
|
|
8
|
+
只对写操作工具(Edit、Write、Bash)触发,读操作不触发。
|
|
9
|
+
git-ai 未安装时静默跳过,不影响正常使用。
|
|
10
|
+
|
|
11
|
+
⚠️ git-ai daemon 要求:checkpoint 数据只存在 daemon 内存中,不持久化。
|
|
12
|
+
如果 daemon 在 checkpoint 和 git commit 之间重启(电脑重启、进程崩溃等),
|
|
13
|
+
commit 将丢失归属数据(显示 untracked 100%)。因此 super-code 在启动时
|
|
14
|
+
和每次 checkpoint 前都会主动调用 git-ai bg start 确保 daemon 在运行。
|
|
15
|
+
|
|
16
|
+
调试:设置环境变量 SUPER_CODE_DEBUG_GIT_AI=1 可将错误信息打印到 stderr。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
_log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# git-ai 可执行文件路径,优先用 PATH 查找,找不到则用已知安装路径
|
|
31
|
+
_GIT_AI_EXE: str | None = None
|
|
32
|
+
_GIT_AI_CHECKED = False
|
|
33
|
+
|
|
34
|
+
# 缓存的 git root(本进程生命周期内不变),用于确保 repo_working_dir 和
|
|
35
|
+
# commit hook 看到的路径一致。Path.cwd() 可能是子目录,必须解析为 git root。
|
|
36
|
+
_GIT_ROOT: str | None = None
|
|
37
|
+
|
|
38
|
+
# daemon 是否已在本 session 中启动过(避免每次 checkpoint 都调 bg start)
|
|
39
|
+
_DAEMON_ENSURED = False
|
|
40
|
+
|
|
41
|
+
# 只对这些工具触发 checkpoint
|
|
42
|
+
WRITE_TOOLS = {"Edit", "Write", "Bash"}
|
|
43
|
+
|
|
44
|
+
# 设置 SUPER_CODE_DEBUG_GIT_AI=1 可启用 stderr 诊断输出
|
|
45
|
+
_DEBUG = os.environ.get("SUPER_CODE_DEBUG_GIT_AI") == "1"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_exe() -> str | None:
|
|
49
|
+
"""返回 git-ai 可执行文件路径,找不到返回 None。结果缓存,只检测一次。"""
|
|
50
|
+
global _GIT_AI_EXE, _GIT_AI_CHECKED
|
|
51
|
+
if _GIT_AI_CHECKED:
|
|
52
|
+
return _GIT_AI_EXE
|
|
53
|
+
_GIT_AI_CHECKED = True
|
|
54
|
+
# 先从 PATH 查找
|
|
55
|
+
found = shutil.which("git-ai")
|
|
56
|
+
if not found:
|
|
57
|
+
# fallback:已知 Windows 安装路径
|
|
58
|
+
fallback = os.path.expanduser(r"~\.git-ai\bin\git-ai.exe")
|
|
59
|
+
if os.path.isfile(fallback):
|
|
60
|
+
found = fallback
|
|
61
|
+
if found:
|
|
62
|
+
_log.debug("git-ai found at: %s", found)
|
|
63
|
+
else:
|
|
64
|
+
_log.debug("git-ai not found – checkpoints disabled")
|
|
65
|
+
_GIT_AI_EXE = found
|
|
66
|
+
return _GIT_AI_EXE
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _git_root(cwd_hint: str) -> str | None:
|
|
70
|
+
"""通过 git rev-parse --show-toplevel 获取 git 根目录。
|
|
71
|
+
|
|
72
|
+
repo_working_dir 必须等于 git root,否则 commit hook 无法匹配 checkpoint 数据
|
|
73
|
+
导致 git-ai stats 显示 untracked 100%。结果缓存,进程生命周期内只查询一次。
|
|
74
|
+
"""
|
|
75
|
+
global _GIT_ROOT
|
|
76
|
+
if _GIT_ROOT is not None:
|
|
77
|
+
return _GIT_ROOT
|
|
78
|
+
try:
|
|
79
|
+
r = subprocess.run(
|
|
80
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
81
|
+
capture_output=True, text=True, timeout=5,
|
|
82
|
+
cwd=cwd_hint or None,
|
|
83
|
+
)
|
|
84
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
85
|
+
_GIT_ROOT = r.stdout.strip()
|
|
86
|
+
return _GIT_ROOT
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
# 回退:git 不可用时用传入的目录
|
|
90
|
+
_GIT_ROOT = cwd_hint
|
|
91
|
+
return _GIT_ROOT
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _rel_path(file_path: str, git_root: str) -> str:
|
|
95
|
+
"""将 file_path 转为相对 git root 的路径,与 git diff 输出格式一致。
|
|
96
|
+
|
|
97
|
+
Windows 上 os.path.abspath 保留输入大小写,而 git rev-parse 返回实际大小写,
|
|
98
|
+
必须用 normcase 做大小写不敏感比较,否则 LLM 传小写盘符会导致 startswith 失败。
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
abs_path = os.path.abspath(file_path)
|
|
102
|
+
root = os.path.abspath(git_root)
|
|
103
|
+
if os.path.normcase(abs_path).startswith(os.path.normcase(root)):
|
|
104
|
+
return abs_path[len(root):].lstrip(os.sep).replace("\\", "/")
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
return file_path.replace("\\", "/")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _ensure_daemon(exe: str, cwd: str | None = None) -> None:
|
|
111
|
+
"""确保 git-ai 后台 daemon 在运行。
|
|
112
|
+
|
|
113
|
+
git-ai 的 checkpoint 数据只存在 daemon 内存中,daemon 重启后数据丢失。
|
|
114
|
+
如果 daemon 没运行,checkpoint 命令会自动启动一个,但在某些场景下
|
|
115
|
+
(如电脑刚重启后第一次调用)可能启动不够及时导致 checkpoint 丢失。
|
|
116
|
+
主动调用 bg start 可以确保 daemon 提前就绪。
|
|
117
|
+
|
|
118
|
+
每次调用会检查 daemon 状态,如果已在运行则 no-op。
|
|
119
|
+
"""
|
|
120
|
+
global _DAEMON_ENSURED
|
|
121
|
+
if _DAEMON_ENSURED:
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
subprocess.run(
|
|
125
|
+
[exe, "bg", "start"],
|
|
126
|
+
capture_output=True, timeout=5,
|
|
127
|
+
cwd=cwd,
|
|
128
|
+
)
|
|
129
|
+
_DAEMON_ENSURED = True
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def ensure_daemon() -> None:
|
|
135
|
+
"""供外部调用(如 app.py 启动时)主动启动 daemon。"""
|
|
136
|
+
exe = _get_exe()
|
|
137
|
+
if not exe:
|
|
138
|
+
return
|
|
139
|
+
_ensure_daemon(exe)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _run(payload: dict, exe: str, cwd: str | None = None) -> None:
|
|
143
|
+
"""把 payload 序列化为 JSON 通过 stdin 传给 git-ai checkpoint agent-v1。
|
|
144
|
+
|
|
145
|
+
失败时记录日志,绝不抛出异常影响主流程。
|
|
146
|
+
设置 SUPER_CODE_DEBUG_GIT_AI=1 可将错误打印到 stderr。
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
data = json.dumps(payload, ensure_ascii=False)
|
|
150
|
+
r = subprocess.run(
|
|
151
|
+
[exe, "ai", "checkpoint", "agent-v1", "--hook-input", "stdin"],
|
|
152
|
+
input=data.encode("utf-8"),
|
|
153
|
+
capture_output=True,
|
|
154
|
+
timeout=10,
|
|
155
|
+
cwd=cwd,
|
|
156
|
+
)
|
|
157
|
+
if r.returncode != 0:
|
|
158
|
+
err = r.stderr.decode("utf-8", errors="replace").strip()
|
|
159
|
+
_log.debug("git-ai checkpoint agent-v1 rc=%d stderr=%s", r.returncode, err)
|
|
160
|
+
if _DEBUG:
|
|
161
|
+
import sys
|
|
162
|
+
print(f"[git-ai] checkpoint failed (rc={r.returncode}): {err}", file=sys.stderr)
|
|
163
|
+
except subprocess.TimeoutExpired:
|
|
164
|
+
_log.debug("git-ai checkpoint agent-v1 timed out after 10s")
|
|
165
|
+
if _DEBUG:
|
|
166
|
+
import sys
|
|
167
|
+
print("[git-ai] checkpoint timed out after 10s", file=sys.stderr)
|
|
168
|
+
except Exception:
|
|
169
|
+
_log.debug("git-ai checkpoint agent-v1 error", exc_info=True)
|
|
170
|
+
if _DEBUG:
|
|
171
|
+
import sys, traceback
|
|
172
|
+
traceback.print_exc(file=sys.stderr)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def before_edit(repo_dir: str, file_path: str) -> None:
|
|
176
|
+
"""编辑文件前调用:把上次 AI 写入到现在的人工改动标记为 human。"""
|
|
177
|
+
exe = _get_exe()
|
|
178
|
+
if not exe:
|
|
179
|
+
return
|
|
180
|
+
root = _git_root(repo_dir)
|
|
181
|
+
if not root:
|
|
182
|
+
return
|
|
183
|
+
_ensure_daemon(exe, root)
|
|
184
|
+
_run({
|
|
185
|
+
"type": "human",
|
|
186
|
+
"repo_working_dir": root.replace("\\", "/"),
|
|
187
|
+
# 告知 git-ai 只 diff 这个文件,速度提升 50-100x
|
|
188
|
+
"will_edit_filepaths": [_rel_path(file_path, root)] if file_path else [],
|
|
189
|
+
}, exe, root)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def after_edit(repo_dir: str, file_path: str,
|
|
193
|
+
messages: list[dict], model: str, session_id: str) -> None:
|
|
194
|
+
"""编辑文件后调用:把本次 AI 编辑标记为 AI。
|
|
195
|
+
|
|
196
|
+
messages: engine._messages,会自动过滤掉 tool_result(git-ai 不接受)。
|
|
197
|
+
"""
|
|
198
|
+
exe = _get_exe()
|
|
199
|
+
if not exe:
|
|
200
|
+
return
|
|
201
|
+
root = _git_root(repo_dir)
|
|
202
|
+
if not root:
|
|
203
|
+
return
|
|
204
|
+
_ensure_daemon(exe, root)
|
|
205
|
+
transcript = _build_transcript(messages)
|
|
206
|
+
_run({
|
|
207
|
+
"type": "ai_agent",
|
|
208
|
+
"repo_working_dir": root.replace("\\", "/"),
|
|
209
|
+
"transcript": transcript,
|
|
210
|
+
"agent_name": "super-code",
|
|
211
|
+
"model": model,
|
|
212
|
+
"conversation_id": session_id,
|
|
213
|
+
"edited_filepaths": [_rel_path(file_path, root)] if file_path else [],
|
|
214
|
+
}, exe, root)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _build_transcript(messages: list[dict]) -> dict[str, Any]:
|
|
218
|
+
"""把 engine 消息历史转换为 git-ai 要求的 transcript 格式。
|
|
219
|
+
|
|
220
|
+
规则:
|
|
221
|
+
- role=user 且 content 是 list(tool_result)→ 跳过(git-ai 不接受)
|
|
222
|
+
- role=user 且 content 是 str → type=user
|
|
223
|
+
- role=assistant → type=assistant(取文本部分)
|
|
224
|
+
- content 里的 tool_use block → type=tool_use
|
|
225
|
+
"""
|
|
226
|
+
out: list[dict[str, Any]] = []
|
|
227
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
228
|
+
|
|
229
|
+
for msg in messages:
|
|
230
|
+
role = msg.get("role", "")
|
|
231
|
+
content = msg.get("content", "")
|
|
232
|
+
|
|
233
|
+
if role == "user":
|
|
234
|
+
if isinstance(content, list):
|
|
235
|
+
# tool_result 消息,跳过
|
|
236
|
+
continue
|
|
237
|
+
out.append({"type": "user", "text": str(content), "timestamp": ts})
|
|
238
|
+
|
|
239
|
+
elif role == "assistant":
|
|
240
|
+
if isinstance(content, list):
|
|
241
|
+
for block in content:
|
|
242
|
+
if not isinstance(block, dict):
|
|
243
|
+
continue
|
|
244
|
+
if block.get("type") == "text":
|
|
245
|
+
out.append({"type": "assistant", "text": block.get("text", ""), "timestamp": ts})
|
|
246
|
+
elif block.get("type") == "tool_use":
|
|
247
|
+
out.append({
|
|
248
|
+
"type": "tool_use",
|
|
249
|
+
"name": block.get("name", ""),
|
|
250
|
+
"input": block.get("input", {}),
|
|
251
|
+
"timestamp": ts,
|
|
252
|
+
})
|
|
253
|
+
else:
|
|
254
|
+
out.append({"type": "assistant", "text": str(content or ""), "timestamp": ts})
|
|
255
|
+
|
|
256
|
+
return {"messages": out}
|