my-pi-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +318 -0
  2. package/package.json +45 -0
  3. package/pyproject.toml +50 -0
  4. package/src/my_agent_core/__init__.py +123 -0
  5. package/src/my_agent_core/agent.py +441 -0
  6. package/src/my_agent_core/background.py +121 -0
  7. package/src/my_agent_core/context.py +505 -0
  8. package/src/my_agent_core/events.py +153 -0
  9. package/src/my_agent_core/extensions/__init__.py +9 -0
  10. package/src/my_agent_core/extensions/core.py +197 -0
  11. package/src/my_agent_core/hooks.py +130 -0
  12. package/src/my_agent_core/loop.py +709 -0
  13. package/src/my_agent_core/main.py +134 -0
  14. package/src/my_agent_core/memory.py +241 -0
  15. package/src/my_agent_core/message_queue.py +110 -0
  16. package/src/my_agent_core/plugins.py +212 -0
  17. package/src/my_agent_core/registry.py +186 -0
  18. package/src/my_agent_core/session/__init__.py +79 -0
  19. package/src/my_agent_core/session/entries.py +197 -0
  20. package/src/my_agent_core/session/jsonl.py +60 -0
  21. package/src/my_agent_core/session/memory.py +137 -0
  22. package/src/my_agent_core/session/session.py +400 -0
  23. package/src/my_agent_core/session/storage.py +245 -0
  24. package/src/my_agent_core/session/store.py +131 -0
  25. package/src/my_agent_core/session/tree.py +86 -0
  26. package/src/my_agent_core/skills.py +149 -0
  27. package/src/my_agent_core/subagent_tasks.py +170 -0
  28. package/src/my_agent_core/subagents.py +148 -0
  29. package/src/my_agent_core/task_store.py +248 -0
  30. package/src/my_agent_core/tool_history.py +189 -0
  31. package/src/my_agent_core/tools/__init__.py +5 -0
  32. package/src/my_agent_core/tools/builtin/__init__.py +5 -0
  33. package/src/my_agent_core/tools/builtin/task.py +30 -0
  34. package/src/my_agent_core/tools/builtin/task_tools.py +215 -0
  35. package/src/my_agent_core/tools/core.py +239 -0
  36. package/src/my_agent_llm/__init__.py +45 -0
  37. package/src/my_agent_llm/auth/__init__.py +46 -0
  38. package/src/my_agent_llm/auth/antigravity.py +209 -0
  39. package/src/my_agent_llm/auth/manager.py +259 -0
  40. package/src/my_agent_llm/auth/quota.py +56 -0
  41. package/src/my_agent_llm/auth/schema.py +94 -0
  42. package/src/my_agent_llm/client.py +116 -0
  43. package/src/my_agent_llm/config.py +17 -0
  44. package/src/my_agent_llm/events.py +84 -0
  45. package/src/my_agent_llm/models.py +195 -0
  46. package/src/my_agent_llm/providers/__init__.py +4 -0
  47. package/src/my_agent_llm/providers/_base.py +94 -0
  48. package/src/my_agent_llm/providers/anthropic.py +298 -0
  49. package/src/my_agent_llm/providers/antigravity.py +480 -0
  50. package/src/my_agent_llm/providers/deepseek.py +196 -0
  51. package/src/my_agent_llm/providers/openai.py +364 -0
  52. package/src/my_agent_llm/providers/registry.py +16 -0
  53. package/src/my_agent_llm/stream.py +218 -0
  54. package/src/my_coding_agent/__init__.py +66 -0
  55. package/src/my_coding_agent/agent.py +208 -0
  56. package/src/my_coding_agent/cli.py +78 -0
  57. package/src/my_coding_agent/file_reference.py +80 -0
  58. package/src/my_coding_agent/macro.py +408 -0
  59. package/src/my_coding_agent/mcp.py +243 -0
  60. package/src/my_coding_agent/mutation_queue.py +37 -0
  61. package/src/my_coding_agent/paths.py +119 -0
  62. package/src/my_coding_agent/permissions.py +84 -0
  63. package/src/my_coding_agent/prompt.py +54 -0
  64. package/src/my_coding_agent/rpc_server.py +2817 -0
  65. package/src/my_coding_agent/settings.py +126 -0
  66. package/src/my_coding_agent/tools/__init__.py +55 -0
  67. package/src/my_coding_agent/tools/base.py +58 -0
  68. package/src/my_coding_agent/tools/bash.py +206 -0
  69. package/src/my_coding_agent/tools/edit.py +226 -0
  70. package/src/my_coding_agent/tools/find.py +118 -0
  71. package/src/my_coding_agent/tools/grep.py +177 -0
  72. package/src/my_coding_agent/tools/ls.py +112 -0
  73. package/src/my_coding_agent/tools/read.py +113 -0
  74. package/src/my_coding_agent/tools/write.py +72 -0
  75. package/tui/README.md +27 -0
  76. package/tui/bin/my-agent.js +98 -0
  77. package/tui/dist/app.d.ts +41 -0
  78. package/tui/dist/app.js +110 -0
  79. package/tui/dist/bridge/event-translator.d.ts +92 -0
  80. package/tui/dist/bridge/event-translator.js +216 -0
  81. package/tui/dist/bridge/kernel-bridge.d.ts +48 -0
  82. package/tui/dist/bridge/kernel-bridge.js +132 -0
  83. package/tui/dist/client.d.ts +63 -0
  84. package/tui/dist/client.js +239 -0
  85. package/tui/dist/components/assistant-message.d.ts +19 -0
  86. package/tui/dist/components/assistant-message.js +90 -0
  87. package/tui/dist/components/compaction-summary-message.d.ts +19 -0
  88. package/tui/dist/components/compaction-summary-message.js +46 -0
  89. package/tui/dist/components/custom-editor.d.ts +18 -0
  90. package/tui/dist/components/custom-editor.js +56 -0
  91. package/tui/dist/components/dynamic-border.d.ts +9 -0
  92. package/tui/dist/components/dynamic-border.js +14 -0
  93. package/tui/dist/components/footer.d.ts +39 -0
  94. package/tui/dist/components/footer.js +199 -0
  95. package/tui/dist/components/header.d.ts +4 -0
  96. package/tui/dist/components/header.js +21 -0
  97. package/tui/dist/components/keys.d.ts +5 -0
  98. package/tui/dist/components/keys.js +12 -0
  99. package/tui/dist/components/login-selector.d.ts +26 -0
  100. package/tui/dist/components/login-selector.js +181 -0
  101. package/tui/dist/components/logout-selector.d.ts +19 -0
  102. package/tui/dist/components/logout-selector.js +88 -0
  103. package/tui/dist/components/model-selector.d.ts +40 -0
  104. package/tui/dist/components/model-selector.js +268 -0
  105. package/tui/dist/components/session-selector.d.ts +54 -0
  106. package/tui/dist/components/session-selector.js +393 -0
  107. package/tui/dist/components/settings-selector.d.ts +24 -0
  108. package/tui/dist/components/settings-selector.js +146 -0
  109. package/tui/dist/components/status-indicator.d.ts +25 -0
  110. package/tui/dist/components/status-indicator.js +60 -0
  111. package/tui/dist/components/theme-selector.d.ts +14 -0
  112. package/tui/dist/components/theme-selector.js +77 -0
  113. package/tui/dist/components/thinking-selector.d.ts +21 -0
  114. package/tui/dist/components/thinking-selector.js +128 -0
  115. package/tui/dist/components/tool-execution.d.ts +31 -0
  116. package/tui/dist/components/tool-execution.js +206 -0
  117. package/tui/dist/components/tree-selector.d.ts +40 -0
  118. package/tui/dist/components/tree-selector.js +173 -0
  119. package/tui/dist/components/user-message-selector.d.ts +21 -0
  120. package/tui/dist/components/user-message-selector.js +103 -0
  121. package/tui/dist/components/user-message.d.ts +5 -0
  122. package/tui/dist/components/user-message.js +15 -0
  123. package/tui/dist/index.d.ts +11 -0
  124. package/tui/dist/index.js +11 -0
  125. package/tui/dist/interactive/chat-viewport.d.ts +19 -0
  126. package/tui/dist/interactive/chat-viewport.js +41 -0
  127. package/tui/dist/interactive/components.d.ts +1 -0
  128. package/tui/dist/interactive/components.js +1 -0
  129. package/tui/dist/interactive/interactive-mode.d.ts +89 -0
  130. package/tui/dist/interactive/interactive-mode.js +1625 -0
  131. package/tui/dist/interactive/theme.d.ts +1 -0
  132. package/tui/dist/interactive/theme.js +1 -0
  133. package/tui/dist/interactive/tui-renderer.d.ts +8 -0
  134. package/tui/dist/interactive/tui-renderer.js +10 -0
  135. package/tui/dist/protocol.d.ts +78 -0
  136. package/tui/dist/protocol.js +1 -0
  137. package/tui/dist/theme/dark.json +54 -0
  138. package/tui/dist/theme/light.json +71 -0
  139. package/tui/dist/theme/theme.d.ts +20 -0
  140. package/tui/dist/theme/theme.js +86 -0
  141. package/tui/package.json +25 -0
