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.
Files changed (61) hide show
  1. commands/__init__.py +859 -0
  2. core/__init__.py +0 -0
  3. core/config.py +263 -0
  4. core/config_template.json +7 -0
  5. core/context.py +271 -0
  6. core/engine.py +635 -0
  7. core/file_state.py +279 -0
  8. core/llm.py +309 -0
  9. core/model_capabilities.py +45 -0
  10. core/permissions.py +204 -0
  11. core/sandbox/__init__.py +15 -0
  12. core/sandbox/blacklist.py +176 -0
  13. core/sandbox/config.py +38 -0
  14. core/sandbox/network.py +136 -0
  15. core/sandbox/path_protection.py +126 -0
  16. core/session.py +295 -0
  17. core/tool.py +45 -0
  18. features/__init__.py +0 -0
  19. features/compact.py +945 -0
  20. features/coordinator.py +105 -0
  21. features/cost_tracker.py +184 -0
  22. features/extract_memories.py +326 -0
  23. features/find_relevant_memories.py +376 -0
  24. features/git_ai.py +256 -0
  25. features/memory.py +531 -0
  26. features/memory_age.py +66 -0
  27. features/memory_scan.py +153 -0
  28. features/memory_types.py +34 -0
  29. features/plan.py +327 -0
  30. features/skills.py +300 -0
  31. features/worker_manager.py +232 -0
  32. mcp/__init__.py +0 -0
  33. mcp/client.py +112 -0
  34. mcp/loader.py +80 -0
  35. mcp/tool_proxy.py +59 -0
  36. super_code_assistant-3.3.6.dist-info/METADATA +45 -0
  37. super_code_assistant-3.3.6.dist-info/RECORD +61 -0
  38. super_code_assistant-3.3.6.dist-info/WHEEL +5 -0
  39. super_code_assistant-3.3.6.dist-info/entry_points.txt +2 -0
  40. super_code_assistant-3.3.6.dist-info/top_level.txt +7 -0
  41. tools/__init__.py +21 -0
  42. tools/agent.py +132 -0
  43. tools/ask_user.py +111 -0
  44. tools/bash.py +77 -0
  45. tools/file_edit.py +269 -0
  46. tools/file_read.py +206 -0
  47. tools/file_write.py +78 -0
  48. tools/glob_tool.py +81 -0
  49. tools/grep_tool.py +134 -0
  50. tools/plan_tools.py +75 -0
  51. tools/skill.py +108 -0
  52. tools/tool.py +44 -0
  53. tools/web_fetch.py +129 -0
  54. tools/web_search.py +220 -0
  55. tui/__init__.py +0 -0
  56. tui/app.py +726 -0
  57. tui/clipboard_image.py +42 -0
  58. tui/keylistener.py +140 -0
  59. tui/prompt.py +752 -0
  60. tui/query.py +200 -0
  61. tui/rendering.py +135 -0
