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
@@ -0,0 +1,126 @@
1
+ """文件路径保护:硬编码的禁止读写路径列表(不可配置的安全基线)。
2
+
3
+ Phase 3 在 Edit/Write/Read/Bash 工具中插入路径检查,堵住「绕开 Bash
4
+ 直接用文件工具写敏感路径」的旁路。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from core.config import get_portable_dir
13
+
14
+
15
+ # ============================================================
16
+ # 受保护路径初始化(模块加载时执行一次)
17
+ # ============================================================
18
+
19
+ def _home() -> str:
20
+ return os.path.expanduser("~")
21
+
22
+
23
+ def _resolve(p: str) -> str:
24
+ """展开 ~ + realpath,取规范绝对路径;路径不存在时回退到 expanduser。"""
25
+ expanded = os.path.expanduser(p)
26
+ try:
27
+ return os.path.realpath(expanded)
28
+ except OSError:
29
+ return os.path.normpath(expanded)
30
+
31
+
32
+ # ---------- 禁止写入 ----------
33
+ _portable = str(get_portable_dir())
34
+ _DENIED_WRITE_PREFIXES: list[str] = [
35
+ # 防沙箱逃逸:super-code 全局配置文件(仅保护 super-code.json 自身,
36
+ # 不保护 mcp.json / skills / plans / projects 等子目录)
37
+ _resolve("~/.config/super-code/super-code.json"),
38
+ # 防沙箱逃逸:便携分发目录中的关键配置文件
39
+ _resolve(os.path.join(_portable, "super-code.json")),
40
+ # SSH / GPG 密钥目录
41
+ _resolve("~/.ssh"),
42
+ _resolve("~/.gnupg"),
43
+ ]
44
+
45
+ if sys.platform == "win32":
46
+ windir = os.environ.get("SystemRoot", "C:\\Windows")
47
+ _DENIED_WRITE_PREFIXES.extend([
48
+ _resolve(windir),
49
+ _resolve(windir + "\\System32"),
50
+ _resolve("~\\AppData\\Roaming"),
51
+ ])
52
+ else:
53
+ _DENIED_WRITE_PREFIXES.extend([
54
+ _resolve("/etc"),
55
+ _resolve("/usr"),
56
+ _resolve("/bin"),
57
+ _resolve("/sbin"),
58
+ _resolve("/boot"),
59
+ _resolve("/dev"),
60
+ ])
61
+
62
+ # 去重 + 排序
63
+ _DENIED_WRITE_PREFIXES = sorted(set(_DENIED_WRITE_PREFIXES))
64
+
65
+ # ---------- 禁止读取 ----------
66
+ _DENIED_READ_PREFIXES: list[str] = [
67
+ _resolve("~/.ssh"),
68
+ _resolve("~/.gnupg"),
69
+ ]
70
+
71
+ if sys.platform == "win32":
72
+ _DENIED_READ_PREFIXES.extend([
73
+ _resolve("~\\AppData\\Roaming"),
74
+ ])
75
+ else:
76
+ _DENIED_READ_PREFIXES.extend([
77
+ _resolve("/proc"),
78
+ _resolve("/etc/ssl/private"),
79
+ ])
80
+
81
+ _DENIED_READ_PREFIXES = sorted(set(_DENIED_READ_PREFIXES))
82
+
83
+
84
+ # ============================================================
85
+ # 公开 API
86
+ # ============================================================
87
+
88
+ def check_path(file_path: str, operation: str = "write") -> tuple[bool, str]:
89
+ """检查路径是否受保护。
90
+
91
+ Args:
92
+ file_path: 文件路径(相对或绝对,支持 ~ 展开)
93
+ operation: "write" 或 "read"
94
+
95
+ Returns:
96
+ (True, "") 表示允许,(False, reason) 表示拒绝。
97
+ """
98
+ if not file_path:
99
+ return True, ""
100
+
101
+ try:
102
+ normalized = _resolve(file_path)
103
+ except Exception:
104
+ # 路径解析失败(如包含非法字符),仍然拒绝以策安全
105
+ return False, f"Sandbox blocked: invalid path: {file_path[:80]}"
106
+
107
+ prefixes = _DENIED_WRITE_PREFIXES if operation == "write" else _DENIED_READ_PREFIXES
108
+
109
+ for prefix in prefixes:
110
+ # 精确匹配前缀本身,或前缀后跟分隔符
111
+ if normalized == prefix or _path_starts_with(normalized, prefix):
112
+ preview = file_path[:80] + ("..." if len(file_path) > 80 else "")
113
+ return False, f"Sandbox blocked: {operation} to protected path: {preview}"
114
+
115
+ return True, ""
116
+
117
+
118
+ def _path_starts_with(path: str, prefix: str) -> bool:
119
+ """检查 path 是否在 prefix 之下(跨平台分隔符安全)。"""
120
+ if not path.startswith(prefix):
121
+ return False
122
+ rest = path[len(prefix):]
123
+ if not rest:
124
+ return True
125
+ seps = (os.sep, os.altsep) if os.altsep else (os.sep,)
126
+ return rest[0] in seps
core/session.py ADDED
@@ -0,0 +1,295 @@
1
+ """Session persistence — JSONL-based conversation storage.
2
+
3
+ Each session is a pair of files under ~/.config/super-code/sessions/{sanitized_cwd}/:
4
+ {session_id}.jsonl — one JSON object per message (append-only)
5
+ {session_id}.meta.json — lightweight metadata for fast listing
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import uuid
14
+ from dataclasses import asdict, dataclass
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ _SESSIONS_ROOT = Path.home() / ".config" / "super-code" / "sessions"
20
+
21
+ # 记忆注入 / skill 注入会把 <system-reminder>...</system-reminder> 前缀拼进第一条
22
+ # user 消息。贪婪匹配到最后一个闭合标签:前缀内部可能嵌套 freshness 的小块
23
+ # system-reminder,非贪婪会提前停在内层闭合标签处,残留文本污染标题。
24
+ _SYSTEM_REMINDER_RE = re.compile(r"<system-reminder>.*</system-reminder>\s*", re.DOTALL)
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Data types
29
+ # ---------------------------------------------------------------------------
30
+
31
+ @dataclass
32
+ class SessionMeta:
33
+ session_id: str
34
+ title: str
35
+ cwd: str
36
+ model: str
37
+ created_at: str
38
+ updated_at: str
39
+ message_count: int = 0
40
+ mode: str | None = None
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Helpers
45
+ # ---------------------------------------------------------------------------
46
+
47
+ def _sanitize_cwd(cwd: str) -> str: # 绝对路径转为安全的目录名称
48
+ """Convert an absolute path to a safe directory name."""
49
+ name = re.sub(r"[^a-zA-Z0-9]", "-", cwd) # 使用正则表达式,把所有不是英文大小写字母和数字的字符替换成 -
50
+ name = re.sub(r"-+", "-", name).strip("-")
51
+ if len(name) > 80:
52
+ h = hashlib.sha1(cwd.encode()).hexdigest()[:8] # 超过80字符,追加8位哈希
53
+ name = name[:80] + "-" + h
54
+ return name
55
+
56
+
57
+ def _now_iso() -> str:
58
+ return datetime.now(timezone.utc).isoformat()
59
+
60
+
61
+ def format_local_time(iso_str: str, fmt: str = "%Y-%m-%d %H:%M") -> str:
62
+ # 把存盘的 ISO 时间(带或不带 tz;无 tz 视为 UTC,兼容历史会话)转为系统本地时区显示
63
+ if not iso_str:
64
+ return ""
65
+ try:
66
+ dt = datetime.fromisoformat(iso_str)
67
+ except ValueError:
68
+ return iso_str[:len(fmt)] # 解析失败兜底返回原串前缀,避免崩
69
+ if dt.tzinfo is None:
70
+ dt = dt.replace(tzinfo=timezone.utc)
71
+ return dt.astimezone().strftime(fmt)
72
+
73
+
74
+ def _serialize_content(content: Any) -> Any: # 把复杂的 content 转成 JSON 可以保存的普通数据。递归的处理
75
+ """Recursively convert SDK objects to plain dicts for JSON serialization."""
76
+ if content is None or isinstance(content, str):
77
+ return content
78
+ if isinstance(content, list):
79
+ return [_serialize_content(item) for item in content]
80
+ if hasattr(content, "model_dump"): # Pydantic BaseModel (Anthropic SDK)
81
+ return content.model_dump()
82
+ if isinstance(content, dict):
83
+ return {k: _serialize_content(v) for k, v in content.items()}
84
+ return content
85
+
86
+
87
+ def _serialize_message(msg: dict) -> dict: # 返回消息字典的JSON副本,专门处理content字段
88
+ """Return a JSON-safe copy of a message dict."""
89
+ out: dict[str, Any] = {}
90
+ for key, val in msg.items():
91
+ out[key] = _serialize_content(val) if key == "content" else val
92
+ return out
93
+
94
+
95
+ def _extract_text(content: Any) -> str: # 尽力提取消息内容中的纯文本
96
+ """Best-effort plain text extraction from message content."""
97
+ if isinstance(content, str):
98
+ return content
99
+ if isinstance(content, list):
100
+ parts = []
101
+ for block in content:
102
+ if isinstance(block, dict):
103
+ parts.append(block.get("text", ""))
104
+ elif hasattr(block, "text"):
105
+ parts.append(getattr(block, "text", ""))
106
+ return " ".join(parts)
107
+ return str(content)
108
+
109
+
110
+ def _generate_title(content: Any) -> str: # 根据第一条用户消息创建会话标题
111
+ """Create a short title from the first user message."""
112
+ text = _extract_text(content).strip()
113
+ # 剥离注入层:标题应取自用户真实输入,而不是记忆检索 / skill 的
114
+ # <system-reminder> 前缀(该前缀由 app.py 拼在 user_input 前面一起落盘)
115
+ text = _SYSTEM_REMINDER_RE.sub("", text, count=1)
116
+ if not text:
117
+ return "(untitled)"
118
+ if len(text) <= 80:
119
+ return text
120
+ truncated = text[:80]
121
+ last_space = truncated.rfind(" ")
122
+ return (truncated[:last_space] if last_space > 40 else truncated) + "…"
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # SessionStore
127
+ # ---------------------------------------------------------------------------
128
+
129
+ class SessionStore:
130
+ """Manages JSONL persistence for a single session."""
131
+
132
+ def __init__(self, cwd: str, model: str,
133
+ session_id: str | None = None,
134
+ mode: str | None = None):
135
+ self.session_id = session_id or uuid.uuid4().hex
136
+ self.cwd = cwd
137
+ self.model = model
138
+ self.mode = mode
139
+ self._dir = _SESSIONS_ROOT / _sanitize_cwd(cwd)
140
+ self._dir.mkdir(parents=True, exist_ok=True)
141
+ self._jsonl_path = self._dir / f"{self.session_id}.jsonl"
142
+ self._meta_path = self._dir / f"{self.session_id}.meta.json"
143
+ self._message_count = 0
144
+ self._title: str = ""
145
+ self._created_at: str = _now_iso()
146
+ # ── Turn-level checkpoint(用于配合 engine.cancel_turn 回滚磁盘) ─────
147
+ # 记录某一轮 submit 开始时的 JSONL 字节位置 + 消息计数。一旦该轮被 abort,
148
+ # rollback_to_checkpoint() 会把文件截回这个位置,确保不会留下孤立 tool_use。
149
+ # 单字段元组而非两个字段:保证 (offset, count) 通过单条 STORE_ATTR 原子写入,
150
+ # 避免 KeyboardInterrupt 在两次赋值之间命中导致半态(offset 已设但 count=None,
151
+ # 触发 rollback 守卫的 None 检查,直接 return,孤立 tool_use 永留磁盘)。
152
+ # None 表示当前没有未完成的 turn checkpoint。
153
+ self._checkpoint: tuple[int, int] | None = None
154
+
155
+ # -- writing -----------------------------------------------------------
156
+
157
+ def append_message(self, message: dict) -> None: # 将一条消息持久化到JSONL文件中,同时更新元数据
158
+ """Persist one message (append to JSONL)."""
159
+ safe = _serialize_message(message)
160
+ safe["_ts"] = _now_iso()
161
+ with open(self._jsonl_path, "a", encoding="utf-8") as fh:
162
+ fh.write(json.dumps(safe, ensure_ascii=False) + "\n")
163
+ self._message_count += 1
164
+
165
+ # Auto-generate title from first user message
166
+ if not self._title and message.get("role") == "user":
167
+ self._title = _generate_title(message.get("content", ""))
168
+
169
+ self._save_meta()
170
+
171
+ def _save_meta(self) -> None: # 保存会话元数据到meta.json中
172
+ meta = SessionMeta(
173
+ session_id=self.session_id,
174
+ title=self._title or "(untitled)",
175
+ cwd=self.cwd,
176
+ model=self.model,
177
+ created_at=self._created_at,
178
+ updated_at=_now_iso(),
179
+ message_count=self._message_count,
180
+ mode=self.mode,
181
+ )
182
+ # 原子替换:直接 open(path, "w") 进入瞬间就清空旧文件,Ctrl+C 落在
183
+ # open 之后、json.dump 之前会留下空 meta.json,导致会话元数据丢失。
184
+ # 改为写临时文件 + os.replace(在 Windows / POSIX 上均为原子操作)。
185
+ tmp = self._meta_path.with_name(self._meta_path.name + ".tmp")
186
+ with open(tmp, "w", encoding="utf-8") as fh:
187
+ json.dump(asdict(meta), fh, ensure_ascii=False)
188
+ os.replace(tmp, self._meta_path)
189
+
190
+ # -- turn checkpoint / rollback ---------------------------------------
191
+
192
+ def mark_checkpoint(self) -> None:
193
+ """记录一个"turn 开始"检查点:当前 JSONL 字节偏移 + 消息数。
194
+
195
+ engine.submit() 在 turn 入口调用本方法。若该轮被 Ctrl+C 中断,
196
+ cancel_turn() 调用 rollback_to_checkpoint() 即可把磁盘文件截回这里,
197
+ 避免留下孤立的 tool_use(缺对应 tool_result),下次 /resume 才不会
198
+ 被 OpenAI 拒收:'Messages with role tool must be a response to a
199
+ preceding message with tool_calls'。
200
+
201
+ 约定:
202
+ - 文件不存在视为偏移 0;rollback 时 truncate 到 0 等于清空。
203
+ - 重复调用会覆盖上一次 checkpoint(正常 turn 完成后不会 rollback,
204
+ 下一轮直接覆盖即可,无需主动清理)。
205
+ """
206
+ try:
207
+ offset = self._jsonl_path.stat().st_size if self._jsonl_path.exists() else 0
208
+ except OSError:
209
+ # 文件系统异常时退化为不记 checkpoint:宁可不回滚也别截错位置
210
+ self._checkpoint = None
211
+ return
212
+ # 单句赋值:BUILD_TUPLE 后 STORE_ATTR 是单条字节码,对 KeyboardInterrupt 原子
213
+ self._checkpoint = (offset, self._message_count)
214
+
215
+ def rollback_to_checkpoint(self) -> None:
216
+ """把 JSONL 截回最近一次 mark_checkpoint 记录的字节位置。
217
+
218
+ 与 engine.cancel_turn() 配套:cancel_turn 删内存切片、本方法删磁盘尾巴,
219
+ 两者保证内存和磁盘严格一致。
220
+
221
+ 无 checkpoint 时是 no-op(防御性:不在 None 状态做截断)。
222
+ 操作完会同步重写 meta.json,让 message_count 反映真实状态。
223
+ """
224
+ if self._checkpoint is None:
225
+ return
226
+ offset, count = self._checkpoint
227
+ # 关键顺序:先截磁盘、再清状态。若反过来,KeyboardInterrupt 在两步之间命中时
228
+ # checkpoint 会永久丢失,第二次 Ctrl+C 也无法回滚,孤立 tool_use 永留磁盘。
229
+ # 当前顺序下中途被打断:checkpoint 仍在 → 下次 cancel_turn 重做 truncate,
230
+ # 同一 offset 重复截断是 O(1) 幂等 no-op,安全。
231
+ try:
232
+ if self._jsonl_path.exists():
233
+ # r+b 模式打开 + truncate:O(1) 截断,不读取文件内容
234
+ with open(self._jsonl_path, "r+b") as fh:
235
+ fh.truncate(offset)
236
+ self._message_count = count
237
+ self._save_meta()
238
+ except OSError:
239
+ # 截断失败时不抛——上层 cancel_turn 已经在 except AbortedError 路径上,
240
+ # 再抛只会把原始 AbortedError 掩盖。下次 /resume 仍可能脏,
241
+ # 但至少不会让 abort 路径自身崩溃。
242
+ # 注意:故意保留 checkpoint 不清,让下次 cancel_turn 有机会重试。
243
+ # 绝不能扩大到 except BaseException:那会吞掉 KeyboardInterrupt。
244
+ return
245
+ # truncate + meta 都成功后才清 checkpoint,单句赋值原子
246
+ self._checkpoint = None
247
+
248
+ # -- reading (class methods) -------------------------------------------
249
+
250
+ @classmethod
251
+ def load_messages(cls, session_id: str, cwd: str) -> list[dict]: # 从磁盘中读取指定会话的所有消息
252
+ """Read all messages for session_id from disk."""
253
+ path = _SESSIONS_ROOT / _sanitize_cwd(cwd) / f"{session_id}.jsonl"
254
+ if not path.exists():
255
+ return []
256
+ messages: list[dict] = []
257
+ with open(path, encoding="utf-8") as fh:
258
+ for line in fh:
259
+ line = line.strip()
260
+ if not line:
261
+ continue
262
+ try:
263
+ obj = json.loads(line)
264
+ except json.JSONDecodeError:
265
+ continue
266
+ obj.pop("_ts", None)
267
+ messages.append(obj)
268
+ return messages
269
+
270
+ @classmethod
271
+ def list_sessions(cls, cwd: str) -> list[SessionMeta]: # 返回指定CWD下可用的会话列表
272
+ """Return available sessions for cwd, most recent first."""
273
+ d = _SESSIONS_ROOT / _sanitize_cwd(cwd)
274
+ if not d.exists():
275
+ return []
276
+ results: list[SessionMeta] = []
277
+ for meta_file in d.glob("*.meta.json"):
278
+ try:
279
+ with open(meta_file, encoding="utf-8") as fh:
280
+ results.append(SessionMeta(**json.load(fh)))
281
+ except Exception:
282
+ continue
283
+ results.sort(key=lambda m: m.updated_at, reverse=True)
284
+ return results
285
+
286
+ @classmethod
287
+ def load_session(cls, session_id: str, cwd: str) -> tuple[SessionMeta | None, list[dict]]: #加载指定会话的元数据和消息内容。
288
+ """Load metadata + messages for session_id."""
289
+ d = _SESSIONS_ROOT / _sanitize_cwd(cwd)
290
+ meta_path = d / f"{session_id}.meta.json"
291
+ meta = None
292
+ if meta_path.exists():
293
+ with open(meta_path, encoding="utf-8") as fh:
294
+ meta = SessionMeta(**json.load(fh))
295
+ return meta, cls.load_messages(session_id, cwd)
core/tool.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class ToolResult:
9
+ content: str
10
+ is_error: bool = False
11
+ metadata: dict | None = None # Phase 1: snippet 元信息(snippet_id/行范围),不参与 LLM 上下文
12
+
13
+
14
+ class Tool(ABC):
15
+ @property
16
+ @abstractmethod
17
+ def name(self) -> str: ...
18
+
19
+ @property
20
+ @abstractmethod
21
+ def description(self) -> str: ...
22
+
23
+ @property
24
+ @abstractmethod
25
+ def input_schema(self) -> dict: ...
26
+
27
+ @abstractmethod
28
+ def execute(self, **kwargs) -> ToolResult: ...
29
+
30
+ def get_activity_description(self, **kwargs) -> str | None:
31
+ return None
32
+
33
+ def is_read_only(self) -> bool:
34
+ return False
35
+
36
+ def to_api_schema(self) -> dict:
37
+ """OpenAI function-calling format."""
38
+ return {
39
+ "type": "function",
40
+ "function": {
41
+ "name": self.name,
42
+ "description": self.description,
43
+ "parameters": self.input_schema,
44
+ },
45
+ }
features/__init__.py ADDED
File without changes