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/core/reminders.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import re
|
|
2
2
|
import shlex
|
|
3
3
|
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from pydantic import BaseModel
|
|
@@ -31,6 +32,45 @@ def get_last_new_user_input(session: Session) -> str | None:
|
|
|
31
32
|
return "\n\n".join(result)
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
@dataclass
|
|
36
|
+
class AtPatternSource:
|
|
37
|
+
"""Represents an @ pattern with its source file (if from a memory file)."""
|
|
38
|
+
|
|
39
|
+
pattern: str
|
|
40
|
+
mentioned_in: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_at_patterns_with_source(session: Session) -> list[AtPatternSource]:
|
|
44
|
+
"""Get @ patterns from last user input and developer messages, preserving source info."""
|
|
45
|
+
patterns: list[AtPatternSource] = []
|
|
46
|
+
|
|
47
|
+
for item in reversed(session.conversation_history):
|
|
48
|
+
if isinstance(item, model.ToolResultItem):
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
if isinstance(item, model.UserMessageItem):
|
|
52
|
+
content = item.content or ""
|
|
53
|
+
if "@" in content:
|
|
54
|
+
for match in AT_FILE_PATTERN.finditer(content):
|
|
55
|
+
path_str = match.group("quoted") or match.group("plain")
|
|
56
|
+
if path_str:
|
|
57
|
+
patterns.append(AtPatternSource(pattern=path_str, mentioned_in=None))
|
|
58
|
+
break
|
|
59
|
+
|
|
60
|
+
if isinstance(item, model.DeveloperMessageItem):
|
|
61
|
+
content = item.content or ""
|
|
62
|
+
if "@" not in content:
|
|
63
|
+
continue
|
|
64
|
+
# Use first memory_path as the source if available
|
|
65
|
+
source = item.memory_paths[0] if item.memory_paths else None
|
|
66
|
+
for match in AT_FILE_PATTERN.finditer(content):
|
|
67
|
+
path_str = match.group("quoted") or match.group("plain")
|
|
68
|
+
if path_str:
|
|
69
|
+
patterns.append(AtPatternSource(pattern=path_str, mentioned_in=source))
|
|
70
|
+
|
|
71
|
+
return patterns
|
|
72
|
+
|
|
73
|
+
|
|
34
74
|
async def _load_at_file_recursive(
|
|
35
75
|
session: Session,
|
|
36
76
|
pattern: str,
|
|
@@ -99,33 +139,22 @@ async def at_file_reader_reminder(
|
|
|
99
139
|
session: Session,
|
|
100
140
|
) -> model.DeveloperMessageItem | None:
|
|
101
141
|
"""Parse @foo/bar to read, with recursive loading of nested @ references"""
|
|
102
|
-
|
|
103
|
-
if not
|
|
104
|
-
return None
|
|
105
|
-
|
|
106
|
-
at_patterns: list[str] = []
|
|
107
|
-
|
|
108
|
-
for match in AT_FILE_PATTERN.finditer(last_user_input):
|
|
109
|
-
quoted = match.group("quoted")
|
|
110
|
-
plain = match.group("plain")
|
|
111
|
-
path_str = quoted if quoted is not None else plain
|
|
112
|
-
if path_str:
|
|
113
|
-
at_patterns.append(path_str)
|
|
114
|
-
|
|
115
|
-
if len(at_patterns) == 0:
|
|
142
|
+
at_pattern_sources = get_at_patterns_with_source(session)
|
|
143
|
+
if not at_pattern_sources:
|
|
116
144
|
return None
|
|
117
145
|
|
|
118
146
|
at_files: dict[str, model.AtPatternParseResult] = {} # path -> content
|
|
119
147
|
collected_images: list[model.ImageURLPart] = []
|
|
120
148
|
visited: set[str] = set()
|
|
121
149
|
|
|
122
|
-
for
|
|
150
|
+
for source in at_pattern_sources:
|
|
123
151
|
await _load_at_file_recursive(
|
|
124
152
|
session,
|
|
125
|
-
pattern,
|
|
153
|
+
source.pattern,
|
|
126
154
|
at_files,
|
|
127
155
|
collected_images,
|
|
128
156
|
visited,
|
|
157
|
+
mentioned_in=source.mentioned_in,
|
|
129
158
|
)
|
|
130
159
|
|
|
131
160
|
if len(at_files) == 0:
|
|
@@ -231,9 +260,9 @@ async def file_changed_externally_reminder(
|
|
|
231
260
|
changed_files: list[tuple[str, str, list[model.ImageURLPart] | None]] = []
|
|
232
261
|
collected_images: list[model.ImageURLPart] = []
|
|
233
262
|
if session.file_tracker and len(session.file_tracker) > 0:
|
|
234
|
-
for path,
|
|
263
|
+
for path, status in session.file_tracker.items():
|
|
235
264
|
try:
|
|
236
|
-
if Path(path).stat().st_mtime > mtime:
|
|
265
|
+
if Path(path).stat().st_mtime > status.mtime:
|
|
237
266
|
context_token = set_tool_context_from_session(session)
|
|
238
267
|
try:
|
|
239
268
|
tool_result = await ReadTool.call_with_args(
|
|
@@ -282,6 +311,7 @@ def get_memory_paths() -> list[tuple[Path, str]]:
|
|
|
282
311
|
),
|
|
283
312
|
(Path.cwd() / "AGENTS.md", "project instructions, checked into the codebase"),
|
|
284
313
|
(Path.cwd() / "CLAUDE.md", "project instructions, checked into the codebase"),
|
|
314
|
+
(Path.cwd() / ".claude" / "CLAUDE.md", "project instructions, checked into the codebase"),
|
|
285
315
|
]
|
|
286
316
|
|
|
287
317
|
|
|
@@ -313,16 +343,32 @@ async def image_reminder(session: Session) -> model.DeveloperMessageItem | None:
|
|
|
313
343
|
)
|
|
314
344
|
|
|
315
345
|
|
|
346
|
+
def _is_memory_loaded(session: Session, path: str) -> bool:
|
|
347
|
+
"""Check if a memory file has already been loaded (tracked with is_memory=True)."""
|
|
348
|
+
status = session.file_tracker.get(path)
|
|
349
|
+
return status is not None and status.is_memory
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _mark_memory_loaded(session: Session, path: str) -> None:
|
|
353
|
+
"""Mark a file as loaded memory in file_tracker."""
|
|
354
|
+
try:
|
|
355
|
+
mtime = Path(path).stat().st_mtime
|
|
356
|
+
except (OSError, FileNotFoundError):
|
|
357
|
+
mtime = 0.0
|
|
358
|
+
session.file_tracker[path] = model.FileStatus(mtime=mtime, is_memory=True)
|
|
359
|
+
|
|
360
|
+
|
|
316
361
|
async def memory_reminder(session: Session) -> model.DeveloperMessageItem | None:
|
|
317
362
|
"""CLAUDE.md AGENTS.md"""
|
|
318
363
|
memory_paths = get_memory_paths()
|
|
319
364
|
memories: list[Memory] = []
|
|
320
365
|
for memory_path, instruction in memory_paths:
|
|
321
|
-
|
|
366
|
+
path_str = str(memory_path)
|
|
367
|
+
if memory_path.exists() and memory_path.is_file() and not _is_memory_loaded(session, path_str):
|
|
322
368
|
try:
|
|
323
369
|
text = memory_path.read_text()
|
|
324
|
-
session
|
|
325
|
-
memories.append(Memory(path=
|
|
370
|
+
_mark_memory_loaded(session, path_str)
|
|
371
|
+
memories.append(Memory(path=path_str, instruction=instruction, content=text))
|
|
326
372
|
except (PermissionError, UnicodeDecodeError, OSError):
|
|
327
373
|
continue
|
|
328
374
|
if len(memories) > 0:
|
|
@@ -358,7 +404,7 @@ async def last_path_memory_reminder(
|
|
|
358
404
|
"""Load CLAUDE.md/AGENTS.md from directories containing files in file_tracker.
|
|
359
405
|
|
|
360
406
|
Uses session.file_tracker to detect accessed paths (works for both tool calls
|
|
361
|
-
and @ file references).
|
|
407
|
+
and @ file references). Checks is_memory flag to avoid duplicate loading.
|
|
362
408
|
"""
|
|
363
409
|
if not session.file_tracker:
|
|
364
410
|
return None
|
|
@@ -367,7 +413,6 @@ async def last_path_memory_reminder(
|
|
|
367
413
|
memories: list[Memory] = []
|
|
368
414
|
|
|
369
415
|
cwd = Path.cwd().resolve()
|
|
370
|
-
loaded_set: set[str] = set(session.loaded_memory)
|
|
371
416
|
seen_memory_files: set[str] = set()
|
|
372
417
|
|
|
373
418
|
for p_str in paths:
|
|
@@ -395,15 +440,14 @@ async def last_path_memory_reminder(
|
|
|
395
440
|
for fname in MEMORY_FILE_NAMES:
|
|
396
441
|
mem_path = current_dir / fname
|
|
397
442
|
mem_path_str = str(mem_path)
|
|
398
|
-
if mem_path_str in seen_memory_files or mem_path_str
|
|
443
|
+
if mem_path_str in seen_memory_files or _is_memory_loaded(session, mem_path_str):
|
|
399
444
|
continue
|
|
400
445
|
if mem_path.exists() and mem_path.is_file():
|
|
401
446
|
try:
|
|
402
447
|
text = mem_path.read_text()
|
|
403
448
|
except (PermissionError, UnicodeDecodeError, OSError):
|
|
404
449
|
continue
|
|
405
|
-
session
|
|
406
|
-
loaded_set.add(mem_path_str)
|
|
450
|
+
_mark_memory_loaded(session, mem_path_str)
|
|
407
451
|
seen_memory_files.add(mem_path_str)
|
|
408
452
|
memories.append(
|
|
409
453
|
Memory(
|
klaude_code/core/task.py
CHANGED
|
@@ -2,13 +2,13 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import time
|
|
5
|
-
from collections.abc import AsyncGenerator, Callable,
|
|
5
|
+
from collections.abc import AsyncGenerator, Callable, Sequence
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
9
|
from klaude_code import const
|
|
10
10
|
from klaude_code.core.reminders import Reminder
|
|
11
|
-
from klaude_code.core.tool import TodoContext, ToolABC
|
|
11
|
+
from klaude_code.core.tool import FileTracker, TodoContext, ToolABC
|
|
12
12
|
from klaude_code.core.turn import TurnError, TurnExecutionContext, TurnExecutor
|
|
13
13
|
from klaude_code.protocol import events, model
|
|
14
14
|
from klaude_code.trace import DebugType, log_debug
|
|
@@ -29,6 +29,8 @@ class MetadataAccumulator:
|
|
|
29
29
|
self._sub_agent_metadata: list[model.TaskMetadata] = []
|
|
30
30
|
self._throughput_weighted_sum: float = 0.0
|
|
31
31
|
self._throughput_tracked_tokens: int = 0
|
|
32
|
+
self._first_token_latency_sum: float = 0.0
|
|
33
|
+
self._first_token_latency_count: int = 0
|
|
32
34
|
self._turn_count: int = 0
|
|
33
35
|
|
|
34
36
|
def add(self, turn_metadata: model.ResponseMetadataItem) -> None:
|
|
@@ -51,13 +53,8 @@ class MetadataAccumulator:
|
|
|
51
53
|
acc_usage.context_limit = usage.context_limit
|
|
52
54
|
|
|
53
55
|
if usage.first_token_latency_ms is not None:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
else:
|
|
57
|
-
acc_usage.first_token_latency_ms = min(
|
|
58
|
-
acc_usage.first_token_latency_ms,
|
|
59
|
-
usage.first_token_latency_ms,
|
|
60
|
-
)
|
|
56
|
+
self._first_token_latency_sum += usage.first_token_latency_ms
|
|
57
|
+
self._first_token_latency_count += 1
|
|
61
58
|
|
|
62
59
|
if usage.throughput_tps is not None:
|
|
63
60
|
current_output = usage.output_tokens
|
|
@@ -83,6 +80,11 @@ class MetadataAccumulator:
|
|
|
83
80
|
else:
|
|
84
81
|
main.usage.throughput_tps = None
|
|
85
82
|
|
|
83
|
+
if self._first_token_latency_count > 0:
|
|
84
|
+
main.usage.first_token_latency_ms = self._first_token_latency_sum / self._first_token_latency_count
|
|
85
|
+
else:
|
|
86
|
+
main.usage.first_token_latency_ms = None
|
|
87
|
+
|
|
86
88
|
main.task_duration_s = task_duration_s
|
|
87
89
|
main.turn_count = self._turn_count
|
|
88
90
|
return model.TaskMetadataItem(main=main, sub_agent_task_metadata=self._sub_agent_metadata)
|
|
@@ -98,7 +100,7 @@ class SessionContext:
|
|
|
98
100
|
session_id: str
|
|
99
101
|
get_conversation_history: Callable[[], list[model.ConversationItem]]
|
|
100
102
|
append_history: Callable[[Sequence[model.ConversationItem]], None]
|
|
101
|
-
file_tracker:
|
|
103
|
+
file_tracker: FileTracker
|
|
102
104
|
todo_context: TodoContext
|
|
103
105
|
|
|
104
106
|
|
|
@@ -147,8 +149,7 @@ class TaskExecutor:
|
|
|
147
149
|
session_id=session_ctx.session_id,
|
|
148
150
|
sub_agent_state=ctx.sub_agent_state,
|
|
149
151
|
)
|
|
150
|
-
|
|
151
|
-
session_ctx.append_history([model.UserMessageItem(content=user_input.text, images=user_input.images)])
|
|
152
|
+
del user_input # Persisted by the operation handler before launching the task.
|
|
152
153
|
|
|
153
154
|
profile = ctx.profile
|
|
154
155
|
metadata_accumulator = MetadataAccumulator(model_name=profile.llm_client.model_name)
|
|
@@ -15,6 +15,7 @@ from .todo.todo_write_tool import TodoWriteTool
|
|
|
15
15
|
from .todo.update_plan_tool import UpdatePlanTool
|
|
16
16
|
from .tool_abc import ToolABC
|
|
17
17
|
from .tool_context import (
|
|
18
|
+
FileTracker,
|
|
18
19
|
TodoContext,
|
|
19
20
|
ToolContextToken,
|
|
20
21
|
build_todo_context,
|
|
@@ -36,6 +37,7 @@ __all__ = [
|
|
|
36
37
|
"BashTool",
|
|
37
38
|
"DiffError",
|
|
38
39
|
"EditTool",
|
|
40
|
+
"FileTracker",
|
|
39
41
|
"MemoryTool",
|
|
40
42
|
"MermaidTool",
|
|
41
43
|
"MultiEditTool",
|
|
@@ -80,7 +80,9 @@ class ApplyPatchHandler:
|
|
|
80
80
|
|
|
81
81
|
if file_tracker is not None:
|
|
82
82
|
with contextlib.suppress(Exception): # pragma: no cover - file tracker best-effort
|
|
83
|
-
|
|
83
|
+
existing = file_tracker.get(resolved)
|
|
84
|
+
is_mem = existing.is_memory if existing else False
|
|
85
|
+
file_tracker[resolved] = model.FileStatus(mtime=Path(resolved).stat().st_mtime, is_memory=is_mem)
|
|
84
86
|
|
|
85
87
|
def remove_fn(path: str) -> None:
|
|
86
88
|
resolved = resolve_path(path)
|
|
@@ -119,8 +119,8 @@ class EditTool(ToolABC):
|
|
|
119
119
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
120
120
|
)
|
|
121
121
|
if file_tracker is not None:
|
|
122
|
-
|
|
123
|
-
if
|
|
122
|
+
tracked_status = file_tracker.get(file_path)
|
|
123
|
+
if tracked_status is None:
|
|
124
124
|
return model.ToolResultItem(
|
|
125
125
|
status="error",
|
|
126
126
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
@@ -128,8 +128,8 @@ class EditTool(ToolABC):
|
|
|
128
128
|
try:
|
|
129
129
|
current_mtime = Path(file_path).stat().st_mtime
|
|
130
130
|
except Exception:
|
|
131
|
-
current_mtime =
|
|
132
|
-
if current_mtime !=
|
|
131
|
+
current_mtime = tracked_status.mtime
|
|
132
|
+
if current_mtime != tracked_status.mtime:
|
|
133
133
|
return model.ToolResultItem(
|
|
134
134
|
status="error",
|
|
135
135
|
output=(
|
|
@@ -193,7 +193,9 @@ class EditTool(ToolABC):
|
|
|
193
193
|
# Update tracker with new mtime
|
|
194
194
|
if file_tracker is not None:
|
|
195
195
|
with contextlib.suppress(Exception):
|
|
196
|
-
|
|
196
|
+
existing = file_tracker.get(file_path)
|
|
197
|
+
is_mem = existing.is_memory if existing else False
|
|
198
|
+
file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
|
|
197
199
|
|
|
198
200
|
# Build output message
|
|
199
201
|
if args.replace_all:
|
|
@@ -92,8 +92,8 @@ class MultiEditTool(ToolABC):
|
|
|
92
92
|
# FileTracker check:
|
|
93
93
|
if file_exists(file_path):
|
|
94
94
|
if file_tracker is not None:
|
|
95
|
-
|
|
96
|
-
if
|
|
95
|
+
tracked_status = file_tracker.get(file_path)
|
|
96
|
+
if tracked_status is None:
|
|
97
97
|
return model.ToolResultItem(
|
|
98
98
|
status="error",
|
|
99
99
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
@@ -101,8 +101,8 @@ class MultiEditTool(ToolABC):
|
|
|
101
101
|
try:
|
|
102
102
|
current_mtime = Path(file_path).stat().st_mtime
|
|
103
103
|
except Exception:
|
|
104
|
-
current_mtime =
|
|
105
|
-
if current_mtime !=
|
|
104
|
+
current_mtime = tracked_status.mtime
|
|
105
|
+
if current_mtime != tracked_status.mtime:
|
|
106
106
|
return model.ToolResultItem(
|
|
107
107
|
status="error",
|
|
108
108
|
output=(
|
|
@@ -164,7 +164,9 @@ class MultiEditTool(ToolABC):
|
|
|
164
164
|
# Update tracker
|
|
165
165
|
if file_tracker is not None:
|
|
166
166
|
with contextlib.suppress(Exception):
|
|
167
|
-
|
|
167
|
+
existing = file_tracker.get(file_path)
|
|
168
|
+
is_mem = existing.is_memory if existing else False
|
|
169
|
+
file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
|
|
168
170
|
|
|
169
171
|
# Build output message
|
|
170
172
|
lines = [f"Applied {len(args.edits)} edits to {file_path}:"]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
Reads a file from the local filesystem. You can access any file directly by using this tool.
|
|
2
2
|
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
|
3
|
+
When you need to read an image, use this tool.
|
|
3
4
|
|
|
4
5
|
Usage:
|
|
5
6
|
- The file_path parameter must be an absolute path, not a relative path
|
|
@@ -11,4 +12,3 @@ Usage:
|
|
|
11
12
|
- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.
|
|
12
13
|
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
|
|
13
14
|
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
|
14
|
-
- This tool does NOT support reading PDF files. Use a Python script with `pdfplumber` (for text/tables) or `pypdf` (for basic operations) to extract content from PDFs.
|
|
@@ -106,12 +106,15 @@ def _read_segment(options: ReadOptions) -> ReadSegmentResult:
|
|
|
106
106
|
)
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
def _track_file_access(file_path: str) -> None:
|
|
109
|
+
def _track_file_access(file_path: str, *, is_memory: bool = False) -> None:
|
|
110
110
|
file_tracker = get_current_file_tracker()
|
|
111
111
|
if file_tracker is None or not file_exists(file_path) or is_directory(file_path):
|
|
112
112
|
return
|
|
113
113
|
with contextlib.suppress(Exception):
|
|
114
|
-
|
|
114
|
+
existing = file_tracker.get(file_path)
|
|
115
|
+
# Preserve is_memory flag if already set
|
|
116
|
+
is_mem = is_memory or (existing.is_memory if existing else False)
|
|
117
|
+
file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
|
|
115
118
|
|
|
116
119
|
|
|
117
120
|
def _is_supported_image_file(file_path: str) -> bool:
|
|
@@ -209,8 +212,9 @@ class ReadTool(ToolABC):
|
|
|
209
212
|
return model.ToolResultItem(
|
|
210
213
|
status="error",
|
|
211
214
|
output=(
|
|
212
|
-
"<tool_use_error>PDF files are not supported by this tool
|
|
213
|
-
"
|
|
215
|
+
"<tool_use_error>PDF files are not supported by this tool.\n"
|
|
216
|
+
"If there's an available skill for PDF, use it.\n"
|
|
217
|
+
"Or use a Python script with `pdfplumber` to extract text/tables:\n\n"
|
|
214
218
|
"```python\n"
|
|
215
219
|
"# /// script\n"
|
|
216
220
|
'# dependencies = ["pdfplumber"]\n'
|
|
@@ -64,10 +64,10 @@ class WriteTool(ToolABC):
|
|
|
64
64
|
exists = file_exists(file_path)
|
|
65
65
|
|
|
66
66
|
if exists:
|
|
67
|
-
|
|
67
|
+
tracked_status: model.FileStatus | None = None
|
|
68
68
|
if file_tracker is not None:
|
|
69
|
-
|
|
70
|
-
if
|
|
69
|
+
tracked_status = file_tracker.get(file_path)
|
|
70
|
+
if tracked_status is None:
|
|
71
71
|
return model.ToolResultItem(
|
|
72
72
|
status="error",
|
|
73
73
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
@@ -75,8 +75,8 @@ class WriteTool(ToolABC):
|
|
|
75
75
|
try:
|
|
76
76
|
current_mtime = Path(file_path).stat().st_mtime
|
|
77
77
|
except Exception:
|
|
78
|
-
current_mtime =
|
|
79
|
-
if current_mtime !=
|
|
78
|
+
current_mtime = tracked_status.mtime
|
|
79
|
+
if current_mtime != tracked_status.mtime:
|
|
80
80
|
return model.ToolResultItem(
|
|
81
81
|
status="error",
|
|
82
82
|
output=(
|
|
@@ -100,7 +100,9 @@ class WriteTool(ToolABC):
|
|
|
100
100
|
|
|
101
101
|
if file_tracker is not None:
|
|
102
102
|
with contextlib.suppress(Exception):
|
|
103
|
-
|
|
103
|
+
existing = file_tracker.get(file_path)
|
|
104
|
+
is_mem = existing.is_memory if existing else False
|
|
105
|
+
file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
|
|
104
106
|
|
|
105
107
|
# Build diff between previous and new content
|
|
106
108
|
after = args.content
|
|
@@ -132,20 +132,22 @@ class SkillLoader:
|
|
|
132
132
|
for user_dir in self.USER_SKILLS_DIRS:
|
|
133
133
|
expanded_dir = user_dir.expanduser()
|
|
134
134
|
if expanded_dir.exists():
|
|
135
|
-
for
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
for pattern in ("SKILL.md", "skill.md"):
|
|
136
|
+
for skill_file in expanded_dir.rglob(pattern):
|
|
137
|
+
skill = self.load_skill(skill_file, location="user")
|
|
138
|
+
if skill:
|
|
139
|
+
skills.append(skill)
|
|
140
|
+
self.loaded_skills[skill.name] = skill
|
|
140
141
|
|
|
141
142
|
# Load project-level skills (override user skills if same name)
|
|
142
143
|
project_dir = self.PROJECT_SKILLS_DIR.resolve()
|
|
143
144
|
if project_dir.exists():
|
|
144
|
-
for
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
for pattern in ("SKILL.md", "skill.md"):
|
|
146
|
+
for skill_file in project_dir.rglob(pattern):
|
|
147
|
+
skill = self.load_skill(skill_file, location="project")
|
|
148
|
+
if skill:
|
|
149
|
+
skills.append(skill)
|
|
150
|
+
self.loaded_skills[skill.name] = skill
|
|
149
151
|
|
|
150
152
|
# Log discovery summary
|
|
151
153
|
if skills:
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import os
|
|
2
4
|
import re
|
|
5
|
+
import signal
|
|
3
6
|
import subprocess
|
|
4
7
|
from pathlib import Path
|
|
5
8
|
|
|
@@ -87,22 +90,94 @@ class BashTool(ToolABC):
|
|
|
87
90
|
|
|
88
91
|
# Run the command using bash -lc so shell semantics work (pipes, &&, etc.)
|
|
89
92
|
# Capture stdout/stderr, respect timeout, and return a ToolMessage.
|
|
93
|
+
#
|
|
94
|
+
# Important: this tool is intentionally non-interactive.
|
|
95
|
+
# - Always detach stdin (DEVNULL) so interactive programs can't steal REPL input.
|
|
96
|
+
# - Always disable pagers/editors to avoid launching TUI subprocesses that can
|
|
97
|
+
# leave the terminal in a bad state.
|
|
90
98
|
cmd = ["bash", "-lc", args.command]
|
|
91
99
|
timeout_sec = max(0.0, args.timeout_ms / 1000.0)
|
|
92
100
|
|
|
101
|
+
env = os.environ.copy()
|
|
102
|
+
env.update(
|
|
103
|
+
{
|
|
104
|
+
# Avoid blocking on git/jj prompts.
|
|
105
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
106
|
+
# Avoid pagers.
|
|
107
|
+
"PAGER": "cat",
|
|
108
|
+
"GIT_PAGER": "cat",
|
|
109
|
+
# Avoid opening editors.
|
|
110
|
+
"EDITOR": "true",
|
|
111
|
+
"VISUAL": "true",
|
|
112
|
+
"GIT_EDITOR": "true",
|
|
113
|
+
"JJ_EDITOR": "true",
|
|
114
|
+
# Encourage non-interactive output.
|
|
115
|
+
"TERM": "dumb",
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
|
|
120
|
+
# Best-effort termination. Ensure we don't hang on cancellation.
|
|
121
|
+
if proc.returncode is not None:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
if os.name == "posix" and proc.pid is not None:
|
|
126
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
127
|
+
else:
|
|
128
|
+
proc.terminate()
|
|
129
|
+
except ProcessLookupError:
|
|
130
|
+
return
|
|
131
|
+
except Exception:
|
|
132
|
+
# Fall back to kill below.
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
with contextlib.suppress(Exception):
|
|
136
|
+
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# Escalate to hard kill if it didn't exit quickly.
|
|
140
|
+
with contextlib.suppress(Exception):
|
|
141
|
+
if os.name == "posix" and proc.pid is not None:
|
|
142
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
143
|
+
else:
|
|
144
|
+
proc.kill()
|
|
145
|
+
with contextlib.suppress(Exception):
|
|
146
|
+
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
147
|
+
|
|
93
148
|
try:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
149
|
+
# Create a dedicated process group so we can terminate the whole tree.
|
|
150
|
+
# (macOS/Linux support start_new_session; Windows does not.)
|
|
151
|
+
kwargs: dict[str, object] = {
|
|
152
|
+
"stdin": asyncio.subprocess.DEVNULL,
|
|
153
|
+
"stdout": asyncio.subprocess.PIPE,
|
|
154
|
+
"stderr": asyncio.subprocess.PIPE,
|
|
155
|
+
"env": env,
|
|
156
|
+
}
|
|
157
|
+
if os.name == "posix":
|
|
158
|
+
kwargs["start_new_session"] = True
|
|
159
|
+
elif os.name == "nt": # pragma: no cover
|
|
160
|
+
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
102
161
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
162
|
+
proc = await asyncio.create_subprocess_exec(*cmd, **kwargs)
|
|
163
|
+
try:
|
|
164
|
+
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
|
|
165
|
+
except TimeoutError:
|
|
166
|
+
with contextlib.suppress(Exception):
|
|
167
|
+
await _terminate_process(proc)
|
|
168
|
+
return model.ToolResultItem(
|
|
169
|
+
status="error",
|
|
170
|
+
output=f"Timeout after {args.timeout_ms} ms running: {args.command}",
|
|
171
|
+
)
|
|
172
|
+
except asyncio.CancelledError:
|
|
173
|
+
# Ensure subprocess is stopped and propagate cancellation.
|
|
174
|
+
with contextlib.suppress(Exception):
|
|
175
|
+
await asyncio.shield(_terminate_process(proc))
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
stdout = _ANSI_ESCAPE_RE.sub("", (stdout_b or b"").decode(errors="replace"))
|
|
179
|
+
stderr = _ANSI_ESCAPE_RE.sub("", (stderr_b or b"").decode(errors="replace"))
|
|
180
|
+
rc = proc.returncode
|
|
106
181
|
|
|
107
182
|
if rc == 0:
|
|
108
183
|
output = stdout if stdout else ""
|
|
@@ -125,17 +200,14 @@ class BashTool(ToolABC):
|
|
|
125
200
|
status="error",
|
|
126
201
|
output=combined.strip(),
|
|
127
202
|
)
|
|
128
|
-
|
|
129
|
-
except subprocess.TimeoutExpired:
|
|
130
|
-
return model.ToolResultItem(
|
|
131
|
-
status="error",
|
|
132
|
-
output=f"Timeout after {args.timeout_ms} ms running: {args.command}",
|
|
133
|
-
)
|
|
134
203
|
except FileNotFoundError:
|
|
135
204
|
return model.ToolResultItem(
|
|
136
205
|
status="error",
|
|
137
206
|
output="bash not found on system path",
|
|
138
207
|
)
|
|
208
|
+
except asyncio.CancelledError:
|
|
209
|
+
# Propagate cooperative cancellation so outer layers can handle interrupts correctly.
|
|
210
|
+
raise
|
|
139
211
|
except Exception as e: # safeguard against unexpected failures
|
|
140
212
|
return model.ToolResultItem(
|
|
141
213
|
status="error",
|
|
@@ -10,7 +10,7 @@ import asyncio
|
|
|
10
10
|
import json
|
|
11
11
|
from typing import TYPE_CHECKING, ClassVar
|
|
12
12
|
|
|
13
|
-
from klaude_code.core.tool.tool_abc import ToolABC
|
|
13
|
+
from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata
|
|
14
14
|
from klaude_code.core.tool.tool_context import current_run_subtask_callback
|
|
15
15
|
from klaude_code.protocol import llm_param, model
|
|
16
16
|
|
|
@@ -36,6 +36,10 @@ class SubAgentTool(ToolABC):
|
|
|
36
36
|
{"_profile": profile},
|
|
37
37
|
)
|
|
38
38
|
|
|
39
|
+
@classmethod
|
|
40
|
+
def metadata(cls) -> ToolMetadata:
|
|
41
|
+
return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=True)
|
|
42
|
+
|
|
39
43
|
@classmethod
|
|
40
44
|
def schema(cls) -> llm_param.ToolSchema:
|
|
41
45
|
profile = cls._profile
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import string
|
|
2
2
|
from abc import ABC, abstractmethod
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
|
|
5
7
|
from klaude_code.protocol import llm_param, model
|
|
@@ -14,6 +16,10 @@ def load_desc(path: Path, substitutions: dict[str, str] | None = None) -> str:
|
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
class ToolABC(ABC):
|
|
19
|
+
@classmethod
|
|
20
|
+
def metadata(cls) -> "ToolMetadata":
|
|
21
|
+
return ToolMetadata()
|
|
22
|
+
|
|
17
23
|
@classmethod
|
|
18
24
|
@abstractmethod
|
|
19
25
|
def schema(cls) -> llm_param.ToolSchema:
|
|
@@ -23,3 +29,15 @@ class ToolABC(ABC):
|
|
|
23
29
|
@abstractmethod
|
|
24
30
|
async def call(cls, arguments: str) -> model.ToolResultItem:
|
|
25
31
|
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ToolConcurrencyPolicy(str, Enum):
|
|
35
|
+
SEQUENTIAL = "sequential"
|
|
36
|
+
CONCURRENT = "concurrent"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ToolMetadata:
|
|
41
|
+
concurrency_policy: ToolConcurrencyPolicy = ToolConcurrencyPolicy.SEQUENTIAL
|
|
42
|
+
has_side_effects: bool = False
|
|
43
|
+
requires_tool_context: bool = True
|