@agxnte/reidx 2.0.2
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.
- package/README.md +724 -0
- package/bin/reidx.js +33 -0
- package/package.json +40 -0
- package/pyproject.toml +45 -0
- package/scripts/find-python.js +24 -0
- package/scripts/postinstall.js +35 -0
- package/src/reidx/__init__.py +3 -0
- package/src/reidx/__main__.py +9 -0
- package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__init__.py +3 -0
- package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/app/commands.py +221 -0
- package/src/reidx/automation/__init__.py +3 -0
- package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
- package/src/reidx/automation/exec.py +35 -0
- package/src/reidx/config/__init__.py +10 -0
- package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
- package/src/reidx/config/loader.py +104 -0
- package/src/reidx/config/models.py +49 -0
- package/src/reidx/config/settings.py +127 -0
- package/src/reidx/deepreid/__init__.py +8 -0
- package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/pipeline.py +221 -0
- package/src/reidx/diagnostics/__init__.py +0 -0
- package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/logger.py +56 -0
- package/src/reidx/goals/__init__.py +21 -0
- package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/goals/models.py +101 -0
- package/src/reidx/goals/store.py +233 -0
- package/src/reidx/integrations/__init__.py +3 -0
- package/src/reidx/integrations/mcp.py +70 -0
- package/src/reidx/nyx/__init__.py +3 -0
- package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
- package/src/reidx/nyx/persona.py +35 -0
- package/src/reidx/policy/__init__.py +15 -0
- package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/policy/engine.py +101 -0
- package/src/reidx/policy/models.py +35 -0
- package/src/reidx/provider/__init__.py +16 -0
- package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
- package/src/reidx/provider/_http.py +29 -0
- package/src/reidx/provider/anthropic.py +176 -0
- package/src/reidx/provider/base.py +50 -0
- package/src/reidx/provider/ollama.py +109 -0
- package/src/reidx/provider/openai.py +144 -0
- package/src/reidx/provider/registry.py +88 -0
- package/src/reidx/provider/store.py +141 -0
- package/src/reidx/provider/stub.py +60 -0
- package/src/reidx/provider_manager/__init__.py +40 -0
- package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/catalog.py +330 -0
- package/src/reidx/provider_manager/database.py +234 -0
- package/src/reidx/provider_manager/keychain.py +102 -0
- package/src/reidx/provider_manager/palette.py +831 -0
- package/src/reidx/runtime/__init__.py +5 -0
- package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/agent.py +198 -0
- package/src/reidx/runtime/orchestrator.py +244 -0
- package/src/reidx/runtime/reasoning.py +77 -0
- package/src/reidx/runtime/state.py +32 -0
- package/src/reidx/runtime/subagent.py +174 -0
- package/src/reidx/session/__init__.py +4 -0
- package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/session/models.py +43 -0
- package/src/reidx/session/store.py +101 -0
- package/src/reidx/tasks/__init__.py +4 -0
- package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/tasks/models.py +44 -0
- package/src/reidx/tasks/store.py +86 -0
- package/src/reidx/tools/__init__.py +44 -0
- package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/base.py +84 -0
- package/src/reidx/tools/file_tools.py +222 -0
- package/src/reidx/tools/registry.py +45 -0
- package/src/reidx/tools/shell_tool.py +75 -0
- package/src/reidx/tools/spawn_agent.py +185 -0
- package/src/reidx/tools/web_tools.py +216 -0
- package/src/reidx/ui/__init__.py +41 -0
- package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
- package/src/reidx/ui/app.py +1343 -0
- package/src/reidx/ui/commands.py +663 -0
- package/src/reidx/ui/render.py +517 -0
- package/src/reidx/ui/repl.py +14 -0
- package/src/reidx/ui/theme.py +126 -0
- package/src/reidx/workflows/__init__.py +4 -0
- package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/workflows/models.py +19 -0
- package/src/reidx/workflows/store.py +56 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""UI rendering helpers (Rich-based).
|
|
2
|
+
|
|
3
|
+
A Claude-Code-style skin: a rounded welcome box, ⏺ bullets for assistant turns
|
|
4
|
+
and tool calls, ⎿ connectors for tool results, and a low-noise status line.
|
|
5
|
+
Rendered in ReidX's red palette. Assistant output stays markdown with
|
|
6
|
+
syntax highlighting; tool calls hang under a bullet with their result.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from rich.console import Console, Group
|
|
15
|
+
from rich.markdown import Markdown
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
from rich.tree import Tree
|
|
20
|
+
|
|
21
|
+
from reidx.goals.models import Goal, GoalNode
|
|
22
|
+
from reidx.policy.engine import PolicyEngine
|
|
23
|
+
from reidx.policy.models import PermissionMode
|
|
24
|
+
from reidx.provider.base import Message
|
|
25
|
+
from reidx.session.models import Session
|
|
26
|
+
from reidx.tasks.models import Task
|
|
27
|
+
from reidx.ui.theme import (
|
|
28
|
+
APP_NAME,
|
|
29
|
+
BOX,
|
|
30
|
+
BULLET,
|
|
31
|
+
DANGER,
|
|
32
|
+
DIM,
|
|
33
|
+
MAX_WIDTH,
|
|
34
|
+
MODE_STYLE,
|
|
35
|
+
PRIMARY,
|
|
36
|
+
PROMPT,
|
|
37
|
+
RISK_STYLE,
|
|
38
|
+
ROLE_ICON,
|
|
39
|
+
ROLE_STYLE,
|
|
40
|
+
SPARKLE,
|
|
41
|
+
STATUS_STYLE,
|
|
42
|
+
SUCCESS,
|
|
43
|
+
TREE,
|
|
44
|
+
WARN,
|
|
45
|
+
context_window_for,
|
|
46
|
+
fmt_tokens,
|
|
47
|
+
short_path,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# The Claude-Code-style glyphs (✻ ⏺ ⎿ › ·) are non-ASCII; a legacy Windows
|
|
51
|
+
# codepage (cp1252) would raise UnicodeEncodeError mid-render. Force UTF-8 so
|
|
52
|
+
# the branding survives regardless of the host console's default encoding.
|
|
53
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
54
|
+
try:
|
|
55
|
+
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
56
|
+
except (AttributeError, ValueError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
console = Console()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- Thinking spinner constants (Claude-Code-style: "✻ Gerund… (12s · ↑ 2.1k tokens)") ---
|
|
63
|
+
# Rendered natively by ui.app's persistent footer (prompt_toolkit), not Rich —
|
|
64
|
+
# the spinner ticks too often (~8Hz) to round-trip through the console-capture
|
|
65
|
+
# bridge each frame. These constants are shared so both look the same.
|
|
66
|
+
|
|
67
|
+
_GERUNDS = [
|
|
68
|
+
"Flibbertigibbeting", "Cogitating", "Percolating", "Ruminating", "Noodling",
|
|
69
|
+
"Conjuring", "Finagling", "Marinating", "Puttering", "Simmering",
|
|
70
|
+
"Frolicking", "Galavanting", "Bamboozling", "Whirring", "Pondering",
|
|
71
|
+
"Scheming", "Effervescing", "Transmuting", "Kerfuffling", "Wrangling",
|
|
72
|
+
"Vibing", "Tinkering", "Synthesizing", "Meandering", "Incubating",
|
|
73
|
+
"Hornswoggling", "Discombobulating", "Moseying", "Percolatin'", "Brewing",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_STAR_FRAMES = "✶✸✹✺✹✷"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _bullet_grid(marker: Text, body) -> Table: # type: ignore[no-untyped-def]
|
|
81
|
+
"""A two-column grid: a bullet marker + hanging-indented body.
|
|
82
|
+
|
|
83
|
+
Wrapped lines in the body align under the body column, mirroring the
|
|
84
|
+
Claude Code '⏺ text' layout.
|
|
85
|
+
"""
|
|
86
|
+
grid = Table.grid(padding=(0, 1))
|
|
87
|
+
grid.add_column(width=1, no_wrap=True)
|
|
88
|
+
grid.add_column(overflow="fold")
|
|
89
|
+
grid.add_row(marker, body)
|
|
90
|
+
return grid
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Mascot ASCII art, printed to the left of the welcome panel in the empty
|
|
94
|
+
# space there. Purely decorative — kept as its own constant so it's easy to
|
|
95
|
+
# swap out.
|
|
96
|
+
_MASCOT = r""" "...'...
|
|
97
|
+
^l'+??!_????:^'...:`"
|
|
98
|
+
,`????????????????"'......
|
|
99
|
+
r^???????i-+??????:?-;Il"...
|
|
100
|
+
.^`_???+,.i`..'<,???????~..." ^
|
|
101
|
+
.-??<.....?l-""?????i::i???l>~..
|
|
102
|
+
.`?_~>>'`";;.^'i???????????~!-?>.."
|
|
103
|
+
^.:???~^~??>!ll_??-???__??+.!~-.''. :
|
|
104
|
+
^..i....!;Iii!!;!????????<l?+,`-<...^
|
|
105
|
+
`...""..",^";'....;!l!<>??^??>!<.i.`.
|
|
106
|
+
`.". ..^."`;-~~?,?.I?.I?.i>.. ?
|
|
107
|
+
... `.'....+?.I.+?._'.'.".x
|
|
108
|
+
x :.....<.;..>? `.`..' -
|
|
109
|
+
:.....~...:'.`. '.
|
|
110
|
+
.....I...".... .
|
|
111
|
+
..........'.'. `
|
|
112
|
+
.......... .i.
|
|
113
|
+
.^l..^.. .
|
|
114
|
+
l `.. .
|
|
115
|
+
~ """
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def banner() -> None:
|
|
119
|
+
"""Claude-Code-style welcome box, with a mascot to its left."""
|
|
120
|
+
from reidx import __version__
|
|
121
|
+
|
|
122
|
+
body = Text.assemble(
|
|
123
|
+
(f"{SPARKLE} ", PRIMARY),
|
|
124
|
+
("Welcome to ", "bold"),
|
|
125
|
+
(APP_NAME, f"bold {PRIMARY}"),
|
|
126
|
+
("!", "bold"),
|
|
127
|
+
(f" v{__version__}\n\n", DIM),
|
|
128
|
+
(" /help for help, /status for your current setup\n\n", DIM),
|
|
129
|
+
(" cwd: ", DIM),
|
|
130
|
+
(str(Path.cwd()), DIM),
|
|
131
|
+
)
|
|
132
|
+
panel = Panel(body, box=BOX, border_style=PRIMARY, padding=(0, 1), width=MAX_WIDTH)
|
|
133
|
+
|
|
134
|
+
grid = Table.grid(padding=(0, 2))
|
|
135
|
+
grid.add_column()
|
|
136
|
+
grid.add_column()
|
|
137
|
+
grid.add_row(Text(_MASCOT, style=PRIMARY), panel)
|
|
138
|
+
console.print(grid)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def status_line_text(status: dict) -> Text:
|
|
142
|
+
"""Build the full status line: app · mode · model · effort · tokens · cwd · tasks."""
|
|
143
|
+
mode = status.get("mode", "—")
|
|
144
|
+
model = status.get("model", "—")
|
|
145
|
+
effort = status.get("effort", "—")
|
|
146
|
+
used = status.get("tokens_used", 0)
|
|
147
|
+
window = status.get("context_window", 0)
|
|
148
|
+
workspace = status.get("workspace", "—")
|
|
149
|
+
tasks = status.get("tasks", 0)
|
|
150
|
+
|
|
151
|
+
mode_color = MODE_STYLE.get(mode, DIM)
|
|
152
|
+
sep = (" · ", DIM)
|
|
153
|
+
pct = f"{(used / window * 100):.0f}%" if window else "—"
|
|
154
|
+
usage = f"{fmt_tokens(used)}/{fmt_tokens(window)} ({pct})" if window else fmt_tokens(used)
|
|
155
|
+
|
|
156
|
+
return Text.assemble(
|
|
157
|
+
(f" {APP_NAME}", f"bold {PRIMARY}"), sep,
|
|
158
|
+
(mode, f"bold {mode_color}"), sep,
|
|
159
|
+
(model, DIM), sep,
|
|
160
|
+
(f"effort:{effort}", DIM), sep,
|
|
161
|
+
(usage, DIM), sep,
|
|
162
|
+
(short_path(workspace), DIM), sep,
|
|
163
|
+
(f"{tasks} tasks", WARN if tasks else DIM),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def status_bar(session: Session | None, mode: PermissionMode, task_count: int = 0, tokens_used: int = 0) -> None:
|
|
168
|
+
"""Low-noise status line, Claude-Code-style — dim, dotted, no box."""
|
|
169
|
+
if session is None:
|
|
170
|
+
console.print(Text(" no active session", style=DIM))
|
|
171
|
+
return
|
|
172
|
+
console.print(
|
|
173
|
+
status_line_text(
|
|
174
|
+
{
|
|
175
|
+
"mode": mode.value,
|
|
176
|
+
"model": session.model,
|
|
177
|
+
"effort": session.reasoning_effort,
|
|
178
|
+
"tokens_used": tokens_used,
|
|
179
|
+
"context_window": context_window_for(session.model),
|
|
180
|
+
"workspace": str(session.workspace),
|
|
181
|
+
"tasks": task_count,
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def status_prompt(session: Session | None, mode: PermissionMode | None) -> Text:
|
|
188
|
+
"""Input caret, Claude-Code-style. Session context lives in the status line."""
|
|
189
|
+
return Text(f"{PROMPT} ", style=f"bold {PRIMARY}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def print_user(text: str) -> None:
|
|
193
|
+
"""Echo the submitted prompt after the input box collapses, under a small
|
|
194
|
+
"User" label, with extra blank lines before/after to clearly separate it
|
|
195
|
+
from whatever came before (the prior reply) and after (this turn's
|
|
196
|
+
reply)."""
|
|
197
|
+
console.print()
|
|
198
|
+
console.print()
|
|
199
|
+
console.print(Text(" User", style=DIM))
|
|
200
|
+
console.print(Text.assemble((f"{PROMPT} ", f"bold {PRIMARY}"), (text, "bold")))
|
|
201
|
+
console.print()
|
|
202
|
+
console.print()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def print_thinking(text: str) -> None:
|
|
206
|
+
"""Chain-of-thought shown above the answer: dim italic under a ✻ marker."""
|
|
207
|
+
if not text or not text.strip():
|
|
208
|
+
return
|
|
209
|
+
console.print(_bullet_grid(Text(SPARKLE, style=DIM), Text(text.strip(), style="dim italic")))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def print_assistant(text: str) -> None:
|
|
213
|
+
"""Markdown assistant output hanging under a ⏺ bullet."""
|
|
214
|
+
console.print(_bullet_grid(Text(BULLET, style=PRIMARY), Markdown(text)))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def print_tool_calls(tool_log: list[dict]) -> None:
|
|
218
|
+
"""Tool calls as ⏺ Name(args) with a ⎿ result line beneath each."""
|
|
219
|
+
if not tool_log:
|
|
220
|
+
return
|
|
221
|
+
for entry in tool_log:
|
|
222
|
+
name = entry["name"]
|
|
223
|
+
ok = entry["ok"]
|
|
224
|
+
error = entry.get("error", "")
|
|
225
|
+
args = entry.get("args", {})
|
|
226
|
+
args_str = ", ".join(f"{k}={v!r}" for k, v in args.items()) if args else ""
|
|
227
|
+
header = Text.assemble(
|
|
228
|
+
(name, "bold"), ("(", DIM), (args_str, DIM), (")", DIM),
|
|
229
|
+
)
|
|
230
|
+
console.print(_bullet_grid(Text(BULLET, style=PRIMARY), header))
|
|
231
|
+
if ok:
|
|
232
|
+
result = Text("ok", style=SUCCESS)
|
|
233
|
+
else:
|
|
234
|
+
result = Text(f"Error: {error}", style=DANGER)
|
|
235
|
+
console.print(Text.assemble((" ", ""), (TREE, DIM), (" ", ""), result))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def print_tasks(tasks: list[Task]) -> None:
|
|
239
|
+
if not tasks:
|
|
240
|
+
console.print(Text("no tasks", style=DIM))
|
|
241
|
+
return
|
|
242
|
+
table = Table(title="tasks", box=BOX, show_header=True, header_style=f"bold {PRIMARY}", border_style=PRIMARY, width=MAX_WIDTH)
|
|
243
|
+
table.add_column("id", style=DIM, width=12)
|
|
244
|
+
table.add_column("status", width=12)
|
|
245
|
+
table.add_column("title")
|
|
246
|
+
for t in tasks:
|
|
247
|
+
color = STATUS_STYLE.get(t.status.value, "white")
|
|
248
|
+
table.add_row(t.id, Text(t.status.value, style=f"bold {color}"), t.title)
|
|
249
|
+
console.print(table)
|
|
250
|
+
# Summary line.
|
|
251
|
+
counts: dict[str, int] = {}
|
|
252
|
+
for t in tasks:
|
|
253
|
+
counts[t.status.value] = counts.get(t.status.value, 0) + 1
|
|
254
|
+
parts = []
|
|
255
|
+
for k, v in counts.items():
|
|
256
|
+
color = STATUS_STYLE.get(k, DIM)
|
|
257
|
+
parts.append(Text(f"{v} {k}", style=color))
|
|
258
|
+
summary = Text(" ").join(parts)
|
|
259
|
+
console.print(summary)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _status_text(value: str) -> Text:
|
|
263
|
+
color = STATUS_STYLE.get(value, "white")
|
|
264
|
+
return Text(value, style=f"bold {color}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _goal_evidence_progress(goal: Goal) -> str:
|
|
268
|
+
total = len(goal.evidence)
|
|
269
|
+
done = sum(1 for evidence in goal.evidence if evidence.satisfied)
|
|
270
|
+
return f"{done}/{total}" if total else "-"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _goal_node_progress(goal: Goal) -> str:
|
|
274
|
+
total = len(goal.nodes)
|
|
275
|
+
done = sum(1 for node in goal.nodes if node.status.value == "completed")
|
|
276
|
+
return f"{done}/{total}" if total else "-"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def print_goals(goals: list[Goal], active_goal_id: str | None = None) -> None:
|
|
280
|
+
if not goals:
|
|
281
|
+
console.print(Text("no goals", style=DIM))
|
|
282
|
+
return
|
|
283
|
+
table = Table(title="goals", box=BOX, show_header=True, header_style=f"bold {PRIMARY}", border_style=PRIMARY, width=MAX_WIDTH)
|
|
284
|
+
table.add_column("", width=2)
|
|
285
|
+
table.add_column("id", style=DIM, width=12)
|
|
286
|
+
table.add_column("status", width=12)
|
|
287
|
+
table.add_column("evidence", width=9)
|
|
288
|
+
table.add_column("nodes", width=7)
|
|
289
|
+
table.add_column("title")
|
|
290
|
+
for goal in goals:
|
|
291
|
+
marker = "●" if goal.id == active_goal_id else ""
|
|
292
|
+
table.add_row(
|
|
293
|
+
marker,
|
|
294
|
+
goal.id,
|
|
295
|
+
_status_text(goal.status.value),
|
|
296
|
+
_goal_evidence_progress(goal),
|
|
297
|
+
_goal_node_progress(goal),
|
|
298
|
+
goal.title,
|
|
299
|
+
)
|
|
300
|
+
console.print(table)
|
|
301
|
+
console.print(Text(" ● = active goal", style=DIM))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def print_goal(goal: Goal) -> None:
|
|
305
|
+
evidence_done = sum(1 for evidence in goal.evidence if evidence.satisfied)
|
|
306
|
+
header = Text.assemble(
|
|
307
|
+
(goal.title, f"bold {PRIMARY}"),
|
|
308
|
+
(" ", ""),
|
|
309
|
+
(goal.id, DIM),
|
|
310
|
+
(" ", ""),
|
|
311
|
+
(goal.status.value, f"bold {STATUS_STYLE.get(goal.status.value, DIM)}"),
|
|
312
|
+
)
|
|
313
|
+
body = Table.grid(padding=(0, 1))
|
|
314
|
+
body.add_column(width=12, style=DIM)
|
|
315
|
+
body.add_column()
|
|
316
|
+
body.add_row("outcome", goal.outcome or "(none)")
|
|
317
|
+
body.add_row("evidence", f"{evidence_done}/{len(goal.evidence)} satisfied" if goal.evidence else "(none)")
|
|
318
|
+
body.add_row("nodes", f"{len(goal.nodes)}")
|
|
319
|
+
body.add_row("tasks", f"{len(goal.task_ids)} linked")
|
|
320
|
+
console.print(Panel(Group(header, body), box=BOX, border_style=PRIMARY, width=MAX_WIDTH))
|
|
321
|
+
|
|
322
|
+
if goal.evidence:
|
|
323
|
+
table = Table(title="evidence", box=BOX, show_header=True, header_style=f"bold {PRIMARY}", border_style=PRIMARY, width=MAX_WIDTH)
|
|
324
|
+
table.add_column("#", style=DIM, width=4)
|
|
325
|
+
table.add_column("done", width=6)
|
|
326
|
+
table.add_column("description")
|
|
327
|
+
table.add_column("note", style=DIM)
|
|
328
|
+
for index, evidence in enumerate(goal.evidence, 1):
|
|
329
|
+
table.add_row(
|
|
330
|
+
str(index),
|
|
331
|
+
"yes" if evidence.satisfied else "no",
|
|
332
|
+
evidence.description,
|
|
333
|
+
evidence.note or "",
|
|
334
|
+
)
|
|
335
|
+
console.print(table)
|
|
336
|
+
|
|
337
|
+
if goal.constraints:
|
|
338
|
+
table = Table(title="constraints", box=BOX, show_header=True, header_style=f"bold {PRIMARY}", border_style=PRIMARY, width=MAX_WIDTH)
|
|
339
|
+
table.add_column("kind", style=DIM, width=12)
|
|
340
|
+
table.add_column("description")
|
|
341
|
+
for constraint in goal.constraints:
|
|
342
|
+
table.add_row(constraint.kind, constraint.description)
|
|
343
|
+
console.print(table)
|
|
344
|
+
|
|
345
|
+
if goal.nodes:
|
|
346
|
+
print_goal_tree(goal)
|
|
347
|
+
|
|
348
|
+
if goal.revisions:
|
|
349
|
+
console.print(Text("revisions", style=f"bold {PRIMARY}"))
|
|
350
|
+
for revision in goal.revisions[-5:]:
|
|
351
|
+
console.print(Text.assemble((" - ", DIM), (revision.note, "white")))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def print_goal_tree(goal: Goal) -> None:
|
|
355
|
+
by_parent: dict[str | None, list[GoalNode]] = {}
|
|
356
|
+
for node in goal.nodes:
|
|
357
|
+
by_parent.setdefault(node.parent_id, []).append(node)
|
|
358
|
+
|
|
359
|
+
root = Tree(Text("nodes", style=f"bold {PRIMARY}"))
|
|
360
|
+
|
|
361
|
+
def add_nodes(parent_tree: Tree, parent_id: str | None) -> None:
|
|
362
|
+
for node in by_parent.get(parent_id, []):
|
|
363
|
+
deps = f" deps:{','.join(node.depends_on)}" if node.depends_on else ""
|
|
364
|
+
label = Text.assemble(
|
|
365
|
+
(node.id, DIM),
|
|
366
|
+
(" ", ""),
|
|
367
|
+
(node.kind.value, DIM),
|
|
368
|
+
(" ", ""),
|
|
369
|
+
(node.status.value, f"bold {STATUS_STYLE.get(node.status.value, DIM)}"),
|
|
370
|
+
(" ", ""),
|
|
371
|
+
(node.title, "white"),
|
|
372
|
+
(deps, DIM),
|
|
373
|
+
)
|
|
374
|
+
branch = parent_tree.add(label)
|
|
375
|
+
add_nodes(branch, node.id)
|
|
376
|
+
|
|
377
|
+
add_nodes(root, None)
|
|
378
|
+
console.print(root)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def print_sessions(sessions: list[Session]) -> None:
|
|
382
|
+
if not sessions:
|
|
383
|
+
console.print(Text("no sessions", style=DIM))
|
|
384
|
+
return
|
|
385
|
+
table = Table(title="sessions", box=BOX, show_header=True, header_style=f"bold {PRIMARY}", border_style=PRIMARY, width=MAX_WIDTH)
|
|
386
|
+
table.add_column("id", style=DIM, width=14)
|
|
387
|
+
table.add_column("status", width=10)
|
|
388
|
+
table.add_column("title")
|
|
389
|
+
table.add_column("updated", width=12)
|
|
390
|
+
table.add_column("workspace", style=DIM)
|
|
391
|
+
now = datetime.now(UTC)
|
|
392
|
+
for s in sessions:
|
|
393
|
+
color = STATUS_STYLE.get(s.status.value, "white")
|
|
394
|
+
age = now - s.updated_at
|
|
395
|
+
mins = int(age.total_seconds() // 60)
|
|
396
|
+
when = f"{mins}m ago" if mins < 60 else f"{mins // 60}h ago"
|
|
397
|
+
table.add_row(
|
|
398
|
+
s.id,
|
|
399
|
+
Text(s.status.value, style=f"bold {color}"),
|
|
400
|
+
s.title,
|
|
401
|
+
when,
|
|
402
|
+
str(s.workspace),
|
|
403
|
+
)
|
|
404
|
+
console.print(table)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def print_workflows(workflows: list) -> None: # type: ignore[no-untyped-def]
|
|
408
|
+
if not workflows:
|
|
409
|
+
console.print(Text("no workflows", style=DIM))
|
|
410
|
+
return
|
|
411
|
+
table = Table(title="workflows", box=BOX, show_header=True, header_style=f"bold {PRIMARY}", border_style=PRIMARY, width=MAX_WIDTH)
|
|
412
|
+
table.add_column("name", style="bold", width=18)
|
|
413
|
+
table.add_column("steps", width=6)
|
|
414
|
+
table.add_column("description")
|
|
415
|
+
for wf in workflows:
|
|
416
|
+
table.add_row(wf.name, str(len(wf.steps)), wf.description or "(none)")
|
|
417
|
+
console.print(table)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def print_workflow_steps(workflow) -> None: # type: ignore[no-untyped-def]
|
|
421
|
+
console.print(Text.assemble((workflow.name, f"bold {PRIMARY}"), (" ", ""), (workflow.description, DIM)))
|
|
422
|
+
for i, step in enumerate(workflow.steps, 1):
|
|
423
|
+
console.print(Text.assemble((f" {i}. ", DIM), (step, "white")))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def print_providers(records, active_name: str, extra_names: list[str]) -> None: # type: ignore[no-untyped-def]
|
|
427
|
+
"""Show connected providers.
|
|
428
|
+
|
|
429
|
+
`records` is the persisted list (name, kind, base_url, default_model);
|
|
430
|
+
`extra_names` is any provider registered but not persisted (e.g. `stub`,
|
|
431
|
+
or `anthropic` picked up from env vars) — those show as kind=built-in.
|
|
432
|
+
`active_name` is the current session provider (highlighted).
|
|
433
|
+
"""
|
|
434
|
+
table = Table(title="providers", box=BOX, show_header=True, header_style=f"bold {PRIMARY}", border_style=PRIMARY, width=MAX_WIDTH)
|
|
435
|
+
table.add_column("name", style="bold", width=18)
|
|
436
|
+
table.add_column("kind", width=20)
|
|
437
|
+
table.add_column("model", width=22)
|
|
438
|
+
table.add_column("base_url")
|
|
439
|
+
for name in extra_names:
|
|
440
|
+
marker = "● " if name == active_name else " "
|
|
441
|
+
table.add_row(f"{marker}{name}", "built-in", "-", "-")
|
|
442
|
+
for r in records:
|
|
443
|
+
marker = "● " if r.name == active_name else " "
|
|
444
|
+
table.add_row(f"{marker}{r.name}", r.kind, r.default_model or "-", r.base_url or "-")
|
|
445
|
+
console.print(table)
|
|
446
|
+
console.print(Text(" ● = active provider (use /use <name> to switch)", style=DIM))
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def print_permissions(policy: PolicyEngine) -> None:
|
|
450
|
+
"""Structured permissions view using a table for readability."""
|
|
451
|
+
cfg = policy.config.policy
|
|
452
|
+
table = Table(title="permissions", box=BOX, show_header=False, border_style=PRIMARY, padding=(0, 1), width=MAX_WIDTH)
|
|
453
|
+
table.add_column("key", style=DIM, width=18)
|
|
454
|
+
table.add_column("value")
|
|
455
|
+
mode_color = MODE_STYLE.get(policy.mode.value, DIM)
|
|
456
|
+
table.add_row("mode", Text(policy.mode.value, style=f"bold {mode_color}"))
|
|
457
|
+
table.add_row("blocked commands", ", ".join(sorted(policy.blocked_commands)) or "(none)")
|
|
458
|
+
table.add_row("allowed commands", ", ".join(sorted(policy.allowed_commands)) or "(none)")
|
|
459
|
+
table.add_row(
|
|
460
|
+
"writable roots",
|
|
461
|
+
", ".join(str(r) for r in cfg.additional_writable_roots) or "(workspace only)",
|
|
462
|
+
)
|
|
463
|
+
table.add_row("read-only paths", ", ".join(str(r) for r in cfg.read_only_paths) or "(none)")
|
|
464
|
+
table.add_row("shell timeout", f"{cfg.shell_timeout_seconds}s")
|
|
465
|
+
console.print(table)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def print_transcript(messages: list[Message], n: int = 20) -> None:
|
|
469
|
+
if not messages:
|
|
470
|
+
console.print(Text("no transcript", style=DIM))
|
|
471
|
+
return
|
|
472
|
+
for m in messages[-n:]:
|
|
473
|
+
icon = ROLE_ICON.get(m.role, "·")
|
|
474
|
+
style = ROLE_STYLE.get(m.role, "white")
|
|
475
|
+
if m.tool_calls:
|
|
476
|
+
calls = ", ".join(c.name for c in m.tool_calls)
|
|
477
|
+
console.print(Text.assemble((f"{icon} ", style), (f"{m.role} ", f"bold {style}"), (f"tools: {calls}", DIM)))
|
|
478
|
+
else:
|
|
479
|
+
text = m.content[:300] + ("…" if len(m.content) > 300 else "")
|
|
480
|
+
console.print(Text.assemble((f"{icon} ", style), (f"{m.role} ", f"bold {style}"), (text, "white")))
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def print_tools(definitions: list) -> None: # type: ignore[no-untyped-def]
|
|
484
|
+
"""Grouped tool listing with risk badges."""
|
|
485
|
+
table = Table(title="tools", box=BOX, show_header=True, header_style=f"bold {PRIMARY}", border_style=PRIMARY, width=MAX_WIDTH)
|
|
486
|
+
table.add_column("name", style="bold", width=18)
|
|
487
|
+
table.add_column("risk", width=8)
|
|
488
|
+
table.add_column("description")
|
|
489
|
+
for d in definitions:
|
|
490
|
+
risk_color = RISK_STYLE.get(d.risk.value, DIM)
|
|
491
|
+
table.add_row(d.name, Text(d.risk.value, style=f"bold {risk_color}"), d.description)
|
|
492
|
+
console.print(table)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def print_error(text: str) -> None:
|
|
496
|
+
"""Inline red error under a ⏺ bullet, Claude-Code-style."""
|
|
497
|
+
console.print(_bullet_grid(Text(BULLET, style=DANGER), Text(f"Error: {text}", style=DANGER)))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def print_info(text: str) -> None:
|
|
501
|
+
console.print(Text(text, style=DIM))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def print_warn(text: str) -> None:
|
|
505
|
+
console.print(Text(text, style=WARN))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def rule(title: str = "") -> None:
|
|
509
|
+
"""Horizontal separator between sections/turns."""
|
|
510
|
+
if title:
|
|
511
|
+
console.rule(Text(title, style=DIM), style=DIM, align="left")
|
|
512
|
+
else:
|
|
513
|
+
console.rule(style=DIM, align="left")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# Backward-compat aliases for any callers expecting the old names.
|
|
517
|
+
status_line = status_bar
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Interactive REPL entry point.
|
|
2
|
+
|
|
3
|
+
Thin wrapper around the full-screen chat TUI in ui.app — kept as a separate
|
|
4
|
+
module so app/commands.py's `from reidx.ui.repl import repl` doesn't need
|
|
5
|
+
to change.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from reidx.runtime.orchestrator import Orchestrator
|
|
10
|
+
from reidx.ui import app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def repl(orchestrator: Orchestrator, initial_prompt: str | None = None) -> int:
|
|
14
|
+
return app.run(orchestrator, initial_prompt=initial_prompt)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Centralized theme: colors, styles, and visual constants.
|
|
2
|
+
|
|
3
|
+
ReidX wears a Claude-Code-style skin in a red palette:
|
|
4
|
+
- dark technical feel, high contrast
|
|
5
|
+
- rounded borders, ⏺ bullets, ⎿ tree connectors
|
|
6
|
+
- clean density over decorative excess
|
|
7
|
+
|
|
8
|
+
All UI modules should pull colors + glyphs from here so the look stays consistent.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from rich import box
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
# Brand.
|
|
18
|
+
APP_NAME = "ReidX"
|
|
19
|
+
|
|
20
|
+
# Core palette — red-forward, high-contrast.
|
|
21
|
+
PRIMARY = "#ff5f5f" # brand, assistant, headers (brand red)
|
|
22
|
+
SECONDARY = "#d75f5f" # secondary accents
|
|
23
|
+
DIM = "dim" # labels, metadata
|
|
24
|
+
SUCCESS = "green" # ok, completed
|
|
25
|
+
WARN = "yellow" # active, prompts, warnings
|
|
26
|
+
DANGER = "red" # errors, failed, blocked
|
|
27
|
+
MUTED = "magenta" # special states
|
|
28
|
+
|
|
29
|
+
# Claude-Code-style glyphs. Use text-presentation code points (not emoji) so the
|
|
30
|
+
# terminal renders them flat and honors the Rich style color. U+23FA (⏺) is an
|
|
31
|
+
# emoji and renders as a fixed-color double-width blob — avoid it.
|
|
32
|
+
SPARKLE = "✻" # welcome banner + thinking mark
|
|
33
|
+
BULLET = "●" # assistant / tool-call bullet (U+25CF, text circle)
|
|
34
|
+
TREE = "⎿" # tool-result connector
|
|
35
|
+
PROMPT = "›" # input prompt caret
|
|
36
|
+
|
|
37
|
+
# Default border box for panels/tables.
|
|
38
|
+
BOX = box.ROUNDED
|
|
39
|
+
|
|
40
|
+
# Role icons for transcript display.
|
|
41
|
+
ROLE_ICON = {
|
|
42
|
+
"system": "§",
|
|
43
|
+
"user": "›",
|
|
44
|
+
"assistant": BULLET,
|
|
45
|
+
"tool": TREE,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
ROLE_STYLE = {
|
|
49
|
+
"system": DIM,
|
|
50
|
+
"user": "bold",
|
|
51
|
+
"assistant": PRIMARY,
|
|
52
|
+
"tool": SUCCESS,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Status badge styles.
|
|
56
|
+
STATUS_STYLE = {
|
|
57
|
+
"draft": DIM,
|
|
58
|
+
"completed": SUCCESS,
|
|
59
|
+
"active": WARN,
|
|
60
|
+
"failed": DANGER,
|
|
61
|
+
"blocked": MUTED,
|
|
62
|
+
"pending": DIM,
|
|
63
|
+
"skipped": DIM,
|
|
64
|
+
"archived": DIM,
|
|
65
|
+
"abandoned": DIM,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Risk colors.
|
|
69
|
+
RISK_STYLE = {
|
|
70
|
+
"low": SUCCESS,
|
|
71
|
+
"medium": WARN,
|
|
72
|
+
"high": DANGER,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Permission mode colors.
|
|
76
|
+
MODE_STYLE = {
|
|
77
|
+
"strict": DANGER,
|
|
78
|
+
"balanced": WARN,
|
|
79
|
+
"autonomous": SUCCESS,
|
|
80
|
+
"custom": MUTED,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Visual constants.
|
|
85
|
+
MAX_WIDTH = 80 # constrain panels/tables so they don't span ultra-wide terminals
|
|
86
|
+
|
|
87
|
+
# Known context-window sizes (tokens) by model prefix, for the status bar's
|
|
88
|
+
# usage readout. Falls back to DEFAULT_CONTEXT_WINDOW for unrecognized models.
|
|
89
|
+
DEFAULT_CONTEXT_WINDOW = 128_000
|
|
90
|
+
CONTEXT_WINDOWS = {
|
|
91
|
+
"stub": 32_000,
|
|
92
|
+
"gpt-5": 400_000,
|
|
93
|
+
"gpt-4": 128_000,
|
|
94
|
+
"claude": 200_000,
|
|
95
|
+
"o1": 200_000,
|
|
96
|
+
"o3": 200_000,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def context_window_for(model: str) -> int:
|
|
101
|
+
for prefix, size in CONTEXT_WINDOWS.items():
|
|
102
|
+
if model.startswith(prefix):
|
|
103
|
+
return size
|
|
104
|
+
return DEFAULT_CONTEXT_WINDOW
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fmt_tokens(n: int) -> str:
|
|
108
|
+
return f"{n / 1000:.1f}k" if n >= 1000 else str(n)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def short_path(path: str, keep: int = 2) -> str:
|
|
112
|
+
"""Collapse a long workspace path to its last `keep` components."""
|
|
113
|
+
parts = Path(path).parts
|
|
114
|
+
if len(parts) <= keep:
|
|
115
|
+
return path
|
|
116
|
+
return str(Path(*parts[-keep:]))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def badge(text: str, color: str) -> Text:
|
|
120
|
+
"""A compact colored badge like [ok] or [strict]."""
|
|
121
|
+
return Text(f"[{text}]", style=color)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def label_value(label: str, value: str, label_color: str = DIM) -> Text:
|
|
125
|
+
"""A dim-label followed by a value: session abc123."""
|
|
126
|
+
return Text.assemble((f"{label} ", label_color), value)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Workflow model: a named, reusable sequence of steps.
|
|
2
|
+
|
|
3
|
+
Each step is either a slash command (e.g. "/model gpt-5-5") or a plain prompt
|
|
4
|
+
submitted as a user turn — exactly what you'd type into the box, run in
|
|
5
|
+
order. Workflows are global (not per-session or per-workspace): saved once,
|
|
6
|
+
runnable from any session.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Workflow(BaseModel):
|
|
16
|
+
name: str
|
|
17
|
+
description: str = ""
|
|
18
|
+
steps: list[str] = Field(default_factory=list)
|
|
19
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|