commands/__init__.py ADDED
@@ -0,0 +1,859 @@
1
+ """Slash command system — parsing and dispatch."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rich.console import Console
10
+ from rich.markup import escape as _escape
11
+ from rich.table import Table
12
+
13
+ if TYPE_CHECKING:
14
+ from core.engine import Engine
15
+ from core.permissions import PermissionChecker
16
+ from core.session import SessionStore
17
+ from features.compact import CompactService
18
+ from features.cost_tracker import CostTracker
19
+ from features.plan import PlanModeManager
20
+
21
+
22
+ @dataclass
23
+ class CommandContext:
24
+ engine: Engine
25
+ session_store: "SessionStore | None"
26
+ console: Console
27
+ cwd: str
28
+ model: str
29
+ permissions: "PermissionChecker | None" = None
30
+ new_session_store: object = None # 创建新会话的工厂函数,当执行完/clear的时候,会清空当前会话,创建新会话
31
+ compact_service: "CompactService | None" = None
32
+ plan_manager: "PlanModeManager | None" = None
33
+ worker_manager: object = None # WorkerManager | None;Step 7 压缩后注入 in-flight worker 状态用
34
+ cost_tracker: "CostTracker | None" = None
35
+ pending_query: str | None = None # 命令执行后需要触发的后续 LLM 查询(如 /init 让模型扫描项目并写 AGENTS.md)
36
+ memory_dir: object = None # Path | None,记忆系统目录(Phase 6)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Parsing
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def parse_command(text: str) -> tuple[str, str] | None:
44
+ """If text starts with '/', return (command_name, args)."""
45
+ text = text.strip()
46
+ if not text.startswith("/"):
47
+ return None
48
+ parts = text.split(None, 1)
49
+ name = parts[0][1:].lower()
50
+ args = parts[1] if len(parts) > 1 else ""
51
+ return name, args
52
+
53
+
54
+ def is_known_command(name: str) -> bool:
55
+ """判断 name 是否是已注册的命令(内置命令或 skill)。
56
+
57
+ 用于让调用方区分"用户敲了未知斜杠命令"和"用户输入恰好以 / 开头但不是命令"
58
+ (如 `/query 接口的路径是什么` —— 这里 `/query` 是用户想问 LLM 的内容,
59
+ 不是命令名)。这种情况下应当把整段文本作为普通输入传给 LLM,而不是报
60
+ "Unknown command"。
61
+
62
+ 判定顺序与 handle_command 保持一致:先查内置 _HANDLERS,再查 skill 注册表。
63
+ """
64
+ if name in _HANDLERS:
65
+ return True
66
+ from features.skills import get_skill
67
+ return get_skill(name) is not None
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Handlers
72
+ # ---------------------------------------------------------------------------
73
+
74
+ # /init 用:让模型扫描项目并写 AGENTS.md。AGENTS.md 会被 src/core/context.py
75
+ # 自动注入到系统提示词,所以一次写入持续生效。
76
+ _INIT_PROMPT_TEMPLATE = """请分析当前代码库并创建(或改进)`AGENTS.md` 文件。
77
+
78
+ `AGENTS.md` 会在每次 super-code 会话启动时被自动注入到系统提示词(参见
79
+ src/core/context.py 的 _get_agents_md_section),所以它必须**精炼**——只写从代码里
80
+ 看不出来、但 super-code 每次都需要知道的事。
81
+
82
+ 工作目录:{cwd}{existing_block}
83
+
84
+ ## 要写什么
85
+
86
+ 1. **常用命令**:build / lint / test / 运行单个测试。重点是非标准的命令——
87
+ 能从 pyproject.toml / package.json / Makefile 直接看到的标准命令(如 `pytest`、
88
+ `npm test`)不必写。
89
+ 2. **高层架构**:需要读多个文件才能理解的"big picture"。例如模块间的协作关系、
90
+ 核心数据流、关键扩展点。
91
+
92
+ ## 怎么扫描
93
+
94
+ - 读 `pyproject.toml` / `package.json` / `Cargo.toml` / `go.mod` 等清单文件
95
+ - 读 `README*`(如果存在),把重要内容**提取**而不是复制
96
+ - 用 `Glob` / `Bash ls` 摸清顶层结构与关键入口
97
+ - 用 `Bash git log --oneline -20` 看 commit 信息风格(团队约定的 commit 格式
98
+ 通常需要写进 AGENTS.md)
99
+ - 检查这些 AI 配置文件,把重要部分纳入 AGENTS.md:
100
+ `.cursor/rules/`、`.cursorrules`、`.github/copilot-instructions.md`、
101
+ `.windsurfrules`、`.clinerules`、`.claude/CLAUDE.md`、`.super-code/`
102
+ - 用 `Grep` 找 lint / 格式化配置(ruff、eslint、prettier 等)
103
+
104
+ ## 避免什么
105
+
106
+ - 不要重复废话,比如"为新工具写单测"、"提供有用的错误信息"、"不要把 API key
107
+ 写进代码"——这些 super-code 已经知道
108
+ - 不要逐个列组件 / 文件结构——super-code 会用 `Glob` / `Read` 自己发现
109
+ - 不要写通用编程实践("写好代码"、"处理边界条件")
110
+ - 不要瞎编"Common Development Tasks"、"Tips for Development"、"Support" 这种
111
+ 段——只写从你**实际读到的文件**里得到的信息
112
+ - 不要重复 README 已有的内容——简短引用即可
113
+
114
+ ## 风格要求
115
+
116
+ - 使用**中文**编写
117
+ - 总长度 ≤ 150 行;超过 200 行就是在写文档而不是给 agent 提示
118
+ - 关键约定带"为什么",例如 "Commit 格式 `fix:V3.0.XX:xxx`(项目历史风格)"
119
+ - 文件开头加上:
120
+
121
+ ```
122
+ # AGENTS.md
123
+
124
+ 本文件给 super-code(以及其它 AI 编码助手)在本仓库中工作时提供必要上下文。
125
+ ```
126
+
127
+ ## 已存在 AGENTS.md 的处理
128
+
129
+ 如果上面提供了"当前 AGENTS.md 内容"块:
130
+ - 读懂现有内容,**保留用户手写、个性化、非显而易见的段落**
131
+ - 用具体 diff 的方式提改进建议(哪些段过时了、哪些命令路径变了、哪里可以补充)
132
+ - 经用户确认后再 Write 覆盖;不要静默覆盖
133
+
134
+ 完成后用一两句话告诉用户改了什么。{extra_hint}
135
+ """
136
+
137
+
138
+ def _cmd_help(ctx: CommandContext, args: str) -> None:
139
+ table = Table(title="Available Commands", show_header=True, header_style="bold cyan")
140
+ table.add_column("Command", style="green")
141
+ table.add_column("Description")
142
+ for name, desc, _ in _COMMAND_TABLE:
143
+ table.add_row(f"/{name}", desc)
144
+ ctx.console.print(table)
145
+
146
+
147
+ def _cmd_clear(ctx: CommandContext, args: str) -> None: # 清空当前引擎的对话历史并创建一个新的会话存储
148
+ ctx.engine.set_messages([])
149
+ # Phase 3: 清空旧会话的 snippet 状态
150
+ from core.file_state import clear_session_state
151
+ old_sid = getattr(ctx.session_store, "session_id", "") if ctx.session_store else ""
152
+ if old_sid:
153
+ clear_session_state(old_sid)
154
+ if callable(ctx.new_session_store):
155
+ new_store = ctx.new_session_store()
156
+ ctx.engine.set_session_store(new_store)
157
+ ctx.session_store = new_store
158
+ ctx.console.print("[green]✓[/green] Conversation cleared. New session started.")
159
+
160
+
161
+ def _cmd_history(ctx: CommandContext, args: str) -> None: # 列出当前工作目录下所有已保存的会话目录,包括ID、标题、时间等元数据
162
+ from core.session import SessionStore
163
+
164
+ sessions = SessionStore.list_sessions(ctx.cwd)
165
+ if not sessions:
166
+ ctx.console.print("[dim]No saved sessions for this directory.[/dim]")
167
+ return
168
+
169
+ table = Table(title="Session History", show_header=True, header_style="bold cyan")
170
+ table.add_column("#", style="dim", width=4)
171
+ table.add_column("ID", style="dim", width=10)
172
+ table.add_column("Title")
173
+ table.add_column("Messages", justify="right", width=8)
174
+ table.add_column("Updated", width=20)
175
+
176
+ for i, meta in enumerate(sessions, 1):
177
+ from core.session import format_local_time
178
+ table.add_row(
179
+ str(i),
180
+ meta.session_id[:8],
181
+ meta.title[:50],
182
+ str(meta.message_count),
183
+ format_local_time(meta.updated_at, "%Y-%m-%d %H:%M:%S"),
184
+ )
185
+ ctx.console.print(table)
186
+
187
+
188
+ def _cmd_resume(ctx: CommandContext, args: str) -> None: # 根据提供的序列号或者会话ID查找并加载历史会话,恢复对话记录
189
+ from core.session import SessionStore
190
+
191
+ sessions = SessionStore.list_sessions(ctx.cwd)
192
+ if not sessions:
193
+ ctx.console.print("[dim]No saved sessions to resume.[/dim]")
194
+ return
195
+
196
+ if not args:
197
+ # 无参数:启动交互式选择器,用方向键选择历史会话
198
+ from tui.prompt import pick_session
199
+ target_meta = pick_session(sessions)
200
+ if target_meta is None:
201
+ return # 用户取消
202
+ else:
203
+ target_meta = None
204
+ try:
205
+ idx = int(args.strip()) - 1
206
+ if 0 <= idx < len(sessions):
207
+ target_meta = sessions[idx]
208
+ except ValueError:
209
+ pass
210
+
211
+ if target_meta is None:
212
+ needle = args.strip().lower()
213
+ for meta in sessions:
214
+ if meta.session_id.lower().startswith(needle):
215
+ target_meta = meta
216
+ break
217
+
218
+ if target_meta is None:
219
+ ctx.console.print(f"[red]Session not found: {args}[/red]")
220
+ return
221
+
222
+ if ctx.session_store and target_meta.session_id == ctx.session_store.session_id:
223
+ ctx.console.print("[dim]Already in this session.[/dim]")
224
+ return
225
+
226
+ meta, messages = SessionStore.load_session(target_meta.session_id, ctx.cwd)
227
+ if not messages:
228
+ ctx.console.print("[red]Session has no messages.[/red]")
229
+ return
230
+
231
+ # Re-open the existing session store (no new file created)
232
+ from core.session import SessionStore as SS
233
+ resumed_store = SS(cwd=ctx.cwd, model=ctx.model,
234
+ session_id=target_meta.session_id)
235
+ resumed_store._message_count = target_meta.message_count
236
+ resumed_store._title = target_meta.title
237
+
238
+ ctx.engine.set_messages(messages)
239
+ ctx.engine.set_session_store(resumed_store)
240
+ ctx.session_store = resumed_store
241
+
242
+ # Phase 3: 从历史 tool_result metadata 重建 snippet 注册表
243
+ rebuilt = ctx.engine.rebuild_snippets_from_messages()
244
+ if rebuilt > 0:
245
+ ctx.console.print(f"[dim]Restored {rebuilt} file snippet(s) from session history.[/dim]")
246
+
247
+ ctx.console.print(
248
+ f"[green]✓[/green] Resumed session [bold]{target_meta.session_id[:8]}[/bold]: "
249
+ f"{_escape(target_meta.title[:50])} ({len(messages)} messages)"
250
+ )
251
+
252
+ # 展示会话中的可见消息(跳过 tool_result 列表)
253
+ from rich.markdown import Markdown
254
+ # 过滤出可展示的消息:user 纯文本 + assistant 有文本内容的
255
+ visible = []
256
+ for msg in messages:
257
+ role = msg.get("role", "")
258
+ content = msg.get("content", "")
259
+ if role == "user":
260
+ if isinstance(content, list):
261
+ continue # tool_result 列表,跳过
262
+ visible.append(msg)
263
+ elif role == "assistant":
264
+ text = (
265
+ " ".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text")
266
+ if isinstance(content, list) else str(content or "")
267
+ )
268
+ if text:
269
+ visible.append({**msg, "_text": text})
270
+
271
+ for msg in visible:
272
+ role = msg.get("role", "")
273
+ if role == "user":
274
+ ctx.console.print(f"\n[bold cyan]You:[/bold cyan] {_escape(msg.get('content', ''))}")
275
+ elif role == "assistant":
276
+ ctx.console.print("\n[bold green]Assistant:[/bold green]")
277
+ ctx.console.print(Markdown(msg["_text"]))
278
+
279
+
280
+ def _build_post_compact_attachments(ctx: CommandContext) -> list[dict]:
281
+ """Step 7-A/B/C:构造"压缩后状态恢复"附件列表。
282
+
283
+ 目前覆盖:
284
+ - Plan 状态:若仍在 plan 模式,注入 reminder + plan 文件当前内容
285
+ - Worker 状态:若有 in-flight worker,注入运行状态描述
286
+ - Skill 内容(Phase B):若本会话调用过 skill,注入其 body 截断版供模型回看
287
+ - 最近文件(Phase C):取最近 Read 过的 top-N 文件,重新读取最新内容并注入
288
+ 每一类都用 try/except 单独包裹,单项失败不影响其它附件,也不影响主压缩流程。
289
+ """
290
+ out: list[dict] = []
291
+
292
+ # Plan 重注入:plan 模式下让模型压缩后仍知道自己处于 plan 模式 + plan 文件内容
293
+ plan_manager = ctx.plan_manager
294
+ if plan_manager is not None:
295
+ try:
296
+ if plan_manager.is_active:
297
+ plan_path = plan_manager.plan_file_path or "(unknown)"
298
+ plan_content = plan_manager.get_plan_content() or "(empty)"
299
+ out.append({
300
+ "role": "user",
301
+ "content": (
302
+ f"[plan-mode-reminder] You are still in plan mode. "
303
+ f"Plan file: {plan_path}\n\n"
304
+ f"[plan-file-content]\n{plan_content}"
305
+ ),
306
+ })
307
+ except Exception:
308
+ # plan 状态读取异常不应阻塞压缩
309
+ pass
310
+
311
+ # Worker 重注入:把 in-flight worker 序列化成简短状态描述
312
+ worker_manager = ctx.worker_manager
313
+ if worker_manager is not None:
314
+ try:
315
+ running = worker_manager.get_running_status() # list[dict]
316
+ if running:
317
+ lines = ["[worker-status] In-flight async workers:"]
318
+ for w in running:
319
+ lines.append(
320
+ f" - task_id={w.get('task_id')} "
321
+ f"description={w.get('description')!r} "
322
+ f"tool_uses={w.get('tool_uses')} "
323
+ f"activity={w.get('activity')!r}"
324
+ )
325
+ out.append({"role": "user", "content": "\n".join(lines)})
326
+ except Exception:
327
+ pass
328
+
329
+ # Skill 重注入(Phase B):恢复"调用过哪些 skill"的内容,让压缩后模型仍能
330
+ # 按 skill 指令工作。注意是"为上下文回顾",不是"再次执行"——附件内容里
331
+ # 显式提示模型不要 re-execute。
332
+ try:
333
+ from features.skills import get_invoked_skills, get_skill
334
+ # 单 skill body 字符上限 ≈ 5K tokens(保头截尾)
335
+ SINGLE_MAX = 20_000
336
+ # 总字符预算 ≈ 25K tokens;超出后剩余 skill 仅占位不展开
337
+ TOTAL_BUDGET = 100_000
338
+ TRUNCATE_MARKER = (
339
+ "\n\n[... skill content truncated for compaction; "
340
+ "the full body remains earlier in the conversation if needed.]"
341
+ )
342
+ used = 0
343
+ for name in get_invoked_skills():
344
+ skill = get_skill(name)
345
+ if skill is None:
346
+ # skill 已被卸载(例如目录变更)→ 跳过,不抛错
347
+ continue
348
+ try:
349
+ body = skill.get_prompt("") or ""
350
+ except Exception:
351
+ continue
352
+ if not body.strip():
353
+ continue
354
+ # 单条上限:保头截尾,附 marker 提示模型可回看完整版
355
+ if len(body) > SINGLE_MAX:
356
+ body = body[:SINGLE_MAX] + TRUNCATE_MARKER
357
+ # 总预算检查:超了则注入占位不展开
358
+ if used + len(body) > TOTAL_BUDGET:
359
+ out.append({
360
+ "role": "user",
361
+ "content": (
362
+ f"[skill-attachment:{name}] (omitted: total skill content "
363
+ f"budget exceeded; refer to earlier conversation if needed)"
364
+ ),
365
+ })
366
+ continue
367
+ used += len(body)
368
+ out.append({
369
+ "role": "user",
370
+ "content": (
371
+ f"[skill-attachment:{name}] You previously invoked this skill. "
372
+ f"Below is its content for reference only — do NOT re-execute "
373
+ f"these instructions; they are already accounted for in the "
374
+ f"conversation summary.\n\n{body}"
375
+ ),
376
+ })
377
+ except Exception:
378
+ # skill 模块异常不应阻塞压缩
379
+ pass
380
+
381
+ # 最近文件重注入(Phase C):取最近 Read 过的 top-N 文件,重新读取最新内容
382
+ # 注入 messages,让压缩后模型仍能就这些文件的具体内容继续工作。
383
+ # 为什么"重新读"而不"复用历史 tool_result":文件可能在压缩之间被改过 / 模型
384
+ # 自己 Edit 过;当前内容才是模型继续工作需要的。
385
+ try:
386
+ from tools.file_read import FileReadTool
387
+
388
+ # 二进制扩展名黑名单:注入这些只会得到乱码占位,浪费 token。
389
+ # 后缀对比小写。点号包含。常见可读文本扩展名一律放行。
390
+ _BINARY_EXTS = {
391
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
392
+ ".pdf", ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
393
+ ".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a", ".lib",
394
+ ".class", ".pyc", ".pyo", ".jar", ".war", ".ear",
395
+ ".mp3", ".mp4", ".wav", ".avi", ".mkv", ".mov", ".webm", ".flac",
396
+ ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
397
+ ".db", ".sqlite", ".sqlite3", ".dat",
398
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
399
+ }
400
+ TOP_N = 5
401
+ SINGLE_MAX = 20_000 # 单文件字符上限(保头截尾)
402
+ TOTAL_BUDGET = 100_000 # 总字符预算
403
+ TRUNCATE_MARKER = (
404
+ "\n\n[... file content truncated for compaction; "
405
+ "use the Read tool with this exact path to fetch the full content if needed.]"
406
+ )
407
+
408
+ from pathlib import Path
409
+ recents = FileReadTool.get_recent_reads() # [(path, ts), ...] 按 ts desc
410
+ used = 0
411
+ emitted = 0
412
+ for fpath, _ts in recents:
413
+ if emitted >= TOP_N:
414
+ break
415
+ # 二进制扩展名黑名单
416
+ try:
417
+ ext = Path(fpath).suffix.lower()
418
+ except Exception:
419
+ ext = ""
420
+ if ext in _BINARY_EXTS:
421
+ continue
422
+
423
+ # 重新读取最新内容(文件可能已被改/删)
424
+ try:
425
+ p = Path(fpath)
426
+ if not p.exists() or not p.is_file():
427
+ out.append({
428
+ "role": "user",
429
+ "content": (
430
+ f"[file-attachment:{fpath}] (file no longer exists; "
431
+ f"refer to earlier conversation for prior content if needed)"
432
+ ),
433
+ })
434
+ emitted += 1
435
+ continue
436
+ content = p.read_text(encoding="utf-8", errors="replace")
437
+ except Exception:
438
+ # 读取失败(权限/编码/磁盘异常等)→ 跳过,不注入占位也不抛
439
+ continue
440
+
441
+ if not content:
442
+ continue
443
+
444
+ # 单文件上限:保头截尾 + marker
445
+ if len(content) > SINGLE_MAX:
446
+ content = content[:SINGLE_MAX] + TRUNCATE_MARKER
447
+
448
+ # 总预算:超了则给占位(让模型仍知道这个文件最近被读过)
449
+ if used + len(content) > TOTAL_BUDGET:
450
+ out.append({
451
+ "role": "user",
452
+ "content": (
453
+ f"[file-attachment:{fpath}] (omitted: total file content "
454
+ f"budget exceeded; use Read tool to fetch if needed)"
455
+ ),
456
+ })
457
+ emitted += 1
458
+ continue
459
+
460
+ used += len(content)
461
+ emitted += 1
462
+ out.append({
463
+ "role": "user",
464
+ "content": (
465
+ f"[file-attachment:{fpath}] You recently read this file. "
466
+ f"Below is its current content (re-read at compaction time) "
467
+ f"for your reference:\n\n{content}"
468
+ ),
469
+ })
470
+ except Exception:
471
+ # 文件重注入整体异常不应阻塞压缩
472
+ pass
473
+
474
+ return out
475
+
476
+
477
+ def _cmd_compact(ctx: CommandContext, args: str) -> None:
478
+ """压缩对话上下文,保留最近消息,用摘要替换历史消息。文件里的消息采用直接覆盖的方式"""
479
+ from features.compact import EmptySummaryError, estimate_tokens
480
+
481
+ if ctx.compact_service is None:
482
+ ctx.console.print("[dim]Compact service not available.[/dim]")
483
+ return
484
+
485
+ messages = ctx.engine.get_messages()
486
+ if len(messages) < 4:
487
+ ctx.console.print("[dim]Too few messages to compact.[/dim]")
488
+ return
489
+
490
+ pre_tokens = estimate_tokens(messages)
491
+ ctx.console.print(f"[dim]Compacting {len(messages)} messages (~{pre_tokens:,} tokens)…[/dim]")
492
+
493
+ # Step 7-A:构造压缩后重注入附件(plan / worker),与 compact 主流程解耦
494
+ attachments = _build_post_compact_attachments(ctx)
495
+
496
+ try:
497
+ new_msgs, _ = ctx.compact_service.compact(
498
+ messages, ctx.engine.system_prompt,
499
+ custom_instructions=args,
500
+ attachments=attachments,
501
+ # 手动/自动轮间压缩同样先剪枝:剪完若已低于自动触发阈值则跳过摘要,
502
+ # 避免"上下文本不紧张还白付一次摘要费"。
503
+ skip_if_under_threshold=True,
504
+ )
505
+ except EmptySummaryError as exc:
506
+ # 摘要 LLM 返回空文本(重试后仍空):中止压缩,历史保持原样。
507
+ # 覆盖写发生在下面,异常时不会执行 → 不会用空摘要吞掉历史(2026-08-16 事故)。
508
+ ctx.console.print(f"[red]✗ Compaction aborted:[/red] {exc}")
509
+ return
510
+ except Exception as exc:
511
+ ctx.console.print(f"[red]✗ Compaction failed:[/red] {exc}")
512
+ return
513
+ ctx.engine.set_messages(new_msgs)
514
+
515
+ # Phase 4: 压缩后 snippet 已过期(历史 read/edit 记录被摘要替代),
516
+ # 失效所有旧 snippet 强制模型重新 read 文件。
517
+ if ctx.session_store is not None:
518
+ sid = getattr(ctx.session_store, "session_id", "")
519
+ if sid:
520
+ from core.file_state import invalidate_all_snippets
521
+ invalidate_all_snippets(sid)
522
+
523
+ # 将压缩后的消息持久化到当前 session
524
+ if ctx.session_store is not None:
525
+ import json
526
+ from core.session import _serialize_message, _now_iso
527
+ with open(ctx.session_store._jsonl_path, "w", encoding="utf-8") as fh: # 压缩后的消息直接覆盖掉文件里的旧的历史消息
528
+ for msg in new_msgs:
529
+ safe = _serialize_message(msg)
530
+ safe["_ts"] = _now_iso()
531
+ fh.write(json.dumps(safe, ensure_ascii=False) + "\n")
532
+ ctx.session_store._message_count = len(new_msgs)
533
+ ctx.session_store._save_meta()
534
+
535
+ from features.compact import estimate_tokens as et
536
+ post_tokens = et(new_msgs)
537
+ ctx.console.print(
538
+ f"[green]✓[/green] Compacted: {pre_tokens:,} → {post_tokens:,} tokens "
539
+ f"({len(messages)} → {len(new_msgs)} messages)"
540
+ )
541
+
542
+
543
+
544
+ def _cmd_skills(ctx: CommandContext, args: str) -> None:
545
+ """列出所有可用的 skill。"""
546
+ from features.skills import list_skills
547
+
548
+ skills = list_skills(user_invocable_only=True)
549
+ if not skills:
550
+ ctx.console.print("[dim]No skills available.[/dim]")
551
+ return
552
+
553
+ table = Table(title="Available Skills", show_header=True, header_style="bold cyan")
554
+ table.add_column("Command", style="green")
555
+ table.add_column("Source", style="dim", width=8)
556
+ table.add_column("Description")
557
+ for s in skills:
558
+ hint = f" [{s.argument_hint}]" if s.argument_hint else ""
559
+ table.add_row(f"/{s.name}{hint}", s.source, s.description)
560
+ ctx.console.print(table)
561
+
562
+
563
+ def _cmd_cost(ctx: CommandContext, args: str) -> None:
564
+ """显示本次会话的 token 用量和费用摘要。"""
565
+ if ctx.cost_tracker is None:
566
+ ctx.console.print("[dim]Cost tracking not available.[/dim]")
567
+ return
568
+ ctx.console.print(ctx.cost_tracker.format_cost())
569
+
570
+
571
+ def _cmd_remember(ctx: CommandContext, args: str) -> None:
572
+ """手动向当天日志追加一条记录。给用户一个入口来记住一些重要的事"""
573
+ from features.memory import append_to_daily_log, ensure_memory_dir
574
+ from pathlib import Path
575
+
576
+ if not args.strip():
577
+ ctx.console.print("[dim]Usage: /remember <text>[/dim]")
578
+ return
579
+ memory_dir = Path(ctx.memory_dir) if ctx.memory_dir else None
580
+ if memory_dir is None:
581
+ ctx.console.print("[dim]Memory system not available.[/dim]")
582
+ return
583
+ ensure_memory_dir(memory_dir)
584
+ append_to_daily_log(memory_dir, args.strip())
585
+ ctx.console.print("[green]✓[/green] Remembered.")
586
+
587
+
588
+ def _cmd_memory(ctx: CommandContext, args: str) -> None:
589
+ """查看 / 列出 / 编辑记忆文件(Step 10)。
590
+
591
+ 用法(Option A:保留旧行为为默认,新增子用法都是显式参数):
592
+ /memory — 打印 MEMORY.md 索引(与旧行为完全一致)
593
+ /memory list — 列出 memory_dir 下所有 topic 文件(按 mtime 倒序)
594
+ /memory <number> — 用 $EDITOR 打开"list"中编号 N 的文件
595
+ /memory <substring> — 模糊匹配 filename / description,打开第一个命中
596
+
597
+ 设计原则:
598
+ - 旧无参数行为不变,老用户 muscle memory 受保护
599
+ - 不做交互式上下键选择器:保持函数纯命令式、易测、轻量
600
+ - 任何错误情况只 console.print 警告,绝不抛异常出栈
601
+ """
602
+ # 局部 import:避免 commands 模块导入时拉起整个记忆/编辑器调用链
603
+ from features.memory import load_memory_index
604
+ from features.memory_scan import scan_memory_files, format_memory_manifest
605
+
606
+ memory_dir = Path(ctx.memory_dir) if ctx.memory_dir else None
607
+ if memory_dir is None:
608
+ ctx.console.print("[dim]Memory system not available.[/dim]")
609
+ return
610
+
611
+ arg = args.strip()
612
+
613
+ # —— 无参数:保持旧行为,打印 MEMORY.md ——
614
+ if not arg:
615
+ index = load_memory_index(memory_dir)
616
+ if index:
617
+ ctx.console.print(index)
618
+ else:
619
+ ctx.console.print("[dim]No memories consolidated yet.[/dim]")
620
+ ctx.console.print(
621
+ "[dim]Tip: `/memory list` to browse topic files, "
622
+ "`/memory <number|substring>` to edit one.[/dim]"
623
+ )
624
+ return
625
+
626
+ # 提前 scan,list / number / substring 三条路径都需要
627
+ headers = scan_memory_files(memory_dir)
628
+
629
+ # —— list 子命令:纯打印清单(与 manifest 同格式),不进编辑器 ——
630
+ if arg.lower() == "list":
631
+ if not headers:
632
+ ctx.console.print("[dim]No topic memories yet.[/dim]")
633
+ return
634
+ # 在 manifest 每行前面补编号,便于用户接着敲 `/memory N`
635
+ manifest = format_memory_manifest(headers).splitlines()
636
+ ctx.console.print(f"[dim]Available memories ({len(headers)}):[/dim]")
637
+ for i, line in enumerate(manifest, 1):
638
+ ctx.console.print(f" {i}. {line[2:]}" if line.startswith("- ") else f" {i}. {line}")
639
+ ctx.console.print(
640
+ "[dim]Use `/memory <number>` to edit, "
641
+ "or `/memory <substring>` to search by name.[/dim]"
642
+ )
643
+ return
644
+
645
+ # —— 数字:按 1-based 编号定位 ——
646
+ if arg.isdigit():
647
+ idx = int(arg)
648
+ if not headers:
649
+ ctx.console.print("[dim]No topic memories to open.[/dim]")
650
+ return
651
+ if not (1 <= idx <= len(headers)):
652
+ ctx.console.print(
653
+ f"[red]Number out of range:[/red] expected 1..{len(headers)}, got {idx}."
654
+ )
655
+ return
656
+ _open_in_editor(ctx, headers[idx - 1].file_path)
657
+ return
658
+
659
+ # —— 子串模糊匹配(大小写不敏感):filename 与 description 都参与 ——
660
+ needle = arg.lower()
661
+ matches = [
662
+ h for h in headers
663
+ if needle in h.filename.lower()
664
+ or (h.description and needle in h.description.lower())
665
+ ]
666
+ if not matches:
667
+ ctx.console.print(f"[dim]No memory matches `{arg}`.[/dim]")
668
+ return
669
+ if len(matches) > 1:
670
+ # 多匹配时提示选择,但仍然打开第一个(保持单参数语义)
671
+ names = ", ".join(m.filename for m in matches[:5])
672
+ more = "" if len(matches) <= 5 else f", +{len(matches) - 5} more"
673
+ ctx.console.print(f"[dim]{len(matches)} matches: {names}{more}. Opening the first.[/dim]")
674
+ _open_in_editor(ctx, matches[0].file_path)
675
+
676
+
677
+ def _open_in_editor(ctx: CommandContext, path: Path) -> None:
678
+ """用 $EDITOR / notepad / vi 打开 path,阻塞直到用户保存退出。
679
+
680
+ 跨平台策略:
681
+ 1. $EDITOR / $VISUAL 环境变量优先(git 同款约定)
682
+ 2. Windows 兜底 notepad;其它平台兜底 vi
683
+ 3. 阻塞期间 spinner 不在跑(命令路径本就同步),直接接管终端即可
684
+ 任何失败只 console.print,不抛出。
685
+ """
686
+ import subprocess
687
+
688
+ if not path.exists():
689
+ ctx.console.print(f"[red]File not found:[/red] {path}")
690
+ return
691
+
692
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
693
+ if not editor:
694
+ editor = "notepad" if os.name == "nt" else "vi"
695
+
696
+ ctx.console.print(f"[dim]Opening {path.name} with {editor}…[/dim]")
697
+ try:
698
+ # shell=False:editor 取自环境变量或硬编码,不需要 shell 解析,避免注入风险
699
+ # 失败码(含用户在 vi 里 :cq)不当作异常,只提示
700
+ result = subprocess.run([editor, str(path)])
701
+ if result.returncode != 0:
702
+ ctx.console.print(
703
+ f"[dim]Editor exited with code {result.returncode}.[/dim]"
704
+ )
705
+ except FileNotFoundError:
706
+ ctx.console.print(
707
+ f"[red]Editor not found:[/red] `{editor}`. "
708
+ "Set $EDITOR to a valid command (e.g. `code -w`, `nano`)."
709
+ )
710
+ except Exception as exc:
711
+ ctx.console.print(f"[red]Failed to launch editor:[/red] {exc}")
712
+
713
+
714
+ def _cmd_dream(ctx: CommandContext, args: str) -> None:
715
+ """触发 dream 整合:复用 app._run_dream(),带权限隔离和锁保护。"""
716
+ from features.memory import (
717
+ ensure_memory_dir, try_acquire_lock, release_lock, record_consolidation,
718
+ )
719
+ from pathlib import Path
720
+ from tui.app import _run_dream
721
+
722
+ memory_dir = Path(ctx.memory_dir) if ctx.memory_dir else None
723
+ if memory_dir is None:
724
+ ctx.console.print("[dim]Memory system not available.[/dim]")
725
+ return
726
+
727
+ ensure_memory_dir(memory_dir)
728
+ if not try_acquire_lock(memory_dir):
729
+ ctx.console.print("[dim]Another dream consolidation is already running.[/dim]")
730
+ return
731
+
732
+ try:
733
+ _run_dream(ctx.engine, memory_dir, ctx.permissions, quiet=False)
734
+ record_consolidation(memory_dir)
735
+ ctx.console.print("[green]✓[/green] Dream consolidation complete.")
736
+ except Exception as exc:
737
+ ctx.console.print(f"[red]Dream failed: {exc}[/red]")
738
+ finally:
739
+ release_lock(memory_dir)
740
+
741
+
742
+ def _cmd_rename(ctx: CommandContext, args: str) -> None:
743
+ if ctx.session_store is None:
744
+ ctx.console.print("[dim]No active session to rename.[/dim]")
745
+ return
746
+ if not args.strip():
747
+ ctx.console.print(
748
+ f"Current session [bold]{ctx.session_store.session_id[:8]}[/bold]: "
749
+ f"{_escape(ctx.session_store._title or '(untitled)')}"
750
+ )
751
+ return
752
+ new_title = args.strip()[:80]
753
+ ctx.session_store._title = new_title
754
+ ctx.session_store._save_meta()
755
+ ctx.console.print(
756
+ f"[green]✓[/green] Session [bold]{ctx.session_store.session_id[:8]}[/bold] renamed to: "
757
+ f"{_escape(new_title)}"
758
+ )
759
+
760
+
761
+ def _cmd_init(ctx: CommandContext, args: str) -> None:
762
+ """扫描项目并生成(或更新)AGENTS.md。命令本身只组装 prompt,扫描和写文件由模型完成。"""
763
+ cwd = Path(ctx.cwd)
764
+ target = cwd / "AGENTS.md"
765
+
766
+ existing_block = ""
767
+ if target.exists():
768
+ try:
769
+ existing = target.read_text(encoding="utf-8", errors="replace")[:10_000]
770
+ existing_block = (
771
+ f"\n\n## 当前 AGENTS.md 内容(请提改进 diff,不要静默覆盖)\n\n"
772
+ f"```markdown\n{existing}\n```\n"
773
+ )
774
+ ctx.console.print(
775
+ f"[dim]发现已有 AGENTS.md({len(existing)} 字符),将提改进建议。[/dim]"
776
+ )
777
+ except OSError:
778
+ pass
779
+ else:
780
+ ctx.console.print("[dim]未发现 AGENTS.md,将生成新文件。[/dim]")
781
+
782
+ extra_hint = f"\n\n额外用户指示:{args.strip()}" if args.strip() else ""
783
+
784
+ ctx.pending_query = _INIT_PROMPT_TEMPLATE.format(
785
+ cwd=str(cwd),
786
+ existing_block=existing_block,
787
+ extra_hint=extra_hint,
788
+ )
789
+
790
+
791
+ # ---------------------------------------------------------------------------
792
+ # Registry
793
+ # ---------------------------------------------------------------------------
794
+
795
+ _COMMAND_TABLE: list[tuple[str, str, object]] = [
796
+ ("help", "Show available commands", _cmd_help),
797
+ ("clear", "Clear conversation, start new session", _cmd_clear),
798
+ ("history", "List saved sessions for this directory", _cmd_history),
799
+ ("resume", "Resume a past session [number|session-id]", _cmd_resume),
800
+ ("compact", "Compress conversation context [instructions]", _cmd_compact),
801
+ ("skills", "List all available skills", _cmd_skills),
802
+ ("cost", "Show token usage and cost summary", _cmd_cost),
803
+ ("remember", "Append a note to today's memory log [text]", _cmd_remember),
804
+ ("memory", "View MEMORY.md, list/open topic memories [list|<n>|<text>]", _cmd_memory),
805
+ ("dream", "Consolidate daily logs into persistent memories", _cmd_dream),
806
+ ("rename", "Rename current session [new-title]", _cmd_rename),
807
+ ("init", "Scan project and write/update AGENTS.md [extra hints]", _cmd_init),
808
+ ]
809
+
810
+ _HANDLERS: dict[str, object] = {name: h for name, _, h in _COMMAND_TABLE}
811
+
812
+
813
+ def handle_command(name: str, args: str, ctx: CommandContext) -> bool:
814
+ """Dispatch slash command. Returns True if handled.
815
+
816
+ 内置命令优先;未匹配时尝试作为 skill 名称执行。
817
+ """
818
+ handler = _HANDLERS.get(name)
819
+ if handler is not None:
820
+ handler(ctx, args) # type: ignore[operator]
821
+ return True
822
+
823
+ # 未匹配内置命令 → 尝试作为 skill 调用
824
+ from features.skills import get_skill
825
+ skill = get_skill(name)
826
+ if skill is not None:
827
+ return _execute_skill(skill, args, ctx)
828
+
829
+ ctx.console.print(f"[red]Unknown command: /{name}[/red] (try /help or /skills)")
830
+ return False
831
+
832
+
833
+ def _execute_skill(skill, args: str, ctx: CommandContext) -> bool:
834
+ """执行 skill:inline 模式注入当前对话,fork 模式在独立会话中运行。"""
835
+ from tui.query import run_query
836
+ from features.skills import mark_skill_invoked
837
+
838
+ prompt = skill.get_prompt(args)
839
+ if not prompt:
840
+ ctx.console.print(f"[dim]Skill /{skill.name} produced no prompt.[/dim]")
841
+ return True
842
+
843
+ ctx.console.print(f"[dim]Running skill: /{skill.name}…[/dim]")
844
+ # Phase B:记录这次调用,压缩时重注入 skill body 用
845
+ mark_skill_invoked(skill.name)
846
+
847
+ if skill.context == "fork":
848
+ # fork 模式:保存当前消息,独立运行,恢复原消息
849
+ saved = list(ctx.engine.get_messages())
850
+ ctx.engine.set_messages([])
851
+ try:
852
+ run_query(ctx.engine, prompt, print_mode=False, permissions=ctx.permissions)
853
+ finally:
854
+ ctx.engine.set_messages(saved) # 无论成功还是失败,都将消息列表恢复到执行前的状态
855
+ else:
856
+ # inline 模式:注入到当前对话
857
+ run_query(ctx.engine, prompt, print_mode=False, permissions=ctx.permissions)
858
+
859
+ return True