klaude-code 1.2.6__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/__init__.py +0 -0
- klaude_code/cli/__init__.py +1 -0
- klaude_code/cli/main.py +298 -0
- klaude_code/cli/runtime.py +331 -0
- klaude_code/cli/session_cmd.py +80 -0
- klaude_code/command/__init__.py +43 -0
- klaude_code/command/clear_cmd.py +20 -0
- klaude_code/command/command_abc.py +92 -0
- klaude_code/command/diff_cmd.py +138 -0
- klaude_code/command/export_cmd.py +86 -0
- klaude_code/command/help_cmd.py +51 -0
- klaude_code/command/model_cmd.py +43 -0
- klaude_code/command/prompt-dev-docs-update.md +56 -0
- klaude_code/command/prompt-dev-docs.md +46 -0
- klaude_code/command/prompt-init.md +45 -0
- klaude_code/command/prompt_command.py +69 -0
- klaude_code/command/refresh_cmd.py +43 -0
- klaude_code/command/registry.py +110 -0
- klaude_code/command/status_cmd.py +111 -0
- klaude_code/command/terminal_setup_cmd.py +252 -0
- klaude_code/config/__init__.py +11 -0
- klaude_code/config/config.py +177 -0
- klaude_code/config/list_model.py +162 -0
- klaude_code/config/select_model.py +67 -0
- klaude_code/const/__init__.py +133 -0
- klaude_code/core/__init__.py +0 -0
- klaude_code/core/agent.py +165 -0
- klaude_code/core/executor.py +485 -0
- klaude_code/core/manager/__init__.py +19 -0
- klaude_code/core/manager/agent_manager.py +127 -0
- klaude_code/core/manager/llm_clients.py +42 -0
- klaude_code/core/manager/llm_clients_builder.py +49 -0
- klaude_code/core/manager/sub_agent_manager.py +86 -0
- klaude_code/core/prompt.py +89 -0
- klaude_code/core/prompts/prompt-claude-code.md +98 -0
- klaude_code/core/prompts/prompt-codex.md +331 -0
- klaude_code/core/prompts/prompt-gemini.md +43 -0
- klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
- klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
- klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
- klaude_code/core/prompts/prompt-subagent.md +8 -0
- klaude_code/core/reminders.py +445 -0
- klaude_code/core/task.py +237 -0
- klaude_code/core/tool/__init__.py +75 -0
- klaude_code/core/tool/file/__init__.py +0 -0
- klaude_code/core/tool/file/apply_patch.py +492 -0
- klaude_code/core/tool/file/apply_patch_tool.md +1 -0
- klaude_code/core/tool/file/apply_patch_tool.py +204 -0
- klaude_code/core/tool/file/edit_tool.md +9 -0
- klaude_code/core/tool/file/edit_tool.py +274 -0
- klaude_code/core/tool/file/multi_edit_tool.md +42 -0
- klaude_code/core/tool/file/multi_edit_tool.py +199 -0
- klaude_code/core/tool/file/read_tool.md +14 -0
- klaude_code/core/tool/file/read_tool.py +326 -0
- klaude_code/core/tool/file/write_tool.md +8 -0
- klaude_code/core/tool/file/write_tool.py +146 -0
- klaude_code/core/tool/memory/__init__.py +0 -0
- klaude_code/core/tool/memory/memory_tool.md +16 -0
- klaude_code/core/tool/memory/memory_tool.py +462 -0
- klaude_code/core/tool/memory/skill_loader.py +245 -0
- klaude_code/core/tool/memory/skill_tool.md +24 -0
- klaude_code/core/tool/memory/skill_tool.py +97 -0
- klaude_code/core/tool/shell/__init__.py +0 -0
- klaude_code/core/tool/shell/bash_tool.md +43 -0
- klaude_code/core/tool/shell/bash_tool.py +123 -0
- klaude_code/core/tool/shell/command_safety.py +363 -0
- klaude_code/core/tool/sub_agent_tool.py +83 -0
- klaude_code/core/tool/todo/__init__.py +0 -0
- klaude_code/core/tool/todo/todo_write_tool.md +182 -0
- klaude_code/core/tool/todo/todo_write_tool.py +121 -0
- klaude_code/core/tool/todo/update_plan_tool.md +3 -0
- klaude_code/core/tool/todo/update_plan_tool.py +104 -0
- klaude_code/core/tool/tool_abc.py +25 -0
- klaude_code/core/tool/tool_context.py +106 -0
- klaude_code/core/tool/tool_registry.py +78 -0
- klaude_code/core/tool/tool_runner.py +252 -0
- klaude_code/core/tool/truncation.py +170 -0
- klaude_code/core/tool/web/__init__.py +0 -0
- klaude_code/core/tool/web/mermaid_tool.md +21 -0
- klaude_code/core/tool/web/mermaid_tool.py +76 -0
- klaude_code/core/tool/web/web_fetch_tool.md +8 -0
- klaude_code/core/tool/web/web_fetch_tool.py +159 -0
- klaude_code/core/turn.py +220 -0
- klaude_code/llm/__init__.py +21 -0
- klaude_code/llm/anthropic/__init__.py +3 -0
- klaude_code/llm/anthropic/client.py +221 -0
- klaude_code/llm/anthropic/input.py +200 -0
- klaude_code/llm/client.py +49 -0
- klaude_code/llm/input_common.py +239 -0
- klaude_code/llm/openai_compatible/__init__.py +3 -0
- klaude_code/llm/openai_compatible/client.py +211 -0
- klaude_code/llm/openai_compatible/input.py +109 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
- klaude_code/llm/openrouter/__init__.py +3 -0
- klaude_code/llm/openrouter/client.py +200 -0
- klaude_code/llm/openrouter/input.py +160 -0
- klaude_code/llm/openrouter/reasoning_handler.py +209 -0
- klaude_code/llm/registry.py +22 -0
- klaude_code/llm/responses/__init__.py +3 -0
- klaude_code/llm/responses/client.py +216 -0
- klaude_code/llm/responses/input.py +167 -0
- klaude_code/llm/usage.py +109 -0
- klaude_code/protocol/__init__.py +4 -0
- klaude_code/protocol/commands.py +21 -0
- klaude_code/protocol/events.py +163 -0
- klaude_code/protocol/llm_param.py +147 -0
- klaude_code/protocol/model.py +287 -0
- klaude_code/protocol/op.py +89 -0
- klaude_code/protocol/op_handler.py +28 -0
- klaude_code/protocol/sub_agent.py +348 -0
- klaude_code/protocol/tools.py +15 -0
- klaude_code/session/__init__.py +4 -0
- klaude_code/session/export.py +624 -0
- klaude_code/session/selector.py +76 -0
- klaude_code/session/session.py +474 -0
- klaude_code/session/templates/export_session.html +1434 -0
- klaude_code/trace/__init__.py +3 -0
- klaude_code/trace/log.py +168 -0
- klaude_code/ui/__init__.py +91 -0
- klaude_code/ui/core/__init__.py +1 -0
- klaude_code/ui/core/display.py +103 -0
- klaude_code/ui/core/input.py +71 -0
- klaude_code/ui/core/stage_manager.py +55 -0
- klaude_code/ui/modes/__init__.py +1 -0
- klaude_code/ui/modes/debug/__init__.py +1 -0
- klaude_code/ui/modes/debug/display.py +36 -0
- klaude_code/ui/modes/exec/__init__.py +1 -0
- klaude_code/ui/modes/exec/display.py +63 -0
- klaude_code/ui/modes/repl/__init__.py +51 -0
- klaude_code/ui/modes/repl/clipboard.py +152 -0
- klaude_code/ui/modes/repl/completers.py +429 -0
- klaude_code/ui/modes/repl/display.py +60 -0
- klaude_code/ui/modes/repl/event_handler.py +375 -0
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
- klaude_code/ui/modes/repl/key_bindings.py +170 -0
- klaude_code/ui/modes/repl/renderer.py +281 -0
- klaude_code/ui/renderers/__init__.py +0 -0
- klaude_code/ui/renderers/assistant.py +21 -0
- klaude_code/ui/renderers/common.py +8 -0
- klaude_code/ui/renderers/developer.py +158 -0
- klaude_code/ui/renderers/diffs.py +215 -0
- klaude_code/ui/renderers/errors.py +16 -0
- klaude_code/ui/renderers/metadata.py +190 -0
- klaude_code/ui/renderers/sub_agent.py +71 -0
- klaude_code/ui/renderers/thinking.py +39 -0
- klaude_code/ui/renderers/tools.py +551 -0
- klaude_code/ui/renderers/user_input.py +65 -0
- klaude_code/ui/rich/__init__.py +1 -0
- klaude_code/ui/rich/live.py +65 -0
- klaude_code/ui/rich/markdown.py +308 -0
- klaude_code/ui/rich/quote.py +34 -0
- klaude_code/ui/rich/searchable_text.py +71 -0
- klaude_code/ui/rich/status.py +240 -0
- klaude_code/ui/rich/theme.py +274 -0
- klaude_code/ui/terminal/__init__.py +1 -0
- klaude_code/ui/terminal/color.py +244 -0
- klaude_code/ui/terminal/control.py +147 -0
- klaude_code/ui/terminal/notifier.py +107 -0
- klaude_code/ui/terminal/progress_bar.py +87 -0
- klaude_code/ui/utils/__init__.py +1 -0
- klaude_code/ui/utils/common.py +108 -0
- klaude_code/ui/utils/debouncer.py +42 -0
- klaude_code/version.py +163 -0
- klaude_code-1.2.6.dist-info/METADATA +178 -0
- klaude_code-1.2.6.dist-info/RECORD +167 -0
- klaude_code-1.2.6.dist-info/WHEEL +4 -0
- klaude_code-1.2.6.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from klaude_code.trace import log, log_debug
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from questionary import Choice
|
|
8
|
+
|
|
9
|
+
from .session import Session
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resume_select_session() -> str | None:
|
|
13
|
+
sessions = Session.list_sessions()
|
|
14
|
+
if not sessions:
|
|
15
|
+
log("No sessions found for this project.")
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
def _fmt(ts: float) -> str:
|
|
19
|
+
try:
|
|
20
|
+
return time.strftime("%m-%d %H:%M:%S", time.localtime(ts))
|
|
21
|
+
except Exception:
|
|
22
|
+
return str(ts)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import questionary
|
|
26
|
+
|
|
27
|
+
choices: list[Choice] = []
|
|
28
|
+
for s in sessions:
|
|
29
|
+
first_user_message = s.first_user_message or "N/A"
|
|
30
|
+
msg_count_display = "N/A" if s.messages_count == -1 else str(s.messages_count)
|
|
31
|
+
model_display = s.model_name or "N/A"
|
|
32
|
+
|
|
33
|
+
title = [
|
|
34
|
+
("class:d", f"{_fmt(s.updated_at):<16} "),
|
|
35
|
+
("class:b", f"{msg_count_display:>3} "),
|
|
36
|
+
(
|
|
37
|
+
"class:t",
|
|
38
|
+
f"{model_display[:29] + '…' if len(model_display) > 29 else model_display:<30} ",
|
|
39
|
+
),
|
|
40
|
+
(
|
|
41
|
+
"class:t",
|
|
42
|
+
f"{first_user_message.strip().replace('\n', ' ↩ '):<50}",
|
|
43
|
+
),
|
|
44
|
+
]
|
|
45
|
+
choices.append(questionary.Choice(title=title, value=s.id))
|
|
46
|
+
return questionary.select(
|
|
47
|
+
message=f"{' Updated at':<17} {'Msg':>3} {'Model':<30} {'First message':<50}",
|
|
48
|
+
choices=choices,
|
|
49
|
+
pointer="→",
|
|
50
|
+
instruction="↑↓ to move",
|
|
51
|
+
style=questionary.Style(
|
|
52
|
+
[
|
|
53
|
+
("t", ""),
|
|
54
|
+
("b", "bold"),
|
|
55
|
+
("d", "dim"),
|
|
56
|
+
]
|
|
57
|
+
),
|
|
58
|
+
).ask()
|
|
59
|
+
except Exception as e:
|
|
60
|
+
log_debug(f"Failed to use questionary for session select, {e}")
|
|
61
|
+
|
|
62
|
+
for i, s in enumerate(sessions, 1):
|
|
63
|
+
msg_count_display = "N/A" if s.messages_count == -1 else str(s.messages_count)
|
|
64
|
+
model_display = s.model_name or "N/A"
|
|
65
|
+
print(
|
|
66
|
+
f"{i}. {_fmt(s.updated_at)} {msg_count_display:>3} "
|
|
67
|
+
f"{model_display[:29] + '…' if len(model_display) > 29 else model_display:<30} {s.id} {s.work_dir}"
|
|
68
|
+
)
|
|
69
|
+
try:
|
|
70
|
+
raw = input("Select a session number: ").strip()
|
|
71
|
+
idx = int(raw)
|
|
72
|
+
if 1 <= idx <= len(sessions):
|
|
73
|
+
return str(sessions[idx - 1].id)
|
|
74
|
+
except Exception:
|
|
75
|
+
return None
|
|
76
|
+
return None
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import Iterable, Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from klaude_code.protocol import events, model
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Session(BaseModel):
|
|
14
|
+
id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
|
15
|
+
work_dir: Path
|
|
16
|
+
conversation_history: list[model.ConversationItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
17
|
+
sub_agent_state: model.SubAgentState | None = None
|
|
18
|
+
# FileTracker: track file path -> last modification time when last read/edited
|
|
19
|
+
file_tracker: dict[str, float] = Field(default_factory=dict)
|
|
20
|
+
# Todo list for the session
|
|
21
|
+
todos: list[model.TodoItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
22
|
+
# Messages count, redundant state for performance optimization to avoid reading entire jsonl file
|
|
23
|
+
messages_count: int = Field(default=0)
|
|
24
|
+
# Model name used for this session
|
|
25
|
+
# Used in list method SessionMetaBrief
|
|
26
|
+
model_name: str | None = None
|
|
27
|
+
# Timestamps (epoch seconds)
|
|
28
|
+
created_at: float = Field(default_factory=lambda: time.time())
|
|
29
|
+
updated_at: float = Field(default_factory=lambda: time.time())
|
|
30
|
+
|
|
31
|
+
# Reminder flags
|
|
32
|
+
loaded_memory: list[str] = Field(default_factory=list)
|
|
33
|
+
need_todo_empty_cooldown_counter: int = Field(exclude=True, default=0)
|
|
34
|
+
need_todo_not_used_cooldown_counter: int = Field(exclude=True, default=0)
|
|
35
|
+
|
|
36
|
+
# Internal: mapping for (de)serialization of conversation items
|
|
37
|
+
_TypeMap: ClassVar[dict[str, type[BaseModel]]] = {
|
|
38
|
+
# Messages
|
|
39
|
+
"SystemMessageItem": model.SystemMessageItem,
|
|
40
|
+
"DeveloperMessageItem": model.DeveloperMessageItem,
|
|
41
|
+
"UserMessageItem": model.UserMessageItem,
|
|
42
|
+
"AssistantMessageItem": model.AssistantMessageItem,
|
|
43
|
+
# Reasoning/Thinking
|
|
44
|
+
"ReasoningTextItem": model.ReasoningTextItem,
|
|
45
|
+
"ReasoningEncryptedItem": model.ReasoningEncryptedItem,
|
|
46
|
+
# Tools
|
|
47
|
+
"ToolCallItem": model.ToolCallItem,
|
|
48
|
+
"ToolResultItem": model.ToolResultItem,
|
|
49
|
+
# Stream/meta (not typically persisted in history, but supported)
|
|
50
|
+
"AssistantMessageDelta": model.AssistantMessageDelta,
|
|
51
|
+
"StartItem": model.StartItem,
|
|
52
|
+
"StreamErrorItem": model.StreamErrorItem,
|
|
53
|
+
"ResponseMetadataItem": model.ResponseMetadataItem,
|
|
54
|
+
"InterruptItem": model.InterruptItem,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _project_key() -> str:
|
|
59
|
+
# Derive a stable per-project key from current working directory
|
|
60
|
+
return str(Path.cwd()).strip("/").replace("/", "-")
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def _base_dir(cls) -> Path:
|
|
64
|
+
return Path.home() / ".klaude" / "projects" / cls._project_key()
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def _sessions_dir(cls) -> Path:
|
|
68
|
+
return cls._base_dir() / "sessions"
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def _messages_dir(cls) -> Path:
|
|
72
|
+
return cls._base_dir() / "messages"
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def _exports_dir(cls) -> Path:
|
|
76
|
+
return cls._base_dir() / "exports"
|
|
77
|
+
|
|
78
|
+
def _session_file(self) -> Path:
|
|
79
|
+
prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(self.created_at))
|
|
80
|
+
return self._sessions_dir() / f"{prefix}-{self.id}.json"
|
|
81
|
+
|
|
82
|
+
def _messages_file(self) -> Path:
|
|
83
|
+
prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(self.created_at))
|
|
84
|
+
return self._messages_dir() / f"{prefix}-{self.id}.jsonl"
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def load(cls, id: str) -> "Session":
|
|
88
|
+
# Load session metadata
|
|
89
|
+
sessions_dir = cls._sessions_dir()
|
|
90
|
+
session_candidates = sorted(
|
|
91
|
+
sessions_dir.glob(f"*-{id}.json"),
|
|
92
|
+
key=lambda p: p.stat().st_mtime,
|
|
93
|
+
reverse=True,
|
|
94
|
+
)
|
|
95
|
+
if not session_candidates:
|
|
96
|
+
# No existing session; create a new one
|
|
97
|
+
return Session(id=id, work_dir=Path.cwd())
|
|
98
|
+
session_path = session_candidates[0]
|
|
99
|
+
|
|
100
|
+
raw = json.loads(session_path.read_text())
|
|
101
|
+
|
|
102
|
+
# Basic fields (conversation history is loaded separately)
|
|
103
|
+
work_dir_str = raw.get("work_dir", str(Path.cwd()))
|
|
104
|
+
|
|
105
|
+
sub_agent_state_raw = raw.get("sub_agent_state")
|
|
106
|
+
sub_agent_state = model.SubAgentState(**sub_agent_state_raw) if sub_agent_state_raw else None
|
|
107
|
+
file_tracker = dict(raw.get("file_tracker", {}))
|
|
108
|
+
todos: list[model.TodoItem] = [model.TodoItem(**item) for item in raw.get("todos", [])]
|
|
109
|
+
loaded_memory = list(raw.get("loaded_memory", []))
|
|
110
|
+
created_at = float(raw.get("created_at", time.time()))
|
|
111
|
+
updated_at = float(raw.get("updated_at", created_at))
|
|
112
|
+
messages_count = int(raw.get("messages_count", 0))
|
|
113
|
+
model_name = raw.get("model_name")
|
|
114
|
+
|
|
115
|
+
sess = Session(
|
|
116
|
+
id=id,
|
|
117
|
+
work_dir=Path(work_dir_str),
|
|
118
|
+
sub_agent_state=sub_agent_state,
|
|
119
|
+
file_tracker=file_tracker,
|
|
120
|
+
todos=todos,
|
|
121
|
+
loaded_memory=loaded_memory,
|
|
122
|
+
created_at=created_at,
|
|
123
|
+
updated_at=updated_at,
|
|
124
|
+
messages_count=messages_count,
|
|
125
|
+
model_name=model_name,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Load conversation history from messages JSONL
|
|
129
|
+
messages_dir = cls._messages_dir()
|
|
130
|
+
# Expect a single messages file per session (prefixed filenames only)
|
|
131
|
+
msg_candidates = sorted(
|
|
132
|
+
messages_dir.glob(f"*-{id}.jsonl"),
|
|
133
|
+
key=lambda p: p.stat().st_mtime,
|
|
134
|
+
reverse=True,
|
|
135
|
+
)
|
|
136
|
+
if msg_candidates:
|
|
137
|
+
messages_path = msg_candidates[0]
|
|
138
|
+
history: list[model.ConversationItem] = []
|
|
139
|
+
for line in messages_path.read_text().splitlines():
|
|
140
|
+
line = line.strip()
|
|
141
|
+
if not line:
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
obj = json.loads(line)
|
|
145
|
+
t = obj.get("type")
|
|
146
|
+
data = obj.get("data", {})
|
|
147
|
+
cls_type = cls._TypeMap.get(t or "")
|
|
148
|
+
if cls_type is None:
|
|
149
|
+
continue
|
|
150
|
+
item = cls_type(**data)
|
|
151
|
+
# pyright: ignore[reportAssignmentType]
|
|
152
|
+
history.append(item) # type: ignore[arg-type]
|
|
153
|
+
except Exception:
|
|
154
|
+
# Best-effort load; skip malformed lines
|
|
155
|
+
continue
|
|
156
|
+
sess.conversation_history = history
|
|
157
|
+
# Update messages count based on loaded history (only UserMessageItem and AssistantMessageItem)
|
|
158
|
+
sess.messages_count = sum(
|
|
159
|
+
1 for it in history if isinstance(it, (model.UserMessageItem, model.AssistantMessageItem))
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return sess
|
|
163
|
+
|
|
164
|
+
def save(self):
|
|
165
|
+
# Ensure directories exist
|
|
166
|
+
sessions_dir = self._sessions_dir()
|
|
167
|
+
messages_dir = self._messages_dir()
|
|
168
|
+
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
messages_dir.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
|
|
171
|
+
# Persist session metadata (excluding conversation history)
|
|
172
|
+
# Update timestamps
|
|
173
|
+
if self.created_at <= 0:
|
|
174
|
+
self.created_at = time.time()
|
|
175
|
+
self.updated_at = time.time()
|
|
176
|
+
payload = {
|
|
177
|
+
"id": self.id,
|
|
178
|
+
"work_dir": str(self.work_dir),
|
|
179
|
+
"sub_agent_state": self.sub_agent_state.model_dump() if self.sub_agent_state else None,
|
|
180
|
+
"file_tracker": self.file_tracker,
|
|
181
|
+
"todos": [todo.model_dump() for todo in self.todos],
|
|
182
|
+
"loaded_memory": self.loaded_memory,
|
|
183
|
+
"created_at": self.created_at,
|
|
184
|
+
"updated_at": self.updated_at,
|
|
185
|
+
"messages_count": self.messages_count,
|
|
186
|
+
"model_name": self.model_name,
|
|
187
|
+
}
|
|
188
|
+
self._session_file().write_text(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
189
|
+
|
|
190
|
+
def append_history(self, items: Sequence[model.ConversationItem]):
|
|
191
|
+
# Append to in-memory history
|
|
192
|
+
self.conversation_history.extend(items)
|
|
193
|
+
# Update messages count (only UserMessageItem and AssistantMessageItem)
|
|
194
|
+
self.messages_count += sum(
|
|
195
|
+
1 for it in items if isinstance(it, (model.UserMessageItem, model.AssistantMessageItem))
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Incrementally persist to JSONL under messages directory
|
|
199
|
+
messages_dir = self._messages_dir()
|
|
200
|
+
messages_dir.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
mpath = self._messages_file()
|
|
202
|
+
|
|
203
|
+
with mpath.open("a", encoding="utf-8") as f:
|
|
204
|
+
for it in items:
|
|
205
|
+
# Serialize with explicit type tag for reliable load
|
|
206
|
+
t = it.__class__.__name__
|
|
207
|
+
data = it.model_dump(mode="json")
|
|
208
|
+
f.write(json.dumps({"type": t, "data": data}, ensure_ascii=False))
|
|
209
|
+
f.write("\n")
|
|
210
|
+
# Refresh metadata timestamp after history change
|
|
211
|
+
self.save()
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def most_recent_session_id(cls) -> str | None:
|
|
215
|
+
sessions_dir = cls._sessions_dir()
|
|
216
|
+
if not sessions_dir.exists():
|
|
217
|
+
return None
|
|
218
|
+
latest_id: str | None = None
|
|
219
|
+
latest_ts: float = -1.0
|
|
220
|
+
for p in sessions_dir.glob("*.json"):
|
|
221
|
+
try:
|
|
222
|
+
data = json.loads(p.read_text())
|
|
223
|
+
# Filter out sub-agent sessions
|
|
224
|
+
if data.get("sub_agent_state", None) is not None:
|
|
225
|
+
continue
|
|
226
|
+
sid = str(data.get("id", p.stem))
|
|
227
|
+
ts = float(data.get("updated_at", 0.0))
|
|
228
|
+
if ts <= 0:
|
|
229
|
+
ts = p.stat().st_mtime
|
|
230
|
+
if ts > latest_ts:
|
|
231
|
+
latest_ts = ts
|
|
232
|
+
latest_id = sid
|
|
233
|
+
except Exception:
|
|
234
|
+
continue
|
|
235
|
+
return latest_id
|
|
236
|
+
|
|
237
|
+
def need_turn_start(self, prev_item: model.ConversationItem | None, item: model.ConversationItem) -> bool:
|
|
238
|
+
# Emit TurnStartEvent when a new turn starts to show an empty line in replay history
|
|
239
|
+
if not isinstance(
|
|
240
|
+
item,
|
|
241
|
+
model.ReasoningEncryptedItem | model.ReasoningTextItem | model.AssistantMessageItem | model.ToolCallItem,
|
|
242
|
+
):
|
|
243
|
+
return False
|
|
244
|
+
if prev_item is None:
|
|
245
|
+
return True
|
|
246
|
+
if isinstance(
|
|
247
|
+
prev_item,
|
|
248
|
+
model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem,
|
|
249
|
+
):
|
|
250
|
+
return True
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
def get_history_item(self) -> Iterable[events.HistoryItemEvent]:
|
|
254
|
+
prev_item: model.ConversationItem | None = None
|
|
255
|
+
for it in self.conversation_history:
|
|
256
|
+
if self.need_turn_start(prev_item, it):
|
|
257
|
+
yield events.TurnStartEvent(
|
|
258
|
+
session_id=self.id,
|
|
259
|
+
)
|
|
260
|
+
match it:
|
|
261
|
+
case model.AssistantMessageItem() as am:
|
|
262
|
+
content = am.content or ""
|
|
263
|
+
yield events.AssistantMessageEvent(
|
|
264
|
+
content=content,
|
|
265
|
+
response_id=am.response_id,
|
|
266
|
+
session_id=self.id,
|
|
267
|
+
)
|
|
268
|
+
case model.ToolCallItem() as tc:
|
|
269
|
+
yield events.ToolCallEvent(
|
|
270
|
+
tool_call_id=tc.call_id,
|
|
271
|
+
tool_name=tc.name,
|
|
272
|
+
arguments=tc.arguments,
|
|
273
|
+
response_id=tc.response_id,
|
|
274
|
+
session_id=self.id,
|
|
275
|
+
is_replay=True,
|
|
276
|
+
)
|
|
277
|
+
case model.ToolResultItem() as tr:
|
|
278
|
+
yield events.ToolResultEvent(
|
|
279
|
+
tool_call_id=tr.call_id,
|
|
280
|
+
tool_name=str(tr.tool_name),
|
|
281
|
+
result=tr.output or "",
|
|
282
|
+
ui_extra=tr.ui_extra,
|
|
283
|
+
session_id=self.id,
|
|
284
|
+
status=tr.status,
|
|
285
|
+
is_replay=True,
|
|
286
|
+
)
|
|
287
|
+
# TODO: Replay Sub-Agent Events
|
|
288
|
+
case model.UserMessageItem() as um:
|
|
289
|
+
yield events.UserMessageEvent(
|
|
290
|
+
content=um.content or "",
|
|
291
|
+
session_id=self.id,
|
|
292
|
+
)
|
|
293
|
+
case model.ReasoningTextItem() as ri:
|
|
294
|
+
yield events.ThinkingEvent(
|
|
295
|
+
content=ri.content,
|
|
296
|
+
session_id=self.id,
|
|
297
|
+
)
|
|
298
|
+
case model.ResponseMetadataItem() as mt:
|
|
299
|
+
yield events.ResponseMetadataEvent(
|
|
300
|
+
session_id=self.id,
|
|
301
|
+
metadata=mt,
|
|
302
|
+
)
|
|
303
|
+
case model.InterruptItem():
|
|
304
|
+
yield events.InterruptEvent(
|
|
305
|
+
session_id=self.id,
|
|
306
|
+
)
|
|
307
|
+
case model.DeveloperMessageItem() as dm:
|
|
308
|
+
yield events.DeveloperMessageEvent(
|
|
309
|
+
session_id=self.id,
|
|
310
|
+
item=dm,
|
|
311
|
+
)
|
|
312
|
+
case _:
|
|
313
|
+
continue
|
|
314
|
+
prev_item = it
|
|
315
|
+
|
|
316
|
+
class SessionMetaBrief(BaseModel):
|
|
317
|
+
id: str
|
|
318
|
+
created_at: float
|
|
319
|
+
updated_at: float
|
|
320
|
+
work_dir: str
|
|
321
|
+
path: str
|
|
322
|
+
first_user_message: str | None = None
|
|
323
|
+
messages_count: int = -1 # -1 indicates N/A
|
|
324
|
+
model_name: str | None = None
|
|
325
|
+
|
|
326
|
+
@classmethod
|
|
327
|
+
def list_sessions(cls) -> list[SessionMetaBrief]:
|
|
328
|
+
"""List all sessions for the current project.
|
|
329
|
+
|
|
330
|
+
Returns a list of dicts with keys: id, created_at, updated_at, work_dir, path.
|
|
331
|
+
Sorted by updated_at descending.
|
|
332
|
+
"""
|
|
333
|
+
sessions_dir = cls._sessions_dir()
|
|
334
|
+
if not sessions_dir.exists():
|
|
335
|
+
return []
|
|
336
|
+
|
|
337
|
+
def _get_first_user_message(session_id: str, created_at: float) -> str | None:
|
|
338
|
+
"""Get the first user message from the session's jsonl file."""
|
|
339
|
+
messages_dir = cls._messages_dir()
|
|
340
|
+
if not messages_dir.exists():
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
# Find the messages file for this session
|
|
344
|
+
prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(created_at))
|
|
345
|
+
msg_file = messages_dir / f"{prefix}-{session_id}.jsonl"
|
|
346
|
+
|
|
347
|
+
if not msg_file.exists():
|
|
348
|
+
# Try to find by pattern if exact file doesn't exist
|
|
349
|
+
msg_candidates = sorted(
|
|
350
|
+
messages_dir.glob(f"*-{session_id}.jsonl"),
|
|
351
|
+
key=lambda p: p.stat().st_mtime,
|
|
352
|
+
reverse=True,
|
|
353
|
+
)
|
|
354
|
+
if not msg_candidates:
|
|
355
|
+
return None
|
|
356
|
+
msg_file = msg_candidates[0]
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
for line in msg_file.read_text().splitlines():
|
|
360
|
+
line = line.strip()
|
|
361
|
+
if not line:
|
|
362
|
+
continue
|
|
363
|
+
obj = json.loads(line)
|
|
364
|
+
if obj.get("type") == "UserMessageItem":
|
|
365
|
+
data = obj.get("data", {})
|
|
366
|
+
content = data.get("content", "")
|
|
367
|
+
if isinstance(content, str):
|
|
368
|
+
return content
|
|
369
|
+
elif isinstance(content, list) and content:
|
|
370
|
+
# Handle structured content - extract text
|
|
371
|
+
text_parts: list[str] = []
|
|
372
|
+
for part in content: # pyright: ignore[reportUnknownVariableType]
|
|
373
|
+
if (
|
|
374
|
+
isinstance(part, dict) and part.get("type") == "text" # pyright: ignore[reportUnknownMemberType]
|
|
375
|
+
):
|
|
376
|
+
text = part.get("text", "") # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
|
|
377
|
+
if isinstance(text, str):
|
|
378
|
+
text_parts.append(text)
|
|
379
|
+
return " ".join(text_parts) if text_parts else None
|
|
380
|
+
return None
|
|
381
|
+
except Exception:
|
|
382
|
+
return None
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
items: list[Session.SessionMetaBrief] = []
|
|
386
|
+
for p in sessions_dir.glob("*.json"):
|
|
387
|
+
try:
|
|
388
|
+
data = json.loads(p.read_text())
|
|
389
|
+
except Exception:
|
|
390
|
+
# Skip unreadable files
|
|
391
|
+
continue
|
|
392
|
+
# Filter out sub-agent sessions
|
|
393
|
+
if data.get("sub_agent_state", None) is not None:
|
|
394
|
+
continue
|
|
395
|
+
sid = str(data.get("id", p.stem))
|
|
396
|
+
created = float(data.get("created_at", p.stat().st_mtime))
|
|
397
|
+
updated = float(data.get("updated_at", p.stat().st_mtime))
|
|
398
|
+
work_dir = str(data.get("work_dir", ""))
|
|
399
|
+
|
|
400
|
+
# Get first user message
|
|
401
|
+
first_user_message = _get_first_user_message(sid, created)
|
|
402
|
+
|
|
403
|
+
# Get messages count from session data, no fallback
|
|
404
|
+
messages_count = int(data.get("messages_count", -1)) # -1 indicates N/A
|
|
405
|
+
|
|
406
|
+
# Get model name from session data
|
|
407
|
+
model_name = data.get("model_name")
|
|
408
|
+
|
|
409
|
+
items.append(
|
|
410
|
+
Session.SessionMetaBrief(
|
|
411
|
+
id=sid,
|
|
412
|
+
created_at=created,
|
|
413
|
+
updated_at=updated,
|
|
414
|
+
work_dir=work_dir,
|
|
415
|
+
path=str(p),
|
|
416
|
+
first_user_message=first_user_message,
|
|
417
|
+
messages_count=messages_count,
|
|
418
|
+
model_name=model_name,
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
# Sort by updated_at desc
|
|
422
|
+
items.sort(key=lambda d: d.updated_at, reverse=True)
|
|
423
|
+
return items
|
|
424
|
+
|
|
425
|
+
@classmethod
|
|
426
|
+
def clean_small_sessions(cls, min_messages: int = 5) -> int:
|
|
427
|
+
"""Remove sessions with fewer than min_messages messages.
|
|
428
|
+
|
|
429
|
+
Returns the number of sessions deleted.
|
|
430
|
+
"""
|
|
431
|
+
sessions = cls.list_sessions()
|
|
432
|
+
deleted_count = 0
|
|
433
|
+
|
|
434
|
+
for session_meta in sessions:
|
|
435
|
+
# Skip sessions with unknown message count
|
|
436
|
+
if session_meta.messages_count < 0:
|
|
437
|
+
continue
|
|
438
|
+
if session_meta.messages_count < min_messages:
|
|
439
|
+
cls._delete_session_files(session_meta.id, session_meta.created_at)
|
|
440
|
+
deleted_count += 1
|
|
441
|
+
|
|
442
|
+
return deleted_count
|
|
443
|
+
|
|
444
|
+
@classmethod
|
|
445
|
+
def clean_all_sessions(cls) -> int:
|
|
446
|
+
"""Remove all sessions for the current project.
|
|
447
|
+
|
|
448
|
+
Returns the number of sessions deleted.
|
|
449
|
+
"""
|
|
450
|
+
sessions = cls.list_sessions()
|
|
451
|
+
deleted_count = 0
|
|
452
|
+
|
|
453
|
+
for session_meta in sessions:
|
|
454
|
+
cls._delete_session_files(session_meta.id, session_meta.created_at)
|
|
455
|
+
deleted_count += 1
|
|
456
|
+
|
|
457
|
+
return deleted_count
|
|
458
|
+
|
|
459
|
+
@classmethod
|
|
460
|
+
def _delete_session_files(cls, session_id: str, created_at: float) -> None:
|
|
461
|
+
"""Delete session and messages files for a given session."""
|
|
462
|
+
sessions_dir = cls._sessions_dir()
|
|
463
|
+
messages_dir = cls._messages_dir()
|
|
464
|
+
|
|
465
|
+
# Delete session file
|
|
466
|
+
prefix = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(created_at))
|
|
467
|
+
session_file = sessions_dir / f"{prefix}-{session_id}.json"
|
|
468
|
+
if session_file.exists():
|
|
469
|
+
session_file.unlink()
|
|
470
|
+
|
|
471
|
+
# Delete messages file
|
|
472
|
+
messages_file = messages_dir / f"{prefix}-{session_id}.jsonl"
|
|
473
|
+
if messages_file.exists():
|
|
474
|
+
messages_file.unlink()
|