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
|
@@ -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
|