klaude-code 1.2.18__py3-none-any.whl → 1.2.20__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 (74) hide show
  1. klaude_code/cli/main.py +42 -22
  2. klaude_code/cli/runtime.py +46 -2
  3. klaude_code/{version.py → cli/self_update.py} +110 -2
  4. klaude_code/command/__init__.py +1 -3
  5. klaude_code/command/clear_cmd.py +5 -4
  6. klaude_code/command/command_abc.py +5 -40
  7. klaude_code/command/debug_cmd.py +2 -2
  8. klaude_code/command/diff_cmd.py +2 -1
  9. klaude_code/command/export_cmd.py +14 -49
  10. klaude_code/command/export_online_cmd.py +10 -4
  11. klaude_code/command/help_cmd.py +2 -1
  12. klaude_code/command/model_cmd.py +7 -5
  13. klaude_code/command/prompt-jj-workspace.md +18 -0
  14. klaude_code/command/prompt_command.py +16 -9
  15. klaude_code/command/refresh_cmd.py +3 -2
  16. klaude_code/command/registry.py +98 -28
  17. klaude_code/command/release_notes_cmd.py +2 -1
  18. klaude_code/command/status_cmd.py +2 -1
  19. klaude_code/command/terminal_setup_cmd.py +2 -1
  20. klaude_code/command/thinking_cmd.py +6 -4
  21. klaude_code/core/executor.py +187 -180
  22. klaude_code/core/manager/sub_agent_manager.py +3 -0
  23. klaude_code/core/prompt.py +4 -1
  24. klaude_code/core/prompts/prompt-sub-agent-explore.md +14 -2
  25. klaude_code/core/prompts/prompt-sub-agent-web.md +3 -3
  26. klaude_code/core/reminders.py +70 -26
  27. klaude_code/core/task.py +13 -12
  28. klaude_code/core/tool/__init__.py +2 -0
  29. klaude_code/core/tool/file/apply_patch_tool.py +3 -1
  30. klaude_code/core/tool/file/edit_tool.py +7 -5
  31. klaude_code/core/tool/file/multi_edit_tool.py +7 -5
  32. klaude_code/core/tool/file/read_tool.md +1 -1
  33. klaude_code/core/tool/file/read_tool.py +8 -4
  34. klaude_code/core/tool/file/write_tool.py +8 -6
  35. klaude_code/core/tool/memory/skill_loader.py +12 -10
  36. klaude_code/core/tool/shell/bash_tool.py +89 -17
  37. klaude_code/core/tool/sub_agent_tool.py +5 -1
  38. klaude_code/core/tool/tool_abc.py +18 -0
  39. klaude_code/core/tool/tool_context.py +6 -6
  40. klaude_code/core/tool/tool_registry.py +1 -1
  41. klaude_code/core/tool/tool_runner.py +7 -7
  42. klaude_code/core/tool/web/web_fetch_tool.py +77 -22
  43. klaude_code/core/tool/web/web_search_tool.py +5 -1
  44. klaude_code/llm/anthropic/client.py +25 -9
  45. klaude_code/llm/openai_compatible/client.py +5 -2
  46. klaude_code/llm/openrouter/client.py +7 -3
  47. klaude_code/llm/responses/client.py +6 -1
  48. klaude_code/protocol/model.py +8 -1
  49. klaude_code/protocol/op.py +47 -0
  50. klaude_code/protocol/op_handler.py +25 -1
  51. klaude_code/protocol/sub_agent/web.py +1 -1
  52. klaude_code/session/codec.py +71 -0
  53. klaude_code/session/export.py +21 -11
  54. klaude_code/session/session.py +186 -322
  55. klaude_code/session/store.py +215 -0
  56. klaude_code/session/templates/export_session.html +48 -47
  57. klaude_code/ui/modes/repl/completers.py +211 -71
  58. klaude_code/ui/modes/repl/event_handler.py +7 -23
  59. klaude_code/ui/modes/repl/input_prompt_toolkit.py +5 -7
  60. klaude_code/ui/modes/repl/renderer.py +2 -2
  61. klaude_code/ui/renderers/common.py +54 -0
  62. klaude_code/ui/renderers/developer.py +2 -3
  63. klaude_code/ui/renderers/errors.py +1 -1
  64. klaude_code/ui/renderers/metadata.py +10 -1
  65. klaude_code/ui/renderers/tools.py +3 -4
  66. klaude_code/ui/rich/__init__.py +10 -1
  67. klaude_code/ui/rich/cjk_wrap.py +228 -0
  68. klaude_code/ui/rich/status.py +0 -1
  69. klaude_code/ui/utils/common.py +0 -18
  70. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/METADATA +18 -2
  71. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/RECORD +73 -70
  72. klaude_code/ui/utils/debouncer.py +0 -42
  73. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/WHEEL +0 -0
  74. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/entry_points.txt +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
