klaude-code 1.2.6__py3-none-any.whl → 1.8.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 (205) hide show
  1. klaude_code/auth/__init__.py +24 -0
  2. klaude_code/auth/codex/__init__.py +20 -0
  3. klaude_code/auth/codex/exceptions.py +17 -0
  4. klaude_code/auth/codex/jwt_utils.py +45 -0
  5. klaude_code/auth/codex/oauth.py +229 -0
  6. klaude_code/auth/codex/token_manager.py +84 -0
  7. klaude_code/cli/auth_cmd.py +73 -0
  8. klaude_code/cli/config_cmd.py +91 -0
  9. klaude_code/cli/cost_cmd.py +338 -0
  10. klaude_code/cli/debug.py +78 -0
  11. klaude_code/cli/list_model.py +307 -0
  12. klaude_code/cli/main.py +233 -134
  13. klaude_code/cli/runtime.py +309 -117
  14. klaude_code/{version.py → cli/self_update.py} +114 -5
  15. klaude_code/cli/session_cmd.py +37 -21
  16. klaude_code/command/__init__.py +88 -27
  17. klaude_code/command/clear_cmd.py +8 -7
  18. klaude_code/command/command_abc.py +31 -31
  19. klaude_code/command/debug_cmd.py +79 -0
  20. klaude_code/command/export_cmd.py +19 -53
  21. klaude_code/command/export_online_cmd.py +154 -0
  22. klaude_code/command/fork_session_cmd.py +267 -0
  23. klaude_code/command/help_cmd.py +7 -8
  24. klaude_code/command/model_cmd.py +60 -10
  25. klaude_code/command/model_select.py +84 -0
  26. klaude_code/command/prompt-jj-describe.md +32 -0
  27. klaude_code/command/prompt_command.py +19 -11
  28. klaude_code/command/refresh_cmd.py +8 -10
  29. klaude_code/command/registry.py +139 -40
  30. klaude_code/command/release_notes_cmd.py +84 -0
  31. klaude_code/command/resume_cmd.py +111 -0
  32. klaude_code/command/status_cmd.py +104 -60
  33. klaude_code/command/terminal_setup_cmd.py +7 -9
  34. klaude_code/command/thinking_cmd.py +98 -0
  35. klaude_code/config/__init__.py +14 -6
  36. klaude_code/config/assets/__init__.py +1 -0
  37. klaude_code/config/assets/builtin_config.yaml +303 -0
  38. klaude_code/config/builtin_config.py +38 -0
  39. klaude_code/config/config.py +378 -109
  40. klaude_code/config/select_model.py +117 -53
  41. klaude_code/config/thinking.py +269 -0
  42. klaude_code/{const/__init__.py → const.py} +50 -19
  43. klaude_code/core/agent.py +20 -28
  44. klaude_code/core/executor.py +327 -112
  45. klaude_code/core/manager/__init__.py +2 -4
  46. klaude_code/core/manager/llm_clients.py +1 -15
  47. klaude_code/core/manager/llm_clients_builder.py +10 -11
  48. klaude_code/core/manager/sub_agent_manager.py +37 -6
  49. klaude_code/core/prompt.py +63 -44
  50. klaude_code/core/prompts/prompt-claude-code.md +2 -13
  51. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
  52. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  53. klaude_code/core/prompts/prompt-codex.md +9 -42
  54. klaude_code/core/prompts/prompt-minimal.md +12 -0
  55. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
  56. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  57. klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
  58. klaude_code/core/reminders.py +283 -95
  59. klaude_code/core/task.py +113 -75
  60. klaude_code/core/tool/__init__.py +24 -31
  61. klaude_code/core/tool/file/_utils.py +36 -0
  62. klaude_code/core/tool/file/apply_patch.py +17 -25
  63. klaude_code/core/tool/file/apply_patch_tool.py +57 -77
  64. klaude_code/core/tool/file/diff_builder.py +151 -0
  65. klaude_code/core/tool/file/edit_tool.py +50 -63
  66. klaude_code/core/tool/file/move_tool.md +41 -0
  67. klaude_code/core/tool/file/move_tool.py +435 -0
  68. klaude_code/core/tool/file/read_tool.md +1 -1
  69. klaude_code/core/tool/file/read_tool.py +86 -86
  70. klaude_code/core/tool/file/write_tool.py +59 -69
  71. klaude_code/core/tool/report_back_tool.py +84 -0
  72. klaude_code/core/tool/shell/bash_tool.py +265 -22
  73. klaude_code/core/tool/shell/command_safety.py +3 -6
  74. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
  75. klaude_code/core/tool/sub_agent_tool.py +13 -2
  76. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  77. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  78. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  79. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  80. klaude_code/core/tool/tool_abc.py +18 -0
  81. klaude_code/core/tool/tool_context.py +27 -12
  82. klaude_code/core/tool/tool_registry.py +7 -7
  83. klaude_code/core/tool/tool_runner.py +44 -36
  84. klaude_code/core/tool/truncation.py +29 -14
  85. klaude_code/core/tool/web/mermaid_tool.md +43 -0
  86. klaude_code/core/tool/web/mermaid_tool.py +2 -5
  87. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  88. klaude_code/core/tool/web/web_fetch_tool.py +112 -22
  89. klaude_code/core/tool/web/web_search_tool.md +23 -0
  90. klaude_code/core/tool/web/web_search_tool.py +130 -0
  91. klaude_code/core/turn.py +168 -66
  92. klaude_code/llm/__init__.py +2 -10
  93. klaude_code/llm/anthropic/client.py +190 -178
  94. klaude_code/llm/anthropic/input.py +39 -15
  95. klaude_code/llm/bedrock/__init__.py +3 -0
  96. klaude_code/llm/bedrock/client.py +60 -0
  97. klaude_code/llm/client.py +7 -21
  98. klaude_code/llm/codex/__init__.py +5 -0
  99. klaude_code/llm/codex/client.py +149 -0
  100. klaude_code/llm/google/__init__.py +3 -0
  101. klaude_code/llm/google/client.py +309 -0
  102. klaude_code/llm/google/input.py +215 -0
  103. klaude_code/llm/input_common.py +3 -9
  104. klaude_code/llm/openai_compatible/client.py +72 -164
  105. klaude_code/llm/openai_compatible/input.py +6 -4
  106. klaude_code/llm/openai_compatible/stream.py +273 -0
  107. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  108. klaude_code/llm/openrouter/client.py +89 -160
  109. klaude_code/llm/openrouter/input.py +18 -30
  110. klaude_code/llm/openrouter/reasoning.py +118 -0
  111. klaude_code/llm/registry.py +39 -7
  112. klaude_code/llm/responses/client.py +184 -171
  113. klaude_code/llm/responses/input.py +20 -1
  114. klaude_code/llm/usage.py +17 -12
  115. klaude_code/protocol/commands.py +17 -1
  116. klaude_code/protocol/events.py +31 -4
  117. klaude_code/protocol/llm_param.py +13 -10
  118. klaude_code/protocol/model.py +232 -29
  119. klaude_code/protocol/op.py +90 -1
  120. klaude_code/protocol/op_handler.py +35 -1
  121. klaude_code/protocol/sub_agent/__init__.py +117 -0
  122. klaude_code/protocol/sub_agent/explore.py +63 -0
  123. klaude_code/protocol/sub_agent/oracle.py +91 -0
  124. klaude_code/protocol/sub_agent/task.py +61 -0
  125. klaude_code/protocol/sub_agent/web.py +79 -0
  126. klaude_code/protocol/tools.py +4 -2
  127. klaude_code/session/__init__.py +2 -2
  128. klaude_code/session/codec.py +71 -0
  129. klaude_code/session/export.py +293 -86
  130. klaude_code/session/selector.py +89 -67
  131. klaude_code/session/session.py +320 -309
  132. klaude_code/session/store.py +220 -0
  133. klaude_code/session/templates/export_session.html +595 -83
  134. klaude_code/session/templates/mermaid_viewer.html +926 -0
  135. klaude_code/skill/__init__.py +27 -0
  136. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  137. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  138. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  139. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  140. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  141. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
  142. klaude_code/skill/manager.py +70 -0
  143. klaude_code/skill/system_skills.py +192 -0
  144. klaude_code/trace/__init__.py +20 -2
  145. klaude_code/trace/log.py +150 -5
  146. klaude_code/ui/__init__.py +4 -9
  147. klaude_code/ui/core/input.py +1 -1
  148. klaude_code/ui/core/stage_manager.py +7 -7
  149. klaude_code/ui/modes/debug/display.py +2 -1
  150. klaude_code/ui/modes/repl/__init__.py +3 -48
  151. klaude_code/ui/modes/repl/clipboard.py +5 -5
  152. klaude_code/ui/modes/repl/completers.py +487 -123
  153. klaude_code/ui/modes/repl/display.py +5 -4
  154. klaude_code/ui/modes/repl/event_handler.py +370 -117
  155. klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
  156. klaude_code/ui/modes/repl/key_bindings.py +146 -23
  157. klaude_code/ui/modes/repl/renderer.py +189 -99
  158. klaude_code/ui/renderers/assistant.py +9 -2
  159. klaude_code/ui/renderers/bash_syntax.py +178 -0
  160. klaude_code/ui/renderers/common.py +78 -0
  161. klaude_code/ui/renderers/developer.py +104 -48
  162. klaude_code/ui/renderers/diffs.py +87 -6
  163. klaude_code/ui/renderers/errors.py +11 -6
  164. klaude_code/ui/renderers/mermaid_viewer.py +57 -0
  165. klaude_code/ui/renderers/metadata.py +112 -76
  166. klaude_code/ui/renderers/sub_agent.py +92 -7
  167. klaude_code/ui/renderers/thinking.py +40 -18
  168. klaude_code/ui/renderers/tools.py +405 -227
  169. klaude_code/ui/renderers/user_input.py +73 -13
  170. klaude_code/ui/rich/__init__.py +10 -1
  171. klaude_code/ui/rich/cjk_wrap.py +228 -0
  172. klaude_code/ui/rich/code_panel.py +131 -0
  173. klaude_code/ui/rich/live.py +17 -0
  174. klaude_code/ui/rich/markdown.py +305 -170
  175. klaude_code/ui/rich/searchable_text.py +10 -13
  176. klaude_code/ui/rich/status.py +190 -49
  177. klaude_code/ui/rich/theme.py +135 -39
  178. klaude_code/ui/terminal/__init__.py +55 -0
  179. klaude_code/ui/terminal/color.py +1 -1
  180. klaude_code/ui/terminal/control.py +13 -22
  181. klaude_code/ui/terminal/notifier.py +44 -4
  182. klaude_code/ui/terminal/selector.py +658 -0
  183. klaude_code/ui/utils/common.py +0 -18
  184. klaude_code-1.8.0.dist-info/METADATA +377 -0
  185. klaude_code-1.8.0.dist-info/RECORD +219 -0
  186. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
  187. klaude_code/command/diff_cmd.py +0 -138
  188. klaude_code/command/prompt-dev-docs-update.md +0 -56
  189. klaude_code/command/prompt-dev-docs.md +0 -46
  190. klaude_code/config/list_model.py +0 -162
  191. klaude_code/core/manager/agent_manager.py +0 -127
  192. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  193. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  194. klaude_code/core/tool/file/multi_edit_tool.py +0 -199
  195. klaude_code/core/tool/memory/memory_tool.md +0 -16
  196. klaude_code/core/tool/memory/memory_tool.py +0 -462
  197. klaude_code/llm/openrouter/reasoning_handler.py +0 -209
  198. klaude_code/protocol/sub_agent.py +0 -348
  199. klaude_code/ui/utils/debouncer.py +0 -42
  200. klaude_code-1.2.6.dist-info/METADATA +0 -178
  201. klaude_code-1.2.6.dist-info/RECORD +0 -167
  202. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  203. /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
  204. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  205. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
