revert(worldline): remove v2 checkpoint tree integration
This commit is contained in:
1 parent
904ade4907
commit
029b343f36
3 files changed
+31
-2333
No files matched your search
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user