7
9
 
8
10
  from pydantic import BaseModel, Field, PrivateAttr
9
11
 
10
- from klaude_code.protocol import events, model, tools
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("/", "-")
20
+
21
+
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,31 +50,23 @@ 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
- # Model name used for this session
23
- # Used in list method SessionMetaBrief
24
55
  model_name: str | None = None
25
- # Timestamps (epoch seconds)
56
+
57
+ model_config_name: str | None = None
58
+ model_thinking: llm_param.Thinking | None = None
26
59
  created_at: float = Field(default_factory=lambda: time.time())
27
60
  updated_at: float = Field(default_factory=lambda: time.time())
28
-
29
- # Reminder flags
30
- loaded_memory: list[str] = Field(default_factory=list)
31
61
  need_todo_empty_cooldown_counter: int = Field(exclude=True, default=0)
32
62
  need_todo_not_used_cooldown_counter: int = Field(exclude=True, default=0)
33
63
 
34
- # Cached messages count (computed property)
35
64
  _messages_count_cache: int | None = PrivateAttr(default=None)
65
+ _store: JsonlSessionStore = PrivateAttr(default_factory=get_default_store)
36
66
 
37
67
  @property
38
68
  def messages_count(self) -> int:
39
- """Count of user, assistant messages, and tool calls in conversation history.
40
-
41
- This is a cached property that is invalidated when append_history is called.
42
- """
69
+ """Count of user, assistant messages, and tool calls in conversation history."""
43
70
  if self._messages_count_cache is None:
44
71
  self._messages_count_cache = sum(
45
72
  1
@@ -49,211 +76,145 @@ class Session(BaseModel):
49
76
  return self._messages_count_cache
50
77
 
51
78
  def _invalidate_messages_count_cache(self) -> None:
52
- """Invalidate the cached messages count."""
53
79
  self._messages_count_cache = None
54
80
 
55
- # Internal: mapping for (de)serialization of conversation items
56
- _TypeMap: ClassVar[dict[str, type[BaseModel]]] = {
57
- # Messages
58
- "SystemMessageItem": model.SystemMessageItem,
59
- "DeveloperMessageItem": model.DeveloperMessageItem,
60
- "UserMessageItem": model.UserMessageItem,
61
- "AssistantMessageItem": model.AssistantMessageItem,
62
- # Reasoning/Thinking
63
- "ReasoningTextItem": model.ReasoningTextItem,
64
- "ReasoningEncryptedItem": model.ReasoningEncryptedItem,
65
- # Tools
66
- "ToolCallItem": model.ToolCallItem,
67
- "ToolResultItem": model.ToolResultItem,
68
- # Stream/meta (not typically persisted in history, but supported)
69
- "AssistantMessageDelta": model.AssistantMessageDelta,
70
- "StartItem": model.StartItem,
71
- "StreamErrorItem": model.StreamErrorItem,
72
- "TaskMetadataItem": model.TaskMetadataItem,
73
- "InterruptItem": model.InterruptItem,
74
- }
75
-
76
81
  @staticmethod
77
82
  def _project_key() -> str:
78
- # Derive a stable per-project key from current working directory
79
- return str(Path.cwd()).strip("/").replace("/", "-")
83
+ return _project_key_from_cwd()
80
84
 
81
85
  @classmethod
82
- def _base_dir(cls) -> Path:
83
- return Path.home() / ".klaude" / "projects" / cls._project_key()
86
+ def paths(cls) -> ProjectPaths:
87
+ return get_default_store().paths
84
88
 
85
89
  @classmethod
86
- def _sessions_dir(cls) -> Path:
87
- return cls._base_dir() / "sessions"
90
+ def create(cls, id: str | None = None, *, work_dir: Path | None = None) -> Session:
91
+ session = Session(id=id or uuid.uuid4().hex, work_dir=work_dir or Path.cwd())
92
+ session._store = get_default_store()
93
+ return session
88
94
 
89
95
  @classmethod
90
- def _messages_dir(cls) -> Path:
91
- return cls._base_dir() / "messages"
92
-
93
- @classmethod
94
- def _exports_dir(cls) -> Path:
95
- return cls._base_dir() / "exports"
96
-
97
- def _session_file(self) -> Path:
98
- prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(self.created_at))
99
- return self._sessions_dir() / f"{prefix}-{self.id}.json"
96
+ def load_meta(cls, id: str) -> Session:
97
+ store = get_default_store()
98
+ raw = store.load_meta(id)
99
+ if raw is None:
100
+ session = Session(id=id, work_dir=Path.cwd())
101
+ session._store = store
102
+ return session
103
+
104
+ work_dir_str = raw.get("work_dir")
105
+ if not isinstance(work_dir_str, str) or not work_dir_str:
106
+ work_dir_str = str(Path.cwd())
100
107
 
