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