pi-multi-viewers 0.1.0
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.
- package/AGENTS.md +192 -0
- package/README.md +153 -0
- package/docs/design.md +288 -0
- package/docs/examples/first-experiment/README.md +42 -0
- package/docs/examples/first-experiment/work-a/AGENTS.md +10 -0
- package/docs/examples/first-experiment/work-a/a/0001.md +65 -0
- package/docs/examples/first-experiment/work-a/a/0002.md +70 -0
- package/docs/examples/first-experiment/work-a/perspective.md +5 -0
- package/docs/examples/first-experiment/work-b/AGENTS.md +10 -0
- package/docs/examples/first-experiment/work-b/b/0001.md +100 -0
- package/docs/examples/first-experiment/work-b/perspective.md +6 -0
- package/docs/reviews/2026-09-10-e2e10-fork-source-modes-review.md +366 -0
- package/docs/reviews/2026-09-10-e2e11-forkmode-guards-review.md +229 -0
- package/docs/reviews/2026-09-10-e2e12-code-review.md +346 -0
- package/docs/reviews/2026-09-11-e2e13-code-review.md +284 -0
- package/docs/reviews/2026-09-11-e2e14-observability-review.md +216 -0
- package/docs/reviews/README.md +36 -0
- package/docs/test-methodology.md +258 -0
- package/extensions/multi-viewers-say/index.ts +156 -0
- package/fake_agent.py +120 -0
- package/human_sayer.py +144 -0
- package/human_viewer.py +215 -0
- package/meeting_core.py +255 -0
- package/meeting_engine.py +733 -0
- package/meeting_fs.py +1066 -0
- package/meeting_loop.py +606 -0
- package/package.json +41 -0
- package/prompts/multi-viewers.md +94 -0
- package/scripts/check-residue.sh +190 -0
- package/scripts/mv.sh +325 -0
- package/start_discussion.py +1485 -0
- package/templates/AGENTS.md.tpl +100 -0
- package/templates/agent.md.tpl +9 -0
- package/templates/gitignore.tpl +7 -0
- package/templates/spec-readme.md.tpl +87 -0
package/meeting_fs.py
ADDED
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
"""meeting 模式文件/git 操作层(I/O 封装)——meeting_core 的配套。
|
|
2
|
+
|
|
3
|
+
职责三类(设计原则 RR 教训):
|
|
4
|
+
- 纯逻辑在 meeting_core(无 I/O),本模块只做文件/git 操作
|
|
5
|
+
- 每 commit 一条消息(约束 3.1):commit 顺序 = 消息顺序
|
|
6
|
+
- 读取点从消息链 seen_at 推导(无本地游标状态)
|
|
7
|
+
- 所有 git 操作用 subprocess(与生产 local_loop 一致)
|
|
8
|
+
- **session fork 源生成/裁剪**(build_fork_source 与配套的 _curate/_fold/
|
|
9
|
+
append_handoff_turns):读主 session 文件、写 fork 源、注入切换叙事
|
|
10
|
+
——session 文件格式知识只留在本模块,调用方(meeting_loop)不内联解析
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import uuid
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import time
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------
|
|
22
|
+
# git 基础操作
|
|
23
|
+
# ---------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def bare_of_base(base):
|
|
26
|
+
"""讨论根目录 → bare 仓库路径(`<base>/repo.git`)。
|
|
27
|
+
|
|
28
|
+
**bare 路径推导的唯一入口(按持有物分两个具名函数)**:持有讨论根目录
|
|
29
|
+
的调用方用本函数;持有某个 agent 工作目录的用 `bare_of_workdir`。
|
|
30
|
+
为什么不合成一个"接受 workdir 或 base"的函数:那要靠值的形状猜语义
|
|
31
|
+
(哪个是 base 哪个是 workdir 无法从字符串判断)——与本项目已立的
|
|
32
|
+
"外部契约按字段拼接、不按值形状猜"同一禁令(见 `_join_model_ref`)。
|
|
33
|
+
"""
|
|
34
|
+
return os.path.join(base, "repo.git")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bare_of_workdir(workdir):
|
|
38
|
+
"""agent 工作目录 → bare 仓库路径(`dirname(workdir)/repo.git`)。"""
|
|
39
|
+
return bare_of_base(os.path.dirname(workdir))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_git(workdir, *args, check=True, timeout=30):
|
|
43
|
+
"""执行 git 命令。
|
|
44
|
+
|
|
45
|
+
-c core.quotepath=false:所有输出(log/ls-tree/diff 的路径)原样
|
|
46
|
+
UTF-8 不带引号转义——中文视角名(viewers 核心)下 quotepath 默认
|
|
47
|
+
转义会让路径解析全部失效(is_message_file 不匹配带引号路径 → 消息
|
|
48
|
+
读不到 → 无限首启/RR 死锁,2026-09-09 实测)。一处统一,全部命令
|
|
49
|
+
生效(各调用点的 -z 双保险)。
|
|
50
|
+
"""
|
|
51
|
+
r = subprocess.run(
|
|
52
|
+
["git", "-c", "core.quotepath=false", *args], cwd=workdir,
|
|
53
|
+
capture_output=True, text=True, timeout=timeout
|
|
54
|
+
)
|
|
55
|
+
if check and r.returncode != 0:
|
|
56
|
+
raise RuntimeError(f"git {args} 失败: out={r.stdout.strip()[:200]!r} err={r.stderr.strip()[:200]!r}")
|
|
57
|
+
return r
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def log(agent, msg):
|
|
61
|
+
"""流程事件日志(**唯一实现**,落 fs = IO owner)。
|
|
62
|
+
|
|
63
|
+
格式契约:`[YYYY-MM-DDTHH:MM:SS.mmm] <agent>: <msg>`,行尾 flush。
|
|
64
|
+
写者是 loop 与 engine(各自产生自己的事件),消费者是人(grep/肉眼)
|
|
65
|
+
——**格式只有一个实现**,此前 loop 与 engine 各有一份逐字相同的副本
|
|
66
|
+
(重复即漂移源)。这一行**不参与任何流程判定**(观测面契约见
|
|
67
|
+
docs/design.md)。
|
|
68
|
+
|
|
69
|
+
时间戳含日期与毫秒(§3.5-P10):秒级 `HH:MM:SS` 无法跨天 join、无法与
|
|
70
|
+
session/commit 时间对齐,同一秒内的多个事件也无法排序。毫秒是**人类
|
|
71
|
+
排错的排序精度**;度量精度由登记字段 `elapsed_ms` 承担(两者口径不同,
|
|
72
|
+
不可互替——见 design.md 的"一个数字一个口径")。
|
|
73
|
+
"""
|
|
74
|
+
ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
|
75
|
+
print(f"[{ts}] {agent}: {msg}", flush=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def iter_after_boundary(session_file):
|
|
79
|
+
"""迭代 session 文件中**边界条目之后**的条目(本轮运行事实)。
|
|
80
|
+
|
|
81
|
+
边界 = `custom_message` + `customType == BOUNDARY_TYPE`(由
|
|
82
|
+
append_handoff_turns 写)。未找到边界(老产物/手工 session)→ 返回
|
|
83
|
+
空迭代:**调用方按"无本轮数据"处理(n/a),不得退回全文扫描**——
|
|
84
|
+
那正是修掉的口径错误(把 fork 携带的历史算成本轮)。
|
|
85
|
+
fail-open:文件不可读/JSON 坏行跳过。
|
|
86
|
+
"""
|
|
87
|
+
seen = False
|
|
88
|
+
try:
|
|
89
|
+
with open(session_file, encoding="utf-8", errors="replace") as f:
|
|
90
|
+
for line in f:
|
|
91
|
+
if not seen:
|
|
92
|
+
if BOUNDARY_TYPE in line:
|
|
93
|
+
# 边界行本身不产出(它没有 message 字段)
|
|
94
|
+
seen = True
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
yield json.loads(line)
|
|
98
|
+
except ValueError:
|
|
99
|
+
continue
|
|
100
|
+
except OSError:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_protocol(bare):
|
|
105
|
+
"""读取共享协议(`HEAD:protocol.json`)——**协议读取的唯一实现**。
|
|
106
|
+
|
|
107
|
+
为什么是单一来源、且必须走 bare:protocol.json 是**共享事实**
|
|
108
|
+
(loop/engine/viewer/wrapper 都依据它),只有 setup 写过一次并
|
|
109
|
+
commit+push 进 bare;work-<agent> 下的本地副本是工作副本,LLM 有
|
|
110
|
+
bash 工具可以改动它——若判定读本地,LLM 就能影响流程判定。
|
|
111
|
+
走 bare HEAD = 判定只认已提交事实(与"状态从 git 共享事实推导"一致)。
|
|
112
|
+
|
|
113
|
+
任何失败(bare 不存在 / 无 HEAD / 文件缺失 / JSON 坏)→ 返回 {}:
|
|
114
|
+
调用方各自决定失败语义(loop 门 `[fatal]`、engine 响亮抛错、
|
|
115
|
+
viewer 显示"未初始化")——原语本身不做政策。
|
|
116
|
+
"""
|
|
117
|
+
r = run_git(bare, "show", "HEAD:protocol.json", check=False)
|
|
118
|
+
if r.returncode != 0:
|
|
119
|
+
return {}
|
|
120
|
+
try:
|
|
121
|
+
data = json.loads(r.stdout)
|
|
122
|
+
except ValueError:
|
|
123
|
+
return {}
|
|
124
|
+
return data if isinstance(data, dict) else {}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def git_head(workdir):
|
|
128
|
+
"""当前 HEAD。"""
|
|
129
|
+
return run_git(workdir, "rev-parse", "HEAD").stdout.strip()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def git_pull(workdir):
|
|
133
|
+
"""pull(--rebase 自动处理分叉)。"""
|
|
134
|
+
return run_git(workdir, "pull", "--rebase", "--autostash", check=False)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def git_commit(workdir, files, subject):
|
|
138
|
+
"""提交指定文件(每 commit 一条消息约束)。
|
|
139
|
+
|
|
140
|
+
files: list[相对路径]
|
|
141
|
+
subject: commit subject
|
|
142
|
+
"""
|
|
143
|
+
run_git(workdir, "add", "--", *files)
|
|
144
|
+
run_git(workdir, "commit", "-m", subject)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def git_push(workdir):
|
|
148
|
+
"""push,带并发容错:非快进失败 → pull --rebase → 重推。
|
|
149
|
+
|
|
150
|
+
meeting 并发写场景:多个 agent 可能同时 push(如同时写 af),
|
|
151
|
+
后到者 push 非快进失败——必须 pull 合并后重推,保证消息进 bare。
|
|
152
|
+
check=False 静默吞失败会导致消息卡在本地 → 共享事实(bare)
|
|
153
|
+
不完整 → 收敛死锁(复现现场:全员 af 卡死,can_start_rr 永不满足)。
|
|
154
|
+
"""
|
|
155
|
+
for attempt in range(5):
|
|
156
|
+
r = run_git(workdir, "push", check=False)
|
|
157
|
+
if r.returncode == 0:
|
|
158
|
+
return r
|
|
159
|
+
# 非快进(并发别人先推)→ pull --rebase 合并 → 重推
|
|
160
|
+
run_git(workdir, "pull", "--rebase", "--autostash", check=False)
|
|
161
|
+
time.sleep(0.2 * (attempt + 1))
|
|
162
|
+
# 重试耗尽:抛异常(不再静默/仅打印——消息滞留本地会让 bare 不完整,
|
|
163
|
+
# 收敛死锁。审核 E。统一由 agent_loop 顶层异常边界处理。)
|
|
164
|
+
raise RuntimeError(
|
|
165
|
+
f"git_push 重试耗尽: {r.stdout.strip()} {r.stderr.strip()}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def git_ls_files(workdir, agent_dir):
|
|
169
|
+
"""列出某 agent 目录下已提交的消息文件(含序号排序)。
|
|
170
|
+
|
|
171
|
+
-z:git 默认转义非 ASCII 路径(quotepath,中文路径输出成
|
|
172
|
+
"\\346\\200..." 带引号)——中文视角名(viewers 核心)会拿到
|
|
173
|
+
带引号路径 → read_message 读不到 → list_my_messages 恒空 → is_first
|
|
174
|
+
恒 True → 无限首启(2026-09-09 实测:freezing 卡死根因)。-z
|
|
175
|
+
(NUL 分隔)不做引号转义,输出原始 UTF-8 路径。
|
|
176
|
+
"""
|
|
177
|
+
r = run_git(workdir, "ls-files", "-z", agent_dir, check=False)
|
|
178
|
+
out = r.stdout.rstrip("\0")
|
|
179
|
+
return sorted(out.split("\0")) if out else []
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def git_show(workdir, commit, path):
|
|
183
|
+
"""读取某 commit 中某文件的内容。"""
|
|
184
|
+
r = run_git(workdir, "show", f"{commit}:{path}", check=False)
|
|
185
|
+
if r.returncode != 0:
|
|
186
|
+
return None
|
|
187
|
+
return r.stdout
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------
|
|
191
|
+
# frontmatter 解析
|
|
192
|
+
# ---------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def _frontmatter_end(content):
|
|
195
|
+
"""frontmatter 块闭合行号(开 `---` + 闭 `---` 完整才返回;否则 None)。
|
|
196
|
+
|
|
197
|
+
块边界确认**只此一份**(parse_frontmatter / extract_body 共用)——
|
|
198
|
+
先边界后解析(review5 A1 根治,用户方法:先确认块边界与完整性再读取)。
|
|
199
|
+
"""
|
|
200
|
+
if not content.startswith("---"):
|
|
201
|
+
return None
|
|
202
|
+
lines = content.splitlines()
|
|
203
|
+
for i in range(1, len(lines)):
|
|
204
|
+
if lines[i].strip() == "---":
|
|
205
|
+
return i
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def parse_frontmatter(content):
|
|
210
|
+
"""先确认 frontmatter 块完整(开 `---` + 闭 `---`),再解析字段
|
|
211
|
+
(review5 A1 根治,用户方法:先边界后解析)。
|
|
212
|
+
|
|
213
|
+
返回 None = 无 frontmatter 或块不完整(不可用)——调用方据此跳过
|
|
214
|
+
(读路径)或删除重写(写路径 commit_new_files)。
|
|
215
|
+
块完整 → dict(字段可信,不会把正文当字段吞掉)。
|
|
216
|
+
|
|
217
|
+
旧实现"只查开头、遍历到文件尾":缺闭合 `---` 时返回部分解析结果
|
|
218
|
+
(1-2 字段)→ 调用方 if not fm 判不出"完整 vs 残缺"→ 误当成功
|
|
219
|
+
→ serialize None → 原样 commit → 确定性修复丢失(A1 根因)。
|
|
220
|
+
"""
|
|
221
|
+
end = _frontmatter_end(content)
|
|
222
|
+
if end is None:
|
|
223
|
+
return None # 无 frontmatter 或块不完整 → 不可用
|
|
224
|
+
lines = content.splitlines()
|
|
225
|
+
fm = {}
|
|
226
|
+
for line in lines[1:end]:
|
|
227
|
+
m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line)
|
|
228
|
+
if m:
|
|
229
|
+
key, val = m.group(1), m.group(2).strip()
|
|
230
|
+
if val.startswith('"') and val.endswith('"') and len(val) >= 2:
|
|
231
|
+
val = val[1:-1] # 对齐 serialize_message 的剥引号(审核#17)
|
|
232
|
+
fm[key] = val
|
|
233
|
+
return fm
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def extract_body(content):
|
|
237
|
+
"""frontmatter 块之后的正文(块不完整 → None)。
|
|
238
|
+
|
|
239
|
+
与 parse_frontmatter 共用 _frontmatter_end(块边界只此一份)。
|
|
240
|
+
human_viewer 展示用(读 bare 内容,非工作区文件)。
|
|
241
|
+
"""
|
|
242
|
+
end = _frontmatter_end(content)
|
|
243
|
+
if end is None:
|
|
244
|
+
return None
|
|
245
|
+
lines = content.splitlines()
|
|
246
|
+
return "\n".join(lines[end + 1:]).strip()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def remove_message(workdir, path):
|
|
250
|
+
"""删除工作区文件(原语;调用方决定政策——engine 删除无效消息文件)。
|
|
251
|
+
|
|
252
|
+
fs 只做"删一个文件"的机械动作;为什么删、删了之后等谁重写,
|
|
253
|
+
都是 engine 的判定职责(L16 边界:IO 归 fs)。
|
|
254
|
+
"""
|
|
255
|
+
os.remove(os.path.join(workdir, path))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def write_text(workdir, path, text):
|
|
259
|
+
"""写文本文件(原语;原子性不做——调用方随后 commit 才是权威化点)。"""
|
|
260
|
+
with open(os.path.join(workdir, path), "w") as f:
|
|
261
|
+
f.write(text)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def file_size(workdir, path):
|
|
265
|
+
"""文件字节数;不存在/不可读 → -1(调用方按"无效"处理)。
|
|
266
|
+
|
|
267
|
+
合并"存在性 + 大小"两次系统调用为一个判定接口:调用方(result.md
|
|
268
|
+
有效性校验)原本是 exists() + getsize() 两步,两步之间文件可能变化。
|
|
269
|
+
"""
|
|
270
|
+
try:
|
|
271
|
+
return os.path.getsize(os.path.join(workdir, path))
|
|
272
|
+
except OSError:
|
|
273
|
+
return -1
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def cat_batch(bare, paths):
|
|
277
|
+
"""`git cat-file --batch` 批量读(一次进程读多个 rev:path)——批量读取原语。
|
|
278
|
+
|
|
279
|
+
**必须二进制模式读**(用户 9343 现场修复):cat-file 的 size 是**字节数**,
|
|
280
|
+
text 模式 read(size) 读**字符数**——中文 UTF-8 3 字节/字符 → 错位 →
|
|
281
|
+
后续 header 全乱 → readline 阻塞等数据 → 挂起死锁(真实 LLM 讨论中文,
|
|
282
|
+
FakeAgent 测试 ASCII 单字节所以本地测试没抓到——R1 根因复发)。
|
|
283
|
+
异常安全:try/finally 保证 stdin.close() + wait()(异常不泄漏进程,
|
|
284
|
+
否则 cat-file 常驻等 stdin EOF——top 3 个常驻进程即死锁现场)。
|
|
285
|
+
|
|
286
|
+
返回 {path: content}。为什么保留批量形态:调用方每轮循环顶都要读全部
|
|
287
|
+
消息(O(n) 次 git_show = 16.8× 回归,实测 733ms vs 43.6ms@498 条)。
|
|
288
|
+
"""
|
|
289
|
+
if not paths:
|
|
290
|
+
return {}
|
|
291
|
+
proc = subprocess.Popen(
|
|
292
|
+
["git", "-C", bare, "cat-file", "--batch"],
|
|
293
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
294
|
+
result = {}
|
|
295
|
+
try:
|
|
296
|
+
for p in paths:
|
|
297
|
+
proc.stdin.write(f"HEAD:{p}\n".encode())
|
|
298
|
+
proc.stdin.flush()
|
|
299
|
+
header = proc.stdout.readline() # 二进制:按字节读行
|
|
300
|
+
if not header:
|
|
301
|
+
break
|
|
302
|
+
header = header.decode("utf-8", "replace").strip()
|
|
303
|
+
parts = header.split()
|
|
304
|
+
# header 格式:"<sha> <type> <size>" 或 "<rev> missing"
|
|
305
|
+
if len(parts) != 3 or parts[1] == "missing":
|
|
306
|
+
continue
|
|
307
|
+
try:
|
|
308
|
+
size = int(parts[2])
|
|
309
|
+
except ValueError:
|
|
310
|
+
continue
|
|
311
|
+
content = proc.stdout.read(size) # 二进制:按字节读 content
|
|
312
|
+
proc.stdout.readline() # 消费块尾换行
|
|
313
|
+
result[p] = content.decode("utf-8", "replace")
|
|
314
|
+
finally:
|
|
315
|
+
proc.stdin.close()
|
|
316
|
+
proc.wait()
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def read_message(workdir, path):
|
|
321
|
+
"""读消息文件(工作区),返回 (frontmatter, body)。"""
|
|
322
|
+
full = os.path.join(workdir, path)
|
|
323
|
+
if not os.path.exists(full):
|
|
324
|
+
return None, None
|
|
325
|
+
with open(full) as f:
|
|
326
|
+
content = f.read()
|
|
327
|
+
return parse_frontmatter(content), content
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _fm_to_lines(frontmatter):
|
|
331
|
+
"""frontmatter → 行列表(review5 F4:write_message 与 serialize_message
|
|
332
|
+
共用清洗规则——值单行化 + 剥引号,避免两处实现漂移)。"""
|
|
333
|
+
lines = ["---"]
|
|
334
|
+
for k, v in frontmatter.items():
|
|
335
|
+
s = str(v).replace("\n", " ").replace("\r", " ").strip()
|
|
336
|
+
if s.startswith('"') and s.endswith('"') and len(s) >= 2:
|
|
337
|
+
s = s[1:-1]
|
|
338
|
+
lines.append(f"{k}: {s}")
|
|
339
|
+
lines.append("---")
|
|
340
|
+
return lines
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def write_message(workdir, path, frontmatter, body):
|
|
344
|
+
"""写消息文件(frontmatter + body)。"""
|
|
345
|
+
full = os.path.join(workdir, path)
|
|
346
|
+
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
347
|
+
with open(full, "w") as f:
|
|
348
|
+
f.write("\n".join(_fm_to_lines(frontmatter)) + "\n\n" + body + "\n")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def serialize_message(frontmatter, original_content):
|
|
352
|
+
"""替换原文件 frontmatter 部分(保留 body),返回新内容。
|
|
353
|
+
|
|
354
|
+
original_content: 原文件全文
|
|
355
|
+
返回: 新全文(frontmatter 按给定 dict 重写,body 保留)
|
|
356
|
+
|
|
357
|
+
边界语义与 parse_frontmatter **不同**(刻意分离,用户 2026-08-30):
|
|
358
|
+
原实现用 `lines[0].strip() != "---"`(允许前导空格),_frontmatter_end
|
|
359
|
+
是 `startswith`(严格)——统一会改变 serialize_message 的行为(核心
|
|
360
|
+
写路径,commit_new_files 用),保持各自原语义。
|
|
361
|
+
"""
|
|
362
|
+
lines = original_content.splitlines()
|
|
363
|
+
# 找到第一个 --- 和第二个 ---
|
|
364
|
+
if not lines or lines[0].strip() != "---":
|
|
365
|
+
return None
|
|
366
|
+
end = None
|
|
367
|
+
for i in range(1, len(lines)):
|
|
368
|
+
if lines[i].strip() == "---":
|
|
369
|
+
end = i
|
|
370
|
+
break
|
|
371
|
+
if end is None:
|
|
372
|
+
return None
|
|
373
|
+
body = "\n".join(lines[end + 1:]).lstrip("\n")
|
|
374
|
+
return "\n".join(_fm_to_lines(frontmatter)) + "\n\n" + body + "\n"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ---------------------------------------------------------------
|
|
378
|
+
# 消息目录操作
|
|
379
|
+
# ---------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
def list_my_messages(workdir, agent_dir):
|
|
382
|
+
"""列出某 agent 的全部消息(含 frontmatter),按序号升序。"""
|
|
383
|
+
paths = git_ls_files(workdir, agent_dir)
|
|
384
|
+
msgs = []
|
|
385
|
+
for p in paths:
|
|
386
|
+
fm, _ = read_message(workdir, p)
|
|
387
|
+
if fm:
|
|
388
|
+
msgs.append(fm)
|
|
389
|
+
return msgs
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def next_msg_id(workdir, agent_dir):
|
|
393
|
+
"""下一个消息序号(已有消息最大序号 + 1,4 位补零)。
|
|
394
|
+
|
|
395
|
+
用 max+1 不用 len+1:LLM 跳号(写 0005 但只有 0001-0003)时
|
|
396
|
+
len+1=4 会与已写序号错位,且写流程信号时可能覆盖 LLM 的跳号
|
|
397
|
+
消息(审核 G4)。max+1 根治。
|
|
398
|
+
"""
|
|
399
|
+
paths = git_ls_files(workdir, agent_dir)
|
|
400
|
+
nums = [int(p.split("/")[-1].split(".")[0]) for p in paths
|
|
401
|
+
if p.split("/")[-1][:4].isdigit()]
|
|
402
|
+
n = (max(nums) if nums else 0) + 1
|
|
403
|
+
return f"{n:04d}"
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def commit_message(agent, msg_id):
|
|
407
|
+
"""commit subject 格式。"""
|
|
408
|
+
return f"discuss: {agent}/{msg_id}"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------------
|
|
412
|
+
# 读取点(从消息链推导,无本地状态)
|
|
413
|
+
# ---------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
def setup_commit(workdir):
|
|
416
|
+
"""讨论起点 = 仓库根 commit(setup 提交,question.md 诞生点)。
|
|
417
|
+
|
|
418
|
+
用途:无历史 agent(从未 responder 成功)写协议信号的 seen_at 兜底——
|
|
419
|
+
固定锚点,从起点读一条不漏。head 兜底会随并发 push 漂移,把从未
|
|
420
|
+
读过的消息虚假标记已读(实测 2026-09-03 crash_recovery flaky 根因)。
|
|
421
|
+
"""
|
|
422
|
+
r = run_git(workdir, "rev-list", "--max-parents=0", "HEAD")
|
|
423
|
+
return r.stdout.strip()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def read_point(workdir, agent_dir):
|
|
427
|
+
"""读取点 = 我最后一条参与消息的 seen_at(跳 freezing)。
|
|
428
|
+
|
|
429
|
+
返回: str("" = 从根读起)
|
|
430
|
+
"""
|
|
431
|
+
from meeting_core import read_point_seen_at
|
|
432
|
+
msgs = list_my_messages(workdir, agent_dir)
|
|
433
|
+
return read_point_seen_at(msgs)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def list_new_messages(workdir, since_ref):
|
|
437
|
+
"""since 之后的新消息文件(路径列表)。
|
|
438
|
+
|
|
439
|
+
since_ref: git ref("" = 全部)
|
|
440
|
+
返回: list[str]
|
|
441
|
+
"""
|
|
442
|
+
if not since_ref:
|
|
443
|
+
# 无读取点:列出全部消息文件
|
|
444
|
+
r = run_git(workdir, "ls-tree", "-r", "-z", "--name-only", "HEAD", check=False)
|
|
445
|
+
files = r.stdout.rstrip("\0").split("\0") if r.stdout.strip() else []
|
|
446
|
+
else:
|
|
447
|
+
r = run_git(workdir, "diff", "-z", "--name-only",
|
|
448
|
+
f"{since_ref}..HEAD", check=False)
|
|
449
|
+
files = r.stdout.rstrip("\0").split("\0") if r.stdout.strip() else []
|
|
450
|
+
# 只保留消息文件(作者目录/NNNN.md)
|
|
451
|
+
return [f for f in files if is_message_file(f)] # P7:统一(L8 漏 fs 这处)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def new_messages_with_meta(workdir, since_ref, me=None):
|
|
455
|
+
"""新消息(带元数据):path / from / to / stale。
|
|
456
|
+
|
|
457
|
+
阶段 4:seen_at 陈旧检测(设计文档 3.3)——对每条消息标注
|
|
458
|
+
"该消息的 seen_at 之后是否有更新"(git diff --name-only seen_at..HEAD 非空 = 陈旧)。
|
|
459
|
+
|
|
460
|
+
me: 本 agent 名——提供时过滤自己的消息(审核#15:build_wake_prompt
|
|
461
|
+
不列自己刚 commit 的消息,避免 prompt 噪音 + token 浪费;触发判定
|
|
462
|
+
本身已过滤 from==me,不受影响;list_new_messages 保持全量语义)。
|
|
463
|
+
since_ref: 读取点(git ref)
|
|
464
|
+
返回: list[dict]:{path, from, to, seen_at, stale}
|
|
465
|
+
"""
|
|
466
|
+
paths = list_new_messages(workdir, since_ref)
|
|
467
|
+
result = []
|
|
468
|
+
for p in paths:
|
|
469
|
+
fm, _ = read_message(workdir, p)
|
|
470
|
+
if not fm:
|
|
471
|
+
continue
|
|
472
|
+
if me is not None and fm.get("from") == me:
|
|
473
|
+
continue
|
|
474
|
+
seen = fm.get("seen_at", "")
|
|
475
|
+
stale = False
|
|
476
|
+
if seen:
|
|
477
|
+
# 陈旧 = 该消息的 seen_at 之后有**其他**消息更新(commit 拓扑序)。
|
|
478
|
+
# 注意:作者写消息时取 seen_at,自身 commit 紧随其后——自身 commit
|
|
479
|
+
# 不算"后续更新"。判定:seen_at..HEAD 的 diff 中,除本消息外还有
|
|
480
|
+
# 其他消息文件(别人的新消息或更新)→ stale。
|
|
481
|
+
r = run_git(workdir, "diff", "-z", "--name-only",
|
|
482
|
+
f"{seen}..HEAD", check=False)
|
|
483
|
+
changed = (r.stdout.rstrip("\0").split("\0")
|
|
484
|
+
if r.stdout.strip() else [])
|
|
485
|
+
others = [f for f in changed
|
|
486
|
+
if is_message_file(f) and f != p]
|
|
487
|
+
stale = bool(others)
|
|
488
|
+
|
|
489
|
+
result.append({
|
|
490
|
+
"path": p,
|
|
491
|
+
"from": fm.get("from", p.split("/")[0]),
|
|
492
|
+
"to": fm.get("to", "all"),
|
|
493
|
+
"seen_at": seen,
|
|
494
|
+
"stale": stale,
|
|
495
|
+
})
|
|
496
|
+
return result
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def is_message_file(path):
|
|
500
|
+
"""判断路径是否为消息文件(作者/NNNN.md)。
|
|
501
|
+
|
|
502
|
+
作者目录不限 ASCII:viewers 中文视角名是产品核心(可读性/性能/…)。
|
|
503
|
+
约束对齐 agent 名校验(禁 / 与空白;目录名不匹配即非消息文件——
|
|
504
|
+
human/、protocol.json 等天然排除)。
|
|
505
|
+
"""
|
|
506
|
+
return bool(re.match(r"^[^/\s]+/\d{4}\.md$", path))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def parse_log_nameonly(output):
|
|
510
|
+
"""解析 `git log --name-only --format=%H` 输出 → [(commit, [files])]。
|
|
511
|
+
|
|
512
|
+
按 commit 拓扑序(输出顺序);每个 commit 的文件列表含其变更文件。
|
|
513
|
+
引擎(rr_next_speaker 回退路径)与 human_viewer(new_messages)共用
|
|
514
|
+
——git 输出解析只此一份,避免两处实现漂移。
|
|
515
|
+
"""
|
|
516
|
+
commits = []
|
|
517
|
+
cur = None
|
|
518
|
+
files = []
|
|
519
|
+
for line in output.strip().splitlines():
|
|
520
|
+
line = line.strip()
|
|
521
|
+
if not line:
|
|
522
|
+
continue
|
|
523
|
+
if len(line) == 40 and all(c in "0123456789abcdef" for c in line):
|
|
524
|
+
if cur is not None:
|
|
525
|
+
commits.append((cur, files))
|
|
526
|
+
cur, files = line, []
|
|
527
|
+
else:
|
|
528
|
+
files.append(line)
|
|
529
|
+
if cur is not None:
|
|
530
|
+
commits.append((cur, files))
|
|
531
|
+
return commits
|
|
532
|
+
|
|
533
|
+
# ---------------------------------------------------------------
|
|
534
|
+
# fork 源生成与裁剪(session 文件层)
|
|
535
|
+
# ---------------------------------------------------------------
|
|
536
|
+
#
|
|
537
|
+
# budget 模式参数(fork 源裁剪)
|
|
538
|
+
#
|
|
539
|
+
# fork 源模式(**值集合与默认值的单一事实源**):
|
|
540
|
+
# budget —— 预算 + 折叠(默认;长会话唯一可行)
|
|
541
|
+
# compaction —— 按 compaction 边界(中小会话,内容原样)
|
|
542
|
+
# full —— 全量(小会话/验证)
|
|
543
|
+
# 两种语义角色(勿混):FORK_MODES 是**处理哪个模式**(分派仍用字面量);
|
|
544
|
+
# DEFAULT_FORK_MODE 是**缺省填谁**(仅默认值位置,全仓引此常量)。
|
|
545
|
+
# 历史值 active/curated(rename 前)按非法值处理(解析入口即报错)。
|
|
546
|
+
FORK_MODES = ("budget", "compaction", "full")
|
|
547
|
+
DEFAULT_FORK_MODE = "budget"
|
|
548
|
+
#
|
|
549
|
+
# 版本守卫:值域校验在 build_fork_source 入口(open 之前)做——非法值
|
|
550
|
+
# 与历史值(改名前的 active/curated)一律报错,绝不静默落到"混合分支"
|
|
551
|
+
# (曾实测:非法值走"边界后全量、不折叠、无预算"→ 930k tokens 超窗,
|
|
552
|
+
# 且被 engine 的异常边界吞成廉价重试)。
|
|
553
|
+
#
|
|
554
|
+
# 预算取值(口径见设计文档「规模口径」节):
|
|
555
|
+
# - 现行重估公式(**事后归纳,非原始设计目标**):基线 ≤ 约 20%×
|
|
556
|
+
# (模型窗口 − 输出预留);当前实例 80k est ≈ 132k 真实 ≈ 664k 的 20%。
|
|
557
|
+
# - 校准比(est → 真实):唤醒 1 ≈ 1.66×;唤醒中后期至 ~2.7×(不固化为 2.0×)。
|
|
558
|
+
# - 该预算只约束 **fork 源(基线)**;运行规模随该 agent 会话累积增长
|
|
559
|
+
# (实测 w1 132k → w5 192–207k)。
|
|
560
|
+
# - pi 默认 compaction(keepRecentTokens=20000 / reserveTokens=16384,
|
|
561
|
+
# 见 pi DEFAULT_COMPACTION_SETTINGS)——讨论 agent 需要更宽的近期窗口。
|
|
562
|
+
BUDGET_KEEP_TOKENS = 80000
|
|
563
|
+
_TOOL_RESULT_KEEP = 8 # 最近 N 条工具输出保留(其余省略)
|
|
564
|
+
_TOOL_RESULT_MAX_CHARS = 4000 # 保留范围内单条工具输出上限
|
|
565
|
+
_TOOL_CALL_MAX_CHARS = 1200 # 工具调用参数上限
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def read_fork_stats(path):
|
|
569
|
+
"""读 fork 源 header 的统计字段。
|
|
570
|
+
|
|
571
|
+
只读首行(O(1),与产物大小无关)。日志所需的 mode/估算/丢弃数取自
|
|
572
|
+
**同一来源**(产物自描述 header)
|
|
573
|
+
——header 格式知识只留本模块,调用方不内联解析(条数由构建返回值提供)。
|
|
574
|
+
读失败返回 {}(调用方降级为只打模式/条数,不阻塞首唤)。
|
|
575
|
+
"""
|
|
576
|
+
try:
|
|
577
|
+
with open(path, encoding="utf-8") as f:
|
|
578
|
+
hdr = json.loads(f.readline())
|
|
579
|
+
except (OSError, ValueError):
|
|
580
|
+
return {}
|
|
581
|
+
if not isinstance(hdr, dict) or hdr.get("type") != "session":
|
|
582
|
+
return {}
|
|
583
|
+
return {
|
|
584
|
+
"mode": hdr.get("forkSourceMode") or "",
|
|
585
|
+
"est": hdr.get("forkSourceTokensEst"),
|
|
586
|
+
"dropped": hdr.get("forkSourceDropped"),
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _est_tokens(text):
|
|
591
|
+
"""粗估 token 数(≈3 字符/token)——仅用于预算裁剪。
|
|
592
|
+
|
|
593
|
+
无文本条目(如 compaction)按 1 token 计——量级 <0.01%,不构成
|
|
594
|
+
容量风险;est 只是集合近似指纹,不是数值承诺(见 I4)。
|
|
595
|
+
"""
|
|
596
|
+
return max(1, len(text) // 3)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _entry_text(entry):
|
|
600
|
+
"""条目的文本贡献(预算估算用;不追求精确,只求可比较)。"""
|
|
601
|
+
m = entry.get("message")
|
|
602
|
+
if not isinstance(m, dict):
|
|
603
|
+
return ""
|
|
604
|
+
c = m.get("content")
|
|
605
|
+
if isinstance(c, str):
|
|
606
|
+
return c
|
|
607
|
+
if not isinstance(c, list):
|
|
608
|
+
return ""
|
|
609
|
+
parts = []
|
|
610
|
+
for x in c:
|
|
611
|
+
if not isinstance(x, dict):
|
|
612
|
+
continue
|
|
613
|
+
t = x.get("type")
|
|
614
|
+
if t == "text":
|
|
615
|
+
parts.append(x.get("text") or "")
|
|
616
|
+
elif t == "thinking":
|
|
617
|
+
parts.append(x.get("thinking") or "")
|
|
618
|
+
elif t == "toolCall":
|
|
619
|
+
parts.append(json.dumps(x.get("arguments", {}), ensure_ascii=False))
|
|
620
|
+
return "\n".join(parts)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _shrink_value(v, limit):
|
|
624
|
+
"""递归截断参数里的长字符串(保留结构/键名——工具调用 JSON 必须
|
|
625
|
+
仍是合法对象:部分 provider 对回放的历史 toolCall 参数有形状要求,
|
|
626
|
+
换成一个 {"_truncated": …} 对象会改变参数形状)。"""
|
|
627
|
+
if isinstance(v, str):
|
|
628
|
+
return v if len(v) <= limit else v[:limit] + f"…[截断,原 {len(v)} 字符]"
|
|
629
|
+
if isinstance(v, dict):
|
|
630
|
+
return {k: _shrink_value(x, limit) for k, x in v.items()}
|
|
631
|
+
if isinstance(v, list):
|
|
632
|
+
return [_shrink_value(x, limit) for x in v]
|
|
633
|
+
return v
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _fold_entry(entry, full_result):
|
|
637
|
+
"""折叠单条目(budget):返回新条目(无改动则原样返回)。
|
|
638
|
+
|
|
639
|
+
- thinking 块整体丢弃——推理痕迹不承担信息职责,但占原始文本约 40%
|
|
640
|
+
- toolCall 参数超限截断(保留名字与开头,够辨认“做了什么”)
|
|
641
|
+
- 工具输出:仅最近若干条保留全文(且单条限长),更早的换为省略标记
|
|
642
|
+
(长度信息保留,便于判断“曾发生过什么”)
|
|
643
|
+
"""
|
|
644
|
+
m = entry.get("message")
|
|
645
|
+
if not isinstance(m, dict):
|
|
646
|
+
return entry
|
|
647
|
+
content = m.get("content")
|
|
648
|
+
if not isinstance(content, list):
|
|
649
|
+
return entry
|
|
650
|
+
role = m.get("role")
|
|
651
|
+
out, changed = [], False
|
|
652
|
+
for x in content:
|
|
653
|
+
if not isinstance(x, dict):
|
|
654
|
+
out.append(x)
|
|
655
|
+
continue
|
|
656
|
+
t = x.get("type")
|
|
657
|
+
if t == "thinking":
|
|
658
|
+
changed = True
|
|
659
|
+
continue
|
|
660
|
+
if t == "toolCall":
|
|
661
|
+
args = x.get("arguments", {})
|
|
662
|
+
shrunk = _shrink_value(args, _TOOL_CALL_MAX_CHARS)
|
|
663
|
+
if shrunk != args:
|
|
664
|
+
n = dict(x)
|
|
665
|
+
n["arguments"] = shrunk
|
|
666
|
+
out.append(n)
|
|
667
|
+
changed = True
|
|
668
|
+
else:
|
|
669
|
+
out.append(x)
|
|
670
|
+
continue
|
|
671
|
+
if t == "text" and role == "toolResult":
|
|
672
|
+
txt = x.get("text") or ""
|
|
673
|
+
if not full_result:
|
|
674
|
+
n = dict(x)
|
|
675
|
+
n["text"] = f"[工具输出已省略 {len(txt)} 字符]"
|
|
676
|
+
out.append(n)
|
|
677
|
+
changed = True
|
|
678
|
+
continue
|
|
679
|
+
if len(txt) > _TOOL_RESULT_MAX_CHARS:
|
|
680
|
+
n = dict(x)
|
|
681
|
+
n["text"] = (txt[:_TOOL_RESULT_MAX_CHARS] +
|
|
682
|
+
f"\n…[工具输出截断,共 {len(txt)} 字符]")
|
|
683
|
+
out.append(n)
|
|
684
|
+
changed = True
|
|
685
|
+
continue
|
|
686
|
+
out.append(x)
|
|
687
|
+
if not changed:
|
|
688
|
+
return entry
|
|
689
|
+
if not out:
|
|
690
|
+
out = [{"type": "text", "text": "[内容已省略]"}]
|
|
691
|
+
n = dict(entry)
|
|
692
|
+
nm = dict(m)
|
|
693
|
+
nm["content"] = out
|
|
694
|
+
n["message"] = nm
|
|
695
|
+
return n
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _normalize_entries(entries):
|
|
699
|
+
"""移除窗口内的 compaction 条目,并把指向它们的 parentId 上溯桥接。
|
|
700
|
+
|
|
701
|
+
为什么必须移除(pi replay 语义,2026-09-10 实测):replay 以**路径上
|
|
702
|
+
最后一个 compaction** 的 `firstKeptEntryId` 为起点——窗口内若有
|
|
703
|
+
compaction 且它不是窗口首条,则锚之前的条目会被**静默丢弃**
|
|
704
|
+
(实测产物 [preface, old, k1, k2, C1, n0..n2] → 可见仅 [C1, n0..n2],
|
|
705
|
+
我们的 preface 也被丢掉)。移除后窗口内无锚,replay 从链首开始 =
|
|
706
|
+
全集合可见(不变量 I4:声明集合 == replay 可见集合)。
|
|
707
|
+
|
|
708
|
+
桥接规则:
|
|
709
|
+
- 被移除 compaction 的子条目 → parentId 上溯到第一个非 compaction
|
|
710
|
+
祖先(连续 compaction 链也正确)
|
|
711
|
+
- 上溯出产物(祖先被裁剪)→ 链首显式 `parentId=None`(与 preface 同型)
|
|
712
|
+
|
|
713
|
+
**为什么连退化情形也要移除**(e2e11 实测 + I4 测试推翻初版守卫):
|
|
714
|
+
初版守卫“窗口仅含 compaction → 保留该条”会破坏 I4——保留的 comp
|
|
715
|
+
仍带 `firstKeptEntryId`,而其锚点早已被预算裁掉 → replay 仍会丢弃
|
|
716
|
+
锚点之前的全部条目(含我们的 preface)。因此一致地移除全部 comp;
|
|
717
|
+
空 body 由调用方以 preface(“已省略…”说明)兼顾,产物不会为空。
|
|
718
|
+
|
|
719
|
+
信息损失有界:最后一条 compaction 的摘要已在 preface("此前压缩
|
|
720
|
+
摘要"行);更早的是被滚动摘要覆盖的旧摘要。
|
|
721
|
+
|
|
722
|
+
返回 (new_entries, removed_n)。
|
|
723
|
+
"""
|
|
724
|
+
comps = [e for e in entries if e.get("type") == "compaction"]
|
|
725
|
+
if not comps:
|
|
726
|
+
return entries, 0
|
|
727
|
+
removed = {e.get("id"): e for e in comps if e.get("id")}
|
|
728
|
+
out = []
|
|
729
|
+
for e in entries:
|
|
730
|
+
if e.get("type") == "compaction":
|
|
731
|
+
continue
|
|
732
|
+
pid = e.get("parentId")
|
|
733
|
+
while pid in removed: # 上溯桥接(可能连续多个)
|
|
734
|
+
pid = removed[pid].get("parentId")
|
|
735
|
+
out.append(dict(e, parentId=pid))
|
|
736
|
+
# 上溯出产物(祖先被预算裁掉)→ 链首显式 None
|
|
737
|
+
ids = {e.get("id") for e in out}
|
|
738
|
+
out = [e if e.get("parentId") in ids else dict(e, parentId=None)
|
|
739
|
+
for e in out]
|
|
740
|
+
return out, len(comps)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _budget_entries(entries, keep_tokens, summary=""):
|
|
744
|
+
"""budget 裁剪流水线的后半段(前置在 build_fork_source:边界选取)。
|
|
745
|
+
|
|
746
|
+
流水线:**fold → trim → align → normalize**;preface 与 est 由调用方
|
|
747
|
+
在规范化后施加(顺序不可换——est 必须反映最终产物)。
|
|
748
|
+
|
|
749
|
+
返回 **(preface 文本, kept 条目, 预算丢弃数, 规范化移除数)**——两个
|
|
750
|
+
计数分开回报,因为 header 的 `forkSourceDropped` 是两者之和(不变量
|
|
751
|
+
I5 记账闭合),而 preface 文本只描述预算省略部分。
|
|
752
|
+
"""
|
|
753
|
+
# ① fold:折叠(最近 _TOOL_RESULT_KEEP 条工具输出保留全文)
|
|
754
|
+
seen, folded = 0, []
|
|
755
|
+
for e in reversed(entries):
|
|
756
|
+
full = True
|
|
757
|
+
if (e.get("message") or {}).get("role") == "toolResult":
|
|
758
|
+
seen += 1
|
|
759
|
+
full = seen <= _TOOL_RESULT_KEEP
|
|
760
|
+
folded.append(_fold_entry(e, full))
|
|
761
|
+
folded.reverse()
|
|
762
|
+
# ② trim:从尾部累计**折叠后**规模(顺序很重要:按原始规模裁会把
|
|
763
|
+
# 预算浪费在随后就被折叠掉的内容上——实测 80k 预算只落到 17.5k)
|
|
764
|
+
cut, acc, i = len(folded), 0, len(folded) - 1
|
|
765
|
+
while i >= 0:
|
|
766
|
+
t = _est_tokens(_entry_text(folded[i]))
|
|
767
|
+
if cut < len(folded) and acc + t > keep_tokens:
|
|
768
|
+
break
|
|
769
|
+
acc += t
|
|
770
|
+
cut = i
|
|
771
|
+
i -= 1
|
|
772
|
+
# ③ align:provider 硬约束(tool 消息必须有其 tool_calls 前置)
|
|
773
|
+
# - 保留区以 toolResult 开头 → 向前扩展,把它的 toolCall 一起纳入
|
|
774
|
+
# - 扩展到顶仍是孤儿(源本身不完整)→ 丢弃这些无主条目
|
|
775
|
+
# (DeepSeek/OpenAI 直接报 "Messages with role 'tool' must be a
|
|
776
|
+
# response to a preceding message with 'tool_calls'",2026-09-10 实测)
|
|
777
|
+
# 两段式:先向前扩展(cut 只减),再清理头部孤儿(cut 只增,必然终止)
|
|
778
|
+
while cut > 0 and (folded[cut].get("message") or {}).get("role") == "toolResult":
|
|
779
|
+
cut -= 1
|
|
780
|
+
while folded[cut:] and \
|
|
781
|
+
(folded[cut].get("message") or {}).get("role") == "toolResult":
|
|
782
|
+
cut += 1
|
|
783
|
+
# 计数在**对齐之后**按最终切点重算(曾按对齐前的切点绑定进 dropped,
|
|
784
|
+
# 回扩条目会同时留在 kept 与 dropped → 双重计数,实测场景 B/C)
|
|
785
|
+
kept = folded[cut:]
|
|
786
|
+
dropped = folded[:cut]
|
|
787
|
+
# ④ normalize:移除窗口内 compaction 并桥接(见 _normalize_entries)
|
|
788
|
+
kept, removed_n = _normalize_entries(kept)
|
|
789
|
+
dropped_tokens = sum(_est_tokens(_entry_text(e)) for e in dropped)
|
|
790
|
+
preface = ""
|
|
791
|
+
# 规范化可能把 body 清空(窗口只容下了 compaction)——此时必须给出
|
|
792
|
+
# preface 作为内容(否则产物只剩 header;且它正是那段历史的交代)
|
|
793
|
+
if dropped or summary or not kept:
|
|
794
|
+
lines = [
|
|
795
|
+
"[上下文说明] 本次分析的上下文来自主 pi 会话,"
|
|
796
|
+
"过早的历史已按预算压缩。",
|
|
797
|
+
]
|
|
798
|
+
if dropped:
|
|
799
|
+
lines.append(
|
|
800
|
+
f"已省略更早的 {len(dropped)} 条消息(约 {dropped_tokens // 1000}k tokens)。")
|
|
801
|
+
if summary:
|
|
802
|
+
lines.append(f"此前压缩摘要:{summary.strip()}")
|
|
803
|
+
preface = "\n".join(lines)
|
|
804
|
+
return preface, kept, len(dropped), removed_n
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def build_fork_source(src_session, out_path, new_id, new_cwd,
|
|
808
|
+
mode=DEFAULT_FORK_MODE, keep_tokens=None):
|
|
809
|
+
"""生成 fork 源 session 文件——供 wake_llm 首唤 `--session` 直接打开
|
|
810
|
+
(不用 `pi --fork`:那是一份全量拷贝,且我们需在尾部注入切换叙事)。
|
|
811
|
+
|
|
812
|
+
三种裁剪策略(header 的 forkSourceMode 标记可核查):
|
|
813
|
+
budget:**预算 + 折叠**(默认)——恢复 pi 自身的压缩不变量(摘要 +
|
|
814
|
+
最近窗口);**构建期**不依赖任何扩展(运行期上下文仍受环境扩展
|
|
815
|
+
的渲染期裁剪影响)。为什么需要:主 pi 实际发送的上下文
|
|
816
|
+
比文件条目小得多(压缩层不在条目里)——实测本会话原始条目
|
|
817
|
+
919k/934k tokens,加 384k completion 预留 > 1M 窗口。
|
|
818
|
+
compaction/full 都是条目级裁剪、无总量上限,对长会话不够。
|
|
819
|
+
compaction:从最后一条 compaction 的 `firstKeptEntryId` 起的条目
|
|
820
|
+
(**含该 compaction 条目本身,位于其自然位置**)——主 session 的
|
|
821
|
+
**条目级**压缩态,内容原样保留(thinking/工具输出不折叠);
|
|
822
|
+
锚点 ID 不在源中 → 明确报错
|
|
823
|
+
full:全部条目(含无 compaction 的引导 session)
|
|
824
|
+
budget 与 compaction 在**无 compaction 时**均全量(引导 session 本就
|
|
825
|
+
干净无先例);budget 仍会跑折叠与统计。
|
|
826
|
+
|
|
827
|
+
产物不变量(I1–I5;由构造保证 + 测试断言,**不做生产守卫**——判定
|
|
828
|
+
依据是复杂度匹配:失败路径设计与归因的成本高于"用检查代替构造纪律"):
|
|
829
|
+
I1 产物内 id 唯一
|
|
830
|
+
I2 链连续:从末条上溯可覆盖**全部**条目;除链首外 parentId 均指向
|
|
831
|
+
产物内条目(链首显式;compaction 模式下链首的父在窗口外属边界
|
|
832
|
+
语义——replay 走到此处即停)
|
|
833
|
+
I3 replay 所用锚点(路径上最后一个 compaction 的 firstKeptEntryId)
|
|
834
|
+
指向产物内条目
|
|
835
|
+
I4 **声明集合 == pi replay 可见集合**(budget/compaction 两个**窗口
|
|
836
|
+
构造**模式;budget 据此移除窗口内 compaction——否则 replay 会丢弃
|
|
837
|
+
锚点之前的条目,含 preface,实测 2026-09-10)。
|
|
838
|
+
full 模式**不适用**:它是源的忠实拷贝,replay 可见集合与**源会话
|
|
839
|
+
自身**语义一致(源里有什么 compaction 就有什么可见性边界),
|
|
840
|
+
我们不做窗口构造,也不改写历史。
|
|
841
|
+
I5 记账闭合:源保留区条目数 = 产物非 preface 条目数 +
|
|
842
|
+
`forkSourceDropped`(= 预算丢弃数 + 规范化移除数)
|
|
843
|
+
|
|
844
|
+
产物 header 自描述(单一来源):forkSourceMode /
|
|
845
|
+
forkSourceTokensEst(估算基准,**不预测请求规模**)/ forkSourceDropped。
|
|
846
|
+
keep_tokens:budget 的预算(默认 BUDGET_KEEP_TOKENS)。
|
|
847
|
+
返回 (entries_written, error)。
|
|
848
|
+
"""
|
|
849
|
+
if mode not in FORK_MODES:
|
|
850
|
+
# 配置错误就地暴露(不落盘、不生成半成品)——见常量块"版本守卫"
|
|
851
|
+
return 0, f"未知 forkMode: {mode!r}(合法值: {'/'.join(FORK_MODES)})"
|
|
852
|
+
try:
|
|
853
|
+
with open(src_session, encoding="utf-8") as f:
|
|
854
|
+
entries = [json.loads(l) for l in f if l.strip()]
|
|
855
|
+
except (OSError, ValueError) as e:
|
|
856
|
+
return 0, f"源 session 读取失败: {e}"
|
|
857
|
+
header = next((e for e in entries if e.get("type") == "session"), None)
|
|
858
|
+
if header is None:
|
|
859
|
+
return 0, "源 session 无 header"
|
|
860
|
+
comps = [(i, e) for i, e in enumerate(entries)
|
|
861
|
+
if e.get("type") == "compaction"]
|
|
862
|
+
summary = ""
|
|
863
|
+
new_ts = header.get("timestamp")
|
|
864
|
+
idx_by_id = {e.get("id"): i for i, e in enumerate(entries) if e.get("id")}
|
|
865
|
+
if mode == "full" or (mode == "compaction" and not comps):
|
|
866
|
+
# full:全部条目。compaction 遇无 compaction 的源(引导 session)
|
|
867
|
+
# 也全量——本就无边界可依(既有语义,标记 full)
|
|
868
|
+
body = entries[1:]
|
|
869
|
+
if mode == "compaction":
|
|
870
|
+
mode = "full"
|
|
871
|
+
elif mode == "compaction":
|
|
872
|
+
# compaction:条目级压缩态,内容原样保留(thinking/工具输出不折叠)。
|
|
873
|
+
# 产物 = entries[kept_idx:]——它**已含**最后一条 compaction 条目
|
|
874
|
+
# (pi 的 appendCompaction 使锚点位置小于 comp 位置),“补一条
|
|
875
|
+
# comp” 恒为重复且引入 last-wins 顺序依赖(e2e11 实测)
|
|
876
|
+
comp = comps[-1][1]
|
|
877
|
+
summary = comp.get("summary") or ""
|
|
878
|
+
new_ts = comp.get("timestamp") or new_ts
|
|
879
|
+
kept_idx = idx_by_id.get(comp.get("firstKeptEntryId"))
|
|
880
|
+
if kept_idx is None:
|
|
881
|
+
return 0, (f"firstKeptEntryId {comp.get('firstKeptEntryId')} "
|
|
882
|
+
f"不在源 session 中")
|
|
883
|
+
body = entries[kept_idx:]
|
|
884
|
+
elif mode == "budget":
|
|
885
|
+
# budget:压缩态边界 → 折叠 → 预算裁剪 → 边界对齐 → 规范化
|
|
886
|
+
if comps:
|
|
887
|
+
comp = comps[-1][1]
|
|
888
|
+
summary = comp.get("summary") or ""
|
|
889
|
+
new_ts = comp.get("timestamp") or new_ts
|
|
890
|
+
kept_idx = idx_by_id.get(comp.get("firstKeptEntryId"))
|
|
891
|
+
if kept_idx is None:
|
|
892
|
+
# 边界 ID 失效:从最后 compaction 条目之后取(容错)
|
|
893
|
+
kept_idx = entries.index(comp) + 1
|
|
894
|
+
body = entries[kept_idx:]
|
|
895
|
+
else:
|
|
896
|
+
body = entries[1:]
|
|
897
|
+
preface, body, dropped_n, removed_n = _budget_entries(
|
|
898
|
+
body, keep_tokens or BUDGET_KEEP_TOKENS, summary)
|
|
899
|
+
if preface:
|
|
900
|
+
pid = uuid.uuid4().hex[:8]
|
|
901
|
+
body = [{
|
|
902
|
+
"type": "message", "id": pid, "parentId": None,
|
|
903
|
+
"timestamp": datetime.now(timezone.utc).isoformat().replace(
|
|
904
|
+
"+00:00", "Z"),
|
|
905
|
+
"message": {"role": "user",
|
|
906
|
+
"content": [{"type": "text", "text": preface}]},
|
|
907
|
+
}] + body
|
|
908
|
+
# 保留区首条接回 preface(一条链;规范化已保证它不再指向
|
|
909
|
+
# 窗口外)
|
|
910
|
+
if len(body) > 1:
|
|
911
|
+
body[1] = dict(body[1], parentId=pid)
|
|
912
|
+
# 双口径合计(不变量 I5 记账闭合):header 计“预算丢弃 + 规范化
|
|
913
|
+
# 移除”,而 preface 文本只描述预算部分
|
|
914
|
+
dropped_total = dropped_n + removed_n
|
|
915
|
+
else: # pragma: no cover
|
|
916
|
+
# 值域守卫之后仍可达的只剩“新值已入 FORK_MODES 但分派未跟上”
|
|
917
|
+
return 0, f"forkMode {mode!r} 尚未实现分派"
|
|
918
|
+
new_header = {
|
|
919
|
+
"type": "session",
|
|
920
|
+
"version": header.get("version", 3),
|
|
921
|
+
"id": new_id,
|
|
922
|
+
"timestamp": new_ts,
|
|
923
|
+
"cwd": new_cwd,
|
|
924
|
+
"parentSession": src_session,
|
|
925
|
+
"forkSourceMode": mode,
|
|
926
|
+
}
|
|
927
|
+
if mode == "budget":
|
|
928
|
+
# 产物侧指纹(口径见设计文档「规模口径」):对**最终产物**统一
|
|
929
|
+
# 计算(单一测点);不预测请求规模
|
|
930
|
+
new_header["forkSourceTokensEst"] = sum(
|
|
931
|
+
_est_tokens(_entry_text(e)) for e in body)
|
|
932
|
+
new_header["forkSourceDropped"] = dropped_total
|
|
933
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
934
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
935
|
+
f.write(json.dumps(new_header, ensure_ascii=False) + "\n")
|
|
936
|
+
for e in body:
|
|
937
|
+
f.write(json.dumps(e, ensure_ascii=False) + "\n")
|
|
938
|
+
return len(body) + 1, None
|
|
939
|
+
|
|
940
|
+
# 测试引导 session 登记日志(防误删,用户 2026-09-09):每个测试/脚本
|
|
941
|
+
# 引导 session 创建时登记一行——清理时回查日志确认"是我建的测试产物"
|
|
942
|
+
# 才删,未登记 = 非本流程创建(真实会话),不得删。路径与
|
|
943
|
+
# scripts/check-residue.sh 的对照逻辑共享。
|
|
944
|
+
SESSION_REGISTRY = os.path.join(
|
|
945
|
+
os.path.expanduser("~/.pi"), "pi-multi-viewers-test-sessions.log")
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _registry_log(session_id, out_path, cwd):
|
|
949
|
+
"""登记一条测试引导 session 记录(追加)。"""
|
|
950
|
+
rec = {
|
|
951
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
952
|
+
"session_id": session_id,
|
|
953
|
+
"path": os.path.abspath(out_path),
|
|
954
|
+
"cwd": cwd,
|
|
955
|
+
}
|
|
956
|
+
try:
|
|
957
|
+
with open(SESSION_REGISTRY, "a", encoding="utf-8") as f:
|
|
958
|
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
959
|
+
except OSError:
|
|
960
|
+
pass # 登记失败不阻塞引导创建(清理时该条会显示未登记 → 不删,安全侧)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def build_bootstrap(out_path, cwd, session_id=None):
|
|
964
|
+
"""生成空白引导 session 文件(零 LLM,替代 pi --print "就绪")。
|
|
965
|
+
|
|
966
|
+
用途(2026-09-09):脚本/测试场景无主 session 时,给 meeting_loop 一
|
|
967
|
+
个合法 fork 源。空 header + 零 message——agent 首唤 --session 打开后
|
|
968
|
+
第一条 user 消息就是 wake prompt(任务描述,无"就绪"噪音 turn)。
|
|
969
|
+
冒烟实测:仅 header 的文件可被 pi --session 正常打开续写。
|
|
970
|
+
|
|
971
|
+
每次创建写登记日志(SESSION_REGISTRY)——清理回查归属(防误删)。
|
|
972
|
+
|
|
973
|
+
返回 (out_path, error)。
|
|
974
|
+
"""
|
|
975
|
+
sid = session_id or str(uuid.uuid4())
|
|
976
|
+
ts = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
977
|
+
header = {"type": "session", "version": 3, "id": sid,
|
|
978
|
+
"timestamp": ts, "cwd": cwd}
|
|
979
|
+
try:
|
|
980
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
981
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
982
|
+
f.write(json.dumps(header, ensure_ascii=False) + "\n")
|
|
983
|
+
except OSError as e:
|
|
984
|
+
return None, f"引导 session 写入失败: {e}"
|
|
985
|
+
_registry_log(sid, out_path, cwd)
|
|
986
|
+
return out_path, None
|
|
987
|
+
|
|
988
|
+
def preserve_result_md(base):
|
|
989
|
+
"""保存 result.md:从 bare git 历史复制到父级目录(T2 合并,e2e7 评审)。
|
|
990
|
+
|
|
991
|
+
两个触发点共享本实现(此前 start_discussion/meeting_loop 各一份,
|
|
992
|
+
日志格式/import 方式/边界处理三处漂移):
|
|
993
|
+
- resultWriter loop 退出(收尾完成时保存)
|
|
994
|
+
- cleanup(清理前兜底保存)
|
|
995
|
+
命名 <base目录名>-result.md(与讨论目录同级)。
|
|
996
|
+
result.md 权威位置 = bare git 历史;无 result.md → 跳过(不报错)。
|
|
997
|
+
返回保存路径或 None。
|
|
998
|
+
"""
|
|
999
|
+
bare = bare_of_base(base)
|
|
1000
|
+
if not os.path.isdir(bare):
|
|
1001
|
+
return None
|
|
1002
|
+
r = run_git(bare, "show", "HEAD:result.md", check=False)
|
|
1003
|
+
if r.returncode != 0 or not r.stdout.strip():
|
|
1004
|
+
return None
|
|
1005
|
+
base_name = os.path.basename(base.rstrip("/")) or "discussion"
|
|
1006
|
+
dest = os.path.join(os.path.dirname(base.rstrip("/")) or ".",
|
|
1007
|
+
f"{base_name}-result.md")
|
|
1008
|
+
with open(dest, "w") as f:
|
|
1009
|
+
f.write(r.stdout)
|
|
1010
|
+
return dest
|
|
1011
|
+
|
|
1012
|
+
# 边界条目类型(显式标记"本轮分析起点"):边界之后的条目才是本次分析的
|
|
1013
|
+
# 运行事实。消费者:`--report` 的 LLM 段(不数 fork 携带的历史 usage)。
|
|
1014
|
+
BOUNDARY_TYPE = "mv.analysis-start"
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def append_handoff_turns(session_file, turns):
|
|
1018
|
+
"""session 尾部追加对话回合(切换叙事,2026-09-09 设计)+ **边界条目**。
|
|
1019
|
+
|
|
1020
|
+
turns: [(role, text), ...]——按序追加,parentId 接到现有链尾,
|
|
1021
|
+
最简字段(role/content,无 provider/usage——pi 加载只关心角色与
|
|
1022
|
+
内容,冒烟实测通过)。
|
|
1023
|
+
用途:fork 源尾部注入"停止旧任务 → 新任务说明 → 确认"对话,
|
|
1024
|
+
显式切断历史叙事惯性(agent 读到的最后叙事是任务切换共识,
|
|
1025
|
+
无法再把自己当成旧叙事的延续)。
|
|
1026
|
+
|
|
1027
|
+
**末尾追加一条 `custom_message` 边界条目**(`BOUNDARY_TYPE`):显式
|
|
1028
|
+
切出"历史(fork 携带)/ 本轮"的分界,供 `--report` 统计本轮 usage。
|
|
1029
|
+
为什么显式而非推断(如按条数/时间戳):条数会随切换叙事改措辞而漂移、
|
|
1030
|
+
时间戳会因时钟精度与主 session 末条接近而歧义——**边界是事实,应登记
|
|
1031
|
+
而非猜**(2026-09-11 实测:不带边界的报告把 fork 的 717 条历史算成
|
|
1032
|
+
本轮 367 次响应)。pi 对 custom_message 条目的容忍已冒烟验证(`pi
|
|
1033
|
+
--session` 打开正常、追问正常回复)。
|
|
1034
|
+
|
|
1035
|
+
返回追加条数(含边界条目——它是本次追加的一部分)。
|
|
1036
|
+
"""
|
|
1037
|
+
with open(session_file, encoding="utf-8") as f:
|
|
1038
|
+
lines = [json.loads(l) for l in f if l.strip()]
|
|
1039
|
+
parent = None
|
|
1040
|
+
for e in reversed(lines):
|
|
1041
|
+
if e.get("id"):
|
|
1042
|
+
parent = e["id"]
|
|
1043
|
+
break
|
|
1044
|
+
n = 0
|
|
1045
|
+
with open(session_file, "a", encoding="utf-8") as f:
|
|
1046
|
+
for role, text in turns:
|
|
1047
|
+
eid = uuid.uuid4().hex[:8]
|
|
1048
|
+
e = {"type": "message", "id": eid, "parentId": parent,
|
|
1049
|
+
"timestamp": datetime.now(timezone.utc).isoformat().replace(
|
|
1050
|
+
"+00:00", "Z"),
|
|
1051
|
+
"message": {"role": role,
|
|
1052
|
+
"content": [{"type": "text", "text": text}]}}
|
|
1053
|
+
f.write(json.dumps(e, ensure_ascii=False) + "\n")
|
|
1054
|
+
parent = eid
|
|
1055
|
+
n += 1
|
|
1056
|
+
# 边界条目(字段最小:type + customType + 时间戳;pi 不需要
|
|
1057
|
+
# 它做任何事,只是链上一条可检索的事实)
|
|
1058
|
+
bid = uuid.uuid4().hex[:8]
|
|
1059
|
+
f.write(json.dumps({
|
|
1060
|
+
"type": "custom_message", "id": bid, "parentId": parent,
|
|
1061
|
+
"customType": BOUNDARY_TYPE, "display": False,
|
|
1062
|
+
"timestamp": datetime.now(timezone.utc).isoformat().replace(
|
|
1063
|
+
"+00:00", "Z"),
|
|
1064
|
+
}, ensure_ascii=False) + "\n")
|
|
1065
|
+
n += 1
|
|
1066
|
+
return n
|