revert(worldline): remove v2 checkpoint tree integration

This commit is contained in:
yanghang committed 2026-06-24 22:52:18 +08:00
1 parent 904ade4907
commit 029b343f36
3 files changed
+31 -2333

No files matched your search

+6 -60
View File
@@ -294,7 +294,7 @@ def _rounds_for_file(path, st):
return n, key
def list_sessions(exclude_pid=None, exclude_log=None, rewind_root=None):
def list_sessions(exclude_pid=None, exclude_log=None):
"""Newest-first list of (path, mtime, preview_text, n_rounds). Preview uses head/tail window only.
`exclude_log` (basename, e.g. 'model_responses_123456.txt') drops the caller's
@@ -324,43 +324,6 @@ def list_sessions(exclude_pid=None, exclude_log=None, rewind_root=None):
valid_keys.append(key)
out.append((f, mtime, preview, rounds))
_save_rounds_cache(valid_keys)
# 【门控·worldline】树感知发现:日志空/缺失但有非空世界线树的会话(回退到起点后日志被
# 清空 → 上面 sz<32 跳过了)。仅当调用方显式传 rewind_root 时启用 → 其他 UI 不传,
# 行为逐字节不变。只读 tree.json 的 nodes/head(不依赖 worldline 模块)。
if rewind_root and os.path.isdir(rewind_root):
have = {os.path.basename(p) for p, *_ in out}
try:
keys = os.listdir(rewind_root)
except OSError:
keys = []
for key in keys:
if not key.startswith('model_responses_'):
continue
log_name = key + '.txt'
if log_name in have or log_name == exclude_log:
continue
log_path = os.path.join(_LOG_DIR, log_name)
try: # 仅收"日志确实空/缺失"的(非空日志已被主循环收录)
if os.path.getsize(log_path) >= 32:
continue
except OSError:
pass # 缺失也算
try:
with open(os.path.join(rewind_root, key, 'tree.json'), encoding='utf-8') as fh:
d = json.load(fh)
except Exception:
continue
nodes = d.get('nodes') or {}
real = [v for v in nodes.values() if v.get('kind') != 'origin']
if not real: # 只有 origin 的空树 → 无内容,跳过
continue
try:
mtime = os.path.getmtime(os.path.join(rewind_root, key, 'tree.json'))
except OSError:
mtime = 0
head = d.get('head')
title = (nodes.get(head, {}).get('title') if head else '') or '(已回退至会话起点)'
out.append((log_path, mtime, f'[世界线] {title}', len(real)))
out.sort(key=lambda x: x[1], reverse=True)
return out
_MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])')
@@ -1051,19 +1014,10 @@ def _load_history_into(agent, path):
return f'⚠️ 非 native 格式,降级恢复 {n} 轮摘要({name})', False
def _is_empty_log(path):
"""日志空(<32 字节)或缺失。用于 allow_empty:回退到会话起点后日志被清空的会话。"""
try:
return os.path.getsize(path) < 32
except OSError:
return True
def continue_inplace(agent, path, agent_id=None, allow_empty=False):
def continue_inplace(agent, path, agent_id=None):
"""原地续:把 agent 的日志指回 `path` 本身,之后轮次追加到 X,延续同一会话。
调用方应已确认空闲(session_occupant 为 None);抢锁失败(被占)返回错误。
`allow_empty`(仅 worldline UI 传):日志为空时不报错,按【空会话】恢复(清空对话,
由调用方按 `.ga_rewind` 树重连),用于"回退至会话起点"的会话。返回 (msg, ok)。"""
返回 (msg, ok)。"""
try: agent.abort()
except Exception: pass
if not acquire_lock(path, agent_id): # 先抢到目标锁;失败则保持现状,不丢自己的锁
@@ -1072,14 +1026,10 @@ def continue_inplace(agent, path, agent_id=None, allow_empty=False):
if cur and os.path.basename(cur) != os.path.basename(path):
release_lock(cur) # 目标到手,旧会话释放为空闲(同一文件则不放)
_retarget_log(agent, path)
msg, ok = _load_history_into(agent, path)
if not ok and allow_empty and _is_empty_log(path):
_replace_backend_history(agent, []) # 空会话:清空对话(载入失败时它没被清)
return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True
return msg, ok
return _load_history_into(agent, path)
def continue_copy(agent, path, agent_id=None, allow_empty=False):
def continue_copy(agent, path, agent_id=None):
"""拷贝续:铸新 logid、把 `path` 内容拷进去,在副本上续;`path` 原件不动。
用于"被占用→用户选拷贝"以及快照源。返回 (msg, ok)。"""
try: agent.abort()
@@ -1092,11 +1042,7 @@ def continue_copy(agent, path, agent_id=None, allow_empty=False):
pass
acquire_lock(newp, agent_id)
_retarget_log(agent, newp)
msg, ok = _load_history_into(agent, newp)
if not ok and allow_empty and _is_empty_log(newp):
_replace_backend_history(agent, [])
return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True
return msg, ok
return _load_history_into(agent, newp)
def install(cls):
+25 -1305
View File
File diff suppressed because it is too large. Load diff
-968
View File
@@ -1,968 +0,0 @@
"""GA 世界线(Rewind / checkpoint)统一后端 —— **UI 无关,单文件,tui/gui 共用**。
三块合一(本文件不含任何 Textual/前端交互逻辑,只产数据与做存储):
1. **持久化**(`RewindStore`):content-addressable blob + checkpoint 树 + 全持久化。
- 统一 **checkpoint 树**;**content-addressable blob**(内容 sha256 去重);
- 只追 `file_write`/`file_patch`(路径由调用方在 `tool_before` 钩子喂入);
- **global baseline**(每文件「首次改前」)解决「回退到该文件被追踪之前」。
2. **视图模型 + 压缩**(`CheckpointTree`/`tree_from_store`/`CompressedTree`):把真实
store 投影成只读树并强制折叠线性段,供任何前端渲染。
3. **导航数学 + 恢复编排**(`next_same_depth`/`nearest_depth_node`/`restore_plan`):
纯计算;`restore_plan` 算出回退后的 history/文件/prefill 并落地(改文件+移 HEAD+
重写投影),前端只管把结果刷到自己的界面。
参考 Claude Code file-history 思路,按 GA 改造。纯 Python、可离线测试;线程安全交调用方
(agent 跑在子线程,约定仅 agent 空闲时 restore)。详见需求文档(ga_rewind_tui_requirements)。
"""
from __future__ import annotations
import difflib
import hashlib
import json
import os
import shutil
import time
from datetime import datetime
import zlib
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from rich.cells import cell_len, set_cell_size
# 该值为 None 表示「该版本文件不存在」(对应 CC 的 backupFileName: null)。
_ABSENT = None
class RewindStore:
"""一个 session 的 checkpoint 树 + blob 库。
用法(典型时序):
store = RewindStore(root, cwd)
# 改前(tool_before 钩子,file_write/file_patch 执行前):
store.track_pre_edit(abs_path)
# ...工具实际写盘...
# 段末(用户提问边界 / turn 收尾):
store.commit("修复 stdout", hist_len=42)
# 浏览 / 恢复:
store.apply_code(node_id) # 把工作区还原到该节点
store.rewind_head(node_id) # 只移 HEAD(下次 commit 即从此分叉)
"""
def __init__(self, root: str, cwd: str) -> None:
self.root = os.path.abspath(root)
self.cwd = os.path.abspath(cwd)
self.objects_dir = os.path.join(self.root, "objects")
self.tree_path = os.path.join(self.root, "tree.json")
# id -> {parent, children[], title, created, kind, hist_len, files{rel: hash|None}}
self.nodes: dict[str, dict] = {}
self.head: str | None = None
self.root_id: str | None = None
self._seq = 0
# 全局:每个被追踪文件的「首次改前」状态(hash 或 None=当时不存在)。
self.baseline: dict[str, str | None] = {}
self.tracked: set[str] = set()
# 段内暂存:本段触碰过的相对路径(commit 时落定)。
self._touched: set[str] = set()
# 最近一次 restore 的 redo 点(内存级软保险②),结构同 node.files。
self._redo: dict | None = None
self.load()
if self.nodes:
self._ensure_origin() # 迁移:旧树(无 origin)挂一个「会话起点」根
# --------------------------------------------------------------- origin
def _ensure_origin(self) -> None:
"""保证存在一个虚拟根「会话起点」(空状态):所有真实 checkpoint 都是它的
后代。幂等。第一次 commit / 迁移旧树 / resume 时调用。
- 它代表「第一轮对话之前」,使「回退到第一个会话之前」= 普通地 rewind 到它;
- files={} + hist_len=0 → apply_code(origin) 经 baseline 回退 = 还原到空起点;
- 它是唯一真根 → 不会出现 forest / head=None。"""
if any(nd.get("kind") == "origin" for nd in self.nodes.values()):
if self.head is None:
self.head = self.root_id
return
oid = "origin"
while oid in self.nodes:
oid += "_"
tops = self.top_level_nodes()
self.nodes[oid] = {"parent": None, "children": list(tops),
"title": "会话起点", "created": time.time(),
"kind": "origin", "hist_len": 0, "files": {}}
for t in tops:
self.nodes[t]["parent"] = oid
self.root_id = oid
if self.head is None:
self.head = oid
# ------------------------------------------------------------------ paths
def key(self, abs_path: str) -> str:
"""绝对路径在 cwd 下 → 存相对(参考 CC maybeShortenFilePath);否则原样。"""
ap = os.path.abspath(abs_path)
try:
if os.path.commonpath([ap, self.cwd]) == self.cwd:
return os.path.relpath(ap, self.cwd).replace(os.sep, "/")
except ValueError:
pass # 跨盘符(Windows)等:commonpath 抛错 → 用绝对路径
return ap.replace(os.sep, "/")
def _abs(self, rel: str) -> str:
if os.path.isabs(rel) or (len(rel) > 1 and rel[1] == ":"):
return os.path.normpath(rel)
return os.path.normpath(os.path.join(self.cwd, rel))
# ------------------------------------------------------------------ blobs
def _put_blob(self, data: bytes) -> str:
h = hashlib.sha256(data).hexdigest()
p = os.path.join(self.objects_dir, h[:2], h)
if not os.path.exists(p):
os.makedirs(os.path.dirname(p), exist_ok=True)
tmp = p + ".tmp"
with open(tmp, "wb") as f:
f.write(zlib.compress(data, 6))
os.replace(tmp, p)
return h
def _get_blob(self, h: str) -> bytes:
with open(os.path.join(self.objects_dir, h[:2], h), "rb") as f:
return zlib.decompress(f.read())
def _snapshot(self, abs_path: str) -> str | None:
"""当前内容 → blob hash;文件不存在 → None。"""
try:
with open(abs_path, "rb") as f:
data = f.read()
except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):
return _ABSENT
return self._put_blob(data)
# --------------------------------------------------------------- tracking
def track_pre_edit(self, abs_path: str) -> None:
"""改前埋点:首次见到该文件时记下它的「改前」状态作 baseline。
幂等——同一文件同段内多次调用只在首次抓 baseline(file_patch 连改不会
覆盖原始版本,对应 CC trackEdit 的 v1 保护)。"""
rel = self.key(abs_path)
self._touched.add(rel)
if rel not in self.baseline:
self.baseline[rel] = self._snapshot(abs_path) # 改前内容,可能是 None
self.tracked.add(rel)
def commit(self, title: str, hist_len: int | None = None, kind: str = "edit",
history: list | None = None) -> str:
"""段末落节点:继承 HEAD.files,把本段触碰文件的「改后」内容写进新节点。
`history` 给定时(对话树化):切 `history[parent.hist_len:当前]` 为本轮对话增量,
存成 content-addressable conv blob,节点记其 hash + `hist_len=len(history)`。
此刻索引可靠 → 增量精确,恢复时沿路径拼回即可,不再靠会漂的绝对下标。
无触碰文件时也会落节点(纯对话推进也是 checkpoint)。返回新节点 id。"""
self._ensure_origin() # 第一次提交前先确保有「会话起点」根
parent = self.head # 至少是 origin,永不为 None
files = dict(self.nodes[parent]["files"]) if parent is not None else {}
for rel in self._touched:
files[rel] = self._snapshot(self._abs(rel)) # 改后内容,删除则 None
conv = None
if history is not None:
parent_len = self.nodes[parent].get("hist_len") if parent is not None else 0
parent_len = parent_len or 0 # None(旧节点) / origin → 0
delta = list(history[parent_len:])
hist_len = len(history)
conv = self._put_blob(
json.dumps(delta, ensure_ascii=False, default=str).encode("utf-8"))
nid = self._new_id()
self.nodes[nid] = {
"parent": parent,
"children": [],
"title": title,
"created": time.time(),
"kind": kind,
"hist_len": hist_len,
"files": files,
"conv": conv,
}
if parent is not None:
self.nodes[parent]["children"].append(nid)
elif self.root_id is None or self.root_id not in self.nodes:
self.root_id = nid # 只认第一个根;rewind-到-根之前再提交会产生第二个顶层节点(forest)
self.head = nid
self._touched.clear()
self.save()
return nid
# ------------------------------------------------------------- navigation
def rewind_head(self, node_id) -> None:
"""移动 HEAD(不动文件)。从非叶 HEAD 下次 commit 即 append 子节点 = fork。
node_id=None 表示「回到根之前」(空起点),下次 commit 产生新的顶层节点。"""
if node_id is not None and node_id not in self.nodes:
raise KeyError(node_id)
self.head = node_id
self.save()
def top_level_nodes(self) -> list[str]:
"""所有顶层节点(parent 为 None 或父已不存在)。通常一个 root,rewind-到-根
之前再提交会出现多个(forest)。"""
return [nid for nid, nd in self.nodes.items()
if nd.get("parent") is None or nd.get("parent") not in self.nodes]
def _target_state(self, node_id: str, rel: str):
"""文件 rel 在 node_id 时应有的状态:节点 files 里有就用它,否则回退到
baseline(CC 的 first-version)。返回 (known: bool, hash_or_None)。"""
files = self.nodes[node_id]["files"]
if rel in files:
return True, files[rel]
if rel in self.baseline:
return True, self.baseline[rel]
return False, None
def apply_code(self, node_id) -> list[tuple[str, str]]:
"""把工作区文件还原到 node_id 的状态。`node_id=None` → 还原到 baseline
(任何 checkpoint 之前的原始状态,用于「回退到根之前」)。
- **只动本 store 追踪过的路径**(self.tracked),绝不碰未记录文件(软保险①)。
- restore 前先把这些路径的当前内容存进 redo 点(软保险②)——误覆盖也能找回。
返回 [(rel, action)],action ∈ {restored, deleted}。"""
if node_id is not None and node_id not in self.nodes:
raise KeyError(node_id)
# 软保险②:redo 点(记录当前 = 即将被覆盖的状态)。
redo_files: dict[str, str | None] = {}
for rel in self.tracked:
redo_files[rel] = self._snapshot(self._abs(rel))
self._redo = {"from": self.head, "files": redo_files}
changed: list[tuple[str, str]] = []
for rel in self.tracked:
if node_id is None:
known = rel in self.baseline
target = self.baseline.get(rel)
else:
known, target = self._target_state(node_id, rel)
if not known:
continue
ap = self._abs(rel)
cur = self._snapshot(ap)
if cur == target:
continue
if target is _ABSENT:
try:
os.remove(ap)
changed.append((rel, "deleted"))
except FileNotFoundError:
pass
else:
os.makedirs(os.path.dirname(ap) or ".", exist_ok=True)
with open(ap, "wb") as f:
f.write(self._get_blob(target))
changed.append((rel, "restored"))
return changed
def diff(self, node_id: str) -> list[dict]:
"""选中节点 vs 当前工作区,逐文件变更摘要(恢复到该节点会发生什么)。
返回 [{rel, action, insertions, deletions}],仅列出会变的文件。
action: restore(覆盖)/ delete(删除)/ create(重建被删文件)。"""
if node_id not in self.nodes:
raise KeyError(node_id)
out: list[dict] = []
for rel in sorted(self.tracked):
known, target = self._target_state(node_id, rel)
if not known:
continue
cur = self._snapshot(self._abs(rel))
if cur == target:
continue
old = self._text(cur) # 当前工作区内容
new = self._text(target) # 该节点内容(恢复目标)
ins = dele = 0
for line in difflib.ndiff(old.splitlines(), new.splitlines()):
if line.startswith("+ "):
ins += 1
elif line.startswith("- "):
dele += 1
if target is _ABSENT:
action = "delete"
elif cur is _ABSENT:
action = "create"
else:
action = "restore"
out.append({"rel": rel, "action": action, "insertions": ins, "deletions": dele})
return out
def node_diff(self, node_id) -> list[dict]:
"""选中节点 vs **父节点**的逐文件内容 diff(= 这次 checkpoint 改了什么)。
返回 [{rel, old, new}](文本),仅含两者不同的文件;父缺失/文件不存在 → 空文本。
供 UI 渲染逐行 diff(区别于 `diff()` 的"节点 vs 当前工作区"计数摘要)。"""
if node_id not in self.nodes:
return []
nd = self.nodes[node_id]
par = nd.get("parent")
pf = self.nodes[par]["files"] if par in self.nodes else {}
nf = nd.get("files") or {}
out: list[dict] = []
for rel in sorted(set(nf) | set(pf)):
nh = nf.get(rel)
# 「上一个状态」:父节点记录过就用它;父节点没有(本节点首次触碰该文件)→
# 回退到 baseline(首次改前内容,CC first-version)。否则被 file_patch 修改的
# 已有文件会因父节点无记录而被误显示成整文件新增,而非真正的逐行 diff。
ph = pf[rel] if rel in pf else self.baseline.get(rel)
if nh == ph:
continue
out.append({"rel": rel, "old": self._text(ph), "new": self._text(nh)})
return out
def _text(self, h: str | None) -> str:
if h is _ABSENT:
return ""
try:
return self._get_blob(h).decode("utf-8", "replace")
except Exception:
return ""
# --------------------------------------------------------- delete + gc
def delete_subtree(self, node_id: str) -> list[str]:
"""删除节点及其整棵子树(不能删 HEAD 祖先路径上的节点 / root 由调用方把关)。
返回被删的节点 id 列表。删后做一次 blob GC。"""
if node_id not in self.nodes:
raise KeyError(node_id)
victims: list[str] = []
def collect(nid: str) -> None:
victims.append(nid)
for c in list(self.nodes[nid]["children"]):
collect(c)
collect(node_id)
parent = self.nodes[node_id]["parent"]
if parent is not None:
self.nodes[parent]["children"] = [
c for c in self.nodes[parent]["children"] if c != node_id
]
for nid in victims:
self.nodes.pop(nid, None)
if node_id == self.root_id:
self.root_id = None
if self.head in victims:
self.head = parent # HEAD 被删 → 退到父
self._gc()
self.save()
return victims
def _gc(self) -> int:
"""删除无人引用的 blob。返回删除数。"""
referenced: set[str] = set()
for node in self.nodes.values():
for h in node["files"].values():
if h is not _ABSENT:
referenced.add(h)
ch = node.get("conv") # 对话增量 blob 也是引用
if ch:
referenced.add(ch)
for h in self.baseline.values():
if h is not _ABSENT:
referenced.add(h)
if self._redo:
for h in self._redo["files"].values():
if h is not _ABSENT:
referenced.add(h)
removed = 0
if not os.path.isdir(self.objects_dir):
return 0
for sub in os.listdir(self.objects_dir):
d = os.path.join(self.objects_dir, sub)
if not os.path.isdir(d):
continue
for h in os.listdir(d):
if h not in referenced:
try:
os.remove(os.path.join(d, h))
removed += 1
except OSError:
pass
return removed
# ------------------------------------------------------------ persistence
def save(self) -> None:
os.makedirs(self.root, exist_ok=True)
payload = {
"nodes": self.nodes,
"head": self.head,
"root": self.root_id,
"seq": self._seq,
"baseline": self.baseline,
"tracked": sorted(self.tracked),
}
tmp = self.tree_path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, default=str)
# 崩溃兜底:替换前把上一份完好的 tree.json 留作 .bak(load 损坏时回退它)。
if os.path.exists(self.tree_path):
try:
shutil.copyfile(self.tree_path, self.tree_path + ".bak")
except OSError:
pass
os.replace(tmp, self.tree_path)
def load(self) -> None:
"""加载树。主文件损坏/缺失 → 回退 .bak;都不行 → 留空 store(降级,不抛)。"""
for path in (self.tree_path, self.tree_path + ".bak"):
if not os.path.exists(path):
continue
try:
with open(path, encoding="utf-8") as f:
d = json.load(f)
except Exception:
continue # 损坏 → 尝试下一个候选
self.nodes = d.get("nodes", {})
self.head = d.get("head")
self.root_id = d.get("root")
self._seq = d.get("seq", 0)
self.baseline = d.get("baseline", {})
self.tracked = set(d.get("tracked", []))
return
def _new_id(self) -> str:
self._seq += 1
return f"v{self._seq}"
# ---------------------------------------------------------- session key
@staticmethod
def key_for_log(log_path: str) -> str:
"""会话 key = log_path 的 basename 去扩展名,如 `model_responses_123456`。
这是 GA 唯一稳定、/continue 据以识别会话的身份(task_dir 是 PID 级、重启即变,
不能用)。"""
return os.path.splitext(os.path.basename(log_path))[0]
@classmethod
def for_log(cls, temp_dir: str, log_path: str, cwd: str) -> "RewindStore":
"""按 GA 的 log_path 落到 `temp/.ga_rewind/<key>/`。"""
root = os.path.join(temp_dir, ".ga_rewind", cls.key_for_log(log_path))
return cls(root, cwd)
def resume_from(self, old_root: str) -> bool:
"""/continue 续接:把旧会话的 rewind 目录接到本 store(贴近 CC 的
copyFileHistoryForResume)。
GA 每个新 agent 生成新 log_path → 新 key,旧会话的树/blob 默认看不到;
这里在新 key 下重建:① 接管旧 tree.json(仅当本 store 尚为空);
② blob 内容寻址,直接 hardlink(同名即同内容,跳过;失败回退 copy)。
返回是否接管成功。"""
if not os.path.isdir(old_root) or os.path.abspath(old_root) == self.root:
return False
old_tree = os.path.join(old_root, "tree.json")
if not self.nodes and os.path.exists(old_tree):
try:
with open(old_tree, encoding="utf-8") as f:
d = json.load(f)
self.nodes = d.get("nodes", {})
self.head = d.get("head")
self.root_id = d.get("root")
self._seq = d.get("seq", 0)
self.baseline = d.get("baseline", {})
self.tracked = set(d.get("tracked", []))
except Exception:
return False
old_objects = os.path.join(old_root, "objects")
if os.path.isdir(old_objects):
for sub in os.listdir(old_objects):
sd = os.path.join(old_objects, sub)
if not os.path.isdir(sd):
continue
dd = os.path.join(self.objects_dir, sub)
os.makedirs(dd, exist_ok=True)
for h in os.listdir(sd):
dst = os.path.join(dd, h)
if os.path.exists(dst):
continue # 内容寻址:同名即同内容
src = os.path.join(sd, h)
try:
os.link(src, dst) # hardlink 不占额外空间
except OSError:
try:
shutil.copyfile(src, dst) # 跨盘/不支持 → 回退 copy
except OSError:
pass
if self.nodes:
self._ensure_origin() # 旧树若无 origin,迁移补上
self.save()
return True
# ------------------------------------------------------------------ views
def linear_path(self) -> list[str]:
"""root → HEAD 的线性路径(节点 id),供 /rewind 时间线展示。"""
return self.path_to(self.head)
def path_to(self, node_id) -> list[str]:
"""root → node_id 的节点 id 列表(含两端)。node_id 缺失 → []。"""
if node_id is None or node_id not in self.nodes:
return []
chain: list[str] = []
cur: str | None = node_id
while cur is not None and cur in self.nodes:
chain.append(cur)
cur = self.nodes[cur].get("parent")
chain.reverse()
return chain
# ----------------------------------------------------------- conversation
def _node_conv(self, node_id) -> list:
"""单节点的对话增量(list)。无 conv / 读失败 → [](容错降级)。"""
h = self.nodes.get(node_id, {}).get("conv")
if not h:
return []
try:
data = json.loads(self._get_blob(h).decode("utf-8"))
return data if isinstance(data, list) else []
except Exception:
return []
def rebuild_history(self, node_id) -> list:
"""沿 root→node_id 拼接各节点对话增量,整体重建 `backend.history`。
这是「树=真相源」的恢复入口:选任意节点(含旁支)都能精确还原其对话,
不依赖内存里那条线性 history。origin / 无 conv 的节点贡献空增量。"""
hist: list = []
for nid in self.path_to(node_id):
hist.extend(self._node_conv(nid))
return hist
# ----------------------------------------------------- 对账(防外部改写灾难)
@staticmethod
def _msg_user_text(msg) -> str:
"""真实用户提问文本;非 user / 纯 tool_result 回填 → ""(不算提问边界)。"""
if not isinstance(msg, dict) or msg.get("role") != "user":
return ""
c = msg.get("content")
if isinstance(c, str):
return c
if isinstance(c, list):
if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in c):
return ""
return " ".join(b.get("text", "") for b in c
if isinstance(b, dict) and b.get("type") == "text")
return ""
@classmethod
def _turn_sig(cls, msgs) -> list:
"""一段消息的「对话身份」= 其中真实用户提问的文本序列(跳过 tool_result 轮,
故不含注入的易变内容)。用于 reconcile 判两段是否同一对话。"""
return [t for t in (cls._msg_user_text(m).strip() for m in msgs) if t]
def reconcile(self, history: list) -> int:
"""把 live history 里树尚未记录的尾部轮次吸收成 conv-only 节点,使树追平日志。
供「带 worldline 的 UI」在打开世界线 / 接管日志前调用 —— 别的(不更新树的)
UI 往同一日志追加后,若不对账,树会滞后;一旦在滞后状态 rewind,
`rewrite_projection` 会拿陈旧树回写、**抹掉那些外部轮次**(灾难性丢失)。
对账后树恒 ⊇ 日志,故 rewind 物理上不可能 clobber 未见过的轮次。
**安全闸**:仅当树 HEAD 路径与 history 前缀是「同一段对话」才在其上 append;
否则判为分歧(用户问了不同的问题),HEAD 退回 origin 另起一条顶层 trunk 容纳
live history,旧树保留为旁支、绝不拿它回写日志。空树 = n=0 特例(吸收全部,
即旧 _bootstrap_conv_tree)。幂等:无尾巴 → no-op,可任意重复调用。返回吸收条数。
判「同一段对话」用**用户提问序列**(`_turn_sig`)而非逐条全等:消息里会被注入
易变内容(如 `[WORKING MEMORY]`,只在 tool_result 轮),同一段对话在不同时刻
捕获并不逐字节相等;逐条全等会把它误判成分歧、复制出一整条重复 trunk。提问
文本在 tool_result 轮之外、稳定不变,是可靠的对话身份。"""
history = list(history or [])
self._ensure_origin()
head = self.head if self.head in self.nodes else self.root_id
known = self.rebuild_history(head)
n = len(known)
if self._turn_sig(history[:n]) != self._turn_sig(known):
# 分歧(提问序列不同):不信任陈旧树。退回空起点,后续 append 只反映 live history。
self.rewind_head(self.root_id)
n = len(self.rebuild_history(self.root_id)) # origin 无 conv → 0
if len(history) <= n:
return 0
# 按用户提问边界切尾巴 [n, len);逐段 commit(history=history[:段末]),
# commit 内部 delta = history[parent.hist_len:段末]。清 _touched 保证纯 conv-only。
starts = [i for i in range(n, len(history))
if self._msg_user_text(history[i]).strip()]
if not starts or starts[0] > n:
starts = [n] + starts
self._touched.clear()
absorbed = 0
for p, s in enumerate(starts):
end = starts[p + 1] if p + 1 < len(starts) else len(history)
title = next((self._msg_user_text(history[i]).strip()
for i in range(s, end)
if self._msg_user_text(history[i]).strip()), "")
title = (title or "(外部续接)").replace("\n", " ").strip()[:80]
self.commit(title or "(外部续接)", kind="edit", history=list(history[:end]))
absorbed += end - s
return absorbed
def first_user_message(self, node_id) -> dict | None:
"""node_id 对话增量里的首条 `role==user` 消息(原始 dict);无则 None。
供 rewind 后精确 prefill(取「该提问本身」,不靠会漂的绝对下标)。文本提取
交调用方(消息内容格式是 GA backend 私有的)。"""
for msg in self._node_conv(node_id):
if isinstance(msg, dict) and msg.get("role") == "user":
return msg
return None
# ============================================================================
# 世界线视图模型 + 压缩 + 导航(从 rewind_tree_view.py 抽出的 UI 无关后端)
# ============================================================================
# 节点**语义**(字形 + 文字标签);**呈现色由前端决定**(颜色不属于后端)。
KIND_GLYPH = {"current": "◉", "rewind": "↩", "origin": "●"} # ◉=当前所在;●=普通/根
KIND_LABEL = {"current": "当前位置", "rewind": "回退来源", "origin": "会话起点"}
def kind_glyph(kind: str) -> str:
return KIND_GLYPH.get(kind, "●")
def kind_label(kind: str) -> str:
return KIND_LABEL.get(kind, "")
def rel_time(ago_s) -> str:
if ago_s is None:
return ""
if ago_s < 60:
return "刚刚"
m = ago_s // 60
if m < 60:
return f"{m} 分钟前"
h = m // 60
if h < 24:
return f"{h} 小时前"
return f"{h // 24} 天前"
def files_summary(files: List[str]) -> str:
if not files:
return "无变更"
if len(files) <= 3:
return "、".join(files)
return "、".join(files[:3]) + f" (+{len(files) - 3})"
_TITLE_MAX_CELLS = 40
def ellipsize(s: str, max_cells: int = _TITLE_MAX_CELLS) -> str:
"""按显示宽度(中文算 2 格)截断,超长加 `…`。用于左树/右上的长提问标题。"""
s = (s or "").replace("\n", " ").strip()
if cell_len(s) <= max_cells:
return s
return set_cell_size(s, max(1, max_cells - 1)).rstrip() + "…"
# --------------------------------------------------------------- 数据模型
@dataclass
class CheckpointNode:
id: str
title: str
parent_id: Optional[str] = None
children: List[str] = field(default_factory=list)
kind: str = "edit"
files: List[str] = field(default_factory=list)
ago: Optional[int] = 0
class CheckpointTree:
"""只读视图树(供压缩/渲染);从 RewindStore 构造。"""
def __init__(self) -> None:
self.nodes: Dict[str, CheckpointNode] = {}
self.root_id: Optional[str] = None
def _changed_files(store, node_id) -> List[str]:
import os
nd = store.nodes[node_id]
par = nd.get("parent")
pf = store.nodes[par]["files"] if par in store.nodes else {}
out: List[str] = []
for k, v in nd["files"].items():
if pf.get(k) != v:
b = os.path.basename(k)
if b not in out:
out.append(b)
return out
def tree_from_store(store, now: float) -> CheckpointTree:
"""把 RewindStore 的真实树投影成只读 CheckpointTree;HEAD 标 current。"""
t = CheckpointTree()
for nid, nd in store.nodes.items():
t.nodes[nid] = CheckpointNode(
id=nid,
title=nd.get("title") or "(空)",
parent_id=nd.get("parent"),
children=[c for c in nd.get("children", []) if c in store.nodes],
kind="current" if nid == store.head else nd.get("kind", "edit"),
files=_changed_files(store, nid),
ago=int(max(0, now - nd.get("created", now))),
)
t.root_id = store.root_id
return t
# --------------------------------------------- 压缩(强制折叠线性段,§4.2)
@dataclass
class DisplayNode:
key: int
kind: str # 'real' | 'fold'
depth: int = 0
parent_key: Optional[int] = None
children: List[int] = field(default_factory=list)
node_id: Optional[str] = None
seg: List[str] = field(default_factory=list)
end_id: Optional[str] = None
class CompressedTree:
def __init__(self, tree: CheckpointTree) -> None:
self.tree = tree
self.disp: Dict[int, DisplayNode] = {}
self.roots: List[int] = [] # 顶层显示节点 keys(通常一个 = origin)
self._k = 0
# 顶层节点(origin「会话起点」)常驻独立显示作锚点;它下面的真实对话链
# 照常折叠(§4.2)。origin 是「第一轮对话之前」的空状态,不是某轮对话,
# 所以独立显示语义清晰、不会像"第一轮对话被强行拎出"那样让人困惑。
for top in self._top_level():
self._build_root_node(top)
def _top_level(self) -> List[str]:
return [nid for nid, n in self.tree.nodes.items()
if n.parent_id is None or n.parent_id not in self.tree.nodes]
def _new_key(self) -> int:
k = self._k
self._k += 1
return k
def _build_root_node(self, root_id: str) -> int:
key = self._new_key()
self.disp[key] = DisplayNode(key=key, kind="real", node_id=root_id, depth=0)
self.roots.append(key)
for child_id in self.tree.nodes[root_id].children:
self._build_chain(child_id, 1, key)
return key
def _build_chain(self, start_id: str, depth: int, parent: Optional[int]) -> int:
seg = [start_id]
cur = start_id
while len(self.tree.nodes[cur].children) == 1:
cur = self.tree.nodes[cur].children[0]
seg.append(cur)
end = seg[-1]
key = self._new_key()
if len(seg) == 1:
self.disp[key] = DisplayNode(key=key, kind="real", node_id=end,
depth=depth, parent_key=parent)
else:
self.disp[key] = DisplayNode(key=key, kind="fold", depth=depth,
parent_key=parent, seg=list(seg), end_id=end)
if parent is None:
self.roots.append(key)
else:
self.disp[parent].children.append(key)
for child_id in self.tree.nodes[end].children:
self._build_chain(child_id, depth + 1, key)
return key
def flatten(self) -> List[int]:
order: List[int] = []
def dfs(k: int) -> None:
order.append(k)
for c in self.disp[k].children:
dfs(c)
for r in self.roots:
dfs(r)
return order
def end_node(self, key: int) -> CheckpointNode:
d = self.disp[key]
return self.tree.nodes[d.node_id if d.kind == "real" else d.end_id]
def label(self, key: int) -> str:
# 真实/折叠都只放末端标题(截断);折叠的 [N nodes] 标记放在 glyph 位(标题前)。
d = self.disp[key]
nid = d.node_id if d.kind == "real" else d.end_id
return ellipsize(self.tree.nodes[nid].title)
def glyph(self, key: int) -> str:
# 折叠节点开头用 [N nodes] 替代原来的 … 省略号(→ "[3 nodes] 标题");
# 真实节点仍用语义字形(● / ↩ / ◉)。
d = self.disp[key]
if d.kind == "fold":
return f"[{len(d.seg)} nodes]"
return kind_glyph(self.tree.nodes[d.node_id].kind)
# ----------------------------------------------------- 导航(纯计算)
def _order_depths(ct: CompressedTree) -> List[tuple[int, int]]:
return [(k, ct.disp[k].depth) for k in ct.flatten()]
def next_same_depth(order, sel, direction):
idx = next(i for i, (k, _) in enumerate(order) if k == sel)
depth = order[idx][1]
same = [i for i, (_, d) in enumerate(order) if d == depth]
pos = same.index(idx)
return order[same[(pos + direction) % len(same)]][0]
def nearest_depth_node(order, sel, delta):
idx = next(i for i, (k, _) in enumerate(order) if k == sel)
target = order[idx][1] + delta
if target < 0:
return sel
cand = [i for i, (_, d) in enumerate(order) if d == target]
if not cand:
return sel
if delta > 0:
best = min(cand, key=lambda i: (abs(i - idx), 0 if i >= idx else 1, i))
else:
best = min(cand, key=lambda i: (abs(i - idx), 0 if i <= idx else 1, -i))
return order[best][0]
def parent_sibling_first_child(ct, sel, direction):
d = ct.disp[sel]
if d.parent_key is None:
return sel
parent = ct.disp[d.parent_key]
if parent.parent_key is None:
return sel
siblings = ct.disp[parent.parent_key].children
if parent.key not in siblings or len(siblings) <= 1:
return sel
start = siblings.index(parent.key)
step = 1 if direction >= 0 else -1
for off in range(1, len(siblings)):
sib = ct.disp[siblings[(start + step * off) % len(siblings)]]
if sib.children:
return sib.children[0]
return sel
# ============================================================================
# 恢复编排(UI 无关):算出回退后的对话/文件/prefill 并落地,前端只刷新自己的显示
# ============================================================================
def user_msg_text(entry) -> str:
"""从一条 user 消息里取出用户可见文本(content 为 str 或 text block 列表)。"""
c = entry.get("content") if isinstance(entry, dict) else None
if isinstance(c, str):
return c
if isinstance(c, list):
parts = [b.get("text", "") for b in c
if isinstance(b, dict) and b.get("type") == "text"]
return "\n".join(p for p in parts if p)
return ""
def render_native_history(history, ts=None):
"""Inverse of `continue_cmd._parse_native_history`: render a `backend.history`
(alternating user/assistant) back into the native `=== Prompt/Response ===`
log text. Lives here (not continue_cmd) because the rewind tree is its only
caller — it rewrites `model_responses_<key>.txt` as a linear projection of
the current HEAD path so /continue + other UIs get a clean single-branch view
(the TUI itself reads only the tree). Round-trip holds:
`_parse_native_history(_pairs(render_native_history(h))) == h`.
Byte-for-byte matches llmcore's `_write_llm_log` for the tool backend:
Prompt = `json.dumps(merged, ensure_ascii=False, indent=2)`; Response =
`str(content_blocks)` which for a list equals `repr(...)` (survives
True/False/None that JSON would emit as true/false/null and break
`ast.literal_eval`). Only complete (user, assistant) pairs are emitted."""
ts = ts or datetime.now().strftime('%Y-%m-%d %H:%M:%S')
out, i, n = [], 0, len(history)
while i < n:
u = history[i]
if isinstance(u, dict) and u.get('role') == 'user' and i + 1 < n:
a = history[i + 1]
if isinstance(a, dict) and a.get('role') == 'assistant':
out.append(f"=== Prompt === {ts}\n{json.dumps(u, ensure_ascii=False, indent=2)}\n\n")
out.append(f"=== Response === {ts}\n{a.get('content')!r}\n\n")
i += 2
continue
i += 1
return "".join(out)
def rewrite_projection(store, target, log_path: str) -> bool:
"""把 `model_responses_<key>.txt` 重写成 root→target 的线性投影(native
`=== Prompt/Response ===` 格式,`continue_cmd._parse_native_history` 可解析)。
给其他 UI + /continue 的兼容视角;消除 rewind/fork 后多分支混排。故障静默。"""
if store is None or not log_path:
return False
try:
text = render_native_history(store.rebuild_history(target))
# 回退到「会话起点」(origin)时 history 为空 → 日志被写空(0 字节)。这是【硬】回退:
# 日志恒等于树 HEAD,无错位,reconcile 不会复制分支。空日志会被普通 UI 的
# list_sessions(sz<32) 无害跳过;带 worldline 的 UI 靠 list_sessions(rewind_root=...)
# 的树感知发现 + continue_inplace(allow_empty=True) 仍能找回并恢复(空对话 + 重连树)。
tmp = log_path + ".tmp"
with open(tmp, "w", encoding="utf-8", errors="replace") as f:
f.write(text)
os.replace(tmp, log_path)
return True
except Exception:
return False
def restore_plan(store, node_id, mode: str = "both", to: str = "before",
log_path: str = "") -> Optional[dict]:
"""UI 无关的回退编排。`to`:
- **before**(选中段内某节点):回到该提问**之前**(连该节点一起清除),prefill 其提问;
target = parent(node) / origin。
- **at**(末尾占位项):在该节点处**继续**(HEAD→node,不清除、无 prefill)。
`mode`: both/conv/code。会就地:重建对话(返回 history,调用方自行赋给 backend)、
`apply_code`(还原文件,前自动留 redo 点)、移 HEAD、重写投影日志。
返回 None(无效) 或 dict:
{history: list|None(仅 conv 变更时), changed: [(rel,action)], prefill: str,
at_origin: bool, target: str, title: str, to: str}
—— 前端据此:赋 backend.history、重建界面消息、prefill 输入框、刷新。"""
if store is None or not node_id or node_id not in store.nodes:
return None
nd = store.nodes[node_id]
prefill = ""
if to == "at":
target = node_id
at_origin = nd.get("kind") == "origin"
else:
parent = nd.get("parent")
parent = parent if parent in store.nodes else None
target = parent if parent is not None else node_id
at_origin = parent is None
if not at_origin:
um = store.first_user_message(node_id)
prefill = (user_msg_text(um) if um else "") or nd.get("title", "")
history = None
if mode in ("both", "conv"):
history = store.rebuild_history(target)
changed = []
if mode in ("both", "code"):
try:
changed = store.apply_code(target)
except Exception:
changed = []
store.rewind_head(target)
if mode in ("both", "conv") and log_path:
rewrite_projection(store, target, log_path)
return {
"history": history,
"changed": changed,
"prefill": prefill,
"at_origin": at_origin,
"target": target,
"title": nd.get("title", ""),
"to": to,
}