@@ -0,0 +1,400 @@
1
+ # pyright: reportUnusedCallResult=false, reportAttributeAccessIssue=false
2
+ """Session 管理:树结构会话 + JSONL 文件持久化(纯正 Tau 只追加多态体系,配 my-agent-llm Message)。
3
+
4
+ 一个会话 = 一棵树(entry 带 id/parent_id)+ current_id 指针。rewind = 移动指针、
5
+ 旧分支保留。文件格式为纯粹的 SessionEntry 流:第 1 行是 SessionInfoEntry,后续每行为 MessageEntry/CompactionEntry 等。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tempfile
12
+ from collections.abc import Iterable
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any, cast
16
+ from uuid import uuid4
17
+
18
+ from my_agent_llm import Message
19
+
20
+ from .entries import (
21
+ CompactionEntry,
22
+ MessageEntry,
23
+ SessionEntry,
24
+ SessionHeaderEntry,
25
+ SessionInfoEntry,
26
+ )
27
+ from .jsonl import entry_from_json_line, entry_to_json_line
28
+ from .tree import path_to_entry
29
+
30
+
31
+ class SessionTree:
32
+ """树:entries(id→entry)+ current_id 指针 + root_id。"""
33
+
34
+ def __init__(self) -> None:
35
+ self.entries: dict[str, SessionEntry] = {}
36
+ self.current_id: str | None = None
37
+ self.root_id: str | None = None
38
+
39
+ def add_entry(self, role: str, content: str, parent_id: str | None = None, **metadata: Any) -> MessageEntry:
40
+ """追加到 current 下(或指定 parent)。首个 entry 成为根。"""
41
+ if parent_id is None:
42
+ parent_id = self.current_id
43
+ entry = MessageEntry(
44
+ id=uuid4().hex[:8],
45
+ parent_id=parent_id,
46
+ message=Message(
47
+ role=cast(Any, role),
48
+ content=content,
49
+ metadata=metadata or None,
50
+ ),
51
+ )
52
+ self.entries[entry.id] = entry
53
+ self.current_id = entry.id
54
+ if self.root_id is None:
55
+ self.root_id = entry.id
56
+ return entry
57
+
58
+ def get_current_path(self) -> list[SessionEntry]:
59
+ """根 → current 的路径(Agent 上下文用)。空树返回 []。"""
60
+ if self.current_id is None:
61
+ return []
62
+ return self.get_path_to_entry(self.current_id)
63
+
64
+ def get_path_to_entry(self, entry_id: str) -> list[SessionEntry]:
65
+ """根 → entry_id 的路径(fork 用)。不存在抛 ValueError。"""
66
+ if entry_id not in self.entries:
67
+ raise ValueError(f"Entry {entry_id} not found")
68
+ try:
69
+ return path_to_entry(self.entries, entry_id)
70
+ except Exception as exc:
71
+ raise ValueError(str(exc)) from exc
72
+
73
+ def rewind(self, entry_id: str) -> None:
74
+ """移动 current 指针到已有节点;新 entry 将在其下追加(长新枝)。不存在抛 ValueError。"""
75
+ if entry_id not in self.entries:
76
+ raise ValueError(f"Entry {entry_id} not found")
77
+ self.current_id = entry_id
78
+
79
+ @classmethod
80
+ def from_jsonl_iter(cls, lines: Iterable[str]) -> SessionTree:
81
+ """从迭代器恢复树。中途某行损坏抛 ValueError(带行号),尾行由 load 处理。"""
82
+ tree = cls()
83
+ for idx, line in enumerate(lines, start=2): # start=2 因为 line 1 是 session_info
84
+ line = line.strip()
85
+ if not line:
86
+ continue
87
+ try:
88
+ entry = entry_from_json_line(line)
89
+ except Exception as exc:
90
+ raise ValueError(f"Corrupted entry at line {idx}: {exc}") from exc
91
+ tree.entries[entry.id] = entry
92
+ tree.current_id = entry.id
93
+ if tree.root_id is None:
94
+ tree.root_id = entry.id
95
+ return tree
96
+
97
+
98
+ class Session:
99
+ """一个会话 = 树 + 路径 + JSONL 文件(纯正 Tau 多态 SessionEntry 流)。
100
+
101
+ 文件第 1 行是 SessionInfoEntry,后续每行是一个多态 SessionEntry。
102
+ """
103
+
104
+ def __init__(self, *, path: Path, cwd: str | None = None, metadata: dict | None = None):
105
+ """新建会话(纯对话,不含 system)。不立即写文件。"""
106
+ self.path = Path(path)
107
+ self.id = f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid4().hex[:8]}"
108
+ self.created_at = datetime.now().isoformat()
109
+ self.cwd = cwd or str(Path.cwd())
110
+ self.tree = SessionTree()
111
+ self.compaction_floor: str | None = None
112
+ self.metadata = metadata or {} # 额外元数据(子代理:agent_type/parent_session_id),进 SessionInfoEntry
113
+ self._storage: Any | None = None
114
+
115
+ @classmethod
116
+ def load(cls, path: Path) -> Session:
117
+ """从 JSONL 文件恢复整棵树。首行非 SessionInfoEntry → ValueError;
118
+ 非尾行损坏 → ValueError(带行号);尾行撕裂 → 丢弃该行(宽容兜底)。"""
119
+ path = Path(path).resolve()
120
+ try:
121
+ with open(path, encoding="utf-8") as f:
122
+ lines = list(f)
123
+ except OSError as exc:
124
+ raise ValueError(f"Failed to read session file {path}: {exc}") from exc
125
+ if not lines:
126
+ raise ValueError(f"Session file {path} is empty")
127
+
128
+ try:
129
+ first_entry = entry_from_json_line(lines[0])
130
+ except Exception as exc:
131
+ raise ValueError(f"Session file {path}: invalid header/info: {exc}") from exc
132
+
133
+ if not isinstance(first_entry, (SessionInfoEntry, SessionHeaderEntry)):
134
+ raise ValueError(f"Session file {path}: line 1 must be a valid SessionHeaderEntry or SessionInfoEntry")
135
+
136
+ tree_lines = lines[1:]
137
+ if tree_lines and tree_lines[-1].strip():
138
+ try:
139
+ entry_from_json_line(tree_lines[-1])
140
+ except Exception:
141
+ tree_lines = tree_lines[:-1]
142
+
143
+ tree = SessionTree.from_jsonl_iter(tree_lines)
144
+ meta = dict(getattr(first_entry, "metadata", {}) or {})
145
+ if getattr(first_entry, "name", None):
146
+ meta["name"] = first_entry.name
147
+ if getattr(first_entry, "title", None):
148
+ meta["title"] = first_entry.title
149
+ if getattr(first_entry, "parent_session", None):
150
+ meta["parent_session"] = first_entry.parent_session
151
+ meta["parent_session_path"] = first_entry.parent_session
152
+ for e in tree.entries.values():
153
+ if isinstance(e, SessionInfoEntry):
154
+ sub_name = getattr(e, "name", None) or getattr(e, "title", None)
155
+ if sub_name:
156
+ meta["name"] = sub_name
157
+ meta["title"] = sub_name
158
+
159
+ # 优先恢复 current_id / root_id
160
+ cur = meta.get("current_id")
161
+ if isinstance(cur, str) and cur in tree.entries:
162
+ tree.current_id = cur
163
+ root = meta.get("root_id")
164
+ if isinstance(root, str) and root in tree.entries:
165
+ tree.root_id = root
166
+
167
+ session = cls(path=path, cwd=first_entry.cwd)
168
+ session.id = first_entry.id
169
+ session.created_at = (
170
+ datetime.fromtimestamp(first_entry.created_at).isoformat()
171
+ if first_entry.created_at
172
+ else datetime.now().isoformat()
173
+ )
174
+ session.tree = tree
175
+ session.compaction_floor = meta.get("compaction_floor")
176
+ session.metadata = {k: v for k, v in meta.items() if k not in ("current_id", "root_id", "compaction_floor")}
177
+ return session
178
+
179
+ def add_message(self, role: str, content: str, parent_id: str | None = None, **metadata: Any) -> MessageEntry:
180
+ """加到树 + save()。"""
181
+ entry = self.tree.add_entry(role, content, parent_id, **metadata)
182
+ self.save()
183
+ return entry
184
+
185
+ def get_current_path_messages(self) -> list[Message]:
186
+ """当前路径 → list[Message](Agent 上下文用)。"""
187
+ return [
188
+ e.message
189
+ if isinstance(e, MessageEntry)
190
+ else Message(
191
+ role=cast(Any, getattr(e, "role", "system")),
192
+ content=getattr(e, "content", ""),
193
+ metadata=getattr(e, "metadata", None),
194
+ )
195
+ for e in self.tree.get_current_path()
196
+ ]
197
+
198
+ def add_summary_cache(
199
+ self,
200
+ summary: str,
201
+ *,
202
+ covered_count: int,
203
+ retained_tail: list[dict],
204
+ tokens_before: int,
205
+ summary_usage: dict | None = None,
206
+ summary_model: str | None = None,
207
+ ) -> None:
208
+ """写一条 CompactionEntry(不动 current_id)+ 更新 compaction_floor。"""
209
+ metadata: dict[str, Any] = {
210
+ "retained_tail": retained_tail,
211
+ "covered_count": covered_count,
212
+ "tokens_before": tokens_before,
213
+ }
214
+ if summary_usage is not None:
215
+ metadata["summary_usage"] = summary_usage
216
+ if summary_model is not None:
217
+ metadata["summary_model"] = summary_model
218
+ entry = CompactionEntry(
219
+ parent_id=self.tree.current_id,
220
+ summary=summary,
221
+ replaces_entry_ids=[],
222
+ metadata=metadata,
223
+ )
224
+ self.tree.entries[entry.id] = entry
225
+ self.compaction_floor = self.tree.current_id
226
+ self.save()
227
+
228
+ def get_full_history_messages(self) -> list[Message]:
229
+ """完整对话历史(排除 CompactionEntry 节点)——宿主看历史、Agent 恢复上下文用。"""
230
+ return [
231
+ e.message
232
+ if isinstance(e, MessageEntry)
233
+ else Message(
234
+ role=cast(Any, getattr(e, "role", "system")),
235
+ content=getattr(e, "content", ""),
236
+ metadata=getattr(e, "metadata", None),
237
+ )
238
+ for e in self.tree.get_current_path()
239
+ if getattr(e, "type", "message") == "message"
240
+ ]
241
+
242
+ def get_latest_compaction_cache(self) -> dict | None:
243
+ """最新一条 CompactionEntry → {summary, covered_count, retained_tail};无则 None。"""
244
+ cache_entries = [
245
+ e
246
+ for e in self.tree.entries.values()
247
+ if isinstance(e, CompactionEntry) or getattr(e, "type", None) == "compaction"
248
+ ]
249
+ if not cache_entries:
250
+ return None
251
+ latest = max(cache_entries, key=lambda e: len(self.tree.get_path_to_entry(e.id)))
252
+ md = getattr(latest, "metadata", {}) or {}
253
+ try:
254
+ covered_count = int(md.get("covered_count", 0))
255
+ except (ValueError, TypeError):
256
+ covered_count = 0
257
+ return {
258
+ "summary": getattr(latest, "summary", getattr(latest, "content", "")),
259
+ "covered_count": covered_count,
260
+ "retained_tail": list(md.get("retained_tail", [])),
261
+ }
262
+
263
+ def rewind(self, entry_id: str) -> None:
264
+ """移动 current 指针(旧分支保留)+ save。压缩后只能回 floor(含)之后,否则 ValueError。"""
265
+ if self.compaction_floor is not None and not self._after_floor(entry_id):
266
+ raise ValueError(
267
+ f"Cannot rewind past compaction floor {self.compaction_floor}: "
268
+ f"entry {entry_id} is prior to compacted history"
269
+ )
270
+ self.tree.rewind(entry_id)
271
+ self.save()
272
+
273
+ def _after_floor(self, entry_id: str) -> bool:
274
+ """entry_id 是否在 compaction_floor(含)之后长出的节点:等于 floor,或沿 parent 链能走到 floor。"""
275
+ if entry_id == self.compaction_floor:
276
+ return True
277
+ cur = self.tree.entries.get(entry_id)
278
+ while cur is not None and cur.parent_id is not None:
279
+ if cur.parent_id == self.compaction_floor:
280
+ return True
281
+ cur = self.tree.entries.get(cur.parent_id)
282
+ return False
283
+
284
+ def save(self) -> None:
285
+ """原子落盘:临时文件 + fsync + os.replace。写入首条 SessionInfoEntry 与所有多态 SessionEntry。"""
286
+ self.path.parent.mkdir(parents=True, exist_ok=True)
287
+ meta = dict(self.metadata)
288
+ meta["current_id"] = self.tree.current_id
289
+ meta["root_id"] = self.tree.root_id
290
+ meta["compaction_floor"] = self.compaction_floor
291
+
292
+ created_at_val: float | None = None
293
+ try:
294
+ if isinstance(self.created_at, str):
295
+ created_at_val = datetime.fromisoformat(self.created_at).timestamp()
296
+ elif isinstance(self.created_at, (int, float)):
297
+ created_at_val = float(self.created_at)
298
+ except Exception:
299
+ created_at_val = None
300
+
301
+ name_val = meta.get("name") or meta.get("title")
302
+ info_entry = SessionInfoEntry(
303
+ id=self.id,
304
+ cwd=self.cwd,
305
+ title=name_val,
306
+ name=name_val,
307
+ created_at=created_at_val,
308
+ metadata=meta,
309
+ )
310
+
311
+ temp_path: Path | None = None
312
+ try:
313
+ with tempfile.NamedTemporaryFile(
314
+ mode="w",
315
+ encoding="utf-8",
316
+ dir=self.path.parent,
317
+ prefix=f".{self.path.name}.",
318
+ suffix=".tmp",
319
+ delete=False,
320
+ ) as f:
321
+ temp_path = Path(f.name)
322
+ f.write(entry_to_json_line(info_entry) + "\n")
323
+ for e in self.tree.entries.values():
324
+ f.write(entry_to_json_line(e) + "\n")
325
+ f.flush()
326
+ os.fsync(f.fileno())
327
+ os.replace(temp_path, self.path)
328
+ except Exception:
329
+ if temp_path is not None:
330
+ temp_path.unlink(missing_ok=True)
331
+ raise
332
+
333
+ def append_entry(self, entry: SessionEntry) -> None:
334
+ """追加一条任意多态 SessionEntry 并原子持久化。"""
335
+ if isinstance(entry, SessionInfoEntry):
336
+ name_val = getattr(entry, "name", None) or getattr(entry, "title", None)
337
+ if name_val:
338
+ self.metadata["name"] = name_val
339
+ self.metadata["title"] = name_val
340
+ if entry.cwd:
341
+ self.cwd = entry.cwd
342
+ self.tree.entries[entry.id] = entry
343
+ if self.tree.root_id is None:
344
+ self.tree.root_id = entry.id
345
+ self.tree.current_id = entry.id
346
+ self.save()
347
+
348
+ @property
349
+ def store(self):
350
+ """兼容层:暴露包含 append_entry 的 storage/store 接口。"""
351
+
352
+ class _SessionStoreProxy:
353
+ def __init__(self, session: Session):
354
+ self._session = session
355
+
356
+ def append_entry(self, entry: SessionEntry) -> None:
357
+ self._session.append_entry(entry)
358
+
359
+ return _SessionStoreProxy(self)
360
+
361
+ def reset(self) -> None:
362
+ """清空树 + 原子重写(纯对话,不含 system)。唯一破坏性操作。"""
363
+ self.tree = SessionTree()
364
+ self.compaction_floor = None
365
+ self.save()
366
+
367
+ def fork(self, entry_id: str, new_path: Path | None = None) -> Session:
368
+ """从某 entry 分叉为新会话:复制根到 entry 的路径为新会话(新 id/路径,独立演化)。"""
369
+ if entry_id not in self.tree.entries:
370
+ raise ValueError(f"Entry {entry_id} not found")
371
+ if new_path is None:
372
+ sid = f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid4().hex[:8]}"
373
+ new_path = self.path.parent / f"{sid}.jsonl"
374
+ new_session = Session(path=new_path, cwd=self.cwd, metadata=dict(self.metadata))
375
+ for entry in self.tree.get_path_to_entry(entry_id):
376
+ if isinstance(entry, MessageEntry):
377
+ new_session.add_message(entry.role, entry.content, **entry.metadata)
378
+ return new_session
379
+
380
+ @property
381
+ def storage(self) -> Any:
382
+ """底层只追加存储对象。"""
383
+ if self._storage is None:
384
+ from my_agent_core.session.storage import JsonlSessionStorage
385
+
386
+ self._storage = JsonlSessionStorage(self.path)
387
+ return self._storage
388
+
389
+ def get_state(self) -> Any:
390
+ """获取从根到当前 current_id 的 SessionState 状态快照。"""
391
+ from my_agent_core.session.memory import SessionState
392
+
393
+ return SessionState(
394
+ messages=tuple(self.get_current_path_messages()),
395
+ active_leaf_id=self.tree.current_id,
396
+ )
397
+
398
+ def get_history(self) -> list[Message]:
399
+ """获取当前路径的对话历史列表。"""
400
+ return self.get_current_path_messages()
@@ -0,0 +1,245 @@
1
+ # pyright: reportUnusedCallResult=false
2
+ """Locked, append-only session storage implementations and protocols.
3
+
4
+ 对齐 Tau (tau_agent.session.storage) 架构设计:
5
+ 集中放置 SessionStorage 协议、InMemorySessionStorage 以及 JsonlSessionStorage。
6
+ 文件锁底层调用以内聚辅助函数 _lock_file / _unlock_file 放置于文件末尾,
7
+ 彻底杜绝模块顶层的条件分支定义,具备工业级跨进程防踩踏与超时保护能力。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ import os
15
+ import sys
16
+ import time
17
+ from collections.abc import AsyncIterator, Sequence
18
+ from pathlib import Path
19
+ from typing import Protocol, runtime_checkable
20
+
21
+ from .entries import SessionEntry
22
+ from .jsonl import (
23
+ SessionJsonlError,
24
+ entry_from_json_line,
25
+ entry_to_json_line,
26
+ )
27
+
28
+
29
+ @runtime_checkable
30
+ class SessionStorage(Protocol):
31
+ """纯异步只追加会话存储协议。
32
+
33
+ 彻底废除全量重写 rewrite_history,保证历史记录发生即不可变。
34
+ """
35
+
36
+ async def append(self, entry: SessionEntry) -> None:
37
+ """追加单个条目到存储末尾。"""
38
+ ...
39
+
40
+ async def append_batch(self, entries: Sequence[SessionEntry]) -> None:
41
+ """原子追加一批条目到存储末尾。"""
42
+ ...
43
+
44
+ async def read_all(self) -> list[SessionEntry]:
45
+ """读取存储中所有按追加时序排列的历史条目。"""
46
+ ...
47
+
48
+
49
+ class InMemorySessionStorage:
50
+ """纯内存只追加会话存储实现。
51
+
52
+ 用于极速单元测试和无持久化需求的会话场景,协程安全。
53
+ """
54
+
55
+ def __init__(self, initial_entries: Sequence[SessionEntry] | None = None) -> None:
56
+ self._entries: list[SessionEntry] = (
57
+ list(initial_entries) if initial_entries else []
58
+ )
59
+ self._lock = asyncio.Lock()
60
+
61
+ async def append(self, entry: SessionEntry) -> None:
62
+ async with self._lock:
63
+ self._entries.append(entry)
64
+
65
+ async def append_batch(self, entries: Sequence[SessionEntry]) -> None:
66
+ async with self._lock:
67
+ self._entries.extend(entries)
68
+
69
+ async def read_all(self) -> list[SessionEntry]:
70
+ async with self._lock:
71
+ return list(self._entries)
72
+
73
+
74
+ class JsonlSessionStorage:
75
+ """基于单文件行级追加的 SessionStorage 实现。
76
+
77
+ 支持 `.{name}.lock` 跨进程文件锁,以及异常退出遗留 `.tmp` 碎片的自愈清理。
78
+ """
79
+
80
+ def __init__(self, path: Path | str) -> None:
81
+ self.path = Path(path)
82
+ self.lock_path = self.path.parent / f".{self.path.name}.lock"
83
+ self._async_lock = asyncio.Lock()
84
+ self._remove_incomplete_temp()
85
+
86
+ def _remove_incomplete_temp(self) -> list[Path]:
87
+ """清理异常中断遗留的临时碎片文件。"""
88
+ cleaned: list[Path] = []
89
+ parent = self.path.parent
90
+ if not parent.exists():
91
+ return cleaned
92
+
93
+ patterns = [
94
+ f".{self.path.name}*.tmp",
95
+ f"{self.path.name}*.tmp",
96
+ ]
97
+ seen: set[Path] = set()
98
+ for pattern in patterns:
99
+ for tmp_file in parent.glob(pattern):
100
+ if tmp_file in seen or not tmp_file.is_file():
101
+ continue
102
+ seen.add(tmp_file)
103
+ with contextlib.suppress(OSError):
104
+ tmp_file.unlink(missing_ok=True)
105
+ cleaned.append(tmp_file)
106
+ return cleaned
107
+
108
+ @contextlib.asynccontextmanager
109
+ async def _process_lock(self, timeout: float = 10.0) -> AsyncIterator[None]:
110
+ """基于 `.{name}.lock` 的跨进程文件锁。"""
111
+ self.path.parent.mkdir(parents=True, exist_ok=True)
112
+ start_time = time.monotonic()
113
+ fd = os.open(str(self.lock_path), os.O_RDWR | os.O_CREAT)
114
+ try:
115
+ acquired = False
116
+ while not acquired:
117
+ try:
118
+ _lock_file(fd)
119
+ acquired = True
120
+ except (BlockingIOError, OSError, PermissionError):
121
+ if time.monotonic() - start_time > timeout:
122
+ raise SessionJsonlError(
123
+ f"Timeout ({timeout}s) waiting for lock on {self.lock_path}"
124
+ ) from None
125
+ await asyncio.sleep(0.01)
126
+ try:
127
+ yield
128
+ finally:
129
+ _unlock_file(fd)
130
+ finally:
131
+ os.close(fd)
132
+
133
+ @contextlib.asynccontextmanager
134
+ async def _lock(self, timeout: float = 10.0) -> AsyncIterator[None]:
135
+ """协程锁与跨进程锁的双重上下文。"""
136
+ async with self._async_lock, self._process_lock(timeout=timeout):
137
+ yield
138
+
139
+ async def append(self, entry: SessionEntry) -> None:
140
+ """追加单个条目到存储末尾。"""
141
+ async with self._lock():
142
+ self._remove_incomplete_temp()
143
+ self.path.parent.mkdir(parents=True, exist_ok=True)
144
+ line = entry_to_json_line(entry)
145
+ try:
146
+ with open(self.path, "a", encoding="utf-8") as f:
147
+ f.write(line + "\n")
148
+ f.flush()
149
+ os.fsync(f.fileno())
150
+ except OSError as exc:
151
+ raise SessionJsonlError(
152
+ f"Failed to append to {self.path}: {exc}"
153
+ ) from exc
154
+
155
+ async def append_batch(self, entries: Sequence[SessionEntry]) -> None:
156
+ """原子追加一批条目到存储末尾。"""
157
+ if not entries:
158
+ return
159
+ async with self._lock():
160
+ self._remove_incomplete_temp()
161
+ self.path.parent.mkdir(parents=True, exist_ok=True)
162
+ lines = [entry_to_json_line(e) for e in entries]
163
+ try:
164
+ with open(self.path, "a", encoding="utf-8") as f:
165
+ for line in lines:
166
+ f.write(line + "\n")
167
+ f.flush()
168
+ os.fsync(f.fileno())
169
+ except OSError as exc:
170
+ raise SessionJsonlError(
171
+ f"Failed to append batch to {self.path}: {exc}"
172
+ ) from exc
173
+
174
+ async def read_all(self) -> list[SessionEntry]:
175
+ """读取存储中所有按追加时序排列的历史条目。"""
176
+ async with self._lock():
177
+ self._remove_incomplete_temp()
178
+ if not self.path.exists():
179
+ return []
180
+
181
+ try:
182
+ with open(self.path, encoding="utf-8") as f:
183
+ raw_lines = f.readlines()
184
+ except OSError as exc:
185
+ raise SessionJsonlError(f"Failed to read {self.path}: {exc}") from exc
186
+
187
+ lines = [ln.strip() for ln in raw_lines if ln.strip()]
188
+ if not lines:
189
+ return []
190
+
191
+ entries: list[SessionEntry] = []
192
+ for i, line in enumerate(lines):
193
+ is_last = i == len(lines) - 1
194
+ try:
195
+ entry = entry_from_json_line(line)
196
+ entries.append(entry)
197
+ except SessionJsonlError as exc:
198
+ # 尾行撕裂容忍:如果是最后一行且前面已有有效条目,丢弃该损坏尾行(宽容兜底)
199
+ if _should_tolerate_tail_error(is_last, len(entries)):
200
+ break
201
+ raise exc
202
+ return entries
203
+
204
+
205
+ # ─── 私有辅助函数(对齐 Tau 内聚风格,置于模块末尾) ──────────────────────────
206
+
207
+
208
+ def _should_tolerate_tail_error(is_last: bool, count: int) -> bool:
209
+ """尾行撕裂容忍:最后一行且前面已有有效条目时为 True。"""
210
+ return is_last and count > 0
211
+
212
+
213
+ def _lock_file(fd: int) -> None:
214
+ """底层文件锁适配(非阻塞排他锁),内部按需判断操作系统。"""
215
+ if sys.platform == "win32":
216
+ import msvcrt
217
+
218
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
219
+ return
220
+
221
+ import fcntl
222
+
223
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
224
+
225
+
226
+ def _unlock_file(fd: int) -> None:
227
+ """底层文件解锁适配。"""
228
+ if sys.platform == "win32":
229
+ import msvcrt
230
+
231
+ with contextlib.suppress(OSError):
232
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
233
+ return
234
+
235
+ import fcntl
236
+
237
+ with contextlib.suppress(OSError):
238
+ fcntl.flock(fd, fcntl.LOCK_UN)
239
+
240
+
241
+ __all__ = [
242
+ "SessionStorage",
243
+ "InMemorySessionStorage",
244
+ "JsonlSessionStorage",
245
+ ]