@@ -1,13 +1,48 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  import time
3
5
  import uuid
4
6
  from collections.abc import Iterable, Sequence
5
7
  from pathlib import Path
6
- from typing import ClassVar
8
+ from typing import Any, cast
9
+
10
+ from pydantic import BaseModel, Field, PrivateAttr, ValidationError
11
+
12
+ from klaude_code.protocol import events, llm_param, model, tools
13
+ from klaude_code.session.store import JsonlSessionStore, ProjectPaths, build_meta_snapshot
14
+
15
+ _DEFAULT_STORES: dict[str, JsonlSessionStore] = {}
16
+
17
+
18
+ def _project_key_from_cwd() -> str:
19
+ return str(Path.cwd()).strip("/").replace("/", "-")
7
20
 
8
- from pydantic import BaseModel, Field
9
21
 
10
- from klaude_code.protocol import events, model
22
+ def _read_json_dict(path: Path) -> dict[str, Any] | None:
23
+ try:
24
+ raw = json.loads(path.read_text(encoding="utf-8"))
25
+ except (json.JSONDecodeError, OSError):
26
+ return None
27
+ if not isinstance(raw, dict):
28
+ return None
29
+ return cast(dict[str, Any], raw)
30
+
31
+
32
+ def get_default_store() -> JsonlSessionStore:
33
+ project_key = _project_key_from_cwd()
34
+ store = _DEFAULT_STORES.get(project_key)
35
+ if store is None:
36
+ store = JsonlSessionStore(project_key=project_key)
37
+ _DEFAULT_STORES[project_key] = store
38
+ return store
39
+
40
+
41
+ async def close_default_store() -> None:
42
+ stores = list(_DEFAULT_STORES.values())
43
+ _DEFAULT_STORES.clear()
44
+ for store in stores:
45
+ await store.aclose()
11
46
 
