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.
@@ -0,0 +1,937 @@
1
+ """Chat command — interactive streaming conversation with System R.
2
+
3
+ Integrates: streaming, credits, confirmation protocol, hooks,
4
+ profile context, session resume, context compaction.
5
+
6
+ All financial values use Decimal. Logging via structlog.
7
+ No bare print() — user display via Rich console.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+
15
+ import click
16
+ import structlog
17
+ from prompt_toolkit import PromptSession
18
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
19
+ from prompt_toolkit.history import FileHistory
20
+
21
+ from neo.auth import AuthManager
22
+ from neo.config import SYSTEMR_HOME, SESSIONS_DIR, ensure_systemr_home
23
+ from neo.confirmation import (
24
+ PERMISSION_PROFILES,
25
+ PermissionLevel,
26
+ get_active_profile,
27
+ get_permission_level,
28
+ set_active_profile,
29
+ )
30
+ from neo.credits import SessionCredits
31
+ from neo.display.chat_renderer import (
32
+ render_assistant_message,
33
+ render_action,
34
+ render_credits,
35
+ render_error,
36
+ render_thinking,
37
+ render_user_message,
38
+ )
39
+ from neo.display.theme import (
40
+ AMBER,
41
+ DIM,
42
+ GRAY,
43
+ GREEN,
44
+ GREEN_DIM,
45
+ MUTED,
46
+ WHITE,
47
+ ICON_ERROR,
48
+ ICON_SUCCESS,
49
+ ICON_THINKING,
50
+ console,
51
+ print_banner,
52
+ print_error,
53
+ print_info,
54
+ print_separator,
55
+ print_success,
56
+ )
57
+ from neo.hooks import HookEvent, fire_hook
58
+ from neo.profile import (
59
+ append_daily_log,
60
+ auto_save_explicit,
61
+ auto_save_rule_violation,
62
+ load_daily_context,
63
+ load_profile,
64
+ load_rules,
65
+ load_standing_orders,
66
+ profile_exists,
67
+ search_memory,
68
+ )
69
+ from neo.model_failover import FailoverChain, load_failover_chain
70
+ from neo.store import LocalStore
71
+ from neo.streaming import (
72
+ ChatRequest,
73
+ SSEEvent,
74
+ chat_blocking,
75
+ send_confirmation,
76
+ stream_chat,
77
+ )
78
+
79
+ logger = structlog.get_logger(module="chat")
80
+
81
+ # Context compaction settings
82
+ COMPACTION_THRESHOLD: int = 30
83
+ COMPACTION_KEEP_RECENT: int = 10
84
+
85
+ # Slash commands handled locally
86
+ LOCAL_COMMANDS: dict[str, str] = {
87
+ "/help": "Show available commands",
88
+ "/morning": "Morning briefing (4 parallel agents)",
89
+ "/eod": "End of day review + journal",
90
+ "/plan": "Plan today's trades",
91
+ "/portfolio": "Show open positions",
92
+ "/risk": "Show risk dashboard",
93
+ "/credits": "Show session credit usage",
94
+ "/remember": "Save a memory (e.g., /remember TSLA support at $192)",
95
+ "/clear": "Clear conversation history",
96
+ "/model": "Show or switch model",
97
+ "/fast": "Switch to fast model (Haiku)",
98
+ "/deep": "Switch to deep model (Opus)",
99
+ "/profile": "Show trader profile",
100
+ "/rules": "Show trading rules",
101
+ "/memory": "Search memory (e.g., /memory TSLA)",
102
+ "/permissions": "View or switch safety profile (paper/standard/experienced)",
103
+ "/sessions": "List recent chat sessions",
104
+ "/cron": "Manage scheduled tasks (add/list/remove)",
105
+ "/q": "Exit",
106
+ }
107
+
108
+
109
+ async def _pipe_mode(token, user_input: str, model_name: str | None, research: bool, json_output: bool) -> None:
110
+ """Non-interactive: send one message, collect full response, print, exit."""
111
+ from neo.streaming import ChatRequest, stream_chat
112
+
113
+ profile_text = load_profile() if profile_exists() else None
114
+ rules_text = load_rules()
115
+
116
+ request = ChatRequest(
117
+ user_input=user_input,
118
+ model=model_name,
119
+ research_mode=research,
120
+ profile=profile_text,
121
+ rules=rules_text,
122
+ )
123
+
124
+ text_parts: list[str] = []
125
+ tools_used: list[str] = []
126
+
127
+ async for event in stream_chat(token.access_token, request):
128
+ if event.event == "text_delta" and event.data:
129
+ text_parts.append(event.data.get("text", ""))
130
+ elif event.event == "action" and event.data:
131
+ tools_used.append(event.data.get("tool", ""))
132
+
133
+ full_text = "".join(text_parts)
134
+
135
+ if json_output:
136
+ import json as _json
137
+ print(_json.dumps({
138
+ "response": full_text,
139
+ "tools_used": tools_used,
140
+ "model": model_name,
141
+ }))
142
+ else:
143
+ print(full_text)
144
+
145
+
146
+ @click.command()
147
+ @click.option("--model", "model_name", default=None, help="LLM model to use")
148
+ @click.option("--research", is_flag=True, default=False, help="Enable research mode")
149
+ @click.option(
150
+ "--continue-session", "resume", is_flag=True, default=False,
151
+ help="Resume last session",
152
+ )
153
+ @click.option("--pipe", "pipe_input", default=None, help="Non-interactive: send one message, print response, exit")
154
+ @click.option("--json", "json_output", is_flag=True, default=False, help="Output as JSON (use with --pipe)")
155
+ def chat(model_name: str | None, research: bool, resume: bool, pipe_input: str | None, json_output: bool) -> None:
156
+ """Start an interactive chat session with System R."""
157
+ auth = AuthManager()
158
+ token = auth.get_token()
159
+ if token is None:
160
+ print_error("Not authenticated. Run `systemr login` first.")
161
+ raise SystemExit(1)
162
+
163
+ if pipe_input is not None:
164
+ asyncio.run(_pipe_mode(token, pipe_input, model_name, research, json_output))
165
+ return
166
+
167
+ if not profile_exists():
168
+ print_info("No profile found. Run `systemr setup` to create one.")
169
+
170
+ ensure_systemr_home()
171
+ store = LocalStore()
172
+
173
+ # Auto-prune old sessions (>30 days, silent, once per session start)
174
+ try:
175
+ pruned = store.prune_sessions(days=30)
176
+ if pruned:
177
+ logger.info("sessions_auto_pruned", count=pruned)
178
+ except Exception:
179
+ pass
180
+
181
+ # Session resume or new
182
+ messages: list[dict[str, str]] = []
183
+ if resume:
184
+ messages, db_session_id = _try_resume_session(store)
185
+ if messages:
186
+ print_info(f"Resumed session with {len(messages)} messages.")
187
+ else:
188
+ db_session_id = store.create_chat_session()
189
+ else:
190
+ db_session_id = store.create_chat_session()
191
+
192
+ print_banner()
193
+ print_separator()
194
+ console.print(f" [{DIM}]Type your message. /help for commands. Ctrl+D to exit.[/]")
195
+ if model_name:
196
+ console.print(f" [{DIM}]Model: {model_name}[/]")
197
+ console.print()
198
+
199
+ # Session state
200
+ failover = load_failover_chain()
201
+ if model_name:
202
+ failover.pinned_model = model_name
203
+ current_model = model_name
204
+ credits = SessionCredits()
205
+ history_file = SYSTEMR_HOME / "chat_history"
206
+ loop = asyncio.new_event_loop()
207
+
208
+ prompt_session: PromptSession[str] = PromptSession(
209
+ history=FileHistory(str(history_file)),
210
+ auto_suggest=AutoSuggestFromHistory(),
211
+ )
212
+
213
+ # Fire session start hook
214
+ fire_hook(HookEvent.PRE_SESSION, context={"model": model_name})
215
+ logger.info("session_started", model=model_name, resume=resume)
216
+
217
+ try:
218
+ while True:
219
+ try:
220
+ user_input = prompt_session.prompt(" you > ").strip()
221
+ except (EOFError, KeyboardInterrupt):
222
+ console.print(f"\n [{GREEN_DIM}]Session closed.[/]")
223
+ break
224
+
225
+ if not user_input:
226
+ continue
227
+
228
+ # Handle slash commands
229
+ if user_input.startswith("/"):
230
+ result = _handle_slash_command(
231
+ user_input, messages, current_model,
232
+ access_token=token.access_token, credits=credits,
233
+ )
234
+ if result == "exit":
235
+ console.print(f"\n [{GREEN_DIM}]Session closed.[/]")
236
+ break
237
+ if result == "clear":
238
+ messages.clear()
239
+ print_success("Conversation cleared.")
240
+ continue
241
+ if result is not None:
242
+ if result.startswith("model:"):
243
+ current_model = result.split(":", 1)[1]
244
+ failover.pinned_model = current_model
245
+ print_success(f"Model: {current_model}")
246
+ continue
247
+
248
+ render_user_message(user_input)
249
+ store.add_chat_message(db_session_id, "user", user_input)
250
+ messages.append({"role": "user", "content": user_input})
251
+
252
+ # Compact if over threshold
253
+ if len(messages) > COMPACTION_THRESHOLD:
254
+ messages = _compact_history(messages)
255
+ logger.info("context_compacted", messages=len(messages))
256
+
257
+ # Build request with profile + rules + standing orders + daily context
258
+ effective_model = current_model or failover.get_model()
259
+ rules_text = load_rules() or ""
260
+ standing = load_standing_orders()
261
+ if standing and standing not in rules_text:
262
+ rules_text = rules_text + "\n\n" + standing if rules_text else standing
263
+ request = ChatRequest(
264
+ user_input=user_input,
265
+ session_id=str(db_session_id),
266
+ model=effective_model,
267
+ history=messages,
268
+ profile=load_profile() or None,
269
+ rules=rules_text or None,
270
+ daily_context=load_daily_context() or None,
271
+ research_mode=research,
272
+ )
273
+
274
+ try:
275
+ reply = loop.run_until_complete(
276
+ _do_chat(request, token.access_token, credits)
277
+ )
278
+ failover.on_success(effective_model)
279
+ except Exception as exc:
280
+ failover.on_failure(effective_model)
281
+ # Try failover model
282
+ fallback_model = failover.get_model()
283
+ if fallback_model != effective_model:
284
+ logger.info("model_failover_retry", from_model=effective_model, to_model=fallback_model)
285
+ request.model = fallback_model
286
+ try:
287
+ reply = loop.run_until_complete(
288
+ _do_chat(request, token.access_token, credits)
289
+ )
290
+ failover.on_success(fallback_model)
291
+ except Exception as exc2:
292
+ failover.on_failure(fallback_model)
293
+ render_error(f"Chat error (failover): {exc2}")
294
+ fire_hook(HookEvent.ERROR, context={"message": str(exc2)})
295
+ reply = ""
296
+ else:
297
+ render_error(f"Chat error: {exc}")
298
+ fire_hook(HookEvent.ERROR, context={"message": str(exc)})
299
+ reply = ""
300
+
301
+ if reply:
302
+ store.add_chat_message(db_session_id, "assistant", reply)
303
+ messages.append({"role": "assistant", "content": reply})
304
+
305
+ finally:
306
+ loop.close()
307
+ _save_session(db_session_id, messages)
308
+ fire_hook(HookEvent.POST_SESSION, context=credits.summary())
309
+ logger.info("session_ended", **credits.summary())
310
+
311
+ # Show session summary if there was activity
312
+ if credits.messages_sent > 0:
313
+ console.print()
314
+ console.print(f" [{GRAY}]Session summary[/]")
315
+ s = credits.summary()
316
+ console.print(f" [{WHITE}]{s['credits_used']:.1f}[/] [{DIM}]credits[/] " # type: ignore[str-format]
317
+ f"[{WHITE}]{s['tools_called']}[/] [{DIM}]tools[/] "
318
+ f"[{WHITE}]{s['duration_min']}m[/] [{DIM}]duration[/]")
319
+ console.print()
320
+
321
+
322
+ async def _do_chat(
323
+ request: ChatRequest,
324
+ access_token: str,
325
+ credits: SessionCredits,
326
+ ) -> str:
327
+ """Execute chat with streaming. Falls back to blocking on failure.
328
+
329
+ Handles SSE events: thinking, action, confirmation_required,
330
+ text_delta, done, error. Fires hooks on trade and error events.
331
+
332
+ Args:
333
+ request: The chat request payload.
334
+ access_token: Bearer token.
335
+ credits: Session credit tracker.
336
+
337
+ Returns:
338
+ The collected assistant response text.
339
+ """
340
+ collected_text = ""
341
+ tool_count = 0
342
+ has_trade = False
343
+
344
+ try:
345
+ async for event in stream_chat(request, access_token):
346
+ if event.event == "thinking":
347
+ text = event.parsed.get("text", event.parsed.get("content", ""))
348
+ if text:
349
+ render_thinking(text[:80])
350
+
351
+ elif event.event == "action":
352
+ tool = event.parsed.get("tool", event.parsed.get("name", ""))
353
+ status = event.parsed.get("status", "running")
354
+ result_text = event.parsed.get("result", "")
355
+ tool_count += 1
356
+
357
+ # Fire BEFORE_TOOL_CALL hook — can inspect/log tool calls
358
+ if tool and status == "running":
359
+ fire_hook(HookEvent.BEFORE_TOOL_CALL, context={
360
+ "tool": tool, "args": event.parsed.get("args", {}),
361
+ })
362
+
363
+ if tool:
364
+ render_action(
365
+ tool, status=status,
366
+ result=str(result_text)[:60] if result_text else "",
367
+ )
368
+ if tool in (
369
+ "place_order", "cancel_order", "modify_order",
370
+ "close_position", "kill_switch", "close_all_positions",
371
+ ):
372
+ has_trade = True
373
+
374
+ elif event.event == "confirmation_required":
375
+ approved = await _handle_confirmation(event, access_token)
376
+ if approved:
377
+ fire_hook(HookEvent.PRE_TRADE, context=event.parsed)
378
+ else:
379
+ tool = event.parsed.get("tool", "unknown")
380
+ auto_save_rule_violation(
381
+ rule="User denied confirmation",
382
+ action_attempted=f"{tool}: {event.parsed.get('description', '')}",
383
+ )
384
+ fire_hook(HookEvent.RULE_VIOLATION, context={
385
+ "tool": tool, "reason": "user_denied",
386
+ })
387
+
388
+ elif event.event == "text_delta":
389
+ text = event.parsed.get("text", event.parsed.get("delta", event.data))
390
+ if text and text != "null":
391
+ collected_text += text
392
+
393
+ elif event.event == "done":
394
+ if collected_text.strip():
395
+ render_assistant_message(collected_text.strip())
396
+ else:
397
+ full = event.parsed.get(
398
+ "response", event.parsed.get("content", ""),
399
+ )
400
+ if full:
401
+ collected_text = full
402
+ render_assistant_message(full)
403
+
404
+ credits_used = event.parsed.get("credits_used")
405
+ balance = event.parsed.get("balance")
406
+ credits.add_response(
407
+ credits_used=credits_used, balance=balance,
408
+ tools=tool_count, trade=has_trade,
409
+ )
410
+ render_credits(credits_used, balance)
411
+
412
+ if has_trade:
413
+ append_daily_log(
414
+ collected_text.strip()[:120] if collected_text else "Trade executed",
415
+ category="trade",
416
+ )
417
+ fire_hook(HookEvent.POST_TRADE, context={"credits_used": credits_used})
418
+ if credits.is_low_balance and not credits.low_balance_warned:
419
+ credits.low_balance_warned = True
420
+ console.print(
421
+ f"\n [{AMBER}]! Credits low: "
422
+ f"{float(credits.last_balance):.1f} OSR. " # type: ignore[arg-type]
423
+ f"Top up at systemr.ai/credits[/]"
424
+ )
425
+ fire_hook(HookEvent.LOW_BALANCE, context={
426
+ "balance": float(credits.last_balance), # type: ignore[arg-type]
427
+ })
428
+
429
+ elif event.event == "error":
430
+ msg = event.parsed.get("message", event.parsed.get("error", event.data))
431
+ render_error(str(msg))
432
+ fire_hook(HookEvent.ERROR, context={"message": str(msg)})
433
+
434
+ except Exception:
435
+ # Fall back to blocking
436
+ try:
437
+ result = await chat_blocking(request, access_token)
438
+ reply = result.get("response", result.get("content", result.get("message", "")))
439
+ if reply:
440
+ collected_text = reply
441
+ render_assistant_message(reply)
442
+ credits.add_response(tools=1)
443
+ except Exception as exc:
444
+ render_error(f"Chat error: {exc}")
445
+ fire_hook(HookEvent.ERROR, context={"message": str(exc)})
446
+
447
+ return collected_text
448
+
449
+
450
+ async def _handle_confirmation(event: SSEEvent, access_token: str) -> bool:
451
+ """Handle a confirmation_required event with real user prompt.
452
+
453
+ Args:
454
+ event: The SSE event with tool details and confirmation token.
455
+ access_token: Bearer token for sending confirmation back.
456
+
457
+ Returns:
458
+ True if user approved, False if denied.
459
+ """
460
+ from neo.confirmation import confirm_trade, confirm_kill_switch
461
+ from neo.types import to_decimal
462
+
463
+ tool = event.parsed.get("tool", "unknown")
464
+ token = event.parsed.get("confirmation_token", "")
465
+ details = event.parsed.get("details", {})
466
+ description = event.parsed.get("description", f"Execute {tool}?")
467
+ perm = get_permission_level(tool)
468
+
469
+ approved = False
470
+
471
+ if perm == PermissionLevel.DOUBLE_CONFIRM:
472
+ positions = details.get("positions", [])
473
+ approved = confirm_kill_switch(positions)
474
+ elif perm == PermissionLevel.CONFIRM:
475
+ if all(k in details for k in ("symbol", "entry", "stop", "shares")):
476
+ approved = confirm_trade(
477
+ symbol=details["symbol"],
478
+ direction=details.get("direction", "long"),
479
+ entry=to_decimal(details["entry"]),
480
+ stop=to_decimal(details["stop"]),
481
+ shares=int(details["shares"]),
482
+ risk_amount=to_decimal(details.get("risk_amount", 0)),
483
+ risk_pct=to_decimal(details.get("risk_pct", 0)),
484
+ target=to_decimal(details["target"]) if details.get("target") else None,
485
+ rr_ratio=to_decimal(details["rr_ratio"]) if details.get("rr_ratio") else None,
486
+ position_value=(
487
+ to_decimal(details["position_value"])
488
+ if details.get("position_value") else None
489
+ ),
490
+ )
491
+ else:
492
+ try:
493
+ response = click.prompt(
494
+ click.style(f" {description} [y/n]", fg="white"),
495
+ type=click.Choice(["y", "n"], case_sensitive=False),
496
+ show_choices=False,
497
+ )
498
+ approved = response.lower() == "y"
499
+ except (click.Abort, EOFError):
500
+ approved = False
501
+ else:
502
+ approved = True
503
+
504
+ if token:
505
+ try:
506
+ await send_confirmation(token, approved, access_token)
507
+ except Exception:
508
+ logger.warning("confirmation_send_failed", token=token[:10])
509
+
510
+ logger.info("confirmation_result", tool=tool, approved=approved)
511
+ return approved
512
+
513
+
514
+ def _handle_slash_command(
515
+ cmd: str,
516
+ messages: list[dict[str, str]],
517
+ current_model: str | None,
518
+ access_token: str | None = None,
519
+ credits: SessionCredits | None = None,
520
+ ) -> str | None:
521
+ """Handle local slash commands.
522
+
523
+ Returns:
524
+ "exit" to end session, "clear" to clear history,
525
+ "model:X" to change model, "" for handled commands,
526
+ None if command should be passed to LLM.
527
+ """
528
+ parts = cmd.strip().split(maxsplit=1)
529
+ command = parts[0].lower()
530
+ arg = parts[1] if len(parts) > 1 else ""
531
+
532
+ if command in ("/q", "/quit", "/exit"):
533
+ return "exit"
534
+ if command == "/clear":
535
+ return "clear"
536
+
537
+ if command == "/help":
538
+ console.print()
539
+ console.print(f" [{GRAY}]commands[/]")
540
+ for name, desc in LOCAL_COMMANDS.items():
541
+ console.print(f" [{WHITE}]{name:<12}[/] [{DIM}]{desc}[/]")
542
+ console.print()
543
+ return ""
544
+
545
+ if command in ("/morning", "/eod", "/plan") and access_token:
546
+ result_text = _run_agent_command(command, access_token, current_model)
547
+ if result_text:
548
+ messages.append({"role": "assistant", "content": result_text})
549
+ return ""
550
+
551
+ if command in ("/portfolio", "/risk") and access_token:
552
+ prompts = {
553
+ "/portfolio": "Show my current portfolio positions with P&L.",
554
+ "/risk": (
555
+ "Show my current risk exposure: total risk %, daily limit usage, "
556
+ "positions count vs max, and remaining risk budget."
557
+ ),
558
+ }
559
+ _run_focused_query(prompts[command], access_token, current_model, messages)
560
+ return ""
561
+
562
+ if command == "/credits" and credits:
563
+ s = credits.summary()
564
+ console.print()
565
+ console.print(f" [{GRAY}]Session credits[/]")
566
+ console.print(f" [{WHITE}]{s['credits_used']:.1f}[/] [{DIM}]credits used[/]") # type: ignore[str-format]
567
+ console.print(f" [{WHITE}]{s['tools_called']}[/] [{DIM}]tools called[/]")
568
+ console.print(f" [{WHITE}]{s['trades_placed']}[/] [{DIM}]trades placed[/]")
569
+ console.print(f" [{WHITE}]{s['duration_min']}m[/] [{DIM}]duration[/]")
570
+ if s["balance"] is not None:
571
+ console.print(f" [{WHITE}]{s['balance']:.1f}[/] [{DIM}]OSR remaining[/]") # type: ignore[str-format]
572
+ console.print()
573
+ return ""
574
+
575
+ if command == "/sessions":
576
+ sub = arg.split(maxsplit=1) if arg else []
577
+ sub_cmd = sub[0] if sub else "list"
578
+ sub_arg = sub[1] if len(sub) > 1 else ""
579
+ _store = LocalStore()
580
+
581
+ if sub_cmd == "export" and sub_arg:
582
+ try:
583
+ export = _store.export_session(int(sub_arg))
584
+ if export:
585
+ import json
586
+ output_file = SESSIONS_DIR / f"export_{sub_arg}.json"
587
+ output_file.write_text(json.dumps(export, indent=2))
588
+ print_success(f"Exported session {sub_arg} to {output_file}")
589
+ else:
590
+ print_error(f"Session {sub_arg} not found")
591
+ except ValueError:
592
+ print_error("Usage: /sessions export <id>")
593
+ elif sub_cmd == "prune":
594
+ pruned = _store.prune_sessions(days=30)
595
+ if pruned:
596
+ print_success(f"Pruned {pruned} sessions older than 30 days")
597
+ else:
598
+ console.print(f" [{DIM}]No sessions to prune.[/]")
599
+ else:
600
+ sessions = _store.list_sessions(limit=15)
601
+ if sessions:
602
+ console.print()
603
+ console.print(f" [{GRAY}]Recent sessions ({len(sessions)})[/]")
604
+ for s in sessions:
605
+ msg_count = s.get("message_count", 0)
606
+ created = s.get("created_at", "")[:10]
607
+ title = s.get("title") or f"Session #{s['id']}"
608
+ console.print(
609
+ f" [{WHITE}]{s['id']:<6}[/] [{DIM}]{created}[/] "
610
+ f"[{WHITE}]{title:<20}[/] [{DIM}]{msg_count} msgs[/]"
611
+ )
612
+ console.print(f" [{DIM}]Commands: /sessions export <id>, /sessions prune[/]")
613
+ console.print()
614
+ else:
615
+ console.print(f" [{DIM}]No sessions yet.[/]")
616
+ return ""
617
+
618
+ if command == "/cron":
619
+ from neo.cron import Schedule, ScheduleKind, add_job, load_jobs, remove_job
620
+ sub = arg.split(maxsplit=1) if arg else []
621
+ sub_cmd = sub[0] if sub else "list"
622
+ sub_arg = sub[1] if len(sub) > 1 else ""
623
+
624
+ if sub_cmd == "list":
625
+ jobs = load_jobs()
626
+ if jobs:
627
+ console.print()
628
+ console.print(f" [{GRAY}]Scheduled tasks ({len(jobs)})[/]")
629
+ for j in jobs:
630
+ status = f"[{GREEN}]on[/]" if j.enabled else f"[{DIM}]off[/]"
631
+ console.print(
632
+ f" {status} [{WHITE}]{j.name:<20}[/] "
633
+ f"[{DIM}]{j.schedule.kind.value}[/] "
634
+ f"[{DIM}]{j.id}[/]"
635
+ )
636
+ console.print()
637
+ else:
638
+ console.print(f" [{DIM}]No scheduled tasks. Use /cron add <name> | <message>[/]")
639
+ elif sub_cmd == "add" and sub_arg:
640
+ parts = sub_arg.split("|", 1)
641
+ name = parts[0].strip()
642
+ message = parts[1].strip() if len(parts) > 1 else name
643
+ job = add_job(name, message, Schedule(kind=ScheduleKind.EVERY, every_seconds=3600))
644
+ print_success(f"Created: {job.name} ({job.id}) — runs every 1h")
645
+ elif sub_cmd == "remove" and sub_arg:
646
+ if remove_job(sub_arg.strip()):
647
+ print_success(f"Removed job {sub_arg.strip()}")
648
+ else:
649
+ print_error(f"Job not found: {sub_arg.strip()}")
650
+ else:
651
+ console.print(f" [{DIM}]Usage: /cron list, /cron add <name> | <message>, /cron remove <id>[/]")
652
+ return ""
653
+
654
+ if command == "/permissions":
655
+ if arg and arg in PERMISSION_PROFILES:
656
+ set_active_profile(arg)
657
+ print_success(f"Permission profile: {arg}")
658
+ else:
659
+ current = get_active_profile()
660
+ console.print()
661
+ console.print(f" [{GRAY}]Permission profile:[/] [{WHITE}]{current}[/]")
662
+ console.print(f" [{DIM}]paper — all trades require DOUBLE_CONFIRM[/]")
663
+ console.print(f" [{DIM}]standard — default safety tiers[/]")
664
+ console.print(f" [{DIM}]experienced — stop/target adjustments auto-approved[/]")
665
+ console.print(f" [{DIM}]Switch: /permissions paper[/]")
666
+ console.print()
667
+ return ""
668
+
669
+ if command == "/memory" and arg:
670
+ results = search_memory(arg, max_results=10)
671
+ if results:
672
+ console.print()
673
+ console.print(f" [{GRAY}]Memory search: {arg}[/]")
674
+ for r in results:
675
+ console.print(f" [{DIM}]{r['file']}:{r['line']}[/] [{WHITE}]{r['content'][:80]}[/]")
676
+ console.print()
677
+ else:
678
+ console.print(f" [{DIM}]No results for '{arg}'[/]")
679
+ return ""
680
+
681
+ if command == "/remember" and arg:
682
+ auto_save_explicit(arg)
683
+ print_success(f"Saved to memory: {arg[:50]}")
684
+ return ""
685
+
686
+ if command == "/model":
687
+ if arg:
688
+ return f"model:{arg}"
689
+ console.print(f" [{GRAY}]Current model:[/] [{WHITE}]{current_model or 'default'}[/]")
690
+ return ""
691
+ if command == "/fast":
692
+ return "model:anthropic.claude-haiku-4-5-20251001"
693
+ if command == "/deep":
694
+ return "model:anthropic.claude-opus-4-6"
695
+
696
+ if command == "/profile":
697
+ profile = load_profile()
698
+ if profile:
699
+ console.print()
700
+ console.print(profile)
701
+ else:
702
+ print_info("No profile. Run `systemr setup` to create one.")
703
+ return ""
704
+
705
+ if command == "/rules":
706
+ rules = load_rules()
707
+ if rules:
708
+ console.print()
709
+ console.print(rules)
710
+ else:
711
+ print_info("No rules set. Run `systemr setup` to add them.")
712
+ return ""
713
+
714
+ # Unknown — pass to LLM
715
+ return None
716
+
717
+
718
+ def _run_agent_command(
719
+ command: str, access_token: str, model: str | None,
720
+ ) -> str:
721
+ """Execute a multi-agent slash command. Returns combined result text."""
722
+ from neo.orchestrator import (
723
+ run_eod_review,
724
+ run_morning_briefing,
725
+ run_trade_plan,
726
+ )
727
+ from rich.markdown import Markdown
728
+ from rich.panel import Panel
729
+
730
+ profile = load_profile() or None
731
+ rules = load_rules() or None
732
+
733
+ agent_states: dict[str, str] = {}
734
+
735
+ def on_start(name: str) -> None:
736
+ agent_states[name] = "running"
737
+ _render_agent_status(agent_states)
738
+
739
+ def on_complete(name: str, success: bool) -> None:
740
+ agent_states[name] = "done" if success else "error"
741
+ _render_agent_status(agent_states)
742
+
743
+ titles = {"/morning": "MORNING BRIEFING", "/eod": "END OF DAY REVIEW", "/plan": "TRADE PLAN"}
744
+ runners = {"/morning": run_morning_briefing, "/eod": run_eod_review, "/plan": run_trade_plan}
745
+
746
+ console.print()
747
+ console.print(f" [{WHITE}]{titles[command]}[/]")
748
+ print_separator()
749
+
750
+ results = asyncio.run(runners[command](
751
+ access_token=access_token, model=model,
752
+ profile=profile, rules=rules,
753
+ on_start=on_start, on_complete=on_complete,
754
+ ))
755
+
756
+ console.print()
757
+ combined = ""
758
+ for r in results:
759
+ if r.success and r.content:
760
+ # Show agent result with cost stats
761
+ duration_str = f"{r.duration_seconds:.1f}s" if r.duration_seconds else ""
762
+ credits_str = f"{r.credits_used}" if r.credits_used else ""
763
+ tools_str = f"{r.tools_called} tools" if r.tools_called else ""
764
+ stats_parts = [s for s in (duration_str, credits_str, tools_str) if s]
765
+ subtitle = f"[{DIM}]{' · '.join(stats_parts)}[/]" if stats_parts else ""
766
+
767
+ console.print(Panel(
768
+ Markdown(r.content),
769
+ title=f"[bold {GREEN}]{r.agent_name}[/]",
770
+ subtitle=subtitle,
771
+ border_style=MUTED, padding=(1, 2),
772
+ ))
773
+ combined += f"\n## {r.agent_name}\n{r.content}\n"
774
+ elif r.error:
775
+ render_error(f"{r.agent_name}: {r.error}")
776
+
777
+ # Show aggregate stats
778
+ total_duration = sum(r.duration_seconds for r in results)
779
+ total_credits = sum(r.credits_used for r in results)
780
+ total_tools = sum(r.tools_called for r in results)
781
+ if total_duration > 0:
782
+ console.print(
783
+ f" [{DIM}]{len(results)} agents · "
784
+ f"{total_duration:.1f}s total · "
785
+ f"{total_credits} credits · "
786
+ f"{total_tools} tools[/]"
787
+ )
788
+ console.print()
789
+ return combined
790
+
791
+
792
+ def _render_agent_status(states: dict[str, str]) -> None:
793
+ """Show parallel agent status line."""
794
+ parts = []
795
+ for name, status in states.items():
796
+ if status == "running":
797
+ parts.append(f"[{GREEN_DIM}]{ICON_THINKING}[/] [{GRAY}]{name}[/]")
798
+ elif status == "done":
799
+ parts.append(f"[{GREEN}]{ICON_SUCCESS}[/] [{GRAY}]{name}[/]")
800
+ else:
801
+ parts.append(f"[{DIM}]{ICON_ERROR}[/] [{GRAY}]{name}[/]")
802
+ if parts:
803
+ console.print(f" {' '.join(parts)}")
804
+
805
+
806
+ def _run_focused_query(
807
+ prompt: str, access_token: str, model: str | None,
808
+ messages: list[dict[str, str]],
809
+ ) -> None:
810
+ """Run a single focused query for /portfolio, /risk, etc."""
811
+ request = ChatRequest(
812
+ user_input=prompt, model=model,
813
+ profile=load_profile() or None, rules=load_rules() or None,
814
+ )
815
+ try:
816
+ result = asyncio.run(chat_blocking(request, access_token))
817
+ reply = result.get("response", result.get("content", result.get("message", "")))
818
+ if reply:
819
+ render_assistant_message(reply)
820
+ messages.append({"role": "assistant", "content": reply})
821
+ except Exception as exc:
822
+ render_error(f"Query failed: {exc}")
823
+
824
+
825
+ def _compact_history(messages: list[dict[str, str]]) -> list[dict[str, str]]:
826
+ """Compact old messages to save context window.
827
+
828
+ Before dropping old messages, flushes key context to the daily trading
829
+ log (OpenClaw pattern: auto-flush before compaction). Then keeps a
830
+ summary of older messages + the most recent N messages.
831
+
832
+ Args:
833
+ messages: Full message history.
834
+
835
+ Returns:
836
+ Compacted message list.
837
+ """
838
+ if len(messages) <= COMPACTION_KEEP_RECENT:
839
+ return messages
840
+
841
+ old_messages = messages[:-COMPACTION_KEEP_RECENT]
842
+ recent = messages[-COMPACTION_KEEP_RECENT:]
843
+
844
+ # Fire COMPACT_BEFORE hook
845
+ fire_hook(HookEvent.COMPACT_BEFORE, context={"messages_dropping": len(old_messages)})
846
+
847
+ # Auto-flush: extract key context from messages being dropped
848
+ _flush_before_compaction(old_messages)
849
+
850
+ # Build a richer summary from old messages
851
+ summary_parts = []
852
+ for msg in old_messages:
853
+ role = msg.get("role", "unknown")
854
+ content = msg.get("content", "")
855
+ # Prioritize trade-related and decision content
856
+ if len(content) > 200:
857
+ content = content[:200] + "..."
858
+ summary_parts.append(f"[{role}]: {content}")
859
+
860
+ # Keep last 10 for the summary text, but capture all context
861
+ summary_text = "\n".join(summary_parts[-10:])
862
+ summary = (
863
+ "[CONVERSATION SUMMARY — older messages compacted]\n"
864
+ "Key context from earlier in this session:\n"
865
+ + summary_text
866
+ )
867
+ return [{"role": "assistant", "content": summary}, *recent]
868
+
869
+
870
+ def _flush_before_compaction(old_messages: list[dict[str, str]]) -> None:
871
+ """Save important context from messages being dropped to daily log.
872
+
873
+ Extracts trade-related content, decisions, and tool results from
874
+ the old messages before they are lost during compaction. One flush
875
+ per compaction cycle.
876
+
877
+ Args:
878
+ old_messages: Messages about to be dropped from context.
879
+ """
880
+ trade_keywords = (
881
+ "bought", "sold", "entry", "exit", "stop", "target", "position",
882
+ "order", "filled", "closed", "profit", "loss", "R-multiple",
883
+ )
884
+ flushed_items = []
885
+
886
+ for msg in old_messages:
887
+ content = msg.get("content", "")
888
+ content_lower = content.lower()
889
+ # Flush assistant messages that mention trades or decisions
890
+ if msg.get("role") == "assistant" and any(kw in content_lower for kw in trade_keywords):
891
+ # Take first 200 chars of trade-relevant messages
892
+ flushed_items.append(content[:200])
893
+
894
+ if flushed_items:
895
+ flush_text = " | ".join(flushed_items[:5]) # Cap at 5 items
896
+ append_daily_log(
897
+ f"[compaction flush] {flush_text}",
898
+ category="flush",
899
+ )
900
+ logger.info("compaction_flush", items=len(flushed_items))
901
+
902
+
903
+ def _try_resume_session(store: LocalStore) -> tuple[list[dict[str, str]], int]:
904
+ """Try to resume the most recent chat session.
905
+
906
+ Args:
907
+ store: The local store instance.
908
+
909
+ Returns:
910
+ Tuple of (messages, session_id). Messages empty if no session found.
911
+ """
912
+ try:
913
+ session_file = SESSIONS_DIR / "last_session.json"
914
+ if session_file.exists():
915
+ data = json.loads(session_file.read_text())
916
+ return data.get("messages", []), data.get("session_id", store.create_chat_session())
917
+ except Exception:
918
+ pass
919
+ return [], store.create_chat_session()
920
+
921
+
922
+ def _save_session(session_id: int, messages: list[dict[str, str]]) -> None:
923
+ """Save session to disk for resume.
924
+
925
+ Args:
926
+ session_id: The database session ID.
927
+ messages: Conversation messages (last 50 kept).
928
+ """
929
+ try:
930
+ ensure_systemr_home()
931
+ session_file = SESSIONS_DIR / "last_session.json"
932
+ session_file.write_text(json.dumps({
933
+ "session_id": session_id,
934
+ "messages": messages[-50:],
935
+ }, indent=2))
936
+ except Exception:
937
+ pass