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.
Files changed (167) hide show
  1. klaude_code/__init__.py +0 -0
  2. klaude_code/cli/__init__.py +1 -0
  3. klaude_code/cli/main.py +298 -0
  4. klaude_code/cli/runtime.py +331 -0
  5. klaude_code/cli/session_cmd.py +80 -0
  6. klaude_code/command/__init__.py +43 -0
  7. klaude_code/command/clear_cmd.py +20 -0
  8. klaude_code/command/command_abc.py +92 -0
  9. klaude_code/command/diff_cmd.py +138 -0
  10. klaude_code/command/export_cmd.py +86 -0
  11. klaude_code/command/help_cmd.py +51 -0
  12. klaude_code/command/model_cmd.py +43 -0
  13. klaude_code/command/prompt-dev-docs-update.md +56 -0
  14. klaude_code/command/prompt-dev-docs.md +46 -0
  15. klaude_code/command/prompt-init.md +45 -0
  16. klaude_code/command/prompt_command.py +69 -0
  17. klaude_code/command/refresh_cmd.py +43 -0
  18. klaude_code/command/registry.py +110 -0
  19. klaude_code/command/status_cmd.py +111 -0
  20. klaude_code/command/terminal_setup_cmd.py +252 -0
  21. klaude_code/config/__init__.py +11 -0
  22. klaude_code/config/config.py +177 -0
  23. klaude_code/config/list_model.py +162 -0
  24. klaude_code/config/select_model.py +67 -0
  25. klaude_code/const/__init__.py +133 -0
  26. klaude_code/core/__init__.py +0 -0
  27. klaude_code/core/agent.py +165 -0
  28. klaude_code/core/executor.py +485 -0
  29. klaude_code/core/manager/__init__.py +19 -0
  30. klaude_code/core/manager/agent_manager.py +127 -0
  31. klaude_code/core/manager/llm_clients.py +42 -0
  32. klaude_code/core/manager/llm_clients_builder.py +49 -0
  33. klaude_code/core/manager/sub_agent_manager.py +86 -0
  34. klaude_code/core/prompt.py +89 -0
  35. klaude_code/core/prompts/prompt-claude-code.md +98 -0
  36. klaude_code/core/prompts/prompt-codex.md +331 -0
  37. klaude_code/core/prompts/prompt-gemini.md +43 -0
  38. klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
  39. klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
  40. klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
  41. klaude_code/core/prompts/prompt-subagent.md +8 -0
  42. klaude_code/core/reminders.py +445 -0
  43. klaude_code/core/task.py +237 -0
  44. klaude_code/core/tool/__init__.py +75 -0
  45. klaude_code/core/tool/file/__init__.py +0 -0
  46. klaude_code/core/tool/file/apply_patch.py +492 -0
  47. klaude_code/core/tool/file/apply_patch_tool.md +1 -0
  48. klaude_code/core/tool/file/apply_patch_tool.py +204 -0
  49. klaude_code/core/tool/file/edit_tool.md +9 -0
  50. klaude_code/core/tool/file/edit_tool.py +274 -0
  51. klaude_code/core/tool/file/multi_edit_tool.md +42 -0
  52. klaude_code/core/tool/file/multi_edit_tool.py +199 -0
  53. klaude_code/core/tool/file/read_tool.md +14 -0
  54. klaude_code/core/tool/file/read_tool.py +326 -0
  55. klaude_code/core/tool/file/write_tool.md +8 -0
  56. klaude_code/core/tool/file/write_tool.py +146 -0
  57. klaude_code/core/tool/memory/__init__.py +0 -0
  58. klaude_code/core/tool/memory/memory_tool.md +16 -0
  59. klaude_code/core/tool/memory/memory_tool.py +462 -0
  60. klaude_code/core/tool/memory/skill_loader.py +245 -0
  61. klaude_code/core/tool/memory/skill_tool.md +24 -0
  62. klaude_code/core/tool/memory/skill_tool.py +97 -0
  63. klaude_code/core/tool/shell/__init__.py +0 -0
  64. klaude_code/core/tool/shell/bash_tool.md +43 -0
  65. klaude_code/core/tool/shell/bash_tool.py +123 -0
  66. klaude_code/core/tool/shell/command_safety.py +363 -0
  67. klaude_code/core/tool/sub_agent_tool.py +83 -0
  68. klaude_code/core/tool/todo/__init__.py +0 -0
  69. klaude_code/core/tool/todo/todo_write_tool.md +182 -0
  70. klaude_code/core/tool/todo/todo_write_tool.py +121 -0
  71. klaude_code/core/tool/todo/update_plan_tool.md +3 -0
  72. klaude_code/core/tool/todo/update_plan_tool.py +104 -0
  73. klaude_code/core/tool/tool_abc.py +25 -0
  74. klaude_code/core/tool/tool_context.py +106 -0
  75. klaude_code/core/tool/tool_registry.py +78 -0
  76. klaude_code/core/tool/tool_runner.py +252 -0
  77. klaude_code/core/tool/truncation.py +170 -0
  78. klaude_code/core/tool/web/__init__.py +0 -0
  79. klaude_code/core/tool/web/mermaid_tool.md +21 -0
  80. klaude_code/core/tool/web/mermaid_tool.py +76 -0
  81. klaude_code/core/tool/web/web_fetch_tool.md +8 -0
  82. klaude_code/core/tool/web/web_fetch_tool.py +159 -0
  83. klaude_code/core/turn.py +220 -0
  84. klaude_code/llm/__init__.py +21 -0
  85. klaude_code/llm/anthropic/__init__.py +3 -0
  86. klaude_code/llm/anthropic/client.py +221 -0
  87. klaude_code/llm/anthropic/input.py +200 -0
  88. klaude_code/llm/client.py +49 -0
  89. klaude_code/llm/input_common.py +239 -0
  90. klaude_code/llm/openai_compatible/__init__.py +3 -0
  91. klaude_code/llm/openai_compatible/client.py +211 -0
  92. klaude_code/llm/openai_compatible/input.py +109 -0
  93. klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
  94. klaude_code/llm/openrouter/__init__.py +3 -0
  95. klaude_code/llm/openrouter/client.py +200 -0
  96. klaude_code/llm/openrouter/input.py +160 -0
  97. klaude_code/llm/openrouter/reasoning_handler.py +209 -0
  98. klaude_code/llm/registry.py +22 -0
  99. klaude_code/llm/responses/__init__.py +3 -0
  100. klaude_code/llm/responses/client.py +216 -0
  101. klaude_code/llm/responses/input.py +167 -0
  102. klaude_code/llm/usage.py +109 -0
  103. klaude_code/protocol/__init__.py +4 -0
  104. klaude_code/protocol/commands.py +21 -0
  105. klaude_code/protocol/events.py +163 -0
  106. klaude_code/protocol/llm_param.py +147 -0
  107. klaude_code/protocol/model.py +287 -0
  108. klaude_code/protocol/op.py +89 -0
  109. klaude_code/protocol/op_handler.py +28 -0
  110. klaude_code/protocol/sub_agent.py +348 -0
  111. klaude_code/protocol/tools.py +15 -0
  112. klaude_code/session/__init__.py +4 -0
  113. klaude_code/session/export.py +624 -0
  114. klaude_code/session/selector.py +76 -0
  115. klaude_code/session/session.py +474 -0
  116. klaude_code/session/templates/export_session.html +1434 -0
  117. klaude_code/trace/__init__.py +3 -0
  118. klaude_code/trace/log.py +168 -0
  119. klaude_code/ui/__init__.py +91 -0
  120. klaude_code/ui/core/__init__.py +1 -0
  121. klaude_code/ui/core/display.py +103 -0
  122. klaude_code/ui/core/input.py +71 -0
  123. klaude_code/ui/core/stage_manager.py +55 -0
  124. klaude_code/ui/modes/__init__.py +1 -0
  125. klaude_code/ui/modes/debug/__init__.py +1 -0
  126. klaude_code/ui/modes/debug/display.py +36 -0
  127. klaude_code/ui/modes/exec/__init__.py +1 -0
  128. klaude_code/ui/modes/exec/display.py +63 -0
  129. klaude_code/ui/modes/repl/__init__.py +51 -0
  130. klaude_code/ui/modes/repl/clipboard.py +152 -0
  131. klaude_code/ui/modes/repl/completers.py +429 -0
  132. klaude_code/ui/modes/repl/display.py +60 -0
  133. klaude_code/ui/modes/repl/event_handler.py +375 -0
  134. klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
  135. klaude_code/ui/modes/repl/key_bindings.py +170 -0
  136. klaude_code/ui/modes/repl/renderer.py +281 -0
  137. klaude_code/ui/renderers/__init__.py +0 -0
  138. klaude_code/ui/renderers/assistant.py +21 -0
  139. klaude_code/ui/renderers/common.py +8 -0
  140. klaude_code/ui/renderers/developer.py +158 -0
  141. klaude_code/ui/renderers/diffs.py +215 -0
  142. klaude_code/ui/renderers/errors.py +16 -0
  143. klaude_code/ui/renderers/metadata.py +190 -0
  144. klaude_code/ui/renderers/sub_agent.py +71 -0
  145. klaude_code/ui/renderers/thinking.py +39 -0
  146. klaude_code/ui/renderers/tools.py +551 -0
  147. klaude_code/ui/renderers/user_input.py +65 -0
  148. klaude_code/ui/rich/__init__.py +1 -0
  149. klaude_code/ui/rich/live.py +65 -0
  150. klaude_code/ui/rich/markdown.py +308 -0
  151. klaude_code/ui/rich/quote.py +34 -0
  152. klaude_code/ui/rich/searchable_text.py +71 -0
  153. klaude_code/ui/rich/status.py +240 -0
  154. klaude_code/ui/rich/theme.py +274 -0
  155. klaude_code/ui/terminal/__init__.py +1 -0
  156. klaude_code/ui/terminal/color.py +244 -0
  157. klaude_code/ui/terminal/control.py +147 -0
  158. klaude_code/ui/terminal/notifier.py +107 -0
  159. klaude_code/ui/terminal/progress_bar.py +87 -0
  160. klaude_code/ui/utils/__init__.py +1 -0
  161. klaude_code/ui/utils/common.py +108 -0
  162. klaude_code/ui/utils/debouncer.py +42 -0
  163. klaude_code/version.py +163 -0
  164. klaude_code-1.2.6.dist-info/METADATA +178 -0
  165. klaude_code-1.2.6.dist-info/RECORD +167 -0
  166. klaude_code-1.2.6.dist-info/WHEEL +4 -0
  167. klaude_code-1.2.6.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,445 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Awaitable, Callable
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from klaude_code import const
8
+ from klaude_code.core.tool import BashTool, ReadTool, reset_tool_context, set_tool_context_from_session
9
+ from klaude_code.protocol import model, tools
10
+ from klaude_code.session import Session
11
+
12
+ type Reminder = Callable[[Session], Awaitable[model.DeveloperMessageItem | None]]
13
+
14
+
15
+ def get_last_new_user_input(session: Session) -> str | None:
16
+ """Get last user input & developer message (CLAUDE.md) from conversation history. if there's a tool result after user input, return None"""
17
+ result: list[str] = []
18
+ for item in reversed(session.conversation_history):
19
+ if isinstance(item, model.ToolResultItem):
20
+ return None
21
+ if isinstance(item, model.UserMessageItem):
22
+ result.append(item.content or "")
23
+ break
24
+ if isinstance(item, model.DeveloperMessageItem):
25
+ result.append(item.content or "")
26
+ return "\n\n".join(result)
27
+
28
+
29
+ async def at_file_reader_reminder(
30
+ session: Session,
31
+ ) -> model.DeveloperMessageItem | None:
32
+ """Parse @foo/bar to read"""
33
+ last_user_input = get_last_new_user_input(session)
34
+ if not last_user_input or "@" not in last_user_input.strip():
35
+ return None
36
+
37
+ at_patterns: list[str] = []
38
+
39
+ for item in last_user_input.strip().split():
40
+ if item.startswith("@") and len(item) > 1:
41
+ at_patterns.append(item.lower().strip("@"))
42
+
43
+ if len(at_patterns) == 0:
44
+ return None
45
+
46
+ at_files: dict[str, model.AtPatternParseResult] = {} # path -> content
47
+ collected_images: list[model.ImageURLPart] = []
48
+
49
+ for pattern in at_patterns:
50
+ path = Path(pattern).resolve()
51
+ context_token = set_tool_context_from_session(session)
52
+ try:
53
+ if path.exists() and path.is_file():
54
+ args = ReadTool.ReadArguments(file_path=str(path))
55
+ tool_result = await ReadTool.call_with_args(args)
56
+ at_result = model.AtPatternParseResult(
57
+ path=str(path),
58
+ tool_name=tools.READ,
59
+ result=tool_result.output or "",
60
+ tool_args=args.model_dump_json(exclude_none=True),
61
+ operation="Read",
62
+ images=tool_result.images,
63
+ )
64
+ at_files[str(path)] = at_result
65
+ if tool_result.images:
66
+ collected_images.extend(tool_result.images)
67
+ elif path.exists() and path.is_dir():
68
+ args = BashTool.BashArguments(command=f"ls {path}")
69
+ tool_result = await BashTool.call_with_args(args)
70
+ at_files[str(path)] = model.AtPatternParseResult(
71
+ path=str(path) + "/",
72
+ tool_name=tools.BASH,
73
+ result=tool_result.output or "",
74
+ tool_args=args.model_dump_json(exclude_none=True),
75
+ operation="List",
76
+ )
77
+ finally:
78
+ reset_tool_context(context_token)
79
+
80
+ if len(at_files) == 0:
81
+ return None
82
+
83
+ at_files_str = "\n\n".join(
84
+ [
85
+ f"""Called the {result.tool_name} tool with the following input: {result.tool_args}
86
+ Result of calling the {result.tool_name} tool:
87
+ {result.result}
88
+ """
89
+ for result in at_files.values()
90
+ ]
91
+ )
92
+ return model.DeveloperMessageItem(
93
+ content=f"""<system-reminder>{at_files_str}\n</system-reminder>""",
94
+ at_files=list(at_files.values()),
95
+ images=collected_images or None,
96
+ )
97
+
98
+
99
+ async def empty_todo_reminder(session: Session) -> model.DeveloperMessageItem | None:
100
+ """Remind agent to use TodoWrite tool when todos are empty/all completed.
101
+
102
+ Behavior:
103
+ - First time in empty state (counter == 0): trigger reminder and set cooldown (e.g., 3).
104
+ - While remaining in empty state with counter > 0: decrement each turn, no reminder.
105
+ - Do not decrement/reset while todos are non-empty (cooldown only counts during empty state).
106
+ """
107
+
108
+ empty_or_all_done = (not session.todos) or all(todo.status == "completed" for todo in session.todos)
109
+
110
+ # Only count down and possibly trigger when empty/all-done
111
+ if not empty_or_all_done:
112
+ return None
113
+
114
+ if session.need_todo_empty_cooldown_counter == 0:
115
+ session.need_todo_empty_cooldown_counter = 3
116
+ return model.DeveloperMessageItem(
117
+ content="""<system-reminder>This is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware. If you are working on tasks that would benefit from a todo list please use the TodoWrite tool to create one. If not, please feel free to ignore. Again do not mention this message to the user.</system-reminder>"""
118
+ )
119
+
120
+ if session.need_todo_empty_cooldown_counter > 0:
121
+ session.need_todo_empty_cooldown_counter -= 1
122
+ return None
123
+
124
+
125
+ async def todo_not_used_recently_reminder(
126
+ session: Session,
127
+ ) -> model.DeveloperMessageItem | None:
128
+ """Remind agent to use TodoWrite tool if it hasn't been used recently (>=10 other tool calls), with cooldown.
129
+
130
+ Cooldown behavior:
131
+ - When condition becomes active (>=10 non-todo tool calls since last TodoWrite) and counter == 0: trigger reminder, set counter = 3.
132
+ - While condition remains active and counter > 0: decrement each turn, do not remind.
133
+ - When condition not active: do nothing to the counter (no decrement), and do not remind.
134
+ """
135
+
136
+ if not session.todos:
137
+ return None
138
+
139
+ # If all todos completed, skip reminder entirely
140
+ if all(todo.status == "completed" for todo in session.todos):
141
+ return None
142
+
143
+ # Count non-todo tool calls since the last TodoWrite
144
+ other_tool_call_count_befor_last_todo = 0
145
+ for item in reversed(session.conversation_history):
146
+ if isinstance(item, model.ToolCallItem):
147
+ if item.name in (tools.TODO_WRITE, tools.UPDATE_PLAN):
148
+ break
149
+ other_tool_call_count_befor_last_todo += 1
150
+ if other_tool_call_count_befor_last_todo >= const.TODO_REMINDER_TOOL_CALL_THRESHOLD:
151
+ break
152
+
153
+ not_used_recently = other_tool_call_count_befor_last_todo >= const.TODO_REMINDER_TOOL_CALL_THRESHOLD
154
+
155
+ if not not_used_recently:
156
+ return None
157
+
158
+ if session.need_todo_not_used_cooldown_counter == 0:
159
+ session.need_todo_not_used_cooldown_counter = 3
160
+ return model.DeveloperMessageItem(
161
+ content=f"""<system-reminder>
162
+ The TodoWrite tool hasn't been used recently. If you're working on tasks that would benefit from tracking progress, consider using the TodoWrite tool to track progress. Also consider cleaning up the todo list if has become stale and no longer matches what you are working on. Only use it if it's relevant to the current work. This is just a gentle reminder - ignore if not applicable.
163
+
164
+
165
+ Here are the existing contents of your todo list:
166
+
167
+ {model.todo_list_str(session.todos)}</system-reminder>""",
168
+ todo_use=True,
169
+ )
170
+
171
+ if session.need_todo_not_used_cooldown_counter > 0:
172
+ session.need_todo_not_used_cooldown_counter -= 1
173
+ return None
174
+
175
+
176
+ async def file_changed_externally_reminder(
177
+ session: Session,
178
+ ) -> model.DeveloperMessageItem | None:
179
+ """Remind agent about user/linter' changes to the files in FileTracker, provding the newest content of the file."""
180
+ changed_files: list[tuple[str, str, list[model.ImageURLPart] | None]] = []
181
+ collected_images: list[model.ImageURLPart] = []
182
+ if session.file_tracker and len(session.file_tracker) > 0:
183
+ for path, mtime in session.file_tracker.items():
184
+ try:
185
+ if Path(path).stat().st_mtime > mtime:
186
+ context_token = set_tool_context_from_session(session)
187
+ try:
188
+ tool_result = await ReadTool.call_with_args(
189
+ ReadTool.ReadArguments(file_path=path)
190
+ ) # This tool will update file tracker
191
+ if tool_result.status == "success":
192
+ changed_files.append((path, tool_result.output or "", tool_result.images))
193
+ if tool_result.images:
194
+ collected_images.extend(tool_result.images)
195
+ finally:
196
+ reset_tool_context(context_token)
197
+ except (
198
+ FileNotFoundError,
199
+ IsADirectoryError,
200
+ OSError,
201
+ PermissionError,
202
+ UnicodeDecodeError,
203
+ ):
204
+ continue
205
+ if len(changed_files) > 0:
206
+ changed_files_str = "\n\n".join(
207
+ [
208
+ f"Note: {file_path} was modified, either by the user or by a linter. Don't tell the user this, since they are already aware. This change was intentional, so make sure to take it into account as you proceed (ie. don't revert it unless the user asks you to). So that you don't need to re-read the file, here's the result of running `cat -n` on a snippet of the edited file:\n\n{file_content}"
209
+ ""
210
+ for file_path, file_content, _ in changed_files
211
+ ]
212
+ )
213
+ return model.DeveloperMessageItem(
214
+ content=f"""<system-reminder>{changed_files_str}""",
215
+ external_file_changes=[file_path for file_path, _, _ in changed_files],
216
+ images=collected_images or None,
217
+ )
218
+
219
+ return None
220
+
221
+
222
+ def get_memory_paths() -> list[tuple[Path, str]]:
223
+ return [
224
+ (
225
+ Path.home() / ".claude" / "CLAUDE.md",
226
+ "user's private global instructions for all projects",
227
+ ),
228
+ (
229
+ Path.home() / ".codex" / "AGENTS.md",
230
+ "user's private global instructions for all projects",
231
+ ),
232
+ (Path.cwd() / "AGENTS.md", "project instructions, checked into the codebase"),
233
+ (Path.cwd() / "AGENT.md", "project instructions, checked into the codebase"),
234
+ (Path.cwd() / "CLAUDE.md", "project instructions, checked into the codebase"),
235
+ ]
236
+
237
+
238
+ class Memory(BaseModel):
239
+ path: str
240
+ instruction: str
241
+ content: str
242
+
243
+
244
+ def get_last_user_message_image_count(session: Session) -> int:
245
+ """Get image count from the last user message in conversation history."""
246
+ for item in reversed(session.conversation_history):
247
+ if isinstance(item, model.ToolResultItem):
248
+ return 0
249
+ if isinstance(item, model.UserMessageItem):
250
+ return len(item.images) if item.images else 0
251
+ return 0
252
+
253
+
254
+ async def image_reminder(session: Session) -> model.DeveloperMessageItem | None:
255
+ """Remind agent about images attached by user in the last message."""
256
+ image_count = get_last_user_message_image_count(session)
257
+ if image_count == 0:
258
+ return None
259
+
260
+ return model.DeveloperMessageItem(
261
+ content=f"<system-reminder>User attached {image_count} image{'s' if image_count > 1 else ''} in their message. Make sure to analyze and reference these images as needed.</system-reminder>",
262
+ user_image_count=image_count,
263
+ )
264
+
265
+
266
+ async def memory_reminder(session: Session) -> model.DeveloperMessageItem | None:
267
+ """CLAUDE.md AGENTS.md"""
268
+ memory_paths = get_memory_paths()
269
+ memories: list[Memory] = []
270
+ for memory_path, instruction in memory_paths:
271
+ if memory_path.exists() and memory_path.is_file() and str(memory_path) not in session.loaded_memory:
272
+ try:
273
+ text = memory_path.read_text()
274
+ session.loaded_memory.append(str(memory_path))
275
+ memories.append(Memory(path=str(memory_path), instruction=instruction, content=text))
276
+ except (PermissionError, UnicodeDecodeError, OSError):
277
+ continue
278
+ if len(memories) > 0:
279
+ memories_str = "\n\n".join(
280
+ [f"Contents of {memory.path} ({memory.instruction}):\n\n{memory.content}" for memory in memories]
281
+ )
282
+ return model.DeveloperMessageItem(
283
+ content=f"""<system-reminder>As you answer the user's questions, you can use the following context:
284
+
285
+ # claudeMd
286
+ Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.
287
+ {memories_str}
288
+
289
+ #important-instruction-reminders
290
+ Do what has been asked; nothing more, nothing less.
291
+ NEVER create files unless they're absolutely necessary for achieving your goal.
292
+ ALWAYS prefer editing an existing file to creating a new one.
293
+ NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
294
+
295
+ IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.
296
+ </system-reminder>""",
297
+ memory_paths=[memory.path for memory in memories],
298
+ )
299
+ return None
300
+
301
+
302
+ def get_last_turn_tool_call(session: Session) -> list[model.ToolCallItem]:
303
+ tool_calls: list[model.ToolCallItem] = []
304
+ for item in reversed(session.conversation_history):
305
+ if isinstance(item, model.ToolCallItem):
306
+ tool_calls.append(item)
307
+ if isinstance(
308
+ item,
309
+ (
310
+ model.ReasoningEncryptedItem,
311
+ model.ReasoningTextItem,
312
+ model.AssistantMessageItem,
313
+ ),
314
+ ):
315
+ break
316
+ return tool_calls
317
+
318
+
319
+ MEMORY_FILE_NAMES = ["CLAUDE.md", "AGENTS.md", "AGENT.md"]
320
+
321
+
322
+ async def last_path_memory_reminder(
323
+ session: Session,
324
+ ) -> model.DeveloperMessageItem | None:
325
+ """When last turn tool call entered a directory (or parent directory) with CLAUDE.md AGENTS.md"""
326
+ tool_calls = get_last_turn_tool_call(session)
327
+ if len(tool_calls) == 0:
328
+ return None
329
+ paths: list[str] = []
330
+ for tool_call in tool_calls:
331
+ if tool_call.name in (tools.READ, tools.EDIT, tools.MULTI_EDIT, tools.WRITE):
332
+ try:
333
+ json_dict = json.loads(tool_call.arguments)
334
+ if path := json_dict.get("file_path", ""):
335
+ paths.append(path)
336
+ except json.JSONDecodeError:
337
+ continue
338
+ elif tool_call.name == tools.BASH:
339
+ # TODO: haiku check file path
340
+ pass
341
+ paths = list(set(paths))
342
+ memories: list[Memory] = []
343
+ if len(paths) == 0:
344
+ return None
345
+
346
+ cwd = Path.cwd().resolve()
347
+ loaded_set: set[str] = set(session.loaded_memory)
348
+ seen_memory_files: set[str] = set()
349
+
350
+ for p_str in paths:
351
+ p = Path(p_str)
352
+ full = (cwd / p).resolve() if not p.is_absolute() else p.resolve()
353
+ try:
354
+ _ = full.relative_to(cwd)
355
+ except ValueError:
356
+ # Not under cwd; skip
357
+ continue
358
+
359
+ # Determine the deepest directory to scan (file parent or directory itself)
360
+ deepest_dir = full if full.is_dir() else full.parent
361
+
362
+ # Iterate each directory level from cwd to deepest_dir
363
+ try:
364
+ rel_parts = deepest_dir.relative_to(cwd).parts
365
+ except ValueError:
366
+ # Shouldn't happen due to check above, but guard anyway
367
+ continue
368
+
369
+ current_dir = cwd
370
+ for part in rel_parts:
371
+ current_dir = current_dir / part
372
+ for fname in MEMORY_FILE_NAMES:
373
+ mem_path = current_dir / fname
374
+ mem_path_str = str(mem_path)
375
+ if mem_path_str in seen_memory_files or mem_path_str in loaded_set:
376
+ continue
377
+ if mem_path.exists() and mem_path.is_file():
378
+ try:
379
+ text = mem_path.read_text()
380
+ except (PermissionError, UnicodeDecodeError, OSError):
381
+ continue
382
+ session.loaded_memory.append(mem_path_str)
383
+ loaded_set.add(mem_path_str)
384
+ seen_memory_files.add(mem_path_str)
385
+ memories.append(
386
+ Memory(
387
+ path=mem_path_str,
388
+ instruction="project instructions, discovered near last accessed path",
389
+ content=text,
390
+ )
391
+ )
392
+
393
+ if len(memories) > 0:
394
+ memories_str = "\n\n".join(
395
+ [f"Contents of {memory.path} ({memory.instruction}):\n\n{memory.content}" for memory in memories]
396
+ )
397
+ return model.DeveloperMessageItem(
398
+ content=f"""<system-reminder>{memories_str}
399
+ </system-reminder>""",
400
+ memory_paths=[memory.path for memory in memories],
401
+ )
402
+
403
+
404
+ ALL_REMINDERS = [
405
+ empty_todo_reminder,
406
+ todo_not_used_recently_reminder,
407
+ file_changed_externally_reminder,
408
+ memory_reminder,
409
+ last_path_memory_reminder,
410
+ at_file_reader_reminder,
411
+ image_reminder,
412
+ ]
413
+
414
+
415
+ def load_agent_reminders(
416
+ model_name: str, sub_agent_type: str | None = None, *, vanilla: bool = False
417
+ ) -> list[Reminder]:
418
+ """Get reminders for an agent based on model and agent type.
419
+
420
+ Args:
421
+ model_name: The model name.
422
+ sub_agent_type: If None, returns main agent reminders. Otherwise returns sub-agent reminders.
423
+ vanilla: If True, returns minimal vanilla reminders (ignores sub_agent_type).
424
+ """
425
+ if vanilla:
426
+ return [at_file_reader_reminder]
427
+
428
+ reminders: list[Reminder] = []
429
+
430
+ # Only main agent (not sub-agent) gets todo reminders, and not for GPT-5
431
+ if sub_agent_type is None and "gpt-5" not in model_name:
432
+ reminders.append(empty_todo_reminder)
433
+ reminders.append(todo_not_used_recently_reminder)
434
+
435
+ reminders.extend(
436
+ [
437
+ memory_reminder,
438
+ last_path_memory_reminder,
439
+ at_file_reader_reminder,
440
+ file_changed_externally_reminder,
441
+ image_reminder,
442
+ ]
443
+ )
444
+
445
+ return reminders
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import AsyncGenerator, Callable, MutableMapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from klaude_code import const
10
+ from klaude_code.core.reminders import Reminder
11
+ from klaude_code.core.tool import TodoContext, ToolABC
12
+ from klaude_code.core.turn import TurnError, TurnExecutionContext, TurnExecutor
13
+ from klaude_code.protocol import events, model
14
+ from klaude_code.trace import DebugType, log_debug
15
+
16
+ if TYPE_CHECKING:
17
+ from klaude_code.core.agent import AgentProfile
18
+
19
+
20
+ class MetadataAccumulator:
21
+ """Accumulates response metadata across multiple turns.
22
+
23
+ Tracks usage statistics including tokens, latency, and throughput,
24
+ merging them into a single aggregated result.
25
+ """
26
+
27
+ def __init__(self, model_name: str) -> None:
28
+ self._accumulated = model.ResponseMetadataItem(model_name=model_name)
29
+ self._throughput_weighted_sum: float = 0.0
30
+ self._throughput_tracked_tokens: int = 0
31
+
32
+ def add(self, turn_metadata: model.ResponseMetadataItem) -> None:
33
+ """Merge a turn's metadata into the accumulated state."""
34
+ accumulated = self._accumulated
35
+ usage = turn_metadata.usage
36
+
37
+ if usage is not None:
38
+ if accumulated.usage is None:
39
+ accumulated.usage = model.Usage()
40
+ acc_usage = accumulated.usage
41
+ acc_usage.input_tokens += usage.input_tokens
42
+ acc_usage.cached_tokens += usage.cached_tokens
43
+ acc_usage.reasoning_tokens += usage.reasoning_tokens
44
+ acc_usage.output_tokens += usage.output_tokens
45
+ acc_usage.total_tokens += usage.total_tokens
46
+
47
+ if usage.context_usage_percent is not None:
48
+ acc_usage.context_usage_percent = usage.context_usage_percent
49
+
50
+ if usage.first_token_latency_ms is not None:
51
+ if acc_usage.first_token_latency_ms is None:
52
+ acc_usage.first_token_latency_ms = usage.first_token_latency_ms
53
+ else:
54
+ acc_usage.first_token_latency_ms = min(
55
+ acc_usage.first_token_latency_ms,
56
+ usage.first_token_latency_ms,
57
+ )
58
+
59
+ if usage.throughput_tps is not None:
60
+ current_output = usage.output_tokens
61
+ if current_output > 0:
62
+ self._throughput_weighted_sum += usage.throughput_tps * current_output
63
+ self._throughput_tracked_tokens += current_output
64
+
65
+ # Accumulate costs
66
+ if usage.input_cost is not None:
67
+ acc_usage.input_cost = (acc_usage.input_cost or 0.0) + usage.input_cost
68
+ if usage.output_cost is not None:
69
+ acc_usage.output_cost = (acc_usage.output_cost or 0.0) + usage.output_cost
70
+ if usage.cache_read_cost is not None:
71
+ acc_usage.cache_read_cost = (acc_usage.cache_read_cost or 0.0) + usage.cache_read_cost
72
+ if usage.total_cost is not None:
73
+ acc_usage.total_cost = (acc_usage.total_cost or 0.0) + usage.total_cost
74
+
75
+ if turn_metadata.provider is not None:
76
+ accumulated.provider = turn_metadata.provider
77
+ if turn_metadata.model_name:
78
+ accumulated.model_name = turn_metadata.model_name
79
+ if turn_metadata.response_id:
80
+ accumulated.response_id = turn_metadata.response_id
81
+ if turn_metadata.status is not None:
82
+ accumulated.status = turn_metadata.status
83
+ if turn_metadata.error_reason is not None:
84
+ accumulated.error_reason = turn_metadata.error_reason
85
+
86
+ def finalize(self, task_duration_s: float) -> model.ResponseMetadataItem:
87
+ """Return the final accumulated metadata with computed throughput and duration."""
88
+ accumulated = self._accumulated
89
+ if accumulated.usage is not None:
90
+ if self._throughput_tracked_tokens > 0:
91
+ accumulated.usage.throughput_tps = self._throughput_weighted_sum / self._throughput_tracked_tokens
92
+ else:
93
+ accumulated.usage.throughput_tps = None
94
+
95
+ accumulated.task_duration_s = task_duration_s
96
+ return accumulated
97
+
98
+
99
+ @dataclass
100
+ class TaskExecutionContext:
101
+ """Execution context required to run a task."""
102
+
103
+ session_id: str
104
+ profile: AgentProfile
105
+ get_conversation_history: Callable[[], list[model.ConversationItem]]
106
+ append_history: Callable[[Sequence[model.ConversationItem]], None]
107
+ tool_registry: dict[str, type[ToolABC]]
108
+ file_tracker: MutableMapping[str, float]
109
+ todo_context: TodoContext
110
+ # For reminder processing - needs access to session
111
+ process_reminder: Callable[[Reminder], AsyncGenerator[events.DeveloperMessageEvent, None]]
112
+ sub_agent_state: model.SubAgentState | None
113
+
114
+
115
+ class TaskExecutor:
116
+ """Executes a complete task (multiple turns until no more tool calls).
117
+
118
+ Manages task-level state like metadata accumulation and retry logic.
119
+ """
120
+
121
+ def __init__(self, context: TaskExecutionContext) -> None:
122
+ self._context = context
123
+ self._current_turn: TurnExecutor | None = None
124
+ self._started_at: float = 0.0
125
+
126
+ @property
127
+ def current_turn(self) -> TurnExecutor | None:
128
+ return self._current_turn
129
+
130
+ def cancel(self) -> list[events.Event]:
131
+ """Cancel the current turn and return any resulting events."""
132
+ ui_events: list[events.Event] = []
133
+ if self._current_turn is not None:
134
+ ui_events.extend(self._current_turn.cancel())
135
+ self._current_turn = None
136
+ return ui_events
137
+
138
+ async def run(self, user_input: model.UserInputPayload) -> AsyncGenerator[events.Event, None]:
139
+ """Execute the task, yielding events as they occur."""
140
+ ctx = self._context
141
+ self._started_at = time.perf_counter()
142
+
143
+ yield events.TaskStartEvent(
144
+ session_id=ctx.session_id,
145
+ sub_agent_state=ctx.sub_agent_state,
146
+ )
147
+
148
+ ctx.append_history([model.UserMessageItem(content=user_input.text, images=user_input.images)])
149
+
150
+ profile = ctx.profile
151
+ metadata_accumulator = MetadataAccumulator(model_name=profile.llm_client.model_name)
152
+ last_assistant_message: events.AssistantMessageEvent | None = None
153
+
154
+ while True:
155
+ # Process reminders at the start of each turn
156
+ for reminder in profile.reminders:
157
+ async for event in ctx.process_reminder(reminder):
158
+ yield event
159
+
160
+ turn_context = TurnExecutionContext(
161
+ session_id=ctx.session_id,
162
+ get_conversation_history=ctx.get_conversation_history,
163
+ append_history=ctx.append_history,
164
+ llm_client=profile.llm_client,
165
+ system_prompt=profile.system_prompt,
166
+ tools=profile.tools,
167
+ tool_registry=ctx.tool_registry,
168
+ file_tracker=ctx.file_tracker,
169
+ todo_context=ctx.todo_context,
170
+ )
171
+
172
+ turn: TurnExecutor | None = None
173
+ turn_succeeded = False
174
+ last_error_message: str | None = None
175
+
176
+ for attempt in range(const.MAX_FAILED_TURN_RETRIES + 1):
177
+ turn = TurnExecutor(turn_context)
178
+ self._current_turn = turn
179
+
180
+ try:
181
+ async for turn_event in turn.run():
182
+ match turn_event:
183
+ case events.AssistantMessageEvent() as am:
184
+ if am.content.strip() != "":
185
+ last_assistant_message = am
186
+ yield am
187
+ case events.ResponseMetadataEvent() as e:
188
+ metadata_accumulator.add(e.metadata)
189
+ case _:
190
+ yield turn_event
191
+
192
+ turn_succeeded = True
193
+ break
194
+ except TurnError as e:
195
+ last_error_message = str(e)
196
+ if attempt < const.MAX_FAILED_TURN_RETRIES:
197
+ delay = _retry_delay_seconds(attempt + 1)
198
+ error_msg = f"Retrying {attempt + 1}/{const.MAX_FAILED_TURN_RETRIES} in {delay:.1f}s"
199
+ if last_error_message:
200
+ error_msg = f"{error_msg} - {last_error_message}"
201
+ yield events.ErrorEvent(error_message=error_msg, can_retry=True)
202
+ await asyncio.sleep(delay)
203
+ finally:
204
+ self._current_turn = None
205
+
206
+ if not turn_succeeded:
207
+ log_debug(
208
+ "Maximum consecutive failed turns reached, aborting task",
209
+ style="red",
210
+ debug_type=DebugType.EXECUTION,
211
+ )
212
+ final_error = f"Turn failed after {const.MAX_FAILED_TURN_RETRIES} retries."
213
+ if last_error_message:
214
+ final_error = f"{last_error_message}\n{final_error}"
215
+ yield events.ErrorEvent(error_message=final_error, can_retry=False)
216
+ return
217
+
218
+ if turn is None or not turn.has_tool_call:
219
+ break
220
+
221
+ # Finalize metadata
222
+ task_duration_s = time.perf_counter() - self._started_at
223
+ accumulated = metadata_accumulator.finalize(task_duration_s)
224
+
225
+ yield events.ResponseMetadataEvent(metadata=accumulated, session_id=ctx.session_id)
226
+ ctx.append_history([accumulated])
227
+ yield events.TaskFinishEvent(
228
+ session_id=ctx.session_id,
229
+ task_result=last_assistant_message.content if last_assistant_message else "",
230
+ )
231
+
232
+
233
+ def _retry_delay_seconds(attempt: int) -> float:
234
+ """Compute exponential backoff delay for the given attempt count."""
235
+ capped_attempt = max(1, attempt)
236
+ delay = const.INITIAL_RETRY_DELAY_S * (2 ** (capped_attempt - 1))
237
+ return min(delay, const.MAX_RETRY_DELAY_S)