systemr-cli 1.0.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.
- neo/__init__.py +3 -0
- neo/__main__.py +5 -0
- neo/auth.py +192 -0
- neo/cli.py +205 -0
- neo/client.py +64 -0
- neo/commands/__init__.py +0 -0
- neo/commands/auth_commands.py +303 -0
- neo/commands/chat_commands.py +937 -0
- neo/commands/cron_commands.py +179 -0
- neo/commands/doctor_command.py +178 -0
- neo/commands/eval_commands.py +73 -0
- neo/commands/journal_commands.py +197 -0
- neo/commands/plan_commands.py +77 -0
- neo/commands/risk_commands.py +68 -0
- neo/commands/scan_commands.py +62 -0
- neo/commands/size_commands.py +60 -0
- neo/config.py +70 -0
- neo/confirmation.py +311 -0
- neo/credits.py +98 -0
- neo/cron.py +365 -0
- neo/display/__init__.py +0 -0
- neo/display/chat_renderer.py +127 -0
- neo/display/formatters.py +112 -0
- neo/display/tables.py +53 -0
- neo/display/theme.py +154 -0
- neo/hooks.py +170 -0
- neo/logging.py +56 -0
- neo/model_failover.py +193 -0
- neo/orchestrator.py +288 -0
- neo/profile.py +505 -0
- neo/store.py +405 -0
- neo/streaming.py +315 -0
- neo/types.py +109 -0
- systemr_cli-1.0.0.dist-info/METADATA +191 -0
- systemr_cli-1.0.0.dist-info/RECORD +37 -0
- systemr_cli-1.0.0.dist-info/WHEEL +4 -0
- systemr_cli-1.0.0.dist-info/entry_points.txt +3 -0
neo/credits.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Session credit tracking — per-response and per-session cost tracking.
|
|
2
|
+
|
|
3
|
+
All credit values use Decimal to prevent floating-point accumulation errors
|
|
4
|
+
over long trading sessions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from neo.types import to_decimal
|
|
15
|
+
|
|
16
|
+
LOW_BALANCE_THRESHOLD = Decimal("50")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SessionCredits:
|
|
21
|
+
"""Track credit usage across a chat session.
|
|
22
|
+
|
|
23
|
+
All financial accumulators use Decimal. The summary() method
|
|
24
|
+
returns values suitable for display.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
total_credits: Accumulated credits used this session.
|
|
28
|
+
tools_called: Number of tool calls observed.
|
|
29
|
+
trades_placed: Number of trade actions executed.
|
|
30
|
+
messages_sent: Number of messages sent.
|
|
31
|
+
start_time: Unix timestamp when session started.
|
|
32
|
+
last_balance: Most recent balance from API.
|
|
33
|
+
low_balance_warned: Whether low balance warning was shown.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
total_credits: Decimal = field(default_factory=lambda: Decimal("0"))
|
|
37
|
+
tools_called: int = 0
|
|
38
|
+
trades_placed: int = 0
|
|
39
|
+
messages_sent: int = 0
|
|
40
|
+
start_time: float = field(default_factory=time.time)
|
|
41
|
+
last_balance: Decimal | None = None
|
|
42
|
+
low_balance_warned: bool = False
|
|
43
|
+
|
|
44
|
+
def add_response(
|
|
45
|
+
self,
|
|
46
|
+
credits_used: Decimal | float | None = None,
|
|
47
|
+
balance: Decimal | float | None = None,
|
|
48
|
+
tools: int = 0,
|
|
49
|
+
trade: bool = False,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Record a response's credit usage.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
credits_used: Credits consumed by this response.
|
|
55
|
+
balance: Remaining balance reported by API.
|
|
56
|
+
tools: Number of tools called in this response.
|
|
57
|
+
trade: Whether this response involved a trade action.
|
|
58
|
+
"""
|
|
59
|
+
self.messages_sent += 1
|
|
60
|
+
if credits_used is not None:
|
|
61
|
+
self.total_credits += to_decimal(credits_used)
|
|
62
|
+
if balance is not None:
|
|
63
|
+
self.last_balance = to_decimal(balance)
|
|
64
|
+
self.tools_called += tools
|
|
65
|
+
if trade:
|
|
66
|
+
self.trades_placed += 1
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def duration_minutes(self) -> int:
|
|
70
|
+
"""Return session duration in minutes."""
|
|
71
|
+
return int((time.time() - self.start_time) / 60)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_low_balance(self) -> bool:
|
|
75
|
+
"""Check if balance is below the warning threshold.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if balance is known and below LOW_BALANCE_THRESHOLD.
|
|
79
|
+
"""
|
|
80
|
+
if self.last_balance is None:
|
|
81
|
+
return False
|
|
82
|
+
return self.last_balance < LOW_BALANCE_THRESHOLD
|
|
83
|
+
|
|
84
|
+
def summary(self) -> dict[str, Any]:
|
|
85
|
+
"""Return a summary dict for display.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Dict with credits_used, tools_called, trades_placed,
|
|
89
|
+
messages, duration_min, and balance.
|
|
90
|
+
"""
|
|
91
|
+
return {
|
|
92
|
+
"credits_used": float(self.total_credits),
|
|
93
|
+
"tools_called": self.tools_called,
|
|
94
|
+
"trades_placed": self.trades_placed,
|
|
95
|
+
"messages": self.messages_sent,
|
|
96
|
+
"duration_min": self.duration_minutes,
|
|
97
|
+
"balance": float(self.last_balance) if self.last_balance is not None else None,
|
|
98
|
+
}
|
neo/cron.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Cron scheduler — local scheduled tasks for System R CLI.
|
|
2
|
+
|
|
3
|
+
Three schedule types (inspired by OpenClaw's cron system):
|
|
4
|
+
at — one-shot ISO 8601 timestamp
|
|
5
|
+
every — fixed interval in seconds
|
|
6
|
+
cron — 5-field cron expression with timezone
|
|
7
|
+
|
|
8
|
+
Jobs persist to ~/.systemr/cron/jobs.json, surviving restarts.
|
|
9
|
+
Each job fires a ChatRequest to agents.systemr.ai in an isolated session.
|
|
10
|
+
Retry with exponential backoff for transient failures.
|
|
11
|
+
|
|
12
|
+
No bare print() — logging via structlog.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import asdict, dataclass, field
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from enum import Enum
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import structlog
|
|
27
|
+
|
|
28
|
+
from neo.config import SYSTEMR_HOME, ensure_systemr_home
|
|
29
|
+
|
|
30
|
+
logger = structlog.get_logger(module="cron")
|
|
31
|
+
|
|
32
|
+
CRON_DIR = SYSTEMR_HOME / "cron"
|
|
33
|
+
JOBS_FILE = CRON_DIR / "jobs.json"
|
|
34
|
+
RUNS_DIR = CRON_DIR / "runs"
|
|
35
|
+
|
|
36
|
+
# Retry settings
|
|
37
|
+
MAX_RETRY_ATTEMPTS: int = 3
|
|
38
|
+
RETRY_BACKOFF_SECONDS: list[float] = [30.0, 60.0, 300.0]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ScheduleKind(Enum):
|
|
42
|
+
"""Type of schedule."""
|
|
43
|
+
|
|
44
|
+
AT = "at" # One-shot ISO 8601 timestamp
|
|
45
|
+
EVERY = "every" # Fixed interval in seconds
|
|
46
|
+
CRON = "cron" # 5-field cron expression
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Schedule:
|
|
51
|
+
"""Job schedule definition.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
kind: Schedule type (at, every, cron).
|
|
55
|
+
at: ISO 8601 timestamp for one-shot jobs.
|
|
56
|
+
every_seconds: Interval in seconds for recurring jobs.
|
|
57
|
+
expr: Cron expression (5-field) for cron jobs.
|
|
58
|
+
tz: IANA timezone for cron expressions (default: local).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
kind: ScheduleKind
|
|
62
|
+
at: str | None = None
|
|
63
|
+
every_seconds: float | None = None
|
|
64
|
+
expr: str | None = None
|
|
65
|
+
tz: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class CronJob:
|
|
70
|
+
"""A scheduled job definition.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
id: Unique job identifier.
|
|
74
|
+
name: Human-readable name.
|
|
75
|
+
schedule: When to run.
|
|
76
|
+
message: Prompt to send to the backend.
|
|
77
|
+
model: Optional model override for this job.
|
|
78
|
+
enabled: Whether the job is active.
|
|
79
|
+
delete_after_run: Auto-delete one-shot jobs after success.
|
|
80
|
+
created_at: ISO 8601 creation timestamp.
|
|
81
|
+
last_run_at: ISO 8601 timestamp of last execution.
|
|
82
|
+
run_count: Total times executed.
|
|
83
|
+
error_count: Consecutive error count.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
id: str = ""
|
|
87
|
+
name: str = ""
|
|
88
|
+
schedule: Schedule = field(default_factory=lambda: Schedule(kind=ScheduleKind.AT))
|
|
89
|
+
message: str = ""
|
|
90
|
+
model: str | None = None
|
|
91
|
+
enabled: bool = True
|
|
92
|
+
delete_after_run: bool = False
|
|
93
|
+
created_at: str = ""
|
|
94
|
+
last_run_at: str | None = None
|
|
95
|
+
run_count: int = 0
|
|
96
|
+
error_count: int = 0
|
|
97
|
+
|
|
98
|
+
def __post_init__(self) -> None:
|
|
99
|
+
"""Set defaults for new jobs."""
|
|
100
|
+
if not self.id:
|
|
101
|
+
self.id = f"job_{uuid.uuid4().hex[:8]}"
|
|
102
|
+
if not self.created_at:
|
|
103
|
+
self.created_at = datetime.now(timezone.utc).isoformat()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class RunRecord:
|
|
108
|
+
"""Record of a single job execution.
|
|
109
|
+
|
|
110
|
+
Attributes:
|
|
111
|
+
job_id: Which job ran.
|
|
112
|
+
run_at: ISO 8601 timestamp.
|
|
113
|
+
status: ok, error, or skipped.
|
|
114
|
+
message: Error message if failed.
|
|
115
|
+
duration_seconds: How long the run took.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
job_id: str
|
|
119
|
+
run_at: str = ""
|
|
120
|
+
status: str = "ok"
|
|
121
|
+
message: str = ""
|
|
122
|
+
duration_seconds: float = 0.0
|
|
123
|
+
|
|
124
|
+
def __post_init__(self) -> None:
|
|
125
|
+
if not self.run_at:
|
|
126
|
+
self.run_at = datetime.now(timezone.utc).isoformat()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _ensure_cron_dirs() -> None:
|
|
130
|
+
"""Create cron directories if they don't exist."""
|
|
131
|
+
ensure_systemr_home()
|
|
132
|
+
CRON_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
133
|
+
RUNS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _serialize_schedule(s: Schedule) -> dict[str, Any]:
|
|
137
|
+
"""Serialize a Schedule to JSON-safe dict."""
|
|
138
|
+
return {
|
|
139
|
+
"kind": s.kind.value,
|
|
140
|
+
"at": s.at,
|
|
141
|
+
"every_seconds": s.every_seconds,
|
|
142
|
+
"expr": s.expr,
|
|
143
|
+
"tz": s.tz,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _deserialize_schedule(d: dict[str, Any]) -> Schedule:
|
|
148
|
+
"""Deserialize a Schedule from JSON dict."""
|
|
149
|
+
return Schedule(
|
|
150
|
+
kind=ScheduleKind(d.get("kind", "at")),
|
|
151
|
+
at=d.get("at"),
|
|
152
|
+
every_seconds=d.get("every_seconds"),
|
|
153
|
+
expr=d.get("expr"),
|
|
154
|
+
tz=d.get("tz"),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def load_jobs() -> list[CronJob]:
|
|
159
|
+
"""Load all cron jobs from disk.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
List of CronJob instances.
|
|
163
|
+
"""
|
|
164
|
+
if not JOBS_FILE.exists():
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
data = json.loads(JOBS_FILE.read_text())
|
|
169
|
+
jobs = []
|
|
170
|
+
for item in data:
|
|
171
|
+
schedule = _deserialize_schedule(item.pop("schedule", {}))
|
|
172
|
+
job = CronJob(**item, schedule=schedule)
|
|
173
|
+
jobs.append(job)
|
|
174
|
+
return jobs
|
|
175
|
+
except (json.JSONDecodeError, TypeError, KeyError) as exc:
|
|
176
|
+
logger.warning("cron_jobs_parse_error", error=str(exc))
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def save_jobs(jobs: list[CronJob]) -> None:
|
|
181
|
+
"""Persist all cron jobs to disk.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
jobs: List of CronJob instances.
|
|
185
|
+
"""
|
|
186
|
+
_ensure_cron_dirs()
|
|
187
|
+
data = []
|
|
188
|
+
for job in jobs:
|
|
189
|
+
item = {
|
|
190
|
+
"id": job.id,
|
|
191
|
+
"name": job.name,
|
|
192
|
+
"schedule": _serialize_schedule(job.schedule),
|
|
193
|
+
"message": job.message,
|
|
194
|
+
"model": job.model,
|
|
195
|
+
"enabled": job.enabled,
|
|
196
|
+
"delete_after_run": job.delete_after_run,
|
|
197
|
+
"created_at": job.created_at,
|
|
198
|
+
"last_run_at": job.last_run_at,
|
|
199
|
+
"run_count": job.run_count,
|
|
200
|
+
"error_count": job.error_count,
|
|
201
|
+
}
|
|
202
|
+
data.append(item)
|
|
203
|
+
|
|
204
|
+
JOBS_FILE.write_text(json.dumps(data, indent=2))
|
|
205
|
+
logger.info("cron_jobs_saved", count=len(jobs))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def add_job(
|
|
209
|
+
name: str,
|
|
210
|
+
message: str,
|
|
211
|
+
schedule: Schedule,
|
|
212
|
+
model: str | None = None,
|
|
213
|
+
delete_after_run: bool = False,
|
|
214
|
+
) -> CronJob:
|
|
215
|
+
"""Create and persist a new cron job.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
name: Human-readable job name.
|
|
219
|
+
message: Prompt to send when job fires.
|
|
220
|
+
schedule: When to run.
|
|
221
|
+
model: Optional model override.
|
|
222
|
+
delete_after_run: Auto-delete one-shot jobs after success.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The created CronJob.
|
|
226
|
+
"""
|
|
227
|
+
job = CronJob(
|
|
228
|
+
name=name,
|
|
229
|
+
message=message,
|
|
230
|
+
schedule=schedule,
|
|
231
|
+
model=model,
|
|
232
|
+
delete_after_run=delete_after_run,
|
|
233
|
+
)
|
|
234
|
+
jobs = load_jobs()
|
|
235
|
+
jobs.append(job)
|
|
236
|
+
save_jobs(jobs)
|
|
237
|
+
logger.info("cron_job_added", id=job.id, name=name, kind=schedule.kind.value)
|
|
238
|
+
return job
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def remove_job(job_id: str) -> bool:
|
|
242
|
+
"""Remove a job by ID.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
job_id: Job identifier.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
True if removed, False if not found.
|
|
249
|
+
"""
|
|
250
|
+
jobs = load_jobs()
|
|
251
|
+
original_count = len(jobs)
|
|
252
|
+
jobs = [j for j in jobs if j.id != job_id]
|
|
253
|
+
if len(jobs) == original_count:
|
|
254
|
+
return False
|
|
255
|
+
save_jobs(jobs)
|
|
256
|
+
logger.info("cron_job_removed", id=job_id)
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def get_job(job_id: str) -> CronJob | None:
|
|
261
|
+
"""Get a job by ID.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
job_id: Job identifier.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
CronJob if found, None otherwise.
|
|
268
|
+
"""
|
|
269
|
+
for job in load_jobs():
|
|
270
|
+
if job.id == job_id:
|
|
271
|
+
return job
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def record_run(run: RunRecord) -> None:
|
|
276
|
+
"""Append a run record to the job's run history.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
run: The RunRecord to persist.
|
|
280
|
+
"""
|
|
281
|
+
_ensure_cron_dirs()
|
|
282
|
+
run_file = RUNS_DIR / f"{run.job_id}.jsonl"
|
|
283
|
+
line = json.dumps({
|
|
284
|
+
"run_at": run.run_at,
|
|
285
|
+
"status": run.status,
|
|
286
|
+
"message": run.message,
|
|
287
|
+
"duration_seconds": run.duration_seconds,
|
|
288
|
+
})
|
|
289
|
+
with open(run_file, "a") as f:
|
|
290
|
+
f.write(line + "\n")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def get_runs(job_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
|
294
|
+
"""Get recent run history for a job.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
job_id: Job identifier.
|
|
298
|
+
limit: Maximum records to return.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
List of run record dicts, newest first.
|
|
302
|
+
"""
|
|
303
|
+
run_file = RUNS_DIR / f"{job_id}.jsonl"
|
|
304
|
+
if not run_file.exists():
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
lines = run_file.read_text().strip().splitlines()
|
|
309
|
+
runs = [json.loads(line) for line in lines[-limit:]]
|
|
310
|
+
runs.reverse()
|
|
311
|
+
return runs
|
|
312
|
+
except (json.JSONDecodeError, TypeError):
|
|
313
|
+
return []
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def is_due(job: CronJob) -> bool:
|
|
317
|
+
"""Check if a job is due to run now.
|
|
318
|
+
|
|
319
|
+
For 'at' jobs: due if current time >= scheduled time.
|
|
320
|
+
For 'every' jobs: due if interval elapsed since last run.
|
|
321
|
+
For 'cron' jobs: simplified check (full cron parsing deferred).
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
job: The job to check.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
True if the job should run now.
|
|
328
|
+
"""
|
|
329
|
+
if not job.enabled:
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
now = time.time()
|
|
333
|
+
|
|
334
|
+
if job.schedule.kind == ScheduleKind.AT:
|
|
335
|
+
if not job.schedule.at:
|
|
336
|
+
return False
|
|
337
|
+
try:
|
|
338
|
+
target = datetime.fromisoformat(job.schedule.at).timestamp()
|
|
339
|
+
return now >= target
|
|
340
|
+
except ValueError:
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
if job.schedule.kind == ScheduleKind.EVERY:
|
|
344
|
+
if not job.schedule.every_seconds:
|
|
345
|
+
return False
|
|
346
|
+
if job.last_run_at is None:
|
|
347
|
+
return True
|
|
348
|
+
try:
|
|
349
|
+
last = datetime.fromisoformat(job.last_run_at).timestamp()
|
|
350
|
+
return now >= last + job.schedule.every_seconds
|
|
351
|
+
except ValueError:
|
|
352
|
+
return True
|
|
353
|
+
|
|
354
|
+
# CRON expression — requires external library for full parsing.
|
|
355
|
+
# For now, treat as always due (will be refined with croner equiv).
|
|
356
|
+
if job.schedule.kind == ScheduleKind.CRON:
|
|
357
|
+
if job.last_run_at is None:
|
|
358
|
+
return True
|
|
359
|
+
try:
|
|
360
|
+
last = datetime.fromisoformat(job.last_run_at).timestamp()
|
|
361
|
+
return now >= last + 60 # Minimum 1 minute between cron runs
|
|
362
|
+
except ValueError:
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
return False
|
neo/display/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Chat message renderer — System R streaming conversation display.
|
|
2
|
+
|
|
3
|
+
All rendering goes through Rich console. No raw sys.stdout writes.
|
|
4
|
+
Text deltas are collected by the chat loop and rendered once as a
|
|
5
|
+
formatted panel when the done event arrives — no double rendering.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from typing import Union
|
|
12
|
+
|
|
13
|
+
from rich.markdown import Markdown
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
|
|
16
|
+
from neo.display.theme import (
|
|
17
|
+
GREEN,
|
|
18
|
+
GREEN_DIM,
|
|
19
|
+
DIM,
|
|
20
|
+
MUTED,
|
|
21
|
+
GRAY,
|
|
22
|
+
RED,
|
|
23
|
+
AMBER,
|
|
24
|
+
console,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Indicators — defined in theme.py, imported for consistency
|
|
28
|
+
ICON_THINKING = "◐"
|
|
29
|
+
ICON_SUCCESS = "✓"
|
|
30
|
+
ICON_ERROR = "✗"
|
|
31
|
+
|
|
32
|
+
# Low balance threshold — must match credits.SessionCredits if used
|
|
33
|
+
_LOW_BALANCE_THRESHOLD: Decimal = Decimal("50")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_user_message(text: str) -> None:
|
|
37
|
+
"""Render a user message with the you > prompt.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
text: The user's input text.
|
|
41
|
+
"""
|
|
42
|
+
console.print(f"\n [{GREEN_DIM}]you >[/] {text}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_assistant_message(text: str) -> None:
|
|
46
|
+
"""Render a complete System R response as a Rich markdown panel.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
text: The full response text (may contain markdown).
|
|
50
|
+
"""
|
|
51
|
+
if not text.strip():
|
|
52
|
+
return
|
|
53
|
+
md = Markdown(text)
|
|
54
|
+
console.print(
|
|
55
|
+
Panel(
|
|
56
|
+
md,
|
|
57
|
+
title=f"[bold {GREEN}]SYSTEM R[/]",
|
|
58
|
+
border_style=MUTED,
|
|
59
|
+
padding=(1, 2),
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_thinking(text: str = "thinking...") -> None:
|
|
65
|
+
"""Show a thinking step from the LLM reasoning process.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
text: Description of what the LLM is thinking about.
|
|
69
|
+
"""
|
|
70
|
+
console.print(f" [{DIM}]{ICON_THINKING} {text}[/]")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_action(
|
|
74
|
+
tool_name: str,
|
|
75
|
+
status: str = "running",
|
|
76
|
+
result: str = "",
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Show a tool call being executed with status indicator.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
tool_name: Name of the tool being called.
|
|
82
|
+
status: One of "running", "complete", "error", "pending".
|
|
83
|
+
result: Optional result summary (truncated in display).
|
|
84
|
+
"""
|
|
85
|
+
if status == "complete":
|
|
86
|
+
suffix = f" [{GRAY}]{result}[/]" if result else ""
|
|
87
|
+
console.print(f" [{GREEN}]{ICON_SUCCESS}[/] [{GRAY}]{tool_name}[/]{suffix}")
|
|
88
|
+
elif status == "error":
|
|
89
|
+
suffix = f" [{RED}]{result}[/]" if result else ""
|
|
90
|
+
console.print(f" [{RED}]{ICON_ERROR}[/] [{GRAY}]{tool_name}[/]{suffix}")
|
|
91
|
+
elif status == "pending":
|
|
92
|
+
suffix = f" [{AMBER}]{result}[/]" if result else ""
|
|
93
|
+
console.print(f" [{AMBER}]![/] [{GRAY}]{tool_name}[/]{suffix}")
|
|
94
|
+
else:
|
|
95
|
+
console.print(f" [{GREEN_DIM}]{ICON_THINKING}[/] [{GRAY}]{tool_name}...[/]")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_error(message: str) -> None:
|
|
99
|
+
"""Show an error from the stream or chat loop.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
message: The error message text.
|
|
103
|
+
"""
|
|
104
|
+
console.print(f"\n [{RED}]{ICON_ERROR}[/] [{GRAY}]{message}[/]")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def render_credits(
|
|
108
|
+
credits_used: Union[Decimal, float, None] = None,
|
|
109
|
+
balance: Union[Decimal, float, None] = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Show credit usage after a response.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
credits_used: Credits consumed by this response.
|
|
115
|
+
balance: Remaining credit balance.
|
|
116
|
+
"""
|
|
117
|
+
parts: list[str] = []
|
|
118
|
+
if credits_used is not None:
|
|
119
|
+
parts.append(f"{float(credits_used):.1f} credits")
|
|
120
|
+
if balance is not None:
|
|
121
|
+
bal = Decimal(str(balance)) if not isinstance(balance, Decimal) else balance
|
|
122
|
+
if bal < _LOW_BALANCE_THRESHOLD:
|
|
123
|
+
parts.append(f"[{AMBER}]balance: {float(balance):.1f}[/]")
|
|
124
|
+
else:
|
|
125
|
+
parts.append(f"balance: {float(balance):.1f}")
|
|
126
|
+
if parts:
|
|
127
|
+
console.print(f" [{DIM}]{' · '.join(parts)}[/]")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Formatting utilities for trading data.
|
|
2
|
+
|
|
3
|
+
All formatters accept both Decimal and float for backward compatibility.
|
|
4
|
+
New code should pass Decimal values (from neo.types).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from typing import Union
|
|
11
|
+
|
|
12
|
+
# Accept both Decimal and float — Decimal preferred, float for backward compat
|
|
13
|
+
Numeric = Union[Decimal, float, int]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fmt_price(value: Numeric, decimals: int = 2) -> str:
|
|
17
|
+
"""Format a price with $ prefix.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
value: Price as Decimal, float, or int.
|
|
21
|
+
decimals: Number of decimal places (default 2).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Formatted string like "$1,234.56".
|
|
25
|
+
"""
|
|
26
|
+
return f"${float(value):,.{decimals}f}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fmt_qty(value: int | float) -> str:
|
|
30
|
+
"""Format a quantity (shares/contracts).
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
value: Quantity as int or float. Floats that are whole numbers
|
|
34
|
+
are displayed without decimals.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Formatted string like "1,200".
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(value, float) and value == int(value):
|
|
40
|
+
value = int(value)
|
|
41
|
+
return f"{value:,}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fmt_pct(value: Numeric, decimals: int = 1, signed: bool = False) -> str:
|
|
45
|
+
"""Format as percentage.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
value: Percentage as Decimal, float, or int.
|
|
49
|
+
decimals: Number of decimal places (default 1).
|
|
50
|
+
signed: If True, prefix with +/- sign (DESIGN.md §4.3).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Formatted string like "1.5%" or "+2.34%".
|
|
54
|
+
"""
|
|
55
|
+
v = float(value)
|
|
56
|
+
if signed:
|
|
57
|
+
sign = "+" if v > 0 else ""
|
|
58
|
+
return f"{sign}{v:.{decimals}f}%"
|
|
59
|
+
return f"{v:.{decimals}f}%"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fmt_r(value: Numeric) -> str:
|
|
63
|
+
"""Format an R-multiple.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
value: R-multiple as Decimal, float, or int.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Formatted string like "+1.50R" or "-1.00R".
|
|
70
|
+
"""
|
|
71
|
+
v = float(value)
|
|
72
|
+
sign = "+" if v > 0 else ""
|
|
73
|
+
return f"{sign}{v:.2f}R"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fmt_risk(value: Numeric) -> str:
|
|
77
|
+
"""Format a risk amount with $ prefix.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
value: Risk amount as Decimal, float, or int.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Formatted string like "$1,046.80".
|
|
84
|
+
"""
|
|
85
|
+
return f"${float(value):,.2f}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def fmt_pnl(value: Numeric) -> str:
|
|
89
|
+
"""Format P&L with sign and Rich color markup.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
value: P&L amount as Decimal, float, or int.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Rich-formatted string with green for profit, red for loss.
|
|
96
|
+
"""
|
|
97
|
+
v = float(value)
|
|
98
|
+
sign = "+" if v >= 0 else ""
|
|
99
|
+
color = "#3ECF8E" if v >= 0 else "#EF4444"
|
|
100
|
+
return f"[{color}]{sign}${v:,.2f}[/]"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def direction_style(direction: str) -> str:
|
|
104
|
+
"""Return Rich style string for trade direction.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
direction: "long" or "short".
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Rich style string with brand colors.
|
|
111
|
+
"""
|
|
112
|
+
return "bold #3ECF8E" if direction.lower() == "long" else "bold #EF4444"
|