klaude-code 1.2.6__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 (167) hide show
  1. klaude_code/__init__.py +0 -0
  2. klaude_code/cli/__init__.py +1 -0
  3. klaude_code/cli/main.py +298 -0
  4. klaude_code/cli/runtime.py +331 -0
  5. klaude_code/cli/session_cmd.py +80 -0
  6. klaude_code/command/__init__.py +43 -0
  7. klaude_code/command/clear_cmd.py +20 -0
  8. klaude_code/command/command_abc.py +92 -0
  9. klaude_code/command/diff_cmd.py +138 -0
  10. klaude_code/command/export_cmd.py +86 -0
  11. klaude_code/command/help_cmd.py +51 -0
  12. klaude_code/command/model_cmd.py +43 -0
  13. klaude_code/command/prompt-dev-docs-update.md +56 -0
  14. klaude_code/command/prompt-dev-docs.md +46 -0
  15. klaude_code/command/prompt-init.md +45 -0
  16. klaude_code/command/prompt_command.py +69 -0
  17. klaude_code/command/refresh_cmd.py +43 -0
  18. klaude_code/command/registry.py +110 -0
  19. klaude_code/command/status_cmd.py +111 -0
  20. klaude_code/command/terminal_setup_cmd.py +252 -0
  21. klaude_code/config/__init__.py +11 -0
  22. klaude_code/config/config.py +177 -0
  23. klaude_code/config/list_model.py +162 -0
  24. klaude_code/config/select_model.py +67 -0
  25. klaude_code/const/__init__.py +133 -0
  26. klaude_code/core/__init__.py +0 -0
  27. klaude_code/core/agent.py +165 -0
  28. klaude_code/core/executor.py +485 -0
  29. klaude_code/core/manager/__init__.py +19 -0
  30. klaude_code/core/manager/agent_manager.py +127 -0
  31. klaude_code/core/manager/llm_clients.py +42 -0
  32. klaude_code/core/manager/llm_clients_builder.py +49 -0
  33. klaude_code/core/manager/sub_agent_manager.py +86 -0
  34. klaude_code/core/prompt.py +89 -0
  35. klaude_code/core/prompts/prompt-claude-code.md +98 -0
  36. klaude_code/core/prompts/prompt-codex.md +331 -0
  37. klaude_code/core/prompts/prompt-gemini.md +43 -0
  38. klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
  39. klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
  40. klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
  41. klaude_code/core/prompts/prompt-subagent.md +8 -0
  42. klaude_code/core/reminders.py +445 -0
  43. klaude_code/core/task.py +237 -0
  44. klaude_code/core/tool/__init__.py +75 -0
  45. klaude_code/core/tool/file/__init__.py +0 -0
  46. klaude_code/core/tool/file/apply_patch.py +492 -0
  47. klaude_code/core/tool/file/apply_patch_tool.md +1 -0
  48. klaude_code/core/tool/file/apply_patch_tool.py +204 -0
  49. klaude_code/core/tool/file/edit_tool.md +9 -0
  50. klaude_code/core/tool/file/edit_tool.py +274 -0
  51. klaude_code/core/tool/file/multi_edit_tool.md +42 -0
  52. klaude_code/core/tool/file/multi_edit_tool.py +199 -0
  53. klaude_code/core/tool/file/read_tool.md +14 -0
  54. klaude_code/core/tool/file/read_tool.py +326 -0
  55. klaude_code/core/tool/file/write_tool.md +8 -0
  56. klaude_code/core/tool/file/write_tool.py +146 -0
  57. klaude_code/core/tool/memory/__init__.py +0 -0
  58. klaude_code/core/tool/memory/memory_tool.md +16 -0
  59. klaude_code/core/tool/memory/memory_tool.py +462 -0
  60. klaude_code/core/tool/memory/skill_loader.py +245 -0
  61. klaude_code/core/tool/memory/skill_tool.md +24 -0
  62. klaude_code/core/tool/memory/skill_tool.py +97 -0
  63. klaude_code/core/tool/shell/__init__.py +0 -0
  64. klaude_code/core/tool/shell/bash_tool.md +43 -0
  65. klaude_code/core/tool/shell/bash_tool.py +123 -0
  66. klaude_code/core/tool/shell/command_safety.py +363 -0
  67. klaude_code/core/tool/sub_agent_tool.py +83 -0
  68. klaude_code/core/tool/todo/__init__.py +0 -0
  69. klaude_code/core/tool/todo/todo_write_tool.md +182 -0
  70. klaude_code/core/tool/todo/todo_write_tool.py +121 -0
  71. klaude_code/core/tool/todo/update_plan_tool.md +3 -0
  72. klaude_code/core/tool/todo/update_plan_tool.py +104 -0
  73. klaude_code/core/tool/tool_abc.py +25 -0
  74. klaude_code/core/tool/tool_context.py +106 -0
  75. klaude_code/core/tool/tool_registry.py +78 -0
  76. klaude_code/core/tool/tool_runner.py +252 -0
  77. klaude_code/core/tool/truncation.py +170 -0
  78. klaude_code/core/tool/web/__init__.py +0 -0
  79. klaude_code/core/tool/web/mermaid_tool.md +21 -0
  80. klaude_code/core/tool/web/mermaid_tool.py +76 -0
  81. klaude_code/core/tool/web/web_fetch_tool.md +8 -0
  82. klaude_code/core/tool/web/web_fetch_tool.py +159 -0
  83. klaude_code/core/turn.py +220 -0
  84. klaude_code/llm/__init__.py +21 -0
  85. klaude_code/llm/anthropic/__init__.py +3 -0
  86. klaude_code/llm/anthropic/client.py +221 -0
  87. klaude_code/llm/anthropic/input.py +200 -0
  88. klaude_code/llm/client.py +49 -0
  89. klaude_code/llm/input_common.py +239 -0
  90. klaude_code/llm/openai_compatible/__init__.py +3 -0
  91. klaude_code/llm/openai_compatible/client.py +211 -0
  92. klaude_code/llm/openai_compatible/input.py +109 -0
  93. klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
  94. klaude_code/llm/openrouter/__init__.py +3 -0
  95. klaude_code/llm/openrouter/client.py +200 -0
  96. klaude_code/llm/openrouter/input.py +160 -0
  97. klaude_code/llm/openrouter/reasoning_handler.py +209 -0
  98. klaude_code/llm/registry.py +22 -0
  99. klaude_code/llm/responses/__init__.py +3 -0
  100. klaude_code/llm/responses/client.py +216 -0
  101. klaude_code/llm/responses/input.py +167 -0
  102. klaude_code/llm/usage.py +109 -0
  103. klaude_code/protocol/__init__.py +4 -0
  104. klaude_code/protocol/commands.py +21 -0
  105. klaude_code/protocol/events.py +163 -0
  106. klaude_code/protocol/llm_param.py +147 -0
  107. klaude_code/protocol/model.py +287 -0
  108. klaude_code/protocol/op.py +89 -0
  109. klaude_code/protocol/op_handler.py +28 -0
  110. klaude_code/protocol/sub_agent.py +348 -0
  111. klaude_code/protocol/tools.py +15 -0
  112. klaude_code/session/__init__.py +4 -0
  113. klaude_code/session/export.py +624 -0
  114. klaude_code/session/selector.py +76 -0
  115. klaude_code/session/session.py +474 -0
  116. klaude_code/session/templates/export_session.html +1434 -0
  117. klaude_code/trace/__init__.py +3 -0
  118. klaude_code/trace/log.py +168 -0
  119. klaude_code/ui/__init__.py +91 -0
  120. klaude_code/ui/core/__init__.py +1 -0
  121. klaude_code/ui/core/display.py +103 -0
  122. klaude_code/ui/core/input.py +71 -0
  123. klaude_code/ui/core/stage_manager.py +55 -0
  124. klaude_code/ui/modes/__init__.py +1 -0
  125. klaude_code/ui/modes/debug/__init__.py +1 -0
  126. klaude_code/ui/modes/debug/display.py +36 -0
  127. klaude_code/ui/modes/exec/__init__.py +1 -0
  128. klaude_code/ui/modes/exec/display.py +63 -0
  129. klaude_code/ui/modes/repl/__init__.py +51 -0
  130. klaude_code/ui/modes/repl/clipboard.py +152 -0
  131. klaude_code/ui/modes/repl/completers.py +429 -0
  132. klaude_code/ui/modes/repl/display.py +60 -0
  133. klaude_code/ui/modes/repl/event_handler.py +375 -0
  134. klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
  135. klaude_code/ui/modes/repl/key_bindings.py +170 -0
  136. klaude_code/ui/modes/repl/renderer.py +281 -0
  137. klaude_code/ui/renderers/__init__.py +0 -0
  138. klaude_code/ui/renderers/assistant.py +21 -0
  139. klaude_code/ui/renderers/common.py +8 -0
  140. klaude_code/ui/renderers/developer.py +158 -0
  141. klaude_code/ui/renderers/diffs.py +215 -0
  142. klaude_code/ui/renderers/errors.py +16 -0
  143. klaude_code/ui/renderers/metadata.py +190 -0
  144. klaude_code/ui/renderers/sub_agent.py +71 -0
  145. klaude_code/ui/renderers/thinking.py +39 -0
  146. klaude_code/ui/renderers/tools.py +551 -0
  147. klaude_code/ui/renderers/user_input.py +65 -0
  148. klaude_code/ui/rich/__init__.py +1 -0
  149. klaude_code/ui/rich/live.py +65 -0
  150. klaude_code/ui/rich/markdown.py +308 -0
  151. klaude_code/ui/rich/quote.py +34 -0
  152. klaude_code/ui/rich/searchable_text.py +71 -0
  153. klaude_code/ui/rich/status.py +240 -0
  154. klaude_code/ui/rich/theme.py +274 -0
  155. klaude_code/ui/terminal/__init__.py +1 -0
  156. klaude_code/ui/terminal/color.py +244 -0
  157. klaude_code/ui/terminal/control.py +147 -0
  158. klaude_code/ui/terminal/notifier.py +107 -0
  159. klaude_code/ui/terminal/progress_bar.py +87 -0
  160. klaude_code/ui/utils/__init__.py +1 -0
  161. klaude_code/ui/utils/common.py +108 -0
  162. klaude_code/ui/utils/debouncer.py +42 -0
  163. klaude_code/version.py +163 -0
  164. klaude_code-1.2.6.dist-info/METADATA +178 -0
  165. klaude_code-1.2.6.dist-info/RECORD +167 -0
  166. klaude_code-1.2.6.dist-info/WHEEL +4 -0
  167. klaude_code-1.2.6.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,76 @@
