tau-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 (283) hide show
  1. tau/__init__.py +0 -0
  2. tau/agent/__init__.py +11 -0
  3. tau/agent/prompt/__init__.py +10 -0
  4. tau/agent/prompt/builder.py +302 -0
  5. tau/agent/prompt/types.py +33 -0
  6. tau/agent/service.py +369 -0
  7. tau/agent/types.py +61 -0
  8. tau/auth/manager.py +247 -0
  9. tau/auth/storage.py +82 -0
  10. tau/auth/types.py +41 -0
  11. tau/builtins/__init__.py +4 -0
  12. tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
  13. tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
  14. tau/builtins/commands/__init__.py +41 -0
  15. tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
  16. tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
  17. tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
  18. tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
  19. tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
  20. tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
  21. tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
  22. tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
  23. tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
  24. tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
  25. tau/builtins/commands/clear.py +16 -0
  26. tau/builtins/commands/compact.py +28 -0
  27. tau/builtins/commands/reload.py +27 -0
  28. tau/builtins/commands/session.py +19 -0
  29. tau/builtins/extensions/footer/__init__.py +76 -0
  30. tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
  31. tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
  32. tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
  33. tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
  34. tau/builtins/extensions/footer/git.py +26 -0
  35. tau/builtins/extensions/footer/model.py +69 -0
  36. tau/builtins/extensions/footer/utils.py +44 -0
  37. tau/builtins/extensions/header/__init__.py +18 -0
  38. tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
  39. tau/builtins/models/__init__.py +0 -0
  40. tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
  41. tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
  42. tau/builtins/models/audio.py +43 -0
  43. tau/builtins/models/image.py +43 -0
  44. tau/builtins/models/text.py +482 -0
  45. tau/builtins/models/video.py +40 -0
  46. tau/builtins/prompts/commit.md +7 -0
  47. tau/builtins/prompts/docs.md +7 -0
  48. tau/builtins/prompts/explain.md +7 -0
  49. tau/builtins/prompts/fix.md +7 -0
  50. tau/builtins/prompts/refactor.md +7 -0
  51. tau/builtins/prompts/review.md +7 -0
  52. tau/builtins/prompts/test.md +7 -0
  53. tau/builtins/providers/__init__.py +0 -0
  54. tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  55. tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
  56. tau/builtins/providers/audio.py +10 -0
  57. tau/builtins/providers/image.py +9 -0
  58. tau/builtins/providers/text.py +33 -0
  59. tau/builtins/providers/video.py +6 -0
  60. tau/builtins/skills/code-review/SKILL.md +4 -0
  61. tau/builtins/skills/debug/SKILL.md +4 -0
  62. tau/builtins/skills/git-commit/SKILL.md +4 -0
  63. tau/builtins/themes/dark.yaml +1 -0
  64. tau/builtins/themes/light.yaml +46 -0
  65. tau/builtins/tools/__init__.py +73 -0
  66. tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  67. tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  68. tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
  69. tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
  70. tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
  71. tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
  72. tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
  73. tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
  74. tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
  75. tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
  76. tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
  77. tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
  78. tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
  79. tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
  80. tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
  81. tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
  82. tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
  83. tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
  84. tau/builtins/tools/edit.py +215 -0
  85. tau/builtins/tools/glob.py +112 -0
  86. tau/builtins/tools/grep.py +146 -0
  87. tau/builtins/tools/ls.py +135 -0
  88. tau/builtins/tools/read.py +122 -0
  89. tau/builtins/tools/terminal.py +150 -0
  90. tau/builtins/tools/write.py +105 -0
  91. tau/commands/__init__.py +10 -0
  92. tau/commands/registry.py +71 -0
  93. tau/commands/types.py +33 -0
  94. tau/console/__init__.py +0 -0
  95. tau/console/cli.py +266 -0
  96. tau/console/commands/__init__.py +0 -0
  97. tau/console/commands/auth.py +193 -0
  98. tau/console/commands/packages.py +104 -0
  99. tau/console/commands/update.py +76 -0
  100. tau/core/__init__.py +0 -0
  101. tau/core/registry.py +102 -0
  102. tau/engine/__init__.py +47 -0
  103. tau/engine/service.py +768 -0
  104. tau/engine/types.py +163 -0
  105. tau/extensions/__init__.py +28 -0
  106. tau/extensions/api.py +928 -0
  107. tau/extensions/context.py +462 -0
  108. tau/extensions/events.py +70 -0
  109. tau/extensions/loader.py +386 -0
  110. tau/extensions/runtime.py +184 -0
  111. tau/extensions/settings.py +137 -0
  112. tau/hooks/__init__.py +112 -0
  113. tau/hooks/engine.py +237 -0
  114. tau/hooks/inference.py +21 -0
  115. tau/hooks/runtime.py +126 -0
  116. tau/hooks/service.py +121 -0
  117. tau/hooks/session.py +117 -0
  118. tau/hooks/tui.py +61 -0
  119. tau/hooks/types.py +72 -0
  120. tau/inference/__init__.py +180 -0
  121. tau/inference/api/__init__.py +0 -0
  122. tau/inference/api/audio/__init__.py +0 -0
  123. tau/inference/api/audio/base.py +29 -0
  124. tau/inference/api/audio/builtins.py +15 -0
  125. tau/inference/api/audio/elevenlabs_audio.py +183 -0
  126. tau/inference/api/audio/gemini_audio.py +95 -0
  127. tau/inference/api/audio/openai_audio.py +159 -0
  128. tau/inference/api/audio/registry.py +15 -0
  129. tau/inference/api/audio/sarvam_audio.py +163 -0
  130. tau/inference/api/audio/service.py +103 -0
  131. tau/inference/api/audio/utils.py +47 -0
  132. tau/inference/api/image/__init__.py +0 -0
  133. tau/inference/api/image/base.py +17 -0
  134. tau/inference/api/image/builtins.py +8 -0
  135. tau/inference/api/image/gemini_image.py +77 -0
  136. tau/inference/api/image/openai_image.py +103 -0
  137. tau/inference/api/image/openrouter.py +144 -0
  138. tau/inference/api/image/registry.py +15 -0
  139. tau/inference/api/image/service.py +71 -0
  140. tau/inference/api/registry.py +82 -0
  141. tau/inference/api/text/__init__.py +0 -0
  142. tau/inference/api/text/anthropic_claude_code.py +222 -0
  143. tau/inference/api/text/anthropic_messages.py +196 -0
  144. tau/inference/api/text/base.py +40 -0
  145. tau/inference/api/text/builtins.py +19 -0
  146. tau/inference/api/text/gemini_generate.py +234 -0
  147. tau/inference/api/text/github_copilot_chat.py +172 -0
  148. tau/inference/api/text/google_antigravity.py +522 -0
  149. tau/inference/api/text/mistral_chat.py +284 -0
  150. tau/inference/api/text/ollama_chat.py +200 -0
  151. tau/inference/api/text/openai_codex_responses.py +497 -0
  152. tau/inference/api/text/openai_completions.py +227 -0
  153. tau/inference/api/text/openai_responses.py +235 -0
  154. tau/inference/api/text/registry.py +50 -0
  155. tau/inference/api/text/service.py +297 -0
  156. tau/inference/api/text/types.py +7 -0
  157. tau/inference/api/text/utils.py +228 -0
  158. tau/inference/api/video/__init__.py +0 -0
  159. tau/inference/api/video/base.py +26 -0
  160. tau/inference/api/video/builtins.py +7 -0
  161. tau/inference/api/video/fal_video.py +119 -0
  162. tau/inference/api/video/openrouter_video.py +142 -0
  163. tau/inference/api/video/registry.py +15 -0
  164. tau/inference/api/video/service.py +72 -0
  165. tau/inference/model/__init__.py +0 -0
  166. tau/inference/model/registry.py +102 -0
  167. tau/inference/model/types.py +65 -0
  168. tau/inference/provider/__init__.py +0 -0
  169. tau/inference/provider/oauth/__init__.py +35 -0
  170. tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
  171. tau/inference/provider/oauth/github_copilot.py +333 -0
  172. tau/inference/provider/oauth/google_antigravity.py +258 -0
  173. tau/inference/provider/oauth/openai_codex.py +309 -0
  174. tau/inference/provider/oauth/pkce.py +14 -0
  175. tau/inference/provider/oauth/types.py +46 -0
  176. tau/inference/provider/oauth/utils.py +154 -0
  177. tau/inference/provider/registry.py +141 -0
  178. tau/inference/provider/types.py +114 -0
  179. tau/inference/types.py +549 -0
  180. tau/inference/utils.py +219 -0
  181. tau/message/__init__.py +0 -0
  182. tau/message/types.py +482 -0
  183. tau/message/utils.py +178 -0
  184. tau/packages/__init__.py +11 -0
  185. tau/packages/manager.py +190 -0
  186. tau/packages/types.py +20 -0
  187. tau/packages/utils.py +67 -0
  188. tau/prompts/expand.py +58 -0
  189. tau/prompts/loader.py +69 -0
  190. tau/prompts/registry.py +45 -0
  191. tau/prompts/types.py +24 -0
  192. tau/rpc/__init__.py +8 -0
  193. tau/rpc/mode.py +783 -0
  194. tau/rpc/types.py +252 -0
  195. tau/runtime/service.py +759 -0
  196. tau/runtime/types.py +303 -0
  197. tau/session/branch_summarization.py +312 -0
  198. tau/session/compaction.py +646 -0
  199. tau/session/manager.py +652 -0
  200. tau/session/types.py +188 -0
  201. tau/session/utils.py +233 -0
  202. tau/settings/manager.py +1077 -0
  203. tau/settings/paths.py +150 -0
  204. tau/settings/storage.py +63 -0
  205. tau/settings/types.py +173 -0
  206. tau/settings/utils.py +25 -0
  207. tau/skills/loader.py +91 -0
  208. tau/skills/registry.py +70 -0
  209. tau/skills/types.py +25 -0
  210. tau/themes/loader.py +238 -0
  211. tau/themes/registry.py +108 -0
  212. tau/themes/types.py +19 -0
  213. tau/tool/__init__.py +3 -0
  214. tau/tool/registry.py +117 -0
  215. tau/tool/render.py +21 -0
  216. tau/tool/types.py +244 -0
  217. tau/trust/__init__.py +13 -0
  218. tau/trust/manager.py +80 -0
  219. tau/trust/types.py +14 -0
  220. tau/trust/utils.py +72 -0
  221. tau/tui/__init__.py +54 -0
  222. tau/tui/agent_hooks.py +346 -0
  223. tau/tui/ansi.py +330 -0
  224. tau/tui/app.py +540 -0
  225. tau/tui/autocomplete.py +33 -0
  226. tau/tui/capabilities.py +119 -0
  227. tau/tui/commands/__init__.py +3 -0
  228. tau/tui/commands/appearance.py +498 -0
  229. tau/tui/commands/auth.py +232 -0
  230. tau/tui/commands/context.py +38 -0
  231. tau/tui/commands/misc.py +82 -0
  232. tau/tui/commands/model.py +118 -0
  233. tau/tui/commands/session.py +464 -0
  234. tau/tui/component.py +268 -0
  235. tau/tui/components/__init__.py +0 -0
  236. tau/tui/components/autocomplete_manager.py +267 -0
  237. tau/tui/components/autocomplete_picker.py +143 -0
  238. tau/tui/components/box.py +90 -0
  239. tau/tui/components/command_palette.py +144 -0
  240. tau/tui/components/dynamic_border.py +19 -0
  241. tau/tui/components/file_picker.py +233 -0
  242. tau/tui/components/image.py +181 -0
  243. tau/tui/components/inline_selector.py +71 -0
  244. tau/tui/components/layout.py +1194 -0
  245. tau/tui/components/message_list.py +692 -0
  246. tau/tui/components/modal.py +97 -0
  247. tau/tui/components/model_palette.py +204 -0
  248. tau/tui/components/picker_overlay.py +174 -0
  249. tau/tui/components/prompt_overlay.py +236 -0
  250. tau/tui/components/resume_modal.py +372 -0
  251. tau/tui/components/select_list.py +222 -0
  252. tau/tui/components/settings_modal.py +274 -0
  253. tau/tui/components/settings_schema.py +203 -0
  254. tau/tui/components/spinner.py +119 -0
  255. tau/tui/components/text_input.py +396 -0
  256. tau/tui/components/text_prompt.py +82 -0
  257. tau/tui/components/tree_select_list.py +580 -0
  258. tau/tui/components/trust_screen.py +97 -0
  259. tau/tui/diff.py +114 -0
  260. tau/tui/fuzzy.py +99 -0
  261. tau/tui/input.py +496 -0
  262. tau/tui/input_handler.py +716 -0
  263. tau/tui/keybindings.py +87 -0
  264. tau/tui/markdown.py +286 -0
  265. tau/tui/message_renderers.py +31 -0
  266. tau/tui/overlay.py +326 -0
  267. tau/tui/renderer.py +378 -0
  268. tau/tui/terminal.py +499 -0
  269. tau/tui/theme.py +148 -0
  270. tau/tui/tui.py +544 -0
  271. tau/tui/ui_context.py +768 -0
  272. tau/tui/utils.py +20 -0
  273. tau/utils/__init__.py +0 -0
  274. tau/utils/http_proxy.py +221 -0
  275. tau/utils/image_processing.py +172 -0
  276. tau/utils/secrets.py +59 -0
  277. tau/utils/version_check.py +60 -0
  278. tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
  279. tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
  280. tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
  281. tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  282. tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  283. tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
