devcopilot 0.2.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 (189) hide show
  1. api/__init__.py +17 -0
  2. api/admin_config.py +1303 -0
  3. api/admin_routes.py +287 -0
  4. api/admin_static/admin.css +459 -0
  5. api/admin_static/admin.js +497 -0
  6. api/admin_static/index.html +77 -0
  7. api/admin_urls.py +34 -0
  8. api/app.py +194 -0
  9. api/command_utils.py +164 -0
  10. api/dependencies.py +144 -0
  11. api/detection.py +152 -0
  12. api/gateway_model_ids.py +54 -0
  13. api/model_catalog.py +133 -0
  14. api/model_router.py +125 -0
  15. api/models/__init__.py +45 -0
  16. api/models/anthropic.py +234 -0
  17. api/models/openai_responses.py +28 -0
  18. api/models/responses.py +60 -0
  19. api/optimization_handlers.py +154 -0
  20. api/request_pipeline.py +424 -0
  21. api/routes.py +156 -0
  22. api/runtime.py +334 -0
  23. api/validation_log.py +48 -0
  24. api/web_server_tools.py +22 -0
  25. api/web_tools/__init__.py +17 -0
  26. api/web_tools/constants.py +15 -0
  27. api/web_tools/egress.py +99 -0
  28. api/web_tools/outbound.py +278 -0
  29. api/web_tools/parsers.py +104 -0
  30. api/web_tools/request.py +87 -0
  31. api/web_tools/streaming.py +206 -0
  32. cli/__init__.py +5 -0
  33. cli/claude_env.py +12 -0
  34. cli/entrypoints.py +166 -0
  35. cli/env.example +209 -0
  36. cli/launchers/__init__.py +1 -0
  37. cli/launchers/claude.py +84 -0
  38. cli/launchers/codex.py +204 -0
  39. cli/launchers/codex_model_catalog.py +186 -0
  40. cli/launchers/common.py +93 -0
  41. cli/managed/__init__.py +6 -0
  42. cli/managed/claude.py +215 -0
  43. cli/managed/manager.py +157 -0
  44. cli/managed/session.py +260 -0
  45. cli/process_registry.py +78 -0
  46. config/__init__.py +5 -0
  47. config/constants.py +13 -0
  48. config/logging_config.py +159 -0
  49. config/nim.py +118 -0
  50. config/paths.py +91 -0
  51. config/provider_catalog.py +259 -0
  52. config/provider_ids.py +7 -0
  53. config/settings.py +538 -0
  54. core/__init__.py +1 -0
  55. core/anthropic/__init__.py +46 -0
  56. core/anthropic/content.py +31 -0
  57. core/anthropic/conversion.py +587 -0
  58. core/anthropic/emitted_sse_tracker.py +346 -0
  59. core/anthropic/errors.py +70 -0
  60. core/anthropic/native_messages_request.py +280 -0
  61. core/anthropic/native_sse_block_policy.py +313 -0
  62. core/anthropic/provider_stream_error.py +34 -0
  63. core/anthropic/server_tool_sse.py +14 -0
  64. core/anthropic/sse.py +440 -0
  65. core/anthropic/stream_contracts.py +205 -0
  66. core/anthropic/stream_recovery.py +346 -0
  67. core/anthropic/stream_recovery_session.py +133 -0
  68. core/anthropic/thinking.py +140 -0
  69. core/anthropic/tokens.py +117 -0
  70. core/anthropic/tools.py +212 -0
  71. core/anthropic/utils.py +9 -0
  72. core/openai_responses/__init__.py +5 -0
  73. core/openai_responses/adapter.py +31 -0
  74. core/openai_responses/anthropic_sse.py +59 -0
  75. core/openai_responses/errors.py +22 -0
  76. core/openai_responses/events.py +19 -0
  77. core/openai_responses/ids.py +21 -0
  78. core/openai_responses/input.py +258 -0
  79. core/openai_responses/items.py +37 -0
  80. core/openai_responses/reasoning.py +52 -0
  81. core/openai_responses/stream.py +25 -0
  82. core/openai_responses/stream_state.py +654 -0
  83. core/openai_responses/tools.py +374 -0
  84. core/openai_responses/usage.py +37 -0
  85. core/rate_limit.py +60 -0
  86. core/trace.py +216 -0
  87. devcopilot-0.2.0.dist-info/METADATA +687 -0
  88. devcopilot-0.2.0.dist-info/RECORD +189 -0
  89. devcopilot-0.2.0.dist-info/WHEEL +4 -0
  90. devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
  91. devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
  92. messaging/__init__.py +26 -0
  93. messaging/cli_event_constants.py +67 -0
  94. messaging/command_context.py +66 -0
  95. messaging/command_dispatcher.py +37 -0
  96. messaging/commands.py +275 -0
  97. messaging/event_parser.py +181 -0
  98. messaging/limiter.py +300 -0
  99. messaging/models.py +36 -0
  100. messaging/node_event_pipeline.py +127 -0
  101. messaging/node_runner.py +342 -0
  102. messaging/platforms/__init__.py +15 -0
  103. messaging/platforms/base.py +228 -0
  104. messaging/platforms/discord.py +567 -0
  105. messaging/platforms/factory.py +103 -0
  106. messaging/platforms/outbox.py +144 -0
  107. messaging/platforms/telegram.py +688 -0
  108. messaging/platforms/voice_flow.py +295 -0
  109. messaging/rendering/__init__.py +3 -0
  110. messaging/rendering/discord_markdown.py +318 -0
  111. messaging/rendering/markdown_tables.py +49 -0
  112. messaging/rendering/profiles.py +55 -0
  113. messaging/rendering/telegram_markdown.py +327 -0
  114. messaging/safe_diagnostics.py +17 -0
  115. messaging/session.py +334 -0
  116. messaging/transcript.py +581 -0
  117. messaging/transcription.py +164 -0
  118. messaging/trees/__init__.py +15 -0
  119. messaging/trees/data.py +482 -0
  120. messaging/trees/manager.py +433 -0
  121. messaging/trees/processor.py +179 -0
  122. messaging/trees/repository.py +177 -0
  123. messaging/turn_intake.py +235 -0
  124. messaging/ui_updates.py +101 -0
  125. messaging/voice.py +76 -0
  126. messaging/workflow.py +200 -0
  127. providers/__init__.py +31 -0
  128. providers/base.py +152 -0
  129. providers/cerebras/__init__.py +7 -0
  130. providers/cerebras/client.py +31 -0
  131. providers/cerebras/request.py +55 -0
  132. providers/codestral/__init__.py +7 -0
  133. providers/codestral/client.py +34 -0
  134. providers/deepseek/__init__.py +11 -0
  135. providers/deepseek/client.py +51 -0
  136. providers/deepseek/request.py +475 -0
  137. providers/defaults.py +41 -0
  138. providers/error_mapping.py +309 -0
  139. providers/exceptions.py +113 -0
  140. providers/fireworks/__init__.py +5 -0
  141. providers/fireworks/client.py +45 -0
  142. providers/fireworks/request.py +48 -0
  143. providers/gemini/__init__.py +7 -0
  144. providers/gemini/client.py +49 -0
  145. providers/gemini/request.py +199 -0
  146. providers/groq/__init__.py +7 -0
  147. providers/groq/client.py +31 -0
  148. providers/groq/request.py +83 -0
  149. providers/kimi/__init__.py +10 -0
  150. providers/kimi/client.py +53 -0
  151. providers/kimi/request.py +42 -0
  152. providers/llamacpp/__init__.py +3 -0
  153. providers/llamacpp/client.py +16 -0
  154. providers/lmstudio/__init__.py +5 -0
  155. providers/lmstudio/client.py +16 -0
  156. providers/mistral/__init__.py +7 -0
  157. providers/mistral/client.py +31 -0
  158. providers/mistral/request.py +37 -0
  159. providers/model_listing.py +133 -0
  160. providers/nvidia_nim/__init__.py +7 -0
  161. providers/nvidia_nim/client.py +91 -0
  162. providers/nvidia_nim/request.py +430 -0
  163. providers/nvidia_nim/voice.py +95 -0
  164. providers/ollama/__init__.py +7 -0
  165. providers/ollama/client.py +39 -0
  166. providers/open_router/__init__.py +7 -0
  167. providers/open_router/client.py +124 -0
  168. providers/open_router/request.py +42 -0
  169. providers/opencode/__init__.py +11 -0
  170. providers/opencode/client.py +31 -0
  171. providers/opencode/request.py +35 -0
  172. providers/rate_limit.py +300 -0
  173. providers/registry.py +527 -0
  174. providers/transports/__init__.py +1 -0
  175. providers/transports/anthropic_messages/__init__.py +5 -0
  176. providers/transports/anthropic_messages/http.py +118 -0
  177. providers/transports/anthropic_messages/recovery.py +206 -0
  178. providers/transports/anthropic_messages/stream.py +295 -0
  179. providers/transports/anthropic_messages/transport.py +236 -0
  180. providers/transports/openai_chat/__init__.py +5 -0
  181. providers/transports/openai_chat/recovery.py +217 -0
  182. providers/transports/openai_chat/stream.py +384 -0
  183. providers/transports/openai_chat/tool_calls.py +293 -0
  184. providers/transports/openai_chat/transport.py +156 -0
  185. providers/wafer/__init__.py +10 -0
  186. providers/wafer/client.py +50 -0
  187. providers/zai/__init__.py +10 -0
  188. providers/zai/client.py +46 -0
  189. providers/zai/request.py +42 -0