1
+ import time
2
+ from typing import TYPE_CHECKING
3
+
4
+ from klaude_code.trace import log, log_debug
5
+
6
+ if TYPE_CHECKING:
7
+ from questionary import Choice
8
+
9
+ from .session import Session
10
+
11
+
12
+ def resume_select_session() -> str | None:
13
+ sessions = Session.list_sessions()
14
+ if not sessions:
15
+ log("No sessions found for this project.")
16
+ return None
17
+
18
+ def _fmt(ts: float) -> str:
19
+ try:
20
+ return time.strftime("%m-%d %H:%M:%S", time.localtime(ts))
21
+ except Exception:
22
+ return str(ts)
23
+
24
+ try:
25
+ import questionary
26
+
27
+ choices: list[Choice] = []
28
+ for s in sessions:
29
+ first_user_message = s.first_user_message or "N/A"
30
+ msg_count_display = "N/A" if s.messages_count == -1 else str(s.messages_count)
31
+ model_display = s.model_name or "N/A"
32
+
33
+ title = [
34
+ ("class:d", f"{_fmt(s.updated_at):<16} "),
35
+ ("class:b", f"{msg_count_display:>3} "),
36
+ (
37
+ "class:t",
38
+ f"{model_display[:29] + '…' if len(model_display) > 29 else model_display:<30} ",
39
+ ),
40
+ (
41
+ "class:t",
42
+ f"{first_user_message.strip().replace('\n', ' ↩ '):<50}",
43
+ ),
44
+ ]
45
+ choices.append(questionary.Choice(title=title, value=s.id))
46
+ return questionary.select(
47
+ message=f"{' Updated at':<17} {'Msg':>3} {'Model':<30} {'First message':<50}",
48
+ choices=choices,
49
+ pointer="→",
50
+ instruction="↑↓ to move",
51
+ style=questionary.Style(
52
+ [
53
+ ("t", ""),
54
+ ("b", "bold"),
55
+ ("d", "dim"),
56
+ ]
57
+ ),
58
+ ).ask()
59
+ except Exception as e:
60
+ log_debug(f"Failed to use questionary for session select, {e}")
61
+
62
+ for i, s in enumerate(sessions, 1):
63
+ msg_count_display = "N/A" if s.messages_count == -1 else str(s.messages_count)
64
+ model_display = s.model_name or "N/A"
65
+ print(
66
+ f"{i}. {_fmt(s.updated_at)} {msg_count_display:>3} "
67
+ f"{model_display[:29] + '…' if len(model_display) > 29 else model_display:<30} {s.id} {s.work_dir}"
68
+ )
69
+ try:
70
+ raw = input("Select a session number: ").strip()
71
+ idx = int(raw)
72
+ if 1 <= idx <= len(sessions):
73
+ return str(sessions[idx - 1].id)
74
+ except Exception:
75
+ return None
76
+ return None
@@ -0,0 +1,474 @@
1
+ import json
2
+ import time
3
+ import uuid
4
+ from collections.abc import Iterable, Sequence
5
+ from pathlib import Path
6
+ from typing import ClassVar
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from klaude_code.protocol import events, model
11
+
12
+
13
+ class Session(BaseModel):
14
+ id: str = Field(default_factory=lambda: uuid.uuid4().hex)
15
+ work_dir: Path
16
+ conversation_history: list[model.ConversationItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
17
+ 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
21
+ 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
+ model_name: str | None = None
27
+ # Timestamps (epoch seconds)
28
+ created_at: float = Field(default_factory=lambda: time.time())
29
+ updated_at: float = Field(default_factory=lambda: time.time())
30
+
31
+ # Reminder flags
32
+ loaded_memory: list[str] = Field(default_factory=list)
33
+ need_todo_empty_cooldown_counter: int = Field(exclude=True, default=0)
34
+ need_todo_not_used_cooldown_counter: int = Field(exclude=True, default=0)
35
+
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
+ }
56
+
57
+ @staticmethod
58
+ def _project_key() -> str:
59
+ # Derive a stable per-project key from current working directory
60
+ return str(Path.cwd()).strip("/").replace("/", "-")
61
+
62
+ @classmethod
63
+ def _base_dir(cls) -> Path:
64
+ return Path.home() / ".klaude" / "projects" / cls._project_key()
65
+
66
+ @classmethod
67
+ def _sessions_dir(cls) -> Path:
68
+ return cls._base_dir() / "sessions"
69
+
70
+ @classmethod
71
+ def _messages_dir(cls) -> Path:
72
+ return cls._base_dir() / "messages"
73
+
74
+ @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"
85
+
86
+ @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,
94
+ )
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
+
100
+ raw = json.loads(session_path.read_text())
101
+
102
+ # Basic fields (conversation history is loaded separately)
103
+ work_dir_str = raw.get("work_dir", str(Path.cwd()))
104
+
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
+ created_at = float(raw.get("created_at", time.time()))
111
+ 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")
114
+
115
+ sess = Session(
116
+ id=id,
117
+ work_dir=Path(work_dir_str),
118
+ sub_agent_state=sub_agent_state,
119
+ file_tracker=file_tracker,
120
+ todos=todos,
121
+ loaded_memory=loaded_memory,
122
+ created_at=created_at,
123
+ updated_at=updated_at,
124
+ messages_count=messages_count,
125
+ model_name=model_name,
126
+ )
127
+
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
+ )
161
+
162
+ return sess
163
+
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)
170
+
171
+ # Persist session metadata (excluding conversation history)
172
+ # Update timestamps
173
+ if self.created_at <= 0:
174
+ self.created_at = time.time()
175
+ 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))
196
+ )
197
+
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()
212
+
213
+ @classmethod
214
+ def most_recent_session_id(cls) -> str | None:
215
+ sessions_dir = cls._sessions_dir()
216
+ if not sessions_dir.exists():
217
+ return None
218
+ latest_id: str | None = None
219
+ latest_ts: float = -1.0
220
+ for p in sessions_dir.glob("*.json"):
221
+ 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
+ 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
235
+ return latest_id
236
+
237
+ 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
+ if not isinstance(
240
+ item,
241
+ model.ReasoningEncryptedItem | model.ReasoningTextItem | model.AssistantMessageItem | model.ToolCallItem,
242
+ ):
243
+ return False
244
+ if prev_item is None:
245
+ return True
246
+ if isinstance(
247
+ prev_item,
248
+ model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem,
249
+ ):
250
+ return True
251
+ return False
252
+
253
+ def get_history_item(self) -> Iterable[events.HistoryItemEvent]:
254
+ prev_item: model.ConversationItem | None = None
255
+ for it in self.conversation_history:
256
+ if self.need_turn_start(prev_item, it):
257
+ yield events.TurnStartEvent(
258
+ session_id=self.id,
259
+ )
260
+ match it:
261
+ case model.AssistantMessageItem() as am:
262
+ content = am.content or ""
263
+ yield events.AssistantMessageEvent(
264
+ content=content,
265
+ response_id=am.response_id,
266
+ session_id=self.id,
267
+ )
268
+ case model.ToolCallItem() as tc:
269
+ yield events.ToolCallEvent(
270
+ tool_call_id=tc.call_id,
271
+ tool_name=tc.name,
272
+ arguments=tc.arguments,
273
+ response_id=tc.response_id,
274
+ session_id=self.id,
275
+ is_replay=True,
276
+ )
277
+ case model.ToolResultItem() as tr:
278
+ yield events.ToolResultEvent(
279
+ tool_call_id=tr.call_id,
280
+ tool_name=str(tr.tool_name),
281
+ result=tr.output or "",
282
+ ui_extra=tr.ui_extra,
283
+ session_id=self.id,
284
+ status=tr.status,
285
+ is_replay=True,
286
+ )
287
+ # TODO: Replay Sub-Agent Events
288
+ case model.UserMessageItem() as um:
289
+ yield events.UserMessageEvent(
290
+ content=um.content or "",
291
+ session_id=self.id,
292
+ )
293
+ 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
+ )
303
+ case model.InterruptItem():
304
+ yield events.InterruptEvent(
305
+ session_id=self.id,
306
+ )
307
+ case model.DeveloperMessageItem() as dm:
308
+ yield events.DeveloperMessageEvent(
309
+ session_id=self.id,
310
+ item=dm,
311
+ )
312
+ case _:
313
+ continue
314
+ prev_item = it
315
+
316
+ class SessionMetaBrief(BaseModel):
317
+ id: str
318
+ created_at: float
319
+ updated_at: float
320
+ work_dir: str
321
+ path: str
322
+ first_user_message: str | None = None
323
+ messages_count: int = -1 # -1 indicates N/A
324
+ model_name: str | None = None
325
+
326
+ @classmethod
327
+ 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]
357
+
358
+ try:
359
+ for line in msg_file.read_text().splitlines():
360
+ line = line.strip()
361
+ if not line:
362
+ 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
384
+
385
+ 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
391
+ continue
392
+ # Filter out sub-agent sessions
393
+ if data.get("sub_agent_state", None) is not None:
394
+ 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
+
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
405
+
406
+ # Get model name from session data
407
+ model_name = data.get("model_name")
408
+
409
+ items.append(
410
+ Session.SessionMetaBrief(
411
+ id=sid,
412
+ created_at=created,
413
+ updated_at=updated,
414
+ work_dir=work_dir,
415
+ path=str(p),
416
+ first_user_message=first_user_message,
417
+ messages_count=messages_count,
418
+ model_name=model_name,
419
+ )
420
+ )
421
+ # Sort by updated_at desc
422
+ items.sort(key=lambda d: d.updated_at, reverse=True)
423
+ return items
424
+
425
+ @classmethod
426
+ 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
+ sessions = cls.list_sessions()
432
+ deleted_count = 0
433
+
434
+ for session_meta in sessions:
435
+ # Skip sessions with unknown message count
436
+ if session_meta.messages_count < 0:
437
+ continue
438
+ if session_meta.messages_count < min_messages:
439
+ cls._delete_session_files(session_meta.id, session_meta.created_at)
440
+ deleted_count += 1
441
+
442
+ return deleted_count
443
+
444
+ @classmethod
445
+ def clean_all_sessions(cls) -> int:
446
+ """Remove all sessions for the current project.
447
+
448
+ Returns the number of sessions deleted.
449
+ """
450
+ sessions = cls.list_sessions()
451
+ deleted_count = 0
452
+
453
+ for session_meta in sessions:
454
+ cls._delete_session_files(session_meta.id, session_meta.created_at)
455
+ deleted_count += 1
456
+
457
+ return deleted_count
458
+
459
+ @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()