klaude-code 1.2.19__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 (57) hide show
  1. klaude_code/cli/runtime.py +5 -0
  2. klaude_code/command/__init__.py +1 -3
  3. klaude_code/command/clear_cmd.py +5 -4
  4. klaude_code/command/command_abc.py +5 -40
  5. klaude_code/command/debug_cmd.py +2 -2
  6. klaude_code/command/diff_cmd.py +2 -1
  7. klaude_code/command/export_cmd.py +14 -49
  8. klaude_code/command/export_online_cmd.py +2 -1
  9. klaude_code/command/help_cmd.py +2 -1
  10. klaude_code/command/model_cmd.py +7 -5
  11. klaude_code/command/prompt-jj-workspace.md +18 -0
  12. klaude_code/command/prompt_command.py +16 -9
  13. klaude_code/command/refresh_cmd.py +3 -2
  14. klaude_code/command/registry.py +31 -6
  15. klaude_code/command/release_notes_cmd.py +2 -1
  16. klaude_code/command/status_cmd.py +2 -1
  17. klaude_code/command/terminal_setup_cmd.py +2 -1
  18. klaude_code/command/thinking_cmd.py +2 -1
  19. klaude_code/core/executor.py +177 -190
  20. klaude_code/core/manager/sub_agent_manager.py +3 -0
  21. klaude_code/core/prompt.py +4 -1
  22. klaude_code/core/prompts/prompt-sub-agent-web.md +3 -3
  23. klaude_code/core/reminders.py +70 -26
  24. klaude_code/core/task.py +4 -5
  25. klaude_code/core/tool/__init__.py +2 -0
  26. klaude_code/core/tool/file/apply_patch_tool.py +3 -1
  27. klaude_code/core/tool/file/edit_tool.py +7 -5
  28. klaude_code/core/tool/file/multi_edit_tool.py +7 -5
  29. klaude_code/core/tool/file/read_tool.py +5 -2
  30. klaude_code/core/tool/file/write_tool.py +8 -6
  31. klaude_code/core/tool/shell/bash_tool.py +89 -17
  32. klaude_code/core/tool/sub_agent_tool.py +5 -1
  33. klaude_code/core/tool/tool_abc.py +18 -0
  34. klaude_code/core/tool/tool_context.py +6 -6
  35. klaude_code/core/tool/tool_runner.py +7 -7
  36. klaude_code/core/tool/web/web_fetch_tool.py +77 -22
  37. klaude_code/core/tool/web/web_search_tool.py +5 -1
  38. klaude_code/protocol/model.py +8 -1
  39. klaude_code/protocol/op.py +47 -0
  40. klaude_code/protocol/op_handler.py +25 -1
  41. klaude_code/protocol/sub_agent/web.py +1 -1
  42. klaude_code/session/codec.py +71 -0
  43. klaude_code/session/export.py +21 -11
  44. klaude_code/session/session.py +177 -333
  45. klaude_code/session/store.py +215 -0
  46. klaude_code/session/templates/export_session.html +13 -14
  47. klaude_code/ui/modes/repl/completers.py +1 -2
  48. klaude_code/ui/modes/repl/event_handler.py +7 -23
  49. klaude_code/ui/modes/repl/input_prompt_toolkit.py +4 -6
  50. klaude_code/ui/rich/__init__.py +10 -1
  51. klaude_code/ui/rich/cjk_wrap.py +228 -0
  52. klaude_code/ui/rich/status.py +0 -1
  53. {klaude_code-1.2.19.dist-info → klaude_code-1.2.20.dist-info}/METADATA +2 -1
  54. {klaude_code-1.2.19.dist-info → klaude_code-1.2.20.dist-info}/RECORD +56 -53
  55. klaude_code/ui/utils/debouncer.py +0 -42
  56. {klaude_code-1.2.19.dist-info → klaude_code-1.2.20.dist-info}/WHEEL +0 -0
  57. {klaude_code-1.2.19.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
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,145 @@ 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()
83
+ return _project_key_from_cwd()
87
84
 
88
85
  @classmethod
89
- def _sessions_dir(cls) -> Path:
90
- return cls._base_dir() / "sessions"
86
+ def paths(cls) -> ProjectPaths:
87
+ return get_default_store().paths
91
88
 
92
89
  @classmethod
93
- def _messages_dir(cls) -> Path:
94
- return cls._base_dir() / "messages"
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
95
94
 
96
95
  @classmethod
97
- def _exports_dir(cls) -> Path:
98
- return cls._base_dir() / "exports"
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())
99
107
 
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,
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
115
111
  )
116
- if not session_candidates:
117
- return None
118
- return session_candidates[0]
119
112
 
