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
tui/app.py
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
"""super-code entry point — argparse, engine setup, and interactive REPL."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import atexit
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from prompt_toolkit.history import FileHistory
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.markup import escape as _escape
|
|
17
|
+
|
|
18
|
+
from commands import CommandContext, handle_command, is_known_command, parse_command
|
|
19
|
+
from core.config import ensure_user_config, load_app_config
|
|
20
|
+
from core.context import build_system_prompt
|
|
21
|
+
from core.engine import Engine
|
|
22
|
+
from core.llm import LLMClient
|
|
23
|
+
from core.model_capabilities import supports_vision
|
|
24
|
+
from core.permissions import PermissionChecker
|
|
25
|
+
from core.session import SessionStore
|
|
26
|
+
from features.compact import CompactService, get_context_window, should_compact
|
|
27
|
+
from features.coordinator import (
|
|
28
|
+
get_coordinator_system_prompt, get_coordinator_user_context,
|
|
29
|
+
get_worker_system_prompt, is_coordinator_mode, set_coordinator_mode,
|
|
30
|
+
)
|
|
31
|
+
from features.cost_tracker import CostTracker
|
|
32
|
+
from features.memory import (
|
|
33
|
+
get_memory_dir, append_to_daily_log, ensure_memory_dir, extract_memory_tags,
|
|
34
|
+
build_dream_prompt, list_sessions_since, read_last_consolidated_at,
|
|
35
|
+
release_lock, should_auto_dream, try_acquire_lock,
|
|
36
|
+
)
|
|
37
|
+
from features.plan import PlanModeManager
|
|
38
|
+
from features.skills import discover_skills, build_skills_prompt_section
|
|
39
|
+
from features.worker_manager import WorkerManager
|
|
40
|
+
from mcp.loader import load_mcp_tools, shutdown_mcp
|
|
41
|
+
from tools import AskUserQuestionTool, BashTool, FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool, \
|
|
42
|
+
WebFetchTool, WebSearchTool
|
|
43
|
+
from tools.agent import AgentTool, SendMessageTool, TaskStopTool
|
|
44
|
+
from tui.prompt import bordered_prompt, slash_completer
|
|
45
|
+
from tui.query import run_query
|
|
46
|
+
from tui.rendering import SpinnerManager, SPINNER_MEMORY
|
|
47
|
+
|
|
48
|
+
console = Console()
|
|
49
|
+
|
|
50
|
+
_DOUBLE_PRESS_TIMEOUT = 0.8
|
|
51
|
+
|
|
52
|
+
# ===== 多模态图片组装(P0)=====
|
|
53
|
+
_IMAGE_PLACEHOLDER_RE = re.compile(r"\[🖼 (\d+): ([^\]]+)\]")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_LOGO_LINES = [
|
|
58
|
+
r"███████╗██╗ ██╗██████╗ ███████╗██████╗",
|
|
59
|
+
r"██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗",
|
|
60
|
+
r"███████╗██║ ██║██████╔╝█████╗ ██████╔╝",
|
|
61
|
+
r"╚════██║██║ ██║██╔═══╝ ██╔══╝ ██╔══██╗",
|
|
62
|
+
r"███████║╚██████╔╝██║ ███████╗██║ ██║",
|
|
63
|
+
r"╚══════╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
def _assemble_image_content(prefix: str, text: str, images: list[dict]) -> str | list[dict]:
|
|
67
|
+
"""把记忆前缀 + 用户文本 + 图片附件组装为内部消息 content。
|
|
68
|
+
把"输入框里看到的文字+占位符"翻译成"模型能看的文字+图片消息"
|
|
69
|
+
无图片 → 返回 str(与现状 memory_prefix + user_input 完全一致,纯文本零回归);
|
|
70
|
+
有图片 → 按占位符出现位置切分 text/image blocks,占位符对应 images[idx]。
|
|
71
|
+
"""
|
|
72
|
+
if not images:
|
|
73
|
+
return prefix + text
|
|
74
|
+
full = prefix + text
|
|
75
|
+
blocks: list[dict] = []
|
|
76
|
+
pos = 0
|
|
77
|
+
matched = 0
|
|
78
|
+
for m in _IMAGE_PLACEHOLDER_RE.finditer(full):
|
|
79
|
+
seg = full[pos:m.start()].strip()
|
|
80
|
+
if seg:
|
|
81
|
+
blocks.append({"type": "text", "text": seg})
|
|
82
|
+
idx = int(m.group(1))
|
|
83
|
+
if 0 <= idx < len(images):
|
|
84
|
+
src = images[idx]
|
|
85
|
+
blocks.append({"type": "image", "source": {
|
|
86
|
+
"media_type": src["media_type"], "data": src["data"]}})
|
|
87
|
+
matched += 1
|
|
88
|
+
pos = m.end()
|
|
89
|
+
if pos < len(full):
|
|
90
|
+
seg = full[pos:].strip()
|
|
91
|
+
if seg:
|
|
92
|
+
blocks.append({"type": "text", "text": seg})
|
|
93
|
+
# 防御:占位符一个都没匹配上(如用户删除了占位符但 images_ref 残留)
|
|
94
|
+
# → 只发文本,不发图(静默丢弃残留图,避免发错内容)
|
|
95
|
+
if matched == 0:
|
|
96
|
+
return full
|
|
97
|
+
if not blocks:
|
|
98
|
+
blocks.append({"type": "text", "text": ""})
|
|
99
|
+
return blocks
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _fmt_tokens(n: int) -> str:
|
|
103
|
+
"""token 数格式化:≥1M 显示 M(整数省略小数位),否则显示 K。"""
|
|
104
|
+
if n >= 1_000_000:
|
|
105
|
+
m = n / 1_000_000
|
|
106
|
+
return f"{m:.0f}M" if m == int(m) else f"{m:.1f}M"
|
|
107
|
+
return f"{round(n / 1024)}K"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _build_selector_client(app_config) -> tuple[Any, str]:
|
|
111
|
+
"""构建记忆 selector 的 LLMClient 与模型名。
|
|
112
|
+
|
|
113
|
+
extract_model 两种形态:
|
|
114
|
+
str → 复用主对话 client(engine._client),仅换模型名 —— 老行为,零差异。
|
|
115
|
+
dict → 独立构建 client:可选字段 {model, base_url, api_key, timeout, extra_body},
|
|
116
|
+
省略即回退主对话值;extra_body 提供时合进 model_profiles 的精确 key——
|
|
117
|
+
_lookup_extra_body 按 key 长度降序匹配,精确模型名最长必然先命中,
|
|
118
|
+
从而覆盖外层 profile(主模型与 extract 同名时也能单独控制推理强度)。
|
|
119
|
+
"""
|
|
120
|
+
extract_cfg = app_config.extract_model
|
|
121
|
+
if not isinstance(extract_cfg, dict):
|
|
122
|
+
return None, extract_cfg or app_config.model
|
|
123
|
+
selector_model = extract_cfg.get("model") or app_config.model
|
|
124
|
+
selector_profiles = dict(app_config.model_profiles)
|
|
125
|
+
extract_extra_body = extract_cfg.get("extra_body")
|
|
126
|
+
if extract_extra_body:
|
|
127
|
+
selector_profiles[selector_model] = {"extra_body": extract_extra_body}
|
|
128
|
+
client = LLMClient(
|
|
129
|
+
provider=app_config.provider,
|
|
130
|
+
api_key=extract_cfg.get("api_key") or app_config.api_key,
|
|
131
|
+
base_url=extract_cfg.get("base_url") or app_config.base_url,
|
|
132
|
+
timeout=float(extract_cfg.get("timeout") or app_config.timeout),
|
|
133
|
+
model_profiles=selector_profiles,
|
|
134
|
+
)
|
|
135
|
+
return client, selector_model
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _print_banner(app_config, cwd: str, session_id: str) -> None:
|
|
139
|
+
"""打印启动横幅:Panel 包裹,左侧紫色 ASCII logo,右侧模型信息。
|
|
140
|
+
|
|
141
|
+
Context(上下文窗口)按模型动态映射(features/compact.get_context_window),
|
|
142
|
+
与自动压缩触发阈值同源,保证展示和实际行为一致。
|
|
143
|
+
"""
|
|
144
|
+
from rich.table import Table
|
|
145
|
+
from rich.text import Text
|
|
146
|
+
from features.compact import get_context_window
|
|
147
|
+
|
|
148
|
+
logo = Text("\n".join(_LOGO_LINES), style="italic bold bright_magenta")
|
|
149
|
+
|
|
150
|
+
info = Text()
|
|
151
|
+
info.append("Model : ", style="cyan"); info.append(f"{app_config.model}\n", style="bright_cyan")
|
|
152
|
+
info.append("Context : ", style="cyan"); info.append(f"{_fmt_tokens(get_context_window(app_config.model))}\n", style="bright_green")
|
|
153
|
+
info.append("Max Output : ", style="cyan"); info.append(f"{_fmt_tokens(app_config.max_tokens)}\n", style="bright_yellow")
|
|
154
|
+
info.append("Session : ", style="cyan"); info.append(f"{session_id[:8]}\n", style="bright_blue")
|
|
155
|
+
info.append("CWD : ", style="cyan"); info.append(f"{cwd}\n", style="bright_white")
|
|
156
|
+
info.append("Version : ", style="cyan"); info.append("v3.3.6", style="bold bright_magenta")
|
|
157
|
+
|
|
158
|
+
table = Table.grid(padding=(0, 3))
|
|
159
|
+
table.add_column(no_wrap=True)
|
|
160
|
+
table.add_column(no_wrap=True, vertical="middle")
|
|
161
|
+
table.add_row(logo, info)
|
|
162
|
+
|
|
163
|
+
console.print()
|
|
164
|
+
console.print(table)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _run_dream(engine, memory_dir, permissions, quiet: bool = True,
|
|
168
|
+
transcript_dir: str = "", session_ids: list | None = None) -> None:
|
|
169
|
+
"""执行 dream 整合:用 LLM 将日志提炼为持久记忆文件并更新 MEMORY.md。
|
|
170
|
+
|
|
171
|
+
quiet=True 时静默运行(自动触发),quiet=False 时显示输出(手动 /dream)。
|
|
172
|
+
dream 模式下权限隔离:只允许 Read/Glob/Grep/Edit/Write(限 memory_dir 内)。
|
|
173
|
+
"""
|
|
174
|
+
if not quiet:
|
|
175
|
+
console.print("[dim]Starting dream consolidation…[/dim]")
|
|
176
|
+
permissions.enter_dream_mode(str(memory_dir))
|
|
177
|
+
try:
|
|
178
|
+
prompt = build_dream_prompt(memory_dir, transcript_dir=transcript_dir,
|
|
179
|
+
session_ids=session_ids)
|
|
180
|
+
run_query(engine, prompt, print_mode=False, permissions=permissions, quiet=quiet)
|
|
181
|
+
finally:
|
|
182
|
+
permissions.exit_dream_mode()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _trigger_auto_dream_bg(app_config, memory_dir: Path, session_store) -> bool:
|
|
186
|
+
"""检查是否满足自动 dream 条件,满足则在后台线程启动 dream,立即返回。
|
|
187
|
+
|
|
188
|
+
Returns True if dream was triggered, False otherwise.
|
|
189
|
+
"""
|
|
190
|
+
current_sid = session_store.session_id if session_store else ""
|
|
191
|
+
sessions_path = getattr(session_store, "_dir", None)
|
|
192
|
+
if not should_auto_dream(memory_dir,
|
|
193
|
+
min_hours=app_config.dream_interval_hours,
|
|
194
|
+
min_sessions=app_config.dream_min_sessions,
|
|
195
|
+
current_session_id=current_sid,
|
|
196
|
+
sessions_dir=sessions_path):
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
prior_mtime = read_last_consolidated_at(memory_dir)
|
|
200
|
+
if not try_acquire_lock(memory_dir):
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
sids = list_sessions_since(prior_mtime, sessions_dir=sessions_path,
|
|
204
|
+
current_session_id=current_sid)
|
|
205
|
+
transcript_dir = str(sessions_path) if sessions_path else ""
|
|
206
|
+
dream_perms = PermissionChecker(auto_approve=True)
|
|
207
|
+
dream_engine = Engine(
|
|
208
|
+
tools=[FileReadTool(), GlobTool(), GrepTool(), FileEditTool(), FileWriteTool()],
|
|
209
|
+
system_prompt="",
|
|
210
|
+
permission_checker=dream_perms,
|
|
211
|
+
provider=app_config.provider,
|
|
212
|
+
api_key=app_config.api_key,
|
|
213
|
+
base_url=app_config.base_url,
|
|
214
|
+
model=app_config.model,
|
|
215
|
+
max_tokens=app_config.max_tokens,
|
|
216
|
+
timeout=app_config.timeout,
|
|
217
|
+
model_profiles=app_config.model_profiles,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _worker():
|
|
221
|
+
try:
|
|
222
|
+
_run_dream(dream_engine, memory_dir, dream_perms, quiet=True,
|
|
223
|
+
transcript_dir=transcript_dir, session_ids=sids)
|
|
224
|
+
release_lock(memory_dir)
|
|
225
|
+
except Exception:
|
|
226
|
+
from features.memory import _lock_path
|
|
227
|
+
import os as _os
|
|
228
|
+
try:
|
|
229
|
+
lp = _lock_path(memory_dir)
|
|
230
|
+
if lp.exists():
|
|
231
|
+
_os.utime(lp, (prior_mtime, prior_mtime))
|
|
232
|
+
except OSError:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
threading.Thread(target=_worker, daemon=True).start()
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main() -> None:
|
|
240
|
+
print("\033]0;super-code\007", end="") # 设置终端标题
|
|
241
|
+
ensure_user_config() # pip/开发模式:首启从内置模板落盘全局配置
|
|
242
|
+
parser = argparse.ArgumentParser(prog="super-code", description="Minimal AI coding assistant")
|
|
243
|
+
parser.add_argument("prompt", nargs="?", help="Prompt to send (optional)")
|
|
244
|
+
parser.add_argument("-p", "--print", action="store_true",
|
|
245
|
+
help="Non-interactive: print response and exit")
|
|
246
|
+
parser.add_argument("--auto-approve", action="store_true",
|
|
247
|
+
help="Auto-approve all tool permissions")
|
|
248
|
+
parser.add_argument("--config", help="Path to a JSON config file")
|
|
249
|
+
parser.add_argument("--provider", choices=("openai",), help="API provider")
|
|
250
|
+
parser.add_argument("--api-key", help="API key")
|
|
251
|
+
parser.add_argument("--base-url", help="Custom API base URL")
|
|
252
|
+
parser.add_argument("--model", help="Model name")
|
|
253
|
+
parser.add_argument("--max-tokens", type=int, help="Maximum output tokens")
|
|
254
|
+
parser.add_argument("--mode", choices=("default", "dream"), default="default",
|
|
255
|
+
help="Permission mode: default (prompt for writes) or dream (auto-approve all)")
|
|
256
|
+
parser.add_argument("--resume", metavar="SESSION", help="Resume a past session by ID or number")
|
|
257
|
+
parser.add_argument("--coordinator", action="store_true",
|
|
258
|
+
help="Enable coordinator mode (orchestrate workers via Agent tool)")
|
|
259
|
+
parser.add_argument("--sandbox", action="store_true",
|
|
260
|
+
help="Enable sandbox mode (block dangerous commands via blacklist, not OS-level isolation)")
|
|
261
|
+
parser.add_argument("--auto-dream", action="store_true",
|
|
262
|
+
help="Disable automatic dream consolidation")
|
|
263
|
+
parser.add_argument("--dream-interval", type=float, metavar="HOURS",
|
|
264
|
+
help="Hours between auto-dream runs (default: 24)")
|
|
265
|
+
parser.add_argument("--dream-min-sessions", type=int, metavar="N",
|
|
266
|
+
help="Minimum new sessions before auto-dream triggers (default: 5)")
|
|
267
|
+
args = parser.parse_args()
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
app_config = load_app_config(args)
|
|
271
|
+
except ValueError as exc:
|
|
272
|
+
parser.error(str(exc))
|
|
273
|
+
|
|
274
|
+
if args.coordinator or app_config.coordinator:
|
|
275
|
+
set_coordinator_mode(True)
|
|
276
|
+
|
|
277
|
+
cwd = str(Path.cwd())
|
|
278
|
+
|
|
279
|
+
# 初始化记忆系统目录(按 git 仓库根隔离,非 git 目录回退到全局目录)
|
|
280
|
+
memory_dir = get_memory_dir(Path(cwd))
|
|
281
|
+
ensure_memory_dir(memory_dir)
|
|
282
|
+
|
|
283
|
+
# 发现并注册 skill,注入系统提示词
|
|
284
|
+
discover_skills(cwd)
|
|
285
|
+
skills_section = build_skills_prompt_section()
|
|
286
|
+
system_prompt = build_system_prompt(cwd=cwd, model=app_config.model, memory_dir=memory_dir)
|
|
287
|
+
if skills_section:
|
|
288
|
+
system_prompt = system_prompt + "\n\n" + skills_section
|
|
289
|
+
|
|
290
|
+
# coordinator 模式:追加 worker 工具上下文 + coordinator 系统提示词
|
|
291
|
+
worker_tool_names = ["Bash", "Read", "Edit", "Write", "Glob", "Grep"]
|
|
292
|
+
if is_coordinator_mode():
|
|
293
|
+
extra = get_coordinator_user_context(worker_tool_names)
|
|
294
|
+
worker_context = extra.get("workerToolsContext")
|
|
295
|
+
if worker_context:
|
|
296
|
+
system_prompt += "\n\n# Coordinator Context\n" + worker_context
|
|
297
|
+
system_prompt += "\n\n" + get_coordinator_system_prompt()
|
|
298
|
+
|
|
299
|
+
# 沙箱:--sandbox 参数或 super-code.json 中 sandbox.enabled = true 均启用
|
|
300
|
+
# 必须在 PermissionChecker 之前初始化,因为 auto_approve_if_sandboxed 依赖它
|
|
301
|
+
from core.sandbox import SandboxManager, SandboxConfig
|
|
302
|
+
_sandbox_cfg: SandboxConfig | None = None
|
|
303
|
+
if args.sandbox or (app_config.sandbox or {}).get("enabled"):
|
|
304
|
+
_sandbox_cfg = SandboxConfig.from_dict(app_config.sandbox)
|
|
305
|
+
if args.sandbox: # CLI 参数优先
|
|
306
|
+
_sandbox_cfg.enabled = True
|
|
307
|
+
sandbox = SandboxManager(_sandbox_cfg) if _sandbox_cfg else None
|
|
308
|
+
|
|
309
|
+
permissions = PermissionChecker(auto_approve=args.auto_approve or args.mode == "dream",
|
|
310
|
+
sandbox_manager=sandbox)
|
|
311
|
+
|
|
312
|
+
# Session setup
|
|
313
|
+
session_store = SessionStore(cwd=cwd, model=app_config.model)
|
|
314
|
+
|
|
315
|
+
# 费用追踪器
|
|
316
|
+
cost_tracker = CostTracker()
|
|
317
|
+
|
|
318
|
+
# WorkerManager:每个 worker 拥有独立的 engine 实例
|
|
319
|
+
def _build_worker_engine() -> Engine:
|
|
320
|
+
return Engine(
|
|
321
|
+
tools=[FileReadTool(sandbox_manager=sandbox), GlobTool(), GrepTool(),
|
|
322
|
+
BashTool(sandbox_manager=sandbox),
|
|
323
|
+
FileEditTool(sandbox_manager=sandbox), FileWriteTool(sandbox_manager=sandbox)],
|
|
324
|
+
system_prompt=get_worker_system_prompt(),
|
|
325
|
+
permission_checker=PermissionChecker(auto_approve=True),
|
|
326
|
+
provider=app_config.provider,
|
|
327
|
+
api_key=app_config.api_key,
|
|
328
|
+
base_url=app_config.base_url,
|
|
329
|
+
model=app_config.model,
|
|
330
|
+
max_tokens=app_config.max_tokens,
|
|
331
|
+
# worker 不挂 session_store(避免污染主会话 JSONL),显式提供 git-ai 钩子所需的最小信息
|
|
332
|
+
repo_dir=cwd,
|
|
333
|
+
agent_session_id=f"worker-{uuid.uuid4().hex[:8]}",
|
|
334
|
+
timeout=app_config.timeout,
|
|
335
|
+
model_profiles=app_config.model_profiles,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
worker_manager = WorkerManager(build_worker_engine=_build_worker_engine)
|
|
339
|
+
|
|
340
|
+
# 主 engine 工具列表(含 AgentTool + Skill)
|
|
341
|
+
mcp_tools = load_mcp_tools(cwd) # 读取 .mcp.json,启动 MCP server,返回工具代理列表
|
|
342
|
+
from tools.skill import SkillTool # 局部 import:避免 worker engine 误带
|
|
343
|
+
tools = [
|
|
344
|
+
FileReadTool(sandbox_manager=sandbox), GlobTool(), GrepTool(), BashTool(sandbox_manager=sandbox),
|
|
345
|
+
FileEditTool(sandbox_manager=sandbox), FileWriteTool(sandbox_manager=sandbox),
|
|
346
|
+
AskUserQuestionTool(), WebFetchTool(), WebSearchTool(),
|
|
347
|
+
AgentTool(worker_manager), SendMessageTool(worker_manager), TaskStopTool(worker_manager),
|
|
348
|
+
SkillTool(),
|
|
349
|
+
*mcp_tools,
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
engine = Engine(
|
|
353
|
+
tools=tools,
|
|
354
|
+
system_prompt=system_prompt,
|
|
355
|
+
permission_checker=permissions,
|
|
356
|
+
provider=app_config.provider,
|
|
357
|
+
api_key=app_config.api_key,
|
|
358
|
+
base_url=app_config.base_url,
|
|
359
|
+
model=app_config.model,
|
|
360
|
+
max_tokens=app_config.max_tokens,
|
|
361
|
+
session_store=session_store,
|
|
362
|
+
cost_tracker=cost_tracker,
|
|
363
|
+
timeout=app_config.timeout,
|
|
364
|
+
model_profiles=app_config.model_profiles,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# Plan mode manager — 先创建,再绑定 engine(避免循环依赖)
|
|
368
|
+
plan_manager = PlanModeManager()
|
|
369
|
+
plan_manager.bind_engine(engine)
|
|
370
|
+
plan_manager.set_permissions(permissions)
|
|
371
|
+
permissions.set_plan_manager(plan_manager)
|
|
372
|
+
|
|
373
|
+
# Compact service — 复用 engine 内部的 LLMClient
|
|
374
|
+
# cost_tracker 注入:压缩成功后记账 + 覆盖 last_input_tokens(底部栏 ctx 占用率)
|
|
375
|
+
compact_service = CompactService(client=engine._client, model=app_config.model,
|
|
376
|
+
cost_tracker=cost_tracker)
|
|
377
|
+
engine.set_compact_service(compact_service) # 注入 engine 供轮内紧急压缩使用
|
|
378
|
+
|
|
379
|
+
# 记忆 selector client:extract_model 为 dict 时独立构建(可换服务商 / 单独控制
|
|
380
|
+
# 推理强度,字段省略即回退主对话值);字符串 / 未配置时返回 None,复用主 client。
|
|
381
|
+
selector_client, selector_model = _build_selector_client(app_config)
|
|
382
|
+
if selector_client is None:
|
|
383
|
+
selector_client = engine._client
|
|
384
|
+
|
|
385
|
+
# 注入 worker 通知回调:engine 在每轮工具执行完成后 drain 通知队列,
|
|
386
|
+
# 将已完成 worker 的结果注入 conversation,coordinator 在同一 turn 内自动感知。
|
|
387
|
+
def _check_worker_notifications():
|
|
388
|
+
notifications = worker_manager.drain_notifications()
|
|
389
|
+
return "\n\n".join(notifications) if notifications else None
|
|
390
|
+
|
|
391
|
+
engine.set_on_after_tools(_check_worker_notifications)
|
|
392
|
+
|
|
393
|
+
def _new_session_store() -> SessionStore:
|
|
394
|
+
store = SessionStore(cwd=cwd, model=app_config.model)
|
|
395
|
+
engine.set_session_store(store)
|
|
396
|
+
return store
|
|
397
|
+
|
|
398
|
+
cmd_ctx = CommandContext( # 把当前程序运行所需的核心对象和状态打包成一个上下文对象,方便后续统一访问
|
|
399
|
+
engine=engine,
|
|
400
|
+
session_store=session_store,
|
|
401
|
+
console=console,
|
|
402
|
+
cwd=cwd,
|
|
403
|
+
model=app_config.model,
|
|
404
|
+
permissions=permissions,
|
|
405
|
+
new_session_store=_new_session_store,
|
|
406
|
+
compact_service=compact_service,
|
|
407
|
+
plan_manager=plan_manager,
|
|
408
|
+
worker_manager=worker_manager,
|
|
409
|
+
cost_tracker=cost_tracker,
|
|
410
|
+
memory_dir=memory_dir,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# --resume flag: load a past session before starting
|
|
414
|
+
if args.resume:
|
|
415
|
+
handle_command("resume", args.resume, cmd_ctx)
|
|
416
|
+
|
|
417
|
+
# Non-interactive mode
|
|
418
|
+
if args.print or args.prompt:
|
|
419
|
+
prompt_text = args.prompt or sys.stdin.read()
|
|
420
|
+
run_query(engine, prompt_text, print_mode=args.print, permissions=permissions)
|
|
421
|
+
return
|
|
422
|
+
|
|
423
|
+
# Interactive REPL
|
|
424
|
+
# 进程级 stdout/stderr patch:把所有 print / console.print 路由到 prompt_toolkit
|
|
425
|
+
# 的 StdoutProxy,避免后台线程(extract_memories 等)在 bordered_prompt 运行期间
|
|
426
|
+
# 直接写终端、把输出糊进输入框。
|
|
427
|
+
# - 无 active prompt 时(流式输出、spinner、命令执行)走真实 stdout,行为不变
|
|
428
|
+
# - 有 active prompt 时由 proxy 缓冲 + run_in_terminal 安全插入输入框上方
|
|
429
|
+
# - raw=True 保留 ANSI 序列,Rich 颜色不丢
|
|
430
|
+
# - Rich Console.file 是动态 property,自动跟随 sys.stdout,无需重建已有 console
|
|
431
|
+
# 任意环境异常(如 PyInstaller stderr=None / 无 Win32 console)直接降级 no-op,
|
|
432
|
+
# 不阻塞 REPL 启动。
|
|
433
|
+
try:
|
|
434
|
+
from prompt_toolkit.patch_stdout import StdoutProxy
|
|
435
|
+
_stdout_proxy = StdoutProxy(raw=True)
|
|
436
|
+
_orig_stdout, _orig_stderr = sys.stdout, sys.stderr
|
|
437
|
+
sys.stdout = _stdout_proxy
|
|
438
|
+
if sys.stderr is not None:
|
|
439
|
+
sys.stderr = _stdout_proxy
|
|
440
|
+
|
|
441
|
+
def _restore_stdio():
|
|
442
|
+
sys.stdout = _orig_stdout
|
|
443
|
+
if _orig_stderr is not None:
|
|
444
|
+
sys.stderr = _orig_stderr
|
|
445
|
+
try:
|
|
446
|
+
_stdout_proxy.close()
|
|
447
|
+
except Exception:
|
|
448
|
+
pass
|
|
449
|
+
atexit.register(_restore_stdio)
|
|
450
|
+
except Exception:
|
|
451
|
+
pass
|
|
452
|
+
|
|
453
|
+
_print_banner(app_config, cwd, session_store.session_id)
|
|
454
|
+
|
|
455
|
+
# 启动 git-ai daemon(如果已安装):后台线程,不阻塞 UI 显示。
|
|
456
|
+
# checkpoint 数据只存在 daemon 内存中,电脑重启后 daemon 不在运行,
|
|
457
|
+
# 提前启动可避免 checkpoint 丢失导致 commit 归属失败。
|
|
458
|
+
# 若后台启动尚未完成时发生首次编辑,before_edit() 内部会同步补启动(幂等)。
|
|
459
|
+
from features.git_ai import ensure_daemon
|
|
460
|
+
threading.Thread(target=ensure_daemon, daemon=True).start()
|
|
461
|
+
|
|
462
|
+
# 历史记录文件,保存在 memory_dir 同级目录
|
|
463
|
+
history_file = memory_dir.parent / "repl_history" # 用户可以通过上下方向键回溯之前的input
|
|
464
|
+
pt_history = FileHistory(str(history_file))
|
|
465
|
+
mode_ref = [False] # [False]=normal, [True]=plan,传给 bordered_prompt 共享状态
|
|
466
|
+
|
|
467
|
+
def _toggle_plan_mode() -> None:
|
|
468
|
+
"""Shift+Tab 回调:真正切换 plan mode,同时同步 UI 状态。
|
|
469
|
+
不打印任何消息——边框颜色变化已是足够的视觉反馈。"""
|
|
470
|
+
if plan_manager.is_active:
|
|
471
|
+
plan_manager.exit()
|
|
472
|
+
mode_ref[0] = False
|
|
473
|
+
else:
|
|
474
|
+
plan_manager.enter()
|
|
475
|
+
mode_ref[0] = True
|
|
476
|
+
|
|
477
|
+
last_ctrlc_time = 0.0
|
|
478
|
+
# 自动压缩熔断器:连续失败计数。达到阈值后本 session 不再尝试 autocompact,
|
|
479
|
+
# 避免一次失败(PTL/网络/超时等)后每一轮用户输入都重新触发并失败,浪费 API 调用。
|
|
480
|
+
# 仅对自动压缩生效;用户主动敲 /compact 不受限。成功压缩一次即清零。
|
|
481
|
+
consecutive_compact_failures = 0
|
|
482
|
+
MAX_CONSECUTIVE_COMPACT_FAILURES = 3
|
|
483
|
+
|
|
484
|
+
def _process_pending_notifications() -> bool:
|
|
485
|
+
"""Drain worker 通知队列并喂给 coordinator(plan mode 下跳过)。
|
|
486
|
+
|
|
487
|
+
Returns True if notifications were processed, False if queue was empty.
|
|
488
|
+
"""
|
|
489
|
+
if plan_manager.is_active:
|
|
490
|
+
return False
|
|
491
|
+
notifications = worker_manager.drain_notifications()
|
|
492
|
+
if not notifications:
|
|
493
|
+
return False
|
|
494
|
+
count = len(notifications)
|
|
495
|
+
console.print(f"[dim]Worker completed ({count}).[/dim]" if count > 1
|
|
496
|
+
else "[dim]Worker completed.[/dim]")
|
|
497
|
+
combined = "\n\n".join(notifications)
|
|
498
|
+
try:
|
|
499
|
+
run_query(engine, combined, print_mode=False, permissions=permissions)
|
|
500
|
+
except KeyboardInterrupt:
|
|
501
|
+
engine.cancel_turn()
|
|
502
|
+
console.print("\n[dim yellow]⏹ Turn cancelled[/dim yellow]")
|
|
503
|
+
except Exception as e:
|
|
504
|
+
engine.cancel_turn()
|
|
505
|
+
console.print(f"\n[red]Failed to process worker notifications:[/red] {_escape(str(e))}")
|
|
506
|
+
return True
|
|
507
|
+
|
|
508
|
+
while True:
|
|
509
|
+
try:
|
|
510
|
+
# 底部栏 ctx 占用率:用最近一次 API 返回的 input_tokens(精确计数),
|
|
511
|
+
# 除以模型 context window。0 = 尚未调用 API,显示层会隐藏。
|
|
512
|
+
ctx_usage = [cmd_ctx.cost_tracker.last_input_tokens if cmd_ctx.cost_tracker else 0,
|
|
513
|
+
get_context_window(cmd_ctx.model)]
|
|
514
|
+
# 图片附件收集(P0 多模态):bordered_prompt 内拖拽图片时按序 append 到这里
|
|
515
|
+
images_ref: list[dict] = []
|
|
516
|
+
user_input = bordered_prompt(console, history=pt_history,
|
|
517
|
+
completer=slash_completer, mode_ref=mode_ref,
|
|
518
|
+
on_mode_toggle=_toggle_plan_mode,
|
|
519
|
+
session_title=cmd_ctx.session_store._title,
|
|
520
|
+
ctx_usage=ctx_usage,
|
|
521
|
+
images_ref=images_ref,
|
|
522
|
+
# worker 进度面板:仅协调者模式启用
|
|
523
|
+
# (get_panel_status 是线程安全快照,含完成态;普通模式传 None 零影响)
|
|
524
|
+
worker_status_cb=worker_manager.get_panel_status
|
|
525
|
+
if is_coordinator_mode() else None)
|
|
526
|
+
if user_input is None:
|
|
527
|
+
user_input = ""
|
|
528
|
+
user_input = user_input.strip()
|
|
529
|
+
except KeyboardInterrupt:
|
|
530
|
+
now = time.monotonic()
|
|
531
|
+
if now - last_ctrlc_time <= _DOUBLE_PRESS_TIMEOUT:
|
|
532
|
+
console.print("\n[dim]Goodbye.[/dim]")
|
|
533
|
+
break
|
|
534
|
+
last_ctrlc_time = now
|
|
535
|
+
console.print("\n[dim yellow]Press Ctrl+C again to exit[/dim yellow]")
|
|
536
|
+
continue
|
|
537
|
+
except EOFError:
|
|
538
|
+
console.print("\n[dim]Goodbye.[/dim]")
|
|
539
|
+
break
|
|
540
|
+
|
|
541
|
+
last_ctrlc_time = 0.0
|
|
542
|
+
|
|
543
|
+
# 每次输入后先检查 worker 通知(空回车也能触发)。
|
|
544
|
+
# 同 mode 的待处理命令一次性 drain 喂给 LLM,N 条 <task-notification> 合并成 1 轮 run_query:
|
|
545
|
+
# - 减少 N 倍的 LLM 调用开销
|
|
546
|
+
# - 避免协调者对每条通知都独立"接电话",进而触发 N 次冗余的 git status 二次验证
|
|
547
|
+
_process_pending_notifications()
|
|
548
|
+
|
|
549
|
+
if not user_input:
|
|
550
|
+
continue
|
|
551
|
+
|
|
552
|
+
# 用户提交了新一轮输入:清除已完成 worker 记录(保留运行中),
|
|
553
|
+
# 面板完成态随之消失,不再常驻输入框上方
|
|
554
|
+
worker_manager.clear_finished()
|
|
555
|
+
|
|
556
|
+
if user_input.startswith("!") and len(user_input) > 1:
|
|
557
|
+
import subprocess
|
|
558
|
+
result = subprocess.run(user_input[1:].lstrip(),
|
|
559
|
+
shell=True, capture_output=True, text=True)
|
|
560
|
+
if result.stdout:
|
|
561
|
+
console.print(result.stdout.rstrip())
|
|
562
|
+
if result.stderr:
|
|
563
|
+
console.print(f"[red]{result.stderr.rstrip()}[/red]")
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
if user_input.lower() in ("exit", "quit", "/exit", "/quit"):
|
|
567
|
+
console.print("[dim]Goodbye.[/dim]")
|
|
568
|
+
break
|
|
569
|
+
|
|
570
|
+
# Slash commands
|
|
571
|
+
# 仅当 / 开头的 token 是**已注册的命令名**时才走命令分支;
|
|
572
|
+
# 否则(如用户问"/query 这个接口的路径是什么")按普通输入交给 LLM,
|
|
573
|
+
# 避免把用户的自然语言误判为 "Unknown command"。
|
|
574
|
+
parsed = parse_command(user_input)
|
|
575
|
+
if parsed and is_known_command(parsed[0]):
|
|
576
|
+
name, cmd_args = parsed
|
|
577
|
+
# 用 try/except 包住命令执行:斜杠命令(如 /compact)内部可能阻塞在
|
|
578
|
+
# 同步网络调用(OpenAI 非流式 create)上,用户按 Ctrl+C 时 KeyboardInterrupt
|
|
579
|
+
# 会从 ssl.recv 一路抛出。若不在此拦截,异常会逃出主循环,导致程序退出
|
|
580
|
+
# (PyInstaller 打包后还会显示 "Failed to execute script")。
|
|
581
|
+
# 普通对话路径有 run_query 内部的 catch 兜底,命令路径之前是裸调,是结构性缺口。
|
|
582
|
+
# 注:当前所有内置命令都遵循"先调 API、后改本地状态"的顺序,中途取消不会留下半状态。
|
|
583
|
+
try:
|
|
584
|
+
handle_command(name, cmd_args, cmd_ctx)
|
|
585
|
+
# /plan <desc> 可能设置 pending_query,触发一次模型查询
|
|
586
|
+
if cmd_ctx.pending_query:
|
|
587
|
+
query = cmd_ctx.pending_query
|
|
588
|
+
cmd_ctx.pending_query = None
|
|
589
|
+
run_query(engine, query, print_mode=False, permissions=permissions)
|
|
590
|
+
except KeyboardInterrupt:
|
|
591
|
+
# 清空可能残留的 pending_query,避免下一轮被误触发
|
|
592
|
+
cmd_ctx.pending_query = None
|
|
593
|
+
# 兜底回滚:斜杠命令内部若已向 session_store 写入半态(如调 LLM 时
|
|
594
|
+
# 留下孤立 tool_use),这里必须显式 cancel_turn;run_query 的兜底
|
|
595
|
+
# 够不到这条命令路径。多调一次是幂等的(checkpoint 为 None 时 no-op)。
|
|
596
|
+
engine.cancel_turn()
|
|
597
|
+
console.print("\n[dim yellow]⏹ Command cancelled[/dim yellow]")
|
|
598
|
+
except Exception as e:
|
|
599
|
+
# 命令执行抛业务异常(典型场景:/compact 调 LLM 失败、网络断开、鉴权
|
|
600
|
+
# 失败、PTL 兜底 3 次全失败、dirty session 触发 400 等)。
|
|
601
|
+
# 不接住的话异常会逃出主循环 → 整个 TUI 当场退出(PyInstaller 打包后
|
|
602
|
+
# 还会显示 "Failed to execute script"),用户被踢出会话。
|
|
603
|
+
# 仅 catch Exception(不含 KeyboardInterrupt/SystemExit):保留 Ctrl+C
|
|
604
|
+
# 与正常退出路径。
|
|
605
|
+
# 不调用 engine.cancel_turn():内置命令均不通过 engine.submit() 推进
|
|
606
|
+
# 对话轮次,cancel_turn 会按上一轮成功 turn 的 checkpoint 误删有效历史。
|
|
607
|
+
cmd_ctx.pending_query = None
|
|
608
|
+
console.print(f"\n[red]Command failed:[/red] {_escape(str(e))}")
|
|
609
|
+
continue
|
|
610
|
+
|
|
611
|
+
# Step 6:把"按相关性精选的记忆"作为 <system-reminder> 前缀注入到 user_input。
|
|
612
|
+
# 只在交互式普通对话路径生效;slash 命令路径不走这里,不会被污染。
|
|
613
|
+
# plan mode 跳过(与 extract / dream 同策略,避免计划阶段噪音)。
|
|
614
|
+
# 失败 / 无匹配 → 返回空串,prefix + user_input 自然降级为 user_input。
|
|
615
|
+
memory_prefix = ""
|
|
616
|
+
if not plan_manager.is_active:
|
|
617
|
+
try:
|
|
618
|
+
from features.find_relevant_memories import build_relevant_memories_prefix, will_need_side_query
|
|
619
|
+
# selector_client / selector_model 在启动时构建:
|
|
620
|
+
# extract_model 为 dict → 独立 client(可换服务商 / 单独控制推理强度)
|
|
621
|
+
# 字符串 / 未配置 → 复用主 client,仅换模型名(老行为)
|
|
622
|
+
_needs_llm = will_need_side_query(user_input, memory_dir)
|
|
623
|
+
if _needs_llm:
|
|
624
|
+
_mem_spinner = SpinnerManager(console)
|
|
625
|
+
_mem_spinner.start("Searching memories…", SPINNER_MEMORY)
|
|
626
|
+
try:
|
|
627
|
+
memory_prefix = build_relevant_memories_prefix(
|
|
628
|
+
user_input, memory_dir, selector_client, selector_model,
|
|
629
|
+
)
|
|
630
|
+
finally:
|
|
631
|
+
if _needs_llm:
|
|
632
|
+
_mem_spinner.stop()
|
|
633
|
+
except Exception:
|
|
634
|
+
# 任何意外(例如 side-query 模型 404)都不应阻塞用户提问
|
|
635
|
+
memory_prefix = ""
|
|
636
|
+
|
|
637
|
+
# 普通对话路径同样需要 API 异常兜底:402 余额不足、401 鉴权、provider 5xx、网络断开
|
|
638
|
+
# 等都会从 openai SDK 经 engine.submit() 一路冒上来,run_query 内部只接 Abort/Ctrl+C。
|
|
639
|
+
# 不接住的话异常会逃出主循环 → 整个 TUI 当场退出。处理方式与 worker 通知路径同源。
|
|
640
|
+
try:
|
|
641
|
+
# 多模态:有图片附件 → 组装 [text, image] 混合 content;无图 → 原样 str(零回归)
|
|
642
|
+
user_content = _assemble_image_content(memory_prefix, user_input, images_ref)
|
|
643
|
+
# 发送前拦截(P2):含图且能力表判定主模型不支持视觉 → 不 submit、不污染会话
|
|
644
|
+
if isinstance(user_content, list) and any(
|
|
645
|
+
isinstance(b, dict) and b.get("type") == "image" for b in user_content):
|
|
646
|
+
cap = supports_vision(cmd_ctx.model)
|
|
647
|
+
if cap is False:
|
|
648
|
+
console.print(
|
|
649
|
+
f"\n[red]当前模型 {cmd_ctx.model} 不支持图片输入。"
|
|
650
|
+
f"请更换支持视觉的主模型(如 deepseek-v4-flash-vision-exp)。[/red]"
|
|
651
|
+
)
|
|
652
|
+
continue # 未入 engine._messages / JSONL,无脏状态,直接等下一轮输入
|
|
653
|
+
run_query(engine, user_content, print_mode=False, permissions=permissions)
|
|
654
|
+
except KeyboardInterrupt:
|
|
655
|
+
engine.cancel_turn()
|
|
656
|
+
console.print("\n[dim yellow]⏹ Turn cancelled[/dim yellow]")
|
|
657
|
+
except Exception as e:
|
|
658
|
+
engine.cancel_turn()
|
|
659
|
+
console.print(f"\n[red]Query failed:[/red] {_escape(str(e))}")
|
|
660
|
+
|
|
661
|
+
# 用户 query 完成后,自动处理在此期间完成的 worker 通知。
|
|
662
|
+
# 循环收敛:coordinator 收到通知后可能 spawn 新 worker,新 worker 可能
|
|
663
|
+
# 在本轮回复期间完成,所以 drain 到队列为空才停止。
|
|
664
|
+
# 硬上限 MAX_AUTO_DRAIN_ROUNDS 防止 coordinator 无限 spawn→complete 循环。
|
|
665
|
+
MAX_AUTO_DRAIN_ROUNDS = 5
|
|
666
|
+
for _ in range(MAX_AUTO_DRAIN_ROUNDS):
|
|
667
|
+
if not _process_pending_notifications():
|
|
668
|
+
break
|
|
669
|
+
|
|
670
|
+
# 从 assistant 输出中提取 <system_reminder> 标签,追加到当天日志
|
|
671
|
+
for tag in extract_memory_tags(engine.last_assistant_text()):
|
|
672
|
+
append_to_daily_log(memory_dir, tag)
|
|
673
|
+
|
|
674
|
+
# Step 5:后台抽取本轮新增对话里值得持久化的偏好 / 事实。fire-and-forget;
|
|
675
|
+
# 内部已做:节流(<1 条新消息跳过)/ 互斥(重叠运行跳过)/ 主智能体已写则跳过。
|
|
676
|
+
# plan mode 下跳过,与 auto-dream 一致:避免计划过程中的临时讨论被当成记忆。
|
|
677
|
+
# 不再有"已保存"通知:后台 print 会撞进主线程 spinner/流式输出造成 UI 错位
|
|
678
|
+
# ("⠦ Thinking…💾 Saved ..."),且记忆系统有 /memory 显式入口可查,通知是
|
|
679
|
+
# 多余信息。
|
|
680
|
+
if not plan_manager.is_active:
|
|
681
|
+
from features.extract_memories import execute_extract_memories
|
|
682
|
+
execute_extract_memories(engine.get_messages(), app_config, memory_dir)
|
|
683
|
+
|
|
684
|
+
# auto-dream 门检查(plan mode 下跳过)
|
|
685
|
+
if not plan_manager.is_active and app_config.auto_dream:
|
|
686
|
+
if _trigger_auto_dream_bg(app_config, memory_dir, session_store):
|
|
687
|
+
console.print("[dim]Dreaming in background…[/dim]")
|
|
688
|
+
|
|
689
|
+
# 每轮结束后检查是否需要自动压缩(plan mode 下跳过;熔断后跳过)
|
|
690
|
+
if not plan_manager.is_active and consecutive_compact_failures < MAX_CONSECUTIVE_COMPACT_FAILURES:
|
|
691
|
+
messages = engine.get_messages()
|
|
692
|
+
if should_compact(messages, model=app_config.model):
|
|
693
|
+
console.print("[dim]Auto-compacting conversation context…[/dim]")
|
|
694
|
+
# 只捕获 Exception(不含 KeyboardInterrupt/SystemExit):保留用户 Ctrl+C
|
|
695
|
+
# 中断 autocompact 的既有逃逸路径,不把"用户取消"误计入熔断失败次数。
|
|
696
|
+
try:
|
|
697
|
+
handle_command("compact", "", cmd_ctx)
|
|
698
|
+
consecutive_compact_failures = 0 # 成功 → 计数清零
|
|
699
|
+
except Exception as e:
|
|
700
|
+
consecutive_compact_failures += 1
|
|
701
|
+
console.print(
|
|
702
|
+
f"[yellow]Auto-compact failed "
|
|
703
|
+
f"({consecutive_compact_failures}/{MAX_CONSECUTIVE_COMPACT_FAILURES}): "
|
|
704
|
+
f"{_escape(str(e))}[/yellow]"
|
|
705
|
+
)
|
|
706
|
+
if consecutive_compact_failures >= MAX_CONSECUTIVE_COMPACT_FAILURES:
|
|
707
|
+
console.print(
|
|
708
|
+
"[yellow]Auto-compact disabled for this session after repeated failures. "
|
|
709
|
+
"Use /compact to retry manually.[/yellow]"
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
# 退出时追加会话摘要到当天日志
|
|
713
|
+
messages = engine.get_messages()
|
|
714
|
+
if messages:
|
|
715
|
+
append_to_daily_log(memory_dir, f"Session ended. {len(messages)} messages exchanged.")
|
|
716
|
+
|
|
717
|
+
# 退出时关闭所有 MCP server 子进程
|
|
718
|
+
shutdown_mcp()
|
|
719
|
+
|
|
720
|
+
# 退出时打印费用摘要
|
|
721
|
+
if cost_tracker.total_cost_usd > 0 or cost_tracker.last_input_tokens > 0:
|
|
722
|
+
console.print(f"\n[dim]{cost_tracker.format_cost()}[/dim]")
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
if __name__ == "__main__":
|
|
726
|
+
main()
|