12
47
 
13
48
  class Session(BaseModel):
@@ -15,264 +50,272 @@ class Session(BaseModel):
15
50
  work_dir: Path
16
51
  conversation_history: list[model.ConversationItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
17
52
  sub_agent_state: model.SubAgentState | None = None
18
- # FileTracker: track file path -> last modification time when last read/edited
19
- file_tracker: dict[str, float] = Field(default_factory=dict)
20
- # Todo list for the session
53
+ file_tracker: dict[str, model.FileStatus] = Field(default_factory=dict)
21
54
  todos: list[model.TodoItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
22
- # Messages count, redundant state for performance optimization to avoid reading entire jsonl file
23
- messages_count: int = Field(default=0)
24
- # Model name used for this session
25
- # Used in list method SessionMetaBrief
26
55
  model_name: str | None = None
27
- # Timestamps (epoch seconds)
56
+
57
+ model_config_name: str | None = None
58
+ model_thinking: llm_param.Thinking | None = None
28
59
  created_at: float = Field(default_factory=lambda: time.time())
29
60
  updated_at: float = Field(default_factory=lambda: time.time())
30
-
31
- # Reminder flags
32
- loaded_memory: list[str] = Field(default_factory=list)
33
61
  need_todo_empty_cooldown_counter: int = Field(exclude=True, default=0)
34
62
  need_todo_not_used_cooldown_counter: int = Field(exclude=True, default=0)
35
63
 
36
- # Internal: mapping for (de)serialization of conversation items
37
- _TypeMap: ClassVar[dict[str, type[BaseModel]]] = {
38
- # Messages
39
- "SystemMessageItem": model.SystemMessageItem,
40
- "DeveloperMessageItem": model.DeveloperMessageItem,
41
- "UserMessageItem": model.UserMessageItem,
42
- "AssistantMessageItem": model.AssistantMessageItem,
43
- # Reasoning/Thinking
44
- "ReasoningTextItem": model.ReasoningTextItem,
45
- "ReasoningEncryptedItem": model.ReasoningEncryptedItem,
46
- # Tools
47
- "ToolCallItem": model.ToolCallItem,
48
- "ToolResultItem": model.ToolResultItem,
49
- # Stream/meta (not typically persisted in history, but supported)
50
- "AssistantMessageDelta": model.AssistantMessageDelta,
51
- "StartItem": model.StartItem,
52
- "StreamErrorItem": model.StreamErrorItem,
53
- "ResponseMetadataItem": model.ResponseMetadataItem,
54
- "InterruptItem": model.InterruptItem,
55
- }
64
+ _messages_count_cache: int | None = PrivateAttr(default=None)
65
+ _user_messages_cache: list[str] | None = PrivateAttr(default=None)
66
+ _store: JsonlSessionStore = PrivateAttr(default_factory=get_default_store)
67
+
68
+ @property
69
+ def messages_count(self) -> int:
70
+ """Count of user, assistant messages, and tool calls in conversation history."""
71
+ if self._messages_count_cache is None:
72
+ self._messages_count_cache = sum(
73
+ 1
74
+ for it in self.conversation_history
75
+ if isinstance(it, (model.UserMessageItem, model.AssistantMessageItem, model.ToolCallItem))
76
+ )
77
+ return self._messages_count_cache
78
+
79
+ def _invalidate_messages_count_cache(self) -> None:
80
+ self._messages_count_cache = None
81
+
82
+ @property
83
+ def user_messages(self) -> list[str]:
84
+ """All user message contents in this session.
85
+
86
+ This is used for session selection UI and search, and is also persisted
87
+ in meta.json to avoid scanning events.jsonl for every session.
88
+ """
89
+
90
+ if self._user_messages_cache is None:
91
+ self._user_messages_cache = [
92
+ it.content for it in self.conversation_history if isinstance(it, model.UserMessageItem) and it.content
93
+ ]
94
+ return self._user_messages_cache
56
95
 
57
96
  @staticmethod
58
97
  def _project_key() -> str:
59
- # Derive a stable per-project key from current working directory
60
- return str(Path.cwd()).strip("/").replace("/", "-")
98
+ return _project_key_from_cwd()
61
99
 
62
100
  @classmethod
63
- def _base_dir(cls) -> Path:
64
- return Path.home() / ".klaude" / "projects" / cls._project_key()
101
+ def paths(cls) -> ProjectPaths:
102
+ return get_default_store().paths
65
103
 
66
104
  @classmethod
67
- def _sessions_dir(cls) -> Path:
68
- return cls._base_dir() / "sessions"
105
+ def exists(cls, id: str) -> bool:
106
+ """Return True if a persisted session exists for the current project."""
69
107
 
70
- @classmethod
71
- def _messages_dir(cls) -> Path:
72
- return cls._base_dir() / "messages"
108
+ paths = cls.paths()
109
+ return paths.meta_file(id).exists() or paths.events_file(id).exists()
73
110
 
74
111
  @classmethod
75
- def _exports_dir(cls) -> Path:
76
- return cls._base_dir() / "exports"
77
-
78
- def _session_file(self) -> Path:
79
- prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(self.created_at))
80
- return self._sessions_dir() / f"{prefix}-{self.id}.json"
81
-
82
- def _messages_file(self) -> Path:
83
- prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(self.created_at))
84
- return self._messages_dir() / f"{prefix}-{self.id}.jsonl"
112
+ def create(cls, id: str | None = None, *, work_dir: Path | None = None) -> Session:
113
+ session = Session(id=id or uuid.uuid4().hex, work_dir=work_dir or Path.cwd())
114
+ session._store = get_default_store()
115
+ return session
85
116
 