101
- def _messages_file(self) -> Path:
102
- prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(self.created_at))
103
- return self._messages_dir() / f"{prefix}-{self.id}.jsonl"
104
-
105
- @classmethod
106
- def create(cls, id: str | None = None) -> "Session":
107
- """Create a new session without checking for existing files."""
108
- return Session(id=id or uuid.uuid4().hex, work_dir=Path.cwd())
109
-
110
- @classmethod
111
- def load(cls, id: str) -> "Session":
112
- """Load an existing session or create a new one if not found."""
113
- # Load session metadata
114
- sessions_dir = cls._sessions_dir()
115
- session_candidates = sorted(
116
- sessions_dir.glob(f"*-{id}.json"),
117
- key=lambda p: p.stat().st_mtime,
118
- reverse=True,
108
+ sub_agent_state_raw = raw.get("sub_agent_state")
109
+ sub_agent_state = (
110
+ model.SubAgentState.model_validate(sub_agent_state_raw) if isinstance(sub_agent_state_raw, dict) else None
119
111
  )
120
- if not session_candidates:
121
- # No existing session; create a new one
122
- return Session(id=id, work_dir=Path.cwd())
123
- session_path = session_candidates[0]
124
112
 
125
- raw = json.loads(session_path.read_text())
113
+ file_tracker_raw = raw.get("file_tracker")
114
+ file_tracker: dict[str, model.FileStatus] = {}
115
+ if isinstance(file_tracker_raw, dict):
116
+ for k, v in cast(dict[object, object], file_tracker_raw).items():
117
+ if isinstance(k, str) and isinstance(v, dict):
118
+ try:
119
+ file_tracker[k] = model.FileStatus.model_validate(v)
120
+ except Exception:
121
+ continue
126
122
 
127
- # Basic fields (conversation history is loaded separately)
128
- work_dir_str = raw.get("work_dir", str(Path.cwd()))
123
+ todos_raw = raw.get("todos")
124
+ todos: list[model.TodoItem] = []
125
+ if isinstance(todos_raw, list):
126
+ for todo_raw in cast(list[object], todos_raw):
127
+ if not isinstance(todo_raw, dict):
128
+ continue
129
+ try:
130
+ todos.append(model.TodoItem.model_validate(todo_raw))
131
+ except Exception:
132
+ continue
129
133
 
130
- sub_agent_state_raw = raw.get("sub_agent_state")
131
- sub_agent_state = model.SubAgentState(**sub_agent_state_raw) if sub_agent_state_raw else None
132
- file_tracker = dict(raw.get("file_tracker", {}))
133
- todos: list[model.TodoItem] = [model.TodoItem(**item) for item in raw.get("todos", [])]
134
- loaded_memory = list(raw.get("loaded_memory", []))
135
134
  created_at = float(raw.get("created_at", time.time()))
136
135
  updated_at = float(raw.get("updated_at", created_at))
137
- model_name = raw.get("model_name")
136
+ model_name = raw.get("model_name") if isinstance(raw.get("model_name"), str) else None
137
+ model_config_name = raw.get("model_config_name") if isinstance(raw.get("model_config_name"), str) else None
138
138
 
139
- sess = Session(
139
+ model_thinking_raw = raw.get("model_thinking")
140
+ model_thinking = (
141
+ llm_param.Thinking.model_validate(model_thinking_raw) if isinstance(model_thinking_raw, dict) else None
142
+ )
143
+
144
+ session = Session(
140
145
  id=id,
141
146
  work_dir=Path(work_dir_str),
142
147
  sub_agent_state=sub_agent_state,
143
148
  file_tracker=file_tracker,
144
149
  todos=todos,
145
- loaded_memory=loaded_memory,
146
150
  created_at=created_at,
147
151
  updated_at=updated_at,
148
152
  model_name=model_name,
153
+ model_config_name=model_config_name,
154
+ model_thinking=model_thinking,
149
155
  )
156
+ session._store = store
157
+ return session
150
158
 
151
- # Load conversation history from messages JSONL
152
- messages_dir = cls._messages_dir()
153
- # Expect a single messages file per session (prefixed filenames only)
154
- msg_candidates = sorted(
155
- messages_dir.glob(f"*-{id}.jsonl"),
156
- key=lambda p: p.stat().st_mtime,
157
- reverse=True,
158
- )
159
- if msg_candidates:
160
- messages_path = msg_candidates[0]
161
- history: list[model.ConversationItem] = []
162
- for line in messages_path.read_text().splitlines():
163
- line = line.strip()
164
- if not line:
165
- continue
166
- try:
167
- obj = json.loads(line)
168
- t = obj.get("type")
169
- data = obj.get("data", {})
170
- cls_type = cls._TypeMap.get(t or "")
171
- if cls_type is None:
172
- continue
173
- item = cls_type(**data)
174
- # pyright: ignore[reportAssignmentType]
175
- history.append(item) # type: ignore[arg-type]
176
- except (json.JSONDecodeError, KeyError, TypeError):
177
- # Best-effort load; skip malformed lines
178
- continue
179
- sess.conversation_history = history
180
- # messages_count is now a computed property, no need to set it
181
-
182
- return sess
159
+ @classmethod
160
+ def load(cls, id: str) -> Session:
161
+ store = get_default_store()
162
+ session = cls.load_meta(id)
163
+ session._store = store
164
+ session.conversation_history = store.load_history(id)
165
+ return session
166
+
167
+ def append_history(self, items: Sequence[model.ConversationItem]) -> None:
168
+ if not items:
169
+ return
183
170
 
184
- def save(self):
185
- # Ensure directories exist
186
- sessions_dir = self._sessions_dir()
187
- messages_dir = self._messages_dir()
188
- sessions_dir.mkdir(parents=True, exist_ok=True)
189
- messages_dir.mkdir(parents=True, exist_ok=True)
171
+ self.conversation_history.extend(items)
172
+ self._invalidate_messages_count_cache()
190
173
 
191
- # Persist session metadata (excluding conversation history)
192
- # Update timestamps
193
174
  if self.created_at <= 0:
194
175
  self.created_at = time.time()
195
176
  self.updated_at = time.time()
196
- payload = {
197
- "id": self.id,
198
- "work_dir": str(self.work_dir),
199
- "sub_agent_state": self.sub_agent_state.model_dump() if self.sub_agent_state else None,
200
- "file_tracker": self.file_tracker,
201
- "todos": [todo.model_dump() for todo in self.todos],
202
- "loaded_memory": self.loaded_memory,
203
- "created_at": self.created_at,
204
- "updated_at": self.updated_at,
205
- "messages_count": self.messages_count,
206
- "model_name": self.model_name,
207
- }
208
- self._session_file().write_text(json.dumps(payload, ensure_ascii=False, indent=2))
209
-
210
- def append_history(self, items: Sequence[model.ConversationItem]):
211
- # Append to in-memory history
212
- self.conversation_history.extend(items)
213
- # Invalidate messages count cache
214
- self._invalidate_messages_count_cache()
215
177
 
216
- # Incrementally persist to JSONL under messages directory
217
- messages_dir = self._messages_dir()
218
- messages_dir.mkdir(parents=True, exist_ok=True)
219
- mpath = self._messages_file()
220
-
221
- with mpath.open("a", encoding="utf-8") as f:
222
- for it in items:
223
- # Serialize with explicit type tag for reliable load
224
- t = it.__class__.__name__
225
- data = it.model_dump(mode="json")
226
- f.write(json.dumps({"type": t, "data": data}, ensure_ascii=False))
227
- f.write("\n")
228
- # Refresh metadata timestamp after history change
229
- self.save()
178
+ meta = build_meta_snapshot(
179
+ session_id=self.id,
180
+ work_dir=self.work_dir,
181
+ sub_agent_state=self.sub_agent_state,
182
+ file_tracker=self.file_tracker,
183
+ todos=list(self.todos),
184
+ created_at=self.created_at,
185
+ updated_at=self.updated_at,
186
+ messages_count=self.messages_count,
187
+ model_name=self.model_name,
188
+ model_config_name=self.model_config_name,
189
+ model_thinking=self.model_thinking,
190
+ )
191
+ self._store.append_and_flush(session_id=self.id, items=items, meta=meta)
192
+
193
+ async def wait_for_flush(self) -> None:
194
+ await self._store.wait_for_flush(self.id)
230
195
 
231
196
  @classmethod
232
197
  def most_recent_session_id(cls) -> str | None:
233
- sessions_dir = cls._sessions_dir()
234
- if not sessions_dir.exists():
235
- return None
198
+ store = get_default_store()
236
199
  latest_id: str | None = None
237
200
  latest_ts: float = -1.0
238
- for p in sessions_dir.glob("*.json"):
201
+ for meta_path in store.iter_meta_files():
202
+ data = _read_json_dict(meta_path)
203
+ if data is None:
204
+ continue
205
+ if data.get("sub_agent_state") is not None:
206
+ continue
207
+ sid = str(data.get("id", meta_path.parent.name))
239
208
  try:
240
- data = json.loads(p.read_text())
241
- # Filter out sub-agent sessions
242
- if data.get("sub_agent_state", None) is not None:
243
- continue
244
- sid = str(data.get("id", p.stem))
245
209
  ts = float(data.get("updated_at", 0.0))
246
- if ts <= 0:
247
- ts = p.stat().st_mtime
248
- if ts > latest_ts:
249
- latest_ts = ts
250
- latest_id = sid
251
- except (json.JSONDecodeError, KeyError, TypeError, OSError):
252
- continue
210
+ except (TypeError, ValueError):
211
+ ts = meta_path.stat().st_mtime
212
+ if ts > latest_ts:
213
+ latest_ts = ts
214
+ latest_id = sid
253
215
  return latest_id
254
216
 
255
217
  def need_turn_start(self, prev_item: model.ConversationItem | None, item: model.ConversationItem) -> bool:
256
- # Emit TurnStartEvent when a new turn starts to show an empty line in replay history
257
218
  if not isinstance(
258
219
  item,
259
220
  model.ReasoningEncryptedItem | model.ReasoningTextItem | model.AssistantMessageItem | model.ToolCallItem,
@@ -261,10 +222,7 @@ class Session(BaseModel):
261
222
  return False
262
223
  if prev_item is None:
263
224
  return True
264
- return isinstance(
265
- prev_item,
266
- model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem,
267
- )
225
+ return isinstance(prev_item, model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem)
268
226
 
269
227
  def get_history_item(self) -> Iterable[events.HistoryItemEvent]:
270
228
  seen_sub_agent_sessions: set[str] = set()
@@ -274,9 +232,7 @@ class Session(BaseModel):
274
232
  yield events.TaskStartEvent(session_id=self.id, sub_agent_state=self.sub_agent_state)
275
233
  for it in self.conversation_history:
276
234
  if self.need_turn_start(prev_item, it):
277
- yield events.TurnStartEvent(
278
- session_id=self.id,
279
- )
235
+ yield events.TurnStartEvent(session_id=self.id)
280
236
  match it:
281
237
  case model.AssistantMessageItem() as am:
282
238
  content = am.content or ""
@@ -308,34 +264,17 @@ class Session(BaseModel):
308
264
  )
309
265
  yield from self._iter_sub_agent_history(tr, seen_sub_agent_sessions)
310
266
  case model.UserMessageItem() as um:
311
- yield events.UserMessageEvent(
312
- content=um.content or "",
313
- session_id=self.id,
314
- )
267
+ yield events.UserMessageEvent(content=um.content or "", session_id=self.id, images=um.images)
315
268
  case model.ReasoningTextItem() as ri:
316
- yield events.ThinkingEvent(
317
- content=ri.content,
318
- session_id=self.id,
319
- )
269
+ yield events.ThinkingEvent(content=ri.content, session_id=self.id)
320
270
  case model.TaskMetadataItem() as mt:
321
- yield events.TaskMetadataEvent(
322
- session_id=self.id,
323
- metadata=mt,
324
- )
271
+ yield events.TaskMetadataEvent(session_id=self.id, metadata=mt)
325
272
  case model.InterruptItem():
326
- yield events.InterruptEvent(
327
- session_id=self.id,
328
- )
273
+ yield events.InterruptEvent(session_id=self.id)
329
274
  case model.DeveloperMessageItem() as dm:
330
- yield events.DeveloperMessageEvent(
331
- session_id=self.id,
332
- item=dm,
333
- )
275
+ yield events.DeveloperMessageEvent(session_id=self.id, item=dm)
334
276
  case model.StreamErrorItem() as se:
335
- yield events.ErrorEvent(
336
- error_message=se.error,
337
- can_retry=False,
338
- )
277
+ yield events.ErrorEvent(error_message=se.error, can_retry=False)
339
278
  case _:
340
279
  continue
341
280
  prev_item = it
@@ -343,37 +282,25 @@ class Session(BaseModel):
343
282
  has_structured_output = report_back_result is not None
344
283
  task_result = report_back_result if has_structured_output else last_assistant_content
345
284
  yield events.TaskFinishEvent(
346
- session_id=self.id,
347
- task_result=task_result,
348
- has_structured_output=has_structured_output,
285
+ session_id=self.id, task_result=task_result, has_structured_output=has_structured_output
349
286
  )
350
287
 
351
288
  def _iter_sub_agent_history(
352
289
  self, tool_result: model.ToolResultItem, seen_sub_agent_sessions: set[str]
353
290
  ) -> Iterable[events.HistoryItemEvent]:
354
- """Replay sub-agent session history when a tool result references it.
355
-
356
- Sub-agent tool results embed a SessionIdUIExtra containing the child session ID.
357
- When present, we load that session and yield its history events so replay/export
358
- can show the full sub-agent transcript instead of only the summarized tool output.
359
- """
360
291
  ui_extra = tool_result.ui_extra
361
292
  if not isinstance(ui_extra, model.SessionIdUIExtra):
362
293
  return
363
-
364
294
  session_id = ui_extra.session_id
365
295
  if not session_id or session_id == self.id:
366
296
  return
367
297
  if session_id in seen_sub_agent_sessions:
368
298
  return
369
-
370
299
  seen_sub_agent_sessions.add(session_id)
371
-
372
300
  try:
373
301
  sub_session = Session.load(session_id)
374
302
  except Exception:
375
303
  return
376
-
377
304
  yield from sub_session.get_history_item()
378
305
 
379
306
  class SessionMetaBrief(BaseModel):
@@ -383,91 +310,52 @@ class Session(BaseModel):
383
310
  work_dir: str
384
311
  path: str
385
312
  first_user_message: str | None = None
386
- messages_count: int = -1 # -1 indicates N/A
313
+ messages_count: int = -1
387
314
  model_name: str | None = None
388
315
 
389
316
  @classmethod
390
317
  def list_sessions(cls) -> list[SessionMetaBrief]:
391
- """List all sessions for the current project.
392
-
393
- Returns a list of dicts with keys: id, created_at, updated_at, work_dir, path.
394
- Sorted by updated_at descending.
395
- """
396
- sessions_dir = cls._sessions_dir()
397
- if not sessions_dir.exists():
398
- return []
399
-
400
- def _get_first_user_message(session_id: str, created_at: float) -> str | None:
401
- """Get the first user message from the session's jsonl file."""
402
- messages_dir = cls._messages_dir()
403
- if not messages_dir.exists():
404
- return None
405
-
406
- # Find the messages file for this session
407
- prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(created_at))
408
- msg_file = messages_dir / f"{prefix}-{session_id}.jsonl"
409
-
410
- if not msg_file.exists():
411
- # Try to find by pattern if exact file doesn't exist
412
- msg_candidates = sorted(
413
- messages_dir.glob(f"*-{session_id}.jsonl"),
414
- key=lambda p: p.stat().st_mtime,
415
- reverse=True,
416
- )
417
- if not msg_candidates:
418
- return None
419
- msg_file = msg_candidates[0]
318
+ store = get_default_store()
420
319
 
320
+ def _get_first_user_message(session_id: str) -> str | None:
321
+ events_path = store.paths.events_file(session_id)
322
+ if not events_path.exists():
323
+ return None
421
324
  try:
422
- for line in msg_file.read_text().splitlines():
423
- line = line.strip()
424
- if not line:
325
+ for line in events_path.read_text(encoding="utf-8").splitlines():
326
+ obj_raw = json.loads(line)
327
+ if not isinstance(obj_raw, dict):
328
+ continue
329
+ obj = cast(dict[str, Any], obj_raw)
330
+ if obj.get("type") != "UserMessageItem":
425
331
  continue
426
- obj = json.loads(line)
427
- if obj.get("type") == "UserMessageItem":
428
- data = obj.get("data", {})
429
- content = data.get("content", "")
430
- if isinstance(content, str):
431
- return content
432
- elif isinstance(content, list) and content:
433
- # Handle structured content - extract text
434
- text_parts: list[str] = []
435
- for part in content: # pyright: ignore[reportUnknownVariableType]
436
- if (
437
- isinstance(part, dict) and part.get("type") == "text" # pyright: ignore[reportUnknownMemberType]
438
- ):
439
- text = part.get("text", "") # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
440
- if isinstance(text, str):
441
- text_parts.append(text)
442
- return " ".join(text_parts) if text_parts else None
332
+ data_raw = obj.get("data")
333
+ if not isinstance(data_raw, dict):
443
334
  return None
444
- except (json.JSONDecodeError, KeyError, TypeError, OSError):
335
+ data = cast(dict[str, Any], data_raw)
336
+ content = data.get("content")
337
+ if isinstance(content, str):
338
+ return content
339
+ return None
340
+ except (OSError, json.JSONDecodeError):
445
341
  return None
446
342
  return None
447
343
 
448
344
  items: list[Session.SessionMetaBrief] = []
449
- for p in sessions_dir.glob("*.json"):
450
- try:
451
- data = json.loads(p.read_text())
452
- except (json.JSONDecodeError, OSError):
453
- # Skip unreadable files
345
+ for meta_path in store.iter_meta_files():
346
+ data = _read_json_dict(meta_path)
347
+ if data is None:
454
348
  continue
455
- # Filter out sub-agent sessions
456
- if data.get("sub_agent_state", None) is not None:
349
+ if data.get("sub_agent_state") is not None:
457
350
  continue
458
- sid = str(data.get("id", p.stem))
459
- created = float(data.get("created_at", p.stat().st_mtime))
460
- updated = float(data.get("updated_at", p.stat().st_mtime))
461
- work_dir = str(data.get("work_dir", ""))
462
-
463
- # Get first user message
464
- first_user_message = _get_first_user_message(sid, created)
465
-
466
- # Get messages count from session data, no fallback
467
- messages_count = int(data.get("messages_count", -1)) # -1 indicates N/A
468
351
 
469
- # Get model name from session data
470
- model_name = data.get("model_name")
352
+ sid = str(data.get("id", meta_path.parent.name))
353
+ created = float(data.get("created_at", meta_path.stat().st_mtime))
354
+ updated = float(data.get("updated_at", meta_path.stat().st_mtime))
355
+ work_dir = str(data.get("work_dir", ""))
356
+ first_user_message = _get_first_user_message(sid)
357
+ messages_count = int(data.get("messages_count", -1))
358
+ model_name = data.get("model_name") if isinstance(data.get("model_name"), str) else None
471
359
 
472
360
  items.append(
473
361
  Session.SessionMetaBrief(
@@ -475,63 +363,39 @@ class Session(BaseModel):
475
363
  created_at=created,
476
364
  updated_at=updated,
477
365
  work_dir=work_dir,
478
- path=str(p),
366
+ path=str(meta_path),
479
367
  first_user_message=first_user_message,
480
368
  messages_count=messages_count,
481
369
  model_name=model_name,
482
370
  )
483
371
  )
484
- # Sort by updated_at desc
372
+
485
373
  items.sort(key=lambda d: d.updated_at, reverse=True)
486
374
  return items
487
375
 
488
376
  @classmethod
489
377
  def clean_small_sessions(cls, min_messages: int = 5) -> int:
490
- """Remove sessions with fewer than min_messages messages.
491
-
492
- Returns the number of sessions deleted.
493
- """
494
378
  sessions = cls.list_sessions()
495
379
  deleted_count = 0
496
-
380
+ store = get_default_store()
497
381
  for session_meta in sessions:
498
- # Skip sessions with unknown message count
499
382
  if session_meta.messages_count < 0:
500
383
  continue
501
384
  if session_meta.messages_count < min_messages:
502
- cls._delete_session_files(session_meta.id, session_meta.created_at)
385
+ store.delete_session(session_meta.id)
503
386
  deleted_count += 1
504
-
505
387
  return deleted_count
506
388
 
507
389
  @classmethod
508
390
  def clean_all_sessions(cls) -> int:
509
- """Remove all sessions for the current project.
510
-
511
- Returns the number of sessions deleted.
512
- """
513
391
  sessions = cls.list_sessions()
514
392
  deleted_count = 0
515
-
393
+ store = get_default_store()
516
394
  for session_meta in sessions:
517
- cls._delete_session_files(session_meta.id, session_meta.created_at)
395
+ store.delete_session(session_meta.id)
518
396
  deleted_count += 1
519
-
520
397
  return deleted_count
521
398
 
522
399
  @classmethod
523
- def _delete_session_files(cls, session_id: str, created_at: float) -> None:
524
- """Delete session and messages files for a given session."""
525
- sessions_dir = cls._sessions_dir()
526
- messages_dir = cls._messages_dir()
527
-
528
- # Delete session file
529
- prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(created_at))
530
- session_file = sessions_dir / f"{prefix}-{session_id}.json"
531
- if session_file.exists():
532
- session_file.unlink()
533
-
534
- # Delete messages file
535
- messages_file = messages_dir / f"{prefix}-{session_id}.jsonl"
536
- if messages_file.exists():
537
- messages_file.unlink()
400
+ def exports_dir(cls) -> Path:
401
+ return get_default_store().paths.exports_dir