120
- @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())
124
-
125
- @classmethod
126
- def load_meta(cls, id: str) -> "Session":
127
- """Load session metadata only (without loading messages history)."""
128
-
129
- session_path = cls._find_session_file(id)
130
- if session_path is None:
131
- return Session(id=id, work_dir=Path.cwd())
132
-
133
- 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
134
122
 
135
- 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
136
133
 
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
134
  created_at = float(raw.get("created_at", time.time()))
143
135
  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")
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
146
138
 
147
139
  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]
140
+ model_thinking = (
141
+ llm_param.Thinking.model_validate(model_thinking_raw) if isinstance(model_thinking_raw, dict) else None
142
+ )
149
143
 
150
- return Session(
144
+ session = Session(
151
145
  id=id,
152
146
  work_dir=Path(work_dir_str),
153
147
  sub_agent_state=sub_agent_state,
154
148
  file_tracker=file_tracker,
155
149
  todos=todos,
156
- loaded_memory=loaded_memory,
157
150
  created_at=created_at,
158
151
  updated_at=updated_at,
159
152
  model_name=model_name,
160
153
  model_config_name=model_config_name,
161
154
  model_thinking=model_thinking,
162
155
  )
156
+ session._store = store
157
+ return session
163
158
 
164
159
  @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
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
201
170
 
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)
171
+ self.conversation_history.extend(items)
172
+ self._invalidate_messages_count_cache()
208
173
 
209
- # Persist session metadata (excluding conversation history)
210
- # Update timestamps
211
174
  if self.created_at <= 0:
212
175
  self.created_at = time.time()
213
176
  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
177
 
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()
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)
250
195
 
251
196
  @classmethod
252
197
  def most_recent_session_id(cls) -> str | None:
253
- sessions_dir = cls._sessions_dir()
254
- if not sessions_dir.exists():
255
- return None
198
+ store = get_default_store()
256
199
  latest_id: str | None = None
257
200
  latest_ts: float = -1.0
258
- 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))
259
208
  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
209
  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
210
+ except (TypeError, ValueError):
211
+ ts = meta_path.stat().st_mtime
212
+ if ts > latest_ts:
213
+ latest_ts = ts
214
+ latest_id = sid
273
215
  return latest_id
274
216
 
275
217
  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
218
  if not isinstance(
278
219
  item,
279
220
  model.ReasoningEncryptedItem | model.ReasoningTextItem | model.AssistantMessageItem | model.ToolCallItem,
@@ -281,10 +222,7 @@ class Session(BaseModel):
281
222
  return False
282
223
  if prev_item is None:
283
224
  return True
284
- return isinstance(
285
- prev_item,
286
- model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem,
287
- )
225
+ return isinstance(prev_item, model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem)
288
226
 
289
227
  def get_history_item(self) -> Iterable[events.HistoryItemEvent]:
290
228
  seen_sub_agent_sessions: set[str] = set()
@@ -294,9 +232,7 @@ class Session(BaseModel):
294
232
  yield events.TaskStartEvent(session_id=self.id, sub_agent_state=self.sub_agent_state)
295
233
  for it in self.conversation_history:
296
234
  if self.need_turn_start(prev_item, it):
297
- yield events.TurnStartEvent(
298
- session_id=self.id,
299
- )
235
+ yield events.TurnStartEvent(session_id=self.id)
300
236
  match it:
301
237
  case model.AssistantMessageItem() as am:
302
238
  content = am.content or ""
@@ -328,34 +264,17 @@ class Session(BaseModel):
328
264
  )
329
265
  yield from self._iter_sub_agent_history(tr, seen_sub_agent_sessions)
330
266
  case model.UserMessageItem() as um:
331
- yield events.UserMessageEvent(
332
- content=um.content or "",
333
- session_id=self.id,
334
- )
267
+ yield events.UserMessageEvent(content=um.content or "", session_id=self.id, images=um.images)
335
268
  case model.ReasoningTextItem() as ri:
336
- yield events.ThinkingEvent(
337
- content=ri.content,
338
- session_id=self.id,
339
- )
269
+ yield events.ThinkingEvent(content=ri.content, session_id=self.id)
340
270
  case model.TaskMetadataItem() as mt:
341
- yield events.TaskMetadataEvent(
342
- session_id=self.id,
343
- metadata=mt,
344
- )
271
+ yield events.TaskMetadataEvent(session_id=self.id, metadata=mt)
345
272
  case model.InterruptItem():
346
- yield events.InterruptEvent(
347
- session_id=self.id,
348
- )
273
+ yield events.InterruptEvent(session_id=self.id)
349
274
  case model.DeveloperMessageItem() as dm:
350
- yield events.DeveloperMessageEvent(
351
- session_id=self.id,
352
- item=dm,
353
- )
275
+ yield events.DeveloperMessageEvent(session_id=self.id, item=dm)
354
276
  case model.StreamErrorItem() as se:
