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/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
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"