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,105 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Iterable
5
+
6
+
7
+ COORDINATOR_ENV_VAR = "SUPER_CODE_COORDINATOR"
8
+
9
+
10
+ def _is_env_truthy(value: str | None) -> bool:
11
+ if value is None:
12
+ return False
13
+ return value.strip().lower() not in {"", "0", "false", "no", "off"}
14
+
15
+
16
+ def is_coordinator_mode() -> bool:
17
+ return _is_env_truthy(os.getenv(COORDINATOR_ENV_VAR))
18
+
19
+
20
+ def set_coordinator_mode(enabled: bool) -> None:
21
+ if enabled:
22
+ os.environ[COORDINATOR_ENV_VAR] = "1"
23
+ else:
24
+ os.environ.pop(COORDINATOR_ENV_VAR, None)
25
+
26
+
27
+ def current_session_mode() -> str:
28
+ return "coordinator" if is_coordinator_mode() else "normal"
29
+
30
+
31
+ def get_coordinator_user_context(worker_tools: Iterable[str]) -> dict[str, str]:
32
+ """返回注入 coordinator system_prompt 的 worker 工具上下文。"""
33
+ if not is_coordinator_mode():
34
+ return {}
35
+ rendered_tools = ", ".join(sorted(set(worker_tools)))
36
+ return {
37
+ "workerToolsContext": (
38
+ "Workers launched via the Agent tool run in the background and have "
39
+ f"access to these tools: {rendered_tools}. "
40
+ "Worker completions arrive later as <task-notification> user messages."
41
+ )
42
+ }
43
+
44
+
45
+ def get_coordinator_system_prompt() -> str:
46
+ """协调者模式的系统提示词:负责分发任务给 worker,综合结果。"""
47
+ return """You are an AI assistant that orchestrates software engineering tasks across multiple workers.
48
+
49
+ ## Your Role
50
+ - Direct workers to research, implement and verify code changes
51
+ - Synthesize results and communicate with the user
52
+ - Answer questions directly when possible — don't delegate trivial work
53
+
54
+ ## Your Tools
55
+ - **Agent** - Spawn a new worker
56
+ - **SendMessage** - Continue an existing worker (send a follow-up to its `to` agent ID)
57
+ - **TaskStop** - Stop a running worker
58
+
59
+ Worker results arrive as user-role messages containing `<task-notification>` XML.
60
+
61
+ ## Task Workflow
62
+ - Research tasks: run workers in parallel
63
+ - Implementation tasks: one worker per file set
64
+ - After research: synthesize findings into a specific prompt before directing follow-up work
65
+
66
+ Never write "based on your findings" — synthesize the findings yourself and give workers specific instructions.
67
+
68
+ ## Trust Worker Reports (HARD RULES)
69
+
70
+ - When a worker reports status=completed and tells you which files it modified and which checks it ran, **trust the report**. Do NOT run `git status` / `git diff` / `git log` / Bash to re-verify the worker's work unless the worker explicitly reported a failure or residual risk.
71
+ - When a worker reports status=failed, **continue THE SAME worker** via SendMessage — it has the full error context from its own run. Do not spawn a new worker just to investigate what the failed worker did.
72
+ - Workers do not run any git write commands (no commit / add / push / branch). The user reviews and commits manually — do not ask workers to commit, and do not commit on their behalf.
73
+ - Worker results arrive batched: a single user message may contain multiple <task-notification> blocks back-to-back. Read them all, then respond once.
74
+ """
75
+
76
+
77
+ def get_worker_system_prompt() -> str:
78
+ """worker 的系统提示词:自主执行任务,结果返回给协调者。"""
79
+ return """You are a worker operating under a coordinator.
80
+
81
+ - Execute the assigned task directly and autonomously.
82
+ - You do not talk to the end user; your final answer goes back to the coordinator.
83
+ - If the prompt says research only, do not modify files.
84
+ - Do not try to spawn other workers.
85
+
86
+ ## Reporting Back
87
+
88
+ Your final message is the ONLY thing the coordinator sees. Make it self-contained:
89
+ - List the files you modified (full paths).
90
+ - List the verification you ran (tests / typecheck / lint) and the result of each (pass/fail with counts when relevant).
91
+ - If something failed or is risky, say so explicitly — do not paper over it.
92
+ - If you only researched, report the concrete findings (file paths, line numbers, types).
93
+
94
+ A vague "done" or "looks good" forces the coordinator to re-verify your work by hand. Be specific so it does not have to.
95
+
96
+ ## Git Discipline (HARD RULE)
97
+
98
+ You MUST NOT run any git write operation. The user reviews and commits changes manually.
99
+ Forbidden commands (non-exhaustive): `git commit`, `git add`, `git push`, `git reset --hard`,
100
+ `git checkout -- ...`, `git checkout <branch>`, `git branch`, `git rebase`, `git merge`, `git stash`,
101
+ `git cherry-pick`, `git tag`, `git remote ...`, `git clean -f`.
102
+
103
+ Read-only git is fine: `git status`, `git diff`, `git log`, `git show`, `git branch --show-current`.
104
+ """
105
+
@@ -0,0 +1,184 @@
1
+ """Token usage and cost tracking."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Pricing per million tokens ($/MTok)
10
+ # ---------------------------------------------------------------------------
11
+
12
+ @dataclass(frozen=True)
13
+ class _PricingTier:
14
+ input: float
15
+ output: float
16
+ cache_write: float = 0.0
17
+ cache_read: float = 0.0
18
+
19
+
20
+ # OpenAI 模型定价(按 $/MTok)
21
+ _TIER_GPT4O = _PricingTier(input=2.5, output=10.0)
22
+ _TIER_GPT4O_MINI = _PricingTier(input=0.15, output=0.60)
23
+ _TIER_GPT4_TURBO = _PricingTier(input=10.0, output=30.0)
24
+
25
+ # Claude 模型定价
26
+ _TIER_3_15 = _PricingTier(input=3.0, output=15.0, cache_write=3.75, cache_read=0.30)
27
+ _TIER_5_25 = _PricingTier(input=5.0, output=25.0, cache_write=6.25, cache_read=0.50)
28
+ _TIER_HAIKU_45 = _PricingTier(input=1.0, output=5.0, cache_write=1.25, cache_read=0.10)
29
+
30
+ # 前缀匹配,先匹配先赢
31
+ _MODEL_PRICING: list[tuple[str, _PricingTier]] = [
32
+ ("gpt-4o-mini", _TIER_GPT4O_MINI),
33
+ ("gpt-4o", _TIER_GPT4O),
34
+ ("gpt-4-turbo", _TIER_GPT4_TURBO),
35
+ ("claude-haiku-4-5", _TIER_HAIKU_45),
36
+ ("claude-opus-4", _TIER_5_25),
37
+ ("claude-sonnet", _TIER_3_15),
38
+ ("claude-3-5-sonnet", _TIER_3_15),
39
+ ("claude-3-7-sonnet", _TIER_3_15),
40
+ ]
41
+
42
+ _DEFAULT_TIER = _TIER_3_15
43
+
44
+
45
+ def _tier_for_model(model: str) -> _PricingTier | None:
46
+ model_lower = model.lower()
47
+ for prefix, tier in _MODEL_PRICING:
48
+ if prefix in model_lower:
49
+ return tier
50
+ # 未知 OpenAI 模型不计费
51
+ if model_lower.startswith(("gpt-", "o1", "o3", "o4")):
52
+ return None
53
+ return _DEFAULT_TIER
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Usage data
58
+ # ---------------------------------------------------------------------------
59
+
60
+ @dataclass
61
+ class ModelUsage:
62
+ input_tokens: int = 0
63
+ output_tokens: int = 0
64
+ cache_read_input_tokens: int = 0
65
+ cache_creation_input_tokens: int = 0
66
+ cost_usd: float = 0.0
67
+ pricing_known: bool = True
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Formatting helpers
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def _fmt_tokens(n: int) -> str:
75
+ """格式化 token 数,使用 k/m 后缀。"""
76
+ if n >= 1_000_000:
77
+ v = n / 1_000_000
78
+ return f"{v:.1f}m" if v != int(v) else f"{int(v)}m"
79
+ if n >= 1_000:
80
+ v = n / 1_000
81
+ return f"{v:.1f}k" if v != int(v) else f"{int(v)}k"
82
+ return str(n)
83
+
84
+
85
+ def _fmt_duration(seconds: float) -> str:
86
+ """格式化秒数为 'Xh Ym Zs' 格式。"""
87
+ seconds = max(0.0, seconds)
88
+ h, rem = divmod(int(seconds), 3600)
89
+ m, s = divmod(rem, 60)
90
+ if h > 0:
91
+ return f"{h}h {m}m {s}s"
92
+ if m > 0:
93
+ return f"{m}m {s}s"
94
+ return f"{s}s"
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # CostTracker
99
+ # ---------------------------------------------------------------------------
100
+
101
+ class CostTracker:
102
+ """跨 API 调用累计 token 用量和费用。"""
103
+
104
+ def __init__(self) -> None:
105
+ self._total_cost_usd: float = 0.0
106
+ self._model_usage: dict[str, ModelUsage] = {}
107
+ self._wall_start: float = time.monotonic()
108
+ self._last_input_tokens: int = 0
109
+
110
+ @property
111
+ def last_input_tokens(self) -> int:
112
+ """最近一次 API 调用的 input_tokens(反映当前上下文大小)。"""
113
+ return self._last_input_tokens
114
+
115
+ def set_last_input_tokens(self, n: int) -> None:
116
+ """手动覆盖 last_input_tokens(压缩成功后设为压缩后新历史的估算值)。
117
+
118
+ 压缩摘要请求的 input 是压缩前的旧历史,add_usage 会把它记成旧值,
119
+ 导致底部栏 ctx 占用率虚高;压缩后实际上下文是"摘要 + 尾部",
120
+ 先估算覆盖,下一轮对话 API 调用后自动纠正为精确值。
121
+ """
122
+ self._last_input_tokens = n
123
+
124
+ @property
125
+ def total_cost_usd(self) -> float:
126
+ return self._total_cost_usd
127
+
128
+ @staticmethod
129
+ def calculate_cost(model: str, usage: dict) -> float:
130
+ """计算单次 API 调用的费用(USD)。"""
131
+ tier = _tier_for_model(model)
132
+ if tier is None:
133
+ return 0.0
134
+ cost = (
135
+ usage.get("input_tokens", 0) * tier.input
136
+ + usage.get("output_tokens", 0) * tier.output
137
+ + usage.get("cache_read_input_tokens", 0) * tier.cache_read
138
+ + usage.get("cache_creation_input_tokens", 0) * tier.cache_write
139
+ ) / 1_000_000
140
+ return cost
141
+
142
+ def add_usage(self, model: str, usage: dict) -> float:
143
+ """记录 token 用量,返回本次调用费用。"""
144
+ cost = self.calculate_cost(model, usage)
145
+ self._total_cost_usd += cost
146
+ self._last_input_tokens = usage.get("input_tokens", 0)
147
+
148
+ mu = self._model_usage.setdefault(model, ModelUsage())
149
+ mu.input_tokens += usage.get("input_tokens", 0)
150
+ mu.output_tokens += usage.get("output_tokens", 0)
151
+ mu.cache_read_input_tokens += usage.get("cache_read_input_tokens", 0)
152
+ mu.cache_creation_input_tokens += usage.get("cache_creation_input_tokens", 0)
153
+ mu.cost_usd += cost
154
+ if _tier_for_model(model) is None:
155
+ mu.pricing_known = False
156
+ return cost
157
+
158
+ def format_cost(self) -> str:
159
+ """生成人类可读的费用摘要。"""
160
+ if not self._model_usage:
161
+ return "No API usage recorded."
162
+
163
+ wall_s = time.monotonic() - self._wall_start
164
+ unknown = any(not mu.pricing_known for mu in self._model_usage.values())
165
+ lines: list[str] = [f"Total cost: ${self._total_cost_usd:.4f}"]
166
+ if unknown:
167
+ lines.append("Pricing note: Costs may be inaccurate (unknown model pricing)")
168
+ lines.append(f"Total duration (wall): {_fmt_duration(wall_s)}")
169
+ lines.append("Usage by model:")
170
+
171
+ max_name = max(len(m) for m in self._model_usage)
172
+ for model, mu in sorted(self._model_usage.items()):
173
+ parts = [f"{_fmt_tokens(mu.input_tokens)} input",
174
+ f"{_fmt_tokens(mu.output_tokens)} output"]
175
+ if mu.cache_read_input_tokens:
176
+ parts.append(f"{_fmt_tokens(mu.cache_read_input_tokens)} cache read")
177
+ if mu.cache_creation_input_tokens:
178
+ parts.append(f"{_fmt_tokens(mu.cache_creation_input_tokens)} cache write")
179
+ detail = ", ".join(parts)
180
+ if not mu.pricing_known:
181
+ detail += ", pricing unavailable"
182
+ lines.append(f" {model.rjust(max_name)}: {detail} (${mu.cost_usd:.4f})")
183
+
184
+ return "\n".join(lines)
@@ -0,0 +1,326 @@
1
+ """Step 5 — 后台 extract_memories(独立子 agent)。
2
+
3
+ 设计目标:
4
+ 每个交互轮结束后,后台启动一个**独立**的小型 LLM agent,让它"看最近 N 条消息
5
+ 然后写记忆"。主智能体可能在主流程里忘了主动写记忆——后台抽取作为兜底,
6
+ 把"用户偏好 / 项目事实 / 反馈"持久化到 memory_dir。
7
+
8
+ 最小改动原则:
9
+ 1. 不修改 core/engine.py / core/permissions.py。后台 agent 用**自己专属的**
10
+ Engine + PermissionChecker 实例(与主对话完全隔离,避免 dream_mode 全局
11
+ 状态污染主流程)。
12
+ 2. 复用 permissions.enter_dream_mode 作为沙箱(语义已经匹配 extract 需求:
13
+ Read/Glob/Grep 全开;Edit/Write 限于 memory_dir 内)。
14
+ 3. 不复用主对话 prompt cache。当前 LLM 层没有 cache 抽象,
15
+ 第一版接受 cache miss、把"最近 N 条消息片段"以纯文本方式拼进 prompt。
16
+ 4. fire-and-forget:后台 daemon 线程跑,主流程不阻塞。
17
+
18
+ 游标与互斥(闭包内状态):
19
+ - last_processed_count: 已抽取过的消息总数;新一轮只看尾巴增量
20
+ - in_progress: 防止重叠运行
21
+ - 主智能体本轮已自己写了 memory_dir 文件 → 跳过(mutual exclusion)
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import threading
26
+ from pathlib import Path
27
+ from typing import Any, Callable
28
+
29
+ from core.engine import Engine
30
+ from core.permissions import PermissionChecker
31
+ from features.memory_scan import format_memory_manifest, scan_memory_files
32
+ from tools.file_edit import FileEditTool
33
+ from tools.file_read import FileReadTool
34
+ from tools.file_write import FileWriteTool
35
+ from tools.glob_tool import GlobTool
36
+ from tools.grep_tool import GrepTool
37
+
38
+ # extract agent 每次最多跑这么多 turn。turn 1 全部并行 Read,turn 2 全部并行 Write,
39
+ # 5 是给"先列目录再细读"的边界场景留余地。
40
+ MAX_EXTRACT_TURNS = 5
41
+
42
+ # 抽取 prompt 里"最近 N 条对话"的上限。控制 prompt 体积,避免一次抽取就把上下文撑满。
43
+ RECENT_MESSAGES_FOR_EXTRACT = 20
44
+
45
+ # 节流:少于这么多新增 model-visible 消息时跳过(避免 user 仅按回车也触发)。
46
+ MIN_NEW_MESSAGES = 1
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Prompt 构造
51
+ # ---------------------------------------------------------------------------
52
+
53
+ def _format_recent_excerpt(messages: list[dict], limit: int = RECENT_MESSAGES_FOR_EXTRACT) -> str:
54
+ """把最近 N 条 user/assistant 消息序列化为纯文本片段。
55
+
56
+ 跳过 tool_result(噪音多、价值低)与 reasoning_content(思考模型私有字段)。
57
+ 每条消息只取**文本块**,丢弃 tool_use 的结构化字段——抽取 agent 关注的是"用户
58
+ 说了什么 / 模型回了什么文字",工具调用细节通常不需要进入抽取上下文。
59
+ """
60
+ out: list[str] = []
61
+ # 只看 user/assistant,按时间正序保留尾部 limit 条
62
+ visible = [m for m in messages if m.get("role") in ("user", "assistant")]
63
+ for msg in visible[-limit:]:
64
+ role = msg.get("role", "?")
65
+ content = msg.get("content", "")
66
+ if isinstance(content, str):
67
+ text = content
68
+ elif isinstance(content, list):
69
+ parts: list[str] = []
70
+ for block in content:
71
+ if isinstance(block, dict):
72
+ if block.get("type") == "text" and block.get("text"):
73
+ parts.append(str(block["text"]))
74
+ # tool_result 形态:{"type": "tool_result", ...} —— 跳过
75
+ elif isinstance(block, str):
76
+ parts.append(block)
77
+ text = "\n".join(parts).strip()
78
+ else:
79
+ text = str(content)
80
+ if not text:
81
+ continue
82
+ # 单条消息超长截断,避免单条 assistant 长 markdown 输出撑爆 excerpt
83
+ if len(text) > 4000:
84
+ text = text[:4000] + "…[truncated]"
85
+ out.append(f"[{role}]\n{text}")
86
+ return "\n\n".join(out)
87
+
88
+
89
+ def build_extract_prompt(message_excerpt: str, existing_memories_manifest: str,
90
+ memory_dir: Path) -> str:
91
+ """构造后台抽取 agent 的 user prompt。
92
+
93
+ 设计要点(对齐 buildExtractAutoOnlyPrompt):
94
+ - 显式声明可用工具 + 写入只允许 memory_dir,让模型不要去试别的工具
95
+ - 预先注入"已有记忆清单"避免重复创建同名文件
96
+ - 强调"只看以下对话片段,不要去 grep/git 验证"——抽取阶段不应分心做研究
97
+ - 强约束 frontmatter 格式,对齐 Step 1/4 的类型枚举
98
+ """
99
+ manifest_block = (
100
+ f"\n\n## Existing memory files\n\n{existing_memories_manifest}\n\n"
101
+ "Check this list before writing — update an existing file rather than creating a duplicate."
102
+ if existing_memories_manifest
103
+ else ""
104
+ )
105
+
106
+ return f"""You are now acting as the memory extraction subagent. Analyze the conversation excerpt below and update the persistent memory system.
107
+
108
+ Memory directory: `{memory_dir}`
109
+
110
+ Available tools: Read, Grep, Glob, and Edit/Write **only for paths inside the memory directory**. Any attempt to edit code outside the memory directory will be denied. You have at most {MAX_EXTRACT_TURNS} turns.
111
+
112
+ Strategy:
113
+ - turn 1: issue all Read calls in parallel for files you might update
114
+ - turn 2: issue all Write/Edit calls in parallel
115
+ - Do NOT investigate or verify content beyond the excerpt (no grepping source code, no git commands)
116
+
117
+ If nothing is worth saving, do nothing and end your turn — extraction is best-effort.
118
+
119
+ ## Memory types
120
+
121
+ Use exactly one of: `user`, `feedback`, `project`, `reference`.
122
+ - user — facts about the user's role, preferences, knowledge
123
+ - feedback — corrections or confirmed approaches; body must include **Why:** and **How to apply:** lines
124
+ - project — ongoing work / decisions / incidents not derivable from code or git; convert relative dates to absolute
125
+ - reference — pointers to external systems (dashboards, ticket trackers)
126
+
127
+ ## What NOT to save
128
+ - Code patterns / architecture / file paths — derivable by reading the project
129
+ - Git history / who-changed-what
130
+ - Debugging fix recipes — they live in the commit message
131
+ - Ephemeral task details from this conversation
132
+
133
+ ## File format
134
+
135
+ Each memory is its own `.md` file with frontmatter:
136
+
137
+ ```markdown
138
+ ---
139
+ name: <kebab-case slug>
140
+ description: <one line; used by future relevance selection>
141
+ type: <user|feedback|project|reference>
142
+ ---
143
+
144
+ <body — Why/How to apply for feedback & project>
145
+ ```
146
+
147
+ After writing a topic file, append (or update) a one-line pointer in `{memory_dir}/MEMORY.md`:
148
+ `- [Title](file.md) — one-line hook`
149
+
150
+ Keep `MEMORY.md` under 200 lines.{manifest_block}
151
+
152
+ ## Conversation excerpt
153
+
154
+ {message_excerpt}
155
+ """
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # 沙箱:与 _run_dream 同模式 —— 后台 agent 用自己专属的 PermissionChecker,
160
+ # 避免 dream_mode 全局状态污染主对话
161
+ # ---------------------------------------------------------------------------
162
+
163
+ def _build_extract_engine(app_config: Any) -> tuple[Engine, PermissionChecker]:
164
+ """创建一个专门给 extract 后台 agent 用的临时 Engine。
165
+
166
+ 工具列表故意不含 Bash / Agent / AskUserQuestion / MCP 等:
167
+ - Bash 在 extract 阶段没有合理用途,关掉就不存在"判定只读"的麻烦
168
+ - Agent 等会让 extract 越权 spawn subagent
169
+ PermissionChecker.dream_mode 会进一步把 Edit/Write 锁死在 memory_dir 内。
170
+ """
171
+ perms = PermissionChecker(auto_approve=True)
172
+ engine = Engine(
173
+ tools=[FileReadTool(), GlobTool(), GrepTool(), FileEditTool(), FileWriteTool()],
174
+ system_prompt="",
175
+ permission_checker=perms,
176
+ provider=app_config.provider,
177
+ api_key=app_config.api_key,
178
+ base_url=app_config.base_url,
179
+ model=app_config.model,
180
+ max_tokens=app_config.max_tokens,
181
+ )
182
+ return engine, perms
183
+
184
+
185
+ def _run_extract_agent(prompt: str, memory_dir: Path, app_config: Any) -> list[str]:
186
+ """同步执行一次抽取。返回写入的文件绝对路径列表(去重,排除 MEMORY.md 单独统计)。
187
+
188
+ 异常一律吞掉,记日志—— extract 是 best-effort,绝不影响主流程。
189
+ """
190
+ engine, perms = _build_extract_engine(app_config)
191
+ perms.enter_dream_mode(str(memory_dir))
192
+ written: list[str] = []
193
+ seen: set[str] = set()
194
+ turns_seen = 0
195
+ try:
196
+ for event in engine.submit(prompt):
197
+ kind = event[0]
198
+ if kind == "tool_call":
199
+ # event: ("tool_call", tool_name, tool_input, activity, tool_use_id)
200
+ _, tool_name, tool_input, _act, _tid = event
201
+ if tool_name in ("Edit", "Write"):
202
+ fp = tool_input.get("file_path") if isinstance(tool_input, dict) else None
203
+ if isinstance(fp, str) and fp and fp not in seen:
204
+ seen.add(fp)
205
+ written.append(fp)
206
+ elif kind == "waiting":
207
+ # waiting = 模型已发完一段 text、准备 emit 工具调用;用它粗略数 turn
208
+ turns_seen += 1
209
+ if turns_seen >= MAX_EXTRACT_TURNS:
210
+ engine.abort()
211
+ except Exception:
212
+ # 静默失败:网络断、API key 失效、模型拒绝……不能让 extract 把 TUI 拖崩
213
+ pass
214
+ finally:
215
+ perms.exit_dream_mode()
216
+ return written
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # 公共入口:闭包式状态 + fire-and-forget 线程
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ def _last_assistant_wrote_memory(messages: list[dict], since_index: int,
225
+ memory_dir: Path) -> bool:
226
+ """判断从上次后台抽取游标之后的新消息里,主智能体有没有动过 memory 目录下的文件。避免重复。"""
227
+ memory_dir_abs = str(memory_dir.resolve())
228
+ for msg in messages[since_index:]:
229
+ if msg.get("role") != "assistant":
230
+ continue
231
+ content = msg.get("content", "")
232
+ if not isinstance(content, list):
233
+ continue
234
+ for block in content:
235
+ if not isinstance(block, dict):
236
+ continue
237
+ if block.get("type") != "tool_use":
238
+ continue
239
+ if block.get("name") not in ("Edit", "Write"):
240
+ continue
241
+ inp = block.get("input") or {}
242
+ fp = inp.get("file_path") if isinstance(inp, dict) else None
243
+ if not isinstance(fp, str):
244
+ continue
245
+ try:
246
+ resolved = str(Path(fp).resolve())
247
+ except OSError:
248
+ continue
249
+ if resolved.startswith(memory_dir_abs):
250
+ return True
251
+ return False
252
+
253
+
254
+ def init_extract_memories() -> tuple[
255
+ Callable[[list[dict], Any, Path], bool],
256
+ Callable[[], None],
257
+ ]:
258
+ """创建一个闭包,封装游标 / in_progress / 锁等可变状态。
259
+
260
+ 返回 (execute, reset):
261
+ execute(messages, app_config, memory_dir) -> bool
262
+ 尝试启动一次后台抽取;返回 True 表示真的起了线程,False 表示被跳过(节流 / 互斥 / 主智能体已写)。
263
+ reset() —— 仅供测试在 setup 时清零状态。
264
+ """
265
+ state = {
266
+ "last_processed_count": 0,
267
+ "in_progress": False,
268
+ }
269
+ lock = threading.Lock()
270
+
271
+ def execute(messages: list[dict], app_config: Any, memory_dir: Path) -> bool:
272
+ with lock:
273
+ if state["in_progress"]:
274
+ return False
275
+ new_count = len(messages) - state["last_processed_count"]
276
+ if new_count < MIN_NEW_MESSAGES:
277
+ return False
278
+ # 主智能体已写记忆 → 推进游标 + 跳过抽取
279
+ if _last_assistant_wrote_memory(messages, state["last_processed_count"], memory_dir):
280
+ state["last_processed_count"] = len(messages)
281
+ return False
282
+ state["in_progress"] = True
283
+ snapshot = list(messages) # 防止 worker 跑期间外部修改 messages
284
+
285
+ def _worker():
286
+ try:
287
+ excerpt = _format_recent_excerpt(snapshot)
288
+ if not excerpt.strip():
289
+ return
290
+ manifest = format_memory_manifest(scan_memory_files(memory_dir))
291
+ prompt = build_extract_prompt(excerpt, manifest, memory_dir)
292
+ _run_extract_agent(prompt, memory_dir, app_config)
293
+ finally:
294
+ with lock:
295
+ state["last_processed_count"] = len(snapshot)
296
+ state["in_progress"] = False
297
+
298
+ threading.Thread(target=_worker, daemon=True).start()
299
+ return True
300
+
301
+ def reset() -> None:
302
+ with lock:
303
+ state["last_processed_count"] = 0
304
+ state["in_progress"] = False
305
+
306
+ return execute, reset
307
+
308
+
309
+ # 模块级单例:与 features.memory._last_session_scan_at 同模式
310
+ _extractor: Callable[[list[dict], Any, Path], bool] | None = None
311
+ _reset: Callable[[], None] | None = None
312
+
313
+
314
+ def execute_extract_memories(messages: list[dict], app_config: Any, memory_dir: Path) -> bool:
315
+ """入口函数。第一次调用时懒初始化闭包。"""
316
+ global _extractor, _reset
317
+ if _extractor is None:
318
+ _extractor, _reset = init_extract_memories()
319
+ return _extractor(messages, app_config, memory_dir)
320
+
321
+
322
+ def reset_extract_memories() -> None:
323
+ """仅供测试 setUp/tearDown 使用,清零游标与 in_progress。"""
324
+ global _extractor, _reset
325
+ if _reset is not None:
326
+ _reset()