klaude-code 1.9.0__py3-none-any.whl → 2.0.0__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/auth/base.py +2 -6
- klaude_code/cli/auth_cmd.py +4 -4
- klaude_code/cli/list_model.py +1 -1
- klaude_code/cli/main.py +1 -1
- klaude_code/cli/runtime.py +7 -5
- klaude_code/cli/self_update.py +1 -1
- klaude_code/cli/session_cmd.py +1 -1
- klaude_code/command/clear_cmd.py +6 -2
- klaude_code/command/command_abc.py +2 -2
- klaude_code/command/debug_cmd.py +4 -4
- klaude_code/command/export_cmd.py +2 -2
- klaude_code/command/export_online_cmd.py +12 -12
- klaude_code/command/fork_session_cmd.py +29 -23
- klaude_code/command/help_cmd.py +4 -4
- klaude_code/command/model_cmd.py +4 -4
- klaude_code/command/model_select.py +1 -1
- klaude_code/command/prompt-commit.md +11 -2
- klaude_code/command/prompt_command.py +3 -3
- klaude_code/command/refresh_cmd.py +2 -2
- klaude_code/command/registry.py +7 -5
- klaude_code/command/release_notes_cmd.py +4 -4
- klaude_code/command/resume_cmd.py +15 -11
- klaude_code/command/status_cmd.py +4 -4
- klaude_code/command/terminal_setup_cmd.py +8 -8
- klaude_code/command/thinking_cmd.py +4 -4
- klaude_code/config/assets/builtin_config.yaml +16 -0
- klaude_code/config/builtin_config.py +16 -5
- klaude_code/config/config.py +7 -2
- klaude_code/const.py +146 -91
- klaude_code/core/agent.py +3 -12
- klaude_code/core/executor.py +21 -13
- klaude_code/core/manager/sub_agent_manager.py +71 -7
- klaude_code/core/prompts/prompt-sub-agent-image-gen.md +1 -0
- klaude_code/core/prompts/prompt-sub-agent-web.md +27 -1
- klaude_code/core/reminders.py +88 -69
- klaude_code/core/task.py +44 -45
- klaude_code/core/tool/file/apply_patch_tool.py +9 -9
- klaude_code/core/tool/file/diff_builder.py +3 -5
- klaude_code/core/tool/file/edit_tool.py +23 -23
- klaude_code/core/tool/file/move_tool.py +43 -43
- klaude_code/core/tool/file/read_tool.py +44 -39
- klaude_code/core/tool/file/write_tool.py +14 -14
- klaude_code/core/tool/report_back_tool.py +4 -4
- klaude_code/core/tool/shell/bash_tool.py +23 -23
- klaude_code/core/tool/skill/skill_tool.py +7 -7
- klaude_code/core/tool/sub_agent_tool.py +38 -9
- klaude_code/core/tool/todo/todo_write_tool.py +8 -8
- klaude_code/core/tool/todo/update_plan_tool.py +6 -6
- klaude_code/core/tool/tool_abc.py +2 -2
- klaude_code/core/tool/tool_context.py +27 -0
- klaude_code/core/tool/tool_runner.py +88 -42
- klaude_code/core/tool/truncation.py +38 -20
- klaude_code/core/tool/web/mermaid_tool.py +6 -7
- klaude_code/core/tool/web/web_fetch_tool.py +68 -30
- klaude_code/core/tool/web/web_search_tool.py +15 -17
- klaude_code/core/turn.py +120 -73
- klaude_code/llm/anthropic/client.py +79 -44
- klaude_code/llm/anthropic/input.py +116 -108
- klaude_code/llm/bedrock/client.py +8 -5
- klaude_code/llm/claude/client.py +18 -8
- klaude_code/llm/client.py +4 -3
- klaude_code/llm/codex/client.py +15 -9
- klaude_code/llm/google/client.py +122 -60
- klaude_code/llm/google/input.py +94 -108
- klaude_code/llm/image.py +123 -0
- klaude_code/llm/input_common.py +136 -189
- klaude_code/llm/openai_compatible/client.py +17 -7
- klaude_code/llm/openai_compatible/input.py +36 -66
- klaude_code/llm/openai_compatible/stream.py +119 -67
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +23 -11
- klaude_code/llm/openrouter/client.py +34 -9
- klaude_code/llm/openrouter/input.py +63 -64
- klaude_code/llm/openrouter/reasoning.py +22 -24
- klaude_code/llm/registry.py +20 -17
- klaude_code/llm/responses/client.py +107 -45
- klaude_code/llm/responses/input.py +115 -98
- klaude_code/llm/usage.py +52 -25
- klaude_code/protocol/__init__.py +1 -0
- klaude_code/protocol/events.py +16 -12
- klaude_code/protocol/llm_param.py +20 -2
- klaude_code/protocol/message.py +250 -0
- klaude_code/protocol/model.py +94 -281
- klaude_code/protocol/op.py +2 -2
- klaude_code/protocol/sub_agent/__init__.py +1 -0
- klaude_code/protocol/sub_agent/explore.py +10 -0
- klaude_code/protocol/sub_agent/image_gen.py +119 -0
- klaude_code/protocol/sub_agent/task.py +10 -0
- klaude_code/protocol/sub_agent/web.py +10 -0
- klaude_code/session/codec.py +6 -6
- klaude_code/session/export.py +261 -62
- klaude_code/session/selector.py +7 -24
- klaude_code/session/session.py +126 -54
- klaude_code/session/store.py +5 -32
- klaude_code/session/templates/export_session.html +1 -1
- klaude_code/session/templates/mermaid_viewer.html +1 -1
- klaude_code/trace/log.py +11 -6
- klaude_code/ui/core/input.py +1 -1
- klaude_code/ui/core/stage_manager.py +1 -8
- klaude_code/ui/modes/debug/display.py +2 -2
- klaude_code/ui/modes/repl/clipboard.py +2 -2
- klaude_code/ui/modes/repl/completers.py +18 -10
- klaude_code/ui/modes/repl/event_handler.py +136 -127
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
- klaude_code/ui/modes/repl/key_bindings.py +1 -1
- klaude_code/ui/modes/repl/renderer.py +107 -15
- klaude_code/ui/renderers/assistant.py +2 -2
- klaude_code/ui/renderers/common.py +65 -7
- klaude_code/ui/renderers/developer.py +7 -6
- klaude_code/ui/renderers/diffs.py +11 -11
- klaude_code/ui/renderers/mermaid_viewer.py +49 -2
- klaude_code/ui/renderers/metadata.py +33 -5
- klaude_code/ui/renderers/sub_agent.py +57 -16
- klaude_code/ui/renderers/thinking.py +37 -2
- klaude_code/ui/renderers/tools.py +180 -165
- klaude_code/ui/rich/live.py +3 -1
- klaude_code/ui/rich/markdown.py +39 -7
- klaude_code/ui/rich/quote.py +76 -1
- klaude_code/ui/rich/status.py +14 -8
- klaude_code/ui/rich/theme.py +8 -2
- klaude_code/ui/terminal/image.py +34 -0
- klaude_code/ui/terminal/notifier.py +2 -1
- klaude_code/ui/terminal/progress_bar.py +4 -4
- klaude_code/ui/terminal/selector.py +22 -4
- klaude_code/ui/utils/common.py +11 -2
- {klaude_code-1.9.0.dist-info → klaude_code-2.0.0.dist-info}/METADATA +4 -2
- klaude_code-2.0.0.dist-info/RECORD +229 -0
- klaude_code-1.9.0.dist-info/RECORD +0 -224
- {klaude_code-1.9.0.dist-info → klaude_code-2.0.0.dist-info}/WHEEL +0 -0
- {klaude_code-1.9.0.dist-info → klaude_code-2.0.0.dist-info}/entry_points.txt +0 -0
klaude_code/session/session.py
CHANGED
|
@@ -9,16 +9,13 @@ from typing import Any, cast
|
|
|
9
9
|
|
|
10
10
|
from pydantic import BaseModel, Field, PrivateAttr, ValidationError
|
|
11
11
|
|
|
12
|
-
from klaude_code.
|
|
13
|
-
from klaude_code.
|
|
12
|
+
from klaude_code.const import ProjectPaths, project_key_from_cwd
|
|
13
|
+
from klaude_code.protocol import events, llm_param, message, model, tools
|
|
14
|
+
from klaude_code.session.store import JsonlSessionStore, build_meta_snapshot
|
|
14
15
|
|
|
15
16
|
_DEFAULT_STORES: dict[str, JsonlSessionStore] = {}
|
|
16
17
|
|
|
17
18
|
|
|
18
|
-
def _project_key_from_cwd() -> str:
|
|
19
|
-
return str(Path.cwd()).strip("/").replace("/", "-")
|
|
20
|
-
|
|
21
|
-
|
|
22
19
|
def _read_json_dict(path: Path) -> dict[str, Any] | None:
|
|
23
20
|
try:
|
|
24
21
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -30,7 +27,7 @@ def _read_json_dict(path: Path) -> dict[str, Any] | None:
|
|
|
30
27
|
|
|
31
28
|
|
|
32
29
|
def get_default_store() -> JsonlSessionStore:
|
|
33
|
-
project_key =
|
|
30
|
+
project_key = project_key_from_cwd()
|
|
34
31
|
store = _DEFAULT_STORES.get(project_key)
|
|
35
32
|
if store is None:
|
|
36
33
|
store = JsonlSessionStore(project_key=project_key)
|
|
@@ -48,7 +45,7 @@ async def close_default_store() -> None:
|
|
|
48
45
|
class Session(BaseModel):
|
|
49
46
|
id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
|
50
47
|
work_dir: Path
|
|
51
|
-
conversation_history: list[
|
|
48
|
+
conversation_history: list[message.HistoryEvent] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
52
49
|
sub_agent_state: model.SubAgentState | None = None
|
|
53
50
|
file_tracker: dict[str, model.FileStatus] = Field(default_factory=dict)
|
|
54
51
|
todos: list[model.TodoItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
@@ -67,12 +64,12 @@ class Session(BaseModel):
|
|
|
67
64
|
|
|
68
65
|
@property
|
|
69
66
|
def messages_count(self) -> int:
|
|
70
|
-
"""Count of user, assistant messages, and tool
|
|
67
|
+
"""Count of user, assistant messages, and tool results in conversation history."""
|
|
71
68
|
if self._messages_count_cache is None:
|
|
72
69
|
self._messages_count_cache = sum(
|
|
73
70
|
1
|
|
74
71
|
for it in self.conversation_history
|
|
75
|
-
if isinstance(it, (
|
|
72
|
+
if isinstance(it, (message.UserMessage, message.AssistantMessage, message.ToolResultMessage))
|
|
76
73
|
)
|
|
77
74
|
return self._messages_count_cache
|
|
78
75
|
|
|
@@ -89,13 +86,15 @@ class Session(BaseModel):
|
|
|
89
86
|
|
|
90
87
|
if self._user_messages_cache is None:
|
|
91
88
|
self._user_messages_cache = [
|
|
92
|
-
|
|
89
|
+
message.join_text_parts(it.parts)
|
|
90
|
+
for it in self.conversation_history
|
|
91
|
+
if isinstance(it, message.UserMessage) and message.join_text_parts(it.parts)
|
|
93
92
|
]
|
|
94
93
|
return self._user_messages_cache
|
|
95
94
|
|
|
96
95
|
@staticmethod
|
|
97
96
|
def _project_key() -> str:
|
|
98
|
-
return
|
|
97
|
+
return project_key_from_cwd()
|
|
99
98
|
|
|
100
99
|
@classmethod
|
|
101
100
|
def paths(cls) -> ProjectPaths:
|
|
@@ -186,21 +185,25 @@ class Session(BaseModel):
|
|
|
186
185
|
session.conversation_history = store.load_history(id)
|
|
187
186
|
return session
|
|
188
187
|
|
|
189
|
-
def append_history(self, items: Sequence[
|
|
188
|
+
def append_history(self, items: Sequence[message.HistoryEvent]) -> None:
|
|
190
189
|
if not items:
|
|
191
190
|
return
|
|
192
191
|
|
|
193
192
|
self.conversation_history.extend(items)
|
|
194
193
|
self._invalidate_messages_count_cache()
|
|
195
194
|
|
|
196
|
-
new_user_messages = [
|
|
195
|
+
new_user_messages = [
|
|
196
|
+
message.join_text_parts(it.parts)
|
|
197
|
+
for it in items
|
|
198
|
+
if isinstance(it, message.UserMessage) and message.join_text_parts(it.parts)
|
|
199
|
+
]
|
|
197
200
|
if new_user_messages:
|
|
198
201
|
if self._user_messages_cache is None:
|
|
199
202
|
# Build from full history once to ensure correctness when resuming older sessions.
|
|
200
203
|
self._user_messages_cache = [
|
|
201
|
-
it.
|
|
204
|
+
message.join_text_parts(it.parts)
|
|
202
205
|
for it in self.conversation_history
|
|
203
|
-
if isinstance(it,
|
|
206
|
+
if isinstance(it, message.UserMessage) and message.join_text_parts(it.parts)
|
|
204
207
|
]
|
|
205
208
|
else:
|
|
206
209
|
self._user_messages_cache.extend(new_user_messages)
|
|
@@ -279,69 +282,91 @@ class Session(BaseModel):
|
|
|
279
282
|
latest_id = sid
|
|
280
283
|
return latest_id
|
|
281
284
|
|
|
282
|
-
def need_turn_start(self, prev_item:
|
|
283
|
-
if not isinstance(
|
|
284
|
-
item,
|
|
285
|
-
model.ReasoningTextItem | model.AssistantMessageItem | model.ToolCallItem,
|
|
286
|
-
):
|
|
285
|
+
def need_turn_start(self, prev_item: message.HistoryEvent | None, item: message.HistoryEvent) -> bool:
|
|
286
|
+
if not isinstance(item, message.AssistantMessage):
|
|
287
287
|
return False
|
|
288
288
|
if prev_item is None:
|
|
289
289
|
return True
|
|
290
|
-
return isinstance(prev_item,
|
|
290
|
+
return isinstance(prev_item, (message.UserMessage, message.ToolResultMessage, message.DeveloperMessage))
|
|
291
291
|
|
|
292
292
|
def get_history_item(self) -> Iterable[events.HistoryItemEvent]:
|
|
293
293
|
seen_sub_agent_sessions: set[str] = set()
|
|
294
|
-
prev_item:
|
|
294
|
+
prev_item: message.HistoryEvent | None = None
|
|
295
295
|
last_assistant_content: str = ""
|
|
296
296
|
report_back_result: str | None = None
|
|
297
|
+
history = self.conversation_history
|
|
298
|
+
history_len = len(history)
|
|
297
299
|
yield events.TaskStartEvent(session_id=self.id, sub_agent_state=self.sub_agent_state)
|
|
298
|
-
for it in
|
|
300
|
+
for idx, it in enumerate(history):
|
|
299
301
|
if self.need_turn_start(prev_item, it):
|
|
300
302
|
yield events.TurnStartEvent(session_id=self.id)
|
|
301
303
|
match it:
|
|
302
|
-
case
|
|
303
|
-
content = am.
|
|
304
|
-
|
|
304
|
+
case message.AssistantMessage() as am:
|
|
305
|
+
content = message.join_text_parts(am.parts)
|
|
306
|
+
images = [part for part in am.parts if isinstance(part, message.ImageFilePart)]
|
|
307
|
+
last_assistant_content = message.format_saved_images(images, content)
|
|
308
|
+
thinking_text = "".join(
|
|
309
|
+
part.text for part in am.parts if isinstance(part, message.ThinkingTextPart)
|
|
310
|
+
)
|
|
311
|
+
for image in images:
|
|
312
|
+
yield events.AssistantImageDeltaEvent(
|
|
313
|
+
file_path=image.file_path,
|
|
314
|
+
response_id=am.response_id,
|
|
315
|
+
session_id=self.id,
|
|
316
|
+
)
|
|
305
317
|
yield events.AssistantMessageEvent(
|
|
318
|
+
thinking_text=thinking_text,
|
|
306
319
|
content=content,
|
|
307
320
|
response_id=am.response_id,
|
|
308
321
|
session_id=self.id,
|
|
309
322
|
)
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
323
|
+
for part in am.parts:
|
|
324
|
+
if not isinstance(part, message.ToolCallPart):
|
|
325
|
+
continue
|
|
326
|
+
if part.tool_name == tools.REPORT_BACK:
|
|
327
|
+
report_back_result = part.arguments_json
|
|
328
|
+
yield events.ToolCallEvent(
|
|
329
|
+
tool_call_id=part.call_id,
|
|
330
|
+
tool_name=part.tool_name,
|
|
331
|
+
arguments=part.arguments_json,
|
|
332
|
+
response_id=am.response_id,
|
|
333
|
+
session_id=self.id,
|
|
334
|
+
)
|
|
335
|
+
if am.stop_reason == "aborted":
|
|
336
|
+
yield events.InterruptEvent(session_id=self.id)
|
|
337
|
+
case message.ToolResultMessage() as tr:
|
|
338
|
+
status = "success" if tr.status == "success" else "error"
|
|
339
|
+
# Check if this is the last tool result in the current turn
|
|
340
|
+
next_item = history[idx + 1] if idx + 1 < history_len else None
|
|
341
|
+
is_last_in_turn = not isinstance(next_item, message.ToolResultMessage)
|
|
321
342
|
yield events.ToolResultEvent(
|
|
322
343
|
tool_call_id=tr.call_id,
|
|
323
344
|
tool_name=str(tr.tool_name),
|
|
324
|
-
result=tr.
|
|
345
|
+
result=tr.output_text,
|
|
325
346
|
ui_extra=tr.ui_extra,
|
|
326
347
|
session_id=self.id,
|
|
327
|
-
status=
|
|
348
|
+
status=status,
|
|
328
349
|
task_metadata=tr.task_metadata,
|
|
350
|
+
is_last_in_turn=is_last_in_turn,
|
|
329
351
|
)
|
|
330
352
|
yield from self._iter_sub_agent_history(tr, seen_sub_agent_sessions)
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
case
|
|
334
|
-
|
|
353
|
+
if tr.status == "aborted":
|
|
354
|
+
yield events.InterruptEvent(session_id=self.id)
|
|
355
|
+
case message.UserMessage() as um:
|
|
356
|
+
images = [part for part in um.parts if isinstance(part, message.ImageURLPart)]
|
|
357
|
+
yield events.UserMessageEvent(
|
|
358
|
+
content=message.join_text_parts(um.parts),
|
|
359
|
+
session_id=self.id,
|
|
360
|
+
images=images or None,
|
|
361
|
+
)
|
|
335
362
|
case model.TaskMetadataItem() as mt:
|
|
336
363
|
yield events.TaskMetadataEvent(session_id=self.id, metadata=mt)
|
|
337
|
-
case
|
|
338
|
-
yield events.InterruptEvent(session_id=self.id)
|
|
339
|
-
case model.DeveloperMessageItem() as dm:
|
|
364
|
+
case message.DeveloperMessage() as dm:
|
|
340
365
|
yield events.DeveloperMessageEvent(session_id=self.id, item=dm)
|
|
341
|
-
case
|
|
366
|
+
case message.StreamErrorItem() as se:
|
|
342
367
|
yield events.ErrorEvent(error_message=se.error, can_retry=False, session_id=self.id)
|
|
343
|
-
case
|
|
344
|
-
|
|
368
|
+
case message.SystemMessage():
|
|
369
|
+
pass
|
|
345
370
|
prev_item = it
|
|
346
371
|
|
|
347
372
|
has_structured_output = report_back_result is not None
|
|
@@ -351,7 +376,7 @@ class Session(BaseModel):
|
|
|
351
376
|
)
|
|
352
377
|
|
|
353
378
|
def _iter_sub_agent_history(
|
|
354
|
-
self, tool_result:
|
|
379
|
+
self, tool_result: message.ToolResultMessage, seen_sub_agent_sessions: set[str]
|
|
355
380
|
) -> Iterable[events.HistoryItemEvent]:
|
|
356
381
|
ui_extra = tool_result.ui_extra
|
|
357
382
|
if not isinstance(ui_extra, model.SessionIdUIExtra):
|
|
@@ -393,14 +418,18 @@ class Session(BaseModel):
|
|
|
393
418
|
if not isinstance(obj_raw, dict):
|
|
394
419
|
continue
|
|
395
420
|
obj = cast(dict[str, Any], obj_raw)
|
|
396
|
-
if obj.get("type") != "
|
|
421
|
+
if obj.get("type") != "UserMessage":
|
|
397
422
|
continue
|
|
398
423
|
data_raw = obj.get("data")
|
|
399
424
|
if not isinstance(data_raw, dict):
|
|
400
425
|
continue
|
|
401
426
|
data = cast(dict[str, Any], data_raw)
|
|
402
|
-
|
|
403
|
-
|
|
427
|
+
try:
|
|
428
|
+
user_msg = message.UserMessage.model_validate(data)
|
|
429
|
+
except ValidationError:
|
|
430
|
+
continue
|
|
431
|
+
content = message.join_text_parts(user_msg.parts)
|
|
432
|
+
if content:
|
|
404
433
|
messages.append(content)
|
|
405
434
|
except (OSError, json.JSONDecodeError):
|
|
406
435
|
pass
|
|
@@ -457,6 +486,49 @@ class Session(BaseModel):
|
|
|
457
486
|
items.sort(key=lambda d: d.updated_at, reverse=True)
|
|
458
487
|
return items
|
|
459
488
|
|
|
489
|
+
@classmethod
|
|
490
|
+
def resolve_sub_agent_session_id(cls, resume: str) -> str:
|
|
491
|
+
"""Resolve a sub-agent session id from an id prefix.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
resume: Full session id or a unique prefix.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
The resolved full session id.
|
|
498
|
+
|
|
499
|
+
Raises:
|
|
500
|
+
ValueError: If resume is empty, not found, or ambiguous.
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
prefix = (resume or "").strip().lower()
|
|
504
|
+
if not prefix:
|
|
505
|
+
raise ValueError("resume cannot be empty")
|
|
506
|
+
|
|
507
|
+
store = get_default_store()
|
|
508
|
+
matches: set[str] = set()
|
|
509
|
+
|
|
510
|
+
for meta_path in store.iter_meta_files():
|
|
511
|
+
data = _read_json_dict(meta_path)
|
|
512
|
+
if data is None:
|
|
513
|
+
continue
|
|
514
|
+
# Only allow resuming sub-agent sessions.
|
|
515
|
+
if data.get("sub_agent_state") is None:
|
|
516
|
+
continue
|
|
517
|
+
sid = str(data.get("id", meta_path.parent.name)).strip()
|
|
518
|
+
if sid.lower().startswith(prefix):
|
|
519
|
+
matches.add(sid)
|
|
520
|
+
|
|
521
|
+
if not matches:
|
|
522
|
+
raise ValueError(f"resume id not found for this project: '{resume}'")
|
|
523
|
+
|
|
524
|
+
resolved = sorted(matches)
|
|
525
|
+
if len(resolved) > 1:
|
|
526
|
+
sample = ", ".join(resolved[:8])
|
|
527
|
+
suffix = "" if len(resolved) <= 8 else f" (+{len(resolved) - 8} more)"
|
|
528
|
+
raise ValueError(f"resume id is ambiguous: '{resume}' matches {sample}{suffix}")
|
|
529
|
+
|
|
530
|
+
return resolved[0]
|
|
531
|
+
|
|
460
532
|
@classmethod
|
|
461
533
|
def clean_small_sessions(cls, min_messages: int = 5) -> int:
|
|
462
534
|
sessions = cls.list_sessions()
|
klaude_code/session/store.py
CHANGED
|
@@ -8,36 +8,11 @@ from dataclasses import dataclass
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Any, cast
|
|
10
10
|
|
|
11
|
-
from klaude_code.
|
|
11
|
+
from klaude_code.const import ProjectPaths
|
|
12
|
+
from klaude_code.protocol import llm_param, message, model
|
|
12
13
|
from klaude_code.session.codec import decode_jsonl_line, encode_jsonl_line
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
@dataclass(frozen=True)
|
|
16
|
-
class ProjectPaths:
|
|
17
|
-
project_key: str
|
|
18
|
-
|
|
19
|
-
@property
|
|
20
|
-
def base_dir(self) -> Path:
|
|
21
|
-
return Path.home() / ".klaude" / "projects" / self.project_key
|
|
22
|
-
|
|
23
|
-
@property
|
|
24
|
-
def sessions_dir(self) -> Path:
|
|
25
|
-
return self.base_dir / "sessions"
|
|
26
|
-
|
|
27
|
-
@property
|
|
28
|
-
def exports_dir(self) -> Path:
|
|
29
|
-
return self.base_dir / "exports"
|
|
30
|
-
|
|
31
|
-
def session_dir(self, session_id: str) -> Path:
|
|
32
|
-
return self.sessions_dir / session_id
|
|
33
|
-
|
|
34
|
-
def events_file(self, session_id: str) -> Path:
|
|
35
|
-
return self.session_dir(session_id) / "events.jsonl"
|
|
36
|
-
|
|
37
|
-
def meta_file(self, session_id: str) -> Path:
|
|
38
|
-
return self.session_dir(session_id) / "meta.json"
|
|
39
|
-
|
|
40
|
-
|
|
41
16
|
class _WriterClosedError(RuntimeError):
|
|
42
17
|
pass
|
|
43
18
|
|
|
@@ -135,7 +110,7 @@ class JsonlSessionStore:
|
|
|
135
110
|
return None
|
|
136
111
|
return cast(dict[str, Any], raw) if isinstance(raw, dict) else None
|
|
137
112
|
|
|
138
|
-
def load_history(self, session_id: str) -> list[
|
|
113
|
+
def load_history(self, session_id: str) -> list[message.HistoryEvent]:
|
|
139
114
|
events_path = self._paths.events_file(session_id)
|
|
140
115
|
if not events_path.exists():
|
|
141
116
|
return []
|
|
@@ -143,7 +118,7 @@ class JsonlSessionStore:
|
|
|
143
118
|
lines = events_path.read_text(encoding="utf-8").splitlines()
|
|
144
119
|
except OSError:
|
|
145
120
|
return []
|
|
146
|
-
items: list[
|
|
121
|
+
items: list[message.HistoryEvent] = []
|
|
147
122
|
for line in lines:
|
|
148
123
|
item = decode_jsonl_line(line)
|
|
149
124
|
if item is None:
|
|
@@ -151,9 +126,7 @@ class JsonlSessionStore:
|
|
|
151
126
|
items.append(item)
|
|
152
127
|
return items
|
|
153
128
|
|
|
154
|
-
def append_and_flush(
|
|
155
|
-
self, *, session_id: str, items: Sequence[model.ConversationItem], meta: dict[str, Any]
|
|
156
|
-
) -> None:
|
|
129
|
+
def append_and_flush(self, *, session_id: str, items: Sequence[message.HistoryEvent], meta: dict[str, Any]) -> None:
|
|
157
130
|
if not items:
|
|
158
131
|
return
|
|
159
132
|
loop = asyncio.get_running_loop()
|
klaude_code/trace/log.py
CHANGED
|
@@ -13,7 +13,12 @@ from rich.console import Console
|
|
|
13
13
|
from rich.logging import RichHandler
|
|
14
14
|
from rich.text import Text
|
|
15
15
|
|
|
16
|
-
from klaude_code import
|
|
16
|
+
from klaude_code.const import (
|
|
17
|
+
DEFAULT_DEBUG_LOG_DIR,
|
|
18
|
+
DEFAULT_DEBUG_LOG_FILE,
|
|
19
|
+
LOG_BACKUP_COUNT,
|
|
20
|
+
LOG_MAX_BYTES,
|
|
21
|
+
)
|
|
17
22
|
|
|
18
23
|
# Module-level logger
|
|
19
24
|
logger = logging.getLogger("klaude_code")
|
|
@@ -123,13 +128,13 @@ def set_debug_logging(
|
|
|
123
128
|
file_path = None
|
|
124
129
|
|
|
125
130
|
if use_file and file_path is not None:
|
|
126
|
-
_prune_old_logs(
|
|
131
|
+
_prune_old_logs(DEFAULT_DEBUG_LOG_DIR, LOG_RETENTION_DAYS, LOG_MAX_TOTAL_BYTES)
|
|
127
132
|
|
|
128
133
|
if use_file and file_path is not None:
|
|
129
134
|
_file_handler = GzipRotatingFileHandler(
|
|
130
135
|
file_path,
|
|
131
|
-
maxBytes=
|
|
132
|
-
backupCount=
|
|
136
|
+
maxBytes=LOG_MAX_BYTES,
|
|
137
|
+
backupCount=LOG_BACKUP_COUNT,
|
|
133
138
|
encoding="utf-8",
|
|
134
139
|
)
|
|
135
140
|
_file_handler.setLevel(logging.DEBUG)
|
|
@@ -238,7 +243,7 @@ def _build_default_log_file_path() -> Path:
|
|
|
238
243
|
"""Build a per-session log path under the default log directory."""
|
|
239
244
|
|
|
240
245
|
now = datetime.now()
|
|
241
|
-
session_dir =
|
|
246
|
+
session_dir = DEFAULT_DEBUG_LOG_DIR / now.strftime("%Y-%m-%d")
|
|
242
247
|
session_dir.mkdir(parents=True, exist_ok=True)
|
|
243
248
|
filename = f"{now.strftime('%H%M%S')}-{os.getpid()}.log"
|
|
244
249
|
return session_dir / filename
|
|
@@ -247,7 +252,7 @@ def _build_default_log_file_path() -> Path:
|
|
|
247
252
|
def _refresh_latest_symlink(target: Path) -> None:
|
|
248
253
|
"""Point the debug.log symlink at the latest session file."""
|
|
249
254
|
|
|
250
|
-
latest =
|
|
255
|
+
latest = DEFAULT_DEBUG_LOG_FILE
|
|
251
256
|
try:
|
|
252
257
|
latest.unlink(missing_ok=True)
|
|
253
258
|
latest.symlink_to(target)
|
klaude_code/ui/core/input.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
4
|
from collections.abc import AsyncIterator
|
|
5
5
|
|
|
6
|
-
from klaude_code.protocol.
|
|
6
|
+
from klaude_code.protocol.message import UserInputPayload
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class InputProviderABC(ABC):
|
|
@@ -40,16 +40,9 @@ class StageManager:
|
|
|
40
40
|
return
|
|
41
41
|
await self.transition_to(Stage.THINKING)
|
|
42
42
|
|
|
43
|
-
async def finish_assistant(self) -> None:
|
|
44
|
-
if self._stage != Stage.ASSISTANT:
|
|
45
|
-
await self._finish_assistant()
|
|
46
|
-
return
|
|
47
|
-
await self._finish_assistant()
|
|
48
|
-
self._stage = Stage.WAITING
|
|
49
|
-
|
|
50
43
|
async def _leave_current_stage(self) -> None:
|
|
51
44
|
if self._stage == Stage.THINKING:
|
|
52
45
|
await self._finish_thinking()
|
|
53
46
|
elif self._stage == Stage.ASSISTANT:
|
|
54
|
-
await self.
|
|
47
|
+
await self._finish_assistant()
|
|
55
48
|
self._stage = Stage.WAITING
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from typing import override
|
|
3
3
|
|
|
4
|
-
from klaude_code import
|
|
4
|
+
from klaude_code.const import DEFAULT_DEBUG_LOG_FILE
|
|
5
5
|
from klaude_code.protocol import events
|
|
6
6
|
from klaude_code.trace import DebugType, log_debug
|
|
7
7
|
from klaude_code.ui.core.display import DisplayABC
|
|
@@ -11,7 +11,7 @@ class DebugEventDisplay(DisplayABC):
|
|
|
11
11
|
def __init__(
|
|
12
12
|
self,
|
|
13
13
|
wrapped_display: DisplayABC | None = None,
|
|
14
|
-
log_file: str | os.PathLike[str] =
|
|
14
|
+
log_file: str | os.PathLike[str] = DEFAULT_DEBUG_LOG_FILE,
|
|
15
15
|
):
|
|
16
16
|
self.wrapped_display = wrapped_display
|
|
17
17
|
self.log_file = log_file
|
|
@@ -19,7 +19,7 @@ from pathlib import Path
|
|
|
19
19
|
|
|
20
20
|
from PIL import Image, ImageGrab
|
|
21
21
|
|
|
22
|
-
from klaude_code.protocol.
|
|
22
|
+
from klaude_code.protocol.message import ImageURLPart
|
|
23
23
|
|
|
24
24
|
# Directory for storing clipboard images
|
|
25
25
|
CLIPBOARD_IMAGES_DIR = Path.home() / ".klaude" / "clipboard" / "images"
|
|
@@ -122,7 +122,7 @@ def _encode_image_file(file_path: str) -> ImageURLPart | None:
|
|
|
122
122
|
encoded = b64encode(f.read()).decode("ascii")
|
|
123
123
|
# Clipboard images are always saved as PNG
|
|
124
124
|
data_url = f"data:image/png;base64,{encoded}"
|
|
125
|
-
return ImageURLPart(
|
|
125
|
+
return ImageURLPart(url=data_url, id=None)
|
|
126
126
|
except OSError:
|
|
127
127
|
return None
|
|
128
128
|
|
|
@@ -27,6 +27,7 @@ from prompt_toolkit.completion import Completer, Completion
|
|
|
27
27
|
from prompt_toolkit.document import Document
|
|
28
28
|
from prompt_toolkit.formatted_text import FormattedText
|
|
29
29
|
|
|
30
|
+
from klaude_code.const import COMPLETER_CACHE_TTL_SEC, COMPLETER_CMD_TIMEOUT_SEC, COMPLETER_DEBOUNCE_SEC
|
|
30
31
|
from klaude_code.protocol.commands import CommandInfo
|
|
31
32
|
from klaude_code.trace.log import DebugType, log_debug
|
|
32
33
|
|
|
@@ -65,7 +66,7 @@ class _SlashCommandCompleter(Completer):
|
|
|
65
66
|
"""Complete slash commands at the beginning of the first line.
|
|
66
67
|
|
|
67
68
|
Behavior:
|
|
68
|
-
- Only triggers when cursor is on first line and text matches
|
|
69
|
+
- Only triggers when cursor is on first line and text matches /…
|
|
69
70
|
- Shows available slash commands with descriptions
|
|
70
71
|
- Inserts trailing space after completion
|
|
71
72
|
"""
|
|
@@ -132,7 +133,7 @@ class _SkillCompleter(Completer):
|
|
|
132
133
|
"""Complete skill names at the beginning of the first line.
|
|
133
134
|
|
|
134
135
|
Behavior:
|
|
135
|
-
- Only triggers when cursor is on first line and text matches $ or
|
|
136
|
+
- Only triggers when cursor is on first line and text matches $ or ¥…
|
|
136
137
|
- Shows available skills with descriptions
|
|
137
138
|
- Inserts trailing space after completion
|
|
138
139
|
"""
|
|
@@ -240,7 +241,7 @@ class _AtFilesCompleter(Completer):
|
|
|
240
241
|
"""Complete @path segments using fd or ripgrep.
|
|
241
242
|
|
|
242
243
|
Behavior:
|
|
243
|
-
- Only triggers when the cursor is after an "
|
|
244
|
+
- Only triggers when the cursor is after an "@…" token (until whitespace).
|
|
244
245
|
- Completes paths relative to the current working directory.
|
|
245
246
|
- Uses `fd` when available (files and directories), falls back to `rg --files` (files only).
|
|
246
247
|
- Debounces external commands and caches results to avoid excessive spawning.
|
|
@@ -251,8 +252,8 @@ class _AtFilesCompleter(Completer):
|
|
|
251
252
|
|
|
252
253
|
def __init__(
|
|
253
254
|
self,
|
|
254
|
-
debounce_sec: float =
|
|
255
|
-
cache_ttl_sec: float =
|
|
255
|
+
debounce_sec: float = COMPLETER_DEBOUNCE_SEC,
|
|
256
|
+
cache_ttl_sec: float = COMPLETER_CACHE_TTL_SEC,
|
|
256
257
|
max_results: int = 20,
|
|
257
258
|
):
|
|
258
259
|
self._debounce_sec = debounce_sec
|
|
@@ -283,7 +284,7 @@ class _AtFilesCompleter(Completer):
|
|
|
283
284
|
|
|
284
285
|
# Command timeout is intentionally higher than a keypress cadence.
|
|
285
286
|
# We rely on caching/narrowing to avoid calling fd repeatedly.
|
|
286
|
-
self._cmd_timeout_sec: float =
|
|
287
|
+
self._cmd_timeout_sec: float = COMPLETER_CMD_TIMEOUT_SEC
|
|
287
288
|
|
|
288
289
|
# ---- prompt_toolkit API ----
|
|
289
290
|
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: # type: ignore[override]
|
|
@@ -293,7 +294,7 @@ class _AtFilesCompleter(Completer):
|
|
|
293
294
|
return [] # type: ignore[reportUnknownVariableType]
|
|
294
295
|
|
|
295
296
|
frag = m.group("frag") # raw text after '@' and before cursor (may be quoted)
|
|
296
|
-
# Normalize fragment for search: support optional quoting syntax @"
|
|
297
|
+
# Normalize fragment for search: support optional quoting syntax @"…".
|
|
297
298
|
is_quoted = frag.startswith('"')
|
|
298
299
|
search_frag = frag
|
|
299
300
|
if is_quoted:
|
|
@@ -459,7 +460,7 @@ class _AtFilesCompleter(Completer):
|
|
|
459
460
|
# 4. Basename hit first, then path hit position, then length
|
|
460
461
|
# Since both fd and rg now search from current directory, all paths are relative to cwd
|
|
461
462
|
kn = keyword_norm
|
|
462
|
-
out: list[tuple[str, tuple[int, int, int, int, int, int, int]]] = []
|
|
463
|
+
out: list[tuple[str, tuple[int, int, int, int, int, int, int, int]]] = []
|
|
463
464
|
for p in paths_from_root:
|
|
464
465
|
pl = p.lower()
|
|
465
466
|
if kn not in pl:
|
|
@@ -481,11 +482,18 @@ class _AtFilesCompleter(Completer):
|
|
|
481
482
|
# Deprioritize paths containing "test"
|
|
482
483
|
has_test = "test" in pl
|
|
483
484
|
|
|
485
|
+
# Calculate basename match quality: how close is base to the keyword?
|
|
486
|
+
# Strip extension for files to compare stem (e.g., "renderer.py" -> "renderer")
|
|
487
|
+
base_stem = base.rsplit(".", 1)[0] if "." in base and not base.startswith(".") else base
|
|
488
|
+
# Exact stem match gets 0, otherwise difference in length
|
|
489
|
+
base_match_quality = abs(len(base_stem) - len(kn)) if base_pos != -1 else 10_000
|
|
490
|
+
|
|
484
491
|
score = (
|
|
485
492
|
1 if is_hidden else 0,
|
|
486
493
|
1 if has_test else 0,
|
|
487
|
-
|
|
488
|
-
|
|
494
|
+
0 if base_pos != -1 else 1, # basename match first
|
|
495
|
+
base_match_quality, # more precise basename match wins
|
|
496
|
+
depth, # then shallower paths
|
|
489
497
|
base_pos if base_pos != -1 else 10_000,
|
|
490
498
|
path_pos,
|
|
491
499
|
len(p),
|