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.
- klaude_code/cli/main.py +23 -0
- klaude_code/cli/runtime.py +17 -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 +12 -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 +90 -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/mermaid_tool.md +26 -0
- 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 +182 -331
- 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.21.dist-info}/METADATA +2 -1
- {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/RECORD +58 -55
- klaude_code/ui/utils/debouncer.py +0 -42
- {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.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,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
|
-
|
|
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
|
|
94
|
-
return
|
|
86
|
+
def paths(cls) -> ProjectPaths:
|
|
87
|
+
return get_default_store().paths
|
|
95
88
|
|
|
96
89
|
@classmethod
|
|
97
|
-
def
|
|
98
|
-
|
|
90
|
+
def exists(cls, id: str) -> bool:
|
|
91
|
+
"""Return True if a persisted session exists for the current project."""
|
|
99
92
|
|
|
100
|
-
|
|
101
|
-
|
|
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) ->
|
|
122
|
-
|
|
123
|
-
|
|
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) ->
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
147
|
+
model_thinking = (
|
|
148
|
+
llm_param.Thinking.model_validate(model_thinking_raw) if isinstance(model_thinking_raw, dict) else None
|
|
149
|
+
)
|
|
149
150
|
|
|
150
|
-
|
|
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) ->
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
self.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
443
|
-
|
|
444
|
-
if not
|
|
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
|
-
|
|
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
|
|
339
|
+
data_raw = obj.get("data")
|
|
340
|
+
if not isinstance(data_raw, dict):
|
|
463
341
|
return None
|
|
464
|
-
|
|
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
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
|
407
|
+
def exports_dir(cls) -> Path:
|
|
408
|
+
return get_default_store().paths.exports_dir
|