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
features/coordinator.py
ADDED
|
@@ -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
|
+
|
features/cost_tracker.py
ADDED
|
@@ -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()
|