355
- yield events.ErrorEvent(
356
- error_message=se.error,
357
- can_retry=False,
358
- )
277
+ yield events.ErrorEvent(error_message=se.error, can_retry=False)
359
278
  case _:
360
279
  continue
361
280
  prev_item = it
@@ -363,37 +282,25 @@ class Session(BaseModel):
363
282
  has_structured_output = report_back_result is not None
364
283
  task_result = report_back_result if has_structured_output else last_assistant_content
365
284
  yield events.TaskFinishEvent(
366
- session_id=self.id,
367
- task_result=task_result,
368
- has_structured_output=has_structured_output,
285
+ session_id=self.id, task_result=task_result, has_structured_output=has_structured_output
369
286
  )
370
287
 
371
288
  def _iter_sub_agent_history(
372
289
  self, tool_result: model.ToolResultItem, seen_sub_agent_sessions: set[str]
373
290
  ) -> 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
291
  ui_extra = tool_result.ui_extra
381
292
  if not isinstance(ui_extra, model.SessionIdUIExtra):
382
293
  return
383
-
384
294
  session_id = ui_extra.session_id
385
295
  if not session_id or session_id == self.id:
386
296
  return
387
297
  if session_id in seen_sub_agent_sessions:
388
298
  return
389
-
390
299
  seen_sub_agent_sessions.add(session_id)
391
-
392
300
  try:
393
301
  sub_session = Session.load(session_id)
394
302
  except Exception:
395
303
  return
396
-
397
304
  yield from sub_session.get_history_item()
398
305
 
399
306
  class SessionMetaBrief(BaseModel):
@@ -403,91 +310,52 @@ class Session(BaseModel):
403
310
  work_dir: str
404
311
  path: str
405
312
  first_user_message: str | None = None
406
- messages_count: int = -1 # -1 indicates N/A
313
+ messages_count: int = -1
407
314
  model_name: str | None = None
408
315
 
409
316
  @classmethod
410
317
  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]
318
+ store = get_default_store()
440
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
441
324
  try:
442
- for line in msg_file.read_text().splitlines():
443
- line = line.strip()
444
- 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":
445
331
  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
332
+ data_raw = obj.get("data")
333
+ if not isinstance(data_raw, dict):
463
334
  return None
464
- 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):
465
341
  return None
466
342
  return None
467
343
 
468
344
  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
345
+ for meta_path in store.iter_meta_files():
346
+ data = _read_json_dict(meta_path)
347
+ if data is None:
474
348
  continue
475
- # Filter out sub-agent sessions
476
- if data.get("sub_agent_state", None) is not None:
349
+ if data.get("sub_agent_state") is not None:
477
350
  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
351
 
489
- # Get model name from session data
490
- 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
491
359
 
492
360
  items.append(
493
361
  Session.SessionMetaBrief(
@@ -495,63 +363,39 @@ class Session(BaseModel):
495
363
  created_at=created,
496
364
  updated_at=updated,
497
365
  work_dir=work_dir,
498
- path=str(p),
366
+ path=str(meta_path),
499
367
  first_user_message=first_user_message,
500
368
  messages_count=messages_count,
501
369
  model_name=model_name,
502
370
  )
503
371
  )
504
- # Sort by updated_at desc
372
+
505
373
  items.sort(key=lambda d: d.updated_at, reverse=True)
506
374
  return items
507
375
 
508
376
  @classmethod
509
377
  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
378
  sessions = cls.list_sessions()
515
379
  deleted_count = 0
516
-
380
+ store = get_default_store()
517
381
  for session_meta in sessions:
518
- # Skip sessions with unknown message count
519
382
  if session_meta.messages_count < 0:
520
383
  continue
521
384
  if session_meta.messages_count < min_messages:
522
- cls._delete_session_files(session_meta.id, session_meta.created_at)
385
+ store.delete_session(session_meta.id)
523
386
  deleted_count += 1
524
-
525
387
  return deleted_count
526
388
 
527
389
  @classmethod
528
390
  def clean_all_sessions(cls) -> int:
529
- """Remove all sessions for the current project.
530
-
531
- Returns the number of sessions deleted.
532
- """
533
391
  sessions = cls.list_sessions()
534
392
  deleted_count = 0
535
-
393
+ store = get_default_store()
536
394
  for session_meta in sessions:
537
- cls._delete_session_files(session_meta.id, session_meta.created_at)
395
+ store.delete_session(session_meta.id)
538
396
  deleted_count += 1
539
-
540
397
  return deleted_count
541
398
 
542
399
  @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()
400
+ def exports_dir(cls) -> Path:
401
+ return get_default_store().paths.exports_dir