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.
Files changed (62) hide show
  1. qi_agent/__init__.py +18 -0
  2. qi_agent/abort.py +40 -0
  3. qi_agent/auth.py +216 -0
  4. qi_agent/cli.py +2178 -0
  5. qi_agent/compaction.py +475 -0
  6. qi_agent/config.py +409 -0
  7. qi_agent/docs/cli.md +188 -0
  8. qi_agent/docs/compaction.md +148 -0
  9. qi_agent/docs/configuration.md +89 -0
  10. qi_agent/docs/containerization.md +80 -0
  11. qi_agent/docs/docs.json +1 -0
  12. qi_agent/docs/environment-variables.md +47 -0
  13. qi_agent/docs/extensions.md +382 -0
  14. qi_agent/docs/how-qi-works.md +93 -0
  15. qi_agent/docs/index.md +61 -0
  16. qi_agent/docs/json.md +112 -0
  17. qi_agent/docs/keybindings.md +83 -0
  18. qi_agent/docs/models.md +138 -0
  19. qi_agent/docs/packages.md +155 -0
  20. qi_agent/docs/providers.md +288 -0
  21. qi_agent/docs/quickstart.md +109 -0
  22. qi_agent/docs/security.md +69 -0
  23. qi_agent/docs/session-format.md +299 -0
  24. qi_agent/docs/sessions.md +127 -0
  25. qi_agent/docs/settings.md +138 -0
  26. qi_agent/docs/skills.md +124 -0
  27. qi_agent/docs/slash-commands.md +55 -0
  28. qi_agent/docs/themes.md +78 -0
  29. qi_agent/docs/tui.md +109 -0
  30. qi_agent/docs/usage.md +112 -0
  31. qi_agent/export_html.py +95 -0
  32. qi_agent/extensions.py +2267 -0
  33. qi_agent/llm.py +609 -0
  34. qi_agent/loader.py +299 -0
  35. qi_agent/model_catalog.py +108 -0
  36. qi_agent/models.py +103 -0
  37. qi_agent/packages.py +430 -0
  38. qi_agent/paths.py +242 -0
  39. qi_agent/pep723.py +74 -0
  40. qi_agent/presets.py +293 -0
  41. qi_agent/prompt.py +150 -0
  42. qi_agent/registry.py +485 -0
  43. qi_agent/runner.py +584 -0
  44. qi_agent/runtime.py +1940 -0
  45. qi_agent/session.py +739 -0
  46. qi_agent/settings.py +501 -0
  47. qi_agent/share.py +111 -0
  48. qi_agent/system_prompt.py +278 -0
  49. qi_agent/theme.py +389 -0
  50. qi_agent/themes/dark.json +90 -0
  51. qi_agent/themes/light.json +89 -0
  52. qi_agent/titling.py +90 -0
  53. qi_agent/tools/__init__.py +349 -0
  54. qi_agent/tools/shell.py +276 -0
  55. qi_agent/trust.py +106 -0
  56. qi_agent/tui.py +6224 -0
  57. qi_agent/workspaces.py +116 -0
  58. qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
  59. qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
  60. qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  61. qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  62. 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