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.
- klaude_code/cli/runtime.py +5 -0
- klaude_code/command/__init__.py +1 -3
- klaude_code/command/clear_cmd.py +5 -4
- klaude_code/command/command_abc.py +5 -40
- klaude_code/command/debug_cmd.py +2 -2
- klaude_code/command/diff_cmd.py +2 -1
- klaude_code/command/export_cmd.py +14 -49
- klaude_code/command/export_online_cmd.py +2 -1
- klaude_code/command/help_cmd.py +2 -1
- klaude_code/command/model_cmd.py +7 -5
- klaude_code/command/prompt-jj-workspace.md +18 -0
- klaude_code/command/prompt_command.py +16 -9
- klaude_code/command/refresh_cmd.py +3 -2
- klaude_code/command/registry.py +31 -6
- klaude_code/command/release_notes_cmd.py +2 -1
- klaude_code/command/status_cmd.py +2 -1
- klaude_code/command/terminal_setup_cmd.py +2 -1
- klaude_code/command/thinking_cmd.py +2 -1
- klaude_code/core/executor.py +177 -190
- klaude_code/core/manager/sub_agent_manager.py +3 -0
- klaude_code/core/prompt.py +4 -1
- klaude_code/core/prompts/prompt-sub-agent-web.md +3 -3
- klaude_code/core/reminders.py +70 -26
- klaude_code/core/task.py +4 -5
- klaude_code/core/tool/__init__.py +2 -0
- klaude_code/core/tool/file/apply_patch_tool.py +3 -1
- klaude_code/core/tool/file/edit_tool.py +7 -5
- klaude_code/core/tool/file/multi_edit_tool.py +7 -5
- klaude_code/core/tool/file/read_tool.py +5 -2
- klaude_code/core/tool/file/write_tool.py +8 -6
- klaude_code/core/tool/shell/bash_tool.py +89 -17
- klaude_code/core/tool/sub_agent_tool.py +5 -1
- klaude_code/core/tool/tool_abc.py +18 -0
- klaude_code/core/tool/tool_context.py +6 -6
- klaude_code/core/tool/tool_runner.py +7 -7
- klaude_code/core/tool/web/web_fetch_tool.py +77 -22
- klaude_code/core/tool/web/web_search_tool.py +5 -1
- klaude_code/protocol/model.py +8 -1
- klaude_code/protocol/op.py +47 -0
- klaude_code/protocol/op_handler.py +25 -1
- klaude_code/protocol/sub_agent/web.py +1 -1
- klaude_code/session/codec.py +71 -0
- klaude_code/session/export.py +21 -11
- klaude_code/session/session.py +177 -333
- klaude_code/session/store.py +215 -0
- klaude_code/session/templates/export_session.html +13 -14
- klaude_code/ui/modes/repl/completers.py +1 -2
- klaude_code/ui/modes/repl/event_handler.py +7 -23
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +4 -6
- klaude_code/ui/rich/__init__.py +10 -1
- klaude_code/ui/rich/cjk_wrap.py +228 -0
- klaude_code/ui/rich/status.py +0 -1
- {klaude_code-1.2.19.dist-info → klaude_code-1.2.20.dist-info}/METADATA +2 -1
- {klaude_code-1.2.19.dist-info → klaude_code-1.2.20.dist-info}/RECORD +56 -53
- klaude_code/ui/utils/debouncer.py +0 -42
- {klaude_code-1.2.19.dist-info → klaude_code-1.2.20.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.19.dist-info → klaude_code-1.2.20.dist-info}/entry_points.txt +0 -0
klaude_code/session/session.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
return
|
|
86
|
+
def paths(cls) -> ProjectPaths:
|
|
87
|
+
return get_default_store().paths
|
|
91
88
|
|
|
92
89
|
@classmethod
|
|
93
|
-
def
|
|
94
|
-
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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 =
|
|
140
|
+
model_thinking = (
|
|
141
|
+
llm_param.Thinking.model_validate(model_thinking_raw) if isinstance(model_thinking_raw, dict) else None
|
|
142
|
+
)
|
|
149
143
|
|
|
150
|
-
|
|
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) ->
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
self.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
443
|
-
|
|
444
|
-
if not
|
|
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
|
-
|
|
447
|
-
if
|
|
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
|
-
|
|
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
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
544
|
-
|
|
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
|