qi-coding-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qi_agent/__init__.py +18 -0
- qi_agent/abort.py +40 -0
- qi_agent/auth.py +216 -0
- qi_agent/cli.py +2178 -0
- qi_agent/compaction.py +475 -0
- qi_agent/config.py +409 -0
- qi_agent/docs/cli.md +188 -0
- qi_agent/docs/compaction.md +148 -0
- qi_agent/docs/configuration.md +89 -0
- qi_agent/docs/containerization.md +80 -0
- qi_agent/docs/docs.json +1 -0
- qi_agent/docs/environment-variables.md +47 -0
- qi_agent/docs/extensions.md +382 -0
- qi_agent/docs/how-qi-works.md +93 -0
- qi_agent/docs/index.md +61 -0
- qi_agent/docs/json.md +112 -0
- qi_agent/docs/keybindings.md +83 -0
- qi_agent/docs/models.md +138 -0
- qi_agent/docs/packages.md +155 -0
- qi_agent/docs/providers.md +288 -0
- qi_agent/docs/quickstart.md +109 -0
- qi_agent/docs/security.md +69 -0
- qi_agent/docs/session-format.md +299 -0
- qi_agent/docs/sessions.md +127 -0
- qi_agent/docs/settings.md +138 -0
- qi_agent/docs/skills.md +124 -0
- qi_agent/docs/slash-commands.md +55 -0
- qi_agent/docs/themes.md +78 -0
- qi_agent/docs/tui.md +109 -0
- qi_agent/docs/usage.md +112 -0
- qi_agent/export_html.py +95 -0
- qi_agent/extensions.py +2267 -0
- qi_agent/llm.py +609 -0
- qi_agent/loader.py +299 -0
- qi_agent/model_catalog.py +108 -0
- qi_agent/models.py +103 -0
- qi_agent/packages.py +430 -0
- qi_agent/paths.py +242 -0
- qi_agent/pep723.py +74 -0
- qi_agent/presets.py +293 -0
- qi_agent/prompt.py +150 -0
- qi_agent/registry.py +485 -0
- qi_agent/runner.py +584 -0
- qi_agent/runtime.py +1940 -0
- qi_agent/session.py +739 -0
- qi_agent/settings.py +501 -0
- qi_agent/share.py +111 -0
- qi_agent/system_prompt.py +278 -0
- qi_agent/theme.py +389 -0
- qi_agent/themes/dark.json +90 -0
- qi_agent/themes/light.json +89 -0
- qi_agent/titling.py +90 -0
- qi_agent/tools/__init__.py +349 -0
- qi_agent/tools/shell.py +276 -0
- qi_agent/trust.py +106 -0
- qi_agent/tui.py +6224 -0
- qi_agent/workspaces.py +116 -0
- qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
- qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
- qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/session.py
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
"""会话(P3,格式 v2 = pi 的树):JSONL 每会话文件,entry 带 type/id/parentId。
|
|
2
|
+
|
|
3
|
+
位置:`~/.qi/agent/sessions/<ts>_<id>.jsonl`(全局,PLAN B1);settings.json 的
|
|
4
|
+
`sessionDir` 可覆盖。
|
|
5
|
+
|
|
6
|
+
**v2(当前)= 树**:每条 entry 带 `id` 与 `parentId`,历史是「从某个节点沿 parent
|
|
7
|
+
回溯到根」的那条链(§branch)。因此同一个文件里可以并存多条分支:回到旧节点继续提问,
|
|
8
|
+
新内容成为它的子节点,旧分支原样保留 —— 这是 pi `/tree` 的数据基础。
|
|
9
|
+
`version: 2` 写在 header。
|
|
10
|
+
|
|
11
|
+
**v1(旧)= 线性**:entry 没有 id/parentId,文件顺序即历史。读入时**在内存里补链**
|
|
12
|
+
(按文件顺序串成一条链),首次写入(append/save)时落盘 —— 与 pi「加载时迁移」等价,
|
|
13
|
+
但读路径(如 `sessions list`)不写文件。
|
|
14
|
+
|
|
15
|
+
其他:
|
|
16
|
+
- `entries` 仍是**文件里的全部 entry**(含 header 与其它分支);要历史请用 `branch()`。
|
|
17
|
+
- header(entries[0])是元数据(id/title/created_at/cwd/version),**不参与树**。
|
|
18
|
+
- `position` = 下一次 append 挂到哪个节点(默认文件最后一条 = `leaf`);`/tree` 改它。
|
|
19
|
+
- dispatch entry 除 agent(name)外还落盘 display_name:展示名应反映**当时**的值,
|
|
20
|
+
回放时无需再装载 agent/插件。
|
|
21
|
+
- 用户消息的 agent_id 是“将处理它的 agent”,不是发言者。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import copy
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from . import paths
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
#: 会话 id 的合法形状。它会**直接拼进文件名**(`<时间戳>_<id>.jsonl`),而 `--session-id`
|
|
39
|
+
#: 是用户给的 —— 一个 `../../x` 会写到会话目录之外。所以限死成“一路文件名成分”:首字符
|
|
40
|
+
#: 字母/数字(顺带排掉 `.` / `..` 与隐藏文件),其余允许点/下划线/连字符。我们自己生成的
|
|
41
|
+
#: `_new_id()`(uuid hex)天然满足,所以这条收窄对正常路径没有影响。
|
|
42
|
+
_SAFE_ID = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]*\Z")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _now() -> str:
|
|
46
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _new_id() -> str:
|
|
50
|
+
return uuid.uuid4().hex[:12]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _session_file_id(session_id: str | None) -> str:
|
|
54
|
+
"""`create()` 用的 id:没给就新生成,给了就**校验**它能不能当文件名。
|
|
55
|
+
|
|
56
|
+
这里选**报错**而不是静默清洗:`--session-id` 是用户明确要一个句柄,把一个 `../x`
|
|
57
|
+
悄悄换成别的名字,现场会得到“我指定的 id 没生效”而且毫无线索。
|
|
58
|
+
"""
|
|
59
|
+
if session_id is None:
|
|
60
|
+
return _new_id()
|
|
61
|
+
text = str(session_id)
|
|
62
|
+
if not _SAFE_ID.match(text):
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"会话 id 不合法:{text!r} —— 它要当文件名用,只允许字母/数字/点/下划线/连字符,"
|
|
65
|
+
"且不能以点开头")
|
|
66
|
+
return text
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _as_version(value: Any) -> int:
|
|
70
|
+
"""header 里的版本号容错:坏值当 2(已迁移过),不因为一行脏数据就报错。
|
|
71
|
+
|
|
72
|
+
形参是 **`Any` 而不是 `object`**:收的就是 `json.loads` 出来的任意值,而 `object`
|
|
73
|
+
静态上不能传给 `int()` —— 用 `object` 就得配一条抑制注释,而抑制只对 pyright 生效。
|
|
74
|
+
仓里同一个容错助手的三处兄弟(`llm.py` / `runtime.py` / `extensions.py`)都是 `Any`,
|
|
75
|
+
这里跟它们一致,不需要任何例外。
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
return int(value)
|
|
79
|
+
except (TypeError, ValueError):
|
|
80
|
+
return 2
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _has_assistant(session: Session) -> bool:
|
|
84
|
+
"""这个会话已经有助手回答了吗?—— `unflushed` 会话落盘的触发条件。
|
|
85
|
+
|
|
86
|
+
为什么以 **assistant** 为界(而不是"有任何 entry"):文件一旦被写出来,它就会出现在
|
|
87
|
+
`/resume` 列表里。用户敲了一句话、没等到回答就退出(打断 / 报错 / Ctrl+C)时,
|
|
88
|
+
列表里多一条无回答的会话毫无价值 —— 而"真聊过一轮"才值得留档。pi 的 `_persist`
|
|
89
|
+
用的正是这个判据。
|
|
90
|
+
"""
|
|
91
|
+
return any(e.get("type") == "message" and e.get("role") == "assistant"
|
|
92
|
+
for e in session.entries)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
#: TUI 在会话自动命名上线前写死的默认标题(`SessionStore.create("tui", …)`)。它**不是名字**:
|
|
96
|
+
#: 那时每个新会话都叫 `tui`,列表里一列同名的 `tui` 等于没有名字。见 `has_title()`。
|
|
97
|
+
LEGACY_DEFAULT_TITLE = "tui"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def has_title(title: str | None) -> bool:
|
|
101
|
+
"""这个标题算「有名字」吗?
|
|
102
|
+
|
|
103
|
+
空串与历史默认值 `LEGACY_DEFAULT_TITLE` 都不算 —— 两处要口径一致:
|
|
104
|
+
自动命名拿它判断「要不要起名」,选择器拿它判断「显示标题还是回落第一句话」。
|
|
105
|
+
"""
|
|
106
|
+
cleaned = (title or "").strip()
|
|
107
|
+
return bool(cleaned) and cleaned != LEGACY_DEFAULT_TITLE
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class Session:
|
|
112
|
+
id: str
|
|
113
|
+
path: Path
|
|
114
|
+
title: str = ""
|
|
115
|
+
created_at: str = ""
|
|
116
|
+
cwd: str | None = None # 会话工作目录(旧会话可能为 None,见 ensure_cwd)
|
|
117
|
+
entries: list[dict] = field(default_factory=list)
|
|
118
|
+
version: int = 2 # 格式版本(1 = 线性旧格式)
|
|
119
|
+
position: str | None = None # 下一次 append 的父节点(None = 用 leaf)
|
|
120
|
+
migrated: bool = False # 读入时补过链,尚未落盘
|
|
121
|
+
#: **内存会话**:`--no-session` 用。entries 照常攒、能被查询与回放,但**任何写盘都被跳过**
|
|
122
|
+
#: (append / save / set_title)。为什么不干脆不建 Session:整条回合链路(历史、用量、
|
|
123
|
+
#: 事件、扩展的 `ctx.session_manager`)都假定"有当前会话" —— 给一个真对象、只是不落盘,
|
|
124
|
+
#: 比在下游到处判 None 可靠得多。
|
|
125
|
+
ephemeral: bool = False
|
|
126
|
+
#: **尚未落过盘**:文件还没被创建出来。pi 的 `flushed` 同义 —— 见 `SessionStore._persist`:
|
|
127
|
+
#: 文件推迟到**第一条 assistant 回答**才写在磁盘上。
|
|
128
|
+
#:
|
|
129
|
+
#: 为什么需要它:回合一开始就得有会话对象(历史、用量、扩展的 `ctx.session_manager`
|
|
130
|
+
#: 都要),但"用户问了却没得到回答"(Ctrl+C、模型报错、问一句就走)不该在磁盘上留东西 ——
|
|
131
|
+
#: 那正是空会话堆积的成因(`docs/session-format.md` §9.1)。用户那句话仍留在内存里,
|
|
132
|
+
#: 真有回答时一起写出。
|
|
133
|
+
unflushed: bool = False
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def parent_session(self) -> str | None:
|
|
137
|
+
"""本会话是从哪个会话分叉出来的(header 的 `parentSession`,pi 同名字段)。
|
|
138
|
+
|
|
139
|
+
只有 `/fork` / `/clone` 建出来的会话有;老会话没有这个键 → None。
|
|
140
|
+
pi 的会话选择器用它把分叉串成树(`threaded` 排序)。
|
|
141
|
+
"""
|
|
142
|
+
header = self.entries[0] if self.entries else {}
|
|
143
|
+
value = header.get("parentSession") if header.get("type") == "session" else None
|
|
144
|
+
return str(value) if value else None
|
|
145
|
+
|
|
146
|
+
# ── 树 ──
|
|
147
|
+
@property
|
|
148
|
+
def tree_entries(self) -> list[dict]:
|
|
149
|
+
"""参与树的 entry(排除 header)。"""
|
|
150
|
+
return [e for e in self.entries if e.get("type") != "session"]
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def leaf(self) -> str | None:
|
|
154
|
+
"""文件里最后一条 entry 的 id —— 落盘的「当前节点」(pi 同款约定)。"""
|
|
155
|
+
for entry in reversed(self.entries):
|
|
156
|
+
if entry.get("type") != "session" and entry.get("id"):
|
|
157
|
+
return str(entry["id"])
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def current(self) -> str | None:
|
|
162
|
+
"""当前节点:优先用显式 position(`/tree` 改过),否则文件末尾。"""
|
|
163
|
+
return self.position or self.leaf
|
|
164
|
+
|
|
165
|
+
def branch(self, leaf: str | None = None) -> list[dict]:
|
|
166
|
+
"""从 `leaf`(默认当前节点)沿 parentId 回溯到根,返回**正序**的 entry 列表。
|
|
167
|
+
|
|
168
|
+
这就是「历史」:会话上下文、回放、消息计数都只看这条链。
|
|
169
|
+
文件损坏(悬空 parent/成环)时退化为文件顺序,不抛异常 —— 会话读不出来
|
|
170
|
+
比丢几条分支更糟。
|
|
171
|
+
"""
|
|
172
|
+
nodes = [e for e in self.entries if e.get("type") != "session"]
|
|
173
|
+
if not nodes:
|
|
174
|
+
return []
|
|
175
|
+
by_id = {str(e["id"]): e for e in nodes if e.get("id")}
|
|
176
|
+
target = leaf or self.current
|
|
177
|
+
node = by_id.get(target) if target else None
|
|
178
|
+
if node is None:
|
|
179
|
+
node = nodes[-1]
|
|
180
|
+
out: list[dict] = []
|
|
181
|
+
seen: set[str] = set()
|
|
182
|
+
while node is not None:
|
|
183
|
+
node_id = str(node.get("id", ""))
|
|
184
|
+
if node_id and node_id in seen: # 环:停下,别死循环
|
|
185
|
+
break
|
|
186
|
+
seen.add(node_id)
|
|
187
|
+
out.append(node)
|
|
188
|
+
parent = node.get("parentId")
|
|
189
|
+
node = by_id.get(str(parent)) if parent else None
|
|
190
|
+
out.reverse()
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
def visible_entries(self, leaf: str | None = None) -> list[dict]:
|
|
194
|
+
"""header + 当前分支 —— web `/messages` 的契约一直把 header 当作 entries[0]
|
|
195
|
+
(前端 types.ts 也这么写),所以这里保留 header,只把其它分支挡掉。
|
|
196
|
+
"""
|
|
197
|
+
header = [e for e in self.entries[:1] if e.get("type") == "session"]
|
|
198
|
+
return [*header, *self.branch(leaf)]
|
|
199
|
+
|
|
200
|
+
def children(self, entry_id: str | None) -> list[dict]:
|
|
201
|
+
"""某节点的直接子节点(用于树渲染);`entry_id=None` = 根层。"""
|
|
202
|
+
return [e for e in self.tree_entries
|
|
203
|
+
if (e.get("parentId") or None) == (entry_id or None)]
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def branch_points(self) -> int:
|
|
207
|
+
"""分叉点数(有 >1 个子节点的节点数)。"""
|
|
208
|
+
counts: dict[str | None, int] = {}
|
|
209
|
+
for e in self.tree_entries:
|
|
210
|
+
key = e.get("parentId") or None
|
|
211
|
+
counts[key] = counts.get(key, 0) + 1
|
|
212
|
+
return sum(1 for n in counts.values() if n > 1)
|
|
213
|
+
|
|
214
|
+
def message_count_of(self, leaf: str | None = None) -> int:
|
|
215
|
+
return sum(1 for e in self.branch(leaf) if e.get("type") == "message")
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def message_count(self) -> int:
|
|
219
|
+
"""当前分支上的消息数(不是文件里所有分支的总和)。"""
|
|
220
|
+
return self.message_count_of()
|
|
221
|
+
|
|
222
|
+
# ── 选择器用的派生量(pi 的 SessionInfo 那几项)──
|
|
223
|
+
@property
|
|
224
|
+
def first_user_text(self) -> str:
|
|
225
|
+
"""当前分支上第一条用户消息 —— 没起过名时,**它就是列表里显示的那行**(pi 同款)。
|
|
226
|
+
|
|
227
|
+
用户消息的 `content` 可能是非字符串(图片块等),那种跳过继续找。
|
|
228
|
+
"""
|
|
229
|
+
for entry in self.branch():
|
|
230
|
+
if entry.get("type") == "message" and entry.get("role") == "user":
|
|
231
|
+
content = entry.get("content")
|
|
232
|
+
if isinstance(content, str) and content.strip():
|
|
233
|
+
return content
|
|
234
|
+
return ""
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def display_label(self) -> str:
|
|
238
|
+
"""列表里显示什么:有名字用名字,否则回落第一句话(再没有就占位)。"""
|
|
239
|
+
if has_title(self.title):
|
|
240
|
+
return self.title.strip()
|
|
241
|
+
first = " ".join(self.first_user_text.split())
|
|
242
|
+
return first or "(无消息)"
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def modified_ts(self) -> float:
|
|
246
|
+
"""最后活动时间:取 entry 里最晚的 `ts`;**没有 entry 时间就回落 header 的 `created_at`**
|
|
247
|
+
(刚建的会话只有 header)。
|
|
248
|
+
|
|
249
|
+
为什么不用文件 mtime:整文件重写(改名、迁移)会把它推到现在,排序就乱了。
|
|
250
|
+
坏值忽略(不抛):一行脏数据不该让整个列表排不出来。
|
|
251
|
+
"""
|
|
252
|
+
best = 0.0
|
|
253
|
+
for entry in self.entries:
|
|
254
|
+
raw = entry.get("ts") or (entry.get("created_at")
|
|
255
|
+
if entry.get("type") == "session" else None)
|
|
256
|
+
if not isinstance(raw, str):
|
|
257
|
+
continue
|
|
258
|
+
try:
|
|
259
|
+
best = max(best, time.mktime(time.strptime(raw[:19], "%Y-%m-%dT%H:%M:%S")))
|
|
260
|
+
except ValueError:
|
|
261
|
+
continue
|
|
262
|
+
return best
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def search_text(self) -> str:
|
|
266
|
+
"""模糊/短语搜索的语料:id + 标题 + **全部**消息 + cwd(pi 的 `getSessionSearchText`)。
|
|
267
|
+
|
|
268
|
+
用 `entries` 而不是 `branch()`:pi 也是把文件里所有消息拼进去 —— 搜的是
|
|
269
|
+
「这个会话里聊过什么」,翻过的旧分支同样算。
|
|
270
|
+
"""
|
|
271
|
+
parts = [self.id, self.title or "", self.cwd or ""]
|
|
272
|
+
for entry in self.entries:
|
|
273
|
+
if entry.get("type") != "message":
|
|
274
|
+
continue
|
|
275
|
+
content = entry.get("content")
|
|
276
|
+
if isinstance(content, str) and content:
|
|
277
|
+
parts.append(content)
|
|
278
|
+
return " ".join(parts)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _as_int(value: object) -> int:
|
|
282
|
+
"""usage 里的计数:非整数(bool 也是 int 的子类,单独排除)一律当 0。
|
|
283
|
+
|
|
284
|
+
provider 差异大 —— 有的回字符串、有的回 null。它们不影响会话能不能跑,所以不报错。
|
|
285
|
+
"""
|
|
286
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
#: 「设置类」entry:记录**模型 / 思考级别**是什么时候被换掉的(pi 同款两类)。
|
|
290
|
+
#:
|
|
291
|
+
#: 为什么落盘:切换动作本身是**界面状态**,不会作为消息进 LLM 上下文 —— 于是 agent
|
|
292
|
+
#: 看不出中途换过模型(它只知道「当前是什么」),回放里也没有切换点。落一条 entry 就有了
|
|
293
|
+
#: 时间线上的事实,而**续会话时按它还原**才让「上次切到的模型」跟着回来(pi 的
|
|
294
|
+
#: `getSessionContextSettings`)。
|
|
295
|
+
#:
|
|
296
|
+
#: 它们**不进上下文**(`Runtime._history()` 只读 `message`),也不在 `/tree` 的默认视图里
|
|
297
|
+
#: 显示(`tui.entry_passes_tree_filter`)—— 与 `custom` 同一条取舍:看得见的归界面,
|
|
298
|
+
#: 进模型的归 message。
|
|
299
|
+
MODEL_CHANGE = "model_change"
|
|
300
|
+
THINKING_LEVEL_CHANGE = "thinking_level_change"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def context_settings(branch: list[dict]) -> dict:
|
|
304
|
+
"""从一条分支上读出**生效的**设置:`{"model": (provider, id) | None, "thinking_level": str | None}`。
|
|
305
|
+
|
|
306
|
+
取**最后一条**(后者胜)—— 与 pi 的 `getSessionContextSettings` 同一口径。
|
|
307
|
+
坏值/缺键当没有:一行脏数据不该让会话打不开。
|
|
308
|
+
"""
|
|
309
|
+
model: tuple[str, str] | None = None
|
|
310
|
+
level: str | None = None
|
|
311
|
+
for entry in branch:
|
|
312
|
+
kind = entry.get("type")
|
|
313
|
+
if kind == MODEL_CHANGE:
|
|
314
|
+
provider = str(entry.get("provider") or "").strip()
|
|
315
|
+
model_id = str(entry.get("model_id") or "").strip()
|
|
316
|
+
if provider and model_id:
|
|
317
|
+
model = (provider, model_id)
|
|
318
|
+
elif kind == THINKING_LEVEL_CHANGE:
|
|
319
|
+
raw = str(entry.get("thinking_level") or "").strip()
|
|
320
|
+
if raw:
|
|
321
|
+
level = raw
|
|
322
|
+
return {"model": model, "thinking_level": level}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def usage_summary(branch: list[dict]) -> dict:
|
|
326
|
+
"""当前分支的用量汇总(**会话级**,读落盘的 entries)。
|
|
327
|
+
|
|
328
|
+
为什么在后端算:前端只拿得到**一个窗口**(`/api/sessions/{id}` 是分页的),
|
|
329
|
+
长会话窗口前面还有几千条 —— 前端求和会静默少算。后端手上有整条分支。
|
|
330
|
+
|
|
331
|
+
口径:
|
|
332
|
+
- `turns` 用户轮数(`role=user` 的 message);
|
|
333
|
+
- `steps` / `llm_calls` / token 累计**只读带 usage 的助手消息**;
|
|
334
|
+
- 写入 usage 之前的老会话、以及被硬取消的轮没有 usage → 它们只计入 `turns`,
|
|
335
|
+
不把 token 当成 0 也不补估值(没有就是没有);
|
|
336
|
+
- `context_tokens` 取**最后一条**带该键的轮 —— 那是那一轮最后一次 LLM 调用
|
|
337
|
+
看到的 prompt 大小(即"现在上下文里装着多少"),**不是**各轮 prompt 相加。
|
|
338
|
+
"""
|
|
339
|
+
turns = 0
|
|
340
|
+
tools = 0
|
|
341
|
+
tool_failures = 0
|
|
342
|
+
steps = 0
|
|
343
|
+
llm_calls = 0
|
|
344
|
+
totals: dict[str, int] = {}
|
|
345
|
+
context_tokens = 0
|
|
346
|
+
for entry in branch:
|
|
347
|
+
kind = entry.get("type")
|
|
348
|
+
if kind == "tool":
|
|
349
|
+
tools += 1
|
|
350
|
+
if entry.get("status") == "error":
|
|
351
|
+
tool_failures += 1
|
|
352
|
+
continue
|
|
353
|
+
if kind != "message":
|
|
354
|
+
continue
|
|
355
|
+
if entry.get("role") == "user":
|
|
356
|
+
turns += 1
|
|
357
|
+
continue
|
|
358
|
+
if entry.get("role") != "assistant":
|
|
359
|
+
continue
|
|
360
|
+
usage = entry.get("usage")
|
|
361
|
+
if not isinstance(usage, dict):
|
|
362
|
+
continue
|
|
363
|
+
steps += _as_int(usage.get("turns"))
|
|
364
|
+
llm_calls += _as_int(usage.get("llm_calls"))
|
|
365
|
+
for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
|
|
366
|
+
totals[key] = totals.get(key, 0) + _as_int(usage.get(key))
|
|
367
|
+
context = _as_int(usage.get("context_tokens"))
|
|
368
|
+
if context > 0:
|
|
369
|
+
context_tokens = context
|
|
370
|
+
# 有的 provider 不给 total_tokens:那就用两侧之和 —— 好过显示 0。
|
|
371
|
+
total = totals.get("total_tokens") or (totals.get("prompt_tokens", 0)
|
|
372
|
+
+ totals.get("completion_tokens", 0))
|
|
373
|
+
return {"turns": turns, "steps": steps, "tools": tools,
|
|
374
|
+
"tool_failures": tool_failures, "llm_calls": llm_calls,
|
|
375
|
+
"prompt_tokens": totals.get("prompt_tokens", 0),
|
|
376
|
+
"completion_tokens": totals.get("completion_tokens", 0),
|
|
377
|
+
"total_tokens": total, "context_tokens": context_tokens}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class SessionError(Exception):
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class SessionStore:
|
|
385
|
+
def __init__(self, root: Path | None = None):
|
|
386
|
+
self.root = root or (paths.global_home() / "sessions")
|
|
387
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
388
|
+
|
|
389
|
+
def _files(self) -> list[Path]:
|
|
390
|
+
if not self.root.is_dir():
|
|
391
|
+
return []
|
|
392
|
+
return sorted(self.root.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
393
|
+
|
|
394
|
+
def _read(self, path: Path) -> list[dict]:
|
|
395
|
+
entries = []
|
|
396
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
397
|
+
line = line.strip()
|
|
398
|
+
if not line:
|
|
399
|
+
continue
|
|
400
|
+
try:
|
|
401
|
+
entries.append(json.loads(line))
|
|
402
|
+
except json.JSONDecodeError:
|
|
403
|
+
continue
|
|
404
|
+
return entries
|
|
405
|
+
|
|
406
|
+
def create(self, title: str = "", cwd: Path | str | None = None,
|
|
407
|
+
session_id: str | None = None,
|
|
408
|
+
parent_session: Path | str | None = None) -> Session:
|
|
409
|
+
"""新建会话文件(v2 树格式)。
|
|
410
|
+
|
|
411
|
+
`cwd` 写进 header:按项目分组与恢复时选对工作目录都靠它。
|
|
412
|
+
省略时为 None,后续由 `ensure_cwd()` 回填。
|
|
413
|
+
|
|
414
|
+
`session_id` 给了就用它(CLI 的 `--session-id <id>`:精确 id,不存在则建),否则随机。
|
|
415
|
+
|
|
416
|
+
`parent_session` = 这个会话从哪个文件分叉出来(pi header 的 `parentSession`)。
|
|
417
|
+
只有 `/fork` / `/clone` 会传;会话选择器的树状(threaded)视图靠它串起来。
|
|
418
|
+
"""
|
|
419
|
+
sid = _session_file_id(session_id) # 校验:C 会进文件名,见 `_SAFE_ID`
|
|
420
|
+
path = self.root / f"{time.strftime('%Y%m%dT%H%M%S')}_{sid}.jsonl"
|
|
421
|
+
now = _now()
|
|
422
|
+
header: dict = {"type": "session", "version": 2, "id": sid,
|
|
423
|
+
"title": title, "created_at": now}
|
|
424
|
+
if cwd is not None:
|
|
425
|
+
header["cwd"] = str(Path(cwd).expanduser().resolve())
|
|
426
|
+
if parent_session is not None:
|
|
427
|
+
header["parentSession"] = str(Path(parent_session).expanduser())
|
|
428
|
+
path.write_text(json.dumps(header, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
429
|
+
return Session(id=sid, path=path, title=title, created_at=now,
|
|
430
|
+
cwd=header.get("cwd"), entries=[header], version=2)
|
|
431
|
+
|
|
432
|
+
def ephemeral(self, title: str = "", cwd: Path | str | None = None) -> Session:
|
|
433
|
+
"""建一个**不落盘**的会话(`qi --no-session`)。
|
|
434
|
+
|
|
435
|
+
与 `create()` 的唯一差别:不写文件、`ephemeral=True`。`path` 仍给一个**看起来正常**
|
|
436
|
+
的路径 —— 下游(`/session` 的信息行、`QI_SESSION_FILE` 环境变量、扩展)都能照常读它,
|
|
437
|
+
只是永远没有那个文件。用 `session.ephemeral` 判真假,不要拿 `path.exists()` 判:
|
|
438
|
+
那会在"文件刚好被删掉"时给出错误答案。
|
|
439
|
+
"""
|
|
440
|
+
sid = _new_id()
|
|
441
|
+
now = _now()
|
|
442
|
+
header: dict = {"type": "session", "version": 2, "id": sid,
|
|
443
|
+
"title": title, "created_at": now}
|
|
444
|
+
if cwd is not None:
|
|
445
|
+
header["cwd"] = str(Path(cwd).expanduser().resolve())
|
|
446
|
+
return Session(id=sid, path=self.root / f"{time.strftime('%Y%m%dT%H%M%S')}_{sid}.jsonl",
|
|
447
|
+
title=title, created_at=now, cwd=header.get("cwd"),
|
|
448
|
+
entries=[header], version=2, ephemeral=True)
|
|
449
|
+
|
|
450
|
+
def reserve(self, title: str = "", cwd: Path | str | None = None) -> Session:
|
|
451
|
+
"""**预留**一个新会话:id 与路径定下来,但**文件先不建**(`unflushed=True`)。
|
|
452
|
+
|
|
453
|
+
这是"裸 `qi` 进来"该走的路 —— 对齐 pi 的 `newSession()`:它同样只算好
|
|
454
|
+
`sessionFile`、`flushed=false`,真写到磁盘要等第一条 assistant 回答
|
|
455
|
+
(pi 的 `_persist`:`openSync(path, "wx")` 一次写出全部 entry)。
|
|
456
|
+
|
|
457
|
+
为什么不能就在 `create()` 里建文件:`qi` 看一眼就走、或问了句就被 Ctrl+C,
|
|
458
|
+
都会留下一个空会话;本机 3000+ 个空会话就是这么攒出来的。
|
|
459
|
+
"""
|
|
460
|
+
sid = _new_id()
|
|
461
|
+
now = _now()
|
|
462
|
+
header: dict = {"type": "session", "version": 2, "id": sid,
|
|
463
|
+
"title": title, "created_at": now}
|
|
464
|
+
if cwd is not None:
|
|
465
|
+
header["cwd"] = str(Path(cwd).expanduser().resolve())
|
|
466
|
+
return Session(id=sid, path=self.root / f"{time.strftime('%Y%m%dT%H%M%S')}_{sid}.jsonl",
|
|
467
|
+
title=title, created_at=now, cwd=header.get("cwd"),
|
|
468
|
+
entries=[header], version=2, unflushed=True)
|
|
469
|
+
|
|
470
|
+
def flush(self, session: Session) -> None:
|
|
471
|
+
"""把一个 `unflushed` 的会话写出来(第一条 assistant 回答到达时,或用户给它改了名)。幂等。
|
|
472
|
+
|
|
473
|
+
用 `"x"`(独占创建,即 `O_EXCL`)而不是 `"w"`:`unflushed` 期间**不该有**文件,
|
|
474
|
+
真有就说明路径撞了 —— 那种情况宁可报错,也不要静默覆盖掉另一个会话
|
|
475
|
+
(用户的会话是**不可再生**的数据)。
|
|
476
|
+
|
|
477
|
+
两个调用点,语义都是"这个会话从此算真的存在了":
|
|
478
|
+
- `_persist`:出现了第一条 assistant 回答(懒建的正路);
|
|
479
|
+
- `set_title`:用户**给它起了名** —— 见那里的注释。
|
|
480
|
+
"""
|
|
481
|
+
if session.ephemeral or not session.unflushed:
|
|
482
|
+
return
|
|
483
|
+
with session.path.open("x", encoding="utf-8") as fh:
|
|
484
|
+
for entry in session.entries:
|
|
485
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
486
|
+
session.unflushed = False
|
|
487
|
+
session.migrated = False
|
|
488
|
+
self.save(session)
|
|
489
|
+
|
|
490
|
+
@staticmethod
|
|
491
|
+
def migrate(entries: list[dict]) -> bool:
|
|
492
|
+
"""把线性(v1)entry 就地补成树(v2):按文件顺序串链。返回是否改过。"""
|
|
493
|
+
changed = False
|
|
494
|
+
parent: str | None = None
|
|
495
|
+
for entry in entries:
|
|
496
|
+
if entry.get("type") == "session":
|
|
497
|
+
if entry.get("version") != 2:
|
|
498
|
+
entry["version"] = 2
|
|
499
|
+
changed = True
|
|
500
|
+
continue
|
|
501
|
+
if not entry.get("id"):
|
|
502
|
+
entry["id"] = _new_id()
|
|
503
|
+
changed = True
|
|
504
|
+
if "parentId" not in entry:
|
|
505
|
+
entry["parentId"] = parent
|
|
506
|
+
changed = True
|
|
507
|
+
parent = str(entry["id"])
|
|
508
|
+
return changed
|
|
509
|
+
|
|
510
|
+
@staticmethod
|
|
511
|
+
def _from_entries(path: Path, entries: list[dict]) -> Session:
|
|
512
|
+
"""用已读到的 entries 造 Session(header = entries[0]),顺带做 v1→v2 迁移。"""
|
|
513
|
+
header = entries[0] if entries else {}
|
|
514
|
+
migrated = SessionStore.migrate(entries)
|
|
515
|
+
cwd = header.get("cwd")
|
|
516
|
+
return Session(id=str(header.get("id", "?")), path=path,
|
|
517
|
+
title=header.get("title", ""),
|
|
518
|
+
created_at=header.get("created_at", ""),
|
|
519
|
+
cwd=cwd if isinstance(cwd, str) else None,
|
|
520
|
+
entries=entries,
|
|
521
|
+
version=_as_version(header.get("version")),
|
|
522
|
+
migrated=migrated)
|
|
523
|
+
|
|
524
|
+
def ensure_cwd(self, session: Session, cwd: Path | str) -> bool:
|
|
525
|
+
"""给旧会话回填 `cwd`(已有值则不动)。返回是否发生了写入。
|
|
526
|
+
|
|
527
|
+
v0.1 的会话 header 没有 cwd;首次被使用时补上,这样历史会话也能按项目分组。
|
|
528
|
+
整文件重写一次,此后 `session.cwd` 有值,不再进本分支。
|
|
529
|
+
header 不合法(空文件/损坏)时**不猜**,直接返回 False。
|
|
530
|
+
"""
|
|
531
|
+
if session.cwd or not session.entries:
|
|
532
|
+
return False
|
|
533
|
+
if session.entries[0].get("type") != "session":
|
|
534
|
+
return False
|
|
535
|
+
resolved = str(Path(cwd).expanduser().resolve())
|
|
536
|
+
session.entries[0]["cwd"] = resolved
|
|
537
|
+
session.cwd = resolved
|
|
538
|
+
self.save(session)
|
|
539
|
+
return True
|
|
540
|
+
|
|
541
|
+
def get(self, session_id: str) -> Session | None:
|
|
542
|
+
"""按会话 id 或文件名 stem 前缀查找(docs/cli.md: `--session <path|id>`)。
|
|
543
|
+
|
|
544
|
+
id = 文件名 `<ts>_<id>.jsonl` 里的 `<id>`;同时接受完整 stem。
|
|
545
|
+
旧实现只比对 header id,而 `latest()` 传的是 stem(含 `<ts>_` 前缀),
|
|
546
|
+
startswith 永不成立 → `qi -c` 永远找不到会话、每次都新建。
|
|
547
|
+
"""
|
|
548
|
+
if not session_id:
|
|
549
|
+
return None
|
|
550
|
+
for p in self._files():
|
|
551
|
+
entries = self._read(p)
|
|
552
|
+
if not entries:
|
|
553
|
+
continue
|
|
554
|
+
hid = str(entries[0].get("id", ""))
|
|
555
|
+
if hid.startswith(session_id) or p.stem.startswith(session_id):
|
|
556
|
+
return self._from_entries(p, entries)
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
def open_file(self, path: Path | str) -> Session | None:
|
|
560
|
+
"""按**文件路径**打开一个会话(pi 的 `--session <path|id>` 里那个 path 形态)。
|
|
561
|
+
|
|
562
|
+
`get()` 只按 header id / 文件名 stem 前缀匹配 —— 一个绝对路径永远不命中,
|
|
563
|
+
而帮助文字里写的是 `path|id`。读不到 / 空文件返回 None(调用方自己报)。
|
|
564
|
+
"""
|
|
565
|
+
p = Path(path).expanduser()
|
|
566
|
+
if not p.is_file():
|
|
567
|
+
return None
|
|
568
|
+
entries = self._read(p)
|
|
569
|
+
if not entries:
|
|
570
|
+
return None
|
|
571
|
+
return self._from_entries(p, entries)
|
|
572
|
+
|
|
573
|
+
def latest(self) -> Session | None:
|
|
574
|
+
files = self._files()
|
|
575
|
+
if not files:
|
|
576
|
+
return None
|
|
577
|
+
return self.get(files[0].stem)
|
|
578
|
+
|
|
579
|
+
def list(self) -> list[Session]:
|
|
580
|
+
out = []
|
|
581
|
+
for p in self._files():
|
|
582
|
+
entries = self._read(p)
|
|
583
|
+
if not entries:
|
|
584
|
+
continue
|
|
585
|
+
out.append(self._from_entries(p, entries))
|
|
586
|
+
return out
|
|
587
|
+
|
|
588
|
+
def delete(self, session_id: str) -> bool:
|
|
589
|
+
s = self.get(session_id)
|
|
590
|
+
if not s:
|
|
591
|
+
return False
|
|
592
|
+
s.path.unlink(missing_ok=True)
|
|
593
|
+
return True
|
|
594
|
+
|
|
595
|
+
def append(self, session: Session, entry: dict) -> None:
|
|
596
|
+
"""在当前节点(`position`/`leaf`)下追加一个子节点。"""
|
|
597
|
+
entry.setdefault("ts", _now())
|
|
598
|
+
entry.setdefault("id", _new_id())
|
|
599
|
+
entry.setdefault("parentId", session.current)
|
|
600
|
+
session.entries.append(entry)
|
|
601
|
+
session.position = str(entry["id"])
|
|
602
|
+
self._persist(session, entry)
|
|
603
|
+
|
|
604
|
+
def _persist(self, session: Session, entry: dict) -> None:
|
|
605
|
+
"""把刚 append 的 entry 落到磁盘(pi 的 `_persist` 同形)。
|
|
606
|
+
|
|
607
|
+
三条分支:
|
|
608
|
+
- **内存会话**(`--no-session`):不碰磁盘;
|
|
609
|
+
- **还没落过盘**(`unflushed`,裸 `qi` 建的新会话):也先不落 —— 文件推迟到
|
|
610
|
+
第一条 **assistant** 回答。这样"问了句就被打断"同样不留文件;
|
|
611
|
+
- 其余:追加一行(补过链的老会话整文件重写)。
|
|
612
|
+
"""
|
|
613
|
+
if session.ephemeral:
|
|
614
|
+
return
|
|
615
|
+
if session.unflushed:
|
|
616
|
+
# 有 assistant 回答了吗?有就现在把**全部** entries 一次性写出(含 header)。
|
|
617
|
+
# (另一个出口是 `set_title`:用户显式命名——见那里的注释。)
|
|
618
|
+
if not _has_assistant(session):
|
|
619
|
+
return
|
|
620
|
+
self.flush(session)
|
|
621
|
+
return
|
|
622
|
+
if session.migrated:
|
|
623
|
+
# 补链过的老 entry 只在内存里,必须整文件重写才能让新节点的 parent 可回溯
|
|
624
|
+
session.migrated = False
|
|
625
|
+
self.save(session)
|
|
626
|
+
return
|
|
627
|
+
with session.path.open("a", encoding="utf-8") as fh:
|
|
628
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
629
|
+
|
|
630
|
+
def set_context_setting(self, session: Session, kind: str, payload: dict) -> bool:
|
|
631
|
+
"""落一条设置类 entry(model / 思考级别)。返回是否真写了。
|
|
632
|
+
|
|
633
|
+
**同值不重写**:级别已经在文件末尾记过就不必再记一条 —— 否则每次启动都会给
|
|
634
|
+
每个会话加一行(pi 也只在 `isChanging` 时才写)。判据只看**当前分支的最后一条**
|
|
635
|
+
同类 entry:分支回退后再换回来,本来就是一次真实的切换。
|
|
636
|
+
|
|
637
|
+
`kind` 只认 `MODEL_CHANGE` / `THINKING_LEVEL_CHANGE`;其它值当写错,不落盘
|
|
638
|
+
(静默写一个客户端不认识的类型,比报错更难查)。
|
|
639
|
+
"""
|
|
640
|
+
if kind not in (MODEL_CHANGE, THINKING_LEVEL_CHANGE):
|
|
641
|
+
return False
|
|
642
|
+
if kind == MODEL_CHANGE:
|
|
643
|
+
entry: dict = {"type": MODEL_CHANGE,
|
|
644
|
+
"provider": str(payload.get("provider") or ""),
|
|
645
|
+
"model_id": str(payload.get("model_id") or "")}
|
|
646
|
+
previous = next((e for e in reversed(session.branch())
|
|
647
|
+
if e.get("type") == MODEL_CHANGE), None)
|
|
648
|
+
unchanged = (previous is not None
|
|
649
|
+
and previous.get("provider") == entry["provider"]
|
|
650
|
+
and previous.get("model_id") == entry["model_id"])
|
|
651
|
+
else:
|
|
652
|
+
entry = {"type": THINKING_LEVEL_CHANGE,
|
|
653
|
+
"thinking_level": str(payload.get("thinking_level") or "")}
|
|
654
|
+
previous = next((e for e in reversed(session.branch())
|
|
655
|
+
if e.get("type") == THINKING_LEVEL_CHANGE), None)
|
|
656
|
+
unchanged = (previous is not None
|
|
657
|
+
and previous.get("thinking_level") == entry["thinking_level"])
|
|
658
|
+
if unchanged:
|
|
659
|
+
return False
|
|
660
|
+
self.append(session, entry)
|
|
661
|
+
return True
|
|
662
|
+
|
|
663
|
+
def save(self, session: Session) -> None:
|
|
664
|
+
"""整文件重写(改名/标题/迁移/分叉等)。内存会话跳过(没有文件可写)。"""
|
|
665
|
+
if session.ephemeral:
|
|
666
|
+
session.migrated = False
|
|
667
|
+
return
|
|
668
|
+
if session.unflushed:
|
|
669
|
+
# 还没落过盘的会话:整文件重写就等于**把它提前建出来了** —— 那是 `flush` 的活。
|
|
670
|
+
# `set_title` 自己要落盘也走 `flush()`(命名是显式意图);所以这里的其余来路
|
|
671
|
+
# 本来也不该写出文件,留着它当保险。
|
|
672
|
+
return
|
|
673
|
+
with session.path.open("w", encoding="utf-8") as fh:
|
|
674
|
+
for e in session.entries:
|
|
675
|
+
fh.write(json.dumps(e, ensure_ascii=False) + "\n")
|
|
676
|
+
session.migrated = False
|
|
677
|
+
|
|
678
|
+
# ── 树操作(pi 的 /tree、/fork、/clone)──
|
|
679
|
+
def set_position(self, session: Session, entry_id: str | None) -> bool:
|
|
680
|
+
"""把「当前节点」移到某个 entry(下次提问会挂在它下面)。"""
|
|
681
|
+
if entry_id is None:
|
|
682
|
+
session.position = None
|
|
683
|
+
return True
|
|
684
|
+
known = {str(e.get("id")) for e in session.tree_entries}
|
|
685
|
+
if entry_id not in known:
|
|
686
|
+
return False
|
|
687
|
+
session.position = entry_id
|
|
688
|
+
return True
|
|
689
|
+
|
|
690
|
+
def set_title(self, session: Session, title: str) -> None:
|
|
691
|
+
"""改标题:**内存 + header entry + 落盘**三处一起改。
|
|
692
|
+
|
|
693
|
+
标题在会话文件里有**两份**:`session.title`(运行时读的)与 header entry 的
|
|
694
|
+
`title`(磁盘上那份真相)。只改一份就会出现"列表里是新名、重开又变回旧的" ——
|
|
695
|
+
所以收进一个方法,别在两处各写一遍(改名端点与自动命名都走它)。
|
|
696
|
+
|
|
697
|
+
**预留中(`unflushed`)的会话会因此立刻落盘**(`flush()`):命名是一个显式动作,
|
|
698
|
+
一个名字总得有文件可落 —— 与 `qi -n <名字>` / `create()` 同一个取舍。
|
|
699
|
+
以前这里回落到 `save()` 的"跳过"分支(文件推迟到第一条回答),于是
|
|
700
|
+
`/name` 之后 `/new`(或退出)会连**会话带名字**一起消失,而界面刚说过
|
|
701
|
+
"会话名已设为 X" —— 用户的原话就是"我 name 会话了之后,新建会话然后 `/resume`
|
|
702
|
+
之前的会话,名称没变"。"""
|
|
703
|
+
header = (session.entries[0]
|
|
704
|
+
if session.entries and session.entries[0].get("type") == "session"
|
|
705
|
+
else None)
|
|
706
|
+
previous = (session.title, header.get("title") if header else None)
|
|
707
|
+
session.title = title
|
|
708
|
+
if header is not None:
|
|
709
|
+
header["title"] = title
|
|
710
|
+
if session.ephemeral:
|
|
711
|
+
return # 内存会话:内存与 header 都改了,没有文件要写
|
|
712
|
+
try:
|
|
713
|
+
if session.unflushed:
|
|
714
|
+
self.flush(session)
|
|
715
|
+
else:
|
|
716
|
+
self.save(session)
|
|
717
|
+
except OSError:
|
|
718
|
+
# 写盘失败就把内存**回滚**:否则会出现"列表里是新名、磁盘上还是旧名",
|
|
719
|
+
# 刷新一次标题就变回去 —— 那是最难查的一类不一致。
|
|
720
|
+
session.title = previous[0]
|
|
721
|
+
if header is not None:
|
|
722
|
+
header["title"] = previous[1]
|
|
723
|
+
raise
|
|
724
|
+
|
|
725
|
+
def fork_at(self, session: Session, entry_id: str | None,
|
|
726
|
+
title: str | None = None) -> Session:
|
|
727
|
+
"""把 `branch(entry_id)` 复制成一个**新会话文件**(pi 的 fork/clone 都是新文件)。
|
|
728
|
+
|
|
729
|
+
`entry_id=None` = 空历史(从第一条消息之前开始)。复制过去的 entry 保持
|
|
730
|
+
原 id/parentId:链在新文件里自洽,未来再引用时不至于指向另一个文件。
|
|
731
|
+
"""
|
|
732
|
+
branch = session.branch(entry_id) if entry_id else []
|
|
733
|
+
new = self.create(title if title is not None else session.title, cwd=session.cwd,
|
|
734
|
+
parent_session=session.path)
|
|
735
|
+
for entry in branch:
|
|
736
|
+
new.entries.append(copy.deepcopy(entry))
|
|
737
|
+
self.save(new)
|
|
738
|
+
new.position = new.leaf
|
|
739
|
+
return new
|