klaude-code 1.2.19__py3-none-any.whl → 1.2.21__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 (59) hide show
  1. klaude_code/cli/main.py +23 -0
  2. klaude_code/cli/runtime.py +17 -0
  3. klaude_code/command/__init__.py +1 -3
  4. klaude_code/command/clear_cmd.py +5 -4
  5. klaude_code/command/command_abc.py +5 -40
  6. klaude_code/command/debug_cmd.py +2 -2
  7. klaude_code/command/diff_cmd.py +2 -1
  8. klaude_code/command/export_cmd.py +14 -49
  9. klaude_code/command/export_online_cmd.py +2 -1
  10. klaude_code/command/help_cmd.py +2 -1
  11. klaude_code/command/model_cmd.py +7 -5
  12. klaude_code/command/prompt-jj-workspace.md +18 -0
  13. klaude_code/command/prompt_command.py +16 -9
  14. klaude_code/command/refresh_cmd.py +3 -2
  15. klaude_code/command/registry.py +31 -6
  16. klaude_code/command/release_notes_cmd.py +2 -1
  17. klaude_code/command/status_cmd.py +2 -1
  18. klaude_code/command/terminal_setup_cmd.py +2 -1
  19. klaude_code/command/thinking_cmd.py +12 -1
  20. klaude_code/core/executor.py +177 -190
  21. klaude_code/core/manager/sub_agent_manager.py +3 -0
  22. klaude_code/core/prompt.py +4 -1
  23. klaude_code/core/prompts/prompt-sub-agent-web.md +3 -3
  24. klaude_code/core/reminders.py +70 -26
  25. klaude_code/core/task.py +4 -5
  26. klaude_code/core/tool/__init__.py +2 -0
  27. klaude_code/core/tool/file/apply_patch_tool.py +3 -1
  28. klaude_code/core/tool/file/edit_tool.py +7 -5
  29. klaude_code/core/tool/file/multi_edit_tool.py +7 -5
  30. klaude_code/core/tool/file/read_tool.py +5 -2
  31. klaude_code/core/tool/file/write_tool.py +8 -6
  32. klaude_code/core/tool/shell/bash_tool.py +90 -17
  33. klaude_code/core/tool/sub_agent_tool.py +5 -1
  34. klaude_code/core/tool/tool_abc.py +18 -0
  35. klaude_code/core/tool/tool_context.py +6 -6
  36. klaude_code/core/tool/tool_runner.py +7 -7
  37. klaude_code/core/tool/web/mermaid_tool.md +26 -0
  38. klaude_code/core/tool/web/web_fetch_tool.py +77 -22
  39. klaude_code/core/tool/web/web_search_tool.py +5 -1
  40. klaude_code/protocol/model.py +8 -1
  41. klaude_code/protocol/op.py +47 -0
  42. klaude_code/protocol/op_handler.py +25 -1
  43. klaude_code/protocol/sub_agent/web.py +1 -1
  44. klaude_code/session/codec.py +71 -0
  45. klaude_code/session/export.py +21 -11
  46. klaude_code/session/session.py +182 -331
  47. klaude_code/session/store.py +215 -0
  48. klaude_code/session/templates/export_session.html +13 -14
  49. klaude_code/ui/modes/repl/completers.py +1 -2
  50. klaude_code/ui/modes/repl/event_handler.py +7 -23
  51. klaude_code/ui/modes/repl/input_prompt_toolkit.py +4 -6
  52. klaude_code/ui/rich/__init__.py +10 -1
  53. klaude_code/ui/rich/cjk_wrap.py +228 -0
  54. klaude_code/ui/rich/status.py +0 -1
  55. {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/METADATA +2 -1
  56. {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/RECORD +58 -55
  57. klaude_code/ui/utils/debouncer.py +0 -42
  58. {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/WHEEL +0 -0
  59. {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/entry_points.txt +0 -0
@@ -8,10 +8,12 @@ handling operations submitted from the CLI and coordinating with agents.
8
8
  from __future__ import annotations
9
9
 
10
10
  import asyncio
11
- from collections.abc import Awaitable, Callable
11
+ import subprocess
12
+ from collections.abc import Callable
12
13
  from dataclasses import dataclass
14
+ from pathlib import Path
13
15
 
14
- from klaude_code.command import InputAction, InputActionType, dispatch_command
16
+ from klaude_code.command import dispatch_command
15
17
  from klaude_code.config import load_config
16
18
  from klaude_code.core.agent import Agent, DefaultModelProfileProvider, ModelProfileProvider
17
19
  from klaude_code.core.manager import LLMClients, SubAgentManager
@@ -20,6 +22,7 @@ from klaude_code.llm.registry import create_llm_client
20
22
  from klaude_code.protocol import commands, events, model, op
21
23
  from klaude_code.protocol.op_handler import OperationHandler
22
24
  from klaude_code.protocol.sub_agent import SubAgentResult
25
+ from klaude_code.session.export import build_export_html, get_default_export_path
23
26
  from klaude_code.session.session import Session
24
27
  from klaude_code.trace import DebugType, log_debug
25
28
 
@@ -76,174 +79,6 @@ class TaskManager:
76
79
  self._tasks.clear()
77
80
 
78
81
 
79
- class InputActionExecutor:
80
- """Execute input actions returned by the command dispatcher.
81
-
82
- This helper encapsulates the logic for running the main agent task,
83
- applying model changes, and clearing conversations so that
84
- :class:`ExecutorContext` stays focused on operation dispatch.
85
- """
86
-
87
- def __init__(
88
- self,
89
- task_manager: TaskManager,
90
- sub_agent_manager: SubAgentManager,
91
- model_profile_provider: ModelProfileProvider,
92
- emit_event: Callable[[events.Event], Awaitable[None]],
93
- on_model_change: Callable[[str], None] | None = None,
94
- ) -> None:
95
- self._task_manager = task_manager
96
- self._sub_agent_manager = sub_agent_manager
97
- self._model_profile_provider = model_profile_provider
98
- self._emit_event = emit_event
99
- self._on_model_change = on_model_change
100
-
101
- async def run(self, action: InputAction, operation: op.UserInputOperation, agent: Agent) -> None:
102
- """Dispatch and execute a single input action."""
103
-
104
- if operation.session_id is None:
105
- raise ValueError("session_id cannot be None for input actions")
106
-
107
- session_id = operation.session_id
108
-
109
- if action.type == InputActionType.RUN_AGENT:
110
- await self._run_agent_action(action, operation, agent, session_id)
111
- return
112
-
113
- if action.type == InputActionType.CHANGE_MODEL:
114
- if not action.model_name:
115
- raise ValueError("ChangeModel action requires model_name")
116
-
117
- await self._apply_model_change(agent, action.model_name)
118
- return
119
-
120
- if action.type == InputActionType.CLEAR:
121
- await self._apply_clear(agent)
122
- return
123
-
124
- raise ValueError(f"Unsupported input action type: {action.type}")
125
-
126
- async def _run_agent_action(
127
- self,
128
- action: InputAction,
129
- operation: op.UserInputOperation,
130
- agent: Agent,
131
- session_id: str,
132
- ) -> None:
133
- task_input = model.UserInputPayload(text=action.text, images=operation.input.images)
134
-
135
- existing_active = self._task_manager.get(operation.id)
136
- if existing_active is not None and not existing_active.task.done():
137
- raise RuntimeError(f"Active task already registered for operation {operation.id}")
138
-
139
- task: asyncio.Task[None] = asyncio.create_task(
140
- self._run_agent_task(agent, task_input, operation.id, session_id)
141
- )
142
- self._task_manager.register(operation.id, task, session_id)
143
-
144
- async def _run_agent_task(
145
- self,
146
- agent: Agent,
147
- user_input: model.UserInputPayload,
148
- task_id: str,
149
- session_id: str,
150
- ) -> None:
151
- """Run the main agent task and forward events to the UI."""
152
-
153
- try:
154
- log_debug(
155
- f"Starting agent task {task_id} for session {session_id}",
156
- style="green",
157
- debug_type=DebugType.EXECUTION,
158
- )
159
-
160
- async def _runner(state: model.SubAgentState) -> SubAgentResult:
161
- return await self._sub_agent_manager.run_sub_agent(agent, state)
162
-
163
- token = current_run_subtask_callback.set(_runner)
164
- try:
165
- async for event in agent.run_task(user_input):
166
- await self._emit_event(event)
167
- finally:
168
- current_run_subtask_callback.reset(token)
169
-
170
- except asyncio.CancelledError:
171
- log_debug(
172
- f"Agent task {task_id} was cancelled",
173
- style="yellow",
174
- debug_type=DebugType.EXECUTION,
175
- )
176
- await self._emit_event(events.TaskFinishEvent(session_id=session_id, task_result="task cancelled"))
177
-
178
- except Exception as e:
179
- import traceback
180
-
181
- log_debug(
182
- f"Agent task {task_id} failed: {e!s}",
183
- style="red",
184
- debug_type=DebugType.EXECUTION,
185
- )
186
- log_debug(traceback.format_exc(), style="red", debug_type=DebugType.EXECUTION)
187
- await self._emit_event(
188
- events.ErrorEvent(
189
- error_message=f"Agent task failed: [{e.__class__.__name__}] {e!s}",
190
- can_retry=False,
191
- )
192
- )
193
-
194
- finally:
195
- self._task_manager.remove(task_id)
196
- log_debug(
197
- f"Cleaned up agent task {task_id}",
198
- style="cyan",
199
- debug_type=DebugType.EXECUTION,
200
- )
201
-
202
- async def _apply_model_change(self, agent: Agent, model_name: str) -> None:
203
- """Change the model used by the active agent and notify the UI."""
204
-
205
- config = load_config()
206
- if config is None:
207
- raise ValueError("Configuration must be initialized before changing model")
208
-
209
- llm_config = config.get_model_config(model_name)
210
- llm_client = create_llm_client(llm_config)
211
- agent.set_model_profile(self._model_profile_provider.build_profile(llm_client))
212
-
213
- agent.session.model_config_name = model_name
214
- agent.session.model_thinking = llm_config.thinking
215
-
216
- developer_item = model.DeveloperMessageItem(
217
- content=f"switched to model: {model_name}",
218
- command_output=model.CommandOutput(command_name=commands.CommandName.MODEL),
219
- )
220
- agent.session.append_history([developer_item])
221
-
222
- await self._emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
223
- await self._emit_event(events.WelcomeEvent(llm_config=llm_config, work_dir=str(agent.session.work_dir)))
224
-
225
- if self._on_model_change is not None:
226
- self._on_model_change(llm_client.model_name)
227
-
228
- async def _apply_clear(self, agent: Agent) -> None:
229
- """Start a new conversation for the agent and notify the UI."""
230
-
231
- new_session = Session(work_dir=agent.session.work_dir)
232
- new_session.model_name = agent.session.model_name
233
- new_session.model_config_name = agent.session.model_config_name
234
- new_session.model_thinking = agent.session.model_thinking
235
-
236
- agent.session = new_session
237
- agent.session.save()
238
-
239
- developer_item = model.DeveloperMessageItem(
240
- content="started new conversation",
241
- command_output=model.CommandOutput(command_name=commands.CommandName.CLEAR),
242
- )
243
-
244
- await self._emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
245
-
246
-
247
82
  class ExecutorContext:
248
83
  """
249
84
  Context object providing shared state and operations for the executor.
@@ -269,13 +104,7 @@ class ExecutorContext:
269
104
 
270
105
  self.task_manager = TaskManager()
271
106
  self.sub_agent_manager = SubAgentManager(event_queue, llm_clients, resolved_profile_provider)
272
- self._action_executor = InputActionExecutor(
273
- task_manager=self.task_manager,
274
- sub_agent_manager=self.sub_agent_manager,
275
- model_profile_provider=resolved_profile_provider,
276
- emit_event=self.emit_event,
277
- on_model_change=on_model_change,
278
- )
107
+ self._on_model_change = on_model_change
279
108
  self._agent: Agent | None = None
280
109
 
281
110
  async def emit_event(self, event: events.Event) -> None:
@@ -347,7 +176,7 @@ class ExecutorContext:
347
176
  await self._ensure_agent(operation.session_id)
348
177
 
349
178
  async def handle_user_input(self, operation: op.UserInputOperation) -> None:
350
- """Handle a user input operation by running it through an agent."""
179
+ """Handle a user input operation by dispatching it into operations."""
351
180
 
352
181
  if operation.session_id is None:
353
182
  raise ValueError("session_id cannot be None")
@@ -356,29 +185,187 @@ class ExecutorContext:
356
185
  agent = await self._ensure_agent(session_id)
357
186
  user_input = operation.input
358
187
 
359
- # emit user input event
188
+ # Emit the original user input to UI (even if the persisted text differs).
360
189
  await self.emit_event(
361
190
  events.UserMessageEvent(content=user_input.text, session_id=session_id, images=user_input.images)
362
191
  )
363
192
 
364
- result = await dispatch_command(user_input.text, agent)
193
+ result = await dispatch_command(user_input, agent, submission_id=operation.id)
194
+ ops: list[op.Operation] = list(result.operations or [])
365
195
 
366
- actions: list[InputAction] = list(result.actions or [])
196
+ run_ops = [candidate for candidate in ops if isinstance(candidate, op.RunAgentOperation)]
197
+ if len(run_ops) > 1:
198
+ raise ValueError("Multiple RunAgentOperation results are not supported")
367
199
 
368
- has_run_agent_action = any(action.type is InputActionType.RUN_AGENT for action in actions)
369
- if not has_run_agent_action:
370
- # No async agent task will run, append user message directly
371
- agent.session.append_history([model.UserMessageItem(content=user_input.text, images=user_input.images)])
200
+ persisted_user_input = run_ops[0].input if run_ops else user_input
201
+ agent.session.append_history(
202
+ [model.UserMessageItem(content=persisted_user_input.text, images=persisted_user_input.images)]
203
+ )
372
204
 
373
205
  if result.events:
374
- agent.session.append_history(
375
- [evt.item for evt in result.events if isinstance(evt, events.DeveloperMessageEvent)]
376
- )
377
206
  for evt in result.events:
207
+ if isinstance(evt, events.DeveloperMessageEvent):
208
+ agent.session.append_history([evt.item])
378
209
  await self.emit_event(evt)
379
210
 
380
- for action in actions:
381
- await self._action_executor.run(action, operation, agent)
211
+ for operation_item in ops:
212
+ await operation_item.execute(handler=self)
213
+
214
+ async def handle_run_agent(self, operation: op.RunAgentOperation) -> None:
215
+ agent = await self._ensure_agent(operation.session_id)
216
+ existing_active = self.task_manager.get(operation.id)
217
+ if existing_active is not None and not existing_active.task.done():
218
+ raise RuntimeError(f"Active task already registered for operation {operation.id}")
219
+ task: asyncio.Task[None] = asyncio.create_task(
220
+ self._run_agent_task(agent, operation.input, operation.id, operation.session_id)
221
+ )
222
+ self.task_manager.register(operation.id, task, operation.session_id)
223
+
224
+ async def handle_change_model(self, operation: op.ChangeModelOperation) -> None:
225
+ agent = await self._ensure_agent(operation.session_id)
226
+ config = load_config()
227
+ if config is None:
228
+ raise ValueError("Configuration must be initialized before changing model")
229
+
230
+ llm_config = config.get_model_config(operation.model_name)
231
+ llm_client = create_llm_client(llm_config)
232
+ agent.set_model_profile(self.model_profile_provider.build_profile(llm_client))
233
+
234
+ agent.session.model_config_name = operation.model_name
235
+ agent.session.model_thinking = llm_config.thinking
236
+
237
+ developer_item = model.DeveloperMessageItem(
238
+ content=f"switched to model: {operation.model_name}",
239
+ command_output=model.CommandOutput(command_name=commands.CommandName.MODEL),
240
+ )
241
+ agent.session.append_history([developer_item])
242
+
243
+ await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
244
+ await self.emit_event(events.WelcomeEvent(llm_config=llm_config, work_dir=str(agent.session.work_dir)))
245
+
246
+ if self._on_model_change is not None:
247
+ self._on_model_change(llm_client.model_name)
248
+
249
+ async def handle_clear_session(self, operation: op.ClearSessionOperation) -> None:
250
+ agent = await self._ensure_agent(operation.session_id)
251
+ new_session = Session.create(work_dir=agent.session.work_dir)
252
+ new_session.model_name = agent.session.model_name
253
+ new_session.model_config_name = agent.session.model_config_name
254
+ new_session.model_thinking = agent.session.model_thinking
255
+ agent.session = new_session
256
+
257
+ developer_item = model.DeveloperMessageItem(
258
+ content="started new conversation",
259
+ command_output=model.CommandOutput(command_name=commands.CommandName.CLEAR),
260
+ )
261
+ await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
262
+
263
+ async def handle_export_session(self, operation: op.ExportSessionOperation) -> None:
264
+ agent = await self._ensure_agent(operation.session_id)
265
+ try:
266
+ output_path = self._resolve_export_output_path(operation.output_path, agent.session)
267
+ html_doc = self._build_export_html(agent)
268
+ await asyncio.to_thread(output_path.parent.mkdir, parents=True, exist_ok=True)
269
+ await asyncio.to_thread(output_path.write_text, html_doc, "utf-8")
270
+ await asyncio.to_thread(self._open_file, output_path)
271
+ developer_item = model.DeveloperMessageItem(
272
+ content=f"Session exported and opened: {output_path}",
273
+ command_output=model.CommandOutput(command_name=commands.CommandName.EXPORT),
274
+ )
275
+ agent.session.append_history([developer_item])
276
+ await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
277
+ except Exception as exc: # pragma: no cover
278
+ import traceback
279
+
280
+ developer_item = model.DeveloperMessageItem(
281
+ content=f"Failed to export session: {exc}\n{traceback.format_exc()}",
282
+ command_output=model.CommandOutput(command_name=commands.CommandName.EXPORT, is_error=True),
283
+ )
284
+ agent.session.append_history([developer_item])
285
+ await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
286
+
287
+ async def _run_agent_task(
288
+ self,
289
+ agent: Agent,
290
+ user_input: model.UserInputPayload,
291
+ task_id: str,
292
+ session_id: str,
293
+ ) -> None:
294
+ try:
295
+ log_debug(
296
+ f"Starting agent task {task_id} for session {session_id}",
297
+ style="green",
298
+ debug_type=DebugType.EXECUTION,
299
+ )
300
+
301
+ async def _runner(state: model.SubAgentState) -> SubAgentResult:
302
+ return await self.sub_agent_manager.run_sub_agent(agent, state)
303
+
304
+ token = current_run_subtask_callback.set(_runner)
305
+ try:
306
+ async for event in agent.run_task(user_input):
307
+ await self.emit_event(event)
308
+ finally:
309
+ current_run_subtask_callback.reset(token)
310
+
311
+ except asyncio.CancelledError:
312
+ log_debug(
313
+ f"Agent task {task_id} was cancelled",
314
+ style="yellow",
315
+ debug_type=DebugType.EXECUTION,
316
+ )
317
+ await self.emit_event(events.TaskFinishEvent(session_id=session_id, task_result="task cancelled"))
318
+
319
+ except Exception as e:
320
+ import traceback
321
+
322
+ log_debug(
323
+ f"Agent task {task_id} failed: {e!s}",
324
+ style="red",
325
+ debug_type=DebugType.EXECUTION,
326
+ )
327
+ log_debug(traceback.format_exc(), style="red", debug_type=DebugType.EXECUTION)
328
+ await self.emit_event(
329
+ events.ErrorEvent(
330
+ error_message=f"Agent task failed: [{e.__class__.__name__}] {e!s}",
331
+ can_retry=False,
332
+ )
333
+ )
334
+ finally:
335
+ self.task_manager.remove(task_id)
336
+ log_debug(
337
+ f"Cleaned up agent task {task_id}",
338
+ style="cyan",
339
+ debug_type=DebugType.EXECUTION,
340
+ )
341
+
342
+ def _resolve_export_output_path(self, raw: str | None, session: Session) -> Path:
343
+ trimmed = (raw or "").strip()
344
+ if trimmed:
345
+ candidate = Path(trimmed).expanduser()
346
+ if not candidate.is_absolute():
347
+ candidate = Path(session.work_dir) / candidate
348
+ if candidate.suffix.lower() != ".html":
349
+ candidate = candidate.with_suffix(".html")
350
+ return candidate
351
+ return get_default_export_path(session)
352
+
353
+ def _build_export_html(self, agent: Agent) -> str:
354
+ profile = agent.profile
355
+ system_prompt = (profile.system_prompt if profile else "") or ""
356
+ tool_schemas = profile.tools if profile else []
357
+ model_name = profile.llm_client.model_name if profile else "unknown"
358
+ return build_export_html(agent.session, system_prompt, tool_schemas, model_name)
359
+
360
+ def _open_file(self, path: Path) -> None:
361
+ try:
362
+ subprocess.run(["open", str(path)], check=True)
363
+ except FileNotFoundError as exc: # pragma: no cover
364
+ msg = "`open` command not found; please open the HTML manually."
365
+ raise RuntimeError(msg) from exc
366
+ except subprocess.CalledProcessError as exc: # pragma: no cover
367
+ msg = f"Failed to open HTML with `open`: {exc}"
368
+ raise RuntimeError(msg) from exc
382
369
 
383
370
  async def handle_interrupt(self, operation: op.InterruptOperation) -> None:
384
371
  """Handle an interrupt by invoking agent.cancel() and cancelling tasks."""
@@ -80,6 +80,9 @@ Only the content passed to `report_back` will be returned to user.\
80
80
  result: str = ""
81
81
  task_metadata: model.TaskMetadata | None = None
82
82
  sub_agent_input = model.UserInputPayload(text=state.sub_agent_prompt, images=None)
83
+ child_session.append_history(
84
+ [model.UserMessageItem(content=sub_agent_input.text, images=sub_agent_input.images)]
85
+ )
83
86
  async for event in child_agent.run_task(sub_agent_input):
84
87
  # Capture TaskFinishEvent content for return
85
88
  if isinstance(event, events.TaskFinishEvent):
@@ -12,6 +12,7 @@ COMMAND_DESCRIPTIONS: dict[str, str] = {
12
12
  "fd": "simple and fast alternative to find",
13
13
  "tree": "directory listing as a tree",
14
14
  "sg": "ast-grep - AST-aware code search",
15
+ "jj": "jujutsu - Git-compatible version control system",
15
16
  }
16
17
 
17
18
  # Mapping from logical prompt keys to resource file paths under the core/prompt directory.
@@ -57,18 +58,20 @@ def _build_env_info(model_name: str) -> str:
57
58
  cwd = Path.cwd()
58
59
  today = datetime.datetime.now().strftime("%Y-%m-%d")
59
60
  is_git_repo = (cwd / ".git").exists()
61
+ is_empty_dir = not any(cwd.iterdir())
60
62
 
61
63
  available_tools: list[str] = []
62
64
  for command, desc in COMMAND_DESCRIPTIONS.items():
63
65
  if shutil.which(command) is not None:
64
66
  available_tools.append(f"{command}: {desc}")
65
67
 
68
+ cwd_display = f"{cwd} (empty)" if is_empty_dir else str(cwd)
66
69
  env_lines: list[str] = [
67
70
  "",
68
71
  "",
69
72
  "Here is useful information about the environment you are running in:",
70
73
  "<env>",
71
- f"Working directory: {cwd}",
74
+ f"Working directory: {cwd_display}",
72
75
  f"Today's Date: {today}",
73
76
  f"Is directory a git repo: {is_git_repo}",
74
77
  f"You are powered by the model: {model_name}",
@@ -1,4 +1,4 @@
1
- You are a web research agent that searches and fetches web content to provide up-to-date information.
1
+ You are a web research subagent that searches and fetches web content to provide up-to-date information as part of team.
2
2
 
3
3
  ## Available Tools
4
4
 
@@ -28,7 +28,7 @@ Balance efficiency with thoroughness. For open-ended questions (e.g., "recommend
28
28
  - Avoid repeating similar queries - they won't yield new results
29
29
  - NEVER use '-', 'site:', or quotes unless explicitly asked
30
30
  - Include year/date for time-sensitive queries (check "Today's date" in <env>), don't limit yourself to your knowledge cutoff date
31
- - Use WebFetch to get full content - search snippets are often insufficient
31
+ - Always use WebFetch to get the complete contents of websites - search snippets are often insufficient
32
32
  - Follow relevant links on pages with WebFetch
33
33
  - If truncated results are saved to local files, use grep/read to explore
34
34
 
@@ -48,4 +48,4 @@ You MUST end every response with a "Sources:" section listing all URLs with thei
48
48
 
49
49
  Sources:
50
50
  - [Source Title](https://example.com) -> /tmp/klaude/web/example_com-123456.md
51
- - [Another Source](https://example.com/page) -> /tmp/klaude/web/example_com_page-123456.md
51
+ - [Another Source](https://example.com/page) -> /tmp/klaude/web/example_com_page-123456.md
@@ -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
- last_user_input = get_last_new_user_input(session)
103
- if not last_user_input or "@" not in last_user_input:
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 pattern in at_patterns:
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, mtime in session.file_tracker.items():
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
- if memory_path.exists() and memory_path.is_file() and str(memory_path) not in session.loaded_memory:
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.loaded_memory.append(str(memory_path))
325
- memories.append(Memory(path=str(memory_path), instruction=instruction, content=text))
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). Uses session.loaded_memory to avoid duplicate loading.
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 in loaded_set:
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.loaded_memory.append(mem_path_str)
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(