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