klaude-code 2.7.0__py3-none-any.whl → 2.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. klaude_code/auth/AGENTS.md +325 -0
  2. klaude_code/auth/__init__.py +17 -1
  3. klaude_code/auth/antigravity/__init__.py +20 -0
  4. klaude_code/auth/antigravity/exceptions.py +17 -0
  5. klaude_code/auth/antigravity/oauth.py +320 -0
  6. klaude_code/auth/antigravity/pkce.py +25 -0
  7. klaude_code/auth/antigravity/token_manager.py +45 -0
  8. klaude_code/auth/base.py +4 -0
  9. klaude_code/auth/claude/oauth.py +29 -9
  10. klaude_code/auth/codex/exceptions.py +4 -0
  11. klaude_code/cli/auth_cmd.py +53 -3
  12. klaude_code/cli/cost_cmd.py +83 -160
  13. klaude_code/cli/list_model.py +50 -0
  14. klaude_code/cli/main.py +1 -1
  15. klaude_code/config/assets/builtin_config.yaml +108 -0
  16. klaude_code/config/builtin_config.py +5 -11
  17. klaude_code/config/config.py +24 -10
  18. klaude_code/const.py +1 -0
  19. klaude_code/core/agent.py +5 -1
  20. klaude_code/core/agent_profile.py +28 -32
  21. klaude_code/core/compaction/AGENTS.md +112 -0
  22. klaude_code/core/compaction/__init__.py +11 -0
  23. klaude_code/core/compaction/compaction.py +707 -0
  24. klaude_code/core/compaction/overflow.py +30 -0
  25. klaude_code/core/compaction/prompts.py +97 -0
  26. klaude_code/core/executor.py +103 -2
  27. klaude_code/core/manager/llm_clients.py +5 -0
  28. klaude_code/core/manager/llm_clients_builder.py +14 -2
  29. klaude_code/core/prompts/prompt-antigravity.md +80 -0
  30. klaude_code/core/prompts/prompt-codex-gpt-5-2.md +335 -0
  31. klaude_code/core/reminders.py +7 -2
  32. klaude_code/core/task.py +126 -0
  33. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  34. klaude_code/core/turn.py +3 -1
  35. klaude_code/llm/antigravity/__init__.py +3 -0
  36. klaude_code/llm/antigravity/client.py +558 -0
  37. klaude_code/llm/antigravity/input.py +261 -0
  38. klaude_code/llm/registry.py +1 -0
  39. klaude_code/protocol/events.py +18 -0
  40. klaude_code/protocol/llm_param.py +1 -0
  41. klaude_code/protocol/message.py +23 -1
  42. klaude_code/protocol/op.py +15 -1
  43. klaude_code/protocol/op_handler.py +5 -0
  44. klaude_code/session/session.py +36 -0
  45. klaude_code/skill/assets/create-plan/SKILL.md +6 -6
  46. klaude_code/tui/command/__init__.py +3 -0
  47. klaude_code/tui/command/compact_cmd.py +32 -0
  48. klaude_code/tui/command/fork_session_cmd.py +110 -14
  49. klaude_code/tui/command/model_picker.py +5 -1
  50. klaude_code/tui/command/thinking_cmd.py +1 -1
  51. klaude_code/tui/commands.py +6 -0
  52. klaude_code/tui/components/rich/markdown.py +57 -1
  53. klaude_code/tui/components/rich/theme.py +10 -2
  54. klaude_code/tui/components/tools.py +39 -25
  55. klaude_code/tui/components/user_input.py +1 -1
  56. klaude_code/tui/input/__init__.py +5 -2
  57. klaude_code/tui/input/drag_drop.py +6 -57
  58. klaude_code/tui/input/key_bindings.py +10 -0
  59. klaude_code/tui/input/prompt_toolkit.py +19 -6
  60. klaude_code/tui/machine.py +25 -0
  61. klaude_code/tui/renderer.py +67 -4
  62. klaude_code/tui/runner.py +18 -2
  63. klaude_code/tui/terminal/image.py +72 -10
  64. klaude_code/tui/terminal/selector.py +31 -7
  65. {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/METADATA +1 -1
  66. {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/RECORD +68 -52
  67. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +0 -117
  68. {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/WHEEL +0 -0
  69. {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,707 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from typing import cast
9
+
10
+ from klaude_code.llm import LLMClientABC
11
+ from klaude_code.protocol import llm_param, message, model
12
+ from klaude_code.session.session import Session
13
+
14
+ from .prompts import (
15
+ COMPACTION_SUMMARY_PREFIX,
16
+ SUMMARIZATION_PROMPT,
17
+ SUMMARIZATION_SYSTEM_PROMPT,
18
+ TASK_PREFIX_SUMMARIZATION_PROMPT,
19
+ UPDATE_SUMMARIZATION_PROMPT,
20
+ )
21
+
22
+ _MAX_TOOL_OUTPUT_CHARS = 4000
23
+ _MAX_TOOL_CALL_CHARS = 2000
24
+ _DEFAULT_IMAGE_TOKENS = 1200
25
+
26
+
27
+ class CompactionReason(str, Enum):
28
+ THRESHOLD = "threshold"
29
+ OVERFLOW = "overflow"
30
+ MANUAL = "manual"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CompactionConfig:
35
+ reserve_tokens: int
36
+ keep_recent_tokens: int
37
+ max_summary_tokens: int
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class CompactionResult:
42
+ summary: str
43
+ first_kept_index: int
44
+ tokens_before: int | None
45
+ details: message.CompactionDetails | None
46
+ kept_items_brief: list[message.KeptItemBrief]
47
+
48
+ def to_entry(self) -> message.CompactionEntry:
49
+ """Convert to a CompactionEntry for persisting in session history."""
50
+ return message.CompactionEntry(
51
+ summary=self.summary,
52
+ first_kept_index=self.first_kept_index,
53
+ tokens_before=self.tokens_before,
54
+ details=self.details,
55
+ kept_items_brief=self.kept_items_brief,
56
+ )
57
+
58
+
59
+ def _resolve_compaction_config(llm_config: llm_param.LLMConfigParameter) -> CompactionConfig:
60
+ default_reserve = 16384
61
+ default_keep = 20000
62
+ context_limit = llm_config.context_limit or 0
63
+ if context_limit <= 0:
64
+ reserve = default_reserve
65
+ keep_recent = default_keep
66
+ else:
67
+ reserve = min(default_reserve, max(2048, int(context_limit * 0.25)))
68
+ keep_recent = min(default_keep, max(4096, int(context_limit * 0.35)))
69
+ max_keep = max(0, context_limit - reserve)
70
+ if max_keep:
71
+ keep_recent = min(keep_recent, max_keep)
72
+ max_summary = max(1024, int(reserve * 0.8))
73
+ return CompactionConfig(reserve_tokens=reserve, keep_recent_tokens=keep_recent, max_summary_tokens=max_summary)
74
+
75
+
76
+ def should_compact_threshold(
77
+ *,
78
+ session: Session,
79
+ config: CompactionConfig | None,
80
+ llm_config: llm_param.LLMConfigParameter,
81
+ ) -> bool:
82
+ compaction_config = config or _resolve_compaction_config(llm_config)
83
+ context_limit = llm_config.context_limit or _get_last_context_limit(session)
84
+ if context_limit is None:
85
+ return False
86
+ tokens_before = _get_last_context_tokens(session)
87
+ # After compaction, the last successful assistant usage reflects the pre-compaction
88
+ # context window. For threshold checks we want the *current* LLM-facing view.
89
+ if tokens_before is None or _has_compaction_after_last_successful_usage(session):
90
+ tokens_before = _estimate_history_tokens(session.get_llm_history())
91
+ return tokens_before > context_limit - compaction_config.reserve_tokens
92
+
93
+
94
+ def _has_compaction_after_last_successful_usage(session: Session) -> bool:
95
+ """Return True if the newest compaction entry is newer than the last usable assistant usage.
96
+
97
+ In that case, usage.context_size is stale for threshold decisions.
98
+ """
99
+
100
+ history = session.conversation_history
101
+
102
+ last_compaction_idx = -1
103
+ for idx in range(len(history) - 1, -1, -1):
104
+ if isinstance(history[idx], message.CompactionEntry):
105
+ last_compaction_idx = idx
106
+ break
107
+
108
+ if last_compaction_idx < 0:
109
+ return False
110
+
111
+ last_usage_idx = -1
112
+ for idx in range(len(history) - 1, -1, -1):
113
+ item = history[idx]
114
+ if not isinstance(item, message.AssistantMessage):
115
+ continue
116
+ if item.usage is None:
117
+ continue
118
+ if item.stop_reason in {"aborted", "error"}:
119
+ continue
120
+ last_usage_idx = idx
121
+ break
122
+
123
+ if last_usage_idx < 0:
124
+ return True
125
+
126
+ return last_compaction_idx > last_usage_idx
127
+
128
+
129
+ async def run_compaction(
130
+ *,
131
+ session: Session,
132
+ reason: CompactionReason,
133
+ focus: str | None,
134
+ llm_client: LLMClientABC,
135
+ llm_config: llm_param.LLMConfigParameter,
136
+ cancel: asyncio.Event | None = None,
137
+ ) -> CompactionResult:
138
+ del reason
139
+ if cancel is not None and cancel.is_set():
140
+ raise asyncio.CancelledError
141
+
142
+ compaction_config = _resolve_compaction_config(llm_config)
143
+ history = session.conversation_history
144
+ if not history:
145
+ raise ValueError("No conversation history to compact")
146
+ _, last_compaction = _find_last_compaction(history)
147
+ base_start_index = last_compaction.first_kept_index if last_compaction else 0
148
+
149
+ cut_index = _find_cut_index(history, base_start_index, compaction_config.keep_recent_tokens)
150
+ cut_index = _adjust_cut_index(history, cut_index, base_start_index)
151
+
152
+ if cut_index <= base_start_index:
153
+ raise ValueError("Nothing to compact (session too small)")
154
+
155
+ previous_summary = last_compaction.summary if last_compaction else None
156
+ tokens_before = _get_last_context_tokens(session)
157
+ if tokens_before is None:
158
+ tokens_before = _estimate_history_tokens(history)
159
+
160
+ split_task = _is_split_task(history, base_start_index, cut_index)
161
+ task_start_index = _find_task_start_index(history, base_start_index, cut_index) if split_task else -1
162
+
163
+ messages_to_summarize = _collect_messages(history, base_start_index, task_start_index if split_task else cut_index)
164
+ task_prefix_messages: list[message.Message] = []
165
+ if split_task and task_start_index >= 0:
166
+ task_prefix_messages = _collect_messages(history, task_start_index, cut_index)
167
+
168
+ if not messages_to_summarize and not task_prefix_messages and not previous_summary:
169
+ raise ValueError("Nothing to compact (no messages to summarize)")
170
+
171
+ if cancel is not None and cancel.is_set():
172
+ raise asyncio.CancelledError
173
+
174
+ summary = await _build_summary(
175
+ messages_to_summarize=messages_to_summarize,
176
+ task_prefix_messages=task_prefix_messages,
177
+ previous_summary=previous_summary,
178
+ focus=focus,
179
+ llm_client=llm_client,
180
+ config=compaction_config,
181
+ cancel=cancel,
182
+ )
183
+
184
+ file_ops = _collect_file_operations(
185
+ session=session,
186
+ summarized_messages=messages_to_summarize,
187
+ task_prefix_messages=task_prefix_messages,
188
+ previous_details=last_compaction.details if last_compaction else None,
189
+ )
190
+ summary += _format_file_operations(file_ops.read_files, file_ops.modified_files)
191
+
192
+ kept_items_brief = _collect_kept_items_brief(history, cut_index)
193
+
194
+ return CompactionResult(
195
+ summary=summary,
196
+ first_kept_index=cut_index,
197
+ tokens_before=tokens_before,
198
+ details=file_ops,
199
+ kept_items_brief=kept_items_brief,
200
+ )
201
+
202
+
203
+ def _collect_kept_items_brief(history: list[message.HistoryEvent], cut_index: int) -> list[message.KeptItemBrief]:
204
+ """Extract brief info about kept (non-compacted) messages."""
205
+ items: list[message.KeptItemBrief] = []
206
+ tool_counts: dict[str, int] = {}
207
+
208
+ def _flush_tool_counts() -> None:
209
+ for tool_name, count in tool_counts.items():
210
+ items.append(message.KeptItemBrief(item_type=tool_name, count=count))
211
+ tool_counts.clear()
212
+
213
+ def _get_preview(text: str, max_len: int = 30) -> str:
214
+ text = text.strip().replace("\n", " ")
215
+ if len(text) > max_len:
216
+ return text[:max_len] + "..."
217
+ return text
218
+
219
+ for idx in range(cut_index, len(history)):
220
+ item = history[idx]
221
+ if isinstance(item, message.CompactionEntry):
222
+ continue
223
+
224
+ if isinstance(item, message.UserMessage):
225
+ _flush_tool_counts()
226
+ text = _join_text_parts(item.parts)
227
+ items.append(message.KeptItemBrief(item_type="User", preview=_get_preview(text)))
228
+
229
+ elif isinstance(item, message.AssistantMessage):
230
+ _flush_tool_counts()
231
+ text = _join_text_parts(item.parts)
232
+ if text.strip():
233
+ items.append(message.KeptItemBrief(item_type="Assistant", preview=_get_preview(text)))
234
+
235
+ elif isinstance(item, message.ToolResultMessage):
236
+ tool_name = _normalize_tool_name(str(item.tool_name))
237
+ tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
238
+
239
+ _flush_tool_counts()
240
+ return items
241
+
242
+
243
+ def _normalize_tool_name(tool_name: str) -> str:
244
+ """Return tool name as-is (no normalization).
245
+
246
+ We intentionally avoid enumerating tool names here; display should reflect
247
+ what was recorded in history.
248
+ """
249
+
250
+ return tool_name.strip()
251
+
252
+
253
+ def _call_args_probably_modify_file(args: dict[str, object]) -> bool:
254
+ """Heuristically detect file modifications from tool call arguments.
255
+
256
+ This avoids enumerating tool names; we infer intent from argument structure.
257
+ """
258
+
259
+ # Common edit signature.
260
+ if "old" in args and "new" in args:
261
+ return True
262
+ # Common write signature.
263
+ if "content" in args:
264
+ return True
265
+ # Common apply_patch signature.
266
+ patch = args.get("patch")
267
+ if isinstance(patch, str) and "*** Begin Patch" in patch:
268
+ return True
269
+ # Batch edits.
270
+ edits = args.get("edits")
271
+ if isinstance(edits, list):
272
+ return True
273
+ return False
274
+
275
+
276
+ def _collect_file_operations(
277
+ *,
278
+ session: Session,
279
+ summarized_messages: list[message.Message],
280
+ task_prefix_messages: list[message.Message],
281
+ previous_details: message.CompactionDetails | None,
282
+ ) -> message.CompactionDetails:
283
+ read_set: set[str] = set()
284
+ modified_set: set[str] = set()
285
+
286
+ if previous_details is not None:
287
+ read_set.update(previous_details.read_files)
288
+ modified_set.update(previous_details.modified_files)
289
+
290
+ for path in session.file_tracker:
291
+ read_set.add(path)
292
+
293
+ for msg in (*summarized_messages, *task_prefix_messages):
294
+ if isinstance(msg, message.AssistantMessage):
295
+ _extract_file_ops_from_tool_calls(msg, read_set, modified_set)
296
+ if isinstance(msg, message.ToolResultMessage):
297
+ _extract_modified_files_from_tool_result(msg, modified_set)
298
+
299
+ read_files = sorted(read_set - modified_set)
300
+ modified_files = sorted(modified_set)
301
+ return message.CompactionDetails(read_files=read_files, modified_files=modified_files)
302
+
303
+
304
+ def _extract_file_ops_from_tool_calls(
305
+ msg: message.AssistantMessage, read_set: set[str], modified_set: set[str]
306
+ ) -> None:
307
+ for part in msg.parts:
308
+ if not isinstance(part, message.ToolCallPart):
309
+ continue
310
+ try:
311
+ args = json.loads(part.arguments_json)
312
+ except json.JSONDecodeError:
313
+ continue
314
+ if not isinstance(args, dict):
315
+ continue
316
+ args_dict = cast(dict[str, object], args)
317
+ path = args_dict.get("file_path") or args_dict.get("path")
318
+ if not isinstance(path, str):
319
+ continue
320
+
321
+ # Always track referenced paths as read context.
322
+ read_set.add(path)
323
+
324
+ # Detect modifications via argument structure (no tool name enumeration).
325
+ if _call_args_probably_modify_file(args_dict):
326
+ modified_set.add(path)
327
+
328
+
329
+ def _extract_modified_files_from_tool_result(msg: message.ToolResultMessage, modified_set: set[str]) -> None:
330
+ ui_extra = msg.ui_extra
331
+ if ui_extra is None:
332
+ return
333
+ match ui_extra:
334
+ case model.DiffUIExtra() as diff:
335
+ modified_set.update(file.file_path for file in diff.files)
336
+ case model.MarkdownDocUIExtra() as doc:
337
+ modified_set.add(doc.file_path)
338
+ case model.MultiUIExtra() as multi:
339
+ for item in multi.items:
340
+ if isinstance(item, model.DiffUIExtra):
341
+ modified_set.update(file.file_path for file in item.files)
342
+ elif isinstance(item, model.MarkdownDocUIExtra):
343
+ modified_set.add(item.file_path)
344
+ case _:
345
+ pass
346
+
347
+
348
+ def _format_file_operations(read_files: list[str], modified_files: list[str]) -> str:
349
+ sections: list[str] = []
350
+ if read_files:
351
+ sections.append("<read-files>\n" + "\n".join(read_files) + "\n</read-files>")
352
+ if modified_files:
353
+ sections.append("<modified-files>\n" + "\n".join(modified_files) + "\n</modified-files>")
354
+ if not sections:
355
+ return ""
356
+ return "\n\n" + "\n\n".join(sections)
357
+
358
+
359
+ def _find_last_compaction(
360
+ history: list[message.HistoryEvent],
361
+ ) -> tuple[int, message.CompactionEntry | None]:
362
+ for idx in range(len(history) - 1, -1, -1):
363
+ item = history[idx]
364
+ if isinstance(item, message.CompactionEntry):
365
+ return idx, item
366
+ return -1, None
367
+
368
+
369
+ def _find_cut_index(history: list[message.HistoryEvent], start_index: int, keep_recent_tokens: int) -> int:
370
+ tokens = 0
371
+ cut_index = start_index
372
+ for idx in range(len(history) - 1, start_index - 1, -1):
373
+ item = history[idx]
374
+ if isinstance(item, message.CompactionEntry):
375
+ continue
376
+ if isinstance(item, message.Message):
377
+ tokens += _estimate_tokens(item)
378
+ # Never cut on a tool result; keeping tool results without their corresponding
379
+ # assistant tool call breaks LLM-facing history.
380
+ if (
381
+ tokens >= keep_recent_tokens
382
+ and isinstance(item, message.Message)
383
+ and not isinstance(item, message.ToolResultMessage)
384
+ ):
385
+ cut_index = idx
386
+ break
387
+ return cut_index
388
+
389
+
390
+ def _adjust_cut_index(history: list[message.HistoryEvent], cut_index: int, start_index: int) -> int:
391
+ if not history:
392
+ return 0
393
+ if cut_index < start_index:
394
+ return start_index
395
+
396
+ def _skip_leading_tool_results(idx: int) -> int:
397
+ while idx < len(history) and isinstance(history[idx], message.ToolResultMessage):
398
+ idx += 1
399
+ return idx
400
+
401
+ # Prefer moving the cut backwards to include the assistant tool call.
402
+ while cut_index > start_index and isinstance(history[cut_index], message.ToolResultMessage):
403
+ cut_index -= 1
404
+
405
+ # If we cannot move backwards enough (e.g. start_index is itself a tool result due to
406
+ # old persisted sessions), move forward to avoid starting kept history with tool results.
407
+ if isinstance(history[cut_index], message.ToolResultMessage):
408
+ forward = _skip_leading_tool_results(cut_index)
409
+ if forward < len(history):
410
+ cut_index = forward
411
+
412
+ if isinstance(history[cut_index], message.DeveloperMessage):
413
+ forward = _find_anchor_index(history, cut_index + 1, forward=True)
414
+ if forward is not None:
415
+ return forward
416
+ backward = _find_anchor_index(history, cut_index - 1, forward=False)
417
+ if backward is not None:
418
+ return backward
419
+
420
+ return cut_index
421
+
422
+
423
+ def _find_anchor_index(history: list[message.HistoryEvent], start: int, *, forward: bool) -> int | None:
424
+ indices = range(start, len(history)) if forward else range(start, -1, -1)
425
+ for idx in indices:
426
+ item = history[idx]
427
+ if isinstance(item, (message.UserMessage, message.ToolResultMessage)):
428
+ return idx
429
+ return None
430
+
431
+
432
+ def _is_split_task(history: list[message.HistoryEvent], start_index: int, cut_index: int) -> bool:
433
+ if cut_index <= start_index:
434
+ return False
435
+ if isinstance(history[cut_index], message.UserMessage):
436
+ return False
437
+ task_start_index = _find_task_start_index(history, start_index, cut_index)
438
+ return task_start_index >= 0
439
+
440
+
441
+ def _find_task_start_index(history: list[message.HistoryEvent], start_index: int, cut_index: int) -> int:
442
+ for idx in range(cut_index, start_index - 1, -1):
443
+ if isinstance(history[idx], message.UserMessage):
444
+ return idx
445
+ return -1
446
+
447
+
448
+ def _collect_messages(history: list[message.HistoryEvent], start_index: int, end_index: int) -> list[message.Message]:
449
+ if end_index < start_index:
450
+ return []
451
+ return [
452
+ item
453
+ for item in history[start_index:end_index]
454
+ if isinstance(item, message.Message) and not isinstance(item, message.SystemMessage)
455
+ ]
456
+
457
+
458
+ async def _build_summary(
459
+ *,
460
+ messages_to_summarize: list[message.Message],
461
+ task_prefix_messages: list[message.Message],
462
+ previous_summary: str | None,
463
+ focus: str | None,
464
+ llm_client: LLMClientABC,
465
+ config: CompactionConfig,
466
+ cancel: asyncio.Event | None,
467
+ ) -> str:
468
+ if cancel is not None and cancel.is_set():
469
+ raise asyncio.CancelledError
470
+
471
+ if task_prefix_messages:
472
+ history_task = (
473
+ _generate_summary(
474
+ messages_to_summarize,
475
+ llm_client,
476
+ config,
477
+ focus,
478
+ previous_summary,
479
+ cancel,
480
+ )
481
+ if messages_to_summarize or previous_summary
482
+ else asyncio.sleep(0, result="No prior history.")
483
+ )
484
+ prefix_task = _generate_task_prefix_summary(task_prefix_messages, llm_client, config, cancel)
485
+ history_summary, task_prefix_summary = await asyncio.gather(history_task, prefix_task)
486
+ return f"{COMPACTION_SUMMARY_PREFIX}\n\n<summary>{history_summary}\n\n---\n\n**Task Context (split task):**\n\n{task_prefix_summary}\n\n</summary>"
487
+
488
+ return await _generate_summary(
489
+ messages_to_summarize,
490
+ llm_client,
491
+ config,
492
+ focus,
493
+ previous_summary,
494
+ cancel,
495
+ )
496
+
497
+
498
+ async def _generate_summary(
499
+ messages_to_summarize: list[message.Message],
500
+ llm_client: LLMClientABC,
501
+ config: CompactionConfig,
502
+ focus: str | None,
503
+ previous_summary: str | None,
504
+ cancel: asyncio.Event | None,
505
+ ) -> str:
506
+ serialized = _serialize_conversation(messages_to_summarize)
507
+ parts: list[message.Part] = [
508
+ message.TextPart(text=f"<conversation>\n{serialized}\n</conversation>"),
509
+ ]
510
+ if previous_summary:
511
+ parts.append(
512
+ message.TextPart(text=f"\n\n<previous-summary>\n{previous_summary}\n</previous-summary>"),
513
+ )
514
+ base_prompt = UPDATE_SUMMARIZATION_PROMPT
515
+ else:
516
+ base_prompt = SUMMARIZATION_PROMPT
517
+ parts.append(
518
+ message.TextPart(text=f"\n\n<instructions>\n{base_prompt}\n</instructions>"),
519
+ )
520
+ if focus:
521
+ parts.append(
522
+ message.TextPart(text=f"\n\nAdditional focus: {focus}"),
523
+ )
524
+ return await _call_summarizer(
525
+ input=[message.UserMessage(parts=parts)],
526
+ llm_client=llm_client,
527
+ max_tokens=config.max_summary_tokens,
528
+ cancel=cancel,
529
+ )
530
+
531
+
532
+ async def _generate_task_prefix_summary(
533
+ messages: list[message.Message],
534
+ llm_client: LLMClientABC,
535
+ config: CompactionConfig,
536
+ cancel: asyncio.Event | None,
537
+ ) -> str:
538
+ serialized = _serialize_conversation(messages)
539
+ return await _call_summarizer(
540
+ input=[
541
+ message.UserMessage(
542
+ parts=[
543
+ message.TextPart(text=f"<conversation>\n{serialized}\n</conversation>\n\n"),
544
+ message.TextPart(text=TASK_PREFIX_SUMMARIZATION_PROMPT),
545
+ ]
546
+ )
547
+ ],
548
+ llm_client=llm_client,
549
+ max_tokens=config.max_summary_tokens,
550
+ cancel=cancel,
551
+ )
552
+
553
+
554
+ async def _call_summarizer(
555
+ *,
556
+ input: list[message.Message],
557
+ llm_client: LLMClientABC,
558
+ max_tokens: int,
559
+ cancel: asyncio.Event | None,
560
+ ) -> str:
561
+ if cancel is not None and cancel.is_set():
562
+ raise asyncio.CancelledError
563
+
564
+ call_param = llm_param.LLMCallParameter(
565
+ input=input,
566
+ system=SUMMARIZATION_SYSTEM_PROMPT,
567
+ session_id=None,
568
+ )
569
+ call_param.max_tokens = max_tokens
570
+ call_param.tools = None
571
+
572
+ stream = await llm_client.call(call_param)
573
+ accumulated: list[str] = []
574
+ final_text: str | None = None
575
+ async for item in stream:
576
+ if isinstance(item, message.AssistantTextDelta):
577
+ accumulated.append(item.content)
578
+ elif isinstance(item, message.StreamErrorItem):
579
+ raise RuntimeError(item.error)
580
+ elif isinstance(item, message.AssistantMessage):
581
+ final_text = message.join_text_parts(item.parts)
582
+
583
+ if cancel is not None and cancel.is_set():
584
+ raise asyncio.CancelledError
585
+
586
+ text = final_text if final_text is not None else "".join(accumulated)
587
+ if not text.strip():
588
+ raise ValueError("Summarizer returned empty output")
589
+ return text.strip()
590
+
591
+
592
+ def _serialize_conversation(messages: list[message.Message]) -> str:
593
+ parts: list[str] = []
594
+ for msg in messages:
595
+ if isinstance(msg, message.UserMessage):
596
+ text = _join_text_parts(msg.parts)
597
+ if not text:
598
+ text = _render_images(msg.parts)
599
+ if text:
600
+ parts.append(f"[User]: {text}")
601
+ elif isinstance(msg, message.AssistantMessage):
602
+ text_parts: list[str] = []
603
+ thinking_parts: list[str] = []
604
+ tool_calls: list[str] = []
605
+ for part in msg.parts:
606
+ if isinstance(part, message.TextPart):
607
+ text_parts.append(part.text)
608
+ elif isinstance(part, message.ThinkingTextPart):
609
+ thinking_parts.append(part.text)
610
+ elif isinstance(part, message.ToolCallPart):
611
+ args = _truncate_text(part.arguments_json, _MAX_TOOL_CALL_CHARS)
612
+ tool_calls.append(f"{part.tool_name}({args})")
613
+ if thinking_parts:
614
+ parts.append("[Assistant thinking]: " + "\n".join(thinking_parts))
615
+ if text_parts:
616
+ parts.append("[Assistant]: " + "\n".join(text_parts))
617
+ if tool_calls:
618
+ parts.append("[Assistant tool calls]: " + "; ".join(tool_calls))
619
+ elif isinstance(msg, message.ToolResultMessage):
620
+ content = _truncate_text(msg.output_text, _MAX_TOOL_OUTPUT_CHARS)
621
+ if content:
622
+ parts.append(f"[Tool result]: {content}")
623
+ elif isinstance(msg, message.DeveloperMessage):
624
+ text = _join_text_parts(msg.parts)
625
+ if text:
626
+ parts.append(f"[Developer]: {text}")
627
+ else: # SystemMessage
628
+ text = _join_text_parts(msg.parts)
629
+ if text:
630
+ parts.append(f"[System]: {text}")
631
+ return "\n\n".join(parts)
632
+
633
+
634
+ def _join_text_parts(parts: Sequence[message.Part]) -> str:
635
+ return "".join(part.text for part in parts if isinstance(part, message.TextPart))
636
+
637
+
638
+ def _render_images(parts: Sequence[message.Part]) -> str:
639
+ images: list[str] = []
640
+ for part in parts:
641
+ if isinstance(part, message.ImageURLPart):
642
+ images.append(part.url)
643
+ elif isinstance(part, message.ImageFilePart):
644
+ images.append(part.file_path)
645
+ if not images:
646
+ return ""
647
+ return "image: " + ", ".join(images)
648
+
649
+
650
+ def _truncate_text(text: str, max_chars: int) -> str:
651
+ if len(text) <= max_chars:
652
+ return text
653
+ return text[:max_chars] + "...(truncated)"
654
+
655
+
656
+ def _estimate_history_tokens(history: list[message.HistoryEvent]) -> int:
657
+ return sum(_estimate_tokens(item) for item in history if isinstance(item, message.Message))
658
+
659
+
660
+ def _estimate_tokens(msg: message.Message) -> int:
661
+ chars = 0
662
+ if isinstance(msg, message.UserMessage):
663
+ chars = sum(len(part.text) for part in msg.parts if isinstance(part, message.TextPart))
664
+ chars += _count_image_tokens(msg.parts)
665
+ elif isinstance(msg, message.AssistantMessage):
666
+ for part in msg.parts:
667
+ if isinstance(part, (message.TextPart, message.ThinkingTextPart)):
668
+ chars += len(part.text)
669
+ elif isinstance(part, message.ToolCallPart):
670
+ chars += len(part.tool_name) + len(part.arguments_json)
671
+ elif isinstance(msg, message.ToolResultMessage):
672
+ chars += len(msg.output_text)
673
+ chars += _count_image_tokens(msg.parts)
674
+ else: # DeveloperMessage or SystemMessage
675
+ chars += sum(len(part.text) for part in msg.parts if isinstance(part, message.TextPart))
676
+ return max(1, (chars + 3) // 4)
677
+
678
+
679
+ def _count_image_tokens(parts: list[message.Part]) -> int:
680
+ count = sum(1 for part in parts if isinstance(part, (message.ImageURLPart, message.ImageFilePart)))
681
+ return count * _DEFAULT_IMAGE_TOKENS
682
+
683
+
684
+ def _get_last_context_tokens(session: Session) -> int | None:
685
+ for item in reversed(session.conversation_history):
686
+ if not isinstance(item, message.AssistantMessage):
687
+ continue
688
+ if item.usage is None:
689
+ continue
690
+ if item.stop_reason in {"aborted", "error"}:
691
+ continue
692
+ usage = item.usage
693
+ if usage.context_size is not None:
694
+ return usage.context_size
695
+ return usage.total_tokens
696
+ return None
697
+
698
+
699
+ def _get_last_context_limit(session: Session) -> int | None:
700
+ for item in reversed(session.conversation_history):
701
+ if not isinstance(item, message.AssistantMessage):
702
+ continue
703
+ if item.usage is None:
704
+ continue
705
+ if item.usage.context_limit is not None:
706
+ return item.usage.context_limit
707
+ return None