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/memory.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""KAIROS memory system — append-only daily logs, dream consolidation, session persistence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import date, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# 配置根目录:用于派生全局 fallback 目录 + 项目级目录的 projects/ 容器。
|
|
12
|
+
# 单独抽出来便于测试 monkeypatch。
|
|
13
|
+
BASE_CONFIG_DIR = Path.home() / ".config" / "super-code"
|
|
14
|
+
|
|
15
|
+
# 全局 fallback 记忆目录:当无法识别为 git 仓库时使用(保留旧行为,避免破坏既有用户的盘上数据)。
|
|
16
|
+
GLOBAL_MEMORY_DIR = BASE_CONFIG_DIR / "memory"
|
|
17
|
+
|
|
18
|
+
# 向后兼容:旧代码 `from features.memory import MEMORY_DIR` 仍可工作;
|
|
19
|
+
# 新代码应改用 `get_memory_dir(cwd)` 以获得项目级隔离。
|
|
20
|
+
MEMORY_DIR = GLOBAL_MEMORY_DIR
|
|
21
|
+
|
|
22
|
+
# sanitize_path 单段最大长度:超出后截断 + 拼接 hash 后缀,避免触发文件系统 255 字节上限。
|
|
23
|
+
MAX_SANITIZED_LENGTH = 200
|
|
24
|
+
|
|
25
|
+
MAX_ENTRYPOINT_LINES = 200
|
|
26
|
+
# MEMORY.md 注入系统提示前的字节上限,与行上限配合使用:行少但单行很长(例如索引条目超过 150 字符)
|
|
27
|
+
# 也可能撑爆 prompt,所以需要"行 + 字节"双重 cap。
|
|
28
|
+
MAX_ENTRYPOINT_BYTES = 25_000
|
|
29
|
+
ENTRYPOINT_NAME = "MEMORY.md"
|
|
30
|
+
LOCK_FILE_NAME = ".consolidate-lock"
|
|
31
|
+
HOLDER_STALE_S = 3600
|
|
32
|
+
SESSION_SCAN_INTERVAL_S = 600
|
|
33
|
+
|
|
34
|
+
_last_session_scan_at: float = 0.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# 项目级记忆目录解析(Step 2)
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
# 不可见非字母数字字符替换正则。
|
|
42
|
+
_SANITIZE_RE = re.compile(r"[^a-zA-Z0-9]")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sanitize_path(name: str) -> str:
|
|
46
|
+
"""把任意路径字符串变成可作为目录名的 safe slug。
|
|
47
|
+
|
|
48
|
+
'/Users/foo/my-project' → '-Users-foo-my-project'
|
|
49
|
+
'C:\\\\Users\\\\foo' → 'C--Users-foo'
|
|
50
|
+
|
|
51
|
+
超过 MAX_SANITIZED_LENGTH(200 字符)时截断 + 拼接稳定 hash 后缀,
|
|
52
|
+
既避免触发文件系统单段 255 字节上限,又保留唯一性。
|
|
53
|
+
|
|
54
|
+
使用 hashlib.sha1 取前 8 个 hex 作为 hash(确定性、跨平台稳定)。
|
|
55
|
+
"""
|
|
56
|
+
sanitized = _SANITIZE_RE.sub("-", name)
|
|
57
|
+
if len(sanitized) <= MAX_SANITIZED_LENGTH:
|
|
58
|
+
return sanitized
|
|
59
|
+
# 截断 + hash 后缀。hash 输入用原始 name(不是 sanitized),保留完整信息熵。
|
|
60
|
+
import hashlib
|
|
61
|
+
digest = hashlib.sha1(name.encode("utf-8", errors="replace")).hexdigest()[:8]
|
|
62
|
+
return f"{sanitized[:MAX_SANITIZED_LENGTH]}-{digest}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _find_git_root(start: Path) -> Path | None:
|
|
66
|
+
"""从 start 目录向上查找 git 仓库根目录。
|
|
67
|
+
|
|
68
|
+
优先调用 `git rev-parse --show-toplevel`(处理 worktree、submodule、bare 仓库等
|
|
69
|
+
所有边界情况,最权威);找不到 git 可执行文件或非 git 仓库 → 返回 None。
|
|
70
|
+
|
|
71
|
+
异常一律降级为 None,绝不让记忆目录解析破坏主流程启动。
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
# 故意不传 text=True:Windows 中文环境的系统默认 ANSI 代码页(GBK 等)
|
|
75
|
+
# 解码 git 的 UTF-8 输出会失败,导致 stdout=None 后续 .strip() AttributeError。
|
|
76
|
+
# 用 bytes 模式 + 显式 utf-8/replace 解码,规避平台编码差异。
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
["git", "-C", str(start), "rev-parse", "--show-toplevel"],
|
|
79
|
+
capture_output=True,
|
|
80
|
+
timeout=3,
|
|
81
|
+
)
|
|
82
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
83
|
+
return None
|
|
84
|
+
if result.returncode != 0:
|
|
85
|
+
return None
|
|
86
|
+
raw = result.stdout or b""
|
|
87
|
+
top = raw.decode("utf-8", errors="replace").strip()
|
|
88
|
+
if not top:
|
|
89
|
+
return None
|
|
90
|
+
# git rev-parse 在 Windows 下返回 forward slash 路径,Path 能正确处理
|
|
91
|
+
return Path(top)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_memory_dir(cwd: Path | str | None = None) -> Path:
|
|
95
|
+
"""返回当前工作目录对应的记忆目录。
|
|
96
|
+
|
|
97
|
+
解析顺序:
|
|
98
|
+
1. 若 cwd 位于 git 仓库内 → <BASE_CONFIG_DIR>/projects/<sanitized-git-root>/memory/
|
|
99
|
+
同一仓库(含 worktree)共享同一目录。
|
|
100
|
+
2. 否则 → GLOBAL_MEMORY_DIR(保留旧行为,避免破坏非 git 工作流)。
|
|
101
|
+
|
|
102
|
+
参数:
|
|
103
|
+
cwd: 任何 PathLike 或 None;None → Path.cwd()。
|
|
104
|
+
"""
|
|
105
|
+
base = Path(cwd) if cwd is not None else Path.cwd()
|
|
106
|
+
git_root = _find_git_root(base)
|
|
107
|
+
if git_root is None:
|
|
108
|
+
return GLOBAL_MEMORY_DIR
|
|
109
|
+
# 用 absolute 字符串作为 sanitize 输入。
|
|
110
|
+
# 不解析 symlink:保持与 cwd 显示一致,避免不同入口指向同一 inode 但 sanitize 结果不同。
|
|
111
|
+
return BASE_CONFIG_DIR / "projects" / _sanitize_path(str(git_root)) / "memory"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# 目录工具
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def ensure_memory_dir(memory_dir: Path) -> None:
|
|
119
|
+
"""创建记忆目录及 logs 子目录。"""
|
|
120
|
+
memory_dir.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
(memory_dir / "logs").mkdir(parents=True, exist_ok=True)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def daily_log_path(memory_dir: Path, today: date | None = None) -> Path:
|
|
125
|
+
"""返回当天日志路径 memory_dir/logs/YYYY/MM/YYYY-MM-DD.md,自动创建父目录。"""
|
|
126
|
+
today = today or date.today()
|
|
127
|
+
path = memory_dir / "logs" / str(today.year) / f"{today.month:02d}" / f"{today.isoformat()}.md"
|
|
128
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
return path
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def append_to_daily_log(memory_dir: Path, entry: str) -> None:
|
|
133
|
+
"""向当天日志追加一条带时间戳的记录。"""
|
|
134
|
+
path = daily_log_path(memory_dir)
|
|
135
|
+
timestamp = datetime.now().strftime("%H:%M")
|
|
136
|
+
with path.open("a", encoding="utf-8") as f:
|
|
137
|
+
f.write(f"- [{timestamp}] {entry}\n")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# 记忆索引
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class EntrypointTruncation:
|
|
146
|
+
"""truncate_entrypoint_content 的结构化返回值。
|
|
147
|
+
|
|
148
|
+
- content: 截断后(含 WARNING 尾巴)的最终文本,可直接拼进 system prompt
|
|
149
|
+
- line_count / byte_count: **原始**行数 / 字节数(不是截断后),用于上层埋点 / 调试
|
|
150
|
+
- was_line_truncated / was_byte_truncated: 哪种 cap 触发了截断
|
|
151
|
+
"""
|
|
152
|
+
content: str
|
|
153
|
+
line_count: int
|
|
154
|
+
byte_count: int
|
|
155
|
+
was_line_truncated: bool
|
|
156
|
+
was_byte_truncated: bool
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def truncate_entrypoint_content(raw: str) -> EntrypointTruncation:
|
|
160
|
+
"""把 MEMORY.md 原文按"行 + 字节"双重 cap 截断,并附 WARNING 提示模型被截断。
|
|
161
|
+
|
|
162
|
+
1. 先按行截(MAX_ENTRYPOINT_LINES = 200)—— 行是自然边界,先按它切
|
|
163
|
+
2. 若仍超字节上限(MAX_ENTRYPOINT_BYTES = 25KB),在 ≤ cap 内的最后一个换行处下刀,
|
|
164
|
+
避免切到半行让模型看到一段破碎的索引条目
|
|
165
|
+
3. 在末尾拼一条 WARNING(带具体触发原因),让模型主动意识到自己只看到部分索引
|
|
166
|
+
|
|
167
|
+
传入空字符串 / 未超 cap 时不附 WARNING、不改动内容(除 strip 前后空白)。
|
|
168
|
+
"""
|
|
169
|
+
trimmed = raw.strip()
|
|
170
|
+
content_lines = trimmed.split("\n") if trimmed else []
|
|
171
|
+
line_count = len(content_lines)
|
|
172
|
+
# 按 UTF-8 字节数衡量;str.encode 一次即可(25KB 体量可忽略开销)
|
|
173
|
+
byte_count = len(trimmed.encode("utf-8"))
|
|
174
|
+
|
|
175
|
+
was_line_truncated = line_count > MAX_ENTRYPOINT_LINES
|
|
176
|
+
# 用**原始**字节数判定字节超限:行截之后体积会缩小,但 WARNING 的语义是
|
|
177
|
+
# "你的索引文件本身就过大",要让上层埋点拿到真实数据
|
|
178
|
+
was_byte_truncated = byte_count > MAX_ENTRYPOINT_BYTES
|
|
179
|
+
|
|
180
|
+
if not was_line_truncated and not was_byte_truncated:
|
|
181
|
+
return EntrypointTruncation(
|
|
182
|
+
content=trimmed,
|
|
183
|
+
line_count=line_count,
|
|
184
|
+
byte_count=byte_count,
|
|
185
|
+
was_line_truncated=False,
|
|
186
|
+
was_byte_truncated=False,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
truncated = (
|
|
190
|
+
"\n".join(content_lines[:MAX_ENTRYPOINT_LINES])
|
|
191
|
+
if was_line_truncated
|
|
192
|
+
else trimmed
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# 字节再切:在 MAX_ENTRYPOINT_BYTES 范围内找最后一个换行;找不到(单行超大)退化为硬切
|
|
196
|
+
if len(truncated.encode("utf-8")) > MAX_ENTRYPOINT_BYTES:
|
|
197
|
+
# 按字节定位切点:先编码再回切,避免多字节字符被一刀两断
|
|
198
|
+
encoded = truncated.encode("utf-8")[:MAX_ENTRYPOINT_BYTES]
|
|
199
|
+
# 在 bytes 上找最后一个 '\n'(0x0A 是单字节,安全)
|
|
200
|
+
cut_at = encoded.rfind(b"\n")
|
|
201
|
+
if cut_at > 0:
|
|
202
|
+
encoded = encoded[:cut_at]
|
|
203
|
+
# errors="ignore" 兜底:rfind 找不到换行硬切时可能切到多字节边界
|
|
204
|
+
truncated = encoded.decode("utf-8", errors="ignore")
|
|
205
|
+
|
|
206
|
+
# WARNING 文案:根据触发原因变化
|
|
207
|
+
if was_byte_truncated and not was_line_truncated:
|
|
208
|
+
reason = f"{byte_count} bytes (limit: {MAX_ENTRYPOINT_BYTES}) — index entries are too long"
|
|
209
|
+
elif was_line_truncated and not was_byte_truncated:
|
|
210
|
+
reason = f"{line_count} lines (limit: {MAX_ENTRYPOINT_LINES})"
|
|
211
|
+
else:
|
|
212
|
+
reason = f"{line_count} lines and {byte_count} bytes"
|
|
213
|
+
|
|
214
|
+
warning = (
|
|
215
|
+
f"\n\n> WARNING: {ENTRYPOINT_NAME} is {reason}. "
|
|
216
|
+
f"Only part of it was loaded. Keep index entries to one line under ~200 chars; "
|
|
217
|
+
f"move detail into topic files."
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return EntrypointTruncation(
|
|
221
|
+
content=truncated + warning,
|
|
222
|
+
line_count=line_count,
|
|
223
|
+
byte_count=byte_count,
|
|
224
|
+
was_line_truncated=was_line_truncated,
|
|
225
|
+
was_byte_truncated=was_byte_truncated,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def load_memory_index(memory_dir: Path) -> str:
|
|
230
|
+
"""读取 MEMORY.md,按"行 + 字节"双重 cap 截断后返回(含 WARNING 尾巴)。
|
|
231
|
+
|
|
232
|
+
不存在 / 读取异常 → 返回空字符串(保留旧行为,让上层 build_memory_system_section
|
|
233
|
+
走"no memories yet"分支)。返回值类型仍是 str,对外签名不变。
|
|
234
|
+
"""
|
|
235
|
+
path = memory_dir / ENTRYPOINT_NAME
|
|
236
|
+
if not path.exists():
|
|
237
|
+
return ""
|
|
238
|
+
try:
|
|
239
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
240
|
+
except OSError:
|
|
241
|
+
return ""
|
|
242
|
+
return truncate_entrypoint_content(text).content
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# 整合锁(防止多进程并发 dream)
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def _lock_path(memory_dir: Path) -> Path:
|
|
250
|
+
return memory_dir / LOCK_FILE_NAME
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def read_last_consolidated_at(memory_dir: Path) -> float:
|
|
254
|
+
"""返回上次整合的 epoch 秒数,从未整合则返回 0。"""
|
|
255
|
+
lp = _lock_path(memory_dir)
|
|
256
|
+
try:
|
|
257
|
+
return lp.stat().st_mtime
|
|
258
|
+
except OSError:
|
|
259
|
+
return 0.0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def try_acquire_lock(memory_dir: Path) -> bool:
|
|
263
|
+
"""尝试获取整合锁,成功返回 True。
|
|
264
|
+
|
|
265
|
+
锁文件持久存在,身兼两职:
|
|
266
|
+
- 互斥锁:存 PID,防止多进程并发 dream
|
|
267
|
+
- 时间戳:mtime 记录上次整合完成时间(供 should_auto_dream 读取)
|
|
268
|
+
"""
|
|
269
|
+
lp = _lock_path(memory_dir)
|
|
270
|
+
my_pid = os.getpid()
|
|
271
|
+
try:
|
|
272
|
+
stat = lp.stat()
|
|
273
|
+
age = datetime.now().timestamp() - stat.st_mtime
|
|
274
|
+
holder_pid = int(lp.read_text().strip())
|
|
275
|
+
# 同进程重入:上次 dream 正常完成后锁文件仍存在(mtime 已更新),
|
|
276
|
+
# 但 holder_pid 是自己,不可能有"另一个" dream 在跑 → 直接允许
|
|
277
|
+
if holder_pid == my_pid:
|
|
278
|
+
lp.write_text(str(my_pid))
|
|
279
|
+
return True
|
|
280
|
+
if age < HOLDER_STALE_S:
|
|
281
|
+
try:
|
|
282
|
+
os.kill(holder_pid, 0) # 检查进程是否存活
|
|
283
|
+
return False
|
|
284
|
+
except OSError:
|
|
285
|
+
pass
|
|
286
|
+
except (OSError, ValueError):
|
|
287
|
+
pass
|
|
288
|
+
lp.write_text(str(my_pid))
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def release_lock(memory_dir: Path) -> None:
|
|
293
|
+
"""更新锁文件 mtime 为当前时间(标记整合完成时间)。"""
|
|
294
|
+
lp = _lock_path(memory_dir)
|
|
295
|
+
try:
|
|
296
|
+
now = datetime.now().timestamp()
|
|
297
|
+
os.utime(lp, (now, now))
|
|
298
|
+
except OSError:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def record_consolidation(memory_dir: Path) -> None:
|
|
303
|
+
"""记录一次整合完成(手动 /dream 也调用此函数)。"""
|
|
304
|
+
lp = _lock_path(memory_dir)
|
|
305
|
+
lp.write_text(str(os.getpid()))
|
|
306
|
+
now = datetime.now().timestamp()
|
|
307
|
+
os.utime(lp, (now, now)) # 更新锁文件的访问时间和修改时间为当前时间
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def should_auto_dream(memory_dir: Path, min_hours: float, min_sessions: int,
|
|
311
|
+
current_session_id: str,
|
|
312
|
+
sessions_dir: Path | None = None) -> bool:
|
|
313
|
+
"""检查是否满足自动 dream 条件:距上次整合超过 min_hours 且新会话数 >= min_sessions。"""
|
|
314
|
+
global _last_session_scan_at
|
|
315
|
+
|
|
316
|
+
last = read_last_consolidated_at(memory_dir)
|
|
317
|
+
now = datetime.now().timestamp()
|
|
318
|
+
hours_since = (now - last) / 3600 if last > 0 else float("inf")
|
|
319
|
+
|
|
320
|
+
if hours_since < min_hours:
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
if now - _last_session_scan_at < SESSION_SCAN_INTERVAL_S:
|
|
324
|
+
return False
|
|
325
|
+
_last_session_scan_at = now
|
|
326
|
+
|
|
327
|
+
scan_dir = sessions_dir or (Path.home() / ".config" / "super-code" / "sessions")
|
|
328
|
+
count = 0
|
|
329
|
+
if scan_dir.exists():
|
|
330
|
+
for f in scan_dir.iterdir():
|
|
331
|
+
if f.suffix == ".jsonl" and current_session_id not in f.name and f.stat().st_mtime > last:
|
|
332
|
+
count += 1
|
|
333
|
+
return count >= min_sessions
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
# <system_reminder> 标签提取
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def extract_memory_tags(text: str) -> list[str]:
|
|
341
|
+
"""从 assistant 输出中提取 <system_reminder>...</system_reminder> 内容。"""
|
|
342
|
+
return [m.strip() for m in re.findall(r"<system_reminder>(.*?)</system_reminder>", text, re.DOTALL)]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def list_sessions_since(since_ts: float, sessions_dir: Path | None = None,
|
|
346
|
+
current_session_id: str = "") -> list[str]:
|
|
347
|
+
"""返回 since_ts 之后修改过的会话 ID 列表(排除当前会话)。"""
|
|
348
|
+
scan_dir = sessions_dir or (Path.home() / ".config" / "super-code" / "sessions")
|
|
349
|
+
result: list[str] = []
|
|
350
|
+
if not scan_dir.exists():
|
|
351
|
+
return result
|
|
352
|
+
for f in scan_dir.iterdir():
|
|
353
|
+
if (f.suffix == ".jsonl"
|
|
354
|
+
and current_session_id not in f.name
|
|
355
|
+
and f.stat().st_mtime > since_ts):
|
|
356
|
+
result.append(f.stem)
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
# 系统提示词段落 — 精简版
|
|
362
|
+
#
|
|
363
|
+
# 与旧版(~6800 tokens)的差异:
|
|
364
|
+
# - 保留 MEMORY.md 索引注入(常驻环境上下文,不受 query 语义限制)
|
|
365
|
+
# - 删除 TYPES_SECTION / WHAT_NOT_TO_SAVE / WHEN_TO_ACCESS / TRUSTING_RECALL
|
|
366
|
+
# 四段长文本 (~2000 tokens),细节由 /memory 命令和 find_relevant_memories 提供
|
|
367
|
+
# - 保留一句 TRUSTING_RECALL 护栏(推荐前先确认文件存在)
|
|
368
|
+
#
|
|
369
|
+
# 分工:
|
|
370
|
+
# MEMORY.md 索引 = 常驻环境上下文(身份、项目事实)
|
|
371
|
+
# find_relevant_memories = 按 query 精选最多 3 条摘要注入(技术细节、历史决策),
|
|
372
|
+
# 需要细节时模型用 Read 工具打开记忆文件
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def build_memory_system_section(memory_dir: Path) -> str:
|
|
377
|
+
"""生成记忆系统说明 + MEMORY.md 索引,拼接到系统提示词。
|
|
378
|
+
|
|
379
|
+
保留 MEMORY.md 内容注入(常驻环境上下文),
|
|
380
|
+
删除冗长的类型说明/保存格式/访问规则(省 ~2000 tokens)。
|
|
381
|
+
find_relevant_memories 仍按需注入精选记忆摘要,两者互补。
|
|
382
|
+
"""
|
|
383
|
+
preamble = (
|
|
384
|
+
f"你有持久化记忆系统,位于 `{memory_dir}/`。\n"
|
|
385
|
+
"读 MEMORY.md 了解索引,参照已有 .md 文件的 frontmatter 格式保存新记忆。\n"
|
|
386
|
+
"用户要求记住某事时立即保存;要求忘记某事时找到并删除对应文件。\n"
|
|
387
|
+
"基于记忆做推荐前,先用 Read/Grep 确认相关文件/函数仍然存在。\n"
|
|
388
|
+
"可用命令:/dream(整合记忆)、/memory(查看索引)、/remember <内容>(手动追加日志)。\n"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
index = load_memory_index(memory_dir)
|
|
392
|
+
if index:
|
|
393
|
+
return preamble + f"\n{index}\n"
|
|
394
|
+
return preamble + "\n尚无已整合的记忆。\n"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
# Dream 整合提示词
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
def build_dream_prompt(memory_dir: Path, transcript_dir: str = "", # 会话记录目录
|
|
402
|
+
session_ids: list[str] | None = None) -> str:
|
|
403
|
+
"""构建 dream 整合的四阶段提示词。"""
|
|
404
|
+
extra_parts: list[str] = []
|
|
405
|
+
extra_parts.append(
|
|
406
|
+
"**Tool constraints for this run:** Bash is not available. "
|
|
407
|
+
"Edit and Write are restricted to files within the memory directory. "
|
|
408
|
+
"Read, Grep, and Glob are unrestricted."
|
|
409
|
+
)
|
|
410
|
+
if session_ids:
|
|
411
|
+
extra_parts.append(
|
|
412
|
+
f"Sessions since last consolidation ({len(session_ids)}):\n"
|
|
413
|
+
+ "\n".join(f"- {sid}" for sid in session_ids)
|
|
414
|
+
)
|
|
415
|
+
extra = "\n\n".join(extra_parts)
|
|
416
|
+
extra_section = f"\n\n## Additional context\n\n{extra}" if extra else ""
|
|
417
|
+
|
|
418
|
+
transcript_line = ""
|
|
419
|
+
if transcript_dir:
|
|
420
|
+
transcript_line = (
|
|
421
|
+
f"\nSession transcripts: `{transcript_dir}` "
|
|
422
|
+
"(large JSONL files — grep narrowly, don't read whole files)\n"
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return f"""\
|
|
426
|
+
# Dream: Memory Consolidation
|
|
427
|
+
|
|
428
|
+
You are performing a dream — a reflective pass over your memory files. \
|
|
429
|
+
Synthesize what you've learned recently into durable, well-organized memories \
|
|
430
|
+
so that future sessions can orient quickly.
|
|
431
|
+
|
|
432
|
+
Memory directory: `{memory_dir}`
|
|
433
|
+
This directory already exists — write to it directly with the Write tool \
|
|
434
|
+
(do not run mkdir or check for its existence).
|
|
435
|
+
{transcript_line}
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
**⚠️ CRITICAL — 两种文件格式完全不同:**
|
|
439
|
+
|
|
440
|
+
| | Topic 文件(Phase 3 产出) | MEMORY.md(Phase 4 产出) |
|
|
441
|
+
|---|---|---|
|
|
442
|
+
| 格式 | YAML frontmatter + Markdown body | 纯 Markdown 链接列表 |
|
|
443
|
+
| 示例 | `---\\nname: foo\\n---\\n\\n内容` | `- [标题](foo.md) — 描述` |
|
|
444
|
+
| 有标题? | ✅ body 内可以有 | ❌ 绝对禁止 # / ## |
|
|
445
|
+
|
|
446
|
+
**MEMORY.md 每行固定格式(必须严格遵守):**
|
|
447
|
+
```
|
|
448
|
+
- [从 topic 文件 frontmatter name 摘的标题](filename.md) — 一行简短描述
|
|
449
|
+
```
|
|
450
|
+
- 必须以 `- ` 开头,然后是 `[标题]`,然后是 `(filename.md)`,然后是 ` — `(em-dash),最后是描述
|
|
451
|
+
- 不是 `filename.md - 描述` ❌
|
|
452
|
+
- 不是 `**filename.md** 描述` ❌
|
|
453
|
+
- 不是 `# Memory Index` / `## 用户记忆` ❌
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## Phase 1 — Orient
|
|
458
|
+
|
|
459
|
+
- Use Glob to list all files in `{memory_dir}/` to see what already exists
|
|
460
|
+
- Read `{ENTRYPOINT_NAME}` to understand the current index
|
|
461
|
+
- Skim existing topic files so you improve them rather than creating duplicates
|
|
462
|
+
|
|
463
|
+
## Phase 2 — Gather recent signal
|
|
464
|
+
|
|
465
|
+
Look for new information worth persisting:
|
|
466
|
+
1. **Daily logs** (`logs/YYYY/MM/YYYY-MM-DD.md`) if present
|
|
467
|
+
2. **Existing memories that drifted** — facts that contradict something you see now
|
|
468
|
+
|
|
469
|
+
## Phase 3 — Consolidate
|
|
470
|
+
|
|
471
|
+
For each thing worth remembering, write or update a memory file at the top \
|
|
472
|
+
level of the memory directory.
|
|
473
|
+
|
|
474
|
+
**File format** (frontmatter required):
|
|
475
|
+
```
|
|
476
|
+
---
|
|
477
|
+
name: kebab-case-slug
|
|
478
|
+
description: one-line summary
|
|
479
|
+
type: user | feedback | project | reference
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
body content
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
**Types:**
|
|
486
|
+
- `user` — 用户角色、偏好、知识背景(永远相关)
|
|
487
|
+
- `feedback` — 用户纠正过的行为规则,含 **Why:** 和 **How to apply:** 行
|
|
488
|
+
- `project` — 代码/git 推导不出的项目事实、决策、deadline;相对日期转绝对日期
|
|
489
|
+
- `reference` — 外部系统的资源指针
|
|
490
|
+
|
|
491
|
+
**不要保存:**
|
|
492
|
+
- 代码结构、架构、文件路径 — 读代码就能推导
|
|
493
|
+
- git 历史、谁改了什么 — git log/blame 是权威
|
|
494
|
+
- 调试方案/修复配方 — fix 在代码里,commit message 有上下文
|
|
495
|
+
- AGENTS.md 已记录的内容
|
|
496
|
+
- 当前会话的临时任务状态
|
|
497
|
+
|
|
498
|
+
## Phase 4 — Prune and index
|
|
499
|
+
|
|
500
|
+
Update `{ENTRYPOINT_NAME}` so it stays under {MAX_ENTRYPOINT_LINES} lines.
|
|
501
|
+
|
|
502
|
+
**严格格式要求:MEMORY.md 是一个纯列表文件 —— 第一行直接开始第一个条目,没有任何标题。**
|
|
503
|
+
|
|
504
|
+
- 每行一条:`- [标题](filename.md) — 一行描述(≤150字符)`
|
|
505
|
+
- 禁止任何标题:不能有 `#`、`##`,不能有"Memory Index"、"用户记忆"等分类标签
|
|
506
|
+
- 禁止用粗体文件名(`**filename**`)代替链接
|
|
507
|
+
- 禁止创造 user/feedback/project/reference 以外的分类
|
|
508
|
+
- 整个文件是纯平铺列表,无前言、无标题、无分段
|
|
509
|
+
|
|
510
|
+
❌ 错误(会被拒绝):
|
|
511
|
+
```
|
|
512
|
+
# Memory Index
|
|
513
|
+
## 用户
|
|
514
|
+
- [用户背景](user.md) — ...
|
|
515
|
+
```
|
|
516
|
+
❌ 错误(缺少 `- ` 前缀和 `[]()` 链接):
|
|
517
|
+
```
|
|
518
|
+
user.md - 中文开发者,偏好先分析不动手
|
|
519
|
+
bugs-and-fixes.md - 21 bugs: 12 fixed, 6 open
|
|
520
|
+
```
|
|
521
|
+
✅ 正确:
|
|
522
|
+
```
|
|
523
|
+
- [用户背景](user.md) — 中文开发者,Java后端,偏好先分析不动手
|
|
524
|
+
- [Bug清单](bugs-and-fixes.md) — 21 bugs:12 fixed, 6 open, 2 partial
|
|
525
|
+
- [只改被要求的代码](feedback/only-change-when-told.md) — 硬边界:未经授权不Edit/Write
|
|
526
|
+
- [Token优化策略](project/token-optimization.md) — 5层方案,省~10,500 tokens/轮
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
Return a brief summary of what you consolidated, updated, or pruned.{extra_section}"""
|
features/memory_age.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""记忆陈旧度(freshness)警告生成。
|
|
2
|
+
|
|
3
|
+
三个纯函数:
|
|
4
|
+
|
|
5
|
+
- memory_age_days(mtime_ms): 距今天数(floor,负值钳到 0)
|
|
6
|
+
- memory_freshness_text(mtime_ms): 普通文本警告,≤1 天返回空串
|
|
7
|
+
- memory_freshness_note(mtime_ms): 同上但包好 <system-reminder> 标签
|
|
8
|
+
|
|
9
|
+
设计动机:
|
|
10
|
+
模型对 ISO 时间戳不敏感("2026-04-15 写的"无法直接触发"可能过时"的推理),
|
|
11
|
+
但对"X days ago"敏感。把陈旧度计算下沉到这里、并在注入点用自然语言描述,
|
|
12
|
+
可以让模型自发去验证而不是把老记忆当作 live state。
|
|
13
|
+
|
|
14
|
+
可注入 now_ms:
|
|
15
|
+
所有函数支持 `now_ms` 关键字参数;默认走 time.time() * 1000,
|
|
16
|
+
测试可显式传入以避免时区 / 单测时钟漂移。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
# 1 天的毫秒数。提到模块级常量便于阅读 / 测试断言。
|
|
23
|
+
_MS_PER_DAY = 86_400_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _now_ms() -> float:
|
|
27
|
+
"""统一获取当前毫秒时间戳。封装一层是为了让单测可以 monkeypatch 此函数。"""
|
|
28
|
+
return time.time() * 1000.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def memory_age_days(mtime_ms: float, now_ms: float | None = None) -> int:
|
|
32
|
+
"""返回 mtime 距今的整数天数(floor)。
|
|
33
|
+
|
|
34
|
+
- 今天 → 0;昨天 → 1;47 天前 → 47
|
|
35
|
+
- 未来时间 / 时钟漂移导致负数 → 钳到 0(绝不输出负值,避免下游格式串崩坏)
|
|
36
|
+
"""
|
|
37
|
+
now = _now_ms() if now_ms is None else now_ms
|
|
38
|
+
delta_days = int((now - mtime_ms) // _MS_PER_DAY)
|
|
39
|
+
return max(0, delta_days)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def memory_freshness_text(mtime_ms: float, now_ms: float | None = None) -> str:
|
|
43
|
+
"""≤1 天(今天 / 昨天)→ 空字符串(避免给新鲜记忆贴噪音 warning)。
|
|
44
|
+
≥2 天 → 一段 plain-text 警告,**不带** <system-reminder> 包裹,供已自带包裹层的调用方使用。
|
|
45
|
+
"""
|
|
46
|
+
d = memory_age_days(mtime_ms, now_ms=now_ms)
|
|
47
|
+
if d <= 1:
|
|
48
|
+
return ""
|
|
49
|
+
return (
|
|
50
|
+
f"This memory is {d} days old. "
|
|
51
|
+
"Memories are point-in-time observations, not live state — "
|
|
52
|
+
"claims about code behavior or file:line citations may be outdated. "
|
|
53
|
+
"Verify against current code before asserting as fact."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def memory_freshness_note(mtime_ms: float, now_ms: float | None = None) -> str:
|
|
58
|
+
"""≤1 天 → 空字符串;≥2 天 → 已包好 <system-reminder>...</system-reminder> 的单行片段,
|
|
59
|
+
末尾带换行便于直接拼到 prompt 字符串里。
|
|
60
|
+
|
|
61
|
+
Step 6 findRelevantMemories 注入路径会直接调用本函数。
|
|
62
|
+
"""
|
|
63
|
+
text = memory_freshness_text(mtime_ms, now_ms=now_ms)
|
|
64
|
+
if not text:
|
|
65
|
+
return ""
|
|
66
|
+
return f"<system-reminder>{text}</system-reminder>\n"
|