tau/session/manager.py ADDED
@@ -0,0 +1,652 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Callable, List
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from tau.session.types import (
7
+ SessionFileEntry, SessionHeader,
8
+ SessionEntry, LabelEntry, LeafEntry,
9
+ SessionOptions, SessionType, MessageEntry,
10
+ ThinkingLevelChangeEntry, SessionInfoEntry,
11
+ ModelChangeEntry, CustomInfoEntry,
12
+ CustomMessageEntry, CompactionEntry, BranchSummaryEntry,
13
+ SessionContext, SessionInfo,
14
+ SessionTreeNode, MessageMeta,
15
+ )
16
+ from tau.session.utils import (
17
+ create_session_id, generate_timestamp, generate_id, read_session_file,
18
+ is_valid_session_file, find_most_recent_session, is_message_with_contents,
19
+ get_last_activity_time, get_session_modified_date, build_session_info,
20
+ list_sessions_from_dir, get_default_session_dir,
21
+ )
22
+ from tau.settings.paths import get_sessions_dir
23
+ from tau.message.types import (
24
+ AgentMessage, AssistantMessage, CustomMessage,
25
+ ImageContent, TextContent, LLMMessage, Role,
26
+ )
27
+ from tau.inference.types import ThinkingLevel
28
+
29
+
30
+ class SessionManager:
31
+ def __init__(
32
+ self,
33
+ cwd: str | Path,
34
+ session_dir: Path | None = None,
35
+ session_file: Path | None = None,
36
+ persist: bool = True,
37
+ ):
38
+ self.session_id: str | None = None
39
+ self.cwd = Path(cwd).resolve()
40
+ self.persist = persist
41
+ self.session_dir = (
42
+ Path(session_dir).resolve() if session_dir
43
+ else get_default_session_dir(self.cwd)
44
+ )
45
+ self.session_file = session_file
46
+ self.by_id: dict[str, SessionEntry] = {}
47
+ self.labels_by_id: dict[str, str] = {}
48
+ self.label_timestamps_by_id: dict[str, float] = {}
49
+ self.leaf_id: str | None = None
50
+ self.entries: list[SessionFileEntry] = []
51
+ self.flushed: bool = False
52
+
53
+ if self.persist and not self.session_dir.exists():
54
+ self.session_dir.mkdir(parents=True, exist_ok=True)
55
+
56
+ if self.session_file:
57
+ self.set_session(self.session_file)
58
+ else:
59
+ self.new_session()
60
+
61
+ def set_session(self, session_file: Path):
62
+ """Load or initialize a session from a file."""
63
+ self.session_file = session_file
64
+ if session_file.exists():
65
+ self.entries = read_session_file(session_file)
66
+
67
+ if not self.entries:
68
+ # File missing or invalid — start fresh in that file location
69
+ session_id = create_session_id()
70
+ header = SessionHeader(
71
+ id=session_id,
72
+ timestamp=generate_timestamp(),
73
+ cwd=self.cwd,
74
+ )
75
+ self.session_id = session_id
76
+ self.entries = [header]
77
+ self.by_id.clear()
78
+ self.labels_by_id.clear()
79
+ self.label_timestamps_by_id.clear()
80
+ self.leaf_id = None
81
+ self.flushed = False
82
+ if self.persist:
83
+ self._rewrite_file()
84
+ self.flushed = True
85
+ else:
86
+ for entry in self.entries:
87
+ if isinstance(entry, SessionHeader):
88
+ self.session_id = entry.id
89
+ break
90
+ self._build_index()
91
+ self.flushed = True
92
+
93
+ def new_session(self, options: SessionOptions | None = None):
94
+ """Create a new session, optionally with parent session and custom ID."""
95
+ options = options or SessionOptions()
96
+ session_id = options.id or create_session_id()
97
+ parent_session = Path(options.parent_session).resolve() if options.parent_session else None
98
+ header = SessionHeader(
99
+ id=session_id,
100
+ timestamp=generate_timestamp(),
101
+ cwd=self.cwd,
102
+ parent_session=parent_session,
103
+ )
104
+
105
+ self.session_id = session_id
106
+ self.entries = [header]
107
+ self.by_id.clear()
108
+ self.labels_by_id.clear()
109
+ self.label_timestamps_by_id.clear()
110
+ self.leaf_id = None
111
+ self.flushed = False
112
+
113
+ if self.persist:
114
+ file_timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S-%f")
115
+ self.session_file = (self.session_dir / f"{file_timestamp}_{session_id}.jsonl").resolve()
116
+
117
+ return self.session_file
118
+
119
+ def _rewrite_file(self):
120
+ """Write all session entries to the session file."""
121
+ if not self.persist or not self.session_file:
122
+ return None
123
+ lines = [entry.model_dump_json(exclude_none=True) for entry in self.entries]
124
+ self.session_file.write_text("\n".join(lines), encoding="utf-8")
125
+
126
+ def _build_index(self):
127
+ """Rebuild internal indices from loaded entries."""
128
+ self.by_id.clear()
129
+ self.labels_by_id.clear()
130
+ self.label_timestamps_by_id.clear()
131
+ self.leaf_id = None
132
+
133
+ for entry in self.entries:
134
+ if isinstance(entry, SessionHeader):
135
+ continue
136
+ self.by_id[entry.id] = entry
137
+ if isinstance(entry, LeafEntry):
138
+ # LeafEntry records a navigation point — target_id is the new leaf.
139
+ self.leaf_id = entry.target_id
140
+ else:
141
+ self.leaf_id = entry.id
142
+ if isinstance(entry, LabelEntry):
143
+ if entry.label:
144
+ self.labels_by_id[entry.target_id] = entry.label
145
+ self.label_timestamps_by_id[entry.target_id] = entry.timestamp
146
+ else:
147
+ self.labels_by_id.pop(entry.target_id, None)
148
+ self.label_timestamps_by_id.pop(entry.target_id, None)
149
+
150
+ def _persist(self, entry: SessionEntry):
151
+ """Append an entry to the session file."""
152
+ if not self.persist or not self.session_file:
153
+ return None
154
+
155
+ has_assistant_message = any(
156
+ isinstance(e, MessageEntry) and isinstance(e.message, AssistantMessage)
157
+ for e in self.entries
158
+ )
159
+
160
+ if not has_assistant_message:
161
+ self.flushed = False
162
+ return
163
+
164
+ with self.session_file.open("a", encoding="utf-8") as f:
165
+ if not self.flushed:
166
+ lines = [e.model_dump_json(exclude_none=True) + "\n" for e in self.entries]
167
+ f.writelines(lines)
168
+ self.flushed = True
169
+ else:
170
+ f.write(entry.model_dump_json(exclude_none=True) + "\n")
171
+
172
+ def _append_entry(self, entry: SessionEntry) -> str:
173
+ """Add an entry to the session and persist it."""
174
+ self.entries.append(entry)
175
+ self.by_id[entry.id] = entry
176
+ self.leaf_id = entry.id
177
+ self._persist(entry)
178
+ return entry.id
179
+
180
+ def append_message(self, message: AgentMessage, meta: MessageMeta | None = None) -> str:
181
+ """Add a message to the session."""
182
+ entry = MessageEntry(message=message, parent_id=self.leaf_id, meta=meta)
183
+ return self._append_entry(entry)
184
+
185
+ def remove_last_message(self, role: str | None = None) -> bool:
186
+ """Remove the message entry at the current leaf, if it matches role.
187
+
188
+ Only ever touches the entry at the tip of the *current* branch — never
189
+ reaches into other branches — so this stays correct after navigating
190
+ the tree. Returns True if an entry was removed.
191
+ """
192
+ entry = self.by_id.get(self.leaf_id) if self.leaf_id is not None else None
193
+ if not isinstance(entry, MessageEntry):
194
+ return False
195
+ if role is not None and getattr(entry.message, "role", None) != role:
196
+ return False
197
+ self.entries.remove(entry)
198
+ self.by_id.pop(entry.id, None)
199
+ self.leaf_id = entry.parent_id
200
+ if self.flushed:
201
+ self._rewrite_file()
202
+ return True
203
+
204
+ def append_thinking_level_change(self, thinking_level: ThinkingLevel) -> str:
205
+ """Record a change in the thinking level setting."""
206
+ entry = ThinkingLevelChangeEntry(thinking_level=thinking_level, parent_id=self.leaf_id)
207
+ return self._append_entry(entry)
208
+
209
+ def append_model_change(self, model_id: str, provider_id: str) -> str:
210
+ """Record a model or provider change."""
211
+ entry = ModelChangeEntry(model_id=model_id, provider_id=provider_id, parent_id=self.leaf_id)
212
+ return self._append_entry(entry)
213
+
214
+ def append_label_change(self, target_id: str, label: str | None = None) -> str:
215
+ """Add or remove a label from an entry."""
216
+ entry = LabelEntry(target_id=target_id, label=label, parent_id=self.leaf_id)
217
+ if label:
218
+ self.labels_by_id[target_id] = label
219
+ self.label_timestamps_by_id[target_id] = entry.timestamp
220
+ else:
221
+ self.labels_by_id.pop(target_id, None)
222
+ self.label_timestamps_by_id.pop(target_id, None)
223
+ return self._append_entry(entry)
224
+
225
+ def append_custom_info(self, custom_type: str, data: Any | None = None) -> str:
226
+ """Add custom metadata to the session."""
227
+ entry = CustomInfoEntry(custom_type=custom_type, data=data, parent_id=self.leaf_id)
228
+ return self._append_entry(entry)
229
+
230
+ def append_custom_message(
231
+ self,
232
+ custom_type: str,
233
+ content: Any,
234
+ display: bool = True,
235
+ details: Any | None = None,
236
+ ) -> str:
237
+ """Add a custom message to the session."""
238
+ entry = CustomMessageEntry(
239
+ custom_type=custom_type,
240
+ content=content,
241
+ display=display,
242
+ details=details,
243
+ parent_id=self.leaf_id,
244
+ )
245
+ return self._append_entry(entry)
246
+
247
+ def append_branch_summary(
248
+ self,
249
+ from_id: str,
250
+ summary: str,
251
+ details: dict | None = None,
252
+ from_hook: bool = False,
253
+ label: str | None = None,
254
+ ) -> str:
255
+ """Record a summary when abandoning a branch."""
256
+ entry = BranchSummaryEntry(
257
+ from_id=from_id,
258
+ summary=summary,
259
+ details=details,
260
+ from_hook=from_hook,
261
+ label=label,
262
+ parent_id=self.leaf_id,
263
+ )
264
+ return self._append_entry(entry)
265
+
266
+ def branch_with_summary(
267
+ self,
268
+ branch_from_id: str | None,
269
+ summary: str,
270
+ details: dict | None = None,
271
+ from_hook: bool = False,
272
+ ) -> str:
273
+ """Navigate to branch_from_id and append a branch_summary entry capturing the abandoned path."""
274
+ if branch_from_id is not None and branch_from_id not in self.by_id:
275
+ raise KeyError(f"Entry {branch_from_id} not found.")
276
+ self.leaf_id = branch_from_id
277
+ entry = BranchSummaryEntry(
278
+ from_id=branch_from_id or "root",
279
+ summary=summary,
280
+ details=details,
281
+ from_hook=from_hook,
282
+ parent_id=branch_from_id,
283
+ )
284
+ return self._append_entry(entry)
285
+
286
+ def append_compaction(
287
+ self,
288
+ summary: str,
289
+ first_kept_entry_id: str,
290
+ tokens_before: int,
291
+ details: dict | None = None,
292
+ ) -> str:
293
+ """Record a context compaction."""
294
+ entry = CompactionEntry(
295
+ summary=summary,
296
+ first_kept_entry_id=first_kept_entry_id,
297
+ tokens_before=tokens_before,
298
+ details=details,
299
+ parent_id=self.leaf_id,
300
+ )
301
+ return self._append_entry(entry)
302
+
303
+ def append_session_info(self, name: str) -> str:
304
+ """Set the session name."""
305
+ entry = SessionInfoEntry(name=name, parent_id=self.leaf_id)
306
+ return self._append_entry(entry)
307
+
308
+ def get_session_name(self) -> str | None:
309
+ """Return the most recent session name, or None if not set."""
310
+ for entry in reversed(self.entries):
311
+ if isinstance(entry, SessionInfoEntry) and entry.name and entry.name.strip():
312
+ return entry.name.strip()
313
+ return None
314
+
315
+ def get_leaf_id(self) -> str | None:
316
+ """Return the ID of the current leaf entry, or None if not set."""
317
+ return self.leaf_id
318
+
319
+ def get_leaf_entry(self) -> SessionEntry | None:
320
+ """Return the current leaf entry, or None if not found."""
321
+ return self.by_id.get(self.leaf_id) if self.leaf_id else None
322
+
323
+ def get_entry(self, id: str) -> SessionEntry | None:
324
+ """Retrieve an entry by ID, or None if not found."""
325
+ return self.by_id.get(id)
326
+
327
+ def get_children(self, parent_id: str) -> list[SessionEntry]:
328
+ """Return all entries with the given parent_id, sorted by timestamp."""
329
+ return sorted(
330
+ [entry for entry in self.get_entries() if entry.parent_id == parent_id],
331
+ key=lambda entry: entry.timestamp,
332
+ )
333
+
334
+ def get_label(self, id: str) -> str | None:
335
+ """Return the label for an entry, or None if not labeled."""
336
+ return self.labels_by_id.get(id)
337
+
338
+ def get_branch(self, from_id: str | None = None) -> list[SessionEntry]:
339
+ """Return entries from root to the given id (or leaf_id), in root→leaf order."""
340
+ path: list[SessionEntry] = []
341
+ cursor = from_id or self.leaf_id
342
+ while cursor:
343
+ current_entry = self.by_id.get(cursor)
344
+ if not current_entry:
345
+ break
346
+ path.append(current_entry)
347
+ cursor = current_entry.parent_id
348
+ path.reverse()
349
+ return path
350
+
351
+ def build_session_context(self) -> SessionContext:
352
+ """Build a context object from the current branch, including messages and settings."""
353
+ from tau.message.types import CompactionSummaryMessage
354
+
355
+ thinking_level: ThinkingLevel = ThinkingLevel.Off
356
+ model_id: str | None = None
357
+ provider_id: str | None = None
358
+ messages: list[AgentMessage] = []
359
+
360
+ entries = self.get_branch()
361
+
362
+ if not entries:
363
+ return SessionContext(
364
+ messages=messages,
365
+ thinking_level=thinking_level,
366
+ model_id=model_id,
367
+ provider_id=provider_id,
368
+ )
369
+
370
+ # Scan all entries for model/thinking-level changes
371
+ for entry in entries:
372
+ match entry:
373
+ case ThinkingLevelChangeEntry():
374
+ thinking_level = entry.thinking_level
375
+ case ModelChangeEntry():
376
+ model_id = entry.model_id
377
+ provider_id = entry.provider_id
378
+
379
+ # Drop history before the most recent compaction
380
+ last_compaction: CompactionEntry | None = None
381
+ for entry in entries:
382
+ if isinstance(entry, CompactionEntry):
383
+ last_compaction = entry
384
+
385
+ if last_compaction is not None:
386
+ first_kept_idx = next(
387
+ (i for i, e in enumerate(entries) if e.id == last_compaction.first_kept_entry_id),
388
+ len(entries),
389
+ )
390
+ slice_entries = entries[first_kept_idx:]
391
+ else:
392
+ slice_entries = entries
393
+
394
+ for entry in slice_entries:
395
+ match entry:
396
+ case MessageEntry():
397
+ messages.append(entry.message)
398
+ case CustomMessageEntry():
399
+ messages.append(CustomMessage.from_session(entry=entry))
400
+ case BranchSummaryEntry():
401
+ from tau.message.types import BranchSummaryMessage
402
+ messages.append(BranchSummaryMessage(
403
+ summary=entry.summary,
404
+ from_id=entry.from_id,
405
+ timestamp=entry.timestamp,
406
+ ))
407
+ case CompactionEntry():
408
+ messages.insert(0, CompactionSummaryMessage(
409
+ summary=entry.summary,
410
+ tokens_before=entry.tokens_before,
411
+ timestamp=entry.timestamp,
412
+ ))
413
+
414
+ return SessionContext(
415
+ messages=messages,
416
+ thinking_level=thinking_level,
417
+ model_id=model_id,
418
+ provider_id=provider_id,
419
+ )
420
+
421
+ def get_header(self) -> SessionHeader | None:
422
+ """Return the session header entry, or None if not found."""
423
+ for entry in self.entries:
424
+ if isinstance(entry, SessionHeader):
425
+ return entry
426
+ return None
427
+
428
+ def get_entries(self) -> list[SessionEntry]:
429
+ """Return all non-header entries in the session."""
430
+ return [entry for entry in self.entries if not isinstance(entry, SessionHeader)]
431
+
432
+ def get_tree(self) -> list[SessionTreeNode]:
433
+ """Build a hierarchical tree structure of all entries."""
434
+ node_map: dict[str, SessionTreeNode] = {}
435
+ roots: list[SessionTreeNode] = []
436
+
437
+ for entry in self.get_entries():
438
+ label = self.labels_by_id.get(entry.id)
439
+ label_timestamp = self.label_timestamps_by_id.get(entry.id)
440
+ node_map[entry.id] = SessionTreeNode(
441
+ entry=entry,
442
+ children=[],
443
+ label_timestamp=label_timestamp,
444
+ label=label,
445
+ )
446
+
447
+ for entry in self.get_entries():
448
+ node = node_map[entry.id]
449
+ if entry.parent_id is None or entry.parent_id == entry.id:
450
+ roots.append(node)
451
+ else:
452
+ parent_node = node_map.get(entry.parent_id)
453
+ if parent_node is None:
454
+ roots.append(node)
455
+ else:
456
+ parent_node.children.append(node)
457
+
458
+ stack = roots.copy()
459
+ while stack:
460
+ node = stack.pop()
461
+ node.children.sort(key=lambda child: child.entry.timestamp)
462
+ stack.extend(node.children)
463
+
464
+ roots.sort(key=lambda node: node.entry.timestamp)
465
+ return roots
466
+
467
+ def branch(self, from_id: str):
468
+ """Navigate to a given entry and record the navigation point."""
469
+ if from_id not in self.by_id:
470
+ raise KeyError(f"Entry {from_id} not found.")
471
+ # Persist a LeafEntry so the navigation point survives restarts.
472
+ leaf_entry = LeafEntry(parent_id=self.leaf_id, target_id=from_id)
473
+ self.entries.append(leaf_entry)
474
+ self.by_id[leaf_entry.id] = leaf_entry
475
+ self._persist(leaf_entry)
476
+ self.leaf_id = from_id
477
+
478
+ def reset_leaf(self):
479
+ """Clear the leaf pointer."""
480
+ self.leaf_id = None
481
+
482
+ def create_branched_session(self, leaf_id: str) -> Path | None:
483
+ """Create a new session file forking from the given entry."""
484
+ previous_session_file = self.session_file
485
+ path = self.get_branch(leaf_id)
486
+
487
+ if not path:
488
+ raise ValueError(f"Entry {leaf_id} not found.")
489
+
490
+ path_without_labels = [entry for entry in path if not isinstance(entry, LabelEntry)]
491
+
492
+ session_id = create_session_id()
493
+ file_timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S-%f")
494
+ new_session_file = self.session_dir / f"{file_timestamp}_{session_id}.jsonl"
495
+
496
+ header = SessionHeader(
497
+ id=session_id,
498
+ timestamp=generate_timestamp(),
499
+ cwd=self.cwd,
500
+ parent_session=previous_session_file if self.persist else None,
501
+ )
502
+
503
+ path_entry_ids = {entry.id for entry in path_without_labels}
504
+ labels_to_write: list[tuple[str, str, float]] = [
505
+ (target_id, label, self.label_timestamps_by_id[target_id])
506
+ for target_id, label in self.labels_by_id.items()
507
+ if target_id in path_entry_ids
508
+ ]
509
+
510
+ label_entries: list[LabelEntry] = []
511
+ last_entry = path_without_labels[-1] if path_without_labels else None
512
+ parent_id = last_entry.id if last_entry else None
513
+ used_ids = set(path_entry_ids)
514
+
515
+ for (target_id, label, label_timestamp) in labels_to_write:
516
+ label_entry = LabelEntry(
517
+ id=generate_id(used_ids),
518
+ parent_id=parent_id,
519
+ timestamp=label_timestamp,
520
+ target_id=target_id,
521
+ label=label,
522
+ )
523
+ used_ids.add(label_entry.id)
524
+ label_entries.append(label_entry)
525
+ parent_id = label_entry.id
526
+
527
+ self.entries = [header, *path_without_labels, *label_entries]
528
+ self.session_id = session_id
529
+ self.session_file = new_session_file if self.persist else None
530
+ self._build_index()
531
+
532
+ has_assistant = any(
533
+ isinstance(entry, MessageEntry) and isinstance(entry.message, AssistantMessage)
534
+ for entry in self.entries
535
+ )
536
+
537
+ if self.persist:
538
+ if has_assistant:
539
+ self._rewrite_file()
540
+ self.flushed = True
541
+ else:
542
+ self.flushed = False
543
+ return new_session_file
544
+ return None
545
+
546
+ @classmethod
547
+ def create(cls, cwd: Path | str, session_dir: Path | str | None = None) -> SessionManager:
548
+ """Create a new SessionManager with a fresh session."""
549
+ cwd = Path(cwd).resolve()
550
+ session_dir = Path(session_dir).resolve() if session_dir else get_default_session_dir(cwd)
551
+ return SessionManager(cwd, session_dir)
552
+
553
+ @staticmethod
554
+ def open(
555
+ path: Path | str,
556
+ session_dir: Path | str | None = None,
557
+ cwd_override: Path | str | None = None,
558
+ ) -> SessionManager:
559
+ """Load an existing session from a file."""
560
+ path = Path(path).resolve()
561
+ entries = read_session_file(path)
562
+ header = next((e for e in entries if isinstance(e, SessionHeader)), None)
563
+ if header is None:
564
+ raise ValueError(f"No header found in session file: {path}")
565
+ cwd = Path(cwd_override).resolve() if cwd_override else Path(header.cwd).resolve()
566
+ session_dir = Path(session_dir).resolve() if session_dir else path.parent
567
+ return SessionManager(cwd, session_dir, path)
568
+
569
+ @staticmethod
570
+ def continue_recent(cwd: Path | str, session_dir: Path | str | None = None) -> SessionManager:
571
+ """Load the most recent session, or create a new one if none exist."""
572
+ cwd = Path(cwd).resolve()
573
+ session_dir = Path(session_dir).resolve() if session_dir else get_default_session_dir(cwd)
574
+ most_recent = find_most_recent_session(session_dir)
575
+ if most_recent:
576
+ return SessionManager(cwd, session_dir, most_recent)
577
+ return SessionManager(cwd, session_dir)
578
+
579
+ @staticmethod
580
+ def in_memory(cwd: Path | None = None) -> SessionManager:
581
+ """Create an in-memory session that is not persisted to disk."""
582
+ cwd = cwd or Path.cwd()
583
+ return SessionManager(cwd, None, None, False)
584
+
585
+ @staticmethod
586
+ def fork_from(
587
+ source: Path | str,
588
+ target_cwd: Path | str,
589
+ session_dir: Path | str | None = None,
590
+ ) -> SessionManager:
591
+ """Create a new session forking from an existing session file."""
592
+ source = Path(source).resolve()
593
+ target_cwd = Path(target_cwd).resolve()
594
+ source_entries = read_session_file(source)
595
+
596
+ if not source_entries:
597
+ raise ValueError(f"Cannot fork: source session file is empty or invalid: {source}")
598
+ if not isinstance(source_entries[0], SessionHeader):
599
+ raise ValueError(f"Cannot fork: source session has no header: {source}")
600
+
601
+ session_dir = Path(session_dir).resolve() if session_dir else get_default_session_dir(target_cwd)
602
+ session_dir.mkdir(parents=True, exist_ok=True)
603
+
604
+ new_session_id = create_session_id()
605
+ file_timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S-%f")
606
+ new_session_file = session_dir / f"{file_timestamp}_{new_session_id}.jsonl"
607
+
608
+ new_header = SessionHeader(
609
+ id=new_session_id,
610
+ timestamp=generate_timestamp(),
611
+ cwd=target_cwd,
612
+ parent_session=source,
613
+ )
614
+
615
+ with new_session_file.open("w", encoding="utf-8") as f:
616
+ f.write(new_header.model_dump_json() + "\n")
617
+ for entry in source_entries:
618
+ if isinstance(entry, SessionHeader):
619
+ continue
620
+ f.write(entry.model_dump_json() + "\n")
621
+
622
+ return SessionManager(target_cwd, session_dir, new_session_file)
623
+
624
+ @staticmethod
625
+ def list(
626
+ cwd: Path | str,
627
+ session_dir: Path | str | None = None,
628
+ on_progress: Callable[[int, int], None] | None = None,
629
+ ) -> list[SessionInfo]:
630
+ cwd = Path(cwd).resolve()
631
+ session_dir = Path(session_dir).resolve() if session_dir else get_default_session_dir(cwd)
632
+ sessions = list_sessions_from_dir(session_dir, on_progress=on_progress)
633
+ sessions.sort(key=lambda s: s.modified.timestamp(), reverse=True)
634
+ return sessions
635
+
636
+ @staticmethod
637
+ def list_all(on_progress: Callable[[int, int], None] | None = None) -> List[SessionInfo]:
638
+ sessions_dir = get_sessions_dir()
639
+ if not sessions_dir.exists():
640
+ return []
641
+
642
+ sessions: list[SessionInfo] = []
643
+ try:
644
+ for cwd_dir in sessions_dir.iterdir():
645
+ if cwd_dir.is_dir():
646
+ dir_sessions = list_sessions_from_dir(cwd_dir, on_progress=on_progress)
647
+ sessions.extend(dir_sessions)
648
+ except Exception:
649
+ pass
650
+
651
+ sessions.sort(key=lambda s: s.modified.timestamp(), reverse=True)
652
+ return sessions