86
117
  @classmethod
87
- def load(cls, id: str) -> "Session":
88
- # Load session metadata
89
- sessions_dir = cls._sessions_dir()
90
- session_candidates = sorted(
91
- sessions_dir.glob(f"*-{id}.json"),
92
- key=lambda p: p.stat().st_mtime,
93
- reverse=True,
118
+ def load_meta(cls, id: str) -> Session:
119
+ store = get_default_store()
120
+ raw = store.load_meta(id)
121
+ if raw is None:
122
+ session = Session(id=id, work_dir=Path.cwd())
123
+ session._store = store
124
+ return session
125
+
126
+ work_dir_str = raw.get("work_dir")
127
+ if not isinstance(work_dir_str, str) or not work_dir_str:
128
+ work_dir_str = str(Path.cwd())
129
+
130
+ sub_agent_state_raw = raw.get("sub_agent_state")
131
+ sub_agent_state = (
132
+ model.SubAgentState.model_validate(sub_agent_state_raw) if isinstance(sub_agent_state_raw, dict) else None
94
133
  )
95
- if not session_candidates:
96
- # No existing session; create a new one
97
- return Session(id=id, work_dir=Path.cwd())
98
- session_path = session_candidates[0]
99
134
 
100
- raw = json.loads(session_path.read_text())
135
+ file_tracker_raw = raw.get("file_tracker")
136
+ file_tracker: dict[str, model.FileStatus] = {}
137
+ if isinstance(file_tracker_raw, dict):
138
+ for k, v in cast(dict[object, object], file_tracker_raw).items():
139
+ if isinstance(k, str) and isinstance(v, dict):
140
+ try:
141
+ file_tracker[k] = model.FileStatus.model_validate(v)
142
+ except ValidationError:
143
+ continue
101
144
 
102
- # Basic fields (conversation history is loaded separately)
103
- work_dir_str = raw.get("work_dir", str(Path.cwd()))
145
+ todos_raw = raw.get("todos")
146
+ todos: list[model.TodoItem] = []
147
+ if isinstance(todos_raw, list):
148
+ for todo_raw in cast(list[object], todos_raw):
149
+ if not isinstance(todo_raw, dict):
150
+ continue
151
+ try:
152
+ todos.append(model.TodoItem.model_validate(todo_raw))
153
+ except ValidationError:
154
+ continue
104
155
 
105
- sub_agent_state_raw = raw.get("sub_agent_state")
106
- sub_agent_state = model.SubAgentState(**sub_agent_state_raw) if sub_agent_state_raw else None
107
- file_tracker = dict(raw.get("file_tracker", {}))
108
- todos: list[model.TodoItem] = [model.TodoItem(**item) for item in raw.get("todos", [])]
109
- loaded_memory = list(raw.get("loaded_memory", []))
110
156
  created_at = float(raw.get("created_at", time.time()))
111
157
  updated_at = float(raw.get("updated_at", created_at))
112
- messages_count = int(raw.get("messages_count", 0))
113
- model_name = raw.get("model_name")
158
+ model_name = raw.get("model_name") if isinstance(raw.get("model_name"), str) else None
159
+ model_config_name = raw.get("model_config_name") if isinstance(raw.get("model_config_name"), str) else None
114
160
 
115
- sess = Session(
161
+ model_thinking_raw = raw.get("model_thinking")
162
+ model_thinking = (
163
+ llm_param.Thinking.model_validate(model_thinking_raw) if isinstance(model_thinking_raw, dict) else None
164
+ )
165
+
166
+ session = Session(
116
167
  id=id,
117
168
  work_dir=Path(work_dir_str),
118
169
  sub_agent_state=sub_agent_state,
119
170
  file_tracker=file_tracker,
120
171
  todos=todos,
121
- loaded_memory=loaded_memory,
122
172
  created_at=created_at,
123
173
  updated_at=updated_at,
124
- messages_count=messages_count,
125
174
  model_name=model_name,
175
+ model_config_name=model_config_name,
176
+ model_thinking=model_thinking,
126
177
  )
178
+ session._store = store
179
+ return session
127
180
 
128
- # Load conversation history from messages JSONL
129
- messages_dir = cls._messages_dir()
130
- # Expect a single messages file per session (prefixed filenames only)
131
- msg_candidates = sorted(
132
- messages_dir.glob(f"*-{id}.jsonl"),
133
- key=lambda p: p.stat().st_mtime,
134
- reverse=True,
135
- )
136
- if msg_candidates:
137
- messages_path = msg_candidates[0]
138
- history: list[model.ConversationItem] = []
139
- for line in messages_path.read_text().splitlines():
140
- line = line.strip()
141
- if not line:
142
- continue
143
- try:
144
- obj = json.loads(line)
145
- t = obj.get("type")
146
- data = obj.get("data", {})
147
- cls_type = cls._TypeMap.get(t or "")
148
- if cls_type is None:
149
- continue
150
- item = cls_type(**data)
151
- # pyright: ignore[reportAssignmentType]
152
- history.append(item) # type: ignore[arg-type]
153
- except Exception:
154
- # Best-effort load; skip malformed lines
155
- continue
156
- sess.conversation_history = history
157
- # Update messages count based on loaded history (only UserMessageItem and AssistantMessageItem)
158
- sess.messages_count = sum(
159
- 1 for it in history if isinstance(it, (model.UserMessageItem, model.AssistantMessageItem))
160
- )
181
+ @classmethod
182
+ def load(cls, id: str) -> Session:
183
+ store = get_default_store()
184
+ session = cls.load_meta(id)
185
+ session._store = store
186
+ session.conversation_history = store.load_history(id)
187
+ return session
161
188
 
162
- return sess
189
+ def append_history(self, items: Sequence[model.ConversationItem]) -> None:
190
+ if not items:
191
+ return
163
192
 
164
- def save(self):
165
- # Ensure directories exist
166
- sessions_dir = self._sessions_dir()
167
- messages_dir = self._messages_dir()
168
- sessions_dir.mkdir(parents=True, exist_ok=True)
169
- messages_dir.mkdir(parents=True, exist_ok=True)
193
+ self.conversation_history.extend(items)
194
+ self._invalidate_messages_count_cache()
195
+
196
+ new_user_messages = [
197
+ it.content for it in items if isinstance(it, model.UserMessageItem) and it.content
198
+ ]
199
+ if new_user_messages:
200
+ if self._user_messages_cache is None:
201
+ # Build from full history once to ensure correctness when resuming older sessions.
202
+ self._user_messages_cache = [
203
+ it.content for it in self.conversation_history if isinstance(it, model.UserMessageItem) and it.content
204
+ ]
205
+ else:
206
+ self._user_messages_cache.extend(new_user_messages)
170
207
 
171
- # Persist session metadata (excluding conversation history)
172
- # Update timestamps
173
208
  if self.created_at <= 0:
174
209
  self.created_at = time.time()
175
210
  self.updated_at = time.time()
176
- payload = {
177
- "id": self.id,
178
- "work_dir": str(self.work_dir),
179
- "sub_agent_state": self.sub_agent_state.model_dump() if self.sub_agent_state else None,
180
- "file_tracker": self.file_tracker,
181
- "todos": [todo.model_dump() for todo in self.todos],
182
- "loaded_memory": self.loaded_memory,
183
- "created_at": self.created_at,
184
- "updated_at": self.updated_at,
185
- "messages_count": self.messages_count,
186
- "model_name": self.model_name,
187
- }
188
- self._session_file().write_text(json.dumps(payload, ensure_ascii=False, indent=2))
189
-
190
- def append_history(self, items: Sequence[model.ConversationItem]):
191
- # Append to in-memory history
192
- self.conversation_history.extend(items)
193
- # Update messages count (only UserMessageItem and AssistantMessageItem)
194
- self.messages_count += sum(
195
- 1 for it in items if isinstance(it, (model.UserMessageItem, model.AssistantMessageItem))
211
+
212
+ meta = build_meta_snapshot(
213
+ session_id=self.id,
214
+ work_dir=self.work_dir,
215
+ sub_agent_state=self.sub_agent_state,
216
+ file_tracker=self.file_tracker,
217
+ todos=list(self.todos),
218
+ user_messages=self.user_messages,
219
+ created_at=self.created_at,
220
+ updated_at=self.updated_at,
221
+ messages_count=self.messages_count,
222
+ model_name=self.model_name,
223
+ model_config_name=self.model_config_name,
224
+ model_thinking=self.model_thinking,
225
+ )
226
+ self._store.append_and_flush(session_id=self.id, items=items, meta=meta)
227
+
228
+ def fork(self, *, new_id: str | None = None, until_index: int | None = None) -> Session:
229
+ """Create a new session as a fork of the current session.
230
+
231
+ The forked session copies metadata and conversation history, but does not
232
+ modify the current session.
233
+
234
+ Args:
235
+ new_id: Optional ID for the forked session.
236
+ until_index: If provided, only copy conversation history up to (but not including) this index.
237
+ If None, copy all history.
238
+ """
239
+
240
+ forked = Session.create(id=new_id, work_dir=self.work_dir)
241
+
242
+ forked.sub_agent_state = None
243
+ forked.model_name = self.model_name
244
+ forked.model_config_name = self.model_config_name
245
+ forked.model_thinking = self.model_thinking.model_copy(deep=True) if self.model_thinking is not None else None
246
+ forked.file_tracker = {k: v.model_copy(deep=True) for k, v in self.file_tracker.items()}
247
+ forked.todos = [todo.model_copy(deep=True) for todo in self.todos]
248
+
249
+ history_to_copy = (
250
+ self.conversation_history[:until_index] if until_index is not None else self.conversation_history
196
251
  )
252
+ items = [it.model_copy(deep=True) for it in history_to_copy]
253
+ if items:
254
+ forked.append_history(items)
255
+
256
+ return forked
197
257
 
198
- # Incrementally persist to JSONL under messages directory
199
- messages_dir = self._messages_dir()
200
- messages_dir.mkdir(parents=True, exist_ok=True)
201
- mpath = self._messages_file()
202
-
203
- with mpath.open("a", encoding="utf-8") as f:
204
- for it in items:
205
- # Serialize with explicit type tag for reliable load
206
- t = it.__class__.__name__
207
- data = it.model_dump(mode="json")
208
- f.write(json.dumps({"type": t, "data": data}, ensure_ascii=False))
209
- f.write("\n")
210
- # Refresh metadata timestamp after history change
211
- self.save()
258
+ async def wait_for_flush(self) -> None:
259
+ await self._store.wait_for_flush(self.id)
212
260
 
213
261
  @classmethod
214
262
  def most_recent_session_id(cls) -> str | None:
215
- sessions_dir = cls._sessions_dir()
216
- if not sessions_dir.exists():
217
- return None
263
+ store = get_default_store()
218
264
  latest_id: str | None = None
219
265
  latest_ts: float = -1.0
220
- for p in sessions_dir.glob("*.json"):
266
+ for meta_path in store.iter_meta_files():
267
+ data = _read_json_dict(meta_path)
268
+ if data is None:
269
+ continue
270
+ if data.get("sub_agent_state") is not None:
271
+ continue
272
+ sid = str(data.get("id", meta_path.parent.name))
221
273
  try:
222
- data = json.loads(p.read_text())
223
- # Filter out sub-agent sessions
224
- if data.get("sub_agent_state", None) is not None:
225
- continue
226
- sid = str(data.get("id", p.stem))
227
274
  ts = float(data.get("updated_at", 0.0))
228
- if ts <= 0:
229
- ts = p.stat().st_mtime
230
- if ts > latest_ts:
231
- latest_ts = ts
232
- latest_id = sid
233
- except Exception:
234
- continue
275
+ except (TypeError, ValueError):
276
+ ts = meta_path.stat().st_mtime
277
+ if ts > latest_ts:
278
+ latest_ts = ts
279
+ latest_id = sid
235
280
  return latest_id
236
281
 
237
282
  def need_turn_start(self, prev_item: model.ConversationItem | None, item: model.ConversationItem) -> bool:
238
- # Emit TurnStartEvent when a new turn starts to show an empty line in replay history
239
283
  if not isinstance(
240
284
  item,
241
- model.ReasoningEncryptedItem | model.ReasoningTextItem | model.AssistantMessageItem | model.ToolCallItem,
285
+ model.ReasoningTextItem | model.AssistantMessageItem | model.ToolCallItem,
242
286
  ):
243
287
  return False
244
288
  if prev_item is None:
245
289
  return True
246
- if isinstance(
247
- prev_item,
248
- model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem,
249
- ):
250
- return True
251
- return False
290
+ return isinstance(prev_item, model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem)
252
291
 
253
292
  def get_history_item(self) -> Iterable[events.HistoryItemEvent]:
293
+ seen_sub_agent_sessions: set[str] = set()
254
294
  prev_item: model.ConversationItem | None = None
295
+ last_assistant_content: str = ""
296
+ report_back_result: str | None = None
297
+ yield events.TaskStartEvent(session_id=self.id, sub_agent_state=self.sub_agent_state)
255
298
  for it in self.conversation_history:
256
299
  if self.need_turn_start(prev_item, it):
257
- yield events.TurnStartEvent(
258
- session_id=self.id,
259
- )
300
+ yield events.TurnStartEvent(session_id=self.id)
260
301
  match it:
261
302
  case model.AssistantMessageItem() as am:
262
303
  content = am.content or ""
304
+ last_assistant_content = content
263
305
  yield events.AssistantMessageEvent(
264
306
  content=content,
265
307
  response_id=am.response_id,
266
308
  session_id=self.id,
267
309
  )
268
310
  case model.ToolCallItem() as tc:
311
+ if tc.name == tools.REPORT_BACK:
312
+ report_back_result = tc.arguments
269
313
  yield events.ToolCallEvent(
270
314
  tool_call_id=tc.call_id,
271
315
  tool_name=tc.name,
272
316
  arguments=tc.arguments,
273
317
  response_id=tc.response_id,
274
318
  session_id=self.id,
275
- is_replay=True,
276
319
  )
277
320
  case model.ToolResultItem() as tr:
278
321
  yield events.ToolResultEvent(
@@ -282,129 +325,121 @@ class Session(BaseModel):
282
325
  ui_extra=tr.ui_extra,
283
326
  session_id=self.id,
284
327
  status=tr.status,
285
- is_replay=True,
328
+ task_metadata=tr.task_metadata,
286
329
  )
287
- # TODO: Replay Sub-Agent Events
330
+ yield from self._iter_sub_agent_history(tr, seen_sub_agent_sessions)
288
331
  case model.UserMessageItem() as um:
289
- yield events.UserMessageEvent(
290
- content=um.content or "",
291
- session_id=self.id,
292
- )
332
+ yield events.UserMessageEvent(content=um.content or "", session_id=self.id, images=um.images)
293
333
  case model.ReasoningTextItem() as ri:
294
- yield events.ThinkingEvent(
295
- content=ri.content,
296
- session_id=self.id,
297
- )
298
- case model.ResponseMetadataItem() as mt:
299
- yield events.ResponseMetadataEvent(
300
- session_id=self.id,
301
- metadata=mt,
302
- )
334
+ yield events.ThinkingEvent(content=ri.content, session_id=self.id)
335
+ case model.TaskMetadataItem() as mt:
336
+ yield events.TaskMetadataEvent(session_id=self.id, metadata=mt)
303
337
  case model.InterruptItem():
304
- yield events.InterruptEvent(
305
- session_id=self.id,
306
- )
338
+ yield events.InterruptEvent(session_id=self.id)
307
339
  case model.DeveloperMessageItem() as dm:
308
- yield events.DeveloperMessageEvent(
309
- session_id=self.id,
310
- item=dm,
311
- )
340
+ yield events.DeveloperMessageEvent(session_id=self.id, item=dm)
341
+ case model.StreamErrorItem() as se:
342
+ yield events.ErrorEvent(error_message=se.error, can_retry=False, session_id=self.id)
312
343
  case _:
313
344
  continue
314
345
  prev_item = it
315
346
 
347
+ has_structured_output = report_back_result is not None
348
+ task_result = report_back_result if has_structured_output else last_assistant_content
349
+ yield events.TaskFinishEvent(
350
+ session_id=self.id, task_result=task_result, has_structured_output=has_structured_output
351
+ )
352
+
353
+ def _iter_sub_agent_history(
354
+ self, tool_result: model.ToolResultItem, seen_sub_agent_sessions: set[str]
355
+ ) -> Iterable[events.HistoryItemEvent]:
356
+ ui_extra = tool_result.ui_extra
357
+ if not isinstance(ui_extra, model.SessionIdUIExtra):
358
+ return
359
+ session_id = ui_extra.session_id
360
+ if not session_id or session_id == self.id:
361
+ return
362
+ if session_id in seen_sub_agent_sessions:
363
+ return
364
+ seen_sub_agent_sessions.add(session_id)
365
+ try:
366
+ sub_session = Session.load(session_id)
367
+ except (OSError, json.JSONDecodeError, ValueError):
368
+ return
369
+ yield from sub_session.get_history_item()
370
+
316
371
  class SessionMetaBrief(BaseModel):
317
372
  id: str
318
373
  created_at: float
319
374
  updated_at: float
320
375
  work_dir: str
321
376
  path: str
322
- first_user_message: str | None = None
323
- messages_count: int = -1 # -1 indicates N/A
377
+ user_messages: list[str] = []
378
+ messages_count: int = -1
324
379
  model_name: str | None = None
325
380
 
326
381
  @classmethod
327
382
  def list_sessions(cls) -> list[SessionMetaBrief]:
328
- """List all sessions for the current project.
329
-
330
- Returns a list of dicts with keys: id, created_at, updated_at, work_dir, path.
331
- Sorted by updated_at descending.
332
- """
333
- sessions_dir = cls._sessions_dir()
334
- if not sessions_dir.exists():
335
- return []
336
-
337
- def _get_first_user_message(session_id: str, created_at: float) -> str | None:
338
- """Get the first user message from the session's jsonl file."""
339
- messages_dir = cls._messages_dir()
340
- if not messages_dir.exists():
341
- return None
342
-
343
- # Find the messages file for this session
344
- prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(created_at))
345
- msg_file = messages_dir / f"{prefix}-{session_id}.jsonl"
346
-
347
- if not msg_file.exists():
348
- # Try to find by pattern if exact file doesn't exist
349
- msg_candidates = sorted(
350
- messages_dir.glob(f"*-{session_id}.jsonl"),
351
- key=lambda p: p.stat().st_mtime,
352
- reverse=True,
353
- )
354
- if not msg_candidates:
355
- return None
356
- msg_file = msg_candidates[0]
383
+ store = get_default_store()
357
384
 
385
+ def _get_user_messages(session_id: str) -> list[str]:
386
+ events_path = store.paths.events_file(session_id)
387
+ if not events_path.exists():
388
+ return []
389
+ messages: list[str] = []
358
390
  try:
359
- for line in msg_file.read_text().splitlines():
360
- line = line.strip()
361
- if not line:
391
+ for line in events_path.read_text(encoding="utf-8").splitlines():
392
+ obj_raw = json.loads(line)
393
+ if not isinstance(obj_raw, dict):
362
394
  continue
363
- obj = json.loads(line)
364
- if obj.get("type") == "UserMessageItem":
365
- data = obj.get("data", {})
366
- content = data.get("content", "")
367
- if isinstance(content, str):
368
- return content
369
- elif isinstance(content, list) and content:
370
- # Handle structured content - extract text
371
- text_parts: list[str] = []
372
- for part in content: # pyright: ignore[reportUnknownVariableType]
373
- if (
374
- isinstance(part, dict) and part.get("type") == "text" # pyright: ignore[reportUnknownMemberType]
375
- ):
376
- text = part.get("text", "") # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
377
- if isinstance(text, str):
378
- text_parts.append(text)
379
- return " ".join(text_parts) if text_parts else None
380
- return None
381
- except Exception:
382
- return None
383
- return None
395
+ obj = cast(dict[str, Any], obj_raw)
396
+ if obj.get("type") != "UserMessageItem":
397
+ continue
398
+ data_raw = obj.get("data")
399
+ if not isinstance(data_raw, dict):
400
+ continue
401
+ data = cast(dict[str, Any], data_raw)
402
+ content = data.get("content")
403
+ if isinstance(content, str):
404
+ messages.append(content)
405
+ except (OSError, json.JSONDecodeError):
406
+ pass
407
+ return messages
408
+
409
+ def _maybe_backfill_user_messages(*, meta_path: Path, meta: dict[str, Any], user_messages: list[str]) -> None:
410
+ if isinstance(meta.get("user_messages"), list):
411
+ return
412
+ meta["user_messages"] = user_messages
413
+ try:
414
+ tmp_path = meta_path.with_suffix(".json.tmp")
415
+ tmp_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
416
+ tmp_path.replace(meta_path)
417
+ except OSError:
418
+ return
384
419
 
385
420
  items: list[Session.SessionMetaBrief] = []
386
- for p in sessions_dir.glob("*.json"):
387
- try:
388
- data = json.loads(p.read_text())
389
- except Exception:
390
- # Skip unreadable files
421
+ for meta_path in store.iter_meta_files():
422
+ data = _read_json_dict(meta_path)
423
+ if data is None:
391
424
  continue
392
- # Filter out sub-agent sessions
393
- if data.get("sub_agent_state", None) is not None:
425
+ if data.get("sub_agent_state") is not None:
394
426
  continue
395
- sid = str(data.get("id", p.stem))
396
- created = float(data.get("created_at", p.stat().st_mtime))
397
- updated = float(data.get("updated_at", p.stat().st_mtime))
398
- work_dir = str(data.get("work_dir", ""))
399
427
 
400
- # Get first user message
401
- first_user_message = _get_first_user_message(sid, created)
402
-
403
- # Get messages count from session data, no fallback
404
- messages_count = int(data.get("messages_count", -1)) # -1 indicates N/A
428
+ sid = str(data.get("id", meta_path.parent.name))
429
+ created = float(data.get("created_at", meta_path.stat().st_mtime))
430
+ updated = float(data.get("updated_at", meta_path.stat().st_mtime))
431
+ work_dir = str(data.get("work_dir", ""))
405
432
 
406
- # Get model name from session data
407
- model_name = data.get("model_name")
433
+ user_messages_raw = data.get("user_messages")
434
+ if isinstance(user_messages_raw, list) and all(
435
+ isinstance(m, str) for m in cast(list[object], user_messages_raw)
436
+ ):
437
+ user_messages = cast(list[str], user_messages_raw)
438
+ else:
439
+ user_messages = _get_user_messages(sid)
440
+ _maybe_backfill_user_messages(meta_path=meta_path, meta=data, user_messages=user_messages)
441
+ messages_count = int(data.get("messages_count", -1))
442
+ model_name = data.get("model_name") if isinstance(data.get("model_name"), str) else None
408
443
 
409
444
  items.append(
410
445
  Session.SessionMetaBrief(
@@ -412,63 +447,39 @@ class Session(BaseModel):
412
447
  created_at=created,
413
448
  updated_at=updated,
414
449
  work_dir=work_dir,
415
- path=str(p),
416
- first_user_message=first_user_message,
450
+ path=str(meta_path),
451
+ user_messages=user_messages,
417
452
  messages_count=messages_count,
418
453
  model_name=model_name,
419
454
  )
420
455
  )
421
- # Sort by updated_at desc
456
+
422
457
  items.sort(key=lambda d: d.updated_at, reverse=True)
423
458
  return items
424
459
 
425
460
  @classmethod
426
461
  def clean_small_sessions(cls, min_messages: int = 5) -> int:
427
- """Remove sessions with fewer than min_messages messages.
428
-
429
- Returns the number of sessions deleted.
430
- """
431
462
  sessions = cls.list_sessions()
432
463
  deleted_count = 0
433
-
464
+ store = get_default_store()
434
465
  for session_meta in sessions:
435
- # Skip sessions with unknown message count
436
466
  if session_meta.messages_count < 0:
437
467
  continue
438
468
  if session_meta.messages_count < min_messages:
439
- cls._delete_session_files(session_meta.id, session_meta.created_at)
469
+ store.delete_session(session_meta.id)
440
470
  deleted_count += 1
441
-
442
471
  return deleted_count
443
472
 
444
473
  @classmethod
445
474
  def clean_all_sessions(cls) -> int:
446
- """Remove all sessions for the current project.
447
-
448
- Returns the number of sessions deleted.
449
- """
450
475
  sessions = cls.list_sessions()
451
476
  deleted_count = 0
452
-
477
+ store = get_default_store()
453
478
  for session_meta in sessions:
454
- cls._delete_session_files(session_meta.id, session_meta.created_at)
479
+ store.delete_session(session_meta.id)
455
480
  deleted_count += 1
456
-
457
481
  return deleted_count
458
482
 
459
483
  @classmethod
460
- def _delete_session_files(cls, session_id: str, created_at: float) -> None:
461
- """Delete session and messages files for a given session."""
462
- sessions_dir = cls._sessions_dir()
463
- messages_dir = cls._messages_dir()
464
-
465
- # Delete session file
466
- prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(created_at))
467
- session_file = sessions_dir / f"{prefix}-{session_id}.json"
468
- if session_file.exists():
469
- session_file.unlink()
470
-
471
- # Delete messages file
472
- messages_file = messages_dir / f"{prefix}-{session_id}.jsonl"
473
- if messages_file.exists():
474
- messages_file.unlink()
484
+ def exports_dir(cls) -> Path:
485
+ return get_default_store().paths.exports_dir