comet-code 0.1.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.
- cli/__init__.py +1 -0
- cli/commands.py +266 -0
- cli/completer.py +18 -0
- cli/render.py +420 -0
- cli/state.py +18 -0
- cli/ui.py +424 -0
- comet_code-0.1.0.dist-info/METADATA +154 -0
- comet_code-0.1.0.dist-info/RECORD +32 -0
- comet_code-0.1.0.dist-info/WHEEL +4 -0
- comet_code-0.1.0.dist-info/entry_points.txt +2 -0
- comet_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- config.py +97 -0
- core/__init__.py +1 -0
- core/graph.py +62 -0
- core/graph_state.py +52 -0
- core/nodes.py +711 -0
- core/orchestrator.py +217 -0
- llm/__init__.py +1 -0
- llm/models.py +46 -0
- llm/openrouter_client.py +32 -0
- llm/prompts.py +179 -0
- main.py +91 -0
- schemas/__init__.py +1 -0
- schemas/attempt.py +65 -0
- schemas/code_chunk.py +30 -0
- schemas/events.py +33 -0
- schemas/mode_policy.py +11 -0
- schemas/plan.py +34 -0
- schemas/session.py +36 -0
- schemas/task.py +66 -0
- schemas/tool.py +24 -0
- tools/__init__.py +450 -0
cli/render.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Streaming event rendering for the interactive shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.console import Group
|
|
11
|
+
from rich.markdown import Markdown
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from schemas.events import EventType, StreamEvent
|
|
16
|
+
|
|
17
|
+
_TOOL_DISPLAY: dict[str, tuple[str, str | None]] = {
|
|
18
|
+
"list_files": ("List", "path"),
|
|
19
|
+
"search_text": ("Search", "pattern"),
|
|
20
|
+
"find_files": ("Find", "pattern"),
|
|
21
|
+
"print_tree": ("Tree", "path"),
|
|
22
|
+
"read_file": ("Read", "path"),
|
|
23
|
+
"read_range": ("Read", "path"),
|
|
24
|
+
"write_file": ("Write", "path"),
|
|
25
|
+
"replace_text": ("Edit", "path"),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ToolHistoryEntry:
|
|
31
|
+
tool_name: str
|
|
32
|
+
args_json: str
|
|
33
|
+
reason: str | None
|
|
34
|
+
status: str = "running"
|
|
35
|
+
preview: str | None = None
|
|
36
|
+
error: str | None = None
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
return {
|
|
40
|
+
"tool_name": self.tool_name,
|
|
41
|
+
"args_json": self.args_json,
|
|
42
|
+
"reason": self.reason,
|
|
43
|
+
"status": self.status,
|
|
44
|
+
"preview": self.preview,
|
|
45
|
+
"error": self.error,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class EventRenderer:
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
console: Console,
|
|
53
|
+
collapsed_tools: bool = True,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.console = console
|
|
56
|
+
self._buffer: list[str] = []
|
|
57
|
+
self._collapsed_tools = collapsed_tools
|
|
58
|
+
self._tool_history: list[ToolHistoryEntry] = []
|
|
59
|
+
self._active_tool_idx: int | None = None
|
|
60
|
+
self._status_text = Text("☄ actualizing star map...", style="bright_cyan")
|
|
61
|
+
self._show_post_tool_transition = False
|
|
62
|
+
self._persisted_tool_history_count = 0
|
|
63
|
+
self._token_count: int = 0
|
|
64
|
+
self._start_time: float = time.monotonic()
|
|
65
|
+
|
|
66
|
+
def get_tool_history(self) -> list[dict]:
|
|
67
|
+
return [item.to_dict() for item in self._tool_history]
|
|
68
|
+
|
|
69
|
+
def get_elapsed_str(self) -> str:
|
|
70
|
+
secs = int(time.monotonic() - self._start_time)
|
|
71
|
+
if secs >= 60:
|
|
72
|
+
return f"{secs // 60}m {secs % 60:02d}s"
|
|
73
|
+
return f"{secs}s"
|
|
74
|
+
|
|
75
|
+
def get_status_text(self) -> Text:
|
|
76
|
+
txt = self._status_text.copy()
|
|
77
|
+
elapsed = self.get_elapsed_str()
|
|
78
|
+
# Separator before token count / elapsed time for clarity
|
|
79
|
+
if self._token_count > 0:
|
|
80
|
+
txt.append(" |", style="dim")
|
|
81
|
+
n = self._token_count
|
|
82
|
+
label = f"~{n/1000:.1f}k" if n >= 1000 else f"~{n}"
|
|
83
|
+
txt.append(f" {label} tokens", style="dim")
|
|
84
|
+
txt.append(" |", style="dim")
|
|
85
|
+
else:
|
|
86
|
+
txt.append(" |", style="dim")
|
|
87
|
+
txt.append(f" {elapsed}", style="dim")
|
|
88
|
+
return txt
|
|
89
|
+
|
|
90
|
+
def persist_tool_history_snapshot(self) -> None:
|
|
91
|
+
if not self._collapsed_tools:
|
|
92
|
+
return
|
|
93
|
+
if self._persisted_tool_history_count >= len(self._tool_history):
|
|
94
|
+
return
|
|
95
|
+
self.console.print()
|
|
96
|
+
self.console.print(self._build_collapsed_tool_summary(start_idx=self._persisted_tool_history_count))
|
|
97
|
+
self._persisted_tool_history_count = len(self._tool_history)
|
|
98
|
+
|
|
99
|
+
def should_render_live_tool_row(self) -> bool:
|
|
100
|
+
return self._collapsed_tools and bool(self._tool_history)
|
|
101
|
+
|
|
102
|
+
def build_live_tool_renderable(self, now: float | None = None) -> Group | Text:
|
|
103
|
+
if not self.should_render_live_tool_row():
|
|
104
|
+
return Text("")
|
|
105
|
+
|
|
106
|
+
visible_idx = self._visible_tool_index()
|
|
107
|
+
if visible_idx is None:
|
|
108
|
+
return Text("")
|
|
109
|
+
|
|
110
|
+
entry = self._tool_history[visible_idx]
|
|
111
|
+
human_name, key_arg = self._format_tool_display(entry)
|
|
112
|
+
lines: list[Text] = [
|
|
113
|
+
Text(" tool", style="dim"),
|
|
114
|
+
Text.assemble(
|
|
115
|
+
(" ", "default"),
|
|
116
|
+
(f"{self._status_dot(entry.status)} ", self._status_style(entry.status)),
|
|
117
|
+
(human_name, "bold bright_cyan"),
|
|
118
|
+
(" ", "default"),
|
|
119
|
+
(key_arg, "white"),
|
|
120
|
+
(" ", "default"),
|
|
121
|
+
(entry.status, "dim"),
|
|
122
|
+
),
|
|
123
|
+
]
|
|
124
|
+
preview = self._tool_secondary_line(entry)
|
|
125
|
+
if preview is not None:
|
|
126
|
+
lines.append(preview)
|
|
127
|
+
if self._show_post_tool_transition:
|
|
128
|
+
lines.append(
|
|
129
|
+
Text.assemble(
|
|
130
|
+
(" ", "default"),
|
|
131
|
+
("────", "dim"),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
return Group(*lines)
|
|
135
|
+
|
|
136
|
+
def render(self, event: StreamEvent) -> None:
|
|
137
|
+
if event.type == EventType.TOKEN:
|
|
138
|
+
self._render_token(event)
|
|
139
|
+
elif event.type == EventType.TOOL_START:
|
|
140
|
+
self._render_tool_start(event)
|
|
141
|
+
elif event.type == EventType.TOOL_END:
|
|
142
|
+
self._render_tool_end(event)
|
|
143
|
+
elif event.type == EventType.LIMIT:
|
|
144
|
+
self._render_limit(event)
|
|
145
|
+
elif event.type == EventType.ATTEMPT_RETRY:
|
|
146
|
+
self._render_attempt_retry(event)
|
|
147
|
+
elif event.type == EventType.USAGE:
|
|
148
|
+
self._render_usage(event)
|
|
149
|
+
elif event.type == EventType.ERROR:
|
|
150
|
+
self.console.print(f"\n [bold red]error:[/bold red] {event.error}\n")
|
|
151
|
+
elif event.type == EventType.FINAL:
|
|
152
|
+
self._render_final(event)
|
|
153
|
+
self._update_status_for_event(event)
|
|
154
|
+
|
|
155
|
+
def _render_token(self, event: StreamEvent) -> None:
|
|
156
|
+
text = event.text or ""
|
|
157
|
+
if text:
|
|
158
|
+
self._buffer.append(text)
|
|
159
|
+
self._token_count += max(1, len(text) // 4)
|
|
160
|
+
if self._tool_history:
|
|
161
|
+
self._show_post_tool_transition = True
|
|
162
|
+
|
|
163
|
+
def _render_tool_start(self, event: StreamEvent) -> None:
|
|
164
|
+
args = event.args or {}
|
|
165
|
+
args_str = json.dumps(args, ensure_ascii=True) if args else "{}"
|
|
166
|
+
reason = (event.reason or "").strip() or None
|
|
167
|
+
if reason and len(reason) > 160:
|
|
168
|
+
reason = reason[:157].rstrip() + "..."
|
|
169
|
+
|
|
170
|
+
entry = ToolHistoryEntry(
|
|
171
|
+
tool_name=event.tool_name or "unknown",
|
|
172
|
+
args_json=args_str,
|
|
173
|
+
reason=reason,
|
|
174
|
+
status="running",
|
|
175
|
+
)
|
|
176
|
+
self._tool_history.append(entry)
|
|
177
|
+
self._active_tool_idx = len(self._tool_history) - 1
|
|
178
|
+
self._show_post_tool_transition = False
|
|
179
|
+
|
|
180
|
+
if self._collapsed_tools:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
human_name, key_arg = self._format_tool_display(entry)
|
|
184
|
+
self.console.print(
|
|
185
|
+
Text.assemble(
|
|
186
|
+
(" tool ", "dim"),
|
|
187
|
+
(human_name, "bold bright_cyan"),
|
|
188
|
+
(" ", "default"),
|
|
189
|
+
(key_arg, "white"),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
if entry.reason:
|
|
193
|
+
self.console.print(Text.assemble((" why ", "dim"), (entry.reason, "italic dim")))
|
|
194
|
+
|
|
195
|
+
def _render_tool_end(self, event: StreamEvent) -> None:
|
|
196
|
+
entry: ToolHistoryEntry | None = None
|
|
197
|
+
if self._active_tool_idx is not None and 0 <= self._active_tool_idx < len(self._tool_history):
|
|
198
|
+
entry = self._tool_history[self._active_tool_idx]
|
|
199
|
+
|
|
200
|
+
if entry is None:
|
|
201
|
+
args = event.args or {}
|
|
202
|
+
args_str = json.dumps(args, ensure_ascii=True) if args else "{}"
|
|
203
|
+
entry = ToolHistoryEntry(
|
|
204
|
+
tool_name=event.tool_name or "unknown",
|
|
205
|
+
args_json=args_str,
|
|
206
|
+
reason=None,
|
|
207
|
+
status="done",
|
|
208
|
+
)
|
|
209
|
+
self._tool_history.append(entry)
|
|
210
|
+
|
|
211
|
+
if event.error:
|
|
212
|
+
entry.status = "error"
|
|
213
|
+
entry.error = event.error
|
|
214
|
+
self._active_tool_idx = None
|
|
215
|
+
self._show_post_tool_transition = True
|
|
216
|
+
if not self._collapsed_tools:
|
|
217
|
+
self.console.print(f" [red]error:[/red] {event.error}")
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
if event.output:
|
|
221
|
+
self._token_count += max(1, len(event.output) // 4)
|
|
222
|
+
lines = event.output.splitlines() or [event.output]
|
|
223
|
+
shown = lines[:2]
|
|
224
|
+
remaining = max(len(lines) - 2, 0)
|
|
225
|
+
preview = "\n".join(shown).strip() or "[no output]"
|
|
226
|
+
if remaining > 0:
|
|
227
|
+
preview = f"{preview}\n… +{remaining} more line(s)"
|
|
228
|
+
entry.preview = preview
|
|
229
|
+
entry.status = "done"
|
|
230
|
+
|
|
231
|
+
if not self._collapsed_tools:
|
|
232
|
+
self.console.print(
|
|
233
|
+
Panel(
|
|
234
|
+
preview,
|
|
235
|
+
border_style="bright_black",
|
|
236
|
+
padding=(0, 1),
|
|
237
|
+
expand=True,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
entry.status = "done"
|
|
242
|
+
self._active_tool_idx = None
|
|
243
|
+
self._show_post_tool_transition = True
|
|
244
|
+
|
|
245
|
+
def _render_final(self, event: StreamEvent) -> None:
|
|
246
|
+
text = "".join(self._buffer).strip()
|
|
247
|
+
if not text:
|
|
248
|
+
text = (event.text or "").strip()
|
|
249
|
+
if not text:
|
|
250
|
+
text = "No final response text was produced."
|
|
251
|
+
|
|
252
|
+
if self._collapsed_tools and self._persisted_tool_history_count < len(self._tool_history):
|
|
253
|
+
self.console.print()
|
|
254
|
+
self.console.print(self._build_collapsed_tool_summary(start_idx=self._persisted_tool_history_count))
|
|
255
|
+
self._persisted_tool_history_count = len(self._tool_history)
|
|
256
|
+
|
|
257
|
+
self.console.print()
|
|
258
|
+
self.console.print(
|
|
259
|
+
Panel(
|
|
260
|
+
Markdown(text),
|
|
261
|
+
title="[bold bright_cyan]response[/bold bright_cyan]",
|
|
262
|
+
border_style="bright_blue",
|
|
263
|
+
padding=(1, 2),
|
|
264
|
+
expand=True,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
self.console.print()
|
|
268
|
+
self._buffer.clear()
|
|
269
|
+
self._show_post_tool_transition = False
|
|
270
|
+
|
|
271
|
+
def _render_limit(self, event: StreamEvent) -> None:
|
|
272
|
+
self._show_post_tool_transition = bool(self._tool_history)
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
def _render_attempt_retry(self, event: StreamEvent) -> None:
|
|
276
|
+
self._show_post_tool_transition = bool(self._tool_history)
|
|
277
|
+
reason = event.reason or "Attempt did not converge."
|
|
278
|
+
self.console.print(
|
|
279
|
+
Text.assemble(
|
|
280
|
+
(" retry ", "dim"),
|
|
281
|
+
("trying a different approach", "bold bright_magenta"),
|
|
282
|
+
(" — ", "dim"),
|
|
283
|
+
(reason, "dim white"),
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _render_usage(self, event: StreamEvent) -> None:
|
|
288
|
+
total = event.total_tokens or 0
|
|
289
|
+
if total <= 0:
|
|
290
|
+
return
|
|
291
|
+
label = f"↓ ~{total:,} tokens"
|
|
292
|
+
if event.estimated:
|
|
293
|
+
label += " (est.)"
|
|
294
|
+
self.console.print(Text(label, style="dim"))
|
|
295
|
+
|
|
296
|
+
def _visible_tool_index(self) -> int | None:
|
|
297
|
+
if not self._tool_history:
|
|
298
|
+
return None
|
|
299
|
+
if self._active_tool_idx is not None and 0 <= self._active_tool_idx < len(self._tool_history):
|
|
300
|
+
return self._active_tool_idx
|
|
301
|
+
return len(self._tool_history) - 1
|
|
302
|
+
|
|
303
|
+
def _build_collapsed_tool_summary(self, start_idx: int = 0) -> Group:
|
|
304
|
+
lines = [Text(" tool", style="dim")]
|
|
305
|
+
for entry in self._tool_history[start_idx:]:
|
|
306
|
+
human_name, key_arg = self._format_tool_display(entry)
|
|
307
|
+
lines.append(
|
|
308
|
+
Text.assemble(
|
|
309
|
+
(" ", "default"),
|
|
310
|
+
(f"{self._status_dot(entry.status)} ", self._status_style(entry.status)),
|
|
311
|
+
(human_name, "bold bright_cyan"),
|
|
312
|
+
(" ", "default"),
|
|
313
|
+
(key_arg, "white"),
|
|
314
|
+
(" ", "default"),
|
|
315
|
+
(entry.status, "dim"),
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
preview = self._tool_secondary_line(entry)
|
|
319
|
+
if preview is not None:
|
|
320
|
+
lines.append(preview)
|
|
321
|
+
return Group(*lines)
|
|
322
|
+
|
|
323
|
+
def _format_tool_display(self, entry: ToolHistoryEntry) -> tuple[str, str]:
|
|
324
|
+
"""Return (human_label, key_arg_value) for clean title rendering."""
|
|
325
|
+
human_label, primary_key = _TOOL_DISPLAY.get(entry.tool_name, (entry.tool_name, None))
|
|
326
|
+
args = self._load_args(entry.args_json)
|
|
327
|
+
key_value = ""
|
|
328
|
+
if primary_key and primary_key in args:
|
|
329
|
+
raw = self._format_arg(primary_key, args[primary_key])
|
|
330
|
+
key_value = raw.split("=", 1)[1].strip('"') if "=" in raw else raw
|
|
331
|
+
elif args:
|
|
332
|
+
first_key, first_val = next(iter(args.items()))
|
|
333
|
+
raw = self._format_arg(first_key, first_val)
|
|
334
|
+
key_value = raw.split("=", 1)[1].strip('"') if "=" in raw else raw
|
|
335
|
+
return human_label, key_value
|
|
336
|
+
|
|
337
|
+
def _format_tool_invocation(self, entry: ToolHistoryEntry) -> str:
|
|
338
|
+
args = self._load_args(entry.args_json)
|
|
339
|
+
if not args:
|
|
340
|
+
return entry.tool_name
|
|
341
|
+
|
|
342
|
+
parts = [entry.tool_name]
|
|
343
|
+
for key, value in args.items():
|
|
344
|
+
parts.append(self._format_arg(key, value))
|
|
345
|
+
return " ".join(parts)
|
|
346
|
+
|
|
347
|
+
def _tool_secondary_line(self, entry: ToolHistoryEntry) -> Text | None:
|
|
348
|
+
detail: str | None = None
|
|
349
|
+
style = "dim"
|
|
350
|
+
if entry.error:
|
|
351
|
+
detail = entry.error
|
|
352
|
+
style = "red"
|
|
353
|
+
elif entry.preview:
|
|
354
|
+
detail = entry.preview.splitlines()[0].strip()
|
|
355
|
+
elif entry.reason:
|
|
356
|
+
detail = entry.reason
|
|
357
|
+
|
|
358
|
+
if not detail:
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
if len(detail) > 120:
|
|
362
|
+
detail = detail[:117].rstrip() + "..."
|
|
363
|
+
return Text.assemble(
|
|
364
|
+
(" ", "default"),
|
|
365
|
+
("└ ", "dim"),
|
|
366
|
+
(detail, style),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def _load_args(self, args_json: str) -> dict[str, object]:
|
|
370
|
+
try:
|
|
371
|
+
loaded = json.loads(args_json)
|
|
372
|
+
except json.JSONDecodeError:
|
|
373
|
+
return {}
|
|
374
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
375
|
+
|
|
376
|
+
def _format_arg(self, key: str, value: object) -> str:
|
|
377
|
+
if isinstance(value, str):
|
|
378
|
+
rendered = value if " " not in value else json.dumps(value, ensure_ascii=True)
|
|
379
|
+
return f"{key}={rendered}"
|
|
380
|
+
if isinstance(value, bool):
|
|
381
|
+
return f"{key}={'true' if value else 'false'}"
|
|
382
|
+
if isinstance(value, (int, float)):
|
|
383
|
+
return f"{key}={value}"
|
|
384
|
+
return f"{key}={json.dumps(value, ensure_ascii=True)}"
|
|
385
|
+
|
|
386
|
+
def _status_dot(self, status: str) -> str:
|
|
387
|
+
if status == "error":
|
|
388
|
+
return "●"
|
|
389
|
+
if status == "running":
|
|
390
|
+
return "●"
|
|
391
|
+
return "●"
|
|
392
|
+
|
|
393
|
+
def _status_style(self, status: str) -> str:
|
|
394
|
+
if status == "error":
|
|
395
|
+
return "red"
|
|
396
|
+
if status == "running":
|
|
397
|
+
return "yellow"
|
|
398
|
+
return "green"
|
|
399
|
+
|
|
400
|
+
def _update_status_for_event(self, event: StreamEvent) -> None:
|
|
401
|
+
if event.type == EventType.TOOL_START:
|
|
402
|
+
self._status_text = Text("☄ running tool...", style="bright_cyan")
|
|
403
|
+
return
|
|
404
|
+
if event.type == EventType.TOOL_END:
|
|
405
|
+
self._status_text = Text("☄ mapping starlines...", style="bright_cyan")
|
|
406
|
+
return
|
|
407
|
+
if event.type == EventType.TOKEN:
|
|
408
|
+
self._status_text = Text("☄ composing answer...", style="bright_cyan")
|
|
409
|
+
return
|
|
410
|
+
if event.type == EventType.FINAL:
|
|
411
|
+
self._status_text = Text("☄ docking complete", style="bright_cyan")
|
|
412
|
+
return
|
|
413
|
+
if event.type == EventType.LIMIT:
|
|
414
|
+
self._status_text = Text("☄ wrapping up an answer...", style="bright_cyan")
|
|
415
|
+
return
|
|
416
|
+
if event.type == EventType.ATTEMPT_RETRY:
|
|
417
|
+
self._status_text = Text("☄ switching approach...", style="bright_magenta")
|
|
418
|
+
return
|
|
419
|
+
if event.type == EventType.ERROR:
|
|
420
|
+
self._status_text = Text("☄ run failed", style="red")
|
cli/state.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Mutable shell state — current mode, current model, etc."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from llm.models import DEFAULT_MODEL, ModelInfo
|
|
8
|
+
from schemas.task import TaskMode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ShellState:
|
|
13
|
+
"""State carried across slash-command invocations within one shell session."""
|
|
14
|
+
|
|
15
|
+
mode: TaskMode = TaskMode.EXPLAIN
|
|
16
|
+
model: ModelInfo = DEFAULT_MODEL
|
|
17
|
+
tool_view_collapsed: bool = True
|
|
18
|
+
last_tool_history: list[dict] = field(default_factory=list)
|