@@ -0,0 +1,581 @@
1
+ """Ordered transcript builder for messaging UIs (Telegram, etc.).
2
+
3
+ This module maintains an ordered list of "segments" that represent what the user
4
+ should see in the chat transcript: thinking, tool calls, tool results, subagent
5
+ headers, and assistant text. It is designed for in-place message editing where
6
+ the transcript grows over time and older content must be truncated.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from abc import ABC, abstractmethod
13
+ from collections import deque
14
+ from collections.abc import Callable, Iterable
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from loguru import logger
19
+
20
+
21
+ def _safe_json_dumps(obj: Any) -> str:
22
+ try:
23
+ return json.dumps(obj, indent=2, ensure_ascii=False, sort_keys=True)
24
+ except Exception:
25
+ return str(obj)
26
+
27
+
28
+ @dataclass
29
+ class Segment(ABC):
30
+ kind: str
31
+
32
+ @abstractmethod
33
+ def render(self, ctx: RenderCtx) -> str: ...
34
+
35
+
36
+ @dataclass
37
+ class ThinkingSegment(Segment):
38
+ def __init__(self) -> None:
39
+ super().__init__(kind="thinking")
40
+ self._parts: list[str] = []
41
+
42
+ def append(self, t: str) -> None:
43
+ if t:
44
+ self._parts.append(t)
45
+
46
+ @property
47
+ def text(self) -> str:
48
+ return "".join(self._parts)
49
+
50
+ def render(self, ctx: RenderCtx) -> str:
51
+ raw = self.text or ""
52
+ if ctx.thinking_tail_max is not None and len(raw) > ctx.thinking_tail_max:
53
+ raw = "..." + raw[-(ctx.thinking_tail_max - 3) :]
54
+ inner = ctx.escape_code(raw)
55
+ return f"💭 {ctx.bold('Thinking')}\n```\n{inner}\n```"
56
+
57
+
58
+ @dataclass
59
+ class TextSegment(Segment):
60
+ def __init__(self) -> None:
61
+ super().__init__(kind="text")
62
+ self._parts: list[str] = []
63
+
64
+ def append(self, t: str) -> None:
65
+ if t:
66
+ self._parts.append(t)
67
+
68
+ @property
69
+ def text(self) -> str:
70
+ return "".join(self._parts)
71
+
72
+ def render(self, ctx: RenderCtx) -> str:
73
+ raw = self.text or ""
74
+ if ctx.text_tail_max is not None and len(raw) > ctx.text_tail_max:
75
+ raw = "..." + raw[-(ctx.text_tail_max - 3) :]
76
+ return ctx.render_markdown(raw)
77
+
78
+
79
+ @dataclass
80
+ class ToolCallSegment(Segment):
81
+ tool_use_id: str
82
+ name: str
83
+ closed: bool = False
84
+ indent_level: int = 0
85
+
86
+ def __init__(self, tool_use_id: str, name: str, *, indent_level: int = 0) -> None:
87
+ super().__init__(kind="tool_call")
88
+ self.tool_use_id = str(tool_use_id or "")
89
+ self.name = str(name or "tool")
90
+ self.indent_level = max(0, int(indent_level))
91
+
92
+ def render(self, ctx: RenderCtx) -> str:
93
+ name = ctx.code_inline(self.name)
94
+ # Per UX requirement: do not display tool args/results, only the tool call.
95
+ prefix = " " * self.indent_level
96
+ return f"{prefix}🛠 {ctx.bold('Tool call:')} {name}"
97
+
98
+
99
+ @dataclass
100
+ class ToolResultSegment(Segment):
101
+ tool_use_id: str
102
+ name: str | None
103
+ content_text: str
104
+ is_error: bool = False
105
+
106
+ def __init__(
107
+ self,
108
+ tool_use_id: str,
109
+ content: Any,
110
+ *,
111
+ name: str | None = None,
112
+ is_error: bool = False,
113
+ ) -> None:
114
+ super().__init__(kind="tool_result")
115
+ self.tool_use_id = str(tool_use_id or "")
116
+ self.name = str(name) if name is not None else None
117
+ self.is_error = bool(is_error)
118
+ if isinstance(content, str):
119
+ self.content_text = content
120
+ else:
121
+ self.content_text = _safe_json_dumps(content)
122
+
123
+ def render(self, ctx: RenderCtx) -> str:
124
+ raw = self.content_text or ""
125
+ if ctx.tool_output_tail_max is not None and len(raw) > ctx.tool_output_tail_max:
126
+ raw = "..." + raw[-(ctx.tool_output_tail_max - 3) :]
127
+ inner = ctx.escape_code(raw)
128
+ label = "Tool error:" if self.is_error else "Tool result:"
129
+ maybe_name = f" {ctx.code_inline(self.name)}" if self.name else ""
130
+ return f"📤 {ctx.bold(label)}{maybe_name}\n```\n{inner}\n```"
131
+
132
+
133
+ @dataclass
134
+ class SubagentSegment(Segment):
135
+ description: str
136
+ tool_calls: int = 0
137
+ tools_used: set[str] = field(default_factory=set)
138
+ current_tool: ToolCallSegment | None = None
139
+
140
+ def __init__(self, description: str) -> None:
141
+ super().__init__(kind="subagent")
142
+ self.description = str(description or "Subagent")
143
+ self.tool_calls = 0
144
+ self.tools_used = set()
145
+ self.current_tool = None
146
+
147
+ def set_current_tool_call(self, tool_use_id: str, name: str) -> ToolCallSegment:
148
+ tool_use_id = str(tool_use_id or "")
149
+ name = str(name or "tool")
150
+ self.tools_used.add(name)
151
+ self.tool_calls += 1
152
+ self.current_tool = ToolCallSegment(tool_use_id, name, indent_level=1)
153
+ return self.current_tool
154
+
155
+ def render(self, ctx: RenderCtx) -> str:
156
+ inner_prefix = " "
157
+
158
+ lines: list[str] = [
159
+ f"🤖 {ctx.bold('Subagent:')} {ctx.code_inline(self.description)}"
160
+ ]
161
+
162
+ if self.current_tool is not None:
163
+ try:
164
+ rendered = self.current_tool.render(ctx)
165
+ except Exception:
166
+ rendered = ""
167
+ if rendered:
168
+ lines.append(rendered)
169
+
170
+ tools_used = sorted(self.tools_used)
171
+ tools_set_raw = "{{{}}}".format(", ".join(tools_used)) if tools_used else "{}"
172
+
173
+ # Keep braces inside a code entity so MarkdownV2 doesn't require escaping them.
174
+ lines.append(
175
+ f"{inner_prefix}{ctx.bold('Tools used:')} {ctx.code_inline(tools_set_raw)}"
176
+ )
177
+ lines.append(
178
+ f"{inner_prefix}{ctx.bold('Tool calls:')} {ctx.code_inline(str(self.tool_calls))}"
179
+ )
180
+ return "\n".join(lines)
181
+
182
+
183
+ @dataclass
184
+ class ErrorSegment(Segment):
185
+ message: str
186
+
187
+ def __init__(self, message: str) -> None:
188
+ super().__init__(kind="error")
189
+ self.message = str(message or "Unknown error")
190
+
191
+ def render(self, ctx: RenderCtx) -> str:
192
+ return f"⚠️ {ctx.bold('Error:')} {ctx.code_inline(self.message)}"
193
+
194
+
195
+ @dataclass
196
+ class RenderCtx:
197
+ bold: Callable[[str], str]
198
+ code_inline: Callable[[str], str]
199
+ escape_code: Callable[[str], str]
200
+ escape_text: Callable[[str], str]
201
+ render_markdown: Callable[[str], str]
202
+
203
+ thinking_tail_max: int | None = 1000
204
+ tool_input_tail_max: int | None = 1200
205
+ tool_output_tail_max: int | None = 1600
206
+ text_tail_max: int | None = 2000
207
+
208
+
209
+ class TranscriptBuffer:
210
+ """Maintains an ordered, truncatable transcript of events."""
211
+
212
+ def __init__(
213
+ self,
214
+ *,
215
+ show_tool_results: bool = True,
216
+ debug_subagent_stack: bool = False,
217
+ ) -> None:
218
+ self._segments: list[Segment] = []
219
+ self._open_thinking_by_index: dict[int, ThinkingSegment] = {}
220
+ self._open_text_by_index: dict[int, TextSegment] = {}
221
+
222
+ # content_block index -> tool call segment (for streaming tool args)
223
+ self._open_tools_by_index: dict[int, ToolCallSegment] = {}
224
+
225
+ # tool_use_id -> tool name (for tool_result labeling)
226
+ self._tool_name_by_id: dict[str, str] = {}
227
+
228
+ self._show_tool_results = bool(show_tool_results)
229
+
230
+ # subagent context stack. Each entry is the Task tool_use_id we are waiting to close.
231
+ self._subagent_stack: list[str] = []
232
+ # Parallel stack of segments for rendering nested subagents.
233
+ self._subagent_segments: list[SubagentSegment] = []
234
+ self._debug_subagent_stack = debug_subagent_stack
235
+
236
+ def _in_subagent(self) -> bool:
237
+ return bool(self._subagent_stack)
238
+
239
+ def _subagent_current(self) -> SubagentSegment | None:
240
+ return self._subagent_segments[-1] if self._subagent_segments else None
241
+
242
+ def _task_heading_from_input(self, inp: Any) -> str:
243
+ # We never display full JSON args; only extract a short heading.
244
+ if isinstance(inp, dict):
245
+ desc = str(inp.get("description", "") or "").strip()
246
+ if desc:
247
+ return desc
248
+ subagent_type = str(inp.get("subagent_type", "") or "").strip()
249
+ if subagent_type:
250
+ return subagent_type
251
+ typ = str(inp.get("type", "") or "").strip()
252
+ if typ:
253
+ return typ
254
+ return "Subagent"
255
+
256
+ def _subagent_push(self, tool_id: str, seg: SubagentSegment) -> None:
257
+ # Some providers can omit ids; still track depth for UI suppression.
258
+ tool_id = (
259
+ str(tool_id or "").strip() or f"__task_{len(self._subagent_stack) + 1}"
260
+ )
261
+ self._subagent_stack.append(tool_id)
262
+ self._subagent_segments.append(seg)
263
+ if self._debug_subagent_stack:
264
+ logger.debug(
265
+ "SUBAGENT_STACK: push id=%r depth=%d heading=%r",
266
+ tool_id,
267
+ len(self._subagent_stack),
268
+ getattr(seg, "description", None),
269
+ )
270
+
271
+ def _subagent_pop(self, tool_id: str) -> bool:
272
+ tool_id = str(tool_id or "").strip()
273
+ if not self._subagent_stack:
274
+ return False
275
+
276
+ def _ids_roughly_match(stack_id: str, result_id: str) -> bool:
277
+ if not stack_id or not result_id:
278
+ return False
279
+ if stack_id == result_id:
280
+ return True
281
+ # Some providers emit Task result ids with a suffix/prefix variant.
282
+ # Treat those as the same logical Task invocation.
283
+ return stack_id.startswith(result_id) or result_id.startswith(stack_id)
284
+
285
+ if tool_id:
286
+ # O(1) common case: LIFO - top of stack matches.
287
+ if _ids_roughly_match(self._subagent_stack[-1], tool_id):
288
+ self._subagent_stack.pop()
289
+ if self._subagent_segments:
290
+ self._subagent_segments.pop()
291
+ if self._debug_subagent_stack:
292
+ logger.debug(
293
+ "SUBAGENT_STACK: pop id=%r depth=%d (LIFO)",
294
+ tool_id,
295
+ len(self._subagent_stack),
296
+ )
297
+ return True
298
+ # Pop to the matching id (defensive against non-LIFO emissions).
299
+ idx = -1
300
+ for i in range(len(self._subagent_stack) - 1, -1, -1):
301
+ if _ids_roughly_match(self._subagent_stack[i], tool_id):
302
+ idx = i
303
+ break
304
+ if idx < 0:
305
+ return False
306
+ while len(self._subagent_stack) > idx:
307
+ popped = self._subagent_stack.pop()
308
+ if self._subagent_segments:
309
+ self._subagent_segments.pop()
310
+ if self._debug_subagent_stack:
311
+ logger.debug(
312
+ "SUBAGENT_STACK: pop id=%r depth=%d (matched=%r)",
313
+ popped,
314
+ len(self._subagent_stack),
315
+ tool_id,
316
+ )
317
+ return True
318
+
319
+ # No id in result; only close if we have a synthetic top marker.
320
+ if self._subagent_stack and self._subagent_stack[-1].startswith("__task_"):
321
+ popped = self._subagent_stack.pop()
322
+ if self._subagent_segments:
323
+ self._subagent_segments.pop()
324
+ if self._debug_subagent_stack:
325
+ logger.debug(
326
+ "SUBAGENT_STACK: pop id=%r depth=%d (synthetic)",
327
+ popped,
328
+ len(self._subagent_stack),
329
+ )
330
+ return True
331
+ return False
332
+
333
+ def _ensure_thinking(self) -> ThinkingSegment:
334
+ seg = ThinkingSegment()
335
+ self._segments.append(seg)
336
+ return seg
337
+
338
+ def _ensure_text(self) -> TextSegment:
339
+ seg = TextSegment()
340
+ self._segments.append(seg)
341
+ return seg
342
+
343
+ def apply(self, ev: dict[str, Any]) -> None:
344
+ """Apply a parsed event to the transcript."""
345
+ et = ev.get("type")
346
+
347
+ # Subagent rules: inside a Task/subagent, we only show tool calls/results.
348
+ if self._in_subagent() and et in (
349
+ "thinking_start",
350
+ "thinking_delta",
351
+ "thinking_chunk",
352
+ "text_start",
353
+ "text_delta",
354
+ "text_chunk",
355
+ ):
356
+ return
357
+
358
+ if et == "thinking_start":
359
+ idx = int(ev.get("index", -1))
360
+ if idx >= 0:
361
+ # Defensive: if a provider reuses indices without emitting a stop,
362
+ # close the previous open segment first.
363
+ self.apply({"type": "block_stop", "index": idx})
364
+ seg = self._ensure_thinking()
365
+ if idx >= 0:
366
+ self._open_thinking_by_index[idx] = seg
367
+ return
368
+ if et in ("thinking_delta", "thinking_chunk"):
369
+ idx = int(ev.get("index", -1))
370
+ seg = self._open_thinking_by_index.get(idx)
371
+ if seg is None:
372
+ seg = self._ensure_thinking()
373
+ if idx >= 0:
374
+ self._open_thinking_by_index[idx] = seg
375
+ seg.append(str(ev.get("text", "")))
376
+ return
377
+ if et == "thinking_stop":
378
+ idx = int(ev.get("index", -1))
379
+ if idx >= 0:
380
+ self._open_thinking_by_index.pop(idx, None)
381
+ return
382
+
383
+ if et == "text_start":
384
+ idx = int(ev.get("index", -1))
385
+ if idx >= 0:
386
+ self.apply({"type": "block_stop", "index": idx})
387
+ seg = self._ensure_text()
388
+ if idx >= 0:
389
+ self._open_text_by_index[idx] = seg
390
+ return
391
+ if et in ("text_delta", "text_chunk"):
392
+ idx = int(ev.get("index", -1))
393
+ seg = self._open_text_by_index.get(idx)
394
+ if seg is None:
395
+ seg = self._ensure_text()
396
+ if idx >= 0:
397
+ self._open_text_by_index[idx] = seg
398
+ seg.append(str(ev.get("text", "")))
399
+ return
400
+ if et == "text_stop":
401
+ idx = int(ev.get("index", -1))
402
+ if idx >= 0:
403
+ self._open_text_by_index.pop(idx, None)
404
+ return
405
+
406
+ if et == "tool_use_start":
407
+ idx = int(ev.get("index", -1))
408
+ if idx >= 0:
409
+ self.apply({"type": "block_stop", "index": idx})
410
+ tool_id = str(ev.get("id", "") or "").strip()
411
+ name = str(ev.get("name", "") or "tool")
412
+ if tool_id:
413
+ self._tool_name_by_id[tool_id] = name
414
+
415
+ # Task tool indicates subagent.
416
+ if name == "Task":
417
+ heading = self._task_heading_from_input(ev.get("input"))
418
+ seg = SubagentSegment(heading)
419
+ self._segments.append(seg)
420
+ self._subagent_push(tool_id, seg)
421
+ return
422
+
423
+ # Normal tool call.
424
+ if self._in_subagent():
425
+ parent = self._subagent_current()
426
+ if parent is not None:
427
+ seg = parent.set_current_tool_call(tool_id, name)
428
+ else:
429
+ seg = ToolCallSegment(tool_id, name)
430
+ self._segments.append(seg)
431
+ else:
432
+ seg = ToolCallSegment(tool_id, name)
433
+ self._segments.append(seg)
434
+
435
+ if idx >= 0:
436
+ self._open_tools_by_index[idx] = seg
437
+ return
438
+
439
+ if et == "tool_use_delta":
440
+ # Track open tool by index for tool_use_stop (closing state).
441
+ return
442
+
443
+ if et == "tool_use_stop":
444
+ idx = int(ev.get("index", -1))
445
+ seg = self._open_tools_by_index.pop(idx, None)
446
+ if seg is not None:
447
+ seg.closed = True
448
+ return
449
+
450
+ if et == "block_stop":
451
+ idx = int(ev.get("index", -1))
452
+ if idx in self._open_tools_by_index:
453
+ self.apply({"type": "tool_use_stop", "index": idx})
454
+ return
455
+ if idx in self._open_thinking_by_index:
456
+ self.apply({"type": "thinking_stop", "index": idx})
457
+ return
458
+ if idx in self._open_text_by_index:
459
+ self.apply({"type": "text_stop", "index": idx})
460
+ return
461
+ return
462
+
463
+ if et == "tool_use":
464
+ tool_id = str(ev.get("id", "") or "").strip()
465
+ name = str(ev.get("name", "") or "tool")
466
+ if tool_id:
467
+ self._tool_name_by_id[tool_id] = name
468
+
469
+ if name == "Task":
470
+ heading = self._task_heading_from_input(ev.get("input"))
471
+ seg = SubagentSegment(heading)
472
+ self._segments.append(seg)
473
+ self._subagent_push(tool_id, seg)
474
+ return
475
+
476
+ if self._in_subagent():
477
+ parent = self._subagent_current()
478
+ if parent is not None:
479
+ seg = parent.set_current_tool_call(tool_id, name)
480
+ else:
481
+ seg = ToolCallSegment(tool_id, name)
482
+ self._segments.append(seg)
483
+ else:
484
+ seg = ToolCallSegment(tool_id, name)
485
+ self._segments.append(seg)
486
+
487
+ seg.closed = True
488
+ return
489
+
490
+ if et == "tool_result":
491
+ tool_id = str(ev.get("tool_use_id", "") or "").strip()
492
+ name = self._tool_name_by_id.get(tool_id)
493
+
494
+ # If this was the Task tool result, close subagent context.
495
+ if self._subagent_stack:
496
+ popped = self._subagent_pop(tool_id)
497
+ top = self._subagent_stack[-1] if self._subagent_stack else ""
498
+ looks_like_task_id = "task" in tool_id.lower()
499
+ # Some streams omit Task tool_use ids (synthetic stack ids), but include
500
+ # a real Task id on tool_result (e.g. "functions.Task:0"). Reconcile that.
501
+ if (
502
+ not popped
503
+ and tool_id
504
+ and top.startswith("__task_")
505
+ and (name in (None, "Task"))
506
+ and looks_like_task_id
507
+ ):
508
+ self._subagent_pop("")
509
+
510
+ if not self._show_tool_results:
511
+ return
512
+
513
+ seg = ToolResultSegment(
514
+ tool_id,
515
+ ev.get("content"),
516
+ name=name,
517
+ is_error=bool(ev.get("is_error", False)),
518
+ )
519
+ self._segments.append(seg)
520
+ return
521
+
522
+ if et == "error":
523
+ self._segments.append(ErrorSegment(str(ev.get("message", ""))))
524
+ return
525
+
526
+ def render(self, ctx: RenderCtx, *, limit_chars: int, status: str | None) -> str:
527
+ """Render transcript with truncation (drop oldest segments)."""
528
+ # Filter out empty rendered segments.
529
+ rendered: list[str] = []
530
+ for seg in self._segments:
531
+ try:
532
+ out = seg.render(ctx)
533
+ except Exception:
534
+ continue
535
+ if out:
536
+ rendered.append(out)
537
+
538
+ status_text = f"\n\n{status}" if status else ""
539
+ prefix_marker = ctx.escape_text("... (truncated)\n")
540
+
541
+ def _join(parts: Iterable[str], add_marker: bool) -> str:
542
+ body = "\n".join(parts)
543
+ if add_marker and body:
544
+ body = prefix_marker + body
545
+ return body + status_text if (body or status_text) else status_text
546
+
547
+ # Fast path.
548
+ candidate = _join(rendered, add_marker=False)
549
+ if len(candidate) <= limit_chars:
550
+ return candidate
551
+
552
+ # Drop oldest segments until under limit (keep the tail).
553
+ # Use deque for O(1) popleft; list.pop(0) would be O(n) per iteration.
554
+ parts: deque[str] = deque(rendered)
555
+ dropped = False
556
+ last_part: str | None = None
557
+ while parts:
558
+ candidate = _join(parts, add_marker=True)
559
+ if len(candidate) <= limit_chars:
560
+ return candidate
561
+ last_part = parts.popleft()
562
+ dropped = True
563
+
564
+ # Nothing fits - preserve tail of last segment instead of only marker+status.
565
+ if dropped and last_part:
566
+ budget = limit_chars - len(prefix_marker) - len(status_text)
567
+ if budget > 20:
568
+ if len(last_part) > budget:
569
+ tail = "..." + last_part[-(budget - 3) :]
570
+ else:
571
+ tail = last_part
572
+ candidate = prefix_marker + tail + status_text
573
+ if len(candidate) <= limit_chars:
574
+ return candidate
575
+
576
+ # Fallback: marker + status only.
577
+ if dropped:
578
+ minimal = prefix_marker + status_text.lstrip("\n")
579
+ if len(minimal) <= limit_chars:
580
+ return minimal
581
+ return status or ""